diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index f9755847b..974e57539 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -1070,11 +1070,11 @@ class InputModel { required bool altPressed, required bool shiftPressed, required bool commandPressed}) { - if (isViewCamera || - !ShortcutModel.isEnabled() || - ShortcutModel.isPassThrough()) return null; + if (isViewCamera) return null; final keyName = physicalKeyName(key); if (keyName == null) return null; + final config = ShortcutModel.config(); + if (!config.enabled || config.passThrough) return null; final mods = []; if (isMacOS || isIOS || isWebOnMacOs) { if (commandPressed) mods.add('primary'); @@ -1084,7 +1084,7 @@ class InputModel { } if (altPressed) mods.add('alt'); if (shiftPressed) mods.add('shift'); - for (final binding in ShortcutModel.readBindings()) { + for (final binding in config.bindings) { final action = binding['action']; final key = binding['key']; final bindingMods = diff --git a/flutter/lib/models/shortcut_model.dart b/flutter/lib/models/shortcut_model.dart index 7745c4d0c..a69ab871e 100644 --- a/flutter/lib/models/shortcut_model.dart +++ b/flutter/lib/models/shortcut_model.dart @@ -99,39 +99,31 @@ class ShortcutModel { })); } - /// Read the bindings JSON from LocalConfig. - static List> readBindings() { + static String _configRaw = ''; + static ShortcutConfig _config = ShortcutConfig.parse(''); + + /// The stored config, parsed once per distinct stored value. The Flutter + /// matcher reads it on every key press, so a key press costs one config + /// read and no JSON parsing while the config is unchanged. + static ShortcutConfig config() { final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); - if (raw.isEmpty) return []; - try { - final parsed = jsonDecode(raw) as Map; - return shortcutBindingMapsFrom(parsed['bindings']); - } catch (_) { - return []; + if (raw != _configRaw) { + _config = ShortcutConfig.parse(raw); + _configRaw = raw; } + return _config; } - static bool isEnabled() { - final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); - if (raw.isEmpty) return false; - try { - final parsed = jsonDecode(raw) as Map; - return parsed['enabled'] == true; - } catch (_) { - return false; - } - } + /// Read the bindings JSON from LocalConfig. Returns a copy the caller may + /// edit. + static List> readBindings() => [ + for (final binding in config().bindings) + Map.of(binding) + ]; - static bool isPassThrough() { - final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey); - if (raw.isEmpty) return false; - try { - final parsed = jsonDecode(raw) as Map; - return parsed['pass_through'] == true; - } catch (_) { - return false; - } - } + static bool isEnabled() => config().enabled; + + static bool isPassThrough() => config().passThrough; /// Persistent companion to [isEnabled]: when on, the matchers return early /// and every keystroke flows through to the remote (i.e. all bindings are @@ -223,6 +215,30 @@ class ShortcutModel { } } +/// One parsed view of the `keyboard-shortcuts` LocalConfig value. Malformed +/// or missing input parses as disabled with no bindings. +class ShortcutConfig { + final bool enabled; + final bool passThrough; + final List> bindings; + + const ShortcutConfig._(this.enabled, this.passThrough, this.bindings); + + static ShortcutConfig parse(String raw) { + if (raw.isEmpty) return const ShortcutConfig._(false, false, []); + try { + final parsed = jsonDecode(raw) as Map; + return ShortcutConfig._( + parsed['enabled'] == true, + parsed['pass_through'] == true, + List.unmodifiable(shortcutBindingMapsFrom(parsed['bindings'])), + ); + } catch (_) { + return const ShortcutConfig._(false, 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 diff --git a/flutter/test/keyboard_shortcuts_test.dart b/flutter/test/keyboard_shortcuts_test.dart index 373a76a2e..094deb785 100644 --- a/flutter/test/keyboard_shortcuts_test.dart +++ b/flutter/test/keyboard_shortcuts_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_actions.dart'; import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_constants.dart'; import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_utils.dart'; +import 'package:flutter_hbb/models/shortcut_model.dart'; ShortcutPlatformCapabilities capabilities({ bool includeFullscreenShortcut = true, @@ -463,6 +464,29 @@ void main() { isFalse); }); + test('ShortcutConfig.parse reads the flags and bindings once', () { + final config = ShortcutConfig.parse(jsonEncode({ + 'enabled': true, + 'pass_through': false, + 'bindings': [ + {'action': 'screenshot', 'mods': ['primary'], 'key': 'p'}, + 'not a binding', + ], + })); + expect(config.enabled, isTrue); + expect(config.passThrough, isFalse); + expect(config.bindings, [ + {'action': 'screenshot', 'mods': ['primary'], 'key': 'p'}, + ]); + + for (final raw in ['', 'not json', '[]', '{"bindings": "x"}']) { + final broken = ShortcutConfig.parse(raw); + expect(broken.enabled, isFalse, reason: raw); + expect(broken.passThrough, isFalse, reason: raw); + expect(broken.bindings, isEmpty, reason: raw); + } + }); + test('non-US layouts record and match the physical key', () { // AZERTY: the key labelled "A" sits where US QWERTY has Q. The native // matcher only sees the physical position (USB HID usage), so the