1import { Events } from "@clo/lib/Events.ts";
2import type { ChildProcessWithoutNullStreams } from "node:child_process";
3import { execFile, spawn } from "node:child_process";
4import { mkdir, stat } from "node:fs/promises";
5import { tmpdir } from "node:os";
6import { dirname, join } from "node:path";
7import { fileURLToPath } from "node:url";
8import { promisify } from "node:util";
9
10const execFileAsync = promisify(execFile);
11
12const APP_MONITOR_START_TIMEOUT_MS = 1000;
13const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/;
14const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
15const FRONTMOST_APP_HELPER_SOURCE_PATH = join(
16 MODULE_DIR,
17 "Mac/frontmost_app_helper.m",
18);
19const HELPER_BUILD_DIR = join(tmpdir(), "meow");
20const FRONTMOST_APP_HELPER_BINARY_PATH = join(
21 HELPER_BUILD_DIR,
22 "mac-frontmost-app-helper",
23);
24const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m");
25const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper");
26const FRONTMOST_HELPER_READ_ONCE_FLAG = "--once";
27const FRONTMOST_HELPER_FOCUS_WINDOW_FLAG = "--focus-window";
28const FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG = "--focus-main-window";
29const DEFAULT_OBJECTIVE_C_FRAMEWORKS = ["AppKit", "Foundation"] as const;
30const FRONTMOST_APP_HELPER_FRAMEWORKS = [
31 ...DEFAULT_OBJECTIVE_C_FRAMEWORKS,
32 "ApplicationServices",
33] as const;
34const 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");
57let frontmostAppHelperBinaryPromise: Promise<string> | null = null;
58let toastHelperBinaryPromise: Promise<string> | null = null;
59
60// Key codes from Carbon/HIToolbox Events.h (virtual key codes on ANSI/US layouts).
61const 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);
184type MacKeyName = keyof typeof KEY_CODES;
185
186const KEY_NAMES = Object.freeze(Object.keys(KEY_CODES) as MacKeyName[]);
187const 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
236type KeyboardAction = {
237 keyCode: number;
238 isDown: boolean;
239};
240
241type FrontmostState = {
242 bundleId: string | null;
243 windows: readonly Mac.Window[];
244};
245
246const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]);
247
248export 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
653export 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
711function parseFrontmostState(stdout: string): FrontmostState {
712 const line = stdout.split(/\r?\n/u).find((candidate) => candidate.trim() !== "");
713 return parseFrontmostStateLine(line ?? "");
714}
715
716function 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
741function 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
755function 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
771function 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
795function normalizeWindowId(value: unknown) {
796 if (!Number.isSafeInteger(value)) {
797 return null;
798 }
799 return value;
800}
801
802function 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
813function getFocusedWindow(windows: readonly Mac.Window[]) {
814 return windows.find((window) => window.focused) ?? null;
815}
816
817function getMainWindow(windows: readonly Mac.Window[]) {
818 return windows.find((window) => window.main) ?? null;
819}
820
821function 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
841function 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
854function 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
861function 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
871function 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
893function 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
904function 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
920function normalizeKeyName(key: string) {
921 return key.trim().toLowerCase().replace(/[\s_-]+/g, "");
922}
923
924function 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
953function 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
963async 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
987async 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
1002async 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
1017async 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
1041function 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
1057function formatToastDispatchError(error: unknown) {
1058 const details = extractCommandErrorOutput(error);
1059 const suffix = details ? ` ${details}` : "";
1060 return `Failed to show a macOS toast.${suffix}`;
1061}
1062
1063function formatWindowFocusError(error: unknown, target: string) {
1064 const details = extractCommandErrorOutput(error);
1065 const suffix = details ? ` ${details}` : "";
1066 return `Failed to focus ${target}.${suffix}`;
1067}
1068
1069function 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
1082function formatAppMonitorError(summary: string, error: unknown) {
1083 const details = extractCommandErrorOutput(error);
1084 const suffix = details ? ` ${details}` : "";
1085 return new Error(`${summary}${suffix}`);
1086}
1087
1088function 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
1096function normalizeToastDetail(detail: string | undefined) {
1097 if (detail === undefined) {
1098 return "";
1099 }
1100 return detail.trim();
1101}
1102
1103function sleep(ms: number) {
1104 return new Promise<void>((resolve) => {
1105 setTimeout(resolve, ms);
1106 });
1107}