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..a90dfdcd8 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/display.dart @@ -0,0 +1,65 @@ +// 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. +class ShortcutDisplay { + static String? formatFor(String actionId) { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return null; + final Map parsed; + try { + parsed = jsonDecode(raw) as Map; + } catch (_) { + return null; + } + if (parsed['enabled'] != 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 []; + final parts = []; + for (final m in ['primary', 'alt', 'shift']) { + if (!mods.contains(m)) continue; + switch (m) { + case 'primary': parts.add(isMac ? '⌘' : 'Ctrl'); break; + case 'alt': parts.add(isMac ? '⌥' : 'Alt'); break; + case 'shift': parts.add(isMac ? '⇧' : 'Shift'); break; + } + } + parts.add(_keyDisplay(keyValue, isMac)); + return isMac ? parts.join('') : parts.join('+'); + } + + static String _keyDisplay(String key, bool isMac) { + switch (key) { + case 'delete': return isMac ? '⌫' : 'Del'; + case 'enter': return isMac ? '⏎' : 'Enter'; + case 'arrow_left': return '←'; + case 'arrow_right':return '→'; + case 'arrow_up': return '↑'; + case 'arrow_down': return '↓'; + } + if (key.startsWith('digit')) return key.substring(5); + 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..9b9e7afb4 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart @@ -0,0 +1,490 @@ +// 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 'recording_dialog.dart'; + +/// One configurable action — id + i18n key for its label. +class KeyboardShortcutActionEntry { + final String id; + final String labelKey; + const KeyboardShortcutActionEntry(this.id, this.labelKey); +} + +/// A named group of actions (e.g. "Session Control"). +class KeyboardShortcutActionGroup { + final String titleKey; + final List actions; + const KeyboardShortcutActionGroup(this.titleKey, this.actions); +} + +/// Canonical action group definitions used by both the desktop and mobile +/// configuration pages. The order of groups and entries here is the order +/// the user sees in the UI. (Not `const` because the per-tab ids come from +/// the `kShortcutActionSwitchTab(n)` helper in `consts.dart`.) +final List kKeyboardShortcutActionGroups = [ + KeyboardShortcutActionGroup('Session Control', [ + KeyboardShortcutActionEntry( + kShortcutActionSendCtrlAltDel, 'Insert Ctrl + Alt + Del'), + KeyboardShortcutActionEntry(kShortcutActionInsertLock, 'Insert Lock'), + KeyboardShortcutActionEntry(kShortcutActionRefresh, 'Refresh'), + KeyboardShortcutActionEntry(kShortcutActionSwitchSides, 'Switch Sides'), + KeyboardShortcutActionEntry( + kShortcutActionToggleRecording, 'Toggle Recording'), + KeyboardShortcutActionEntry( + kShortcutActionToggleBlockInput, 'Toggle Block User Input'), + ]), + KeyboardShortcutActionGroup('Display', [ + KeyboardShortcutActionEntry( + kShortcutActionToggleFullscreen, 'Toggle Fullscreen'), + KeyboardShortcutActionEntry( + kShortcutActionSwitchDisplayNext, 'Switch to next display'), + KeyboardShortcutActionEntry( + kShortcutActionSwitchDisplayPrev, 'Switch to previous display'), + KeyboardShortcutActionEntry(kShortcutActionViewMode1to1, 'View Mode 1:1'), + KeyboardShortcutActionEntry( + kShortcutActionViewModeShrink, 'View Mode Shrink'), + KeyboardShortcutActionEntry( + kShortcutActionViewModeStretch, 'View Mode Stretch'), + ]), + KeyboardShortcutActionGroup('Other', [ + KeyboardShortcutActionEntry(kShortcutActionScreenshot, 'Take Screenshot'), + KeyboardShortcutActionEntry(kShortcutActionToggleAudio, 'Toggle Audio'), + KeyboardShortcutActionEntry( + kShortcutActionTogglePrivacyMode, 'Toggle Privacy Mode'), + for (var n = 1; n <= 9; n++) + KeyboardShortcutActionEntry( + kShortcutActionSwitchTab(n), 'Switch Tab $n'), + ]), +]; + +/// 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; + + const KeyboardShortcutsPageBody({ + Key? key, + this.compact = true, + this.editButtonHint, + this.headerBanner, + }) : 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 { + final json = _readJson(); + json['enabled'] = v; + // First-time enable: seed defaults if the user has never bound anything. + final list = (json['bindings'] as List?) ?? const []; + if (v && list.isEmpty) { + json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts()); + } + await _writeJson(json); + } + + Future _resetToDefaults() async { + final json = _readJson(); + json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts()); + await _writeJson(json); + } + + String _labelFor(String actionId) { + for (final g in kKeyboardShortcutActionGroups) { + for (final a in g.actions) { + if (a.id == actionId) return translate(a.labelKey); + } + } + return actionId; + } + + // ----- 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), + ], + // Top toggle — mirrors the General-tab _OptionCheckBox semantics. + Row( + children: [ + Checkbox( + value: enabled, + onChanged: (v) async { + if (v == null) return; + await _setEnabled(v); + }, + ), + const SizedBox(width: 4), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _setEnabled(!enabled), + child: Text( + translate('Enable keyboard shortcuts in remote session'), + ), + ), + ), + ], + ), + 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), + // Disabled visual state when toggle is off — but still scrollable. + Opacity( + opacity: enabled ? 1.0 : 0.5, + child: AbsorbPointer( + absorbing: !enabled, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final group in kKeyboardShortcutActionGroups) + _buildGroup(context, group), + ], + ), + ), + ), + ], + ); + } + + Widget _buildGroup(BuildContext context, KeyboardShortcutActionGroup group) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Text( + translate(group.titleKey), + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 8), + const Expanded( + child: Divider(thickness: 1), + ), + ], + ), + ), + const SizedBox(height: 4), + for (final action in group.actions) + widget.compact + ? _buildCompactRow(context, action) + : _buildTouchRow(context, action), + ], + ); + } + + /// Desktop dense row: label | shortcut | edit | clear, all in one Row. + Widget _buildCompactRow( + BuildContext context, KeyboardShortcutActionEntry entry) { + final shortcut = ShortcutDisplayForActionId.format(entry.id); + 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 = ShortcutDisplayForActionId.format(entry.id); + 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), + ], + ), + ); + } +} + +/// Thin wrapper around [ShortcutDisplay.formatFor] that ignores the +/// `enabled` flag so the configuration page can always show the user what +/// they have bound, even when the feature is currently disabled. +class ShortcutDisplayForActionId { + static String? format(String actionId) { + final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); + if (raw.isEmpty) return null; + final Map parsed; + try { + parsed = jsonDecode(raw) as Map; + } catch (_) { + return null; + } + final list = (parsed['bindings'] as List? ?? const []) + .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 — render the row as unbound instead of crashing the + // settings page. + 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 []; + final parts = []; + for (final m in ['primary', 'alt', 'shift']) { + if (!mods.contains(m)) continue; + switch (m) { + case 'primary': + parts.add(isMac ? '⌘' : 'Ctrl'); + break; + case 'alt': + parts.add(isMac ? '⌥' : 'Alt'); + break; + case 'shift': + parts.add(isMac ? '⇧' : 'Shift'); + break; + } + } + parts.add(_keyDisplay(keyValue, isMac)); + return isMac ? parts.join('') : parts.join('+'); + } + + static String _keyDisplay(String key, bool isMac) { + switch (key) { + case 'delete': + return isMac ? '⌫' : 'Del'; + case 'enter': + return isMac ? '⏎' : 'Enter'; + case 'arrow_left': + return '←'; + case 'arrow_right': + return '→'; + case 'arrow_up': + return '↑'; + case 'arrow_down': + return '↓'; + } + if (key.startsWith('digit')) return key.substring(5); + return key.toUpperCase(); + } +} 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..62d3431f9 --- /dev/null +++ b/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart @@ -0,0 +1,371 @@ +// 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'; + +/// 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; + + @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 the required Ctrl+Alt+Shift + /// (Cmd+Option+Shift on macOS) prefix and a non-modifier key. + bool get _hasRequiredPrefix => + _mods.contains('primary') && + _mods.contains('alt') && + _mods.contains('shift'); + + /// 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 = _logicalToKeyName(logical); + + final mods = {}; + if (HardwareKeyboard.instance.isAltPressed) mods.add('alt'); + if (HardwareKeyboard.instance.isShiftPressed) mods.add('shift'); + final primary = _isMac + ? HardwareKeyboard.instance.isMetaPressed + : HardwareKeyboard.instance.isControlPressed; + if (primary) 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; + } + }); + return KeyEventResult.handled; + } + + void _onSave() { + if (_key == null || !_hasRequiredPrefix) return; + // Sort mods to match the canonical order used by Rust default_bindings: + // primary, alt, shift. + final ordered = [ + if (_mods.contains('primary')) 'primary', + if (_mods.contains('alt')) 'alt', + if (_mods.contains('shift')) 'shift', + ]; + final binding = { + 'action': widget.actionId, + 'mods': ordered, + 'key': _key!, + }; + Navigator.of(context).pop(RecordingResult(binding, _conflictActionId)); + } + + String _formatPrefix() { + if (_isMac) return 'Cmd+Option+Shift'; + return 'Ctrl+Alt+Shift'; + } + + String _formatCombo() { + final parts = []; + for (final m in ['primary', 'alt', 'shift']) { + if (!_mods.contains(m)) continue; + switch (m) { + case 'primary': + parts.add(_isMac ? '⌘' : 'Ctrl'); + break; + case 'alt': + parts.add(_isMac ? '⌥' : 'Alt'); + break; + case 'shift': + parts.add(_isMac ? '⇧' : 'Shift'); + break; + } + } + if (_key != null) { + parts.add(_keyDisplay(_key!)); + } + if (parts.isEmpty) return translate('shortcut-recording-press-keys-tip'); + return _isMac ? parts.join('') : parts.join('+'); + } + + String _keyDisplay(String key) { + switch (key) { + case 'delete': + return _isMac ? '⌫' : 'Del'; + case 'enter': + return _isMac ? '⏎' : 'Enter'; + case 'arrow_left': + return '←'; + case 'arrow_right': + return '→'; + case 'arrow_up': + return '↑'; + case 'arrow_down': + return '↓'; + } + 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; + final canSave = hasKey && _hasRequiredPrefix; + + Widget statusLine; + 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-prefix')} ${_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), + ], + ); + } + + /// 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. Returns null for modifier-only or unsupported keys. + static String? _logicalToKeyName(LogicalKeyboardKey k) { + if (k == LogicalKeyboardKey.delete) return 'delete'; + if (k == LogicalKeyboardKey.enter || + k == LogicalKeyboardKey.numpadEnter) return 'enter'; + 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'; + + 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', + }; + if (letters.containsKey(k)) return letters[k]; + + final digits = { + 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', + }; + if (digits.containsKey(k)) return digits[k]; + + return null; + } +} diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 1a6160324..b3c66a383 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -21,11 +21,13 @@ class TTextMenu { final VoidCallback? onPressed; Widget? trailingIcon; bool divider; + final String? actionId; TTextMenu( {required this.child, required this.onPressed, this.trailingIcon, - this.divider = false}); + this.divider = false, + this.actionId}); Widget getChild() { if (trailingIcon != null) { @@ -229,7 +231,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text('${translate("Insert Ctrl + Alt + Del")}'), - onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)), + onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId), + actionId: kShortcutActionSendCtrlAltDel), ); } // restart @@ -250,7 +253,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text(translate('Insert Lock')), - onPressed: () => bind.sessionLockScreen(sessionId: sessionId)), + onPressed: () => bind.sessionLockScreen(sessionId: sessionId), + actionId: kShortcutActionInsertLock), ); } // blockUserInput @@ -268,7 +272,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { sessionId: sessionId, value: '${blockInput.value ? 'un' : ''}block-input'); blockInput.value = !blockInput.value; - })); + }, + actionId: kShortcutActionToggleBlockInput)); } // switchSides if (isDefaultConn && @@ -280,13 +285,15 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add(TTextMenu( child: Text(translate('Switch Sides')), onPressed: () => - showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager))); + showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager), + actionId: kShortcutActionSwitchSides)); } // refresh if (pi.version.isNotEmpty) { v.add(TTextMenu( child: Text(translate('Refresh')), onPressed: () => sessionRefreshVideo(sessionId, pi), + actionId: kShortcutActionRefresh, )); } // record @@ -308,7 +315,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ) ], ), - onPressed: () => ffi.recordingModel.toggle())); + onPressed: () => ffi.recordingModel.toggle(), + actionId: kShortcutActionToggleRecording)); } // to-do: @@ -342,6 +350,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { }); } }, + actionId: kShortcutActionScreenshot, )); } } @@ -352,6 +361,13 @@ 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 (final menu in v) { + if (menu.actionId != null && menu.onPressed != null) { + ffi.shortcutModel.register(menu.actionId!, menu.onPressed!); + } + } return v; } diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 51c08cf33..e10c07729 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -686,3 +686,24 @@ extension WindowsTargetExt on int { } const kCheckSoftwareUpdateFinish = 'check_software_update_finish'; + +// 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 kShortcutActionScreenshot = 'screenshot'; +const kShortcutActionInsertLock = 'insert_lock'; +const kShortcutActionRefresh = 'refresh'; +const kShortcutActionToggleAudio = 'toggle_audio'; +const kShortcutActionToggleBlockInput = 'toggle_block_input'; +const kShortcutActionToggleRecording = 'toggle_recording'; +const kShortcutActionTogglePrivacyMode = 'toggle_privacy_mode'; +const kShortcutActionViewMode1to1 = 'view_mode_1_to_1'; +const kShortcutActionViewModeShrink = 'view_mode_shrink'; +const kShortcutActionViewModeStretch = 'view_mode_stretch'; +const kShortcutActionSwitchSides = 'switch_sides'; +String kShortcutActionSwitchTab(int n) => 'switch_tab_$n'; + +const kShortcutLocalConfigKey = 'keyboard-shortcuts'; +const kShortcutEventName = 'shortcut_triggered'; 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..03859ada0 --- /dev/null +++ b/flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart @@ -0,0 +1,58 @@ +// 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, + ), + ); + } +} diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index d118b6793..f1e9bfae4 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -10,12 +10,14 @@ import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; +import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; import 'package:flutter_hbb/mobile/widgets/dialog.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/plugin/manager.dart'; import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart'; @@ -421,11 +423,57 @@ class _GeneralState extends State<_General> { if (!isWeb) audio(context), if (!isWeb) record(context), if (!isWeb) WaylandCard(), - other() + other(), + if (!bind.isIncomingOnly()) keyboardShortcuts(), ], ).marginOnly(bottom: _kListViewBottomMargin); } + Widget keyboardShortcuts() { + // The bindings JSON (LocalConfig key `keyboard-shortcuts`) is the single + // source of truth — it embeds an `enabled` boolean alongside the bindings + // list. We mutate the JSON in place via _OptionCheckBox's optGetter / + // optSetter hooks rather than introducing a parallel boolean key, so the + // Rust matcher and the Web matcher both read the same flag without drift. + return _Card(title: 'Keyboard Shortcuts', children: [ + _OptionCheckBox( + context, + 'Enable keyboard shortcuts in remote session', + kShortcutLocalConfigKey, + isServer: false, + optGetter: ShortcutModel.isEnabled, + optSetter: (k, v) async { + final raw = bind.mainGetLocalOption(key: k); + Map parsed = {}; + if (raw.isNotEmpty) { + try { + parsed = jsonDecode(raw) as Map; + } catch (_) { + parsed = {}; + } + } + parsed['enabled'] = v; + parsed['bindings'] ??= []; + // Seed defaults the first time the user enables shortcuts so the + // common combos (Ctrl+Alt+Shift+Enter for fullscreen, etc.) work + // out of the box. Mirrors the same logic on the dedicated config + // page. + final list = (parsed['bindings'] as List?) ?? const []; + if (v && list.isEmpty) { + parsed['bindings'] = + jsonDecode(bind.mainGetDefaultKeyboardShortcuts()); + } + await bind.mainSetLocalOption(key: k, value: jsonEncode(parsed)); + // Refresh the matcher cache so the new flag / bindings 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(); + }, + ), + _ShortcutsConfigureRow(), + ]); + } + Widget theme() { final current = MyTheme.getThemeModePreference().toShortString(); onChanged(String value) async { @@ -2946,6 +2994,37 @@ class _CountDownButtonState extends State<_CountDownButton> { } } +// Tappable row that pushes the shortcut configuration page. +class _ShortcutsConfigureRow extends StatelessWidget { + // ignore: unused_element + const _ShortcutsConfigureRow({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const DesktopKeyboardShortcutsPage(), + )); + }, + child: Row( + children: [ + Expanded( + child: Text(translate('Configure shortcuts...')), + ), + Icon(Icons.arrow_forward_ios, + size: 16, color: disabledTextColor(context, true)) + .marginOnly(right: 4), + ], + ).marginOnly( + left: _kCheckBoxLeftMargin, + top: 6, + bottom: 6, + ), + ); + } +} + //#endregion //#region dialogs diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 29e710bbc..8df5eb4b5 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -17,6 +17,7 @@ import '../../common/widgets/toolbar.dart'; import '../../models/model.dart'; import '../../models/input_model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../../common/shared_state.dart'; import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; @@ -126,6 +127,19 @@ class _RemotePageState extends State _ffi.ffiModel.pi.platform, _ffi.dialogManager); _ffi.recordingModel .updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId)); + // Seed shortcut action callbacks once the session is ready, so that + // global keyboard shortcuts work even if the user never opens the + // toolbar menu. The returned list is intentionally discarded — the + // side effect of registering callbacks (inside toolbarControls) is + // what we want here. + if (mounted) { + toolbarControls(context, widget.id, _ffi); + // Register the default-bound actions that `toolbarControls` doesn't + // own (fullscreen, switch display, switch tab). Done in addition, + // not instead of, the toolbar registration above. + registerSessionShortcutActions(_ffi, + tabController: widget.tabController); + } }); _ffi.canvasModel.initializeEdgeScrollFallback(this); _ffi.start( diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index ec05c987f..5488a767c 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/display.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -763,8 +764,31 @@ class _ControlMenu extends StatelessWidget { if (e.divider) { return Divider(); } else { + final hint = e.actionId == null + ? null + : ShortcutDisplay.formatFor(e.actionId!); + final child = hint == null + ? e.child + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: e.child), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + hint, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ), + ], + ); return MenuButton( - child: e.child, + child: child, onPressed: e.onPressed, ffi: ffi, trailingIcon: e.trailingIcon); 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 9064c122b..7ccd41f08 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -21,6 +21,7 @@ import '../../common/widgets/remote_input.dart'; import '../../models/input_model.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; import '../widgets/custom_scale_widget.dart'; @@ -119,6 +120,18 @@ class _RemotePageState extends State with WidgetsBindingObserver { } _disableAndroidSoftKeyboard( isKeyboardVisible: keyboardVisibilityController.isVisible); + // Seed shortcut action callbacks once the session is ready, so that + // global keyboard shortcuts work even if the user never opens the + // toolbar menu. The returned list is intentionally discarded — the + // side effect of registering callbacks (inside toolbarControls) is + // what we want here. + if (mounted) { + toolbarControls(context, widget.id, gFFI); + // Mobile has no DesktopTabController, so tab-switch shortcuts + // remain unregistered (they will simply log a no-handler debug + // line if a mobile user binds one — they have no tabs to switch). + registerSessionShortcutActions(gFFI); + } }); WidgetsBinding.instance.addObserver(this); } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index 509260636..ed766cf76 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -17,8 +17,10 @@ import '../../common/widgets/login.dart'; import '../../consts.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../widgets/dialog.dart'; import 'home_page.dart'; +import 'mobile_keyboard_shortcuts_page.dart'; import 'scan_page.dart'; class SettingsPage extends StatefulWidget implements PageShape { @@ -819,6 +821,22 @@ class _SettingsState extends State with WidgetsBindingObserver { showThemeSettings(gFFI.dialogManager); }, ), + SettingsTile.navigation( + leading: Icon(Icons.keyboard_outlined), + title: Text(translate('Keyboard Shortcuts')), + description: Text(ShortcutModel.isEnabled() + ? translate('On') + : translate('Off')), + onPressed: (context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MobileKeyboardShortcutsPage(), + )).then((_) { + if (mounted) setState(() {}); + }); + }, + ), if (!bind.isDisableAccount()) SettingsTile.switchTile( title: Text(translate('note-at-conn-end-tip')), @@ -1352,3 +1370,4 @@ SettingsTile _getPopupDialogRadioEntry({ ), ); } + diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 6fdffd796..17e69533c 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -699,6 +699,7 @@ class InputModel { } } +<<<<<<< HEAD // Safe: this only re-dispatches synthesized Shift key-up events. // The key-up path clears the tracked Shift state so this does not loop. void _releaseTrackedShiftKeyEventIfNeeded() { @@ -826,6 +827,7 @@ class InputModel { return KeyEventResult.ignored; } } + if (isWindows || isLinux) { // Ignore meta keys. Because flutter window will loose focus if meta key is pressed. if (e.physicalKey == PhysicalKeyboardKey.metaLeft || diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index e94834a2b..72ecdc99d 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -21,6 +21,7 @@ import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/desktop_render_texture.dart'; @@ -476,6 +477,11 @@ class FfiModel with ChangeNotifier { } else if (name == 'exit_relative_mouse_mode') { // Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS) parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease(); + } else if (name == kShortcutEventName) { + final action = evt['action']; + if (action is String) { + parent.target?.shortcutModel.onTriggered(action); + } } else { debugPrint('Event is not handled in the fixed branch: $name'); } @@ -3623,6 +3629,7 @@ class FFI { late final ElevationModel elevationModel; // session late final CmFileModel cmFileModel; // cm late final TextureModel textureModel; //session + late final ShortcutModel shortcutModel; // session late final Peers recentPeersModel; // global late final Peers favoritePeersModel; // global late final Peers lanPeersModel; // global @@ -3652,6 +3659,7 @@ class FFI { elevationModel = ElevationModel(WeakReference(this)); cmFileModel = CmFileModel(WeakReference(this)); textureModel = TextureModel(WeakReference(this)); + shortcutModel = ShortcutModel(WeakReference(this)); recentPeersModel = Peers( name: PeersModelName.recent, loadEvent: LoadEvent.recent, diff --git a/flutter/lib/models/shortcut_model.dart b/flutter/lib/models/shortcut_model.dart new file mode 100644 index 000000000..b14919d14 --- /dev/null +++ b/flutter/lib/models/shortcut_model.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../common.dart'; +import '../consts.dart'; +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; + } + } +} + +/// 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. +/// +/// [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, +}) { + final sessionId = ffi.sessionId; + + // Toggle Fullscreen — desktop & web-desktop only. `stateGlobal.setFullscreen` + // handles native window vs. browser fullscreen; on mobile fullscreen is the + // permanent default, so we leave the action unregistered (becomes a logged + // no-op if a mobile user binds it). + if (isDesktop || isWebDesktop) { + ffi.shortcutModel.register(kShortcutActionToggleFullscreen, () { + stateGlobal.setFullscreen(!stateGlobal.fullscreen.value); + }); + } + + // Switch Display Next / Prev — requires the peer to have at least 2 + // displays. No-op when only one display is available or when the user has + // selected the "All displays" pseudo-display. + void switchDisplayBy(int delta) { + final pi = ffi.ffiModel.pi; + final count = pi.displays.length; + if (count <= 1) return; + final current = pi.currentDisplay; + if (current == kAllDisplayValue) return; + final 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 Tab 1..9 — 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. No-op on mobile / + // web (no controller passed) and when the requested tab index is out of + // range. + if (tabController != null) { + for (var n = 1; n <= 9; n++) { + final idx = n - 1; + ffi.shortcutModel.register(kShortcutActionSwitchTab(n), () { + if (tabController.state.value.tabs.length > idx) { + tabController.jumpTo(idx); + } + }); + } + } +} diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index a3d93f88e..fd97e5d8a 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart'; import 'dart:html' as html; import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/common.dart' as common; final _privateConstructorUsedError = UnsupportedError( 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); @@ -930,6 +931,30 @@ class RustdeskImpl { ])); } + // Tell the JS-side matcher (flutter/web/js/src/shortcut_matcher.ts) to + // re-read its bindings from LocalStorage. Mirrors the native call which + // refreshes the Rust matcher's in-memory cache. + void mainReloadKeyboardShortcuts({dynamic hint}) { + js.context.callMethod('reloadShortcuts', []); + } + + // Mirror of `default_bindings()` in `src/keyboard/shortcuts.rs`. Keep these + // two lists in sync — if you add or change a default binding on the Rust + // side, update the literal below to match. + String mainGetDefaultKeyboardShortcuts({dynamic hint}) { + const prefix = ['primary', 'alt', 'shift']; + final list = >[ + {'action': 'send_ctrl_alt_del', 'mods': prefix, 'key': 'delete'}, + {'action': 'toggle_fullscreen', 'mods': prefix, 'key': 'enter'}, + {'action': 'switch_display_next', 'mods': prefix, 'key': 'arrow_right'}, + {'action': 'switch_display_prev', 'mods': prefix, 'key': 'arrow_left'}, + {'action': 'screenshot', 'mods': prefix, 'key': 'p'}, + for (var n = 1; n <= 9; n++) + {'action': 'switch_tab_$n', 'mods': prefix, 'key': 'digit$n'}, + ]; + return jsonEncode(list); + } + String mainGetInputSource({dynamic hint}) { final inputSource = js.context.callMethod('getByName', ['option:local', 'input-source']); @@ -1176,6 +1201,15 @@ class RustdeskImpl { } Future mainInit({required String appDir, dynamic hint}) { + // JS -> Dart shortcut bridge. The matcher in flutter/web/js/src/ + // shortcut_matcher.ts calls `window.onShortcutTriggered(actionId)` when a + // binding fires; route it to the active session's ShortcutModel. + // Web is single-window so `gFFI` is always the active session. + js.context['onShortcutTriggered'] = (dynamic action) { + if (action is String) { + common.gFFI.shortcutModel.onTriggered(action); + } + }; return Future.value(); } 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 { + 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 { SyncReturn(is_installed_lower_version()) } diff --git a/src/keyboard.rs b/src/keyboard.rs index b9cf4da2d..0e3cff85e 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,33 @@ pub mod client { } pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option) { + // 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. + if let Some(action_id) = crate::keyboard::shortcuts::match_event(event) { + #[cfg(feature = "flutter")] + { + // The rdev grab loop is genuinely process-wide: it does not know which + // Flutter SessionID the keystroke was meant for, so we route to the + // globally-current session via flutter::get_cur_session_id() (maintained + // by session_enter_or_leave). This is the only behavior available on the + // rdev path; the Flutter path threads the explicit per-call SessionID + // through process_event_with_session instead. + let session_id = crate::flutter::get_cur_session_id(); + crate::flutter::push_session_event( + &session_id, + "shortcut_triggered", + vec![("action", &action_id)], + ); + } + #[cfg(not(feature = "flutter"))] + { + let _ = action_id; + } + return; + } + let keyboard_mode = get_keyboard_mode_enum(keyboard_mode); if is_long_press(&event) { return; @@ -334,7 +364,33 @@ pub mod client { event: &Event, lock_modes: Option, session: &Session, + session_id: SessionID, ) { + // 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. + if let Some(action_id) = crate::keyboard::shortcuts::match_event(event) { + #[cfg(feature = "flutter")] + { + // The Flutter path threads the explicit SessionID from the FFI entry + // (session_handle_flutter_*key_event) through this call, so the dispatch + // targets the exact tab the keystroke originated from — no dependency on + // the global focus tracker and no multi-window race. + crate::flutter::push_session_event( + &session_id, + "shortcut_triggered", + vec![("action", &action_id)], + ); + } + #[cfg(not(feature = "flutter"))] + { + let _ = action_id; + let _ = session_id; + } + return; + } + 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..be141576c --- /dev/null +++ b/src/keyboard/shortcuts.rs @@ -0,0 +1,370 @@ +//! 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> = 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 SCREENSHOT: &str = "screenshot"; + pub const INSERT_LOCK: &str = "insert_lock"; + pub const REFRESH: &str = "refresh"; + pub const TOGGLE_AUDIO: &str = "toggle_audio"; + pub const TOGGLE_BLOCK_INPUT: &str = "toggle_block_input"; + pub const TOGGLE_RECORDING: &str = "toggle_recording"; + pub const TOGGLE_PRIVACY_MODE: &str = "toggle_privacy_mode"; + pub const VIEW_MODE_1_TO_1: &str = "view_mode_1_to_1"; + pub const VIEW_MODE_SHRINK: &str = "view_mode_shrink"; + pub const VIEW_MODE_STRETCH: &str = "view_mode_stretch"; + pub const SWITCH_SIDES: &str = "switch_sides"; + // switch_tab_1 .. switch_tab_9 are generated below. +} + +pub fn switch_tab_action_id(n: u8) -> Option<&'static str> { + match n { + 1 => Some("switch_tab_1"), + 2 => Some("switch_tab_2"), + 3 => Some("switch_tab_3"), + 4 => Some("switch_tab_4"), + 5 => Some("switch_tab_5"), + 6 => Some("switch_tab_6"), + 7 => Some("switch_tab_7"), + 8 => Some("switch_tab_8"), + 9 => Some("switch_tab_9"), + _ => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Modifier { + Primary, + Alt, + Shift, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Binding { + pub action: String, + pub mods: Vec, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Bindings { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub bindings: Vec, +} + +pub fn default_bindings() -> Vec { + let prefix = || vec![Modifier::Primary, Modifier::Alt, Modifier::Shift]; + let mut v = 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() }, + ]; + for n in 1..=9u8 { + if let Some(action) = switch_tab_action_id(n) { + v.push(Binding { + action: action.into(), + mods: prefix(), + key: format!("digit{n}"), + }); + } + } + v +} + +/// Match a normalized (key, modifiers) pair against the given bindings. +/// Returns the matched action ID, or None. +pub fn match_normalized<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> { + if !b.enabled { + return None; + } + for binding in &b.bindings { + 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 { + let mut v = Vec::new(); + let primary = if cfg!(target_os = "macos") { command } else { ctrl }; + if primary { 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 { + use rdev::{EventType, Key}; + let key = match event.event_type { + EventType::KeyPress(k) => k, + _ => return None, + }; + Some(match key { + Key::Delete => "delete".into(), + Key::Return => "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::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(), + _ => 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 { + 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. +pub fn match_event(event: &rdev::Event) -> Option { + let bindings = current(); + if !bindings.enabled { + return None; + } + 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) +} + +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, + }; + } + bits +} + +fn mods_equal(a: &[Modifier], b: &[Modifier]) -> bool { + mods_bits(a) == mods_bits(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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(&"switch_tab_1")); + assert!(actions.contains(&"switch_tab_9")); + // 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_disabled() { + let bindings = Bindings { enabled: 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, 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, 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, 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, + 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!(target_os = "macos") { + // On macOS Ctrl is NOT primary + assert!(!mods.contains(&Modifier::Primary)); + } else { + assert!(mods.contains(&Modifier::Primary)); + } + assert!(mods.contains(&Modifier::Alt)); + assert!(mods.contains(&Modifier::Shift)); + } + + #[test] + fn modifier_normalization_command_is_primary_on_mac() { + let mods = normalize_modifiers(true, false, true, /*command=*/true); + if cfg!(target_os = "macos") { + assert!(mods.contains(&Modifier::Primary)); + } else { + // On Win/Linux Command/Meta is NOT primary + assert!(!mods.contains(&Modifier::Primary)); + } + } + + #[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..ff088a09b 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -743,5 +743,44 @@ 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", "启用后,列出的组合键将在本地触发会话操作,而不会发送到远程端。所有快捷键必须包含 Ctrl+Alt+Shift(macOS 上为 Cmd+Option+Shift),以避免与正常输入冲突。"), + ("Reset to defaults", "恢复默认设置"), + ("shortcut-reset-confirm-tip", "这将以默认快捷键替换所有当前绑定。是否继续?"), + ("Session Control", "会话控制"), + ("Toggle Fullscreen", "切换全屏"), + ("Switch to next display", "切换到下一个显示器"), + ("Switch to previous display", "切换到上一个显示器"), + ("View Mode 1:1", "原始大小"), + ("View Mode Shrink", "缩小"), + ("View Mode Stretch", "拉伸"), + ("Take Screenshot", "截图"), + ("Toggle Audio", "切换音频"), + ("Toggle Privacy Mode", "切换隐私模式"), + ("Toggle Recording", "切换录制"), + ("Toggle Block User Input", "切换屏蔽用户输入"), + ("Switch Tab 1", "切换到第 1 个标签"), + ("Switch Tab 2", "切换到第 2 个标签"), + ("Switch Tab 3", "切换到第 3 个标签"), + ("Switch Tab 4", "切换到第 4 个标签"), + ("Switch Tab 5", "切换到第 5 个标签"), + ("Switch Tab 6", "切换到第 6 个标签"), + ("Switch Tab 7", "切换到第 7 个标签"), + ("Switch Tab 8", "切换到第 8 个标签"), + ("Switch Tab 9", "切换到第 9 个标签"), + ("Edit", "编辑"), + ("Save", "保存"), + ("Set Shortcut", "设置快捷键"), + ("shortcut-recording-instruction", "请按下您想使用的组合键。"), + ("shortcut-recording-press-keys-tip", "请按下组合键..."), + ("shortcut-must-include-prefix", "必须包含"), + ("shortcut-already-bound-to", "已绑定到"), + ("Replace", "替换"), + ("Valid", "有效"), + ("shortcut-mobile-physical-keyboard-tip", "录制需要使用物理键盘,不支持软键盘。"), + ("On", "开"), + ("Off", "关"), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 73974a2e5..b78e806c3 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -274,5 +274,47 @@ 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."), + ("Keyboard Shortcuts", ""), + ("Configure shortcuts...", ""), + ("Enable keyboard shortcuts in remote session", ""), + ("shortcut-page-description", "When enabled, listed key combinations trigger session actions locally instead of being sent to the remote. All bindings must include Ctrl+Alt+Shift (Cmd+Option+Shift on macOS) to avoid conflicts with normal typing."), + ("Reset to defaults", ""), + ("shortcut-reset-confirm-tip", "This will replace all current bindings with the default set. Continue?"), + ("Session Control", ""), + ("Display", ""), + ("Other", ""), + ("Toggle Fullscreen", ""), + ("Switch to next display", ""), + ("Switch to previous display", ""), + ("View Mode 1:1", ""), + ("View Mode Shrink", ""), + ("View Mode Stretch", ""), + ("Take Screenshot", ""), + ("Toggle Audio", ""), + ("Toggle Privacy Mode", ""), + ("Toggle Recording", ""), + ("Toggle Block User Input", ""), + ("Switch Tab 1", ""), + ("Switch Tab 2", ""), + ("Switch Tab 3", ""), + ("Switch Tab 4", ""), + ("Switch Tab 5", ""), + ("Switch Tab 6", ""), + ("Switch Tab 7", ""), + ("Switch Tab 8", ""), + ("Switch Tab 9", ""), + ("Edit", ""), + ("Save", ""), + ("Set Shortcut", ""), + ("shortcut-recording-instruction", "Press the key combination you want to use."), + ("shortcut-recording-press-keys-tip", "Press a key combination..."), + ("shortcut-must-include-prefix", "Must include"), + ("shortcut-already-bound-to", "Already bound to"), + ("Replace", ""), + ("Valid", ""), + ("shortcut-mobile-physical-keyboard-tip", "Recording requires a physical keyboard. Soft keyboards are not supported."), + ("Clear", ""), + ("On", ""), + ("Off", ""), ].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 Session { #[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 Session { #[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 Session { 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 Session { #[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 Session { #[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 Session { 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 Session { fn _handle_key_non_flutter_simulation( &self, + session_id: SessionID, keyboard_mode: &str, character: &str, usb_hid: i32, @@ -1092,7 +1105,13 @@ impl Session { #[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