mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-02 10:16:28 +02:00
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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -55,4 +55,6 @@ examples/**/target/
|
||||
vcpkg_installed
|
||||
flutter/lib/generated_plugin_registrant.dart
|
||||
libsciter.dylib
|
||||
flutter/web/
|
||||
flutter/web/
|
||||
# Local git worktrees
|
||||
.worktrees/
|
||||
|
||||
24
AGENTS.md
24
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<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.
|
||||
|
||||
65
flutter/lib/common/widgets/keyboard_shortcuts/display.dart
Normal file
65
flutter/lib/common/widgets/keyboard_shortcuts/display.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
490
flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
Normal file
490
flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
95
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
Normal file
95
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
141
flutter/lib/models/shortcut_model.dart
Normal file
141
flutter/lib/models/shortcut_model.dart
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
370
src/keyboard/shortcuts.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user