chore: consume shortcut keys on mac
This commit is contained in:
parent
10f8f6cb78
commit
e769c00f37
14 changed files with 315 additions and 8 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 " +
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
15
packages/types/src/schemas/methods/set-shortcuts.ts
Normal file
15
packages/types/src/schemas/methods/set-shortcuts.ts
Normal 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>;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue