| 1 | // @effect-diagnostics nodeBuiltinImport:off -- This native boundary keeps one JXA process open while the permission helper tracks System Settings. |
| 2 | import * as NodeChildProcess from "node:child_process"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | import type * as Electron from "electron"; |
| 5 | |
| 6 | const 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 | ); |
| 15 | export type SettingsWindow = typeof SettingsWindow.Type; |
| 16 | const 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. |
| 21 | const SETTINGS_WINDOW_SCRIPT = ` |
| 22 | ObjC.import("CoreGraphics"); |
| 23 | ObjC.import("AppKit"); |
| 24 | function 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. */ |
| 60 | export 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. */ |
| 73 | export 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 | } |