feat: keyboard shortcuts in remote sessions

Add an opt-in keyboard-shortcut system that triggers session
actions (Send Ctrl+Alt+Del, Toggle Fullscreen, Switch Display,
Screenshot, Switch Tab, etc.) via three-modifier combinations
during a remote session.

Architecture
- Native: src/keyboard/shortcuts.rs intercepts at the encoder
  layer (process_event and process_event_with_session), so the
  feature is input-source-independent. Bindings persist as a
  single JSON blob in LocalConfig.
- Web: matching + keydown intercept live in the separate hand-
  written TS client at flutter/web/js/ (gitignored, not in this
  repo). flutter/lib/web/bridge.dart::mainInit registers
  window.onShortcutTriggered so the JS matcher can dispatch
  back into the active session's ShortcutModel; the bridge's
  mainReloadKeyboardShortcuts forwards to a JS reloadShortcuts
  on settings writes.
- Three-modifier prefix (Ctrl+Alt+Shift; Cmd+Option+Shift on
  macOS/iOS) sidesteps the need for a pass-through toggle.
- Flutter native path threads the explicit per-call SessionID
  for tab-precise routing; rdev path uses globally-current
  session.

UI
- Settings -> General -> Keyboard Shortcuts opens a dedicated
  configuration page; desktop and mobile share a body widget.
- Recording dialog with live capture, prefix validation, and a
  conflict-replace flow.
- Toolbar menu items display the bound shortcut inline.
- Default bindings (adapted from AnyDesk):
    +Del    Send Ctrl+Alt+Del
    +Enter  Toggle Fullscreen
    +Left/Right  Switch Display Prev/Next
    +P      Screenshot
    +1..9   Switch Session Tab

Other
- AGENTS.md: documented (a) flutter_rust_bridge_codegen needs
  a pinned version + Dart bridge wrappers should be hand-
  written, and (b) the Web-target split where flutter/web/js/
  is the runtime owner on Web rather than wasm-compiled Rust.
- 38 new i18n strings in src/lang/en.rs with Chinese
  translations in src/lang/cn.rs.

Refs discussion #1933.
This commit is contained in:
rustdesk
2026-04-28 14:28:02 +08:00
parent bfd31d21e4
commit 04faf21c78
24 changed files with 2028 additions and 12 deletions

4
.gitignore vendored
View File

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

View File

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

View File

@@ -0,0 +1,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<String, dynamic> parsed;
try {
parsed = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
if (parsed['enabled'] != true) return null;
final list = (parsed['bindings'] as List? ?? []).cast<Map<String, dynamic>>();
final found = list.firstWhere(
(b) => b['action'] == actionId,
orElse: () => {},
);
if (found.isEmpty) return null;
// Guard against a hand-edited / corrupt config where `key` is missing or
// not a string — silently treat the binding as unbound rather than
// crashing the toolbar render.
final keyValue = found['key'];
if (keyValue is! String) return null;
final isMac = defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.iOS;
// `mods` similarly may be malformed; treat a non-list as no modifiers.
final modsRaw = found['mods'];
final mods = modsRaw is List
? modsRaw.whereType<String>().toList()
: const <String>[];
final parts = <String>[];
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();
}
}

View File

@@ -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<KeyboardShortcutActionEntry> 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<KeyboardShortcutActionGroup> 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<KeyboardShortcutsPageBody> createState() =>
KeyboardShortcutsPageBodyState();
}
/// Public state so platform shells can call [resetToDefaultsWithConfirm] from
/// their AppBar action.
class KeyboardShortcutsPageBodyState extends State<KeyboardShortcutsPageBody> {
// ----- Persistence helpers -----
Map<String, dynamic> _readJson() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return {'enabled': false, 'bindings': <dynamic>[]};
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
parsed['bindings'] ??= <dynamic>[];
parsed['enabled'] ??= false;
return parsed;
} catch (_) {
return {'enabled': false, 'bindings': <dynamic>[]};
}
}
Future<void> _writeJson(Map<String, dynamic> json) async {
await bind.mainSetLocalOption(
key: kShortcutLocalConfigKey, value: jsonEncode(json));
// Refresh the matcher cache so writes take effect immediately. On native
// this hits the Rust matcher; on Web the bridge forwards to the JS-side
// matcher in flutter/web/js/.
bind.mainReloadKeyboardShortcuts();
if (mounted) setState(() {});
}
/// Replace the bindings entry for [actionId] with [binding]. If [binding]
/// is null, removes the existing entry. If the user is replacing a
/// conflicting binding, [clearActionId] points at the action whose
/// (now-stale) binding should be removed in the same write.
Future<void> _setBinding(
String actionId, {
Map<String, dynamic>? binding,
String? clearActionId,
}) async {
final json = _readJson();
final list = ((json['bindings'] as List?) ?? <dynamic>[])
.cast<Map<String, dynamic>>()
.toList();
list.removeWhere((b) {
final a = b['action'];
return a == actionId || (clearActionId != null && a == clearActionId);
});
if (binding != null) {
list.add(binding);
}
json['bindings'] = list;
await _writeJson(json);
}
Future<void> _setEnabled(bool v) async {
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<void> _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<void> _onEdit(KeyboardShortcutActionEntry entry) async {
final json = _readJson();
final bindings = ((json['bindings'] as List?) ?? <dynamic>[])
.cast<Map<String, dynamic>>();
final result = await showRecordingDialog(
context: context,
actionId: entry.id,
actionLabel: translate(entry.labelKey),
existingBindings: bindings,
actionLabelLookup: _labelFor,
);
if (result == null) return;
await _setBinding(
entry.id,
binding: result.binding,
clearActionId: result.clearActionId,
);
}
Future<void> _onClear(KeyboardShortcutActionEntry entry) async {
await _setBinding(entry.id, binding: null);
}
/// Public — invoked from the platform AppBar action.
Future<void> resetToDefaultsWithConfirm() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(translate('Reset to defaults')),
content: Text(translate('shortcut-reset-confirm-tip')),
actions: [
dialogButton('Cancel',
onPressed: () => Navigator.of(ctx).pop(false),
isOutline: true),
dialogButton('OK', onPressed: () => Navigator.of(ctx).pop(true)),
],
),
);
if (confirmed == true) {
await _resetToDefaults();
}
}
// ----- Build -----
@override
Widget build(BuildContext context) {
final enabled = ShortcutModel.isEnabled();
final theme = Theme.of(context);
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (widget.headerBanner != null) ...[
widget.headerBanner!,
const SizedBox(height: 12),
],
// 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<String, dynamic> parsed;
try {
parsed = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
final list = (parsed['bindings'] as List? ?? const [])
.cast<Map<String, dynamic>>();
final found = list.firstWhere(
(b) => b['action'] == actionId,
orElse: () => {},
);
if (found.isEmpty) return null;
// Guard against a hand-edited / corrupt config where `key` is missing or
// not a string — 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<String>().toList()
: const <String>[];
final parts = <String>[];
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();
}
}

View File

@@ -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<String, dynamic> binding;
/// If the chosen combo conflicted with another action, the user chose
/// "Replace" — the caller must clear this action's binding before writing
/// the new one.
final String? clearActionId;
RecordingResult(this.binding, this.clearActionId);
}
/// Show the recording dialog.
///
/// [actionId] is the action being edited (used for the title and to detect
/// "binding to itself" — that's not a conflict).
/// [actionLabel] is the translated, user-facing action name.
/// [existingBindings] is the current bindings list (used for conflict detection).
/// [actionLabelLookup] resolves an actionId to its translated label, used in
/// the conflict warning.
Future<RecordingResult?> showRecordingDialog({
required BuildContext context,
required String actionId,
required String actionLabel,
required List<Map<String, dynamic>> existingBindings,
required String Function(String) actionLabelLookup,
}) {
return showDialog<RecordingResult>(
context: context,
barrierDismissible: false,
builder: (ctx) => _RecordingDialog(
actionId: actionId,
actionLabel: actionLabel,
existingBindings: existingBindings,
actionLabelLookup: actionLabelLookup,
),
);
}
class _RecordingDialog extends StatefulWidget {
final String actionId;
final String actionLabel;
final List<Map<String, dynamic>> existingBindings;
final String Function(String) actionLabelLookup;
const _RecordingDialog({
required this.actionId,
required this.actionLabel,
required this.existingBindings,
required this.actionLabelLookup,
});
@override
State<_RecordingDialog> createState() => _RecordingDialogState();
}
class _RecordingDialogState extends State<_RecordingDialog> {
final FocusNode _focusNode = FocusNode();
// Captured combo. null until the user presses something with a non-modifier.
Set<String> _mods = {};
String? _key;
@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<String>().toSet();
if (otherKey == _key &&
otherMods.length == _mods.length &&
otherMods.containsAll(_mods)) {
return otherAction;
}
}
return null;
}
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
return KeyEventResult.handled;
}
if (event is! KeyDownEvent) return KeyEventResult.handled;
// Ignore modifier-only KeyDowns: don't lock in a partial combo.
final logical = event.logicalKey;
final keyName = _logicalToKeyName(logical);
final mods = <String>{};
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 = <String>[
if (_mods.contains('primary')) 'primary',
if (_mods.contains('alt')) 'alt',
if (_mods.contains('shift')) 'shift',
];
final binding = <String, dynamic>{
'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 = <String>[];
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, String>{
LogicalKeyboardKey.keyA: 'a', LogicalKeyboardKey.keyB: 'b',
LogicalKeyboardKey.keyC: 'c', LogicalKeyboardKey.keyD: 'd',
LogicalKeyboardKey.keyE: 'e', LogicalKeyboardKey.keyF: 'f',
LogicalKeyboardKey.keyG: 'g', LogicalKeyboardKey.keyH: 'h',
LogicalKeyboardKey.keyI: 'i', LogicalKeyboardKey.keyJ: 'j',
LogicalKeyboardKey.keyK: 'k', LogicalKeyboardKey.keyL: 'l',
LogicalKeyboardKey.keyM: 'm', LogicalKeyboardKey.keyN: 'n',
LogicalKeyboardKey.keyO: 'o', LogicalKeyboardKey.keyP: 'p',
LogicalKeyboardKey.keyQ: 'q', LogicalKeyboardKey.keyR: 'r',
LogicalKeyboardKey.keyS: 's', LogicalKeyboardKey.keyT: 't',
LogicalKeyboardKey.keyU: 'u', LogicalKeyboardKey.keyV: 'v',
LogicalKeyboardKey.keyW: 'w', LogicalKeyboardKey.keyX: 'x',
LogicalKeyboardKey.keyY: 'y', LogicalKeyboardKey.keyZ: 'z',
};
if (letters.containsKey(k)) return letters[k];
final digits = <LogicalKeyboardKey, String>{
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;
}
}

View File

@@ -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<TTextMenu> 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<TTextMenu> 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<TTextMenu> 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<TTextMenu> 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<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
)
],
),
onPressed: () => ffi.recordingModel.toggle()));
onPressed: () => ffi.recordingModel.toggle(),
actionId: kShortcutActionToggleRecording));
}
// to-do:
@@ -342,6 +350,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
});
}
},
actionId: kShortcutActionScreenshot,
));
}
}
@@ -352,6 +361,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
onPressed: () => onCopyFingerprint(FingerprintState.find(id).value),
));
}
// Register tagged callbacks with the shortcut model so global keyboard
// shortcuts can dispatch the same actions as the toolbar menu items.
for (final menu in v) {
if (menu.actionId != null && menu.onPressed != null) {
ffi.shortcutModel.register(menu.actionId!, menu.onPressed!);
}
}
return v;
}

View File

@@ -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';

View File

@@ -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<DesktopKeyboardShortcutsPage> createState() =>
_DesktopKeyboardShortcutsPageState();
}
class _DesktopKeyboardShortcutsPageState
extends State<DesktopKeyboardShortcutsPage> {
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(translate('Keyboard Shortcuts')),
actions: [
TextButton.icon(
onPressed: () =>
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
icon: const Icon(Icons.restore),
label: Text(translate('Reset to defaults')),
).marginOnly(right: 12),
],
),
body: KeyboardShortcutsPageBody(
key: _bodyKey,
compact: true,
),
);
}
}

View File

@@ -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<String, dynamic> parsed = {};
if (raw.isNotEmpty) {
try {
parsed = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
parsed = {};
}
}
parsed['enabled'] = v;
parsed['bindings'] ??= <dynamic>[];
// 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

View File

@@ -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<RemotePage>
_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(

View File

@@ -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);

View File

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

View File

@@ -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<RemotePage> 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);
}

View File

@@ -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<SettingsPage> 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({
),
);
}

View File

@@ -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 ||

View File

@@ -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,

View File

@@ -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<FFI> parent;
final Map<String, VoidCallback> _callbacks = {};
ShortcutModel(this.parent);
/// Called by toolbar / menu builders to register what to do when the
/// matched shortcut fires.
void register(String actionId, VoidCallback callback) {
_callbacks[actionId] = callback;
}
void unregister(String actionId) {
_callbacks.remove(actionId);
}
/// Called by the session event listener when a `shortcut_triggered` event
/// arrives for this session.
void onTriggered(String actionId) {
final cb = _callbacks[actionId];
if (cb != null) {
cb();
} else {
debugPrint('shortcut_triggered: no handler for $actionId');
}
}
/// Read the bindings JSON from LocalConfig.
static List<Map<String, dynamic>> readBindings() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return [];
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
final list = (parsed['bindings'] as List?) ?? [];
return list.cast<Map<String, dynamic>>();
} catch (_) {
return [];
}
}
static bool isEnabled() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return false;
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
return parsed['enabled'] == true;
} catch (_) {
return false;
}
}
}
/// 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);
}
});
}
}
}

View File

@@ -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 = <Map<String, dynamic>>[
{'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<void> 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();
}

View File

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

View File

@@ -10,6 +10,7 @@ use crate::{client::get_key_state, common::GrabState};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use hbb_common::log;
use hbb_common::message_proto::*;
use hbb_common::SessionID;
#[cfg(any(target_os = "windows", target_os = "macos"))]
use rdev::KeyCode;
use rdev::{Event, EventType, Key};
@@ -79,6 +80,8 @@ lazy_static::lazy_static! {
};
}
pub mod shortcuts;
pub mod client {
use super::*;
@@ -319,6 +322,33 @@ 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.
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<i32>,
session: &Session<T>,
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;

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

@@ -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<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 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<Modifier>,
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Bindings {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub bindings: Vec<Binding>,
}
pub fn default_bindings() -> Vec<Binding> {
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<Modifier> {
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<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::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<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.
pub fn match_event(event: &rdev::Event) -> Option<String> {
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);
}
}

View File

@@ -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+ShiftmacOS 上为 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();
}

View File

@@ -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();
}

View File

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