1// @effect-diagnostics nodeBuiltinImport:off -- This native boundary keeps one JXA process open while the permission helper tracks System Settings.
2import * as NodeChildProcess from "node:child_process";
3import * as Schema from "effect/Schema";
4import type * as Electron from "electron";
5
6const SettingsWindow = Schema.NullOr(
7 Schema.Struct({
8 x: Schema.Finite,
9 y: Schema.Finite,
10 width: Schema.Finite,
11 height: Schema.Finite,
12 frontmost: Schema.Boolean,
13 }),
14);
15export type SettingsWindow = typeof SettingsWindow.Type;
16const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(SettingsWindow));
17
18// Window bounds and owner PIDs are available before Screen Recording is granted.
19// Use the bundle identifier rather than the localized app/window title. A single
20// process avoids launching osascript repeatedly while the user moves Settings.
21const SETTINGS_WINDOW_SCRIPT = `
22ObjC.import("CoreGraphics");
23ObjC.import("AppKit");
24function run() {
25 let previous = "";
26 while (true) {
27 const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.systempreferences");
28 let result = null;
29 if (apps.count > 0) {
30 const pid = apps.objectAtIndex(0).processIdentifier;
31 const front = $.NSWorkspace.sharedWorkspace.frontmostApplication;
32 const list = $.CGWindowListCopyWindowInfo(
33 $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements,
34 $.kCGNullWindowID
35 );
36 if (list) {
37 $.CFMakeCollectable(list);
38 const count = $.CFArrayGetCount(list);
39 for (let i = 0; i < count; i++) {
40 const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i));
41 if (w.objectForKey("kCGWindowOwnerPID").js !== pid || w.objectForKey("kCGWindowLayer").js !== 0) continue;
42 const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds"));
43 if (b.Width < 500 || b.Height < 350) continue;
44 result = { x: b.X, y: b.Y, width: b.Width, height: b.Height, frontmost: !front.isNil() && front.processIdentifier === pid };
45 break;
46 }
47 }
48 }
49 const line = JSON.stringify(result);
50 if (line !== previous) {
51 const data = $(line + "\\n").dataUsingEncoding($.NSUTF8StringEncoding);
52 $.NSFileHandle.fileHandleWithStandardOutput.writeData(data);
53 previous = line;
54 }
55 $.NSThread.sleepForTimeInterval(result && result.frontmost ? 0.5 : 1);
56 }
57}`;
58
59/** Place the panel inside Settings' content column, above its bottom edge. */
60export function settingsHelperBounds(settings: NonNullable<SettingsWindow>): Electron.Rectangle {
61 const sidebarWidth = 216;
62 const inset = 16;
63 const width = Math.min(560, settings.width - sidebarWidth - inset * 2);
64 return {
65 x: Math.round(settings.x + sidebarWidth + (settings.width - sidebarWidth - width) / 2),
66 y: Math.round(settings.y + settings.height - 140 - inset),
67 width: Math.round(width),
68 height: 140,
69 };
70}
71
72/** Track only metadata; this does not request Accessibility or Screen Recording. */
73export function watchMacSettingsWindow(
74 onChange: (window: SettingsWindow) => void,
75 onUnavailable: () => void,
76): () => void {
77 const child = NodeChildProcess.spawn(
78 "/usr/bin/osascript",
79 ["-l", "JavaScript", "-e", SETTINGS_WINDOW_SCRIPT],
80 {
81 stdio: ["ignore", "pipe", "ignore"],
82 },
83 );
84 let pending = "";
85 let closed = false;
86 child.stdout.setEncoding("utf8");
87 child.stdout.on("data", (chunk: string) => {
88 pending += chunk;
89 let end: number;
90 while ((end = pending.indexOf("\n")) !== -1) {
91 const line = pending.slice(0, end);
92 pending = pending.slice(end + 1);
93 if (closed) return;
94 let settings: SettingsWindow;
95 try {
96 settings = decodeSettingsWindow(line);
97 } catch {
98 onUnavailable();
99 continue;
100 }
101 onChange(settings);
102 }
103 });
104 const onExit = () => {
105 if (!closed) onUnavailable();
106 };
107 child.on("error", onExit);
108 child.on("exit", onExit);
109 return () => {
110 closed = true;
111 child.stdout.removeAllListeners("data");
112 child.kill();
113 };
114}