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

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