Skip to content
Source
Sign inSign up

ActiveWindow.ts

180 lines5.9 KB

1// @effect-diagnostics nodeBuiltinImport:off -- This platform boundary asks the OS for its foreground window with Node.
2​
3import * as NodeChildProcess from "node:child_process";
4​
5import * as Schema from "effect/Schema";
6​
7import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts";
8​
9/** id is CGWindowNumber on macOS or HWND on Windows, matching the capture backend. */
10export type ActiveWindow = {
11 readonly platform: "macos" | "windows";
12 readonly id: number;
13 readonly title: string;
14 readonly bounds: {
15 readonly x: number;
16 readonly y: number;
17 readonly width: number;
18 readonly height: number;
19 };
20 readonly owner: {
21 readonly name: string;
22 readonly processId: number;
23 readonly path: string;
24 readonly bundleId?: string;
25 };
26};
27​
28const MAC_LOOKUP_TIMEOUT_MS = 5_000;
29​
30const MAC_HELPER_STRIP_MAX_THICKNESS_RATIO = 0.25;
31​
32const MacActiveWindowLookup = Schema.Struct({
33 owner: Schema.Struct({
34 name: Schema.String,
35 processId: Schema.Number,
36 path: Schema.String,
37 bundleId: Schema.String,
38 }),
39 windows: Schema.Array(
40 Schema.Struct({
41 id: Schema.Number,
42 title: Schema.String,
43 bounds: Schema.Struct({
44 x: Schema.Number,
45 y: Schema.Number,
46 width: Schema.Number,
47 height: Schema.Number,
48 }),
49 alpha: Schema.Number,
50 }),
51 ),
52});
53const decodeMacActiveWindowLookup = Schema.decodeUnknownSync(
54 Schema.fromJsonString(MacActiveWindowLookup),
55);
56​
57const MAC_LOOKUP_SCRIPT = `
58ObjC.import("CoreGraphics");
59ObjC.import("AppKit");
60function run() {
61 const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;
62 if (app.isNil()) return "";
63 const pid = app.processIdentifier;
64 let ownerName = String(app.localizedName.js || "");
65 const list = $.CGWindowListCopyWindowInfo(
66 $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements,
67 $.kCGNullWindowID,
68 );
69 $.CFMakeCollectable(list);
70 const count = $.CFArrayGetCount(list);
71 const windows = [];
72 for (let i = 0; i < count; i++) {
73 const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i));
74 if (w.objectForKey("kCGWindowOwnerPID").js !== pid) continue;
75 if (w.objectForKey("kCGWindowLayer").js !== 0) continue;
76 if (!ownerName) {
77 ownerName = String(ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || "");
78 }
79 const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds"));
80 windows.push({
81 id: w.objectForKey("kCGWindowNumber").js,
82 title: String(ObjC.unwrap(w.objectForKey("kCGWindowName")) || ""),
83 bounds: { x: b.X, y: b.Y, width: b.Width, height: b.Height },
84 alpha: w.objectForKey("kCGWindowAlpha").js,
85 });
86 }
87 return JSON.stringify({
88 owner: {
89 name: ownerName,
90 processId: pid,
91 path: String(app.bundleURL.path.js || ""),
92 bundleId: String(app.bundleIdentifier.js || ""),
93 },
94 windows,
95 });
96}`;
97​
98function runMacLookup(): Promise<string> {
99 return new Promise((resolve, reject) => {
100 NodeChildProcess.execFile(
101 "/usr/bin/osascript",
102 ["-l", "JavaScript", "-e", MAC_LOOKUP_SCRIPT],
103 { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 1024 * 1024 },
104 (error, stdout) => {
105 if (error) reject(error);
106 else resolve(stdout);
107 },
108 );
109 });
111​
112async function macActiveWindow(): Promise<ActiveWindow | undefined> {
113 const output = (await runMacLookup()).trim();
114 if (!output) return undefined;
115 const lookup = decodeMacActiveWindowLookup(output);
116 const visible = lookup.windows.filter(
117 (window) => window.alpha > 0 && window.bounds.width > 0 && window.bounds.height > 0,
118 );
119 // Helpers belong to the content behind them, which may not be the app's largest window.
120 const window = visible.find((candidate, index) => {
121 if (candidate.title.trim() !== "") return true;
122 const bounds = candidate.bounds;
123 return !visible.some((reference, referenceIndex) => {
124 if (referenceIndex <= index) return false;
125 return (
126 (bounds.x === reference.bounds.x &&
127 bounds.width === reference.bounds.width &&
128 bounds.y <= reference.bounds.y &&
129 reference.bounds.y - bounds.y <=
130 reference.bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO &&
131 bounds.height <= bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO &&
132 bounds.height <= reference.bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO) ||
133 (bounds.y === reference.bounds.y &&
134 bounds.height === reference.bounds.height &&
135 bounds.x <= reference.bounds.x &&
136 reference.bounds.x - bounds.x <=
137 reference.bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO &&
138 bounds.width <= bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO &&
139 bounds.width <= reference.bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO)
140 );
141 });
142 });
143 if (!window) return undefined;
144 return {
145 platform: "macos",
146 id: window.id,
147 title: window.title,
148 bounds: window.bounds,
149 owner: {
150 name: lookup.owner.name,
151 processId: lookup.owner.processId,
152 path: lookup.owner.path,
153 ...(lookup.owner.bundleId ? { bundleId: lookup.owner.bundleId } : {}),
154 },
155 };
157​
158async function windowsActiveWindow(): Promise<ActiveWindow | undefined> {
159 const api = await loadWindowsForegroundApi();
160 const handle = api.getForegroundWindow();
161 if (handle === 0n) return undefined;
162 const bounds = api.getWindowRect(handle);
163 if (!bounds) return undefined;
164 const { processId } = api.getWindowThreadAndProcessId(handle);
165 const path = processId === 0 ? "" : api.getProcessImagePath(processId);
166 const name = path.split(/[\\/]/).pop() ?? "";
167 return {
168 platform: "windows",
169 id: Number(handle),
170 title: api.getWindowText(handle),
171 bounds,
172 owner: { name, processId, path },
173 };
175​
176export function activeWindow(platform: NodeJS.Platform): Promise<ActiveWindow | undefined> {
177 if (platform === "darwin") return macActiveWindow();
178 if (platform === "win32") return windowsActiveWindow();
179 return Promise.resolve(undefined);