diff --git a/apps/desktop/src/components/shortcut-input.tsx b/apps/desktop/src/components/shortcut-input.tsx index 2f40520..06b5ae7 100644 --- a/apps/desktop/src/components/shortcut-input.tsx +++ b/apps/desktop/src/components/shortcut-input.tsx @@ -36,8 +36,8 @@ function validateShortcut(keys: string[]): ValidationResult { const modifierKeys = keys.filter((key) => MODIFIER_KEYS.includes(key)); const regularKeys = keys.filter((key) => !MODIFIER_KEYS.includes(key)); - // disallow only regular keys - if (modifierKeys.length === 0 && regularKeys.length === 1) { + // Require at least one modifier key + if (modifierKeys.length === 0) { return { valid: false, error: diff --git a/apps/desktop/src/db/app-settings.ts b/apps/desktop/src/db/app-settings.ts index 098d495..6fd3ebb 100644 --- a/apps/desktop/src/db/app-settings.ts +++ b/apps/desktop/src/db/app-settings.ts @@ -24,7 +24,7 @@ import { type NewAppSettings, type AppSettingsData, } from "./schema"; -import { isWindows, isMacOS } from "../utils/platform"; +import { isMacOS } from "../utils/platform"; // Current settings schema version - increment when making breaking changes const CURRENT_SETTINGS_VERSION = 2; diff --git a/apps/desktop/src/main/managers/shortcut-manager.ts b/apps/desktop/src/main/managers/shortcut-manager.ts index 696b644..1addc3f 100644 --- a/apps/desktop/src/main/managers/shortcut-manager.ts +++ b/apps/desktop/src/main/managers/shortcut-manager.ts @@ -37,6 +37,7 @@ export class ShortcutManager extends EventEmitter { async initialize(nativeBridge: NativeBridge | null) { this.nativeBridge = nativeBridge; await this.loadShortcuts(); + this.syncShortcutsToNative(); // fire-and-forget this.setupEventListeners(); } @@ -50,8 +51,31 @@ export class ShortcutManager extends EventEmitter { } } + /** + * Sync the configured shortcuts to the native helper for key consumption. + * This tells the native helper which key combinations to consume + * (prevent default behavior like cursor movement for arrow keys). + */ + private async syncShortcutsToNative() { + if (!this.nativeBridge) { + log.debug("Native bridge not available, skipping shortcut sync"); + return; + } + + try { + await this.nativeBridge.setShortcuts({ + pushToTalk: this.shortcuts.pushToTalk, + toggleRecording: this.shortcuts.toggleRecording, + }); + log.info("Shortcuts synced to native helper"); + } catch (error) { + log.error("Failed to sync shortcuts to native helper", { error }); + } + } + async reloadShortcuts() { await this.loadShortcuts(); + this.syncShortcutsToNative(); // fire-and-forget } setIsRecordingShortcut(isRecording: boolean) { diff --git a/apps/desktop/src/services/platform/native-bridge-service.ts b/apps/desktop/src/services/platform/native-bridge-service.ts index 0bf26db..28ee2e4 100644 --- a/apps/desktop/src/services/platform/native-bridge-service.ts +++ b/apps/desktop/src/services/platform/native-bridge-service.ts @@ -25,6 +25,8 @@ import { MuteSystemAudioResult, RestoreSystemAudioParams, RestoreSystemAudioResult, + SetShortcutsParams, + SetShortcutsResult, } from "@amical/types"; // Define the interface for RPC methods @@ -49,8 +51,10 @@ interface RPCMethods { params: RestoreSystemAudioParams; result: RestoreSystemAudioResult; }; - // Add other methods here, e.g.: - // setLogLevel: { params: SetLogLevelParams; result: SetLogLevelResult }; + setShortcuts: { + params: SetShortcutsParams; + result: SetShortcutsResult; + }; } // Define event types for the client @@ -381,6 +385,28 @@ export class NativeBridge extends EventEmitter { return this.accessibilityContext; } + /** + * Send the configured shortcuts to the native helper for key consumption. + * When these shortcuts are pressed, the native helper will consume the key events + * to prevent default behavior (e.g., cursor movement for arrow keys). + */ + async setShortcuts(shortcuts: SetShortcutsParams): Promise { + try { + const result = await this.call("setShortcuts", shortcuts); + this.logger.info("Shortcuts synced to native helper", { + pushToTalk: shortcuts.pushToTalk, + toggleRecording: shortcuts.toggleRecording, + success: result.success, + }); + return result.success; + } catch (error) { + this.logger.error("Failed to sync shortcuts to native helper", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + // Typed event emitter methods on( event: E, diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/KeycodeMap.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/KeycodeMap.swift new file mode 100644 index 0000000..7803d1e --- /dev/null +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/KeycodeMap.swift @@ -0,0 +1,91 @@ +import Foundation + +/// macOS CGKeyCode to key name mapping +/// Matches the TypeScript keycode-map.ts for consistency +private let macOSKeycodeToKey: [Int: String] = [ + // Letters + 0: "A", + 1: "S", + 2: "D", + 3: "F", + 4: "H", + 5: "G", + 6: "Z", + 7: "X", + 8: "C", + 9: "V", + 11: "B", + 12: "Q", + 13: "W", + 14: "E", + 15: "R", + 16: "Y", + 17: "T", + 31: "O", + 32: "U", + 34: "I", + 35: "P", + 37: "L", + 38: "J", + 40: "K", + 45: "N", + 46: "M", + + // Numbers + 18: "1", + 19: "2", + 20: "3", + 21: "4", + 22: "6", + 23: "5", + 25: "9", + 26: "7", + 28: "8", + 29: "0", + + // Special keys + 48: "Tab", + 49: "Space", + 51: "Delete", + 52: "Enter", + 53: "Escape", + + // Function keys + 122: "F1", + 120: "F2", + 99: "F3", + 118: "F4", + 96: "F5", + 97: "F6", + 98: "F7", + 100: "F8", + 101: "F9", + 109: "F10", + 103: "F11", + 111: "F12", + + // Arrow keys + 123: "Left", + 124: "Right", + 125: "Down", + 126: "Up", + + // Punctuation and symbols + 27: "-", + 24: "=", + 33: "[", + 30: "]", + 42: "\\", + 41: ";", + 39: "'", + 43: ",", + 47: ".", + 44: "/", + 50: "`", +] + +/// Convert a macOS CGKeyCode to a key name string +/// Returns nil if the keycode is not mapped +func keyCodeToName(_ keyCode: Int) -> String? { + return macOSKeycodeToKey[keyCode] +} diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift index eb0ae49..64264a0 100644 --- a/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/RpcHandler.swift @@ -147,6 +147,42 @@ class IOBridge: NSObject { rpcResponse = RPCResponseSchema(error: nil, id: request.id, result: nil) } + case .setShortcuts: + logToStderr("[IOBridge] Handling setShortcuts for ID: \(request.id)") + guard let paramsAnyCodable = request.params else { + let errPayload = Error( + code: -32602, data: nil, message: "Missing params for setShortcuts") + rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil) + sendRpcResponse(rpcResponse) + return + } + + do { + let paramsData = try jsonEncoder.encode(paramsAnyCodable) + let shortcutsParams = try jsonDecoder.decode( + SetShortcutsParamsSchema.self, from: paramsData) + + // Update the ShortcutManager with the new shortcuts + ShortcutManager.shared.setShortcuts( + pushToTalk: shortcutsParams.pushToTalk, + toggleRecording: shortcutsParams.toggleRecording + ) + + let resultPayload = SetShortcutsResultSchema(success: true) + let resultData = try jsonEncoder.encode(resultPayload) + let resultAsJsonAny = try jsonDecoder.decode(JSONAny.self, from: resultData) + rpcResponse = RPCResponseSchema(error: nil, id: request.id, result: resultAsJsonAny) + + } catch { + logToStderr( + "[IOBridge] Error processing setShortcuts params: \(error.localizedDescription) for ID: \(request.id)" + ) + let errPayload = Error( + code: -32602, data: request.params, + message: "Invalid params: \(error.localizedDescription)") + rpcResponse = RPCResponseSchema(error: errPayload, id: request.id, result: nil) + } + default: logToStderr("[IOBridge] Method not found: \(request.method) for ID: \(request.id)") let errPayload = Error( diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/ShortcutManager.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/ShortcutManager.swift new file mode 100644 index 0000000..d6e9def --- /dev/null +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/ShortcutManager.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Represents the state of modifier keys at a given moment +struct ModifierState { + let fn: Bool + let cmd: Bool + let ctrl: Bool + let alt: Bool + let shift: Bool +} + +/// Manages configured shortcuts and determines if key events should be consumed +/// Thread-safe singleton that can be updated from IOBridge (background thread) +/// and queried from event tap callback (main thread) +class ShortcutManager { + static let shared = ShortcutManager() + + private var pushToTalkKeys: [String] = [] + private var toggleRecordingKeys: [String] = [] + private let lock = NSLock() + private let dateFormatter: DateFormatter + + private init() { + self.dateFormatter = DateFormatter() + self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + } + + private func logToStderr(_ message: String) { + let timestamp = dateFormatter.string(from: Date()) + let logMessage = "[\(timestamp)] \(message)\n" + FileHandle.standardError.write(logMessage.data(using: .utf8)!) + } + + /// Update the configured shortcuts + /// Called from IOBridge when setShortcuts RPC is received + func setShortcuts(pushToTalk: [String], toggleRecording: [String]) { + lock.lock() + defer { lock.unlock() } + self.pushToTalkKeys = pushToTalk + self.toggleRecordingKeys = toggleRecording + logToStderr("[ShortcutManager] Shortcuts updated - PTT: \(pushToTalk), Toggle: \(toggleRecording)") + } + + /// Check if this key event should be consumed (prevent default behavior) + /// Called from event tap callback for keyDown/keyUp events only + func shouldConsumeKey(keyCode: Int, modifiers: ModifierState) -> Bool { + lock.lock() + defer { lock.unlock() } + + // Early exit if no shortcuts configured + if pushToTalkKeys.isEmpty && toggleRecordingKeys.isEmpty { + return false + } + + // Build set of currently active keys (modifiers + this regular key) + var activeKeys = Set() + if modifiers.fn { activeKeys.insert("Fn") } + if modifiers.cmd { activeKeys.insert("Cmd") } + if modifiers.ctrl { activeKeys.insert("Ctrl") } + if modifiers.alt { activeKeys.insert("Alt") } + if modifiers.shift { activeKeys.insert("Shift") } + + // Add the regular key being pressed + if let keyName = keyCodeToName(keyCode) { + activeKeys.insert(keyName) + } + + // PTT: subset match (all PTT keys pressed, possibly with extras) + let pttKeys = Set(pushToTalkKeys) + let pttMatch = !pttKeys.isEmpty && pttKeys.isSubset(of: activeKeys) + + // Toggle: exact match (only these keys pressed) + let toggleKeys = Set(toggleRecordingKeys) + let toggleMatch = !toggleKeys.isEmpty && toggleKeys == activeKeys + + return pttMatch || toggleMatch + } +} diff --git a/packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift b/packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift index a526828..3023c59 100644 --- a/packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift +++ b/packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift @@ -61,8 +61,24 @@ func eventTapCallback( ) anInstance.sendKeyEvent(helperEvent) + + // Check if this key event matches a configured shortcut and should be consumed + // Only check for regular key events (not modifier-only events) + let modifiers = ModifierState( + fn: event.flags.contains(.maskSecondaryFn), + cmd: event.flags.contains(.maskCommand), + ctrl: event.flags.contains(.maskControl), + alt: event.flags.contains(.maskAlternate), + shift: event.flags.contains(.maskShift) + ) + + if ShortcutManager.shared.shouldConsumeKey(keyCode: Int(keyCode), modifiers: modifiers) { + // CONSUME - prevent default behavior (e.g., cursor movement for arrow keys) + return nil + } } else if type == .flagsChanged { // Handle flags changed events (like Fn key press/release) + // Modifier-only events always pass through - they don't cause unwanted behavior on their own let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let payload = KeyEventPayload( 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 567de71..461e058 100644 --- a/packages/native-helpers/windows-helper/src/Models/Generated/Models.cs +++ b/packages/native-helpers/windows-helper/src/Models/Generated/Models.cs @@ -440,7 +440,7 @@ namespace WindowsHelper.Models public bool? ShiftKey { get; set; } } - public enum Method { GetAccessibilityContext, GetAccessibilityTreeDetails, MuteSystemAudio, PasteText, RestoreSystemAudio }; + public enum Method { GetAccessibilityContext, GetAccessibilityTreeDetails, MuteSystemAudio, PasteText, RestoreSystemAudio, SetShortcuts }; public enum KeyDownEventType { KeyDown }; @@ -586,6 +586,8 @@ namespace WindowsHelper.Models return Method.PasteText; case "restoreSystemAudio": return Method.RestoreSystemAudio; + case "setShortcuts": + return Method.SetShortcuts; } throw new Exception("Cannot unmarshal type Method"); } @@ -609,6 +611,9 @@ namespace WindowsHelper.Models case Method.RestoreSystemAudio: JsonSerializer.Serialize(writer, "restoreSystemAudio", options); return; + case Method.SetShortcuts: + JsonSerializer.Serialize(writer, "setShortcuts", options); + return; } throw new Exception("Cannot marshal type Method"); } diff --git a/packages/types/scripts/generate-json-schemas.ts b/packages/types/scripts/generate-json-schemas.ts index 58895da..1812968 100644 --- a/packages/types/scripts/generate-json-schemas.ts +++ b/packages/types/scripts/generate-json-schemas.ts @@ -25,6 +25,10 @@ import { RestoreSystemAudioParamsSchema, RestoreSystemAudioResultSchema, } from "../src/schemas/methods/restore-system-audio.js"; +import { + SetShortcutsParamsSchema, + SetShortcutsResultSchema, +} from "../src/schemas/methods/set-shortcuts.js"; import { KeyDownEventSchema, KeyUpEventSchema, @@ -88,6 +92,16 @@ const schemasToGenerate = [ name: "MuteSystemAudioResult", category: "methods", }, + { + zod: SetShortcutsParamsSchema, + name: "SetShortcutsParams", + category: "methods", + }, + { + zod: SetShortcutsResultSchema, + name: "SetShortcutsResult", + category: "methods", + }, ]; schemasToGenerate.forEach(({ zod, name, category }) => { diff --git a/packages/types/scripts/generate-swift-models.ts b/packages/types/scripts/generate-swift-models.ts index 680670b..a30b581 100644 --- a/packages/types/scripts/generate-swift-models.ts +++ b/packages/types/scripts/generate-swift-models.ts @@ -1,6 +1,5 @@ import { execSync } from "child_process"; import fs from "fs"; -import path from "path"; const generatedDir = "../native-helpers/swift-helper/Sources/SwiftHelper/models/generated"; @@ -30,6 +29,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 " + diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 201c428..b086d6d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,6 +8,7 @@ export * from "./schemas/methods/get-accessibility-context.js"; export * from "./schemas/methods/paste-text.js"; export * from "./schemas/methods/mute-system-audio.js"; export * from "./schemas/methods/restore-system-audio.js"; +export * from "./schemas/methods/set-shortcuts.js"; // Event Schemas export * from "./schemas/events/key-events.js"; diff --git a/packages/types/src/schemas/methods/set-shortcuts.ts b/packages/types/src/schemas/methods/set-shortcuts.ts new file mode 100644 index 0000000..fa9ae80 --- /dev/null +++ b/packages/types/src/schemas/methods/set-shortcuts.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +// Schema for setShortcuts RPC method +// Used to sync configured shortcuts to the native helper for event consumption + +export const SetShortcutsParamsSchema = z.object({ + pushToTalk: z.array(z.string()), + toggleRecording: z.array(z.string()), +}); +export type SetShortcutsParams = z.infer; + +export const SetShortcutsResultSchema = z.object({ + success: z.boolean(), +}); +export type SetShortcutsResult = z.infer; diff --git a/packages/types/src/schemas/rpc/request.ts b/packages/types/src/schemas/rpc/request.ts index 0a4e735..c08f76f 100644 --- a/packages/types/src/schemas/rpc/request.ts +++ b/packages/types/src/schemas/rpc/request.ts @@ -10,7 +10,7 @@ const RPCMethodNameSchema = z.union([ z.literal("pasteText"), z.literal("muteSystemAudio"), z.literal("restoreSystemAudio"), - // Add other method names here + z.literal("setShortcuts"), ]); export const RpcRequestSchema = z.object({