mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-06 22:28:13 +03:00
fix(keyboard): shortcuts, harden config and callback lifecycle
Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -3933,6 +3933,7 @@ class FFI {
|
||||
ffiModel.pi.currentDisplay);
|
||||
}
|
||||
imageModel.callbacksOnFirstImage.clear();
|
||||
shortcutModel.clear();
|
||||
await imageModel.update(null);
|
||||
cursorModel.clear();
|
||||
ffiModel.clear();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user