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
4import * as NodeChildProcess from "node:child_process";
5
6import type {
7 CapturedWindowAccessibilityContext,
8 SnapShotAccessibilityRequest,
9} from "./SnapShotAccessibility.ts";
10
11const START_TIMEOUT_MS = 1_000;
12const RESULT_TIMEOUT_MS = 4_000;
13
14export type AccessibilityRead = {
15 readonly started: Promise<void>;
16 readonly result: Promise<CapturedWindowAccessibilityContext | undefined>;
17};
18
19export type AccessibilityProcess = {
20 readonly alive: boolean;
21 readonly read: (request: SnapShotAccessibilityRequest) => AccessibilityRead;
22 readonly close: () => void;
23};
24
25export type AccessibilityProcessPool = {
26 readonly warm: () => void;
27 readonly cool: () => void;
28 readonly read: (request: SnapShotAccessibilityRequest) => AccessibilityRead;
29 readonly close: () => void;
30};
31
32type AccessibilityMessage =
33 | "ready"
34 | "started"
35 | { readonly type: "result"; readonly context?: CapturedWindowAccessibilityContext };
36
37const completedRead = (context?: CapturedWindowAccessibilityContext): AccessibilityRead => ({
38 started: Promise.resolve(),
39 result: Promise.resolve(context),
40});
41
42const unavailableProcess = (): AccessibilityProcess => ({
43 alive: false,
44 read: () => completedRead(),
45 close: () => undefined,
46});
47
48export 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
136export 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}