chore: consume shortcut keys on mac

This commit is contained in:
nchopra 2025-12-21 11:02:00 +05:30
parent 10f8f6cb78
commit e769c00f37
14 changed files with 315 additions and 8 deletions

View file

@ -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:

View file

@ -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;

View file

@ -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) {

View file

@ -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<boolean> {
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<E extends keyof NativeBridgeEvents>(
event: E,

View file

@ -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]
}

View file

@ -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(

View file

@ -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<String>()
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
}
}

View file

@ -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(

View file

@ -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");
}

View file

@ -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 }) => {

View file

@ -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 " +

View file

@ -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";

View file

@ -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<typeof SetShortcutsParamsSchema>;
export const SetShortcutsResultSchema = z.object({
success: z.boolean(),
});
export type SetShortcutsResult = z.infer<typeof SetShortcutsResultSchema>;

View file

@ -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({