fix(keyboard): shortcuts, harden config and callback lifecycle

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-05-05 21:34:22 +08:00
parent 42a88ac1f0
commit d403d640f8
11 changed files with 181 additions and 63 deletions

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../../../consts.dart'; import '../../../consts.dart';
import '../../../models/platform_model.dart'; import '../../../models/platform_model.dart';
import 'shortcut_utils.dart';
/// Read the bindings JSON and produce a human-readable shortcut string for /// Read the bindings JSON and produce a human-readable shortcut string for
/// `actionId`, formatted for the current OS. Returns null if unbound, or — /// `actionId`, formatted for the current OS. Returns null if unbound, or —
@@ -44,7 +45,7 @@ class ShortcutDisplay {
// would press it expecting the local action and instead the keys would go // would press it expecting the local action and instead the keys would go
// to the remote. Treat as unbound for display purposes. // to the remote. Treat as unbound for display purposes.
if (requireEnabled && parsed['pass_through'] == true) return null; if (requireEnabled && parsed['pass_through'] == true) return null;
final list = (parsed['bindings'] as List? ?? []).cast<Map<String, dynamic>>(); final list = shortcutBindingMapsFrom(parsed['bindings']);
final found = list.firstWhere( final found = list.firstWhere(
(b) => b['action'] == actionId, (b) => b['action'] == actionId,
orElse: () => {}, orElse: () => {},

View File

@@ -109,9 +109,7 @@ class KeyboardShortcutsPageBodyState extends State<KeyboardShortcutsPageBody> {
String? clearActionId, String? clearActionId,
}) async { }) async {
final json = _readJson(); final json = _readJson();
final list = ((json['bindings'] as List?) ?? <dynamic>[]) final list = shortcutBindingMapsFrom(json['bindings']);
.cast<Map<String, dynamic>>()
.toList();
list.removeWhere((b) { list.removeWhere((b) {
final a = b['action']; final a = b['action'];
return a == actionId || (clearActionId != null && a == clearActionId); return a == actionId || (clearActionId != null && a == clearActionId);
@@ -172,8 +170,7 @@ class KeyboardShortcutsPageBodyState extends State<KeyboardShortcutsPageBody> {
Future<void> _onEdit(KeyboardShortcutActionEntry entry) async { Future<void> _onEdit(KeyboardShortcutActionEntry entry) async {
final json = _readJson(); final json = _readJson();
final bindings = ((json['bindings'] as List?) ?? <dynamic>[]) final bindings = shortcutBindingMapsFrom(json['bindings']);
.cast<Map<String, dynamic>>();
final result = await showRecordingDialog( final result = await showRecordingDialog(
context: context, context: context,
actionId: entry.id, actionId: entry.id,

View File

@@ -2,9 +2,9 @@
// //
// Modal dialog used by the Keyboard Shortcuts settings page to capture a new // 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, // key combination for a given action. The dialog listens for KeyDown events,
// extracts the modifier set + non-modifier key, validates against the // extracts the modifier set + non-modifier key, validates that at least one
// "must include Ctrl+Alt+Shift (Cmd+Option+Shift on macOS)" rule, and reports // modifier is present, and reports any conflict with another already-bound
// any conflict with another already-bound action. // action.
// //
// On Save, returns the new binding map ({action, mods, key}) plus the // On Save, returns the new binding map ({action, mods, key}) plus the
// optional id of the action whose binding should be cleared (the conflict // optional id of the action whose binding should be cleared (the conflict
@@ -147,8 +147,7 @@ class _RecordingDialogState extends State<_RecordingDialog> {
final otherAction = b['action'] as String?; final otherAction = b['action'] as String?;
if (otherAction == null || otherAction == widget.actionId) continue; if (otherAction == null || otherAction == widget.actionId) continue;
final otherKey = b['key'] as String?; final otherKey = b['key'] as String?;
final otherMods = final otherMods = shortcutModSetFrom(b['mods']);
((b['mods'] as List?) ?? const []).cast<String>().toSet();
if (otherKey == _key && if (otherKey == _key &&
otherMods.length == _mods.length && otherMods.length == _mods.length &&
otherMods.containsAll(_mods)) { otherMods.containsAll(_mods)) {

View File

@@ -229,6 +229,10 @@ List<KeyboardShortcutActionGroup> filterKeyboardShortcutActionGroupsForPlatform(
if (!cap.includeScreenshotShortcut && id == kShortcutActionScreenshot) { if (!cap.includeScreenshotShortcut && id == kShortcutActionScreenshot) {
return false; return false;
} }
if (!cap.includeScreenshotShortcut &&
id == kShortcutActionToggleRelativeMouseMode) {
return false;
}
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(id)) return false; if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(id)) return false;
if (!cap.includeToolbarShortcut && id == kShortcutActionToggleToolbar) { if (!cap.includeToolbarShortcut && id == kShortcutActionToggleToolbar) {
return false; return false;

View File

@@ -11,6 +11,30 @@ List<String> canonicalShortcutModsForSave(Set<String> mods) {
]; ];
} }
List<Map<String, dynamic>> shortcutBindingMapsFrom(dynamic rawBindings) {
if (rawBindings is! Iterable) return <Map<String, dynamic>>[];
final bindings = <Map<String, dynamic>>[];
for (final raw in rawBindings) {
if (raw is! Map) continue;
final binding = <String, dynamic>{};
for (final entry in raw.entries) {
final key = entry.key;
if (key is String) {
binding[key] = entry.value;
}
}
if (binding.isNotEmpty) {
bindings.add(binding);
}
}
return bindings;
}
Set<String> shortcutModSetFrom(dynamic rawMods) {
if (rawMods is! Iterable) return <String>{};
return rawMods.whereType<String>().toSet();
}
bool isSwitchTabShortcutAction(String? actionId) { bool isSwitchTabShortcutAction(String? actionId) {
return actionId == kShortcutActionSwitchTabNext || return actionId == kShortcutActionSwitchTabNext ||
actionId == kShortcutActionSwitchTabPrev; actionId == kShortcutActionSwitchTabPrev;
@@ -144,9 +168,7 @@ List<Map<String, dynamic>> filterDefaultBindingsForPlatform(
ShortcutPlatformCapabilities cap, ShortcutPlatformCapabilities cap,
) { ) {
final filtered = <Map<String, dynamic>>[]; final filtered = <Map<String, dynamic>>[];
for (final raw in bindings) { for (final binding in shortcutBindingMapsFrom(bindings)) {
if (raw is! Map) continue;
final binding = Map<String, dynamic>.from(raw);
final action = binding['action'] as String?; final action = binding['action'] as String?;
if (!cap.includeFullscreenShortcut && if (!cap.includeFullscreenShortcut &&
action == kShortcutActionToggleFullscreen) { action == kShortcutActionToggleFullscreen) {
@@ -155,6 +177,10 @@ List<Map<String, dynamic>> filterDefaultBindingsForPlatform(
if (!cap.includeScreenshotShortcut && action == kShortcutActionScreenshot) { if (!cap.includeScreenshotShortcut && action == kShortcutActionScreenshot) {
continue; continue;
} }
if (!cap.includeScreenshotShortcut &&
action == kShortcutActionToggleRelativeMouseMode) {
continue;
}
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(action)) { if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(action)) {
continue; continue;
} }

View File

@@ -34,6 +34,48 @@ const _kToolbarOwnedActionIds = <String>[
kShortcutActionSendClipboardKeystrokes, kShortcutActionSendClipboardKeystrokes,
]; ];
const _kToolbarViewStyleActionIds = <String>[
kShortcutActionViewModeOriginal,
kShortcutActionViewModeAdaptive,
kShortcutActionViewModeCustom,
];
const _kToolbarImageQualityActionIds = <String>[
kShortcutActionImageQualityBest,
kShortcutActionImageQualityBalanced,
kShortcutActionImageQualityLow,
];
const _kToolbarCodecActionIds = <String>[
kShortcutActionCodecAuto,
kShortcutActionCodecVp8,
kShortcutActionCodecVp9,
kShortcutActionCodecAv1,
kShortcutActionCodecH264,
kShortcutActionCodecH265,
];
const _kToolbarCursorActionIds = <String>[
kShortcutActionToggleShowRemoteCursor,
kShortcutActionToggleFollowRemoteCursor,
kShortcutActionToggleFollowRemoteWindow,
kShortcutActionToggleZoomCursor,
];
const _kToolbarDisplayToggleActionIds = <String>[
kShortcutActionToggleQualityMonitor,
kShortcutActionToggleMute,
kShortcutActionToggleEnableFileCopyPaste,
kShortcutActionToggleDisableClipboard,
kShortcutActionToggleLockAfterSessionEnd,
kShortcutActionToggleTrueColor,
];
const _kToolbarKeyboardToggleActionIds = <String>[
kShortcutActionToggleSwapCtrlCmd,
kShortcutActionToggleSwapLeftRightMouse,
];
class TTextMenu { class TTextMenu {
final Widget child; final Widget child;
final VoidCallback? onPressed; final VoidCallback? onPressed;
@@ -92,7 +134,13 @@ class TToggleMenu {
/// Register each tagged entry's `onChanged` with the session [ShortcutModel]. /// Register each tagged entry's `onChanged` with the session [ShortcutModel].
/// Passthrough — returns [menus] so a caller can wrap `return [...]` directly. /// Passthrough — returns [menus] so a caller can wrap `return [...]` directly.
List<TToggleMenu> _registerToggleMenuShortcuts( List<TToggleMenu> _registerToggleMenuShortcuts(
FFI ffi, List<TToggleMenu> menus) { FFI ffi,
List<TToggleMenu> menus, {
List<String> ownedActionIds = const [],
}) {
for (final actionId in ownedActionIds) {
ffi.shortcutModel.unregister(actionId);
}
for (final menu in menus) { for (final menu in menus) {
final actionId = menu.actionId; final actionId = menu.actionId;
if (actionId == null) continue; if (actionId == null) continue;
@@ -109,7 +157,13 @@ List<TToggleMenu> _registerToggleMenuShortcuts(
/// Radio variant of [_registerToggleMenuShortcuts]. /// Radio variant of [_registerToggleMenuShortcuts].
List<TRadioMenu<T>> _registerRadioMenuShortcuts<T>( List<TRadioMenu<T>> _registerRadioMenuShortcuts<T>(
FFI ffi, List<TRadioMenu<T>> menus) { FFI ffi,
List<TRadioMenu<T>> menus, {
List<String> ownedActionIds = const [],
}) {
for (final actionId in ownedActionIds) {
ffi.shortcutModel.unregister(actionId);
}
for (final menu in menus) { for (final menu in menus) {
final actionId = menu.actionId; final actionId = menu.actionId;
if (actionId == null) continue; if (actionId == null) continue;
@@ -486,7 +540,7 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
groupValue: groupValue, groupValue: groupValue,
onChanged: onChanged, onChanged: onChanged,
actionId: kShortcutActionViewModeCustom) actionId: kShortcutActionViewModeCustom)
]); ], ownedActionIds: _kToolbarViewStyleActionIds);
} }
Future<List<TRadioMenu<String>>> toolbarImageQuality( Future<List<TRadioMenu<String>>> toolbarImageQuality(
@@ -526,7 +580,7 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
customImageQualityDialog(ffi.sessionId, id, ffi); customImageQualityDialog(ffi.sessionId, id, ffi);
}, },
), ),
]); ], ownedActionIds: _kToolbarImageQualityActionIds);
} }
Future<List<TRadioMenu<String>>> toolbarCodec( Future<List<TRadioMenu<String>>> toolbarCodec(
@@ -553,7 +607,10 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
} }
final visible = final visible =
codecs.length == 4 && (codecs[0] || codecs[1] || codecs[2] || codecs[3]); codecs.length == 4 && (codecs[0] || codecs[1] || codecs[2] || codecs[3]);
if (!visible) return []; if (!visible) {
return _registerRadioMenuShortcuts<String>(ffi, [],
ownedActionIds: _kToolbarCodecActionIds);
}
onChanged(String? value) async { onChanged(String? value) async {
if (value == null) return; if (value == null) return;
await bind.sessionPeerOption( await bind.sessionPeerOption(
@@ -583,7 +640,7 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
if (codecs[1]) radio('AV1', 'av1', codecs[1], kShortcutActionCodecAv1), if (codecs[1]) radio('AV1', 'av1', codecs[1], kShortcutActionCodecAv1),
if (codecs[2]) radio('H264', 'h264', codecs[2], kShortcutActionCodecH264), if (codecs[2]) radio('H264', 'h264', codecs[2], kShortcutActionCodecH264),
if (codecs[3]) radio('H265', 'h265', codecs[3], kShortcutActionCodecH265), if (codecs[3]) radio('H265', 'h265', codecs[3], kShortcutActionCodecH265),
]); ], ownedActionIds: _kToolbarCodecActionIds);
} }
Future<List<TToggleMenu>> toolbarCursor( Future<List<TToggleMenu>> toolbarCursor(
@@ -701,7 +758,8 @@ Future<List<TToggleMenu>> toolbarCursor(
}, },
)); ));
} }
return _registerToggleMenuShortcuts(ffi, v); return _registerToggleMenuShortcuts(ffi, v,
ownedActionIds: _kToolbarCursorActionIds);
} }
Future<List<TToggleMenu>> toolbarDisplayToggle( Future<List<TToggleMenu>> toolbarDisplayToggle(
@@ -868,7 +926,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
}, },
child: Text(translate('View Mode')))); child: Text(translate('View Mode'))));
} }
return _registerToggleMenuShortcuts(ffi, v); return _registerToggleMenuShortcuts(ffi, v,
ownedActionIds: _kToolbarDisplayToggleActionIds);
} }
var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1)); var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1));
@@ -1037,7 +1096,8 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
onChanged: enabled ? onChanged : null, onChanged: enabled ? onChanged : null,
child: Text(translate('swap-left-right-mouse')))); child: Text(translate('swap-left-right-mouse'))));
} }
return _registerToggleMenuShortcuts(ffi, v); return _registerToggleMenuShortcuts(ffi, v,
ownedActionIds: _kToolbarKeyboardToggleActionIds);
} }
/// Drive each toolbar helper for its registration side effect, so a shortcut /// Drive each toolbar helper for its registration side effect, so a shortcut

View File

@@ -774,16 +774,20 @@ class _ControlMenu extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Flexible(child: e.child), Flexible(child: e.child),
Padding( Flexible(
padding: const EdgeInsets.only(left: 16), child: Padding(
child: Text( padding: const EdgeInsets.only(left: 16),
hint, child: Text(
style: Theme.of(context) hint,
.textTheme maxLines: 1,
.bodySmall overflow: TextOverflow.ellipsis,
?.copyWith( style: Theme.of(context)
color: Theme.of(context).hintColor, .textTheme
), .bodySmall
?.copyWith(
color: Theme.of(context).hintColor,
),
),
), ),
), ),
], ],

View File

@@ -821,22 +821,23 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
showThemeSettings(gFFI.dialogManager); showThemeSettings(gFFI.dialogManager);
}, },
), ),
SettingsTile.navigation( if (!disabledSettings)
leading: Icon(Icons.keyboard_outlined), SettingsTile.navigation(
title: Text(translate('Keyboard Shortcuts')), leading: Icon(Icons.keyboard_outlined),
description: Text(ShortcutModel.isEnabled() title: Text(translate('Keyboard Shortcuts')),
? translate('On') description: Text(ShortcutModel.isEnabled()
: translate('Off')), ? translate('On')
onPressed: (context) { : translate('Off')),
Navigator.push( onPressed: (context) {
context, Navigator.push(
MaterialPageRoute( context,
builder: (_) => const MobileKeyboardShortcutsPage(), MaterialPageRoute(
)).then((_) { builder: (_) => const MobileKeyboardShortcutsPage(),
if (mounted) setState(() {}); )).then((_) {
}); if (mounted) setState(() {});
}, });
), },
),
if (!bind.isDisableAccount()) if (!bind.isDisableAccount())
SettingsTile.switchTile( SettingsTile.switchTile(
title: Text(translate('note-at-conn-end-tip')), title: Text(translate('note-at-conn-end-tip')),

View File

@@ -3933,6 +3933,7 @@ class FFI {
ffiModel.pi.currentDisplay); ffiModel.pi.currentDisplay);
} }
imageModel.callbacksOnFirstImage.clear(); imageModel.callbacksOnFirstImage.clear();
shortcutModel.clear();
await imageModel.update(null); await imageModel.update(null);
cursorModel.clear(); cursorModel.clear();
ffiModel.clear(); ffiModel.clear();

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -15,6 +16,8 @@ import '../models/model.dart';
import '../models/platform_model.dart'; import '../models/platform_model.dart';
import '../models/state_model.dart'; import '../models/state_model.dart';
typedef ShortcutCallback = FutureOr<void> Function();
/// Per-session shortcut dispatcher. Attached to FFI when a session is created. /// Per-session shortcut dispatcher. Attached to FFI when a session is created.
/// ///
/// The Rust matcher (src/keyboard/shortcuts.rs) emits `shortcut_triggered` /// The Rust matcher (src/keyboard/shortcuts.rs) emits `shortcut_triggered`
@@ -24,13 +27,13 @@ import '../models/state_model.dart';
/// builders previously registered for that action id. /// builders previously registered for that action id.
class ShortcutModel { class ShortcutModel {
final WeakReference<FFI> parent; final WeakReference<FFI> parent;
final Map<String, VoidCallback> _callbacks = {}; final Map<String, ShortcutCallback> _callbacks = {};
ShortcutModel(this.parent); ShortcutModel(this.parent);
/// Called by toolbar / menu builders to register what to do when the /// Called by toolbar / menu builders to register what to do when the
/// matched shortcut fires. /// matched shortcut fires.
void register(String actionId, VoidCallback callback) { void register(String actionId, ShortcutCallback callback) {
_callbacks[actionId] = callback; _callbacks[actionId] = callback;
} }
@@ -38,12 +41,19 @@ class ShortcutModel {
_callbacks.remove(actionId); _callbacks.remove(actionId);
} }
void clear() {
_callbacks.clear();
}
/// Called by the session event listener when a `shortcut_triggered` event /// Called by the session event listener when a `shortcut_triggered` event
/// arrives for this session. /// arrives for this session.
void onTriggered(String actionId) { void onTriggered(String actionId) {
final cb = _callbacks[actionId]; final cb = _callbacks[actionId];
if (cb != null) { if (cb != null) {
cb(); unawaited(Future.sync(cb).catchError((e, st) {
debugPrint(
'shortcut_triggered: handler failed for $actionId: $e\n$st');
}));
} else { } else {
debugPrint('shortcut_triggered: no handler for $actionId'); debugPrint('shortcut_triggered: no handler for $actionId');
} }
@@ -55,8 +65,7 @@ class ShortcutModel {
if (raw.isEmpty) return []; if (raw.isEmpty) return [];
try { try {
final parsed = jsonDecode(raw) as Map<String, dynamic>; final parsed = jsonDecode(raw) as Map<String, dynamic>;
final list = (parsed['bindings'] as List?) ?? []; return shortcutBindingMapsFrom(parsed['bindings']);
return list.cast<Map<String, dynamic>>();
} catch (_) { } catch (_) {
return []; return [];
} }
@@ -120,14 +129,14 @@ class ShortcutModel {
} }
} }
json['enabled'] = v; json['enabled'] = v;
final list = (json['bindings'] as List?) ?? const []; final list = shortcutBindingMapsFrom(json['bindings']);
if (v && list.isEmpty) { if (v && list.isEmpty) {
json['bindings'] = filterDefaultBindingsForPlatform( json['bindings'] = filterDefaultBindingsForPlatform(
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List, jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
currentPlatformCapabilities(), currentPlatformCapabilities(),
); );
} else { } else {
json['bindings'] ??= <dynamic>[]; json['bindings'] = list;
} }
await bind.mainSetLocalOption( await bind.mainSetLocalOption(
key: kShortcutLocalConfigKey, value: jsonEncode(json)); key: kShortcutLocalConfigKey, value: jsonEncode(json));

View File

@@ -2,6 +2,7 @@
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use hbb_common::log;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts"; const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts";
@@ -255,20 +256,35 @@ pub fn reload_from_config() {
let parsed = if raw.is_empty() { let parsed = if raw.is_empty() {
Bindings::default() Bindings::default()
} else { } else {
serde_json::from_str(&raw).unwrap_or_default() match serde_json::from_str(&raw) {
Ok(parsed) => parsed,
Err(e) => {
log::warn!("Failed to parse keyboard shortcut config: {}", e);
Bindings::default()
}
}
}; };
if let Ok(mut w) = CACHE.write() { match CACHE.write() {
*w = Arc::new(parsed); Ok(mut w) => {
*w = Arc::new(parsed);
}
Err(poison) => {
log::error!("Keyboard shortcut cache write lock is poisoned");
*poison.into_inner() = Arc::new(parsed);
}
} }
} }
/// Snapshot of the currently cached bindings. Cheap (one atomic increment) — /// Snapshot of the currently cached bindings. Cheap (one atomic increment) —
/// safe to call on every keystroke. /// safe to call on every keystroke.
pub fn current() -> Arc<Bindings> { pub fn current() -> Arc<Bindings> {
CACHE match CACHE.read() {
.read() Ok(b) => Arc::clone(&b),
.map(|b| Arc::clone(&b)) Err(poison) => {
.unwrap_or_else(|_| Arc::new(Bindings::default())) log::error!("Keyboard shortcut cache read lock is poisoned");
Arc::clone(&poison.into_inner())
}
}
} }
/// Match an `rdev::Event` against the cached bindings. Returns the matched /// Match an `rdev::Event` against the cached bindings. Returns the matched