From cd7686baa2f50bae65b467104f7db9e9c1a140ee Mon Sep 17 00:00:00 2001 From: rustdesk Date: Thu, 30 Apr 2026 16:40:42 +0800 Subject: [PATCH] feat(shortcuts): user-configurable keyboard shortcuts for session actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 4 +- AGENTS.md | 24 + .../widgets/keyboard_shortcuts/display.dart | 110 +++ .../widgets/keyboard_shortcuts/page_body.dart | 484 ++++++++++++ .../keyboard_shortcuts/recording_dialog.dart | 400 ++++++++++ .../keyboard_shortcuts/shortcut_actions.dart | 288 +++++++ .../shortcut_constants.dart | 104 +++ .../keyboard_shortcuts/shortcut_utils.dart | 200 +++++ flutter/lib/common/widgets/toolbar.dart | 181 +++-- .../desktop_keyboard_shortcuts_page.dart | 63 ++ .../desktop/pages/desktop_setting_page.dart | 24 +- flutter/lib/desktop/pages/remote_page.dart | 4 +- .../lib/desktop/widgets/remote_toolbar.dart | 5 +- .../pages/mobile_keyboard_shortcuts_page.dart | 95 +++ flutter/lib/mobile/pages/remote_page.dart | 6 +- flutter/lib/models/shortcut_model.dart | 536 +++++++++++++ .../fixtures/default_keyboard_shortcuts.json | 11 + .../fixtures/supported_shortcut_keys.json | 11 + flutter/test/keyboard_shortcuts_test.dart | 426 +++++++++++ src/flutter_ffi.rs | 14 + src/keyboard.rs | 42 ++ src/keyboard/shortcuts.rs | 707 ++++++++++++++++++ src/lang/cn.rs | 34 + src/lang/en.rs | 9 + src/ui_session_interface.rs | 25 +- 25 files changed, 3729 insertions(+), 78 deletions(-) create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/display.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/shortcut_constants.dart create mode 100644 flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart create mode 100644 flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart create mode 100644 flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart create mode 100644 flutter/lib/models/shortcut_model.dart create mode 100644 flutter/test/fixtures/default_keyboard_shortcuts.json create mode 100644 flutter/test/fixtures/supported_shortcut_keys.json create mode 100644 flutter/test/keyboard_shortcuts_test.dart create mode 100644 src/keyboard/shortcuts.rs diff --git a/.gitignore b/.gitignore index d2e09a906..9ce4b5bb3 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ examples/**/target/ vcpkg_installed flutter/lib/generated_plugin_registrant.dart libsciter.dylib -flutter/web/ \ No newline at end of file +flutter/web/ +# Local git worktrees +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index e36c65fab..abb022286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` / `Future` 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. diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/display.dart b/flutter/lib/common/widgets/keyboard_shortcuts/display.dart new file mode 100644 index 000000000..eccd11e0f --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/display.dart @@ -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? _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? parsed; + if (raw == _cachedRaw) { + parsed = _cachedParsed; + } else { + try { + parsed = jsonDecode(raw) as Map; + } 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>(); + 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().toList() + : const []; + // 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 = []; + 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(); + } +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart b/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart new file mode 100644 index 000000000..885ae3116 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart @@ -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 createState() => + KeyboardShortcutsPageBodyState(); +} + +/// Public state so platform shells can call [resetToDefaultsWithConfirm] from +/// their AppBar action. +class KeyboardShortcutsPageBodyState extends State { + // ----- Persistence helpers ----- + + Map _readJson() { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return {'enabled': false, 'bindings': []}; + try { + final parsed = jsonDecode(raw) as Map; + parsed['bindings'] ??= []; + parsed['enabled'] ??= false; + return parsed; + } catch (_) { + return {'enabled': false, 'bindings': []}; + } + } + + Future _writeJson(Map 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 _setBinding( + String actionId, { + Map? binding, + String? clearActionId, + }) async { + final json = _readJson(); + final list = ((json['bindings'] as List?) ?? []) + .cast>() + .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 _setEnabled(bool v) async { + await ShortcutModel.setEnabled(v); + if (mounted) setState(() {}); + } + + Future _setPassThrough(bool v) async { + await ShortcutModel.setPassThrough(v); + if (mounted) setState(() {}); + } + + Future _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 _groupsForCurrentPlatform() { + return filterKeyboardShortcutActionGroupsForPlatform( + ShortcutModel.currentPlatformCapabilities(), + ); + } + + // ----- UI handlers ----- + + Future _onEdit(KeyboardShortcutActionEntry entry) async { + final json = _readJson(); + final bindings = ((json['bindings'] as List?) ?? []) + .cast>(); + 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 _onClear(KeyboardShortcutActionEntry entry) async { + await _setBinding(entry.id, binding: null); + } + + /// Public — invoked from the platform AppBar action. + Future resetToDefaultsWithConfirm() async { + final confirmed = await showDialog( + 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 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, + ), + ), + ); + } +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart b/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart new file mode 100644 index 000000000..73734f7ae --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart @@ -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 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 showRecordingDialog({ + required BuildContext context, + required String actionId, + required String actionLabel, + required List> existingBindings, + required String Function(String) actionLabelLookup, +}) { + return showDialog( + 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> 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 _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.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().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 = {}; + 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 = { + '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 = []; + 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), + ], + ); + } +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart new file mode 100644 index 000000000..f8f561e07 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart @@ -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 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 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 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 allActionEntries( + Iterable 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 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 = []; + for (final group in kKeyboardShortcutActionGroups) { + final filteredChildren = []; + 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; +} diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_constants.dart b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_constants.dart new file mode 100644 index 000000000..05c2fc174 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_constants.dart @@ -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> kDefaultShortcutBindings = [ + for (final entry in >[ + [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], + }, +]; diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart new file mode 100644 index 000000000..5862ff1d4 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart @@ -0,0 +1,200 @@ +import 'package:flutter/services.dart'; + +import 'shortcut_constants.dart'; + +List canonicalShortcutModsForSave(Set mods) { + return [ + 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.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.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.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> filterDefaultBindingsForPlatform( + Iterable bindings, + ShortcutPlatformCapabilities cap, +) { + final filtered = >[]; + for (final raw in bindings) { + if (raw is! Map) continue; + final binding = Map.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; +} diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 8c67987c1..0803485bf 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -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 = [ kShortcutActionSendCtrlAltDel, kShortcutActionRestartRemote, @@ -39,6 +30,8 @@ const _kToolbarOwnedActionIds = [ kShortcutActionSwitchSides, kShortcutActionRefresh, kShortcutActionScreenshot, + kShortcutActionResetCanvas, + kShortcutActionSendClipboardKeystrokes, ]; class TTextMenu { @@ -74,20 +67,61 @@ class TRadioMenu { final T value; final T groupValue; final ValueChanged? 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? 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 _registerToggleMenuShortcuts( + FFI ffi, List 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> _registerRadioMenuShortcuts( + FFI ffi, List> 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 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 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 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>> toolbarViewStyle( .then((_) => ffi.canvasModel.updateViewStyle()); } - return [ + return _registerRadioMenuShortcuts(ffi, [ TRadioMenu( child: Text(translate('Scale original')), value: kRemoteViewStyleOriginal, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionViewModeOriginal), TRadioMenu( child: Text(translate('Scale adaptive')), value: kRemoteViewStyleAdaptive, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionViewModeAdaptive), TRadioMenu( child: Text(translate('Scale custom')), value: kRemoteViewStyleCustom, groupValue: groupValue, - onChanged: onChanged) - ]; + onChanged: onChanged, + actionId: kShortcutActionViewModeCustom) + ]); } Future>> toolbarImageQuality( @@ -473,22 +498,25 @@ Future>> toolbarImageQuality( await bind.sessionSetImageQuality(sessionId: ffi.sessionId, value: value); } - return [ + return _registerRadioMenuShortcuts(ffi, [ TRadioMenu( child: Text(translate('Good image quality')), value: kRemoteImageQualityBest, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionImageQualityBest), TRadioMenu( child: Text(translate('Balanced')), value: kRemoteImageQualityBalanced, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionImageQualityBalanced), TRadioMenu( child: Text(translate('Optimize reaction time')), value: kRemoteImageQualityLow, groupValue: groupValue, - onChanged: onChanged), + onChanged: onChanged, + actionId: kShortcutActionImageQualityLow), TRadioMenu( child: Text(translate('Custom')), value: kRemoteImageQualityCustom, @@ -498,7 +526,7 @@ Future>> toolbarImageQuality( customImageQualityDialog(ffi.sessionId, id, ffi); }, ), - ]; + ]); } Future>> toolbarCodec( @@ -533,12 +561,14 @@ Future>> toolbarCodec( bind.sessionChangePreferCodec(sessionId: sessionId); } - TRadioMenu radio(String label, String value, bool enabled) { + TRadioMenu radio( + String label, String value, bool enabled, String actionId) { return TRadioMenu( 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>> 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> toolbarCursor( @@ -578,6 +608,7 @@ Future> 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> 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> 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> 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> toolbarCursor( }, )); } - return v; + return _registerToggleMenuShortcuts(ffi, v); } Future> toolbarDisplayToggle( @@ -683,6 +717,7 @@ Future> 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> 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> toolbarDisplayToggle( sessionId: sessionId, arg: kOptionEnableFileCopyPaste); v.add(TToggleMenu( value: value, + actionId: kShortcutActionToggleEnableFileCopyPaste, onChanged: enabled ? (value) { if (value == null) return; @@ -738,6 +775,7 @@ Future> 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> 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> 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> 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 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 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) { diff --git a/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart b/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart new file mode 100644 index 000000000..3625ef42d --- /dev/null +++ b/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart @@ -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 createState() => + _DesktopKeyboardShortcutsPageState(); +} + +class _DesktopKeyboardShortcutsPageState + extends State { + final GlobalKey _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, + ), + ); + } +} diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 39a7c36c6..aa07418aa 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -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 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), diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 944962573..ee4de5048 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -134,12 +134,10 @@ class _RemotePageState extends State // 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); diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 5488a767c..e3ffa755a 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -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, diff --git a/flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart b/flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart new file mode 100644 index 000000000..67a433c39 --- /dev/null +++ b/flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart @@ -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 createState() => + _MobileKeyboardShortcutsPageState(); +} + +class _MobileKeyboardShortcutsPageState + extends State { + final GlobalKey _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), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 7ccd41f08..a1e116460 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -127,10 +127,10 @@ class _RemotePageState extends State 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); diff --git a/flutter/lib/models/shortcut_model.dart b/flutter/lib/models/shortcut_model.dart new file mode 100644 index 000000000..3e33575ed --- /dev/null +++ b/flutter/lib/models/shortcut_model.dart @@ -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 parent; + final Map _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> readBindings() { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return []; + try { + final parsed = jsonDecode(raw) as Map; + final list = (parsed['bindings'] as List?) ?? []; + return list.cast>(); + } catch (_) { + return []; + } + } + + static bool isEnabled() { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return false; + try { + final parsed = jsonDecode(raw) as Map; + 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; + 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 setPassThrough(bool v) async { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + Map json = {}; + if (raw.isNotEmpty) { + try { + json = jsonDecode(raw) as Map; + } 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 setEnabled(bool v) async { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + Map json = {}; + if (raw.isNotEmpty) { + try { + json = jsonDecode(raw) as Map; + } 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'] ??= []; + } + 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` 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?; + 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 list; + try { + list = jsonDecode(raw) as List; + } 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(); + }); + } +} diff --git a/flutter/test/fixtures/default_keyboard_shortcuts.json b/flutter/test/fixtures/default_keyboard_shortcuts.json new file mode 100644 index 000000000..121fd615b --- /dev/null +++ b/flutter/test/fixtures/default_keyboard_shortcuts.json @@ -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"} +] diff --git a/flutter/test/fixtures/supported_shortcut_keys.json b/flutter/test/fixtures/supported_shortcut_keys.json new file mode 100644 index 000000000..69fb67f44 --- /dev/null +++ b/flutter/test/fixtures/supported_shortcut_keys.json @@ -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" +] diff --git a/flutter/test/keyboard_shortcuts_test.dart b/flutter/test/keyboard_shortcuts_test.dart new file mode 100644 index 000000000..8d55cb20e --- /dev/null +++ b/flutter/test/keyboard_shortcuts_test.dart @@ -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; + 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 idSet(Iterable 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:'` 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'); + } + }); +} diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 1ee13f4df..0e675b8a6 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -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()) } diff --git a/src/keyboard.rs b/src/keyboard.rs index b9cf4da2d..ec089b4bd 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -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; diff --git a/src/keyboard/shortcuts.rs b/src/keyboard/shortcuts.rs new file mode 100644 index 000000000..15a33a342 --- /dev/null +++ b/src/keyboard/shortcuts.rs @@ -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); + } +} diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 75d16ff92..14418e4b9 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -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(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 73974a2e5..1b0e7ae7d 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -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(); } diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index c18c17fe2..c96bd39b8 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -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