| 1 | // @effect-diagnostics globalTimers:off -- Helper timeouts run at child-process callback boundaries outside Effect fibers. |
| 2 | // @effect-diagnostics nodeBuiltinImport:off -- This desktop-only helper owns a Node child process. |
| 3 | |
| 4 | import * as NodeChildProcess from "node:child_process"; |
| 5 | |
| 6 | import type { |
| 7 | CapturedWindowAccessibilityContext, |
| 8 | SnapShotAccessibilityRequest, |
| 9 | } from "./SnapShotAccessibility.ts"; |
| 10 | |
| 11 | const START_TIMEOUT_MS = 1_000; |
| 12 | const RESULT_TIMEOUT_MS = 4_000; |
| 13 | |
| 14 | export type AccessibilityRead = { |
| 15 | readonly started: Promise<void>; |
| 16 | readonly result: Promise<CapturedWindowAccessibilityContext | undefined>; |
| 17 | }; |
| 18 | |
| 19 | export type AccessibilityProcess = { |
| 20 | readonly alive: boolean; |
| 21 | readonly read: (request: SnapShotAccessibilityRequest) => AccessibilityRead; |
| 22 | readonly close: () => void; |
| 23 | }; |
| 24 | |
| 25 | export type AccessibilityProcessPool = { |
| 26 | readonly warm: () => void; |
| 27 | readonly cool: () => void; |
| 28 | readonly read: (request: SnapShotAccessibilityRequest) => AccessibilityRead; |
| 29 | readonly close: () => void; |
| 30 | }; |
| 31 | |
| 32 | type AccessibilityMessage = |
| 33 | | "ready" |
| 34 | | "started" |
| 35 | | { readonly type: "result"; readonly context?: CapturedWindowAccessibilityContext }; |
| 36 | |
| 37 | const completedRead = (context?: CapturedWindowAccessibilityContext): AccessibilityRead => ({ |
| 38 | started: Promise.resolve(), |
| 39 | result: Promise.resolve(context), |
| 40 | }); |
| 41 | |
| 42 | const unavailableProcess = (): AccessibilityProcess => ({ |
| 43 | alive: false, |
| 44 | read: () => completedRead(), |
| 45 | close: () => undefined, |
| 46 | }); |
| 47 | |
| 48 | export function startSnapShotAccessibilityProcess(workerPath: string): AccessibilityProcess { |
| 49 | let worker: NodeChildProcess.ChildProcess; |
| 50 | try { |
| 51 | worker = NodeChildProcess.fork(workerPath, ["read"], { |
| 52 | // Electron defaults to its Helper executable on macOS, which does not |
| 53 | // share the main app's Accessibility grant checked during setup. |
| 54 | execPath: process.execPath, |
| 55 | env: { |
| 56 | ...process.env, |
| 57 | ELECTRON_RUN_AS_NODE: "1", |
| 58 | }, |
| 59 | execArgv: [], |
| 60 | stdio: ["ignore", "ignore", "inherit", "ipc"], |
| 61 | }); |
| 62 | } catch { |
| 63 | return unavailableProcess(); |
| 64 | } |
| 65 | |
| 66 | let ready = false; |
| 67 | let request: SnapShotAccessibilityRequest | undefined; |
| 68 | let startedResolve: (() => void) | undefined; |
| 69 | let resultResolve: ((value: CapturedWindowAccessibilityContext | undefined) => void) | undefined; |
| 70 | let startTimeout: ReturnType<typeof setTimeout> | undefined; |
| 71 | let resultTimeout: ReturnType<typeof setTimeout> | undefined; |
| 72 | let settled = false; |
| 73 | let settledContext: CapturedWindowAccessibilityContext | undefined; |
| 74 | |
| 75 | const clearTimers = () => { |
| 76 | if (startTimeout) clearTimeout(startTimeout); |
| 77 | if (resultTimeout) clearTimeout(resultTimeout); |
| 78 | }; |
| 79 | const finish = (context?: CapturedWindowAccessibilityContext) => { |
| 80 | if (settled) return; |
| 81 | settled = true; |
| 82 | settledContext = context; |
| 83 | clearTimers(); |
| 84 | startedResolve?.(); |
| 85 | resultResolve?.(context); |
| 86 | worker.kill(); |
| 87 | }; |
| 88 | const sendRequest = () => { |
| 89 | if (!ready || !request || settled) return; |
| 90 | startTimeout ??= setTimeout(() => finish(), START_TIMEOUT_MS); |
| 91 | startTimeout.unref(); |
| 92 | try { |
| 93 | worker.send(request, (error) => { |
| 94 | if (error) finish(); |
| 95 | }); |
| 96 | } catch { |
| 97 | finish(); |
| 98 | } |
| 99 | }; |
| 100 | |
| 101 | worker.on("message", (rawMessage) => { |
| 102 | const message = rawMessage as AccessibilityMessage; |
| 103 | if (message === "ready") { |
| 104 | ready = true; |
| 105 | sendRequest(); |
| 106 | } else if (message === "started") { |
| 107 | if (startTimeout) clearTimeout(startTimeout); |
| 108 | startedResolve?.(); |
| 109 | } else { |
| 110 | finish(message.context); |
| 111 | } |
| 112 | }); |
| 113 | worker.once("error", () => finish()); |
| 114 | worker.once("exit", () => finish()); |
| 115 | |
| 116 | return { |
| 117 | get alive() { |
| 118 | return !settled; |
| 119 | }, |
| 120 | read: (nextRequest) => { |
| 121 | if (settled) return completedRead(settledContext); |
| 122 | request = nextRequest; |
| 123 | const started = Promise.withResolvers<void>(); |
| 124 | const result = Promise.withResolvers<CapturedWindowAccessibilityContext | undefined>(); |
| 125 | startedResolve = started.resolve; |
| 126 | resultResolve = result.resolve; |
| 127 | resultTimeout = setTimeout(() => finish(), RESULT_TIMEOUT_MS); |
| 128 | resultTimeout.unref(); |
| 129 | sendRequest(); |
| 130 | return { started: started.promise, result: result.promise }; |
| 131 | }, |
| 132 | close: () => finish(), |
| 133 | }; |
| 134 | } |
| 135 | |
| 136 | export function makeSnapShotAccessibilityProcessPool(workerPath: string): AccessibilityProcessPool { |
| 137 | let standby: AccessibilityProcess | undefined; |
| 138 | const active = new Set<AccessibilityProcess>(); |
| 139 | let closed = false; |
| 140 | |
| 141 | const warm = () => { |
| 142 | if (closed || standby?.alive) return; |
| 143 | standby = startSnapShotAccessibilityProcess(workerPath); |
| 144 | }; |
| 145 | const cool = () => { |
| 146 | standby?.close(); |
| 147 | standby = undefined; |
| 148 | }; |
| 149 | |
| 150 | return { |
| 151 | warm, |
| 152 | cool, |
| 153 | read: (request) => { |
| 154 | if (closed) return completedRead(); |
| 155 | const workerProcess = standby?.alive |
| 156 | ? standby |
| 157 | : startSnapShotAccessibilityProcess(workerPath); |
| 158 | standby = undefined; |
| 159 | warm(); |
| 160 | active.add(workerProcess); |
| 161 | const read = workerProcess.read(request); |
| 162 | void read.result.then( |
| 163 | () => active.delete(workerProcess), |
| 164 | () => active.delete(workerProcess), |
| 165 | ); |
| 166 | return read; |
| 167 | }, |
| 168 | close: () => { |
| 169 | if (closed) return; |
| 170 | closed = true; |
| 171 | cool(); |
| 172 | for (const workerProcess of active) workerProcess.close(); |
| 173 | active.clear(); |
| 174 | }, |
| 175 | }; |
| 176 | } |