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 '../../../consts.dart';
import '../../../models/platform_model.dart';
import 'shortcut_utils.dart';
/// Read the bindings JSON and produce a human-readable shortcut string for
/// `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
// to the remote. Treat as unbound for display purposes.
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(
(b) => b['action'] == actionId,
orElse: () => {},

View File

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

View File

@@ -2,9 +2,9 @@
//
// 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.
// extracts the modifier set + non-modifier key, validates that at least one
// modifier is present, 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
@@ -147,8 +147,7 @@ class _RecordingDialogState extends State<_RecordingDialog> {
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();
final otherMods = shortcutModSetFrom(b['mods']);
if (otherKey == _key &&
otherMods.length == _mods.length &&
otherMods.containsAll(_mods)) {

View File

@@ -229,6 +229,10 @@ List<KeyboardShortcutActionGroup> filterKeyboardShortcutActionGroupsForPlatform(
if (!cap.includeScreenshotShortcut && id == kShortcutActionScreenshot) {
return false;
}
if (!cap.includeScreenshotShortcut &&
id == kShortcutActionToggleRelativeMouseMode) {
return false;
}
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(id)) return false;
if (!cap.includeToolbarShortcut && id == kShortcutActionToggleToolbar) {
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) {
return actionId == kShortcutActionSwitchTabNext ||
actionId == kShortcutActionSwitchTabPrev;
@@ -144,9 +168,7 @@ List<Map<String, dynamic>> filterDefaultBindingsForPlatform(
ShortcutPlatformCapabilities cap,
) {
final filtered = <Map<String, dynamic>>[];
for (final raw in bindings) {
if (raw is! Map) continue;
final binding = Map<String, dynamic>.from(raw);
for (final binding in shortcutBindingMapsFrom(bindings)) {
final action = binding['action'] as String?;
if (!cap.includeFullscreenShortcut &&
action == kShortcutActionToggleFullscreen) {
@@ -155,6 +177,10 @@ List<Map<String, dynamic>> filterDefaultBindingsForPlatform(
if (!cap.includeScreenshotShortcut && action == kShortcutActionScreenshot) {
continue;
}
if (!cap.includeScreenshotShortcut &&
action == kShortcutActionToggleRelativeMouseMode) {
continue;
}
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(action)) {
continue;
}

View File

@@ -34,6 +34,48 @@ const _kToolbarOwnedActionIds = <String>[
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 {
final Widget child;
final VoidCallback? onPressed;
@@ -92,7 +134,13 @@ class TToggleMenu {
/// Register each tagged entry's `onChanged` with the session [ShortcutModel].
/// Passthrough — returns [menus] so a caller can wrap `return [...]` directly.
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) {
final actionId = menu.actionId;
if (actionId == null) continue;
@@ -109,7 +157,13 @@ List<TToggleMenu> _registerToggleMenuShortcuts(
/// Radio variant of [_registerToggleMenuShortcuts].
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) {
final actionId = menu.actionId;
if (actionId == null) continue;
@@ -486,7 +540,7 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
groupValue: groupValue,
onChanged: onChanged,
actionId: kShortcutActionViewModeCustom)
]);
], ownedActionIds: _kToolbarViewStyleActionIds);
}
Future<List<TRadioMenu<String>>> toolbarImageQuality(
@@ -526,7 +580,7 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
customImageQualityDialog(ffi.sessionId, id, ffi);
},
),
]);
], ownedActionIds: _kToolbarImageQualityActionIds);
}
Future<List<TRadioMenu<String>>> toolbarCodec(
@@ -553,7 +607,10 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
}
final visible =
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 {
if (value == null) return;
await bind.sessionPeerOption(
@@ -583,7 +640,7 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
if (codecs[1]) radio('AV1', 'av1', codecs[1], kShortcutActionCodecAv1),
if (codecs[2]) radio('H264', 'h264', codecs[2], kShortcutActionCodecH264),
if (codecs[3]) radio('H265', 'h265', codecs[3], kShortcutActionCodecH265),
]);
], ownedActionIds: _kToolbarCodecActionIds);
}
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(
@@ -868,7 +926,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
},
child: Text(translate('View Mode'))));
}
return _registerToggleMenuShortcuts(ffi, v);
return _registerToggleMenuShortcuts(ffi, v,
ownedActionIds: _kToolbarDisplayToggleActionIds);
}
var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1));
@@ -1037,7 +1096,8 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
onChanged: enabled ? onChanged : null,
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

View File

@@ -774,16 +774,20 @@ class _ControlMenu extends StatelessWidget {
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,
),
Flexible(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
hint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context).hintColor,
),
),
),
),
],

View File

@@ -821,22 +821,23 @@ 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 (!disabledSettings)
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')),

View File

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

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
@@ -15,6 +16,8 @@ import '../models/model.dart';
import '../models/platform_model.dart';
import '../models/state_model.dart';
typedef ShortcutCallback = FutureOr<void> Function();
/// Per-session shortcut dispatcher. Attached to FFI when a session is created.
///
/// 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.
class ShortcutModel {
final WeakReference<FFI> parent;
final Map<String, VoidCallback> _callbacks = {};
final Map<String, ShortcutCallback> _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) {
void register(String actionId, ShortcutCallback callback) {
_callbacks[actionId] = callback;
}
@@ -38,12 +41,19 @@ class ShortcutModel {
_callbacks.remove(actionId);
}
void clear() {
_callbacks.clear();
}
/// 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();
unawaited(Future.sync(cb).catchError((e, st) {
debugPrint(
'shortcut_triggered: handler failed for $actionId: $e\n$st');
}));
} else {
debugPrint('shortcut_triggered: no handler for $actionId');
}
@@ -55,8 +65,7 @@ class ShortcutModel {
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>>();
return shortcutBindingMapsFrom(parsed['bindings']);
} catch (_) {
return [];
}
@@ -120,14 +129,14 @@ class ShortcutModel {
}
}
json['enabled'] = v;
final list = (json['bindings'] as List?) ?? const [];
final list = shortcutBindingMapsFrom(json['bindings']);
if (v && list.isEmpty) {
json['bindings'] = filterDefaultBindingsForPlatform(
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
currentPlatformCapabilities(),
);
} else {
json['bindings'] ??= <dynamic>[];
json['bindings'] = list;
}
await bind.mainSetLocalOption(
key: kShortcutLocalConfigKey, value: jsonEncode(json));

View File

@@ -2,6 +2,7 @@
use std::sync::{Arc, RwLock};
use hbb_common::log;
use serde::{Deserialize, Serialize};
const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts";
@@ -255,20 +256,35 @@ pub fn reload_from_config() {
let parsed = if raw.is_empty() {
Bindings::default()
} 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() {
*w = Arc::new(parsed);
match CACHE.write() {
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) —
/// 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 CACHE.read() {
Ok(b) => Arc::clone(&b),
Err(poison) => {
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