| 1 | import { Events } from "@clo/lib/Events.ts"; |
| 2 | import type { ChildProcessWithoutNullStreams } from "node:child_process"; |
| 3 | import { execFile, spawn } from "node:child_process"; |
| 4 | import { mkdir, stat } from "node:fs/promises"; |
| 5 | import { tmpdir } from "node:os"; |
| 6 | import { dirname, join } from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; |
| 8 | import { promisify } from "node:util"; |
| 9 | |
| 10 | const execFileAsync = promisify(execFile); |
| 11 | |
| 12 | const APP_MONITOR_START_TIMEOUT_MS = 1000; |
| 13 | const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/; |
| 14 | const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); |
| 15 | const FRONTMOST_APP_HELPER_SOURCE_PATH = join( |
| 16 | MODULE_DIR, |
| 17 | "Mac/frontmost_app_helper.m", |
| 18 | ); |
| 19 | const HELPER_BUILD_DIR = join(tmpdir(), "meow"); |
| 20 | const FRONTMOST_APP_HELPER_BINARY_PATH = join( |
| 21 | HELPER_BUILD_DIR, |
| 22 | "mac-frontmost-app-helper", |
| 23 | ); |
| 24 | const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m"); |
| 25 | const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper"); |
| 26 | const FRONTMOST_HELPER_READ_ONCE_FLAG = "--once"; |
| 27 | const FRONTMOST_HELPER_FOCUS_WINDOW_FLAG = "--focus-window"; |
| 28 | const FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG = "--focus-main-window"; |
| 29 | const DEFAULT_OBJECTIVE_C_FRAMEWORKS = ["AppKit", "Foundation"] as const; |
| 30 | const FRONTMOST_APP_HELPER_FRAMEWORKS = [ |
| 31 | ...DEFAULT_OBJECTIVE_C_FRAMEWORKS, |
| 32 | "ApplicationServices", |
| 33 | ] as const; |
| 34 | const KEYBOARD_EVENT_SCRIPT = [ |
| 35 | "ObjC.import(\"ApplicationServices\");", |
| 36 | "function run(argv) {", |
| 37 | " const payload = JSON.parse(argv[0] ?? '{}');", |
| 38 | " const actions = Array.isArray(payload.actions) ? payload.actions : [];", |
| 39 | " for (const action of actions) {", |
| 40 | " if (!Number.isInteger(action.keyCode)) {", |
| 41 | " throw new Error(`Invalid key code: ${JSON.stringify(action)}`);", |
| 42 | " }", |
| 43 | " const event = $.CGEventCreateKeyboardEvent(", |
| 44 | " null,", |
| 45 | " action.keyCode,", |
| 46 | " Boolean(action.isDown),", |
| 47 | " );", |
| 48 | " if (!event) {", |
| 49 | " throw new Error(`Failed to create keyboard event for key code ${action.keyCode}`);", |
| 50 | " }", |
| 51 | " $.CGEventPost($.kCGHIDEventTap, event);", |
| 52 | " $.CFRelease(event);", |
| 53 | " }", |
| 54 | " return \"\";", |
| 55 | "}", |
| 56 | ].join("\n"); |
| 57 | let frontmostAppHelperBinaryPromise: Promise<string> | null = null; |
| 58 | let toastHelperBinaryPromise: Promise<string> | null = null; |
| 59 | |
| 60 | // Key codes from Carbon/HIToolbox Events.h (virtual key codes on ANSI/US layouts). |
| 61 | const KEY_CODES = Object.freeze( |
| 62 | { |
| 63 | a: 0x00, |
| 64 | s: 0x01, |
| 65 | d: 0x02, |
| 66 | f: 0x03, |
| 67 | h: 0x04, |
| 68 | g: 0x05, |
| 69 | z: 0x06, |
| 70 | x: 0x07, |
| 71 | c: 0x08, |
| 72 | v: 0x09, |
| 73 | isoSection: 0x0A, |
| 74 | b: 0x0B, |
| 75 | q: 0x0C, |
| 76 | w: 0x0D, |
| 77 | e: 0x0E, |
| 78 | r: 0x0F, |
| 79 | y: 0x10, |
| 80 | t: 0x11, |
| 81 | "1": 0x12, |
| 82 | "2": 0x13, |
| 83 | "3": 0x14, |
| 84 | "4": 0x15, |
| 85 | "6": 0x16, |
| 86 | "5": 0x17, |
| 87 | equal: 0x18, |
| 88 | "9": 0x19, |
| 89 | "7": 0x1A, |
| 90 | minus: 0x1B, |
| 91 | "8": 0x1C, |
| 92 | "0": 0x1D, |
| 93 | rightBracket: 0x1E, |
| 94 | o: 0x1F, |
| 95 | u: 0x20, |
| 96 | leftBracket: 0x21, |
| 97 | i: 0x22, |
| 98 | p: 0x23, |
| 99 | return: 0x24, |
| 100 | l: 0x25, |
| 101 | j: 0x26, |
| 102 | quote: 0x27, |
| 103 | k: 0x28, |
| 104 | semicolon: 0x29, |
| 105 | backslash: 0x2A, |
| 106 | comma: 0x2B, |
| 107 | slash: 0x2C, |
| 108 | n: 0x2D, |
| 109 | m: 0x2E, |
| 110 | period: 0x2F, |
| 111 | tab: 0x30, |
| 112 | space: 0x31, |
| 113 | grave: 0x32, |
| 114 | delete: 0x33, |
| 115 | escape: 0x35, |
| 116 | rightCommand: 0x36, |
| 117 | command: 0x37, |
| 118 | shift: 0x38, |
| 119 | capsLock: 0x39, |
| 120 | option: 0x3A, |
| 121 | control: 0x3B, |
| 122 | rightShift: 0x3C, |
| 123 | rightOption: 0x3D, |
| 124 | rightControl: 0x3E, |
| 125 | function: 0x3F, |
| 126 | f17: 0x40, |
| 127 | numpadDecimal: 0x41, |
| 128 | numpadMultiply: 0x43, |
| 129 | numpadPlus: 0x45, |
| 130 | numpadClear: 0x47, |
| 131 | volumeUp: 0x48, |
| 132 | volumeDown: 0x49, |
| 133 | mute: 0x4A, |
| 134 | numpadDivide: 0x4B, |
| 135 | numpadEnter: 0x4C, |
| 136 | numpadMinus: 0x4E, |
| 137 | f18: 0x4F, |
| 138 | f19: 0x50, |
| 139 | numpadEquals: 0x51, |
| 140 | numpad0: 0x52, |
| 141 | numpad1: 0x53, |
| 142 | numpad2: 0x54, |
| 143 | numpad3: 0x55, |
| 144 | numpad4: 0x56, |
| 145 | numpad5: 0x57, |
| 146 | numpad6: 0x58, |
| 147 | numpad7: 0x59, |
| 148 | f20: 0x5A, |
| 149 | numpad8: 0x5B, |
| 150 | numpad9: 0x5C, |
| 151 | jisYen: 0x5D, |
| 152 | jisUnderscore: 0x5E, |
| 153 | jisKeypadComma: 0x5F, |
| 154 | f5: 0x60, |
| 155 | f6: 0x61, |
| 156 | f7: 0x62, |
| 157 | f3: 0x63, |
| 158 | f8: 0x64, |
| 159 | f9: 0x65, |
| 160 | jisEisu: 0x66, |
| 161 | f11: 0x67, |
| 162 | jisKana: 0x68, |
| 163 | f13: 0x69, |
| 164 | f16: 0x6A, |
| 165 | f14: 0x6B, |
| 166 | f10: 0x6D, |
| 167 | f12: 0x6F, |
| 168 | f15: 0x71, |
| 169 | help: 0x72, |
| 170 | home: 0x73, |
| 171 | pageUp: 0x74, |
| 172 | forwardDelete: 0x75, |
| 173 | f4: 0x76, |
| 174 | end: 0x77, |
| 175 | f2: 0x78, |
| 176 | pageDown: 0x79, |
| 177 | f1: 0x7A, |
| 178 | leftArrow: 0x7B, |
| 179 | rightArrow: 0x7C, |
| 180 | downArrow: 0x7D, |
| 181 | upArrow: 0x7E, |
| 182 | } as const, |
| 183 | ); |
| 184 | type MacKeyName = keyof typeof KEY_CODES; |
| 185 | |
| 186 | const KEY_NAMES = Object.freeze(Object.keys(KEY_CODES) as MacKeyName[]); |
| 187 | const KEY_CODE_LOOKUP = createKeyCodeLookup(KEY_CODES, { |
| 188 | backslash: ["\\"], |
| 189 | capsLock: ["caps"], |
| 190 | comma: [","], |
| 191 | command: ["cmd", "leftCommand", "leftCmd", "meta", "super"], |
| 192 | control: ["ctrl", "leftControl", "leftCtrl"], |
| 193 | delete: ["backspace"], |
| 194 | downArrow: ["down", "arrowDown"], |
| 195 | equal: ["="], |
| 196 | escape: ["esc"], |
| 197 | forwardDelete: ["deleteForward", "forwardDel"], |
| 198 | function: ["fn"], |
| 199 | grave: ["`", "backtick"], |
| 200 | leftArrow: ["left", "arrowLeft"], |
| 201 | leftBracket: ["[", "openBracket"], |
| 202 | minus: ["-"], |
| 203 | numpadClear: ["keypadClear"], |
| 204 | numpadDecimal: [ |
| 205 | "keypadDecimal", |
| 206 | "numpadDot", |
| 207 | "keypadDot", |
| 208 | "numpadPeriod", |
| 209 | "keypadPeriod", |
| 210 | ], |
| 211 | numpadDivide: ["keypadDivide", "keypadSlash"], |
| 212 | numpadEnter: ["keypadEnter", "keypadReturn"], |
| 213 | numpadEquals: ["keypadEquals"], |
| 214 | numpadMinus: ["keypadMinus"], |
| 215 | numpadMultiply: ["keypadMultiply", "keypadAsterisk"], |
| 216 | numpadPlus: ["keypadPlus"], |
| 217 | option: ["alt", "opt", "leftOption", "leftAlt"], |
| 218 | pageDown: ["pgdn"], |
| 219 | pageUp: ["pgup"], |
| 220 | period: ["."], |
| 221 | quote: ["'", "apostrophe"], |
| 222 | return: ["enter", "mainEnter"], |
| 223 | rightArrow: ["right", "arrowRight"], |
| 224 | rightBracket: ["]", "closeBracket"], |
| 225 | rightCommand: ["rightCmd"], |
| 226 | rightControl: ["rightCtrl"], |
| 227 | rightOption: ["rightAlt"], |
| 228 | rightShift: ["rightShift"], |
| 229 | semicolon: [";"], |
| 230 | shift: ["leftShift"], |
| 231 | slash: ["/"], |
| 232 | space: ["spacebar"], |
| 233 | upArrow: ["up", "arrowUp"], |
| 234 | }); |
| 235 | |
| 236 | type KeyboardAction = { |
| 237 | keyCode: number; |
| 238 | isDown: boolean; |
| 239 | }; |
| 240 | |
| 241 | type FrontmostState = { |
| 242 | bundleId: string | null; |
| 243 | windows: readonly Mac.Window[]; |
| 244 | }; |
| 245 | |
| 246 | const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]); |
| 247 | |
| 248 | export class Mac extends Events<Mac.EventMap> { |
| 249 | static readonly keyCodes = KEY_CODES; |
| 250 | static readonly keyNames = KEY_NAMES; |
| 251 | |
| 252 | #closed = false; |
| 253 | #started = false; |
| 254 | #appMonitor: ChildProcessWithoutNullStreams | null = null; |
| 255 | #appMonitorBuffer = ""; |
| 256 | #appMonitorStartup: Promise<void> | null = null; |
| 257 | #currentApp: string | null = null; |
| 258 | #windows: readonly Mac.Window[] = EMPTY_WINDOWS; |
| 259 | #keyboardQueue: Promise<void> = Promise.resolve(); |
| 260 | |
| 261 | private constructor(_options: Mac.Options = {}) { |
| 262 | super(); |
| 263 | } |
| 264 | |
| 265 | static async open(options: Mac.Options = {}) { |
| 266 | const mac = new Mac(options); |
| 267 | await mac.start(); |
| 268 | return mac; |
| 269 | } |
| 270 | |
| 271 | static resolveKeyCode(key: Mac.Key) { |
| 272 | return resolveKeyCode(key); |
| 273 | } |
| 274 | |
| 275 | get currentApp(): string | null { |
| 276 | return this.#currentApp; |
| 277 | } |
| 278 | |
| 279 | get windows(): readonly Mac.Window[] { |
| 280 | return this.#windows; |
| 281 | } |
| 282 | |
| 283 | get window(): Mac.Window | null { |
| 284 | return getFocusedWindow(this.#windows); |
| 285 | } |
| 286 | |
| 287 | get mainWindow(): Mac.Window | null { |
| 288 | return getMainWindow(this.#windows); |
| 289 | } |
| 290 | |
| 291 | async start() { |
| 292 | if (this.#closed) { |
| 293 | throw new Error("Cannot start a closed Mac instance"); |
| 294 | } |
| 295 | if (this.#started) { |
| 296 | return this.#currentApp; |
| 297 | } |
| 298 | |
| 299 | this.#started = true; |
| 300 | try { |
| 301 | await this.#startAppMonitor(); |
| 302 | } catch (error) { |
| 303 | this.#started = false; |
| 304 | throw error; |
| 305 | } |
| 306 | |
| 307 | return this.#currentApp; |
| 308 | } |
| 309 | |
| 310 | close() { |
| 311 | if (this.#closed) { |
| 312 | return; |
| 313 | } |
| 314 | |
| 315 | this.#closed = true; |
| 316 | this.#started = false; |
| 317 | this.#stopAppMonitor(); |
| 318 | |
| 319 | this.emit("close"); |
| 320 | } |
| 321 | |
| 322 | async focusApp(bundleId: string) { |
| 323 | this.#assertOpen("Cannot focus an app from a closed Mac instance"); |
| 324 | |
| 325 | await execFileAsync("/usr/bin/open", ["-b", bundleId]); |
| 326 | await this.#syncFrontmostState(); |
| 327 | } |
| 328 | |
| 329 | async focusWindow(window: Mac.Window | number) { |
| 330 | this.#assertOpen("Cannot focus a window from a closed Mac instance"); |
| 331 | |
| 332 | const helperPath = await ensureFrontmostAppHelperBinary(); |
| 333 | const windowId = resolveWindowId(window); |
| 334 | try { |
| 335 | await execFileAsync(helperPath, [ |
| 336 | FRONTMOST_HELPER_FOCUS_WINDOW_FLAG, |
| 337 | String(windowId), |
| 338 | ]); |
| 339 | } catch (error) { |
| 340 | throw new Error(formatWindowFocusError(error, `window ${windowId}`)); |
| 341 | } |
| 342 | |
| 343 | await this.#syncFrontmostState(); |
| 344 | } |
| 345 | |
| 346 | async focusMainWindow() { |
| 347 | this.#assertOpen("Cannot focus the main window from a closed Mac instance"); |
| 348 | |
| 349 | const helperPath = await ensureFrontmostAppHelperBinary(); |
| 350 | try { |
| 351 | await execFileAsync(helperPath, [ |
| 352 | FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG, |
| 353 | ]); |
| 354 | } catch (error) { |
| 355 | throw new Error(formatWindowFocusError(error, "the main window")); |
| 356 | } |
| 357 | |
| 358 | await this.#syncFrontmostState(); |
| 359 | } |
| 360 | |
| 361 | async pressKey(key: Mac.Key, options: Mac.KeyPressOptions = {}) { |
| 362 | const keyCode = resolveKeyCode(key); |
| 363 | const modifierCodes = uniqueKeyCodes(options.modifiers ?? []); |
| 364 | const holdMs = normalizeDelayMs(options.holdMs ?? 0, "holdMs"); |
| 365 | const keyDownActions = [ |
| 366 | ...modifierCodes.map((modifierKeyCode) => ({ |
| 367 | keyCode: modifierKeyCode, |
| 368 | isDown: true, |
| 369 | })), |
| 370 | { keyCode, isDown: true }, |
| 371 | ]; |
| 372 | const keyUpActions = [ |
| 373 | { keyCode, isDown: false }, |
| 374 | ...modifierCodes |
| 375 | .slice() |
| 376 | .reverse() |
| 377 | .map((modifierKeyCode) => ({ |
| 378 | keyCode: modifierKeyCode, |
| 379 | isDown: false, |
| 380 | })), |
| 381 | ]; |
| 382 | |
| 383 | return this.#enqueueKeyboardOperation(async () => { |
| 384 | this.#assertOpen("Cannot send key events from a closed Mac instance"); |
| 385 | await this.#runKeyboardActions(keyDownActions); |
| 386 | try { |
| 387 | if (holdMs > 0) { |
| 388 | await sleep(holdMs); |
| 389 | } |
| 390 | } finally { |
| 391 | await this.#runKeyboardActions(keyUpActions); |
| 392 | } |
| 393 | }); |
| 394 | } |
| 395 | |
| 396 | async keyDown(key: Mac.Key) { |
| 397 | const keyCode = resolveKeyCode(key); |
| 398 | return this.#enqueueKeyboardOperation(async () => { |
| 399 | this.#assertOpen("Cannot send key events from a closed Mac instance"); |
| 400 | await this.#runKeyboardActions([{ keyCode, isDown: true }]); |
| 401 | }); |
| 402 | } |
| 403 | |
| 404 | async keyUp(key: Mac.Key) { |
| 405 | const keyCode = resolveKeyCode(key); |
| 406 | return this.#enqueueKeyboardOperation(async () => { |
| 407 | this.#assertOpen("Cannot send key events from a closed Mac instance"); |
| 408 | await this.#runKeyboardActions([{ keyCode, isDown: false }]); |
| 409 | }); |
| 410 | } |
| 411 | |
| 412 | toast(message: string, options: Mac.ToastOptions = {}) { |
| 413 | this.#assertOpen("Cannot show a toast from a closed Mac instance"); |
| 414 | void dispatchToast(message, options).catch((error) => { |
| 415 | queueMicrotask(() => { |
| 416 | this.emit("error", error); |
| 417 | }); |
| 418 | }); |
| 419 | } |
| 420 | |
| 421 | async #startAppMonitor() { |
| 422 | if (this.#appMonitor) { |
| 423 | return; |
| 424 | } |
| 425 | if (this.#appMonitorStartup) { |
| 426 | return this.#appMonitorStartup; |
| 427 | } |
| 428 | |
| 429 | this.#appMonitorStartup = this.#spawnAppMonitor().finally(() => { |
| 430 | this.#appMonitorStartup = null; |
| 431 | }); |
| 432 | |
| 433 | return this.#appMonitorStartup; |
| 434 | } |
| 435 | |
| 436 | #stopAppMonitor() { |
| 437 | const appMonitor = this.#appMonitor; |
| 438 | this.#appMonitor = null; |
| 439 | this.#appMonitorBuffer = ""; |
| 440 | if (!appMonitor) { |
| 441 | return; |
| 442 | } |
| 443 | |
| 444 | appMonitor.removeAllListeners(); |
| 445 | appMonitor.stdout.removeAllListeners(); |
| 446 | appMonitor.stderr.removeAllListeners(); |
| 447 | appMonitor.kill(); |
| 448 | } |
| 449 | |
| 450 | async #spawnAppMonitor() { |
| 451 | const helperPath = await ensureFrontmostAppHelperBinary(); |
| 452 | |
| 453 | await new Promise<void>((resolve, reject) => { |
| 454 | let stderr = ""; |
| 455 | let startupFinished = false; |
| 456 | let startupSucceeded = false; |
| 457 | |
| 458 | const finishStartup = ( |
| 459 | result: { ok: true } | { ok: false; error: Error }, |
| 460 | ) => { |
| 461 | if (startupFinished) { |
| 462 | return false; |
| 463 | } |
| 464 | startupFinished = true; |
| 465 | clearTimeout(startupTimeout); |
| 466 | if (result.ok) { |
| 467 | startupSucceeded = true; |
| 468 | resolve(); |
| 469 | } else { |
| 470 | reject(result.error); |
| 471 | } |
| 472 | return true; |
| 473 | }; |
| 474 | |
| 475 | const appMonitor = spawn(helperPath, [], { |
| 476 | stdio: ["ignore", "pipe", "pipe"], |
| 477 | }); |
| 478 | |
| 479 | this.#appMonitor = appMonitor; |
| 480 | this.#appMonitorBuffer = ""; |
| 481 | appMonitor.stdout.setEncoding("utf8"); |
| 482 | appMonitor.stderr.setEncoding("utf8"); |
| 483 | |
| 484 | const startupTimeout = setTimeout(() => { |
| 485 | const error = new Error( |
| 486 | "Timed out waiting for the macOS app monitor to start.", |
| 487 | ); |
| 488 | if (finishStartup({ ok: false, error })) { |
| 489 | this.#stopAppMonitor(); |
| 490 | } |
| 491 | }, APP_MONITOR_START_TIMEOUT_MS); |
| 492 | |
| 493 | appMonitor.stdout.on("data", (chunk: string) => { |
| 494 | const sawLine = this.#handleAppMonitorOutput(chunk); |
| 495 | if (sawLine) { |
| 496 | finishStartup({ ok: true }); |
| 497 | } |
| 498 | }); |
| 499 | appMonitor.stderr.on("data", (chunk: string) => { |
| 500 | stderr += chunk; |
| 501 | }); |
| 502 | appMonitor.once("error", (error) => { |
| 503 | this.#appMonitorExited(appMonitor); |
| 504 | const monitorError = formatAppMonitorError( |
| 505 | "The macOS app monitor process failed.", |
| 506 | error, |
| 507 | ); |
| 508 | if ( |
| 509 | !finishStartup({ ok: false, error: monitorError }) && startupSucceeded |
| 510 | ) { |
| 511 | this.#emitMonitorError(monitorError); |
| 512 | } |
| 513 | }); |
| 514 | appMonitor.once("exit", (code, signal) => { |
| 515 | this.#appMonitorExited(appMonitor); |
| 516 | const monitorError = formatAppMonitorError( |
| 517 | formatAppMonitorExitMessage(code, signal), |
| 518 | stderr, |
| 519 | ); |
| 520 | if ( |
| 521 | !finishStartup({ ok: false, error: monitorError }) && startupSucceeded |
| 522 | ) { |
| 523 | this.#emitMonitorError(monitorError); |
| 524 | } |
| 525 | }); |
| 526 | }); |
| 527 | } |
| 528 | |
| 529 | #appMonitorExited(appMonitor: ChildProcessWithoutNullStreams) { |
| 530 | if (this.#appMonitor === appMonitor) { |
| 531 | this.#appMonitor = null; |
| 532 | } |
| 533 | this.#appMonitorBuffer = ""; |
| 534 | } |
| 535 | |
| 536 | #handleAppMonitorOutput(chunk: string) { |
| 537 | this.#appMonitorBuffer += chunk; |
| 538 | let sawLine = false; |
| 539 | |
| 540 | while (true) { |
| 541 | const newlineIndex = this.#appMonitorBuffer.indexOf("\n"); |
| 542 | if (newlineIndex === -1) { |
| 543 | return sawLine; |
| 544 | } |
| 545 | |
| 546 | const line = this.#appMonitorBuffer |
| 547 | .slice(0, newlineIndex) |
| 548 | .replace(/\r$/, ""); |
| 549 | this.#appMonitorBuffer = this.#appMonitorBuffer.slice(newlineIndex + 1); |
| 550 | sawLine = true; |
| 551 | this.#applyFrontmostState(parseFrontmostStateLine(line)); |
| 552 | } |
| 553 | } |
| 554 | |
| 555 | async #syncFrontmostState() { |
| 556 | try { |
| 557 | return this.#applyFrontmostState(await this.#readFrontmostState()); |
| 558 | } catch (error) { |
| 559 | this.#emitMonitorError(error); |
| 560 | return { bundleId: this.#currentApp, windows: this.#windows }; |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | #applyFrontmostState(state: FrontmostState) { |
| 565 | this.#setCurrentApp(state.bundleId); |
| 566 | this.#setWindows(state.windows); |
| 567 | return state; |
| 568 | } |
| 569 | |
| 570 | #setCurrentApp(bundleId: string | null) { |
| 571 | if (this.#closed) { |
| 572 | return bundleId; |
| 573 | } |
| 574 | if (!bundleId) { |
| 575 | this.#currentApp = null; |
| 576 | return bundleId; |
| 577 | } |
| 578 | if (bundleId !== this.#currentApp) { |
| 579 | this.#currentApp = bundleId; |
| 580 | this.emit("app-change", bundleId); |
| 581 | } |
| 582 | return bundleId; |
| 583 | } |
| 584 | |
| 585 | #setWindows(windows: readonly Mac.Window[]) { |
| 586 | if (this.#closed) { |
| 587 | return windows; |
| 588 | } |
| 589 | if (windowsEqual(this.#windows, windows)) { |
| 590 | return windows; |
| 591 | } |
| 592 | |
| 593 | const previousWindow = getFocusedWindow(this.#windows); |
| 594 | this.#windows = windows; |
| 595 | this.emit("windows", windows); |
| 596 | |
| 597 | const nextWindow = getFocusedWindow(windows); |
| 598 | if (!windowEquals(previousWindow, nextWindow)) { |
| 599 | this.emit("window", nextWindow); |
| 600 | } |
| 601 | |
| 602 | return windows; |
| 603 | } |
| 604 | |
| 605 | async #readFrontmostState() { |
| 606 | const helperPath = await ensureFrontmostAppHelperBinary(); |
| 607 | const { stdout } = await execFileAsync(helperPath, [ |
| 608 | FRONTMOST_HELPER_READ_ONCE_FLAG, |
| 609 | ]); |
| 610 | return parseFrontmostState(stdout); |
| 611 | } |
| 612 | |
| 613 | async #enqueueKeyboardOperation<T>(operation: () => Promise<T>) { |
| 614 | const next = this.#keyboardQueue.then(operation, operation); |
| 615 | this.#keyboardQueue = next.then( |
| 616 | () => undefined, |
| 617 | () => undefined, |
| 618 | ); |
| 619 | return next; |
| 620 | } |
| 621 | |
| 622 | async #runKeyboardActions(actions: ReadonlyArray<KeyboardAction>) { |
| 623 | if (actions.length === 0) { |
| 624 | return; |
| 625 | } |
| 626 | |
| 627 | try { |
| 628 | await execFileAsync("/usr/bin/osascript", [ |
| 629 | "-l", |
| 630 | "JavaScript", |
| 631 | "-e", |
| 632 | KEYBOARD_EVENT_SCRIPT, |
| 633 | JSON.stringify({ actions }), |
| 634 | ]); |
| 635 | } catch (error) { |
| 636 | throw new Error(formatKeyboardDispatchError(error)); |
| 637 | } |
| 638 | } |
| 639 | |
| 640 | #assertOpen(message: string) { |
| 641 | if (this.#closed) { |
| 642 | throw new Error(message); |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | #emitMonitorError(error: unknown) { |
| 647 | queueMicrotask(() => { |
| 648 | this.emit("error", error); |
| 649 | }); |
| 650 | } |
| 651 | } |
| 652 | |
| 653 | export declare namespace Mac { |
| 654 | export interface Options { |
| 655 | /** Deprecated: app-change is now event-driven and no longer polls. */ |
| 656 | pollIntervalMs?: number; |
| 657 | } |
| 658 | |
| 659 | export interface KeyCode { |
| 660 | keyCode: number; |
| 661 | } |
| 662 | |
| 663 | export type KeyName = MacKeyName; |
| 664 | export type Key = KeyName | number | KeyCode; |
| 665 | |
| 666 | export interface KeyPressOptions { |
| 667 | holdMs?: number; |
| 668 | modifiers?: readonly Key[]; |
| 669 | } |
| 670 | |
| 671 | export interface ToastOptions { |
| 672 | detail?: string; |
| 673 | durationMs?: number; |
| 674 | } |
| 675 | |
| 676 | export interface Window { |
| 677 | readonly id: number; |
| 678 | readonly title: string; |
| 679 | readonly main: boolean; |
| 680 | readonly focused: boolean; |
| 681 | } |
| 682 | |
| 683 | export type EventMap = { |
| 684 | "app-change": [bundleId: string]; |
| 685 | "close": []; |
| 686 | "error": [error: unknown]; |
| 687 | "window": [window: Window | null]; |
| 688 | "windows": [windows: readonly Window[]]; |
| 689 | }; |
| 690 | |
| 691 | export type BundleId = |
| 692 | | (string & {}) |
| 693 | | "com.apple.Chess" |
| 694 | | "com.apple.MobileSMS" |
| 695 | | "com.apple.Music" |
| 696 | | "com.apple.Notes" |
| 697 | | "com.apple.Preview" |
| 698 | | "com.apple.QuickTimePlayerX" |
| 699 | | "com.apple.Safari" |
| 700 | | "com.apple.finder" |
| 701 | | "com.apple.iCal" |
| 702 | | "com.blackmagic-design.fusion" |
| 703 | | "com.cockos.reaper" |
| 704 | | "com.google.Chrome" |
| 705 | | "com.google.Chrome" |
| 706 | | "com.mitchellh.ghostty" |
| 707 | | "org.mozilla.firefox" |
| 708 | | "org.whispersystems.signal-desktop"; |
| 709 | } |
| 710 | |
| 711 | function parseFrontmostState(stdout: string): FrontmostState { |
| 712 | const line = stdout.split(/\r?\n/u).find((candidate) => candidate.trim() !== ""); |
| 713 | return parseFrontmostStateLine(line ?? ""); |
| 714 | } |
| 715 | |
| 716 | function parseFrontmostStateLine(line: string): FrontmostState { |
| 717 | const trimmed = line.trim(); |
| 718 | if (trimmed === "") { |
| 719 | return { bundleId: null, windows: EMPTY_WINDOWS }; |
| 720 | } |
| 721 | |
| 722 | try { |
| 723 | const parsed = JSON.parse(trimmed); |
| 724 | if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { |
| 725 | const record = parsed as { bundleId?: unknown; windows?: unknown }; |
| 726 | return { |
| 727 | bundleId: normalizeBundleId(record.bundleId), |
| 728 | windows: normalizeWindows(record.windows), |
| 729 | }; |
| 730 | } |
| 731 | } catch { |
| 732 | // Fall back to the older helper output if a stale binary is still running. |
| 733 | } |
| 734 | |
| 735 | return { |
| 736 | bundleId: normalizeBundleId(trimmed), |
| 737 | windows: EMPTY_WINDOWS, |
| 738 | }; |
| 739 | } |
| 740 | |
| 741 | function normalizeBundleId(value: unknown) { |
| 742 | const directBundleId = typeof value === "string" ? value.trim() : ""; |
| 743 | const bundleId = BUNDLE_ID_PATTERN.exec(directBundleId)?.[0] |
| 744 | ?? directBundleId; |
| 745 | if ( |
| 746 | bundleId === "" |
| 747 | || bundleId === "undefined" |
| 748 | || bundleId === "[id nil]" |
| 749 | ) { |
| 750 | return null; |
| 751 | } |
| 752 | return bundleId; |
| 753 | } |
| 754 | |
| 755 | function normalizeWindows(value: unknown): readonly Mac.Window[] { |
| 756 | if (!Array.isArray(value) || value.length === 0) { |
| 757 | return EMPTY_WINDOWS; |
| 758 | } |
| 759 | |
| 760 | const windows: Mac.Window[] = []; |
| 761 | for (const candidate of value) { |
| 762 | const window = normalizeWindow(candidate); |
| 763 | if (window) { |
| 764 | windows.push(window); |
| 765 | } |
| 766 | } |
| 767 | |
| 768 | return windows.length === 0 ? EMPTY_WINDOWS : Object.freeze(windows); |
| 769 | } |
| 770 | |
| 771 | function normalizeWindow(value: unknown): Mac.Window | null { |
| 772 | if (!value || typeof value !== "object" || Array.isArray(value)) { |
| 773 | return null; |
| 774 | } |
| 775 | |
| 776 | const record = value as { |
| 777 | id?: unknown; |
| 778 | title?: unknown; |
| 779 | main?: unknown; |
| 780 | focused?: unknown; |
| 781 | }; |
| 782 | const id = normalizeWindowId(record.id); |
| 783 | if (id === null) { |
| 784 | return null; |
| 785 | } |
| 786 | |
| 787 | return Object.freeze({ |
| 788 | id, |
| 789 | title: typeof record.title === "string" ? record.title : "", |
| 790 | main: Boolean(record.main), |
| 791 | focused: Boolean(record.focused), |
| 792 | }); |
| 793 | } |
| 794 | |
| 795 | function normalizeWindowId(value: unknown) { |
| 796 | if (!Number.isSafeInteger(value)) { |
| 797 | return null; |
| 798 | } |
| 799 | return value; |
| 800 | } |
| 801 | |
| 802 | function resolveWindowId(window: Mac.Window | number) { |
| 803 | const windowId = typeof window === "number" ? window : window?.id; |
| 804 | const normalizedId = normalizeWindowId(windowId); |
| 805 | if (normalizedId === null) { |
| 806 | throw new Error( |
| 807 | "Mac.focusWindow expects a window object returned by mac.windows or a numeric window id.", |
| 808 | ); |
| 809 | } |
| 810 | return normalizedId; |
| 811 | } |
| 812 | |
| 813 | function getFocusedWindow(windows: readonly Mac.Window[]) { |
| 814 | return windows.find((window) => window.focused) ?? null; |
| 815 | } |
| 816 | |
| 817 | function getMainWindow(windows: readonly Mac.Window[]) { |
| 818 | return windows.find((window) => window.main) ?? null; |
| 819 | } |
| 820 | |
| 821 | function windowsEqual( |
| 822 | left: readonly Mac.Window[], |
| 823 | right: readonly Mac.Window[], |
| 824 | ) { |
| 825 | if (left === right) { |
| 826 | return true; |
| 827 | } |
| 828 | if (left.length !== right.length) { |
| 829 | return false; |
| 830 | } |
| 831 | |
| 832 | for (let index = 0; index < left.length; index += 1) { |
| 833 | if (!windowEquals(left[index], right[index])) { |
| 834 | return false; |
| 835 | } |
| 836 | } |
| 837 | |
| 838 | return true; |
| 839 | } |
| 840 | |
| 841 | function windowEquals(left: Mac.Window | null, right: Mac.Window | null) { |
| 842 | if (left === right) { |
| 843 | return true; |
| 844 | } |
| 845 | if (!left || !right) { |
| 846 | return left === right; |
| 847 | } |
| 848 | return left.id === right.id |
| 849 | && left.title === right.title |
| 850 | && left.main === right.main |
| 851 | && left.focused === right.focused; |
| 852 | } |
| 853 | |
| 854 | function normalizeDelayMs(value: number, name: string) { |
| 855 | if (!Number.isFinite(value) || value < 0) { |
| 856 | throw new Error(`${name} must be a non-negative number`); |
| 857 | } |
| 858 | return Math.round(value); |
| 859 | } |
| 860 | |
| 861 | function normalizeDurationMs( |
| 862 | value: number, |
| 863 | name: string, |
| 864 | min: number, |
| 865 | max: number, |
| 866 | ) { |
| 867 | const rounded = normalizeDelayMs(value, name); |
| 868 | return Math.min(max, Math.max(min, rounded)); |
| 869 | } |
| 870 | |
| 871 | function resolveKeyCode(key: Mac.Key): number { |
| 872 | if (typeof key === "number") { |
| 873 | return normalizeKeyCode(key); |
| 874 | } |
| 875 | |
| 876 | if (typeof key === "string") { |
| 877 | const keyCode = KEY_CODE_LOOKUP.get(normalizeKeyName(key)); |
| 878 | if (keyCode !== undefined) { |
| 879 | return keyCode; |
| 880 | } |
| 881 | throw new Error( |
| 882 | `Unknown Mac key "${key}". Use Mac.keyCodes for named keys or pass a numeric keyCode.`, |
| 883 | ); |
| 884 | } |
| 885 | |
| 886 | if (key && typeof key === "object" && "keyCode" in key) { |
| 887 | return normalizeKeyCode(key.keyCode); |
| 888 | } |
| 889 | |
| 890 | throw new Error(`Unsupported Mac key: ${String(key)}`); |
| 891 | } |
| 892 | |
| 893 | function normalizeKeyCode(keyCode: number) { |
| 894 | if ( |
| 895 | !Number.isInteger(keyCode) |
| 896 | || keyCode < 0 |
| 897 | || keyCode > 0xFFFF |
| 898 | ) { |
| 899 | throw new Error(`Mac keyCode must be an integer between 0 and 65535`); |
| 900 | } |
| 901 | return keyCode; |
| 902 | } |
| 903 | |
| 904 | function uniqueKeyCodes(keys: readonly Mac.Key[]) { |
| 905 | const seen = new Set<number>(); |
| 906 | const keyCodes: number[] = []; |
| 907 | |
| 908 | for (const key of keys) { |
| 909 | const keyCode = resolveKeyCode(key); |
| 910 | if (seen.has(keyCode)) { |
| 911 | continue; |
| 912 | } |
| 913 | seen.add(keyCode); |
| 914 | keyCodes.push(keyCode); |
| 915 | } |
| 916 | |
| 917 | return keyCodes; |
| 918 | } |
| 919 | |
| 920 | function normalizeKeyName(key: string) { |
| 921 | return key.trim().toLowerCase().replace(/[\s_-]+/g, ""); |
| 922 | } |
| 923 | |
| 924 | function createKeyCodeLookup( |
| 925 | keyCodes: Record<string, number>, |
| 926 | aliases: Partial<Record<MacKeyName, readonly string[]>>, |
| 927 | ) { |
| 928 | const lookup = new Map<string, number>(); |
| 929 | |
| 930 | for (const [name, keyCode] of Object.entries(keyCodes)) { |
| 931 | lookup.set(normalizeKeyName(name), keyCode); |
| 932 | if (name.startsWith("numpad")) { |
| 933 | lookup.set( |
| 934 | normalizeKeyName(`keypad${name.slice("numpad".length)}`), |
| 935 | keyCode, |
| 936 | ); |
| 937 | } |
| 938 | } |
| 939 | |
| 940 | for (const [canonicalName, names] of Object.entries(aliases)) { |
| 941 | const keyCode = keyCodes[canonicalName]; |
| 942 | if (keyCode === undefined) { |
| 943 | continue; |
| 944 | } |
| 945 | for (const name of names ?? []) { |
| 946 | lookup.set(normalizeKeyName(name), keyCode); |
| 947 | } |
| 948 | } |
| 949 | |
| 950 | return lookup; |
| 951 | } |
| 952 | |
| 953 | function formatKeyboardDispatchError(error: unknown) { |
| 954 | const details = extractCommandErrorOutput(error); |
| 955 | const suffix = details ? ` ${details}` : ""; |
| 956 | return ( |
| 957 | "Failed to send a macOS keyboard event via osascript. " |
| 958 | + "Make sure this process is allowed in System Settings > Privacy & Security > Accessibility." |
| 959 | + suffix |
| 960 | ); |
| 961 | } |
| 962 | |
| 963 | async function dispatchToast(message: string, options: Mac.ToastOptions) { |
| 964 | const payload = { |
| 965 | message: normalizeToastText(message, "message"), |
| 966 | detail: normalizeToastDetail(options.detail), |
| 967 | durationMs: normalizeDurationMs( |
| 968 | options.durationMs ?? 1000, |
| 969 | "toast durationMs", |
| 970 | 250, |
| 971 | 4000, |
| 972 | ), |
| 973 | }; |
| 974 | |
| 975 | try { |
| 976 | const helperPath = await ensureToastHelperBinary(); |
| 977 | await execFileAsync(helperPath, [ |
| 978 | payload.message, |
| 979 | payload.detail, |
| 980 | String(payload.durationMs / 1000), |
| 981 | ]); |
| 982 | } catch (error) { |
| 983 | throw new Error(formatToastDispatchError(error)); |
| 984 | } |
| 985 | } |
| 986 | |
| 987 | async function ensureToastHelperBinary() { |
| 988 | if (!toastHelperBinaryPromise) { |
| 989 | toastHelperBinaryPromise = buildObjectiveCHelperBinary( |
| 990 | TOAST_HELPER_SOURCE_PATH, |
| 991 | TOAST_HELPER_BINARY_PATH, |
| 992 | DEFAULT_OBJECTIVE_C_FRAMEWORKS, |
| 993 | ).catch((error) => { |
| 994 | toastHelperBinaryPromise = null; |
| 995 | throw error; |
| 996 | }); |
| 997 | } |
| 998 | |
| 999 | return toastHelperBinaryPromise; |
| 1000 | } |
| 1001 | |
| 1002 | async function ensureFrontmostAppHelperBinary() { |
| 1003 | if (!frontmostAppHelperBinaryPromise) { |
| 1004 | frontmostAppHelperBinaryPromise = buildObjectiveCHelperBinary( |
| 1005 | FRONTMOST_APP_HELPER_SOURCE_PATH, |
| 1006 | FRONTMOST_APP_HELPER_BINARY_PATH, |
| 1007 | FRONTMOST_APP_HELPER_FRAMEWORKS, |
| 1008 | ).catch((error) => { |
| 1009 | frontmostAppHelperBinaryPromise = null; |
| 1010 | throw error; |
| 1011 | }); |
| 1012 | } |
| 1013 | |
| 1014 | return frontmostAppHelperBinaryPromise; |
| 1015 | } |
| 1016 | |
| 1017 | async function buildObjectiveCHelperBinary( |
| 1018 | sourcePath: string, |
| 1019 | binaryPath: string, |
| 1020 | frameworks: readonly string[], |
| 1021 | ) { |
| 1022 | await mkdir(HELPER_BUILD_DIR, { recursive: true }); |
| 1023 | |
| 1024 | const [sourceStats, binaryStats] = await Promise.all([ |
| 1025 | stat(sourcePath), |
| 1026 | stat(binaryPath).catch(() => null), |
| 1027 | ]); |
| 1028 | |
| 1029 | if (!binaryStats || binaryStats.mtimeMs < sourceStats.mtimeMs) { |
| 1030 | const args = ["-fobjc-arc"]; |
| 1031 | for (const framework of frameworks) { |
| 1032 | args.push("-framework", framework); |
| 1033 | } |
| 1034 | args.push(sourcePath, "-o", binaryPath); |
| 1035 | await execFileAsync("/usr/bin/clang", args); |
| 1036 | } |
| 1037 | |
| 1038 | return binaryPath; |
| 1039 | } |
| 1040 | |
| 1041 | function extractCommandErrorOutput(error: unknown) { |
| 1042 | if (!error || typeof error !== "object") { |
| 1043 | return ""; |
| 1044 | } |
| 1045 | |
| 1046 | const stdout = "stdout" in error && typeof error.stdout === "string" |
| 1047 | ? error.stdout.trim() |
| 1048 | : ""; |
| 1049 | const stderr = "stderr" in error && typeof error.stderr === "string" |
| 1050 | ? error.stderr.trim() |
| 1051 | : ""; |
| 1052 | const message = error instanceof Error ? error.message.trim() : ""; |
| 1053 | |
| 1054 | return [stderr, stdout, message].find((value) => value !== "") ?? ""; |
| 1055 | } |
| 1056 | |
| 1057 | function formatToastDispatchError(error: unknown) { |
| 1058 | const details = extractCommandErrorOutput(error); |
| 1059 | const suffix = details ? ` ${details}` : ""; |
| 1060 | return `Failed to show a macOS toast.${suffix}`; |
| 1061 | } |
| 1062 | |
| 1063 | function formatWindowFocusError(error: unknown, target: string) { |
| 1064 | const details = extractCommandErrorOutput(error); |
| 1065 | const suffix = details ? ` ${details}` : ""; |
| 1066 | return `Failed to focus ${target}.${suffix}`; |
| 1067 | } |
| 1068 | |
| 1069 | function formatAppMonitorExitMessage( |
| 1070 | code: number | null, |
| 1071 | signal: NodeJS.Signals | null, |
| 1072 | ) { |
| 1073 | if (signal) { |
| 1074 | return `The macOS app monitor stopped after receiving ${signal}.`; |
| 1075 | } |
| 1076 | if (code === null || code === 0) { |
| 1077 | return "The macOS app monitor stopped unexpectedly."; |
| 1078 | } |
| 1079 | return `The macOS app monitor exited with code ${code}.`; |
| 1080 | } |
| 1081 | |
| 1082 | function formatAppMonitorError(summary: string, error: unknown) { |
| 1083 | const details = extractCommandErrorOutput(error); |
| 1084 | const suffix = details ? ` ${details}` : ""; |
| 1085 | return new Error(`${summary}${suffix}`); |
| 1086 | } |
| 1087 | |
| 1088 | function normalizeToastText(value: string, name: string) { |
| 1089 | const text = value.trim(); |
| 1090 | if (text === "") { |
| 1091 | throw new Error(`Mac.toast ${name} must be a non-empty string`); |
| 1092 | } |
| 1093 | return text; |
| 1094 | } |
| 1095 | |
| 1096 | function normalizeToastDetail(detail: string | undefined) { |
| 1097 | if (detail === undefined) { |
| 1098 | return ""; |
| 1099 | } |
| 1100 | return detail.trim(); |
| 1101 | } |
| 1102 | |
| 1103 | function sleep(ms: number) { |
| 1104 | return new Promise<void>((resolve) => { |
| 1105 | setTimeout(resolve, ms); |
| 1106 | }); |
| 1107 | } |