| 1 | // @effect-diagnostics nodeBuiltinImport:off -- This platform boundary asks the OS for its foreground window with Node. |
| 2 | |
| 3 | import * as NodeChildProcess from "node:child_process"; |
| 4 | |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | |
| 7 | import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts"; |
| 8 | |
| 9 | /** |
| 10 | * The foreground window as the snapshot service needs it. `id` is the |
| 11 | * CGWindowNumber on macOS and the HWND on Windows, which is what the capture |
| 12 | * backends key on. |
| 13 | */ |
| 14 | export type ActiveWindow = { |
| 15 | readonly platform: "macos" | "windows"; |
| 16 | readonly id: number; |
| 17 | readonly title: string; |
| 18 | readonly bounds: { |
| 19 | readonly x: number; |
| 20 | readonly y: number; |
| 21 | readonly width: number; |
| 22 | readonly height: number; |
| 23 | }; |
| 24 | readonly owner: { |
| 25 | readonly name: string; |
| 26 | readonly processId: number; |
| 27 | readonly path: string; |
| 28 | readonly bundleId?: string; |
| 29 | }; |
| 30 | }; |
| 31 | |
| 32 | const MAC_LOOKUP_TIMEOUT_MS = 5_000; |
| 33 | |
| 34 | const MacActiveWindow = Schema.Struct({ |
| 35 | id: Schema.Number, |
| 36 | title: Schema.String, |
| 37 | bounds: Schema.Struct({ |
| 38 | x: Schema.Number, |
| 39 | y: Schema.Number, |
| 40 | width: Schema.Number, |
| 41 | height: Schema.Number, |
| 42 | }), |
| 43 | owner: Schema.Struct({ |
| 44 | name: Schema.String, |
| 45 | processId: Schema.Number, |
| 46 | path: Schema.String, |
| 47 | bundleId: Schema.String, |
| 48 | }), |
| 49 | }); |
| 50 | const decodeMacActiveWindow = Schema.decodeUnknownSync(Schema.fromJsonString(MacActiveWindow)); |
| 51 | |
| 52 | // The frontmost app's first on-screen, layer-0 window in front-to-back order is |
| 53 | // the active window. Window titles need Screen Recording, which the snapshot |
| 54 | // service has already requested by the time this runs. |
| 55 | const MAC_LOOKUP_SCRIPT = ` |
| 56 | ObjC.import("CoreGraphics"); |
| 57 | ObjC.import("AppKit"); |
| 58 | function run() { |
| 59 | const app = $.NSWorkspace.sharedWorkspace.frontmostApplication; |
| 60 | if (app.isNil()) return ""; |
| 61 | const pid = app.processIdentifier; |
| 62 | const list = $.CGWindowListCopyWindowInfo( |
| 63 | $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, |
| 64 | $.kCGNullWindowID, |
| 65 | ); |
| 66 | $.CFMakeCollectable(list); |
| 67 | const count = $.CFArrayGetCount(list); |
| 68 | for (let i = 0; i < count; i++) { |
| 69 | const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i)); |
| 70 | if (w.objectForKey("kCGWindowOwnerPID").js !== pid) continue; |
| 71 | if (w.objectForKey("kCGWindowLayer").js !== 0) continue; |
| 72 | const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds")); |
| 73 | return JSON.stringify({ |
| 74 | id: w.objectForKey("kCGWindowNumber").js, |
| 75 | title: String(ObjC.unwrap(w.objectForKey("kCGWindowName")) || ""), |
| 76 | bounds: { x: b.X, y: b.Y, width: b.Width, height: b.Height }, |
| 77 | owner: { |
| 78 | name: String(app.localizedName.js || ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || ""), |
| 79 | processId: pid, |
| 80 | path: String(app.bundleURL.path.js || ""), |
| 81 | bundleId: String(app.bundleIdentifier.js || ""), |
| 82 | }, |
| 83 | }); |
| 84 | } |
| 85 | return ""; |
| 86 | }`; |
| 87 | |
| 88 | function runMacLookup(): Promise<string> { |
| 89 | return new Promise((resolve, reject) => { |
| 90 | NodeChildProcess.execFile( |
| 91 | "/usr/bin/osascript", |
| 92 | ["-l", "JavaScript", "-e", MAC_LOOKUP_SCRIPT], |
| 93 | { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 64 * 1024 }, |
| 94 | (error, stdout) => { |
| 95 | if (error) reject(error); |
| 96 | else resolve(stdout); |
| 97 | }, |
| 98 | ); |
| 99 | }); |
| 100 | } |
| 101 | |
| 102 | async function macActiveWindow(): Promise<ActiveWindow | undefined> { |
| 103 | const output = (await runMacLookup()).trim(); |
| 104 | if (!output) return undefined; |
| 105 | const window = decodeMacActiveWindow(output); |
| 106 | return { |
| 107 | platform: "macos", |
| 108 | id: window.id, |
| 109 | title: window.title, |
| 110 | bounds: window.bounds, |
| 111 | owner: { |
| 112 | name: window.owner.name, |
| 113 | processId: window.owner.processId, |
| 114 | path: window.owner.path, |
| 115 | ...(window.owner.bundleId ? { bundleId: window.owner.bundleId } : {}), |
| 116 | }, |
| 117 | }; |
| 118 | } |
| 119 | |
| 120 | async function windowsActiveWindow(): Promise<ActiveWindow | undefined> { |
| 121 | const api = await loadWindowsForegroundApi(); |
| 122 | const handle = api.getForegroundWindow(); |
| 123 | if (handle === 0n) return undefined; |
| 124 | const bounds = api.getWindowRect(handle); |
| 125 | if (!bounds) return undefined; |
| 126 | const { processId } = api.getWindowThreadAndProcessId(handle); |
| 127 | const path = processId === 0 ? "" : api.getProcessImagePath(processId); |
| 128 | const name = path.split(/[\\/]/).pop() ?? ""; |
| 129 | return { |
| 130 | platform: "windows", |
| 131 | id: Number(handle), |
| 132 | title: api.getWindowText(handle), |
| 133 | bounds, |
| 134 | owner: { name, processId, path }, |
| 135 | }; |
| 136 | } |
| 137 | |
| 138 | /** Resolve the OS foreground window, or `undefined` when there is none. */ |
| 139 | export function activeWindow(platform: NodeJS.Platform): Promise<ActiveWindow | undefined> { |
| 140 | if (platform === "darwin") return macActiveWindow(); |
| 141 | if (platform === "win32") return windowsActiveWindow(); |
| 142 | return Promise.resolve(undefined); |
| 143 | } |