From 75da6a34d58d3ef23ff082a16a6252a61654565a Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 15 Mar 2026 20:07:24 -0700 Subject: [PATCH] feat: fx window stuff --- config/reaper.ts | 7 + readme.md | 14 +- src/Mac.ts | 303 ++++++++++++++---- src/Mac/frontmost_app_helper.m | 552 ++++++++++++++++++++++++++++++++- 4 files changed, 799 insertions(+), 77 deletions(-) diff --git a/config/reaper.ts b/config/reaper.ts index 94ab0006f7e6992a8dfb2b5895a13a0f5576e53f..ec630446e860bde309457964262d0da16d641140 100644 --- a/config/reaper.ts +++ b/config/reaper.ts @@ -10,6 +10,9 @@ export default config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) => console.info(transport); se.leds.audioOnly = transport.recording; }); + mac.on("window", (window) => { + se.leds.videoOnly = Boolean(window?.title.startsWith("FX:")); + }); // Keyboard Actions SpeedEditor.camNumbersToNumpad(se, mac); @@ -41,6 +44,10 @@ export default config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) => } }); se.onPress("videoOnly", () => { + if (mac.window?.title.startsWith("FX:") && mac.mainWindow) { + mac.focusMainWindow(); + return; + } reaper.runAction("track-view-fx-chain-for-current-last-touched-track"); }); diff --git a/readme.md b/readme.md index 501565a4b684b6e76e01b53aa9ac4141d728c886..5c1d5a4c2b04e476e9e41bd3eaaaa90bf5d53e44 100644 --- a/readme.md +++ b/readme.md @@ -37,7 +37,9 @@ Bind to the Mac desktop interface. ```ts const mac = await Mac.open(); -mac.on() +mac.on("app-change", (bundle) => { + console.info("Current App: " + bundle); +}); ``` ### REAPER @@ -48,7 +50,15 @@ With the help of an OSC extension, REAPER can be controlled with TypeScript. import { Reaper } from "@clo/creative-control/Reaper.ts"; const reaper = new Reaper(); - +reaper.on("transport", (transport) => { + console.info( + transport.recording + ? "You are recording" + : transport.playing + ? "Playing" + : "Stopped" + ); +}); ``` Setup: diff --git a/src/Mac.ts b/src/Mac.ts index 9dea64babeef44e18c82e437fe7c2b1d6530bf9f..65156b17908fa60da604a4f8730dba4bae7ade19 100644 --- a/src/Mac.ts +++ b/src/Mac.ts @@ -10,7 +10,6 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const APP_MONITOR_START_TIMEOUT_MS = 1000; -const FRONT_ASN_PATTERN = /ASN:[^\]\s]+/; const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/; const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); const FRONTMOST_APP_HELPER_SOURCE_PATH = join( @@ -24,16 +23,14 @@ const FRONTMOST_APP_HELPER_BINARY_PATH = join( ); const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m"); const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper"); -const FRONTMOST_APP_SCRIPT = [ - 'ObjC.import("AppKit");', - "const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;", - "if (!app || ObjC.unwrap(app) === null) {", - ' console.log("");', - "} else {", - " const bundleId = ObjC.unwrap(app.bundleIdentifier);", - ' console.log(bundleId ?? "");', - "}", -].join("\n"); +const FRONTMOST_HELPER_READ_ONCE_FLAG = "--once"; +const FRONTMOST_HELPER_FOCUS_WINDOW_FLAG = "--focus-window"; +const FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG = "--focus-main-window"; +const DEFAULT_OBJECTIVE_C_FRAMEWORKS = ["AppKit", "Foundation"] as const; +const FRONTMOST_APP_HELPER_FRAMEWORKS = [ + ...DEFAULT_OBJECTIVE_C_FRAMEWORKS, + "ApplicationServices", +] as const; const KEYBOARD_EVENT_SCRIPT = [ 'ObjC.import("ApplicationServices");', "function run(argv) {", @@ -239,6 +236,13 @@ type KeyboardAction = { isDown: boolean; }; +type FrontmostState = { + bundleId: string | null; + windows: readonly Mac.Window[]; +}; + +const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]); + export class Mac extends Events { static readonly keyCodes = KEY_CODES; static readonly keyNames = KEY_NAMES; @@ -249,6 +253,7 @@ export class Mac extends Events { #appMonitorBuffer = ""; #appMonitorStartup: Promise | null = null; #currentApp: string | null = null; + #windows: readonly Mac.Window[] = EMPTY_WINDOWS; #keyboardQueue: Promise = Promise.resolve(); private constructor(_options: Mac.Options = {}) { @@ -269,6 +274,18 @@ export class Mac extends Events { return this.#currentApp; } + get windows(): readonly Mac.Window[] { + return this.#windows; + } + + get window(): Mac.Window | null { + return getFocusedWindow(this.#windows); + } + + get mainWindow(): Mac.Window | null { + return getMainWindow(this.#windows); + } + async start() { if (this.#closed) { throw new Error("Cannot start a closed Mac instance"); @@ -304,7 +321,37 @@ export class Mac extends Events { this.#assertOpen("Cannot focus an app from a closed Mac instance"); await execFileAsync("/usr/bin/open", ["-b", bundleId]); - await this.#syncCurrentApp(); + await this.#syncFrontmostState(); + } + + async focusWindow(window: Mac.Window | number) { + this.#assertOpen("Cannot focus a window from a closed Mac instance"); + + const helperPath = await ensureFrontmostAppHelperBinary(); + const windowId = resolveWindowId(window); + try { + await execFileAsync(helperPath, [ + FRONTMOST_HELPER_FOCUS_WINDOW_FLAG, + String(windowId), + ]); + } catch (error) { + throw new Error(formatWindowFocusError(error, `window ${windowId}`)); + } + + await this.#syncFrontmostState(); + } + + async focusMainWindow() { + this.#assertOpen("Cannot focus the main window from a closed Mac instance"); + + const helperPath = await ensureFrontmostAppHelperBinary(); + try { + await execFileAsync(helperPath, [FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG]); + } catch (error) { + throw new Error(formatWindowFocusError(error, "the main window")); + } + + await this.#syncFrontmostState(); } async pressKey(key: Mac.Key, options: Mac.KeyPressOptions = {}) { @@ -493,21 +540,31 @@ export class Mac extends Events { .replace(/\r$/, ""); this.#appMonitorBuffer = this.#appMonitorBuffer.slice(newlineIndex + 1); sawLine = true; - this.#setCurrentApp(normalizeBundleId(line)); + this.#applyFrontmostState(parseFrontmostStateLine(line)); } } - async #syncCurrentApp() { + async #syncFrontmostState() { try { - return this.#setCurrentApp(await this.#readFrontmostApp()); + return this.#applyFrontmostState(await this.#readFrontmostState()); } catch (error) { this.#emitMonitorError(error); - return null; + return { bundleId: this.#currentApp, windows: this.#windows }; } } + #applyFrontmostState(state: FrontmostState) { + this.#setCurrentApp(state.bundleId); + this.#setWindows(state.windows); + return state; + } + #setCurrentApp(bundleId: string | null) { - if (this.#closed || !bundleId) { + if (this.#closed) { + return bundleId; + } + if (!bundleId) { + this.#currentApp = null; return bundleId; } if (bundleId !== this.#currentApp) { @@ -517,37 +574,32 @@ export class Mac extends Events { return bundleId; } - async #readFrontmostApp() { - const fromLaunchServices = await this.#readFrontmostAppFromLaunchServices(); - if (fromLaunchServices) { - return fromLaunchServices; + #setWindows(windows: readonly Mac.Window[]) { + if (this.#closed) { + return windows; } + if (windowsEqual(this.#windows, windows)) { + return windows; + } + + const previousWindow = getFocusedWindow(this.#windows); + this.#windows = windows; + this.emit("windows", windows); - const { stdout } = await execFileAsync("/usr/bin/osascript", [ - "-l", - "JavaScript", - "-e", - FRONTMOST_APP_SCRIPT, - ]); + const nextWindow = getFocusedWindow(windows); + if (!windowEquals(previousWindow, nextWindow)) { + this.emit("window", nextWindow); + } - return normalizeBundleId(stdout); + return windows; } - async #readFrontmostAppFromLaunchServices() { - const { stdout } = await execFileAsync("/usr/bin/lsappinfo", ["front"]); - const frontSpecifier = extractFrontAppSpecifier(stdout); - if (!frontSpecifier) { - return null; - } - - const { stdout: info } = await execFileAsync("/usr/bin/lsappinfo", [ - "info", - "-only", - "bundleid", - frontSpecifier, + async #readFrontmostState() { + const helperPath = await ensureFrontmostAppHelperBinary(); + const { stdout } = await execFileAsync(helperPath, [ + FRONTMOST_HELPER_READ_ONCE_FLAG, ]); - - return normalizeBundleId(info); + return parseFrontmostState(stdout); } async #enqueueKeyboardOperation(operation: () => Promise) { @@ -613,10 +665,19 @@ export declare namespace Mac { durationMs?: number; } + export interface Window { + readonly id: number; + readonly title: string; + readonly main: boolean; + readonly focused: boolean; + } + export type EventMap = { "app-change": [bundleId: string]; "close": []; "error": [error: unknown]; + "window": [window: Window | null]; + "windows": [windows: readonly Window[]]; }; export type BundleId = @@ -639,8 +700,38 @@ export declare namespace Mac { | "org.whispersystems.signal-desktop"; } -function normalizeBundleId(stdout: string) { - const directBundleId = stdout.trim(); +function parseFrontmostState(stdout: string): FrontmostState { + const line = stdout.split(/\r?\n/u).find((candidate) => candidate.trim() !== ""); + return parseFrontmostStateLine(line ?? ""); +} + +function parseFrontmostStateLine(line: string): FrontmostState { + const trimmed = line.trim(); + if (trimmed === "") { + return { bundleId: null, windows: EMPTY_WINDOWS }; + } + + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const record = parsed as { bundleId?: unknown; windows?: unknown }; + return { + bundleId: normalizeBundleId(record.bundleId), + windows: normalizeWindows(record.windows), + }; + } + } catch { + // Fall back to the older helper output if a stale binary is still running. + } + + return { + bundleId: normalizeBundleId(trimmed), + windows: EMPTY_WINDOWS, + }; +} + +function normalizeBundleId(value: unknown) { + const directBundleId = typeof value === "string" ? value.trim() : ""; const bundleId = BUNDLE_ID_PATTERN.exec(directBundleId)?.[0] ?? directBundleId; if ( @@ -653,14 +744,103 @@ function normalizeBundleId(stdout: string) { return bundleId; } -function extractFrontAppSpecifier(stdout: string) { - const trimmed = stdout.trim(); - if (trimmed === "" || trimmed === "[ NULL ]" || trimmed === "NULL") { +function normalizeWindows(value: unknown): readonly Mac.Window[] { + if (!Array.isArray(value) || value.length === 0) { + return EMPTY_WINDOWS; + } + + const windows: Mac.Window[] = []; + for (const candidate of value) { + const window = normalizeWindow(candidate); + if (window) { + windows.push(window); + } + } + + return windows.length === 0 ? EMPTY_WINDOWS : Object.freeze(windows); +} + +function normalizeWindow(value: unknown): Mac.Window | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const record = value as { + id?: unknown; + title?: unknown; + main?: unknown; + focused?: unknown; + }; + const id = normalizeWindowId(record.id); + if (id === null) { + return null; + } + + return Object.freeze({ + id, + title: typeof record.title === "string" ? record.title : "", + main: Boolean(record.main), + focused: Boolean(record.focused), + }); +} + +function normalizeWindowId(value: unknown) { + if (!Number.isSafeInteger(value)) { return null; } + return value; +} - return FRONT_ASN_PATTERN.exec(trimmed)?.[0] ?? - trimmed.replace(/^\[\s*|\s*\]$/g, ""); +function resolveWindowId(window: Mac.Window | number) { + const windowId = typeof window === "number" ? window : window?.id; + const normalizedId = normalizeWindowId(windowId); + if (normalizedId === null) { + throw new Error( + "Mac.focusWindow expects a window object returned by mac.windows or a numeric window id.", + ); + } + return normalizedId; +} + +function getFocusedWindow(windows: readonly Mac.Window[]) { + return windows.find((window) => window.focused) ?? null; +} + +function getMainWindow(windows: readonly Mac.Window[]) { + return windows.find((window) => window.main) ?? null; +} + +function windowsEqual( + left: readonly Mac.Window[], + right: readonly Mac.Window[], +) { + if (left === right) { + return true; + } + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if (!windowEquals(left[index], right[index])) { + return false; + } + } + + return true; +} + +function windowEquals(left: Mac.Window | null, right: Mac.Window | null) { + if (left === right) { + return true; + } + if (!left || !right) { + return left === right; + } + return left.id === right.id && + left.title === right.title && + left.main === right.main && + left.focused === right.focused; } function normalizeDelayMs(value: number, name: string) { @@ -801,6 +981,7 @@ async function ensureToastHelperBinary() { toastHelperBinaryPromise = buildObjectiveCHelperBinary( TOAST_HELPER_SOURCE_PATH, TOAST_HELPER_BINARY_PATH, + DEFAULT_OBJECTIVE_C_FRAMEWORKS, ).catch((error) => { toastHelperBinaryPromise = null; throw error; @@ -815,6 +996,7 @@ async function ensureFrontmostAppHelperBinary() { frontmostAppHelperBinaryPromise = buildObjectiveCHelperBinary( FRONTMOST_APP_HELPER_SOURCE_PATH, FRONTMOST_APP_HELPER_BINARY_PATH, + FRONTMOST_APP_HELPER_FRAMEWORKS, ).catch((error) => { frontmostAppHelperBinaryPromise = null; throw error; @@ -827,6 +1009,7 @@ async function ensureFrontmostAppHelperBinary() { async function buildObjectiveCHelperBinary( sourcePath: string, binaryPath: string, + frameworks: readonly string[], ) { await mkdir(HELPER_BUILD_DIR, { recursive: true }); @@ -836,16 +1019,12 @@ async function buildObjectiveCHelperBinary( ]); if (!binaryStats || binaryStats.mtimeMs < sourceStats.mtimeMs) { - await execFileAsync("/usr/bin/clang", [ - "-fobjc-arc", - "-framework", - "AppKit", - "-framework", - "Foundation", - sourcePath, - "-o", - binaryPath, - ]); + const args = ["-fobjc-arc"]; + for (const framework of frameworks) { + args.push("-framework", framework); + } + args.push(sourcePath, "-o", binaryPath); + await execFileAsync("/usr/bin/clang", args); } return binaryPath; @@ -873,6 +1052,12 @@ function formatToastDispatchError(error: unknown) { return `Failed to show a macOS toast.${suffix}`; } +function formatWindowFocusError(error: unknown, target: string) { + const details = extractCommandErrorOutput(error); + const suffix = details ? ` ${details}` : ""; + return `Failed to focus ${target}.${suffix}`; +} + function formatAppMonitorExitMessage(code: number | null, signal: NodeJS.Signals | null) { if (signal) { return `The macOS app monitor stopped after receiving ${signal}.`; diff --git a/src/Mac/frontmost_app_helper.m b/src/Mac/frontmost_app_helper.m index 5156ed7051795c89dd3b2bffe6b7f4e9ee01e3c0..a4c6735f1b5695f35851efa78f839fbf81732f1b 100644 --- a/src/Mac/frontmost_app_helper.m +++ b/src/Mac/frontmost_app_helper.m @@ -1,40 +1,560 @@ #import +#import #import -static void PrintBundleIdentifier(NSRunningApplication *application) { - NSString *bundleIdentifier = application.bundleIdentifier ?: @""; - const char *utf8 = bundleIdentifier.UTF8String ?: ""; - fprintf(stdout, "%s\n", utf8); +static CFStringRef const kCloverAXWindowNumberAttribute = CFSTR("AXWindowNumber"); + +static NSArray *CopyWindowElements(AXUIElementRef applicationElement) { + if (!applicationElement) { + return @[]; + } + + CFTypeRef value = NULL; + AXError error = AXUIElementCopyAttributeValue( + applicationElement, + kAXWindowsAttribute, + &value + ); + if (error != kAXErrorSuccess || !value) { + if (value) { + CFRelease(value); + } + return @[]; + } + if (CFGetTypeID(value) != CFArrayGetTypeID()) { + CFRelease(value); + return @[]; + } + + return CFBridgingRelease(value); +} + +static NSString *CopyStringAttribute(AXUIElementRef element, CFStringRef attribute) { + if (!element) { + return nil; + } + + CFTypeRef value = NULL; + AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); + if (error != kAXErrorSuccess || !value) { + if (value) { + CFRelease(value); + } + return nil; + } + if (CFGetTypeID(value) != CFStringGetTypeID()) { + CFRelease(value); + return nil; + } + + return CFBridgingRelease(value); +} + +static NSNumber *CopyNumberAttribute(AXUIElementRef element, CFStringRef attribute) { + if (!element) { + return nil; + } + + CFTypeRef value = NULL; + AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); + if (error != kAXErrorSuccess || !value) { + if (value) { + CFRelease(value); + } + return nil; + } + if (CFGetTypeID(value) != CFNumberGetTypeID()) { + CFRelease(value); + return nil; + } + + return CFBridgingRelease(value); +} + +static BOOL CopyBoolAttribute( + AXUIElementRef element, + CFStringRef attribute, + BOOL fallback +) { + if (!element) { + return fallback; + } + + CFTypeRef value = NULL; + AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); + if (error != kAXErrorSuccess || !value) { + if (value) { + CFRelease(value); + } + return fallback; + } + + BOOL result = fallback; + CFTypeID typeId = CFGetTypeID(value); + if (typeId == CFBooleanGetTypeID()) { + result = CFBooleanGetValue((CFBooleanRef)value); + } else if (typeId == CFNumberGetTypeID()) { + int numericValue = 0; + if (CFNumberGetValue((CFNumberRef)value, kCFNumberIntType, &numericValue)) { + result = numericValue != 0; + } + } + + CFRelease(value); + return result; +} + +static NSNumber *WindowIdentifierForElement(AXUIElementRef windowElement, NSInteger index) { + NSNumber *windowNumber = CopyNumberAttribute(windowElement, kCloverAXWindowNumberAttribute); + return windowNumber ?: @(-(index + 1)); +} + +static NSDictionary *SnapshotWindow( + AXUIElementRef windowElement, + NSInteger index +) { + if (!windowElement) { + return nil; + } + + return @{ + @"id": WindowIdentifierForElement(windowElement, index), + @"title": CopyStringAttribute(windowElement, kAXTitleAttribute) ?: @"", + @"main": @(CopyBoolAttribute(windowElement, kAXMainAttribute, NO)), + @"focused": @(CopyBoolAttribute(windowElement, kAXFocusedAttribute, NO)), + }; +} + +static NSArray *> *SnapshotWindowsForApplicationElement( + AXUIElementRef applicationElement +) { + NSArray *windowElements = CopyWindowElements(applicationElement); + NSMutableArray *> *snapshots = [NSMutableArray arrayWithCapacity:windowElements.count]; + + for (NSUInteger index = 0; index < windowElements.count; index++) { + AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index]; + NSDictionary *snapshot = SnapshotWindow(windowElement, index); + if (snapshot) { + [snapshots addObject:snapshot]; + } + } + + return snapshots; +} + +static void PrintState( + NSRunningApplication *application, + NSArray *> *windows +) { + NSDictionary *payload = @{ + @"bundleId": application.bundleIdentifier ?: [NSNull null], + @"windows": windows ?: @[], + }; + + NSError *error = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error]; + if (!json || error) { + const char *message = error.localizedDescription.UTF8String ?: "Failed to encode state"; + fprintf(stderr, "%s\n", message); + return; + } + + fwrite(json.bytes, 1, json.length, stdout); + fputc('\n', stdout); fflush(stdout); } -@interface FrontmostAppObserver : NSObject +static void PrintCurrentState(void) { + NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication; + id applicationElement = application + ? CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)) + : nil; + PrintState( + application, + SnapshotWindowsForApplicationElement((__bridge AXUIElementRef)applicationElement) + ); +} + +static AXUIElementRef CopyWindowElementForIdentifier( + AXUIElementRef applicationElement, + long long targetIdentifier +) { + NSArray *windowElements = CopyWindowElements(applicationElement); + for (NSUInteger index = 0; index < windowElements.count; index++) { + AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index]; + if (WindowIdentifierForElement(windowElement, index).longLongValue == targetIdentifier) { + return (AXUIElementRef)CFRetain(windowElement); + } + } + return NULL; +} + +static AXUIElementRef CopyMainWindowElement(AXUIElementRef applicationElement) { + if (!applicationElement) { + return NULL; + } + + CFTypeRef value = NULL; + AXError error = AXUIElementCopyAttributeValue( + applicationElement, + kAXMainWindowAttribute, + &value + ); + if (error == kAXErrorSuccess && value) { + if (CFGetTypeID(value) == AXUIElementGetTypeID()) { + return (AXUIElementRef)value; + } + CFRelease(value); + } + + NSArray *windowElements = CopyWindowElements(applicationElement); + for (NSUInteger index = 0; index < windowElements.count; index++) { + AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index]; + if (CopyBoolAttribute(windowElement, kAXMainAttribute, NO)) { + return (AXUIElementRef)CFRetain(windowElement); + } + } + + return NULL; +} + +static BOOL FocusWindowElement( + NSRunningApplication *application, + AXUIElementRef windowElement, + NSString **failure +) { + if (!application || !windowElement) { + if (failure) { + *failure = @"No window is available to focus."; + } + return NO; + } + + [application activateWithOptions:NSApplicationActivateAllWindows]; + + AXError unminimizeError = AXUIElementSetAttributeValue( + windowElement, + kAXMinimizedAttribute, + kCFBooleanFalse + ); + AXError raiseError = AXUIElementPerformAction(windowElement, kAXRaiseAction); + AXError mainError = AXUIElementSetAttributeValue( + windowElement, + kAXMainAttribute, + kCFBooleanTrue + ); + AXError focusedError = AXUIElementSetAttributeValue( + windowElement, + kAXFocusedAttribute, + kCFBooleanTrue + ); + + BOOL succeeded = + unminimizeError == kAXErrorSuccess || + raiseError == kAXErrorSuccess || + mainError == kAXErrorSuccess || + focusedError == kAXErrorSuccess; + if (succeeded) { + return YES; + } + + if (failure) { + *failure = [NSString stringWithFormat: + @"Could not focus the requested window (unminimize=%d raise=%d main=%d focused=%d).", + (int)unminimizeError, + (int)raiseError, + (int)mainError, + (int)focusedError + ]; + } + return NO; +} + +static BOOL FocusWindowWithIdentifier(long long targetIdentifier) { + if (!AXIsProcessTrusted()) { + fprintf(stderr, "%s\n", "Accessibility access is required to focus windows."); + return NO; + } + + NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication; + if (!application) { + fprintf(stderr, "%s\n", "No frontmost application is available."); + return NO; + } + + id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)); + AXUIElementRef windowElement = CopyWindowElementForIdentifier( + (__bridge AXUIElementRef)applicationElement, + targetIdentifier + ); + if (!windowElement) { + fprintf(stderr, "Window %lld was not found.\n", targetIdentifier); + return NO; + } + + NSString *failure = nil; + BOOL focused = FocusWindowElement(application, windowElement, &failure); + CFRelease(windowElement); + if (!focused) { + fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the requested window."); + } + return focused; +} + +static BOOL FocusMainWindow(void) { + if (!AXIsProcessTrusted()) { + fprintf(stderr, "%s\n", "Accessibility access is required to focus windows."); + return NO; + } + + NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication; + if (!application) { + fprintf(stderr, "%s\n", "No frontmost application is available."); + return NO; + } + + id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)); + AXUIElementRef windowElement = CopyMainWindowElement((__bridge AXUIElementRef)applicationElement); + if (!windowElement) { + fprintf(stderr, "%s\n", "The frontmost application does not report a main window."); + return NO; + } + + NSString *failure = nil; + BOOL focused = FocusWindowElement(application, windowElement, &failure); + CFRelease(windowElement); + if (!focused) { + fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the main window."); + } + return focused; +} + +@interface FrontmostAppObserver : NSObject { + @private + id _accessibilityObserver; + id _applicationElement; + NSMutableArray *_windowElements; +} +- (void)start; +- (void)handleActivation:(NSNotification *)notification; +- (void)handleAccessibilityNotification:(NSString *)notification; @end +static void FrontmostAccessibilityCallback( + AXObserverRef observer, + AXUIElementRef element, + CFStringRef notification, + void *context +) { + @autoreleasepool { + FrontmostAppObserver *frontmostObserver = (__bridge FrontmostAppObserver *)context; + [frontmostObserver handleAccessibilityNotification:(__bridge NSString *)notification]; + } +} + @implementation FrontmostAppObserver +- (instancetype)init { + self = [super init]; + if (self) { + _windowElements = [NSMutableArray array]; + } + return self; +} + +- (void)dealloc { + [NSWorkspace.sharedWorkspace.notificationCenter removeObserver:self]; + [self clearObservedApplication]; +} + +- (AXObserverRef)observerRef { + return (__bridge AXObserverRef)_accessibilityObserver; +} + +- (AXUIElementRef)applicationElementRef { + return (__bridge AXUIElementRef)_applicationElement; +} + +- (void)start { + NSWorkspace *workspace = NSWorkspace.sharedWorkspace; + [workspace.notificationCenter addObserver:self + selector:@selector(handleActivation:) + name:NSWorkspaceDidActivateApplicationNotification + object:nil]; + [self observeApplication:workspace.frontmostApplication]; +} + - (void)handleActivation:(NSNotification *)notification { NSRunningApplication *application = notification.userInfo[NSWorkspaceApplicationKey]; - PrintBundleIdentifier(application); + [self observeApplication:application]; +} + +- (void)handleAccessibilityNotification:(NSString *)notification { + (void)notification; + [self refreshWindowsAndEmit]; +} + +- (void)observeApplication:(NSRunningApplication *)application { + [self clearObservedApplication]; + if (!application) { + PrintState(nil, @[]); + return; + } + + _applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier)); + + AXObserverRef observer = NULL; + AXError observerError = AXObserverCreate( + application.processIdentifier, + FrontmostAccessibilityCallback, + &observer + ); + if (observerError == kAXErrorSuccess && observer) { + _accessibilityObserver = CFBridgingRelease(observer); + CFRunLoopAddSource( + CFRunLoopGetCurrent(), + AXObserverGetRunLoopSource(self.observerRef), + kCFRunLoopDefaultMode + ); + + [self addApplicationNotification:kAXFocusedWindowChangedNotification]; + [self addApplicationNotification:kAXMainWindowChangedNotification]; + [self addApplicationNotification:kAXWindowCreatedNotification]; + } + + [self refreshWindowsAndEmit]; +} + +- (void)clearObservedApplication { + [self clearWindowNotifications]; + + AXObserverRef observer = self.observerRef; + AXUIElementRef applicationElement = self.applicationElementRef; + if (observer && applicationElement) { + AXObserverRemoveNotification(observer, applicationElement, kAXFocusedWindowChangedNotification); + AXObserverRemoveNotification(observer, applicationElement, kAXMainWindowChangedNotification); + AXObserverRemoveNotification(observer, applicationElement, kAXWindowCreatedNotification); + } + if (observer) { + CFRunLoopRemoveSource( + CFRunLoopGetCurrent(), + AXObserverGetRunLoopSource(observer), + kCFRunLoopDefaultMode + ); + } + + _accessibilityObserver = nil; + _applicationElement = nil; +} + +- (void)clearWindowNotifications { + AXObserverRef observer = self.observerRef; + if (observer) { + for (id windowObject in _windowElements) { + AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject; + AXObserverRemoveNotification(observer, windowElement, kAXTitleChangedNotification); + AXObserverRemoveNotification(observer, windowElement, kAXUIElementDestroyedNotification); + } + } + + [_windowElements removeAllObjects]; +} + +- (void)addApplicationNotification:(CFStringRef)notification { + AXObserverRef observer = self.observerRef; + AXUIElementRef applicationElement = self.applicationElementRef; + if (!observer || !applicationElement) { + return; + } + + AXObserverAddNotification( + observer, + applicationElement, + notification, + (__bridge void *)self + ); +} + +- (void)addWindowNotification:(CFStringRef)notification element:(AXUIElementRef)windowElement { + AXObserverRef observer = self.observerRef; + if (!observer || !windowElement) { + return; + } + + AXObserverAddNotification( + observer, + windowElement, + notification, + (__bridge void *)self + ); +} + +- (NSArray *> *)refreshObservedWindows { + [self clearWindowNotifications]; + + NSArray *windowElements = CopyWindowElements(self.applicationElementRef); + NSMutableArray *> *windows = [NSMutableArray arrayWithCapacity:windowElements.count]; + + for (NSUInteger index = 0; index < windowElements.count; index++) { + id windowObject = windowElements[index]; + AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject; + NSDictionary *snapshot = SnapshotWindow(windowElement, index); + if (!snapshot) { + continue; + } + + [_windowElements addObject:windowObject]; + [self addWindowNotification:kAXTitleChangedNotification element:windowElement]; + [self addWindowNotification:kAXUIElementDestroyedNotification element:windowElement]; + [windows addObject:snapshot]; + } + + return windows; +} + +- (void)refreshWindowsAndEmit { + PrintState( + NSWorkspace.sharedWorkspace.frontmostApplication, + [self refreshObservedWindows] + ); } @end -int main(void) { +int main(int argc, const char *argv[]) { @autoreleasepool { [NSApplication sharedApplication]; [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited]; + if (argc > 1) { + NSString *command = [NSString stringWithUTF8String:argv[1]]; + if ([command isEqualToString:@"--once"]) { + PrintCurrentState(); + return 0; + } + if ([command isEqualToString:@"--focus-window"]) { + if (argc < 3) { + fprintf(stderr, "%s\n", "Missing window identifier."); + return 1; + } + char *end = NULL; + long long windowIdentifier = strtoll(argv[2], &end, 10); + if (end == argv[2] || (end && *end != '\0')) { + fprintf(stderr, "%s\n", "Window identifier must be an integer."); + return 1; + } + return FocusWindowWithIdentifier(windowIdentifier) ? 0 : 1; + } + if ([command isEqualToString:@"--focus-main-window"]) { + return FocusMainWindow() ? 0 : 1; + } + + fprintf(stderr, "Unknown argument: %s\n", argv[1]); + return 1; + } + FrontmostAppObserver *observer = [FrontmostAppObserver new]; - NSWorkspace *workspace = NSWorkspace.sharedWorkspace; - NSNotificationCenter *center = workspace.notificationCenter; - - PrintBundleIdentifier(workspace.frontmostApplication); - [center addObserver:observer - selector:@selector(handleActivation:) - name:NSWorkspaceDidActivateApplicationNotification - object:nil]; - + [observer start]; [[NSRunLoop currentRunLoop] run]; } -- 2.54.0