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 }) =>
1010 console.info(transport);
1111 se.leds.audioOnly = transport.recording;
1212 });
13 mac.on("window", (window) => {
14 se.leds.videoOnly = Boolean(window?.title.startsWith("FX:"));
15 });
1316
1417 // Keyboard Actions
1518 SpeedEditor.camNumbersToNumpad(se, mac);
......@@ -41,6 +44,10 @@ export default config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) =>
4144 }
4245 });
4346 se.onPress("videoOnly", () => {
47 if (mac.window?.title.startsWith("FX:") && mac.mainWindow) {
48 mac.focusMainWindow();
49 return;
50 }
4451 reaper.runAction("track-view-fx-chain-for-current-last-touched-track");
4552 });
4653
readme.md+12-2
......@@ -37,7 +37,9 @@ Bind to the Mac desktop interface.
3737```ts
3838const mac = await Mac.open();
3939
40mac.on()
40mac.on("app-change", (bundle) => {
41 console.info("Current App: " + bundle);
42});
4143```
4244
4345### REAPER
......@@ -48,7 +50,15 @@ With the help of an OSC extension, REAPER can be controlled with TypeScript.
4850import { Reaper } from "@clo/creative-control/Reaper.ts";
4951
5052const reaper = new Reaper();
51
53reaper.on("transport", (transport) => {
54 console.info(
55 transport.recording
56 ? "You are recording"
57 : transport.playing
58 ? "Playing"
59 : "Stopped"
60 );
61});
5262```
5363
5464Setup:
src/Mac.ts+244-59
......@@ -10,7 +10,6 @@ import { promisify } from "node:util";
1010const execFileAsync = promisify(execFile);
1111
1212const APP_MONITOR_START_TIMEOUT_MS = 1000;
13const FRONT_ASN_PATTERN = /ASN:[^\]\s]+/;
1413const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/;
1514const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
1615const FRONTMOST_APP_HELPER_SOURCE_PATH = join(
......@@ -24,16 +23,14 @@ const FRONTMOST_APP_HELPER_BINARY_PATH = join(
2423);
2524const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m");
2625const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper");
27const FRONTMOST_APP_SCRIPT = [
28 'ObjC.import("AppKit");',
29 "const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;",
30 "if (!app || ObjC.unwrap(app) === null) {",
31 ' console.log("");',
32 "} else {",
33 " const bundleId = ObjC.unwrap(app.bundleIdentifier);",
34 ' console.log(bundleId ?? "");',
35 "}",
36].join("\n");
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;
3734const KEYBOARD_EVENT_SCRIPT = [
3835 'ObjC.import("ApplicationServices");',
3936 "function run(argv) {",
......@@ -239,6 +236,13 @@ type KeyboardAction = {
239236 isDown: boolean;
240237};
241238
239type FrontmostState = {
240 bundleId: string | null;
241 windows: readonly Mac.Window[];
242};
243
244const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]);
245
242246export class Mac extends Events<Mac.EventMap> {
243247 static readonly keyCodes = KEY_CODES;
244248 static readonly keyNames = KEY_NAMES;
......@@ -249,6 +253,7 @@ export class Mac extends Events<Mac.EventMap> {
249253 #appMonitorBuffer = "";
250254 #appMonitorStartup: Promise<void> | null = null;
251255 #currentApp: string | null = null;
256 #windows: readonly Mac.Window[] = EMPTY_WINDOWS;
252257 #keyboardQueue: Promise<void> = Promise.resolve();
253258
254259 private constructor(_options: Mac.Options = {}) {
......@@ -269,6 +274,18 @@ export class Mac extends Events<Mac.EventMap> {
269274 return this.#currentApp;
270275 }
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
272289 async start() {
273290 if (this.#closed) {
274291 throw new Error("Cannot start a closed Mac instance");
......@@ -304,7 +321,37 @@ export class Mac extends Events<Mac.EventMap> {
304321 this.#assertOpen("Cannot focus an app from a closed Mac instance");
305322
306323 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();
308355 }
309356
310357 async pressKey(key: Mac.Key, options: Mac.KeyPressOptions = {}) {
......@@ -493,21 +540,31 @@ export class Mac extends Events<Mac.EventMap> {
493540 .replace(/\r$/, "");
494541 this.#appMonitorBuffer = this.#appMonitorBuffer.slice(newlineIndex + 1);
495542 sawLine = true;
496 this.#setCurrentApp(normalizeBundleId(line));
543 this.#applyFrontmostState(parseFrontmostStateLine(line));
497544 }
498545 }
499546
500 async #syncCurrentApp() {
547 async #syncFrontmostState() {
501548 try {
502 return this.#setCurrentApp(await this.#readFrontmostApp());
549 return this.#applyFrontmostState(await this.#readFrontmostState());
503550 } catch (error) {
504551 this.#emitMonitorError(error);
505 return null;
552 return { bundleId: this.#currentApp, windows: this.#windows };
506553 }
507554 }
508555
556 #applyFrontmostState(state: FrontmostState) {
557 this.#setCurrentApp(state.bundleId);
558 this.#setWindows(state.windows);
559 return state;
560 }
561
509562 #setCurrentApp(bundleId: string | null) {
510 if (this.#closed || !bundleId) {
563 if (this.#closed) {
564 return bundleId;
565 }
566 if (!bundleId) {
567 this.#currentApp = null;
511568 return bundleId;
512569 }
513570 if (bundleId !== this.#currentApp) {
......@@ -517,37 +574,32 @@ export class Mac extends Events<Mac.EventMap> {
517574 return bundleId;
518575 }
519576
520 async #readFrontmostApp() {
521 const fromLaunchServices = await this.#readFrontmostAppFromLaunchServices();
522 if (fromLaunchServices) {
523 return fromLaunchServices;
577 #setWindows(windows: readonly Mac.Window[]) {
578 if (this.#closed) {
579 return windows;
580 }
581 if (windowsEqual(this.#windows, windows)) {
582 return windows;
524583 }
525584
526 const { stdout } = await execFileAsync("/usr/bin/osascript", [
527 "-l",
528 "JavaScript",
529 "-e",
530 FRONTMOST_APP_SCRIPT,
531 ]);
532
533 return normalizeBundleId(stdout);
534 }
585 const previousWindow = getFocusedWindow(this.#windows);
586 this.#windows = windows;
587 this.emit("windows", windows);
535588
536 async #readFrontmostAppFromLaunchServices() {
537 const { stdout } = await execFileAsync("/usr/bin/lsappinfo", ["front"]);
538 const frontSpecifier = extractFrontAppSpecifier(stdout);
539 if (!frontSpecifier) {
540 return null;
589 const nextWindow = getFocusedWindow(windows);
590 if (!windowEquals(previousWindow, nextWindow)) {
591 this.emit("window", nextWindow);
541592 }
542593
543 const { stdout: info } = await execFileAsync("/usr/bin/lsappinfo", [
544 "info",
545 "-only",
546 "bundleid",
547 frontSpecifier,
548 ]);
594 return windows;
595 }
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);
551603 }
552604
553605 async #enqueueKeyboardOperation<T>(operation: () => Promise<T>) {
......@@ -613,10 +665,19 @@ export declare namespace Mac {
613665 durationMs?: number;
614666 }
615667
668 export interface Window {
669 readonly id: number;
670 readonly title: string;
671 readonly main: boolean;
672 readonly focused: boolean;
673 }
674
616675 export type EventMap = {
617676 "app-change": [bundleId: string];
618677 "close": [];
619678 "error": [error: unknown];
679 "window": [window: Window | null];
680 "windows": [windows: readonly Window[]];
620681 };
621682
622683 export type BundleId =
......@@ -639,8 +700,38 @@ export declare namespace Mac {
639700 | "org.whispersystems.signal-desktop";
640701}
641702
642function normalizeBundleId(stdout: string) {
643 const directBundleId = stdout.trim();
703function parseFrontmostState(stdout: string): FrontmostState {
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() : "";
644735 const bundleId = BUNDLE_ID_PATTERN.exec(directBundleId)?.[0] ??
645736 directBundleId;
646737 if (
......@@ -653,14 +744,103 @@ function normalizeBundleId(stdout: string) {
653744 return bundleId;
654745}
655746
656function extractFrontAppSpecifier(stdout: string) {
657 const trimmed = stdout.trim();
658 if (trimmed === "" || trimmed === "[ NULL ]" || trimmed === "NULL") {
747function normalizeWindows(value: unknown): readonly Mac.Window[] {
748 if (!Array.isArray(value) || value.length === 0) {
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)) {
659765 return null;
660766 }
661767
662 return FRONT_ASN_PATTERN.exec(trimmed)?.[0] ??
663 trimmed.replace(/^\[\s*|\s*\]$/g, "");
768 const record = value as {
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;
664844}
665845
666846function normalizeDelayMs(value: number, name: string) {
......@@ -801,6 +981,7 @@ async function ensureToastHelperBinary() {
801981 toastHelperBinaryPromise = buildObjectiveCHelperBinary(
802982 TOAST_HELPER_SOURCE_PATH,
803983 TOAST_HELPER_BINARY_PATH,
984 DEFAULT_OBJECTIVE_C_FRAMEWORKS,
804985 ).catch((error) => {
805986 toastHelperBinaryPromise = null;
806987 throw error;
......@@ -815,6 +996,7 @@ async function ensureFrontmostAppHelperBinary() {
815996 frontmostAppHelperBinaryPromise = buildObjectiveCHelperBinary(
816997 FRONTMOST_APP_HELPER_SOURCE_PATH,
817998 FRONTMOST_APP_HELPER_BINARY_PATH,
999 FRONTMOST_APP_HELPER_FRAMEWORKS,
8181000 ).catch((error) => {
8191001 frontmostAppHelperBinaryPromise = null;
8201002 throw error;
......@@ -827,6 +1009,7 @@ async function ensureFrontmostAppHelperBinary() {
8271009async function buildObjectiveCHelperBinary(
8281010 sourcePath: string,
8291011 binaryPath: string,
1012 frameworks: readonly string[],
8301013) {
8311014 await mkdir(HELPER_BUILD_DIR, { recursive: true });
8321015
......@@ -836,16 +1019,12 @@ async function buildObjectiveCHelperBinary(
8361019 ]);
8371020
8381021 if (!binaryStats || binaryStats.mtimeMs < sourceStats.mtimeMs) {
839 await execFileAsync("/usr/bin/clang", [
840 "-fobjc-arc",
841 "-framework",
842 "AppKit",
843 "-framework",
844 "Foundation",
845 sourcePath,
846 "-o",
847 binaryPath,
848 ]);
1022 const args = ["-fobjc-arc"];
1023 for (const framework of frameworks) {
1024 args.push("-framework", framework);
1025 }
1026 args.push(sourcePath, "-o", binaryPath);
1027 await execFileAsync("/usr/bin/clang", args);
8491028 }
8501029
8511030 return binaryPath;
......@@ -873,6 +1052,12 @@ function formatToastDispatchError(error: unknown) {
8731052 return `Failed to show a macOS toast.${suffix}`;
8741053}
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
8761061function formatAppMonitorExitMessage(code: number | null, signal: NodeJS.Signals | null) {
8771062 if (signal) {
8781063 return `The macOS app monitor stopped after receiving ${signal}.`;
src/Mac/frontmost_app_helper.m+535-15
......@@ -1,40 +1,560 @@
11#import <AppKit/AppKit.h>
2#import <ApplicationServices/ApplicationServices.h>
23#import <Foundation/Foundation.h>
34
4static void PrintBundleIdentifier(NSRunningApplication *application) {
5 NSString *bundleIdentifier = application.bundleIdentifier ?: @"";
6 const char *utf8 = bundleIdentifier.UTF8String ?: "";
7 fprintf(stdout, "%s\n", utf8);
5static CFStringRef const kCloverAXWindowNumberAttribute = CFSTR("AXWindowNumber");
6
7static NSArray<id> *CopyWindowElements(AXUIElementRef applicationElement) {
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);
8164 fflush(stdout);
9165}
10166
11@interface FrontmostAppObserver : NSObject
167static 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;
12341@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
14355@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
16387- (void)handleActivation:(NSNotification *)notification {
17388 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 );
19520}
20521
21522@end
22523
23int main(void) {
524int main(int argc, const char *argv[]) {
24525 @autoreleasepool {
25526 [NSApplication sharedApplication];
26527 [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited];
27528
28 FrontmostAppObserver *observer = [FrontmostAppObserver new];
29 NSWorkspace *workspace = NSWorkspace.sharedWorkspace;
30 NSNotificationCenter *center = workspace.notificationCenter;
529 if (argc > 1) {
530 NSString *command = [NSString stringWithUTF8String:argv[1]];
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);
33 [center addObserver:observer
34 selector:@selector(handleActivation:)
35 name:NSWorkspaceDidActivateApplicationNotification
36 object:nil];
552 fprintf(stderr, "Unknown argument: %s\n", argv[1]);
553 return 1;
554 }
37555
556 FrontmostAppObserver *observer = [FrontmostAppObserver new];
557 [observer start];
38558 [[NSRunLoop currentRunLoop] run];
39559 }
40560