authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-15 20:07:24-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-15 20:07:47-07:00
log75da6a34d58d3ef23ff082a16a6252a61654565a
tree41a2892537caf63cfea736ba51af845802d1fce5
parentd9c781c70166411ff54304f0e28780029ba28e3e
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: fx window stuff


4 files changed, 798 insertions(+), 76 deletions(-)

config/reaper.ts+7
...@@ -10,6 +10,9 @@ export default config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) =>...@@ -10,6 +10,9 @@ export default config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) =>
10 console.info(transport);10 console.info(transport);
11 se.leds.audioOnly = transport.recording;11 se.leds.audioOnly = transport.recording;
12 });12 });
13 mac.on("window", (window) => {
14 se.leds.videoOnly = Boolean(window?.title.startsWith("FX:"));
15 });
1316
14 // Keyboard Actions17 // Keyboard Actions
15 SpeedEditor.camNumbersToNumpad(se, mac);18 SpeedEditor.camNumbersToNumpad(se, mac);
...@@ -41,6 +44,10 @@ export default config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) =>...@@ -41,6 +44,10 @@ export default config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) =>
41 }44 }
42 });45 });
43 se.onPress("videoOnly", () => {46 se.onPress("videoOnly", () => {
47 if (mac.window?.title.startsWith("FX:") && mac.mainWindow) {
48 mac.focusMainWindow();
49 return;
50 }
44 reaper.runAction("track-view-fx-chain-for-current-last-touched-track");51 reaper.runAction("track-view-fx-chain-for-current-last-touched-track");
45 });52 });
4653
readme.md+12-2
...@@ -37,7 +37,9 @@ Bind to the Mac desktop interface....@@ -37,7 +37,9 @@ Bind to the Mac desktop interface.
37```ts37```ts
38const mac = await Mac.open();38const mac = await Mac.open();
3939
40mac.on()40mac.on("app-change", (bundle) => {
41 console.info("Current App: " + bundle);
42});
41```43```
4244
43### REAPER45### REAPER
...@@ -48,7 +50,15 @@ With the help of an OSC extension, REAPER can be controlled with TypeScript....@@ -48,7 +50,15 @@ With the help of an OSC extension, REAPER can be controlled with TypeScript.
48import { Reaper } from "@clo/creative-control/Reaper.ts";50import { Reaper } from "@clo/creative-control/Reaper.ts";
4951
50const reaper = new Reaper();52const reaper = new Reaper();
5153reaper.on("transport", (transport) => {
54 console.info(
55 transport.recording
56 ? "You are recording"
57 : transport.playing
58 ? "Playing"
59 : "Stopped"
60 );
61});
52```62```
5363
54Setup:64Setup:
src/Mac.ts+244-59
...@@ -10,7 +10,6 @@ import { promisify } from "node:util";...@@ -10,7 +10,6 @@ import { promisify } from "node:util";
10const execFileAsync = promisify(execFile);10const execFileAsync = promisify(execFile);
1111
12const APP_MONITOR_START_TIMEOUT_MS = 1000;12const APP_MONITOR_START_TIMEOUT_MS = 1000;
13const FRONT_ASN_PATTERN = /ASN:[^\]\s]+/;
14const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/;13const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/;
15const MODULE_DIR = dirname(fileURLToPath(import.meta.url));14const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
16const FRONTMOST_APP_HELPER_SOURCE_PATH = join(15const FRONTMOST_APP_HELPER_SOURCE_PATH = join(
...@@ -24,16 +23,14 @@ const FRONTMOST_APP_HELPER_BINARY_PATH = join(...@@ -24,16 +23,14 @@ const FRONTMOST_APP_HELPER_BINARY_PATH = join(
24);23);
25const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m");24const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m");
26const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper");25const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper");
27const FRONTMOST_APP_SCRIPT = [26const FRONTMOST_HELPER_READ_ONCE_FLAG = "--once";
28 'ObjC.import("AppKit");',27const FRONTMOST_HELPER_FOCUS_WINDOW_FLAG = "--focus-window";
29 "const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;",28const FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG = "--focus-main-window";
30 "if (!app || ObjC.unwrap(app) === null) {",29const DEFAULT_OBJECTIVE_C_FRAMEWORKS = ["AppKit", "Foundation"] as const;
31 ' console.log("");',30const FRONTMOST_APP_HELPER_FRAMEWORKS = [
32 "} else {",31 ...DEFAULT_OBJECTIVE_C_FRAMEWORKS,
33 " const bundleId = ObjC.unwrap(app.bundleIdentifier);",32 "ApplicationServices",
34 ' console.log(bundleId ?? "");',33] as const;
35 "}",
36].join("\n");
37const KEYBOARD_EVENT_SCRIPT = [34const KEYBOARD_EVENT_SCRIPT = [
38 'ObjC.import("ApplicationServices");',35 'ObjC.import("ApplicationServices");',
39 "function run(argv) {",36 "function run(argv) {",
...@@ -239,6 +236,13 @@ type KeyboardAction = {...@@ -239,6 +236,13 @@ type KeyboardAction = {
239 isDown: boolean;236 isDown: boolean;
240};237};
241238
239type FrontmostState = {
240 bundleId: string | null;
241 windows: readonly Mac.Window[];
242};
243
244const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]);
245
242export class Mac extends Events<Mac.EventMap> {246export class Mac extends Events<Mac.EventMap> {
243 static readonly keyCodes = KEY_CODES;247 static readonly keyCodes = KEY_CODES;
244 static readonly keyNames = KEY_NAMES;248 static readonly keyNames = KEY_NAMES;
...@@ -249,6 +253,7 @@ export class Mac extends Events<Mac.EventMap> {...@@ -249,6 +253,7 @@ export class Mac extends Events<Mac.EventMap> {
249 #appMonitorBuffer = "";253 #appMonitorBuffer = "";
250 #appMonitorStartup: Promise<void> | null = null;254 #appMonitorStartup: Promise<void> | null = null;
251 #currentApp: string | null = null;255 #currentApp: string | null = null;
256 #windows: readonly Mac.Window[] = EMPTY_WINDOWS;
252 #keyboardQueue: Promise<void> = Promise.resolve();257 #keyboardQueue: Promise<void> = Promise.resolve();
253258
254 private constructor(_options: Mac.Options = {}) {259 private constructor(_options: Mac.Options = {}) {
...@@ -269,6 +274,18 @@ export class Mac extends Events<Mac.EventMap> {...@@ -269,6 +274,18 @@ export class Mac extends Events<Mac.EventMap> {
269 return this.#currentApp;274 return this.#currentApp;
270 }275 }
271276
277 get windows(): readonly Mac.Window[] {
278 return this.#windows;
279 }
280
281 get window(): Mac.Window | null {
282 return getFocusedWindow(this.#windows);
283 }
284
285 get mainWindow(): Mac.Window | null {
286 return getMainWindow(this.#windows);
287 }
288
272 async start() {289 async start() {
273 if (this.#closed) {290 if (this.#closed) {
274 throw new Error("Cannot start a closed Mac instance");291 throw new Error("Cannot start a closed Mac instance");
...@@ -304,7 +321,37 @@ export class Mac extends Events<Mac.EventMap> {...@@ -304,7 +321,37 @@ export class Mac extends Events<Mac.EventMap> {
304 this.#assertOpen("Cannot focus an app from a closed Mac instance");321 this.#assertOpen("Cannot focus an app from a closed Mac instance");
305322
306 await execFileAsync("/usr/bin/open", ["-b", bundleId]);323 await execFileAsync("/usr/bin/open", ["-b", bundleId]);
307 await this.#syncCurrentApp();324 await this.#syncFrontmostState();
325 }
326
327 async focusWindow(window: Mac.Window | number) {
328 this.#assertOpen("Cannot focus a window from a closed Mac instance");
329
330 const helperPath = await ensureFrontmostAppHelperBinary();
331 const windowId = resolveWindowId(window);
332 try {
333 await execFileAsync(helperPath, [
334 FRONTMOST_HELPER_FOCUS_WINDOW_FLAG,
335 String(windowId),
336 ]);
337 } catch (error) {
338 throw new Error(formatWindowFocusError(error, `window ${windowId}`));
339 }
340
341 await this.#syncFrontmostState();
342 }
343
344 async focusMainWindow() {
345 this.#assertOpen("Cannot focus the main window from a closed Mac instance");
346
347 const helperPath = await ensureFrontmostAppHelperBinary();
348 try {
349 await execFileAsync(helperPath, [FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG]);
350 } catch (error) {
351 throw new Error(formatWindowFocusError(error, "the main window"));
352 }
353
354 await this.#syncFrontmostState();
308 }355 }
309356
310 async pressKey(key: Mac.Key, options: Mac.KeyPressOptions = {}) {357 async pressKey(key: Mac.Key, options: Mac.KeyPressOptions = {}) {
...@@ -493,21 +540,31 @@ export class Mac extends Events<Mac.EventMap> {...@@ -493,21 +540,31 @@ export class Mac extends Events<Mac.EventMap> {
493 .replace(/\r$/, "");540 .replace(/\r$/, "");
494 this.#appMonitorBuffer = this.#appMonitorBuffer.slice(newlineIndex + 1);541 this.#appMonitorBuffer = this.#appMonitorBuffer.slice(newlineIndex + 1);
495 sawLine = true;542 sawLine = true;
496 this.#setCurrentApp(normalizeBundleId(line));543 this.#applyFrontmostState(parseFrontmostStateLine(line));
497 }544 }
498 }545 }
499546
500 async #syncCurrentApp() {547 async #syncFrontmostState() {
501 try {548 try {
502 return this.#setCurrentApp(await this.#readFrontmostApp());549 return this.#applyFrontmostState(await this.#readFrontmostState());
503 } catch (error) {550 } catch (error) {
504 this.#emitMonitorError(error);551 this.#emitMonitorError(error);
505 return null;552 return { bundleId: this.#currentApp, windows: this.#windows };
506 }553 }
507 }554 }
508555
556 #applyFrontmostState(state: FrontmostState) {
557 this.#setCurrentApp(state.bundleId);
558 this.#setWindows(state.windows);
559 return state;
560 }
561
509 #setCurrentApp(bundleId: string | null) {562 #setCurrentApp(bundleId: string | null) {
510 if (this.#closed || !bundleId) {563 if (this.#closed) {
564 return bundleId;
565 }
566 if (!bundleId) {
567 this.#currentApp = null;
511 return bundleId;568 return bundleId;
512 }569 }
513 if (bundleId !== this.#currentApp) {570 if (bundleId !== this.#currentApp) {
...@@ -517,37 +574,32 @@ export class Mac extends Events<Mac.EventMap> {...@@ -517,37 +574,32 @@ export class Mac extends Events<Mac.EventMap> {
517 return bundleId;574 return bundleId;
518 }575 }
519576
520 async #readFrontmostApp() {577 #setWindows(windows: readonly Mac.Window[]) {
521 const fromLaunchServices = await this.#readFrontmostAppFromLaunchServices();578 if (this.#closed) {
522 if (fromLaunchServices) {579 return windows;
523 return fromLaunchServices;580 }
581 if (windowsEqual(this.#windows, windows)) {
582 return windows;
524 }583 }
525584
526 const { stdout } = await execFileAsync("/usr/bin/osascript", [585 const previousWindow = getFocusedWindow(this.#windows);
527 "-l",586 this.#windows = windows;
528 "JavaScript",587 this.emit("windows", windows);
529 "-e",
530 FRONTMOST_APP_SCRIPT,
531 ]);
532
533 return normalizeBundleId(stdout);
534 }
535588
536 async #readFrontmostAppFromLaunchServices() {589 const nextWindow = getFocusedWindow(windows);
537 const { stdout } = await execFileAsync("/usr/bin/lsappinfo", ["front"]);590 if (!windowEquals(previousWindow, nextWindow)) {
538 const frontSpecifier = extractFrontAppSpecifier(stdout);591 this.emit("window", nextWindow);
539 if (!frontSpecifier) {
540 return null;
541 }592 }
542593
543 const { stdout: info } = await execFileAsync("/usr/bin/lsappinfo", [594 return windows;
544 "info",595 }
545 "-only",
546 "bundleid",
547 frontSpecifier,
548 ]);
549596
550 return normalizeBundleId(info);597 async #readFrontmostState() {
598 const helperPath = await ensureFrontmostAppHelperBinary();
599 const { stdout } = await execFileAsync(helperPath, [
600 FRONTMOST_HELPER_READ_ONCE_FLAG,
601 ]);
602 return parseFrontmostState(stdout);
551 }603 }
552604
553 async #enqueueKeyboardOperation<T>(operation: () => Promise<T>) {605 async #enqueueKeyboardOperation<T>(operation: () => Promise<T>) {
...@@ -613,10 +665,19 @@ export declare namespace Mac {...@@ -613,10 +665,19 @@ export declare namespace Mac {
613 durationMs?: number;665 durationMs?: number;
614 }666 }
615667
668 export interface Window {
669 readonly id: number;
670 readonly title: string;
671 readonly main: boolean;
672 readonly focused: boolean;
673 }
674
616 export type EventMap = {675 export type EventMap = {
617 "app-change": [bundleId: string];676 "app-change": [bundleId: string];
618 "close": [];677 "close": [];
619 "error": [error: unknown];678 "error": [error: unknown];
679 "window": [window: Window | null];
680 "windows": [windows: readonly Window[]];
620 };681 };
621682
622 export type BundleId =683 export type BundleId =
...@@ -639,8 +700,38 @@ export declare namespace Mac {...@@ -639,8 +700,38 @@ export declare namespace Mac {
639 | "org.whispersystems.signal-desktop";700 | "org.whispersystems.signal-desktop";
640}701}
641702
642function normalizeBundleId(stdout: string) {703function parseFrontmostState(stdout: string): FrontmostState {
643 const directBundleId = stdout.trim();704 const line = stdout.split(/\r?\n/u).find((candidate) => candidate.trim() !== "");
705 return parseFrontmostStateLine(line ?? "");
706}
707
708function parseFrontmostStateLine(line: string): FrontmostState {
709 const trimmed = line.trim();
710 if (trimmed === "") {
711 return { bundleId: null, windows: EMPTY_WINDOWS };
712 }
713
714 try {
715 const parsed = JSON.parse(trimmed);
716 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
717 const record = parsed as { bundleId?: unknown; windows?: unknown };
718 return {
719 bundleId: normalizeBundleId(record.bundleId),
720 windows: normalizeWindows(record.windows),
721 };
722 }
723 } catch {
724 // Fall back to the older helper output if a stale binary is still running.
725 }
726
727 return {
728 bundleId: normalizeBundleId(trimmed),
729 windows: EMPTY_WINDOWS,
730 };
731}
732
733function normalizeBundleId(value: unknown) {
734 const directBundleId = typeof value === "string" ? value.trim() : "";
644 const bundleId = BUNDLE_ID_PATTERN.exec(directBundleId)?.[0] ??735 const bundleId = BUNDLE_ID_PATTERN.exec(directBundleId)?.[0] ??
645 directBundleId;736 directBundleId;
646 if (737 if (
...@@ -653,14 +744,103 @@ function normalizeBundleId(stdout: string) {...@@ -653,14 +744,103 @@ function normalizeBundleId(stdout: string) {
653 return bundleId;744 return bundleId;
654}745}
655746
656function extractFrontAppSpecifier(stdout: string) {747function normalizeWindows(value: unknown): readonly Mac.Window[] {
657 const trimmed = stdout.trim();748 if (!Array.isArray(value) || value.length === 0) {
658 if (trimmed === "" || trimmed === "[ NULL ]" || trimmed === "NULL") {749 return EMPTY_WINDOWS;
750 }
751
752 const windows: Mac.Window[] = [];
753 for (const candidate of value) {
754 const window = normalizeWindow(candidate);
755 if (window) {
756 windows.push(window);
757 }
758 }
759
760 return windows.length === 0 ? EMPTY_WINDOWS : Object.freeze(windows);
761}
762
763function normalizeWindow(value: unknown): Mac.Window | null {
764 if (!value || typeof value !== "object" || Array.isArray(value)) {
659 return null;765 return null;
660 }766 }
661767
662 return FRONT_ASN_PATTERN.exec(trimmed)?.[0] ??768 const record = value as {
663 trimmed.replace(/^\[\s*|\s*\]$/g, "");769 id?: unknown;
770 title?: unknown;
771 main?: unknown;
772 focused?: unknown;
773 };
774 const id = normalizeWindowId(record.id);
775 if (id === null) {
776 return null;
777 }
778
779 return Object.freeze({
780 id,
781 title: typeof record.title === "string" ? record.title : "",
782 main: Boolean(record.main),
783 focused: Boolean(record.focused),
784 });
785}
786
787function normalizeWindowId(value: unknown) {
788 if (!Number.isSafeInteger(value)) {
789 return null;
790 }
791 return value;
792}
793
794function resolveWindowId(window: Mac.Window | number) {
795 const windowId = typeof window === "number" ? window : window?.id;
796 const normalizedId = normalizeWindowId(windowId);
797 if (normalizedId === null) {
798 throw new Error(
799 "Mac.focusWindow expects a window object returned by mac.windows or a numeric window id.",
800 );
801 }
802 return normalizedId;
803}
804
805function getFocusedWindow(windows: readonly Mac.Window[]) {
806 return windows.find((window) => window.focused) ?? null;
807}
808
809function getMainWindow(windows: readonly Mac.Window[]) {
810 return windows.find((window) => window.main) ?? null;
811}
812
813function windowsEqual(
814 left: readonly Mac.Window[],
815 right: readonly Mac.Window[],
816) {
817 if (left === right) {
818 return true;
819 }
820 if (left.length !== right.length) {
821 return false;
822 }
823
824 for (let index = 0; index < left.length; index += 1) {
825 if (!windowEquals(left[index], right[index])) {
826 return false;
827 }
828 }
829
830 return true;
831}
832
833function windowEquals(left: Mac.Window | null, right: Mac.Window | null) {
834 if (left === right) {
835 return true;
836 }
837 if (!left || !right) {
838 return left === right;
839 }
840 return left.id === right.id &&
841 left.title === right.title &&
842 left.main === right.main &&
843 left.focused === right.focused;
664}844}
665845
666function normalizeDelayMs(value: number, name: string) {846function normalizeDelayMs(value: number, name: string) {
...@@ -801,6 +981,7 @@ async function ensureToastHelperBinary() {...@@ -801,6 +981,7 @@ async function ensureToastHelperBinary() {
801 toastHelperBinaryPromise = buildObjectiveCHelperBinary(981 toastHelperBinaryPromise = buildObjectiveCHelperBinary(
802 TOAST_HELPER_SOURCE_PATH,982 TOAST_HELPER_SOURCE_PATH,
803 TOAST_HELPER_BINARY_PATH,983 TOAST_HELPER_BINARY_PATH,
984 DEFAULT_OBJECTIVE_C_FRAMEWORKS,
804 ).catch((error) => {985 ).catch((error) => {
805 toastHelperBinaryPromise = null;986 toastHelperBinaryPromise = null;
806 throw error;987 throw error;
...@@ -815,6 +996,7 @@ async function ensureFrontmostAppHelperBinary() {...@@ -815,6 +996,7 @@ async function ensureFrontmostAppHelperBinary() {
815 frontmostAppHelperBinaryPromise = buildObjectiveCHelperBinary(996 frontmostAppHelperBinaryPromise = buildObjectiveCHelperBinary(
816 FRONTMOST_APP_HELPER_SOURCE_PATH,997 FRONTMOST_APP_HELPER_SOURCE_PATH,
817 FRONTMOST_APP_HELPER_BINARY_PATH,998 FRONTMOST_APP_HELPER_BINARY_PATH,
999 FRONTMOST_APP_HELPER_FRAMEWORKS,
818 ).catch((error) => {1000 ).catch((error) => {
819 frontmostAppHelperBinaryPromise = null;1001 frontmostAppHelperBinaryPromise = null;
820 throw error;1002 throw error;
...@@ -827,6 +1009,7 @@ async function ensureFrontmostAppHelperBinary() {...@@ -827,6 +1009,7 @@ async function ensureFrontmostAppHelperBinary() {
827async function buildObjectiveCHelperBinary(1009async function buildObjectiveCHelperBinary(
828 sourcePath: string,1010 sourcePath: string,
829 binaryPath: string,1011 binaryPath: string,
1012 frameworks: readonly string[],
830) {1013) {
831 await mkdir(HELPER_BUILD_DIR, { recursive: true });1014 await mkdir(HELPER_BUILD_DIR, { recursive: true });
8321015
...@@ -836,16 +1019,12 @@ async function buildObjectiveCHelperBinary(...@@ -836,16 +1019,12 @@ async function buildObjectiveCHelperBinary(
836 ]);1019 ]);
8371020
838 if (!binaryStats || binaryStats.mtimeMs < sourceStats.mtimeMs) {1021 if (!binaryStats || binaryStats.mtimeMs < sourceStats.mtimeMs) {
839 await execFileAsync("/usr/bin/clang", [1022 const args = ["-fobjc-arc"];
840 "-fobjc-arc",1023 for (const framework of frameworks) {
841 "-framework",1024 args.push("-framework", framework);
842 "AppKit",1025 }
843 "-framework",1026 args.push(sourcePath, "-o", binaryPath);
844 "Foundation",1027 await execFileAsync("/usr/bin/clang", args);
845 sourcePath,
846 "-o",
847 binaryPath,
848 ]);
849 }1028 }
8501029
851 return binaryPath;1030 return binaryPath;
...@@ -873,6 +1052,12 @@ function formatToastDispatchError(error: unknown) {...@@ -873,6 +1052,12 @@ function formatToastDispatchError(error: unknown) {
873 return `Failed to show a macOS toast.${suffix}`;1052 return `Failed to show a macOS toast.${suffix}`;
874}1053}
8751054
1055function formatWindowFocusError(error: unknown, target: string) {
1056 const details = extractCommandErrorOutput(error);
1057 const suffix = details ? ` ${details}` : "";
1058 return `Failed to focus ${target}.${suffix}`;
1059}
1060
876function formatAppMonitorExitMessage(code: number | null, signal: NodeJS.Signals | null) {1061function formatAppMonitorExitMessage(code: number | null, signal: NodeJS.Signals | null) {
877 if (signal) {1062 if (signal) {
878 return `The macOS app monitor stopped after receiving ${signal}.`;1063 return `The macOS app monitor stopped after receiving ${signal}.`;
src/Mac/frontmost_app_helper.m+535-15
...@@ -1,40 +1,560 @@...@@ -1,40 +1,560 @@
1#import <AppKit/AppKit.h>1#import <AppKit/AppKit.h>
2#import <ApplicationServices/ApplicationServices.h>
2#import <Foundation/Foundation.h>3#import <Foundation/Foundation.h>
34
4static void PrintBundleIdentifier(NSRunningApplication *application) {5static CFStringRef const kCloverAXWindowNumberAttribute = CFSTR("AXWindowNumber");
5 NSString *bundleIdentifier = application.bundleIdentifier ?: @"";6
6 const char *utf8 = bundleIdentifier.UTF8String ?: "";7static NSArray<id> *CopyWindowElements(AXUIElementRef applicationElement) {
7 fprintf(stdout, "%s\n", utf8);8 if (!applicationElement) {
9 return @[];
10 }
11
12 CFTypeRef value = NULL;
13 AXError error = AXUIElementCopyAttributeValue(
14 applicationElement,
15 kAXWindowsAttribute,
16 &value
17 );
18 if (error != kAXErrorSuccess || !value) {
19 if (value) {
20 CFRelease(value);
21 }
22 return @[];
23 }
24 if (CFGetTypeID(value) != CFArrayGetTypeID()) {
25 CFRelease(value);
26 return @[];
27 }
28
29 return CFBridgingRelease(value);
30}
31
32static NSString *CopyStringAttribute(AXUIElementRef element, CFStringRef attribute) {
33 if (!element) {
34 return nil;
35 }
36
37 CFTypeRef value = NULL;
38 AXError error = AXUIElementCopyAttributeValue(element, attribute, &value);
39 if (error != kAXErrorSuccess || !value) {
40 if (value) {
41 CFRelease(value);
42 }
43 return nil;
44 }
45 if (CFGetTypeID(value) != CFStringGetTypeID()) {
46 CFRelease(value);
47 return nil;
48 }
49
50 return CFBridgingRelease(value);
51}
52
53static NSNumber *CopyNumberAttribute(AXUIElementRef element, CFStringRef attribute) {
54 if (!element) {
55 return nil;
56 }
57
58 CFTypeRef value = NULL;
59 AXError error = AXUIElementCopyAttributeValue(element, attribute, &value);
60 if (error != kAXErrorSuccess || !value) {
61 if (value) {
62 CFRelease(value);
63 }
64 return nil;
65 }
66 if (CFGetTypeID(value) != CFNumberGetTypeID()) {
67 CFRelease(value);
68 return nil;
69 }
70
71 return CFBridgingRelease(value);
72}
73
74static BOOL CopyBoolAttribute(
75 AXUIElementRef element,
76 CFStringRef attribute,
77 BOOL fallback
78) {
79 if (!element) {
80 return fallback;
81 }
82
83 CFTypeRef value = NULL;
84 AXError error = AXUIElementCopyAttributeValue(element, attribute, &value);
85 if (error != kAXErrorSuccess || !value) {
86 if (value) {
87 CFRelease(value);
88 }
89 return fallback;
90 }
91
92 BOOL result = fallback;
93 CFTypeID typeId = CFGetTypeID(value);
94 if (typeId == CFBooleanGetTypeID()) {
95 result = CFBooleanGetValue((CFBooleanRef)value);
96 } else if (typeId == CFNumberGetTypeID()) {
97 int numericValue = 0;
98 if (CFNumberGetValue((CFNumberRef)value, kCFNumberIntType, &numericValue)) {
99 result = numericValue != 0;
100 }
101 }
102
103 CFRelease(value);
104 return result;
105}
106
107static NSNumber *WindowIdentifierForElement(AXUIElementRef windowElement, NSInteger index) {
108 NSNumber *windowNumber = CopyNumberAttribute(windowElement, kCloverAXWindowNumberAttribute);
109 return windowNumber ?: @(-(index + 1));
110}
111
112static NSDictionary<NSString *, id> *SnapshotWindow(
113 AXUIElementRef windowElement,
114 NSInteger index
115) {
116 if (!windowElement) {
117 return nil;
118 }
119
120 return @{
121 @"id": WindowIdentifierForElement(windowElement, index),
122 @"title": CopyStringAttribute(windowElement, kAXTitleAttribute) ?: @"",
123 @"main": @(CopyBoolAttribute(windowElement, kAXMainAttribute, NO)),
124 @"focused": @(CopyBoolAttribute(windowElement, kAXFocusedAttribute, NO)),
125 };
126}
127
128static NSArray<NSDictionary<NSString *, id> *> *SnapshotWindowsForApplicationElement(
129 AXUIElementRef applicationElement
130) {
131 NSArray<id> *windowElements = CopyWindowElements(applicationElement);
132 NSMutableArray<NSDictionary<NSString *, id> *> *snapshots = [NSMutableArray arrayWithCapacity:windowElements.count];
133
134 for (NSUInteger index = 0; index < windowElements.count; index++) {
135 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index];
136 NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index);
137 if (snapshot) {
138 [snapshots addObject:snapshot];
139 }
140 }
141
142 return snapshots;
143}
144
145static void PrintState(
146 NSRunningApplication *application,
147 NSArray<NSDictionary<NSString *, id> *> *windows
148) {
149 NSDictionary<NSString *, id> *payload = @{
150 @"bundleId": application.bundleIdentifier ?: [NSNull null],
151 @"windows": windows ?: @[],
152 };
153
154 NSError *error = nil;
155 NSData *json = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
156 if (!json || error) {
157 const char *message = error.localizedDescription.UTF8String ?: "Failed to encode state";
158 fprintf(stderr, "%s\n", message);
159 return;
160 }
161
162 fwrite(json.bytes, 1, json.length, stdout);
163 fputc('\n', stdout);
8 fflush(stdout);164 fflush(stdout);
9}165}
10166
11@interface FrontmostAppObserver : NSObject167static void PrintCurrentState(void) {
168 NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication;
169 id applicationElement = application
170 ? CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier))
171 : nil;
172 PrintState(
173 application,
174 SnapshotWindowsForApplicationElement((__bridge AXUIElementRef)applicationElement)
175 );
176}
177
178static AXUIElementRef CopyWindowElementForIdentifier(
179 AXUIElementRef applicationElement,
180 long long targetIdentifier
181) {
182 NSArray<id> *windowElements = CopyWindowElements(applicationElement);
183 for (NSUInteger index = 0; index < windowElements.count; index++) {
184 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index];
185 if (WindowIdentifierForElement(windowElement, index).longLongValue == targetIdentifier) {
186 return (AXUIElementRef)CFRetain(windowElement);
187 }
188 }
189 return NULL;
190}
191
192static AXUIElementRef CopyMainWindowElement(AXUIElementRef applicationElement) {
193 if (!applicationElement) {
194 return NULL;
195 }
196
197 CFTypeRef value = NULL;
198 AXError error = AXUIElementCopyAttributeValue(
199 applicationElement,
200 kAXMainWindowAttribute,
201 &value
202 );
203 if (error == kAXErrorSuccess && value) {
204 if (CFGetTypeID(value) == AXUIElementGetTypeID()) {
205 return (AXUIElementRef)value;
206 }
207 CFRelease(value);
208 }
209
210 NSArray<id> *windowElements = CopyWindowElements(applicationElement);
211 for (NSUInteger index = 0; index < windowElements.count; index++) {
212 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index];
213 if (CopyBoolAttribute(windowElement, kAXMainAttribute, NO)) {
214 return (AXUIElementRef)CFRetain(windowElement);
215 }
216 }
217
218 return NULL;
219}
220
221static BOOL FocusWindowElement(
222 NSRunningApplication *application,
223 AXUIElementRef windowElement,
224 NSString **failure
225) {
226 if (!application || !windowElement) {
227 if (failure) {
228 *failure = @"No window is available to focus.";
229 }
230 return NO;
231 }
232
233 [application activateWithOptions:NSApplicationActivateAllWindows];
234
235 AXError unminimizeError = AXUIElementSetAttributeValue(
236 windowElement,
237 kAXMinimizedAttribute,
238 kCFBooleanFalse
239 );
240 AXError raiseError = AXUIElementPerformAction(windowElement, kAXRaiseAction);
241 AXError mainError = AXUIElementSetAttributeValue(
242 windowElement,
243 kAXMainAttribute,
244 kCFBooleanTrue
245 );
246 AXError focusedError = AXUIElementSetAttributeValue(
247 windowElement,
248 kAXFocusedAttribute,
249 kCFBooleanTrue
250 );
251
252 BOOL succeeded =
253 unminimizeError == kAXErrorSuccess ||
254 raiseError == kAXErrorSuccess ||
255 mainError == kAXErrorSuccess ||
256 focusedError == kAXErrorSuccess;
257 if (succeeded) {
258 return YES;
259 }
260
261 if (failure) {
262 *failure = [NSString stringWithFormat:
263 @"Could not focus the requested window (unminimize=%d raise=%d main=%d focused=%d).",
264 (int)unminimizeError,
265 (int)raiseError,
266 (int)mainError,
267 (int)focusedError
268 ];
269 }
270 return NO;
271}
272
273static BOOL FocusWindowWithIdentifier(long long targetIdentifier) {
274 if (!AXIsProcessTrusted()) {
275 fprintf(stderr, "%s\n", "Accessibility access is required to focus windows.");
276 return NO;
277 }
278
279 NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication;
280 if (!application) {
281 fprintf(stderr, "%s\n", "No frontmost application is available.");
282 return NO;
283 }
284
285 id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier));
286 AXUIElementRef windowElement = CopyWindowElementForIdentifier(
287 (__bridge AXUIElementRef)applicationElement,
288 targetIdentifier
289 );
290 if (!windowElement) {
291 fprintf(stderr, "Window %lld was not found.\n", targetIdentifier);
292 return NO;
293 }
294
295 NSString *failure = nil;
296 BOOL focused = FocusWindowElement(application, windowElement, &failure);
297 CFRelease(windowElement);
298 if (!focused) {
299 fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the requested window.");
300 }
301 return focused;
302}
303
304static BOOL FocusMainWindow(void) {
305 if (!AXIsProcessTrusted()) {
306 fprintf(stderr, "%s\n", "Accessibility access is required to focus windows.");
307 return NO;
308 }
309
310 NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication;
311 if (!application) {
312 fprintf(stderr, "%s\n", "No frontmost application is available.");
313 return NO;
314 }
315
316 id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier));
317 AXUIElementRef windowElement = CopyMainWindowElement((__bridge AXUIElementRef)applicationElement);
318 if (!windowElement) {
319 fprintf(stderr, "%s\n", "The frontmost application does not report a main window.");
320 return NO;
321 }
322
323 NSString *failure = nil;
324 BOOL focused = FocusWindowElement(application, windowElement, &failure);
325 CFRelease(windowElement);
326 if (!focused) {
327 fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the main window.");
328 }
329 return focused;
330}
331
332@interface FrontmostAppObserver : NSObject {
333 @private
334 id _accessibilityObserver;
335 id _applicationElement;
336 NSMutableArray<id> *_windowElements;
337}
338- (void)start;
339- (void)handleActivation:(NSNotification *)notification;
340- (void)handleAccessibilityNotification:(NSString *)notification;
12@end341@end
13342
343static void FrontmostAccessibilityCallback(
344 AXObserverRef observer,
345 AXUIElementRef element,
346 CFStringRef notification,
347 void *context
348) {
349 @autoreleasepool {
350 FrontmostAppObserver *frontmostObserver = (__bridge FrontmostAppObserver *)context;
351 [frontmostObserver handleAccessibilityNotification:(__bridge NSString *)notification];
352 }
353}
354
14@implementation FrontmostAppObserver355@implementation FrontmostAppObserver
15356
357- (instancetype)init {
358 self = [super init];
359 if (self) {
360 _windowElements = [NSMutableArray array];
361 }
362 return self;
363}
364
365- (void)dealloc {
366 [NSWorkspace.sharedWorkspace.notificationCenter removeObserver:self];
367 [self clearObservedApplication];
368}
369
370- (AXObserverRef)observerRef {
371 return (__bridge AXObserverRef)_accessibilityObserver;
372}
373
374- (AXUIElementRef)applicationElementRef {
375 return (__bridge AXUIElementRef)_applicationElement;
376}
377
378- (void)start {
379 NSWorkspace *workspace = NSWorkspace.sharedWorkspace;
380 [workspace.notificationCenter addObserver:self
381 selector:@selector(handleActivation:)
382 name:NSWorkspaceDidActivateApplicationNotification
383 object:nil];
384 [self observeApplication:workspace.frontmostApplication];
385}
386
16- (void)handleActivation:(NSNotification *)notification {387- (void)handleActivation:(NSNotification *)notification {
17 NSRunningApplication *application = notification.userInfo[NSWorkspaceApplicationKey];388 NSRunningApplication *application = notification.userInfo[NSWorkspaceApplicationKey];
18 PrintBundleIdentifier(application);389 [self observeApplication:application];
390}
391
392- (void)handleAccessibilityNotification:(NSString *)notification {
393 (void)notification;
394 [self refreshWindowsAndEmit];
395}
396
397- (void)observeApplication:(NSRunningApplication *)application {
398 [self clearObservedApplication];
399 if (!application) {
400 PrintState(nil, @[]);
401 return;
402 }
403
404 _applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier));
405
406 AXObserverRef observer = NULL;
407 AXError observerError = AXObserverCreate(
408 application.processIdentifier,
409 FrontmostAccessibilityCallback,
410 &observer
411 );
412 if (observerError == kAXErrorSuccess && observer) {
413 _accessibilityObserver = CFBridgingRelease(observer);
414 CFRunLoopAddSource(
415 CFRunLoopGetCurrent(),
416 AXObserverGetRunLoopSource(self.observerRef),
417 kCFRunLoopDefaultMode
418 );
419
420 [self addApplicationNotification:kAXFocusedWindowChangedNotification];
421 [self addApplicationNotification:kAXMainWindowChangedNotification];
422 [self addApplicationNotification:kAXWindowCreatedNotification];
423 }
424
425 [self refreshWindowsAndEmit];
426}
427
428- (void)clearObservedApplication {
429 [self clearWindowNotifications];
430
431 AXObserverRef observer = self.observerRef;
432 AXUIElementRef applicationElement = self.applicationElementRef;
433 if (observer && applicationElement) {
434 AXObserverRemoveNotification(observer, applicationElement, kAXFocusedWindowChangedNotification);
435 AXObserverRemoveNotification(observer, applicationElement, kAXMainWindowChangedNotification);
436 AXObserverRemoveNotification(observer, applicationElement, kAXWindowCreatedNotification);
437 }
438 if (observer) {
439 CFRunLoopRemoveSource(
440 CFRunLoopGetCurrent(),
441 AXObserverGetRunLoopSource(observer),
442 kCFRunLoopDefaultMode
443 );
444 }
445
446 _accessibilityObserver = nil;
447 _applicationElement = nil;
448}
449
450- (void)clearWindowNotifications {
451 AXObserverRef observer = self.observerRef;
452 if (observer) {
453 for (id windowObject in _windowElements) {
454 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject;
455 AXObserverRemoveNotification(observer, windowElement, kAXTitleChangedNotification);
456 AXObserverRemoveNotification(observer, windowElement, kAXUIElementDestroyedNotification);
457 }
458 }
459
460 [_windowElements removeAllObjects];
461}
462
463- (void)addApplicationNotification:(CFStringRef)notification {
464 AXObserverRef observer = self.observerRef;
465 AXUIElementRef applicationElement = self.applicationElementRef;
466 if (!observer || !applicationElement) {
467 return;
468 }
469
470 AXObserverAddNotification(
471 observer,
472 applicationElement,
473 notification,
474 (__bridge void *)self
475 );
476}
477
478- (void)addWindowNotification:(CFStringRef)notification element:(AXUIElementRef)windowElement {
479 AXObserverRef observer = self.observerRef;
480 if (!observer || !windowElement) {
481 return;
482 }
483
484 AXObserverAddNotification(
485 observer,
486 windowElement,
487 notification,
488 (__bridge void *)self
489 );
490}
491
492- (NSArray<NSDictionary<NSString *, id> *> *)refreshObservedWindows {
493 [self clearWindowNotifications];
494
495 NSArray<id> *windowElements = CopyWindowElements(self.applicationElementRef);
496 NSMutableArray<NSDictionary<NSString *, id> *> *windows = [NSMutableArray arrayWithCapacity:windowElements.count];
497
498 for (NSUInteger index = 0; index < windowElements.count; index++) {
499 id windowObject = windowElements[index];
500 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject;
501 NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index);
502 if (!snapshot) {
503 continue;
504 }
505
506 [_windowElements addObject:windowObject];
507 [self addWindowNotification:kAXTitleChangedNotification element:windowElement];
508 [self addWindowNotification:kAXUIElementDestroyedNotification element:windowElement];
509 [windows addObject:snapshot];
510 }
511
512 return windows;
513}
514
515- (void)refreshWindowsAndEmit {
516 PrintState(
517 NSWorkspace.sharedWorkspace.frontmostApplication,
518 [self refreshObservedWindows]
519 );
19}520}
20521
21@end522@end
22523
23int main(void) {524int main(int argc, const char *argv[]) {
24 @autoreleasepool {525 @autoreleasepool {
25 [NSApplication sharedApplication];526 [NSApplication sharedApplication];
26 [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited];527 [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited];
27528
28 FrontmostAppObserver *observer = [FrontmostAppObserver new];529 if (argc > 1) {
29 NSWorkspace *workspace = NSWorkspace.sharedWorkspace;530 NSString *command = [NSString stringWithUTF8String:argv[1]];
30 NSNotificationCenter *center = workspace.notificationCenter;531 if ([command isEqualToString:@"--once"]) {
532 PrintCurrentState();
533 return 0;
534 }
535 if ([command isEqualToString:@"--focus-window"]) {
536 if (argc < 3) {
537 fprintf(stderr, "%s\n", "Missing window identifier.");
538 return 1;
539 }
540 char *end = NULL;
541 long long windowIdentifier = strtoll(argv[2], &end, 10);
542 if (end == argv[2] || (end && *end != '\0')) {
543 fprintf(stderr, "%s\n", "Window identifier must be an integer.");
544 return 1;
545 }
546 return FocusWindowWithIdentifier(windowIdentifier) ? 0 : 1;
547 }
548 if ([command isEqualToString:@"--focus-main-window"]) {
549 return FocusMainWindow() ? 0 : 1;
550 }
31551
32 PrintBundleIdentifier(workspace.frontmostApplication);552 fprintf(stderr, "Unknown argument: %s\n", argv[1]);
33 [center addObserver:observer553 return 1;
34 selector:@selector(handleActivation:)554 }
35 name:NSWorkspaceDidActivateApplicationNotification
36 object:nil];
37555
556 FrontmostAppObserver *observer = [FrontmostAppObserver new];
557 [observer start];
38 [[NSRunLoop currentRunLoop] run];558 [[NSRunLoop currentRunLoop] run];
39 }559 }
40560