diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root new file mode 120000 index 0000000..945c9b4 --- /dev/null +++ b/_codeql_detected_source_root @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.cpp b/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.cpp index 681990e..0ac1f4b 100644 --- a/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.cpp +++ b/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.cpp @@ -88,6 +88,38 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress( } } + // Helper function to send modifier key events + auto sendModifierKey = [](UINT vkCode, bool down) { + WORD sc = (WORD)MapVirtualKey(vkCode, MAPVK_VK_TO_VSC); + INPUT in = {0}; + in.type = INPUT_KEYBOARD; + in.ki.wVk = 0; + in.ki.wScan = sc; + in.ki.dwFlags = KEYEVENTF_SCANCODE | (down ? 0 : KEYEVENTF_KEYUP); + SendInput(1, &in, sizeof(INPUT)); + }; + + // Helper function to process modifiers + auto processModifiers = [&sendModifierKey](const std::vector& mods, bool down) { + for (const std::string& modifier : mods) { + if (modifier == "shiftModifier") { + sendModifierKey(VK_SHIFT, down); + } else if (modifier == "controlModifier") { + sendModifierKey(VK_CONTROL, down); + } else if (modifier == "altModifier") { + sendModifierKey(VK_MENU, down); + } else if (modifier == "metaModifier") { + sendModifierKey(VK_LWIN, down); + } + } + }; + + // Press modifier keys first (if keyDown) + if (keyDown) { + processModifiers(modifiers, true); + } + + // Send the main key WORD sc = (WORD)MapVirtualKey(keyCode, MAPVK_VK_TO_VSC); INPUT in = {0}; @@ -102,6 +134,11 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress( } SendInput(1, &in, sizeof(INPUT)); + // Release modifier keys (if keyUp) + if (!keyDown) { + processModifiers(modifiers, false); + } + /*BYTE byteValue = static_cast(keyCode); keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);*/ diff --git a/lib/bluetooth/devices/elite/elite_square.dart b/lib/bluetooth/devices/elite/elite_square.dart index faccf40..0e6d9bf 100644 --- a/lib/bluetooth/devices/elite/elite_square.dart +++ b/lib/bluetooth/devices/elite/elite_square.dart @@ -40,11 +40,11 @@ class EliteSquare extends BluetoothDevice { actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)')); if (_lastValue != null) { - final currentRelevantPart = fullValue.length >= 19 - ? fullValue.substring(6, fullValue.length - 13) + final currentRelevantPart = fullValue.length >= 14 + ? fullValue.substring(6, 14) : fullValue.substring(6); - final lastRelevantPart = _lastValue!.length >= 19 - ? _lastValue!.substring(6, _lastValue!.length - 13) + final lastRelevantPart = _lastValue!.length >= 14 + ? _lastValue!.substring(6, 14) : _lastValue!.substring(6); if (currentRelevantPart != lastRelevantPart) { diff --git a/lib/utils/actions/desktop.dart b/lib/utils/actions/desktop.dart index 444bff8..0f91c9d 100644 --- a/lib/utils/actions/desktop.dart +++ b/lib/utils/actions/desktop.dart @@ -30,14 +30,14 @@ class DesktopActions extends BaseActions { return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue); } else if (keyPair.physicalKey != null) { if (isKeyDown && isKeyUp) { - await keyPressSimulator.simulateKeyDown(keyPair.physicalKey); - await keyPressSimulator.simulateKeyUp(keyPair.physicalKey); + await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers); + await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers); return 'Key clicked: $keyPair'; } else if (isKeyDown) { - await keyPressSimulator.simulateKeyDown(keyPair.physicalKey); + await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers); return 'Key pressed: $keyPair'; } else { - await keyPressSimulator.simulateKeyUp(keyPair.physicalKey); + await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers); return 'Key released: $keyPair'; } } else { diff --git a/lib/utils/keymap/apps/custom_app.dart b/lib/utils/keymap/apps/custom_app.dart index 71d986c..610849e 100644 --- a/lib/utils/keymap/apps/custom_app.dart +++ b/lib/utils/keymap/apps/custom_app.dart @@ -52,6 +52,7 @@ class CustomApp extends SupportedApp { ControllerButton zwiftButton, { required PhysicalKeyboardKey? physicalKey, required LogicalKeyboardKey? logicalKey, + List modifiers = const [], bool isLongPress = false, Offset? touchPosition, InGameAction? inGameAction, @@ -62,6 +63,7 @@ class CustomApp extends SupportedApp { if (keyPair != null) { keyPair.physicalKey = physicalKey; keyPair.logicalKey = logicalKey; + keyPair.modifiers = modifiers; keyPair.isLongPress = isLongPress; keyPair.touchPosition = touchPosition ?? Offset.zero; keyPair.inGameAction = inGameAction; @@ -72,6 +74,7 @@ class CustomApp extends SupportedApp { buttons: [zwiftButton], physicalKey: physicalKey, logicalKey: logicalKey, + modifiers: modifiers, isLongPress: isLongPress, touchPosition: touchPosition ?? Offset.zero, inGameAction: inGameAction, diff --git a/lib/utils/keymap/keymap.dart b/lib/utils/keymap/keymap.dart index 4d6fe69..0ad6a21 100644 --- a/lib/utils/keymap/keymap.dart +++ b/lib/utils/keymap/keymap.dart @@ -66,6 +66,7 @@ class KeyPair { final List buttons; PhysicalKeyboardKey? physicalKey; LogicalKeyboardKey? logicalKey; + List modifiers; Offset touchPosition; bool isLongPress; InGameAction? inGameAction; @@ -75,6 +76,7 @@ class KeyPair { required this.buttons, required this.physicalKey, required this.logicalKey, + this.modifiers = const [], this.touchPosition = Offset.zero, this.isLongPress = false, this.inGameAction, @@ -109,7 +111,7 @@ class KeyPair { @override String toString() { - return logicalKey?.keyLabel ?? + final baseKey = logicalKey?.keyLabel ?? switch (physicalKey) { PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause', PhysicalKeyboardKey.mediaTrackNext => 'Next Track', @@ -119,6 +121,24 @@ class KeyPair { PhysicalKeyboardKey.audioVolumeDown => 'Volume Down', _ => 'Not assigned', }; + + if (modifiers.isEmpty || baseKey == 'Not assigned') { + return baseKey; + } + + // Format modifiers + key (e.g., "Ctrl+Alt+R") + final modifierStrings = modifiers.map((m) { + return switch (m) { + ModifierKey.shiftModifier => 'Shift', + ModifierKey.controlModifier => 'Ctrl', + ModifierKey.altModifier => 'Alt', + ModifierKey.metaModifier => 'Meta', + ModifierKey.functionModifier => 'Fn', + _ => m.name, + }; + }).toList(); + + return '${modifierStrings.join('+')}+$baseKey'; } String encode() { @@ -128,6 +148,7 @@ class KeyPair { 'actions': buttons.map((e) => e.name).toList(), if (logicalKey != null) 'logicalKey': logicalKey?.keyId.toString(), if (physicalKey != null) 'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0', + if (modifiers.isNotEmpty) 'modifiers': modifiers.map((e) => e.name).toList(), if (touchPosition != Offset.zero) 'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy}, 'isLongPress': isLongPress, 'inGameAction': inGameAction?.name, @@ -156,6 +177,15 @@ class KeyPair { if (buttons.isEmpty) { return null; } + + // Decode modifiers if present + final List modifiers = decoded.containsKey('modifiers') + ? (decoded['modifiers'] as List) + .map((e) => ModifierKey.values.firstOrNullWhere((element) => element.name == e)) + .whereType() + .toList() + : []; + return KeyPair( buttons: buttons, logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0 @@ -164,6 +194,7 @@ class KeyPair { physicalKey: decoded.containsKey('physicalKey') && int.parse(decoded['physicalKey']) != 0 ? PhysicalKeyboardKey(int.parse(decoded['physicalKey'])) : null, + modifiers: modifiers, touchPosition: touchPosition, isLongPress: decoded['isLongPress'] ?? false, inGameAction: decoded.containsKey('inGameAction') diff --git a/lib/widgets/custom_keymap_selector.dart b/lib/widgets/custom_keymap_selector.dart index a1f137e..6c7020d 100644 --- a/lib/widgets/custom_keymap_selector.dart +++ b/lib/widgets/custom_keymap_selector.dart @@ -25,6 +25,7 @@ class _HotKeyListenerState extends State { final FocusNode _focusNode = FocusNode(); KeyDownEvent? _pressedKey; ControllerButton? _pressedButton; + final Set _activeModifiers = {}; @override void initState() { @@ -52,20 +53,84 @@ class _HotKeyListenerState extends State { void _onKey(KeyEvent event) { setState(() { + // Track modifier keys if (event is KeyDownEvent) { - _pressedKey = event; - widget.customApp.setKey( - _pressedButton!, - physicalKey: _pressedKey!.physicalKey, - logicalKey: _pressedKey!.logicalKey, - touchPosition: widget.keyPair?.touchPosition, - ); + final wasModifier = _updateModifierState(event.logicalKey, add: true); + // Regular key pressed - record it along with active modifiers + if (!wasModifier) { + _pressedKey = event; + widget.customApp.setKey( + _pressedButton!, + physicalKey: _pressedKey!.physicalKey, + logicalKey: _pressedKey!.logicalKey, + modifiers: _activeModifiers.toList(), + touchPosition: widget.keyPair?.touchPosition, + ); + } + } else if (event is KeyUpEvent) { + // Clear modifier when released + _updateModifierState(event.logicalKey, add: false); } }); } + bool _updateModifierState(LogicalKeyboardKey key, {required bool add}) { + ModifierKey? modifier; + + if (key == LogicalKeyboardKey.shift || + key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight) { + modifier = ModifierKey.shiftModifier; + } else if (key == LogicalKeyboardKey.control || + key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight) { + modifier = ModifierKey.controlModifier; + } else if (key == LogicalKeyboardKey.alt || + key == LogicalKeyboardKey.altLeft || + key == LogicalKeyboardKey.altRight) { + modifier = ModifierKey.altModifier; + } else if (key == LogicalKeyboardKey.meta || + key == LogicalKeyboardKey.metaLeft || + key == LogicalKeyboardKey.metaRight) { + modifier = ModifierKey.metaModifier; + } else if (key == LogicalKeyboardKey.fn) { + modifier = ModifierKey.functionModifier; + } + + if (modifier != null) { + if (add) { + _activeModifiers.add(modifier); + } else { + _activeModifiers.remove(modifier); + } + return true; + } + return false; + } + + String _formatModifierName(ModifierKey m) { + return switch (m) { + ModifierKey.shiftModifier => 'Shift', + ModifierKey.controlModifier => 'Ctrl', + ModifierKey.altModifier => 'Alt', + ModifierKey.metaModifier => 'Meta', + ModifierKey.functionModifier => 'Fn', + _ => m.name, + }; + } + String _formatKey(KeyDownEvent? key) { - return key?.logicalKey.keyLabel ?? 'Waiting...'; + if (key == null) { + return _activeModifiers.isEmpty ? 'Waiting...' : '${_activeModifiers.map(_formatModifierName).join('+')}+...'; + } + + if (_activeModifiers.isEmpty) { + return key.logicalKey.keyLabel; + } + + final modifierStrings = _activeModifiers.map(_formatModifierName); + + return '${modifierStrings.join('+')}+${key.logicalKey.keyLabel}'; } @override diff --git a/test/elite_square_test.dart b/test/elite_square_test.dart new file mode 100644 index 0000000..732fc9c --- /dev/null +++ b/test/elite_square_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_test/flutter_test.dart'; + +// Helper function matching the Elite Square implementation +// Extracts the 8-character button code from positions 6-14 of the hex string +String extractButtonCode(String hexValue) { + if (hexValue.length >= 14) { + return hexValue.substring(6, 14); + } + return hexValue.substring(6); +} + +String bytesToHex(List bytes) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); +} + +void main() { + group('Elite Square Button Detection Tests', () { + test('Should extract correct button code from hex string', () { + // Test with actual dump data + expect(extractButtonCode('030153000000020318f40101'), equals('00000002')); + expect(extractButtonCode('030153000000010318f40101'), equals('00000001')); + expect(extractButtonCode('030153000004000318f40101'), equals('00000400')); + expect(extractButtonCode('030153000001000318f40101'), equals('00000100')); + expect(extractButtonCode('030153000008000318f40101'), equals('00000800')); + expect(extractButtonCode('030153000002000318f40101'), equals('00000200')); + expect(extractButtonCode('030153000000000318f40101'), equals('00000000')); + }); + + test('Should detect button changes correctly', () { + // Test that button code extraction is consistent for comparison + final idleState = '030153000000000318f40101'; + final buttonPressed = '030153000000020318f40101'; + + final idleCode = extractButtonCode(idleState); + final pressedCode = extractButtonCode(buttonPressed); + + expect(idleCode, equals('00000000')); + expect(pressedCode, equals('00000002')); + expect(idleCode != pressedCode, isTrue); + }); + + test('Should handle button release correctly', () { + // Simulate button press and release + final states = [ + '030153000000000318f40101', // idle + '030153000000020318f40101', // button pressed + '030153000000000318f40101', // button released (back to idle) + ]; + + final parts = states.map(extractButtonCode).toList(); + + expect(parts[0], equals('00000000')); // idle + expect(parts[1], equals('00000002')); // pressed + expect(parts[2], equals('00000000')); // released + + // Verify state transitions + expect(parts[0] != parts[1], isTrue); // idle -> pressed + expect(parts[1] != parts[2], isTrue); // pressed -> released + expect(parts[0] == parts[2], isTrue); // back to idle + }); + + test('Should handle all button codes from mapping', () { + // Test all button codes from the mapping + final buttonCodes = { + "00000200": "up", + "00000100": "left", + "00000800": "down", + "00000400": "right", + "00002000": "x", + "00001000": "square", + "00008000": "campagnoloLeft", + "00004000": "leftBrake", + "00000002": "leftShift1", + "00000001": "leftShift2", + "02000000": "y", + "01000000": "a", + "08000000": "b", + "04000000": "z", + "20000000": "circle", + "10000000": "triangle", + "80000000": "campagnoloRight", + "40000000": "rightBrake", + "00020000": "rightShift1", + "00010000": "rightShift2", + }; + + // Verify all button codes are 8 characters + for (final code in buttonCodes.keys) { + expect(code.length, equals(8), reason: 'Button code $code should be 8 characters'); + } + }); + + test('Should convert bytes to hex correctly', () { + // Test with sample data from the dump + // 030153000000020318f40101 = [0x03, 0x01, 0x53, 0x00, 0x00, 0x00, 0x02, 0x03, 0x18, 0xf4, 0x01, 0x01] + final bytes = [0x03, 0x01, 0x53, 0x00, 0x00, 0x00, 0x02, 0x03, 0x18, 0xf4, 0x01, 0x01]; + final hex = bytesToHex(bytes); + + expect(hex, equals('030153000000020318f40101')); + }); + + test('Should handle edge cases', () { + // Test with short strings + expect(extractButtonCode('0123456789'), equals('6789')); + expect(extractButtonCode('01234567'), equals('67')); + + // Test with exact length (14 chars extracts positions 6-14) + expect(extractButtonCode('01234567890123'), equals('67890123')); + + // Test strings shorter than 14 extract from position 6 to end + expect(extractButtonCode('0123456789ABC'), equals('6789ABC')); + }); + }); + + group('Elite Square Protocol Tests', () { + test('Should recognize button press pattern', () { + // According to the dump, the pattern is: + // Base: 030153000000000318f40101 + // Byte positions 6-13 (8 chars) change to indicate button + + final baseHex = '030153000000000318f40101'; + final buttonHex = '030153000000020318f40101'; + + // Extract the button part (positions 6-14) + final baseButton = baseHex.substring(6, 14); + final pressedButton = buttonHex.substring(6, 14); + + expect(baseButton, equals('00000000')); + expect(pressedButton, equals('00000002')); + }); + }); +} diff --git a/test/modifier_keys_test.dart b/test/modifier_keys_test.dart new file mode 100644 index 0000000..b5eff88 --- /dev/null +++ b/test/modifier_keys_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:swift_control/bluetooth/devices/zwift/constants.dart'; +import 'package:swift_control/utils/keymap/keymap.dart'; + +void main() { + group('Modifier Keys KeyPair Tests', () { + test('KeyPair should encode and decode modifiers property', () { + // Create a KeyPair with modifiers + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyR, + logicalKey: LogicalKeyboardKey.keyR, + modifiers: [ModifierKey.controlModifier, ModifierKey.altModifier], + ); + + // Encode the KeyPair + final encoded = keyPair.encode(); + + // Decode the KeyPair + final decoded = KeyPair.decode(encoded); + + // Verify the decoded KeyPair has the correct properties + expect(decoded, isNotNull); + expect(decoded!.modifiers.length, 2); + expect(decoded.modifiers, contains(ModifierKey.controlModifier)); + expect(decoded.modifiers, contains(ModifierKey.altModifier)); + expect(decoded.buttons, equals([ZwiftButtons.a])); + expect(decoded.physicalKey, equals(PhysicalKeyboardKey.keyR)); + expect(decoded.logicalKey, equals(LogicalKeyboardKey.keyR)); + }); + + test('KeyPair should default modifiers to empty list when not specified in decode', () { + // Create a legacy encoded KeyPair without modifiers property + const legacyEncoded = ''' + { + "actions": ["a"], + "logicalKey": "97", + "physicalKey": "458752", + "touchPosition": {"x": 0.0, "y": 0.0}, + "isLongPress": false + } + '''; + + // Decode the legacy KeyPair + final decoded = KeyPair.decode(legacyEncoded); + + // Verify the decoded KeyPair defaults modifiers to empty + expect(decoded, isNotNull); + expect(decoded!.modifiers, isEmpty); + }); + + test('KeyPair constructor should default modifiers to empty list', () { + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + ); + + expect(keyPair.modifiers, isEmpty); + }); + + test('KeyPair should correctly encode empty modifiers', () { + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + modifiers: [], + ); + + final encoded = keyPair.encode(); + final decoded = KeyPair.decode(encoded); + + expect(decoded, isNotNull); + expect(decoded!.modifiers, isEmpty); + }); + + test('KeyPair toString should format modifiers correctly', () { + final keyPairWithCtrlAlt = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyR, + logicalKey: LogicalKeyboardKey.keyR, + modifiers: [ModifierKey.controlModifier, ModifierKey.altModifier], + ); + + final result = keyPairWithCtrlAlt.toString(); + expect(result, contains('Ctrl')); + expect(result, contains('Alt')); + expect(result, contains('R')); + }); + + test('KeyPair toString should handle single modifier', () { + final keyPairWithShift = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + modifiers: [ModifierKey.shiftModifier], + ); + + final result = keyPairWithShift.toString(); + expect(result, contains('Shift')); + expect(result, contains('A')); + }); + + test('KeyPair toString should handle no modifiers', () { + final keyPairNoModifier = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + modifiers: [], + ); + + final result = keyPairNoModifier.toString(); + expect(result, equals('A')); + expect(result, isNot(contains('+'))); + }); + + test('KeyPair should encode and decode all modifier types', () { + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyF, + logicalKey: LogicalKeyboardKey.keyF, + modifiers: [ + ModifierKey.shiftModifier, + ModifierKey.controlModifier, + ModifierKey.altModifier, + ModifierKey.metaModifier, + ], + ); + + final encoded = keyPair.encode(); + final decoded = KeyPair.decode(encoded); + + expect(decoded, isNotNull); + expect(decoded!.modifiers.length, 4); + expect(decoded.modifiers, contains(ModifierKey.shiftModifier)); + expect(decoded.modifiers, contains(ModifierKey.controlModifier)); + expect(decoded.modifiers, contains(ModifierKey.altModifier)); + expect(decoded.modifiers, contains(ModifierKey.metaModifier)); + }); + }); +}