| author | |
| committer | |
| log | 75da6a34d58d3ef23ff082a16a6252a61654565a |
| tree | 41a2892537caf63cfea736ba51af845802d1fce5 |
| parent | d9c781c70166411ff54304f0e28780029ba28e3e |
| signature |
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 | }); | ||
| 13 | 16 | ||
| 14 | // Keyboard Actions | 17 | // 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 | }); |
| 46 | 53 |
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 | ```ts | 37 | ```ts |
| 38 | const mac = await Mac.open(); | 38 | const mac = await Mac.open(); |
| 39 | 39 | ||
| 40 | mac.on() | 40 | mac.on("app-change", (bundle) => { |
| 41 | console.info("Current App: " + bundle); | ||
| 42 | }); | ||
| 41 | ``` | 43 | ``` |
| 42 | 44 | ||
| 43 | ### REAPER | 45 | ### 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. |
| 48 | import { Reaper } from "@clo/creative-control/Reaper.ts"; | 50 | import { Reaper } from "@clo/creative-control/Reaper.ts"; |
| 49 | 51 | ||
| 50 | const reaper = new Reaper(); | 52 | const reaper = new Reaper(); |
| 51 | 53 | reaper.on("transport", (transport) => { | |
| 54 | console.info( | ||
| 55 | transport.recording | ||
| 56 | ? "You are recording" | ||
| 57 | : transport.playing | ||
| 58 | ? "Playing" | ||
| 59 | : "Stopped" | ||
| 60 | ); | ||
| 61 | }); | ||
| 52 | ``` | 62 | ``` |
| 53 | 63 | ||
| 54 | Setup: | 64 | Setup: |
src/Mac.ts+244-59| ... | @@ -10,7 +10,6 @@ import { promisify } from "node:util"; | ... | @@ -10,7 +10,6 @@ import { promisify } from "node:util"; |
| 10 | const execFileAsync = promisify(execFile); | 10 | const execFileAsync = promisify(execFile); |
| 11 | 11 | ||
| 12 | const APP_MONITOR_START_TIMEOUT_MS = 1000; | 12 | const APP_MONITOR_START_TIMEOUT_MS = 1000; |
| 13 | const FRONT_ASN_PATTERN = /ASN:[^\]\s]+/; | ||
| 14 | const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/; | 13 | const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/; |
| 15 | const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); | 14 | const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); |
| 16 | const FRONTMOST_APP_HELPER_SOURCE_PATH = join( | 15 | const 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 | ); |
| 25 | const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m"); | 24 | const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m"); |
| 26 | const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper"); | 25 | const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper"); |
| 27 | const FRONTMOST_APP_SCRIPT = [ | 26 | const FRONTMOST_HELPER_READ_ONCE_FLAG = "--once"; |
| 28 | 'ObjC.import("AppKit");', | 27 | const FRONTMOST_HELPER_FOCUS_WINDOW_FLAG = "--focus-window"; |
| 29 | "const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;", | 28 | const FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG = "--focus-main-window"; |
| 30 | "if (!app || ObjC.unwrap(app) === null) {", | 29 | const DEFAULT_OBJECTIVE_C_FRAMEWORKS = ["AppKit", "Foundation"] as const; |
| 31 | ' console.log("");', | 30 | const 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"); | ||
| 37 | const KEYBOARD_EVENT_SCRIPT = [ | 34 | const 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 | }; |
| 241 | 238 | ||
| 239 | type FrontmostState = { | ||
| 240 | bundleId: string | null; | ||
| 241 | windows: readonly Mac.Window[]; | ||
| 242 | }; | ||
| 243 | |||
| 244 | const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]); | ||
| 245 | |||
| 242 | export class Mac extends Events<Mac.EventMap> { | 246 | export 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(); |
| 253 | 258 | ||
| 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 | } |
| 271 | 276 | ||
| 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"); |
| 305 | 322 | ||
| 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 | } |
| 309 | 356 | ||
| 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 | } |
| 499 | 546 | ||
| 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 | } |
| 508 | 555 | ||
| 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 | } |
| 519 | 576 | ||
| 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 | } |
| 525 | 584 | ||
| 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 | } | ||
| 535 | 588 | ||
| 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 | } |
| 542 | 593 | ||
| 543 | const { stdout: info } = await execFileAsync("/usr/bin/lsappinfo", [ | 594 | return windows; |
| 544 | "info", | 595 | } |
| 545 | "-only", | ||
| 546 | "bundleid", | ||
| 547 | frontSpecifier, | ||
| 548 | ]); | ||
| 549 | 596 | ||
| 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 | } |
| 552 | 604 | ||
| 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 | } |
| 615 | 667 | ||
| 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 | }; |
| 621 | 682 | ||
| 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 | } |
| 641 | 702 | ||
| 642 | function normalizeBundleId(stdout: string) { | 703 | function 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 | |||
| 708 | function 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 | |||
| 733 | function 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 | } |
| 655 | 746 | ||
| 656 | function extractFrontAppSpecifier(stdout: string) { | 747 | function 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 | |||
| 763 | function normalizeWindow(value: unknown): Mac.Window | null { | ||
| 764 | if (!value || typeof value !== "object" || Array.isArray(value)) { | ||
| 659 | return null; | 765 | return null; |
| 660 | } | 766 | } |
| 661 | 767 | ||
| 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 | |||
| 787 | function normalizeWindowId(value: unknown) { | ||
| 788 | if (!Number.isSafeInteger(value)) { | ||
| 789 | return null; | ||
| 790 | } | ||
| 791 | return value; | ||
| 792 | } | ||
| 793 | |||
| 794 | function 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 | |||
| 805 | function getFocusedWindow(windows: readonly Mac.Window[]) { | ||
| 806 | return windows.find((window) => window.focused) ?? null; | ||
| 807 | } | ||
| 808 | |||
| 809 | function getMainWindow(windows: readonly Mac.Window[]) { | ||
| 810 | return windows.find((window) => window.main) ?? null; | ||
| 811 | } | ||
| 812 | |||
| 813 | function 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 | |||
| 833 | function 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 | } |
| 665 | 845 | ||
| 666 | function normalizeDelayMs(value: number, name: string) { | 846 | function 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() { |
| 827 | async function buildObjectiveCHelperBinary( | 1009 | async 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 }); |
| 832 | 1015 | ||
| ... | @@ -836,16 +1019,12 @@ async function buildObjectiveCHelperBinary( | ... | @@ -836,16 +1019,12 @@ async function buildObjectiveCHelperBinary( |
| 836 | ]); | 1019 | ]); |
| 837 | 1020 | ||
| 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 | } |
| 850 | 1029 | ||
| 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 | } |
| 875 | 1054 | ||
| 1055 | function formatWindowFocusError(error: unknown, target: string) { | ||
| 1056 | const details = extractCommandErrorOutput(error); | ||
| 1057 | const suffix = details ? ` ${details}` : ""; | ||
| 1058 | return `Failed to focus ${target}.${suffix}`; | ||
| 1059 | } | ||
| 1060 | |||
| 876 | function formatAppMonitorExitMessage(code: number | null, signal: NodeJS.Signals | null) { | 1061 | function 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> |
| 3 | 4 | ||
| 4 | static void PrintBundleIdentifier(NSRunningApplication *application) { | 5 | static CFStringRef const kCloverAXWindowNumberAttribute = CFSTR("AXWindowNumber"); |
| 5 | NSString *bundleIdentifier = application.bundleIdentifier ?: @""; | 6 | |
| 6 | const char *utf8 = bundleIdentifier.UTF8String ?: ""; | 7 | static 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 | |||
| 32 | static 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 | |||
| 53 | static 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 | |||
| 74 | static 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 | |||
| 107 | static NSNumber *WindowIdentifierForElement(AXUIElementRef windowElement, NSInteger index) { | ||
| 108 | NSNumber *windowNumber = CopyNumberAttribute(windowElement, kCloverAXWindowNumberAttribute); | ||
| 109 | return windowNumber ?: @(-(index + 1)); | ||
| 110 | } | ||
| 111 | |||
| 112 | static 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 | |||
| 128 | static 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 | |||
| 145 | static 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 | } |
| 10 | 166 | ||
| 11 | @interface FrontmostAppObserver : NSObject | 167 | static 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 | |||
| 178 | static 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 | |||
| 192 | static 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 | |||
| 221 | static 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 | |||
| 273 | static 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 | |||
| 304 | static 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 | @end | 341 | @end |
| 13 | 342 | ||
| 343 | static 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 FrontmostAppObserver | 355 | @implementation FrontmostAppObserver |
| 15 | 356 | ||
| 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 | } |
| 20 | 521 | ||
| 21 | @end | 522 | @end |
| 22 | 523 | ||
| 23 | int main(void) { | 524 | int main(int argc, const char *argv[]) { |
| 24 | @autoreleasepool { | 525 | @autoreleasepool { |
| 25 | [NSApplication sharedApplication]; | 526 | [NSApplication sharedApplication]; |
| 26 | [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited]; | 527 | [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited]; |
| 27 | 528 | ||
| 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 | } | ||
| 31 | 551 | ||
| 32 | PrintBundleIdentifier(workspace.frontmostApplication); | 552 | fprintf(stderr, "Unknown argument: %s\n", argv[1]); |
| 33 | [center addObserver:observer | 553 | return 1; |
| 34 | selector:@selector(handleActivation:) | 554 | } |
| 35 | name:NSWorkspaceDidActivateApplicationNotification | ||
| 36 | object:nil]; | ||
| 37 | 555 | ||
| 556 | FrontmostAppObserver *observer = [FrontmostAppObserver new]; | ||
| 557 | [observer start]; | ||
| 38 | [[NSRunLoop currentRunLoop] run]; | 558 | [[NSRunLoop currentRunLoop] run]; |
| 39 | } | 559 | } |
| 40 | 560 |