diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/display.dart b/flutter/lib/common/widgets/keyboard_shortcuts/display.dart index eccd11e0f..b824bacc2 100644 --- a/flutter/lib/common/widgets/keyboard_shortcuts/display.dart +++ b/flutter/lib/common/widgets/keyboard_shortcuts/display.dart @@ -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>(); + final list = shortcutBindingMapsFrom(parsed['bindings']); final found = list.firstWhere( (b) => b['action'] == actionId, orElse: () => {}, diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart b/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart index 885ae3116..4d8083d29 100644 --- a/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart +++ b/flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart @@ -109,9 +109,7 @@ class KeyboardShortcutsPageBodyState extends State { String? clearActionId, }) async { final json = _readJson(); - final list = ((json['bindings'] as List?) ?? []) - .cast>() - .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 { Future _onEdit(KeyboardShortcutActionEntry entry) async { final json = _readJson(); - final bindings = ((json['bindings'] as List?) ?? []) - .cast>(); + final bindings = shortcutBindingMapsFrom(json['bindings']); final result = await showRecordingDialog( context: context, actionId: entry.id, diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart b/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart index 73734f7ae..e4c10eab3 100644 --- a/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart +++ b/flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart @@ -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().toSet(); + final otherMods = shortcutModSetFrom(b['mods']); if (otherKey == _key && otherMods.length == _mods.length && otherMods.containsAll(_mods)) { diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart index f8f561e07..bca8ffa7a 100644 --- a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart +++ b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_actions.dart @@ -229,6 +229,10 @@ List 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; diff --git a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart index 5862ff1d4..775bae0be 100644 --- a/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart +++ b/flutter/lib/common/widgets/keyboard_shortcuts/shortcut_utils.dart @@ -11,6 +11,30 @@ List canonicalShortcutModsForSave(Set mods) { ]; } +List> shortcutBindingMapsFrom(dynamic rawBindings) { + if (rawBindings is! Iterable) return >[]; + final bindings = >[]; + for (final raw in rawBindings) { + if (raw is! Map) continue; + final binding = {}; + 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 shortcutModSetFrom(dynamic rawMods) { + if (rawMods is! Iterable) return {}; + return rawMods.whereType().toSet(); +} + bool isSwitchTabShortcutAction(String? actionId) { return actionId == kShortcutActionSwitchTabNext || actionId == kShortcutActionSwitchTabPrev; @@ -144,9 +168,7 @@ List> filterDefaultBindingsForPlatform( ShortcutPlatformCapabilities cap, ) { final filtered = >[]; - for (final raw in bindings) { - if (raw is! Map) continue; - final binding = Map.from(raw); + for (final binding in shortcutBindingMapsFrom(bindings)) { final action = binding['action'] as String?; if (!cap.includeFullscreenShortcut && action == kShortcutActionToggleFullscreen) { @@ -155,6 +177,10 @@ List> filterDefaultBindingsForPlatform( if (!cap.includeScreenshotShortcut && action == kShortcutActionScreenshot) { continue; } + if (!cap.includeScreenshotShortcut && + action == kShortcutActionToggleRelativeMouseMode) { + continue; + } if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(action)) { continue; } diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 0803485bf..5be1780cd 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -34,6 +34,48 @@ const _kToolbarOwnedActionIds = [ kShortcutActionSendClipboardKeystrokes, ]; +const _kToolbarViewStyleActionIds = [ + kShortcutActionViewModeOriginal, + kShortcutActionViewModeAdaptive, + kShortcutActionViewModeCustom, +]; + +const _kToolbarImageQualityActionIds = [ + kShortcutActionImageQualityBest, + kShortcutActionImageQualityBalanced, + kShortcutActionImageQualityLow, +]; + +const _kToolbarCodecActionIds = [ + kShortcutActionCodecAuto, + kShortcutActionCodecVp8, + kShortcutActionCodecVp9, + kShortcutActionCodecAv1, + kShortcutActionCodecH264, + kShortcutActionCodecH265, +]; + +const _kToolbarCursorActionIds = [ + kShortcutActionToggleShowRemoteCursor, + kShortcutActionToggleFollowRemoteCursor, + kShortcutActionToggleFollowRemoteWindow, + kShortcutActionToggleZoomCursor, +]; + +const _kToolbarDisplayToggleActionIds = [ + kShortcutActionToggleQualityMonitor, + kShortcutActionToggleMute, + kShortcutActionToggleEnableFileCopyPaste, + kShortcutActionToggleDisableClipboard, + kShortcutActionToggleLockAfterSessionEnd, + kShortcutActionToggleTrueColor, +]; + +const _kToolbarKeyboardToggleActionIds = [ + 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 _registerToggleMenuShortcuts( - FFI ffi, List menus) { + FFI ffi, + List menus, { + List 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 _registerToggleMenuShortcuts( /// Radio variant of [_registerToggleMenuShortcuts]. List> _registerRadioMenuShortcuts( - FFI ffi, List> menus) { + FFI ffi, + List> menus, { + List 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>> toolbarViewStyle( groupValue: groupValue, onChanged: onChanged, actionId: kShortcutActionViewModeCustom) - ]); + ], ownedActionIds: _kToolbarViewStyleActionIds); } Future>> toolbarImageQuality( @@ -526,7 +580,7 @@ Future>> toolbarImageQuality( customImageQualityDialog(ffi.sessionId, id, ffi); }, ), - ]); + ], ownedActionIds: _kToolbarImageQualityActionIds); } Future>> toolbarCodec( @@ -553,7 +607,10 @@ Future>> toolbarCodec( } final visible = codecs.length == 4 && (codecs[0] || codecs[1] || codecs[2] || codecs[3]); - if (!visible) return []; + if (!visible) { + return _registerRadioMenuShortcuts(ffi, [], + ownedActionIds: _kToolbarCodecActionIds); + } onChanged(String? value) async { if (value == null) return; await bind.sessionPeerOption( @@ -583,7 +640,7 @@ Future>> 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> toolbarCursor( @@ -701,7 +758,8 @@ Future> toolbarCursor( }, )); } - return _registerToggleMenuShortcuts(ffi, v); + return _registerToggleMenuShortcuts(ffi, v, + ownedActionIds: _kToolbarCursorActionIds); } Future> toolbarDisplayToggle( @@ -868,7 +926,8 @@ Future> 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 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 diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index e3ffa755a..e249cb19a 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -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, + ), + ), ), ), ], diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index ed766cf76..60cfe843a 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -821,22 +821,23 @@ class _SettingsState extends State 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')), diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 72ecdc99d..ccecea19b 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -3933,6 +3933,7 @@ class FFI { ffiModel.pi.currentDisplay); } imageModel.callbacksOnFirstImage.clear(); + shortcutModel.clear(); await imageModel.update(null); cursorModel.clear(); ffiModel.clear(); diff --git a/flutter/lib/models/shortcut_model.dart b/flutter/lib/models/shortcut_model.dart index 3e33575ed..a0dc26267 100644 --- a/flutter/lib/models/shortcut_model.dart +++ b/flutter/lib/models/shortcut_model.dart @@ -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 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 parent; - final Map _callbacks = {}; + final Map _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; - final list = (parsed['bindings'] as List?) ?? []; - return list.cast>(); + 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'] ??= []; + json['bindings'] = list; } await bind.mainSetLocalOption( key: kShortcutLocalConfigKey, value: jsonEncode(json)); diff --git a/src/keyboard/shortcuts.rs b/src/keyboard/shortcuts.rs index 15a33a342..1677062a1 100644 --- a/src/keyboard/shortcuts.rs +++ b/src/keyboard/shortcuts.rs @@ -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 { - 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