diff --git a/packages/native-helpers/windows-helper/src/Models/Generated/Models.cs b/packages/native-helpers/windows-helper/src/Models/Generated/Models.cs index 461e058..8322b6d 100644 --- a/packages/native-helpers/windows-helper/src/Models/Generated/Models.cs +++ b/packages/native-helpers/windows-helper/src/Models/Generated/Models.cs @@ -20,6 +20,8 @@ // var muteSystemAudioResult = MuteSystemAudioResult.FromJson(jsonString); // var restoreSystemAudioParams = RestoreSystemAudioParams.FromJson(jsonString); // var restoreSystemAudioResult = RestoreSystemAudioResult.FromJson(jsonString); +// var setShortcutsParams = SetShortcutsParams.FromJson(jsonString); +// var setShortcutsResult = SetShortcutsResult.FromJson(jsonString); // var keyDownEvent = KeyDownEvent.FromJson(jsonString); // var keyUpEvent = KeyUpEvent.FromJson(jsonString); // var flagsChangedEvent = FlagsChangedEvent.FromJson(jsonString); @@ -224,6 +226,21 @@ namespace WindowsHelper.Models public bool Success { get; set; } } + public partial class SetShortcutsParams + { + [JsonPropertyName("pushToTalk")] + public List PushToTalk { get; set; } + + [JsonPropertyName("toggleRecording")] + public List ToggleRecording { get; set; } + } + + public partial class SetShortcutsResult + { + [JsonPropertyName("success")] + public bool Success { get; set; } + } + public partial class KeyDownEvent { [JsonPropertyName("payload")] @@ -510,6 +527,16 @@ namespace WindowsHelper.Models public static RestoreSystemAudioResult FromJson(string json) => JsonSerializer.Deserialize(json, WindowsHelper.Models.Converter.Settings); } + public partial class SetShortcutsParams + { + public static SetShortcutsParams FromJson(string json) => JsonSerializer.Deserialize(json, WindowsHelper.Models.Converter.Settings); + } + + public partial class SetShortcutsResult + { + public static SetShortcutsResult FromJson(string json) => JsonSerializer.Deserialize(json, WindowsHelper.Models.Converter.Settings); + } + public partial class KeyDownEvent { public static KeyDownEvent FromJson(string json) => JsonSerializer.Deserialize(json, WindowsHelper.Models.Converter.Settings); @@ -543,6 +570,8 @@ namespace WindowsHelper.Models public static string ToJson(this object self) => JsonSerializer.Serialize(self, WindowsHelper.Models.Converter.Settings); public static string ToJson(this MuteSystemAudioResult self) => JsonSerializer.Serialize(self, WindowsHelper.Models.Converter.Settings); public static string ToJson(this RestoreSystemAudioResult self) => JsonSerializer.Serialize(self, WindowsHelper.Models.Converter.Settings); + public static string ToJson(this SetShortcutsParams self) => JsonSerializer.Serialize(self, WindowsHelper.Models.Converter.Settings); + public static string ToJson(this SetShortcutsResult self) => JsonSerializer.Serialize(self, WindowsHelper.Models.Converter.Settings); public static string ToJson(this KeyDownEvent self) => JsonSerializer.Serialize(self, WindowsHelper.Models.Converter.Settings); public static string ToJson(this KeyUpEvent self) => JsonSerializer.Serialize(self, WindowsHelper.Models.Converter.Settings); public static string ToJson(this FlagsChangedEvent self) => JsonSerializer.Serialize(self, WindowsHelper.Models.Converter.Settings); diff --git a/packages/native-helpers/windows-helper/src/RpcHandler.cs b/packages/native-helpers/windows-helper/src/RpcHandler.cs index 617ddbe..282c9f1 100644 --- a/packages/native-helpers/windows-helper/src/RpcHandler.cs +++ b/packages/native-helpers/windows-helper/src/RpcHandler.cs @@ -98,7 +98,11 @@ namespace WindowsHelper case Method.RestoreSystemAudio: response = HandleRestoreSystemAudio(request); break; - + + case Method.SetShortcuts: + response = HandleSetShortcuts(request); + break; + default: LogToStderr($"Method not found: {request.Method} for ID: {request.Id}"); response = new RpcResponse @@ -338,6 +342,54 @@ namespace WindowsHelper audioCompletionHandler?.Invoke(requestId); } + private RpcResponse HandleSetShortcuts(RpcRequest request) + { + LogToStderr($"[RpcHandler] Handling setShortcuts for ID: {request.Id}"); + + try + { + var paramsJson = JsonSerializer.Serialize(request.Params, jsonOptions); + var setShortcutsParams = JsonSerializer.Deserialize(paramsJson, jsonOptions); + + if (setShortcutsParams == null) + { + return new RpcResponse + { + Id = request.Id.ToString(), + Error = new Error + { + Code = -32602, + Message = "Invalid params: could not deserialize SetShortcutsParams" + } + }; + } + + ShortcutManager.Instance.SetShortcuts( + setShortcutsParams.PushToTalk?.ToArray() ?? Array.Empty(), + setShortcutsParams.ToggleRecording?.ToArray() ?? Array.Empty() + ); + + return new RpcResponse + { + Id = request.Id.ToString(), + Result = new SetShortcutsResult { Success = true } + }; + } + catch (Exception ex) + { + LogToStderr($"[RpcHandler] Error in setShortcuts: {ex.Message}"); + return new RpcResponse + { + Id = request.Id.ToString(), + Error = new Error + { + Code = -32603, + Message = $"Internal error: {ex.Message}" + } + }; + } + } + private void SendRpcResponse(RpcResponse response) { try diff --git a/packages/native-helpers/windows-helper/src/ShortcutManager.cs b/packages/native-helpers/windows-helper/src/ShortcutManager.cs new file mode 100644 index 0000000..6f8242c --- /dev/null +++ b/packages/native-helpers/windows-helper/src/ShortcutManager.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace WindowsHelper +{ + /// + /// Represents the state of modifier keys at a given moment. + /// + public struct ModifierState + { + public bool Win; + public bool Ctrl; + public bool Alt; + public bool Shift; + } + + /// + /// Manages configured shortcuts and determines if key events should be consumed. + /// Thread-safe singleton that can be updated from RpcHandler (background thread) + /// and queried from ShortcutMonitor hook callback (main thread). + /// Mirrors swift-helper/Sources/SwiftHelper/ShortcutManager.swift + /// + public class ShortcutManager + { + private static readonly Lazy _instance = new(() => new ShortcutManager()); + public static ShortcutManager Instance => _instance.Value; + + private readonly object _lock = new(); + private string[] _pushToTalkKeys = Array.Empty(); + private string[] _toggleRecordingKeys = Array.Empty(); + + private ShortcutManager() { } + + private void LogToStderr(string message) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); + Console.Error.WriteLine($"[{timestamp}] [ShortcutManager] {message}"); + Console.Error.Flush(); + } + + /// + /// Update the configured shortcuts. + /// Called from RpcHandler when setShortcuts RPC is received. + /// + public void SetShortcuts(string[] pushToTalk, string[] toggleRecording) + { + lock (_lock) + { + _pushToTalkKeys = pushToTalk ?? Array.Empty(); + _toggleRecordingKeys = toggleRecording ?? Array.Empty(); + LogToStderr($"Shortcuts updated - PTT: [{string.Join(", ", _pushToTalkKeys)}], Toggle: [{string.Join(", ", _toggleRecordingKeys)}]"); + } + } + + /// + /// Check if this key event should be consumed (prevent default behavior). + /// Called from ShortcutMonitor hook callback for keyDown/keyUp events only. + /// + public bool ShouldConsumeKey(int vkCode, ModifierState modifiers) + { + lock (_lock) + { + // Early exit if no shortcuts configured + if (_pushToTalkKeys.Length == 0 && _toggleRecordingKeys.Length == 0) + { + return false; + } + + // Build set of currently active keys (modifiers + this regular key) + var activeKeys = new HashSet(); + if (modifiers.Win) activeKeys.Add("Win"); + if (modifiers.Ctrl) activeKeys.Add("Ctrl"); + if (modifiers.Alt) activeKeys.Add("Alt"); + if (modifiers.Shift) activeKeys.Add("Shift"); + + // Add the regular key being pressed + var keyName = VirtualKeyMap.GetKeyName(vkCode); + if (keyName != null) + { + activeKeys.Add(keyName); + } + + // PTT: subset match (all PTT keys pressed, possibly with extras) + var pttKeys = new HashSet(_pushToTalkKeys); + var pttMatch = pttKeys.Count > 0 && pttKeys.IsSubsetOf(activeKeys); + + // Toggle: exact match (only these keys pressed) + var toggleKeys = new HashSet(_toggleRecordingKeys); + var toggleMatch = toggleKeys.Count > 0 && toggleKeys.SetEquals(activeKeys); + + return pttMatch || toggleMatch; + } + } + } +} diff --git a/packages/native-helpers/windows-helper/src/ShortcutMonitor.cs b/packages/native-helpers/windows-helper/src/ShortcutMonitor.cs index 4af0e3c..b9138c6 100644 --- a/packages/native-helpers/windows-helper/src/ShortcutMonitor.cs +++ b/packages/native-helpers/windows-helper/src/ShortcutMonitor.cs @@ -328,6 +328,22 @@ namespace WindowsHelper { // Send regular key event KeyEventOccurred?.Invoke(this, keyEvent); + + // Check if this key event should be consumed (prevent default behavior) + // Only for regular key events, not modifiers + var modifierState = new ModifierState + { + Win = winPressed, + Ctrl = ctrlPressed, + Alt = altPressed, + Shift = shiftPressed + }; + + if (ShortcutManager.Instance.ShouldConsumeKey((int)kbStruct.vkCode, modifierState)) + { + // Consume - prevent default behavior (e.g., cursor movement for arrow keys) + return (IntPtr)1; + } } } } diff --git a/packages/native-helpers/windows-helper/src/VirtualKeyMap.cs b/packages/native-helpers/windows-helper/src/VirtualKeyMap.cs new file mode 100644 index 0000000..ab06a76 --- /dev/null +++ b/packages/native-helpers/windows-helper/src/VirtualKeyMap.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; + +namespace WindowsHelper +{ + /// + /// Windows Virtual Key code to key name mapping. + /// Matches the Swift KeycodeMap.swift for cross-platform shortcut consistency. + /// + public static class VirtualKeyMap + { + // Virtual Key code constants + private const int VK_BACK = 0x08; + private const int VK_TAB = 0x09; + private const int VK_RETURN = 0x0D; + private const int VK_ESCAPE = 0x1B; + private const int VK_SPACE = 0x20; + private const int VK_LEFT = 0x25; + private const int VK_UP = 0x26; + private const int VK_RIGHT = 0x27; + private const int VK_DOWN = 0x28; + + // Letters: 0x41-0x5A (A-Z) + // Numbers: 0x30-0x39 (0-9) + // Function keys: 0x70-0x7B (F1-F12) + + private static readonly Dictionary VkCodeToKey = new() + { + // Letters (A-Z: 0x41-0x5A) + { 0x41, "A" }, + { 0x42, "B" }, + { 0x43, "C" }, + { 0x44, "D" }, + { 0x45, "E" }, + { 0x46, "F" }, + { 0x47, "G" }, + { 0x48, "H" }, + { 0x49, "I" }, + { 0x4A, "J" }, + { 0x4B, "K" }, + { 0x4C, "L" }, + { 0x4D, "M" }, + { 0x4E, "N" }, + { 0x4F, "O" }, + { 0x50, "P" }, + { 0x51, "Q" }, + { 0x52, "R" }, + { 0x53, "S" }, + { 0x54, "T" }, + { 0x55, "U" }, + { 0x56, "V" }, + { 0x57, "W" }, + { 0x58, "X" }, + { 0x59, "Y" }, + { 0x5A, "Z" }, + + // Numbers (0-9: 0x30-0x39) + { 0x30, "0" }, + { 0x31, "1" }, + { 0x32, "2" }, + { 0x33, "3" }, + { 0x34, "4" }, + { 0x35, "5" }, + { 0x36, "6" }, + { 0x37, "7" }, + { 0x38, "8" }, + { 0x39, "9" }, + + // Special keys + { VK_TAB, "Tab" }, + { VK_SPACE, "Space" }, + { VK_BACK, "Delete" }, // Backspace = Delete (same as Swift) + { VK_RETURN, "Enter" }, + { VK_ESCAPE, "Escape" }, + + // Function keys (F1-F12: 0x70-0x7B) + { 0x70, "F1" }, + { 0x71, "F2" }, + { 0x72, "F3" }, + { 0x73, "F4" }, + { 0x74, "F5" }, + { 0x75, "F6" }, + { 0x76, "F7" }, + { 0x77, "F8" }, + { 0x78, "F9" }, + { 0x79, "F10" }, + { 0x7A, "F11" }, + { 0x7B, "F12" }, + + // Arrow keys + { VK_LEFT, "Left" }, + { VK_RIGHT, "Right" }, + { VK_DOWN, "Down" }, + { VK_UP, "Up" }, + + // Punctuation and symbols + { 0xBD, "-" }, // VK_OEM_MINUS + { 0xBB, "=" }, // VK_OEM_PLUS (equals sign without shift) + { 0xDB, "[" }, // VK_OEM_4 + { 0xDD, "]" }, // VK_OEM_6 + { 0xDC, "\\" }, // VK_OEM_5 + { 0xBA, ";" }, // VK_OEM_1 + { 0xDE, "'" }, // VK_OEM_7 + { 0xBC, "," }, // VK_OEM_COMMA + { 0xBE, "." }, // VK_OEM_PERIOD + { 0xBF, "/" }, // VK_OEM_2 + { 0xC0, "`" }, // VK_OEM_3 + }; + + /// + /// Convert a Windows Virtual Key code to a key name string. + /// Returns null if the keycode is not mapped. + /// + public static string? GetKeyName(int vkCode) + { + return VkCodeToKey.TryGetValue(vkCode, out var name) ? name : null; + } + } +} diff --git a/packages/types/scripts/generate-csharp-models.ts b/packages/types/scripts/generate-csharp-models.ts index f643464..3c40a77 100644 --- a/packages/types/scripts/generate-csharp-models.ts +++ b/packages/types/scripts/generate-csharp-models.ts @@ -37,6 +37,8 @@ try { "generated/json-schemas/methods/mute-system-audio-result.schema.json " + "generated/json-schemas/methods/restore-system-audio-params.schema.json " + "generated/json-schemas/methods/restore-system-audio-result.schema.json " + + "generated/json-schemas/methods/set-shortcuts-params.schema.json " + + "generated/json-schemas/methods/set-shortcuts-result.schema.json " + "generated/json-schemas/events/key-down-event.schema.json " + "generated/json-schemas/events/key-up-event.schema.json " + "generated/json-schemas/events/flags-changed-event.schema.json " +