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

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