Skip to content
Source
Sign inSign up

SnapShotAccessibilityProcess.ts

175 lines5.2 KB

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's default macOS Helper executable lacks the main app's Accessibility grant.
53 execPath: process.execPath,
54 env: {
55 ...process.env,
56 ELECTRON_RUN_AS_NODE: "1",
57 },
58 execArgv: [],
59 stdio: ["ignore", "ignore", "inherit", "ipc"],
60 });
61 } catch {
62 return unavailableProcess();
63 }
64​
65 let ready = false;
66 let request: SnapShotAccessibilityRequest | undefined;
67 let startedResolve: (() => void) | undefined;
68 let resultResolve: ((value: CapturedWindowAccessibilityContext | undefined) => void) | undefined;
69 let startTimeout: ReturnType<typeof setTimeout> | undefined;
70 let resultTimeout: ReturnType<typeof setTimeout> | undefined;
71 let settled = false;
72 let settledContext: CapturedWindowAccessibilityContext | undefined;
73​
74 const clearTimers = () => {
75 if (startTimeout) clearTimeout(startTimeout);
76 if (resultTimeout) clearTimeout(resultTimeout);
77 };
78 const finish = (context?: CapturedWindowAccessibilityContext) => {
79 if (settled) return;
80 settled = true;
81 settledContext = context;
82 clearTimers();
83 startedResolve?.();
84 resultResolve?.(context);
85 worker.kill();
86 };
87 const sendRequest = () => {
88 if (!ready || !request || settled) return;
89 startTimeout ??= setTimeout(() => finish(), START_TIMEOUT_MS);
90 startTimeout.unref();
91 try {
92 worker.send(request, (error) => {
93 if (error) finish();
94 });
95 } catch {
96 finish();
97 }
98 };
99​
100 worker.on("message", (rawMessage) => {
101 const message = rawMessage as AccessibilityMessage;
102 if (message === "ready") {
103 ready = true;
104 sendRequest();
105 } else if (message === "started") {
106 if (startTimeout) clearTimeout(startTimeout);
107 startedResolve?.();
108 } else {
109 finish(message.context);
110 }
111 });
112 worker.once("error", () => finish());
113 worker.once("exit", () => finish());
114​
115 return {
116 get alive() {
117 return !settled;
118 },
119 read: (nextRequest) => {
120 if (settled) return completedRead(settledContext);
121 request = nextRequest;
122 const started = Promise.withResolvers<void>();
123 const result = Promise.withResolvers<CapturedWindowAccessibilityContext | undefined>();
124 startedResolve = started.resolve;
125 resultResolve = result.resolve;
126 resultTimeout = setTimeout(() => finish(), RESULT_TIMEOUT_MS);
127 resultTimeout.unref();
128 sendRequest();
129 return { started: started.promise, result: result.promise };
130 },
131 close: () => finish(),
132 };
134​
135export function makeSnapShotAccessibilityProcessPool(workerPath: string): AccessibilityProcessPool {
136 let standby: AccessibilityProcess | undefined;
137 const active = new Set<AccessibilityProcess>();
138 let closed = false;
139​
140 const warm = () => {
141 if (closed || standby?.alive) return;
142 standby = startSnapShotAccessibilityProcess(workerPath);
143 };
144 const cool = () => {
145 standby?.close();
146 standby = undefined;
147 };
148​
149 return {
150 warm,
151 cool,
152 read: (request) => {
153 if (closed) return completedRead();
154 const workerProcess = standby?.alive
155 ? standby
156 : startSnapShotAccessibilityProcess(workerPath);
157 standby = undefined;
158 warm();
159 active.add(workerProcess);
160 const read = workerProcess.read(request);
161 void read.result.then(
162 () => active.delete(workerProcess),
163 () => active.delete(workerProcess),
164 );
165 return read;
166 },
167 close: () => {
168 if (closed) return;
169 closed = true;
170 cool();
171 for (const workerProcess of active) workerProcess.close();
172 active.clear();
173 },
174 };