staysfixed 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,913 @@
1
+ /**
2
+ * The half of the macOS adapter that touches the machine.
3
+ *
4
+ * Everything here either runs a program or parses what one printed. The adapter next door
5
+ * decides what any of it MEANS. They are split because the parsing is the part that has to be
6
+ * tested without a Mac app on screen, and a test that needs a window open is a test nobody
7
+ * runs.
8
+ *
9
+ * The measurements that shaped all of this were taken on 2026-08-31 and are written down in
10
+ * the opening comment of `macos.js`. The short version: the accessibility layer is already on
11
+ * every Mac, it is reachable from `osascript` with nothing installed, and it costs about four
12
+ * milliseconds per control.
13
+ */
14
+
15
+ import fsp from 'node:fs/promises';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { spawn } from 'node:child_process';
19
+ import { sizeBucket } from './contract.js';
20
+ import { endOfChild, letGoOf } from './process.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Numbers, and where each one came from
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * How many controls to read from one window before stopping and saying so.
28
+ *
29
+ * Windows can afford 4000 because UI Automation reads a whole cached subtree in one pass at
30
+ * about 2ms a control. macOS has no cached subtree read at all — every attribute is a live
31
+ * Mach round trip into the app being watched — and it was measured at 3.9ms a control on this
32
+ * machine. 4000 of those is sixteen seconds for ONE read, and the settle loop reads more than
33
+ * once, so the same cap here would mean a minute and a half per window. 1500 keeps a full read
34
+ * near six seconds, which is the same order as everything else this tool does. Anything past
35
+ * it is reported as unchecked, never as absent.
36
+ */
37
+ export const MAX_TREE_NODES = 1500;
38
+
39
+ /**
40
+ * The wall clock the probe stops at whatever it has read.
41
+ *
42
+ * The cap above is a count, and a count is not enough on its own: one app answering slowly
43
+ * turns 1500 controls into an unbounded wait. Activity Monitor, measured on this machine, had
44
+ * read 5,251 controls after twenty seconds and had not finished. So there is a clock as well as
45
+ * a count, and hitting either one is reported.
46
+ */
47
+ export const TREE_BUDGET_MS = 12_000;
48
+
49
+ /**
50
+ * How long any single accessibility question may take before it is abandoned.
51
+ *
52
+ * `AXUIElementSetMessagingTimeout` is the only thing standing between this tool and an app
53
+ * that has stopped pumping its event loop. Without it a hung app hangs the run, because every
54
+ * AX read is a synchronous message into that app's main thread. Two seconds is long enough for
55
+ * a busy app and short enough that a dead one is noticed rather than waited on.
56
+ */
57
+ export const AX_MESSAGE_TIMEOUT_S = 2;
58
+
59
+ /** How long to let an app get its first window up before calling it a no-show. */
60
+ export const WINDOW_WAIT_MS = 20_000;
61
+
62
+ /** A window picture bigger than this is dropped rather than carried back inline. */
63
+ export const MAX_SHOT_BYTES = 4 * 1024 * 1024;
64
+
65
+ /** How long one probe call may take overall, including `osascript`'s own 110-150ms start. */
66
+ const PROBE_LIMIT_MS = 60_000;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // The probe
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * The JavaScript-for-Automation program that reads the screen.
74
+ *
75
+ * It is fed to `osascript -l JavaScript` on STANDARD INPUT, with one JSON argument after it,
76
+ * so nothing is ever written to disk: there is nothing left behind, nothing to go stale, and
77
+ * nothing for the person who owns this Mac to find later and wonder about. That is the same
78
+ * decision the Windows probe made, for the same reason.
79
+ *
80
+ * Why JavaScript for Automation and not a compiled Swift probe. The design assumed a small
81
+ * Swift binary would have to be built and shipped, exactly as the Windows design assumed a
82
+ * .NET one. It was checked on a real Mac on 2026-08-31 before a line was written, and it is
83
+ * the wrong answer for three reasons. `osascript` is on every Mac and needs no developer
84
+ * tools, while `swiftc` needs Xcode or the Command Line Tools and most people running a
85
+ * regression check on a Mac app do have those — but the ones who do not would be asked to
86
+ * install four gigabytes to read a checkbox. A compiled probe would have to be signed, or
87
+ * Gatekeeper is entitled to refuse it. And JavaScript for Automation's Objective-C bridge
88
+ * publishes the ENTIRE accessibility C API through BridgeSupport, so the compiled version
89
+ * would have no more reach than this one. It reads a real AppKit window in 57 milliseconds
90
+ * with nothing installed.
91
+ *
92
+ * Three things are enforced inside the probe rather than in JavaScript, because this is the
93
+ * only side that can enforce them:
94
+ *
95
+ * - Every element is read with ONE round trip, not thirteen.
96
+ * `AXUIElementCopyMultipleAttributeValues` fetches all twelve attributes and the child
97
+ * list together. The first version of this asked for each attribute separately and could
98
+ * not finish a large window in two minutes.
99
+ * - Every read has a clock on it and a count on it, and says which one it hit.
100
+ * - The tree is cross-checked against CoreGraphics' own window list before it is believed.
101
+ * That is a completely different mechanism — the window server, rather than the app's own
102
+ * accessibility responder — which is what makes it a real second opinion rather than the
103
+ * same question asked twice.
104
+ *
105
+ * @returns {string} JavaScript for Automation, ready for `osascript -l JavaScript -`
106
+ */
107
+ export function macosProbeScript() {
108
+ return `
109
+ ObjC.import('Cocoa');
110
+ ObjC.import('ApplicationServices');
111
+
112
+ // The twelve things worth knowing about a control, fetched together in one message to the
113
+ // app. AXChildren rides along in the same call so walking the tree costs nothing extra.
114
+ var WANT = ['AXRole','AXSubrole','AXTitle','AXDescription','AXValue','AXIdentifier',
115
+ 'AXEnabled','AXHelp','AXPlaceholderValue','AXSelected','AXFocused','AXChildren'];
116
+ var WANT_CF = $(WANT.map(function (n) { return $(n); }));
117
+
118
+ // An out-parameter comes back as an opaque CFTypeRef that the bridge will not let you call
119
+ // CFArrayGetCount on. castRefToObject turns it into the NSArray it really is, and the
120
+ // elements of THAT array can be handed straight back into the AX functions. Casting them
121
+ // back with castObjectToRef fails; this took an afternoon to find and is why it is written
122
+ // down here rather than left to be rediscovered.
123
+ function attr(el, name) {
124
+ var r = Ref();
125
+ if ($.AXUIElementCopyAttributeValue(el, $(name), r) !== 0) return null;
126
+ return ObjC.castRefToObject(r[0]);
127
+ }
128
+
129
+ // A batched read hands back an error wrapper, not null, for anything the control does not
130
+ // have. Only strings and numbers are real answers; everything else is "it does not have one".
131
+ function plain(v) {
132
+ if (v === null || v === undefined) return null;
133
+ if (!v.isKindOfClass) return null;
134
+ if (v.isKindOfClass($.NSString)) return ObjC.unwrap(v);
135
+ if (v.isKindOfClass($.NSNumber)) return ObjC.unwrap(v);
136
+ return null;
137
+ }
138
+
139
+ function actionsOf(el) {
140
+ var r = Ref();
141
+ if ($.AXUIElementCopyActionNames(el, r) !== 0) return [];
142
+ var a = ObjC.castRefToObject(r[0]);
143
+ var out = [];
144
+ for (var i = 0; i < a.count; i++) out.push(ObjC.unwrap(a.objectAtIndex(i)));
145
+ return out.sort();
146
+ }
147
+
148
+ function batch(el) {
149
+ var r = Ref();
150
+ if ($.AXUIElementCopyMultipleAttributeValues(el, WANT_CF, 0, r) !== 0) return null;
151
+ return ObjC.castRefToObject(r[0]);
152
+ }
153
+
154
+ function appFor(pid) {
155
+ var app = $.AXUIElementCreateApplication(pid);
156
+ $.AXUIElementSetMessagingTimeout(app, ${AX_MESSAGE_TIMEOUT_S});
157
+ return app;
158
+ }
159
+
160
+ function axWindows(app) {
161
+ var w = attr(app, 'AXWindows');
162
+ return w === null ? null : w;
163
+ }
164
+
165
+ // The second opinion. CoreGraphics knows what is actually on the screen because the window
166
+ // server drew it; the accessibility tree knows what the app SAYS is on the screen. When the
167
+ // two disagree the accessibility answer is not trustworthy, and a tree that is not
168
+ // trustworthy must never be stored as though it were empty.
169
+ function cgWindowsFor(pid) {
170
+ var list = $.CGWindowListCopyWindowInfo(
171
+ $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, 0);
172
+ var arr = ObjC.castRefToObject(list);
173
+ var out = [];
174
+ for (var i = 0; i < arr.count; i++) {
175
+ var d = arr.objectAtIndex(i);
176
+ var owner = ObjC.unwrap(d.objectForKey('kCGWindowOwnerPID'));
177
+ if (pid >= 0 && owner !== pid) continue;
178
+ // Layer 0 is an ordinary window. Anything else is a menu, a tooltip, a shadow or a
179
+ // status item, and counting those against the accessibility tree would make the
180
+ // cross-check disagree for no reason.
181
+ if (ObjC.unwrap(d.objectForKey('kCGWindowLayer')) !== 0) continue;
182
+ out.push({
183
+ id: ObjC.unwrap(d.objectForKey('kCGWindowNumber')),
184
+ title: ObjC.unwrap(d.objectForKey('kCGWindowName')),
185
+ owner: ObjC.unwrap(d.objectForKey('kCGWindowOwnerName')),
186
+ pid: owner,
187
+ w: Math.round(ObjC.unwrap(d.objectForKey('kCGWindowBounds').objectForKey('Width'))),
188
+ h: Math.round(ObjC.unwrap(d.objectForKey('kCGWindowBounds').objectForKey('Height'))),
189
+ });
190
+ }
191
+ return out;
192
+ }
193
+
194
+ // Depth-first with an explicit stack rather than recursion. A deep tree in a scripting
195
+ // runtime with a small stack is a crash, and a crashed probe reads exactly like an app with
196
+ // no controls.
197
+ function readTree(win, limit, budgetMs, withActions) {
198
+ var t0 = $.NSDate.date;
199
+ var out = [];
200
+ var stack = [{ el: win, d: 0 }];
201
+ var truncated = false;
202
+ var ranOut = false;
203
+ while (stack.length > 0) {
204
+ if (out.length >= limit) { truncated = true; break; }
205
+ if (-t0.timeIntervalSinceNow * 1000 > budgetMs) { ranOut = true; break; }
206
+ var cur = stack.pop();
207
+ var b = batch(cur.el);
208
+ if (b === null) continue;
209
+ out.push({
210
+ d: cur.d,
211
+ role: plain(b.objectAtIndex(0)),
212
+ sub: plain(b.objectAtIndex(1)),
213
+ title: plain(b.objectAtIndex(2)),
214
+ desc: plain(b.objectAtIndex(3)),
215
+ value: plain(b.objectAtIndex(4)),
216
+ id: plain(b.objectAtIndex(5)),
217
+ on: plain(b.objectAtIndex(6)),
218
+ help: plain(b.objectAtIndex(7)),
219
+ hint: plain(b.objectAtIndex(8)),
220
+ sel: plain(b.objectAtIndex(9)),
221
+ foc: plain(b.objectAtIndex(10)),
222
+ can: withActions ? actionsOf(cur.el) : [],
223
+ });
224
+ var kids = b.objectAtIndex(11);
225
+ if (kids && kids.isKindOfClass && kids.isKindOfClass($.NSArray)) {
226
+ for (var k = kids.count - 1; k >= 0; k--) stack.push({ el: kids.objectAtIndex(k), d: cur.d + 1 });
227
+ }
228
+ }
229
+ return { nodes: out, truncated: truncated, ranOut: ranOut,
230
+ ms: Math.round(-t0.timeIntervalSinceNow * 1000) };
231
+ }
232
+
233
+ // What the window says it is, boiled down to one string, used only to decide whether two
234
+ // readings in a row agree. Actions are left out on purpose: fetching them is a second round
235
+ // trip per control, it is about a third of the whole cost, and nothing about a control's
236
+ // action list changes while a window is settling.
237
+ function signature(win, limit, budgetMs) {
238
+ var t = readTree(win, limit, budgetMs, false);
239
+ var parts = [];
240
+ for (var i = 0; i < t.nodes.length; i++) {
241
+ var n = t.nodes[i];
242
+ parts.push(n.role + '/' + n.title + '/' + n.desc + '/' + n.value + '/' + n.on + '/' + n.sel);
243
+ }
244
+ return { key: parts.join('|'), tree: t };
245
+ }
246
+
247
+ // A control is found by what it IS and what it is CALLED, never by where it sits. An index
248
+ // would be stable right up until somebody adds a control above it.
249
+ function findControl(el, wanted, depth) {
250
+ if (depth > 60) return null;
251
+ var b = batch(el);
252
+ if (b === null) return null;
253
+ var id = plain(b.objectAtIndex(5));
254
+ var role = plain(b.objectAtIndex(0));
255
+ var title = plain(b.objectAtIndex(2));
256
+ var desc = plain(b.objectAtIndex(3));
257
+ if (id !== null && id === wanted) return el;
258
+ if (title !== null && title === wanted) return el;
259
+ if (desc !== null && desc === wanted) return el;
260
+ if (role !== null && title !== null && (role + ':' + title) === wanted) return el;
261
+ var kids = b.objectAtIndex(11);
262
+ if (kids && kids.isKindOfClass && kids.isKindOfClass($.NSArray)) {
263
+ for (var i = 0; i < kids.count; i++) {
264
+ var hit = findControl(kids.objectAtIndex(i), wanted, depth + 1);
265
+ if (hit !== null) return hit;
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+
271
+ function findInApp(app, wanted) {
272
+ var wins = axWindows(app);
273
+ if (wins === null) return null;
274
+ for (var i = 0; i < wins.count; i++) {
275
+ var hit = findControl(wins.objectAtIndex(i), wanted, 0);
276
+ if (hit !== null) return hit;
277
+ }
278
+ return null;
279
+ }
280
+
281
+ // How much of a picture is not black. A locked screen, or a Mac with no screen-recording
282
+ // permission, hands back a window picture that is entirely black, and reporting that as
283
+ // evidence would be reporting a blank page as a photograph.
284
+ function litPixels(file) {
285
+ var img = $.NSImage.alloc.initWithContentsOfFile(file);
286
+ if (!img || img.isNil()) return -1;
287
+ var tiff = img.TIFFRepresentation;
288
+ if (!tiff || tiff.isNil()) return -1;
289
+ var rep = $.NSBitmapImageRep.imageRepWithData(tiff);
290
+ if (!rep || rep.isNil()) return -1;
291
+ // NSNumber-ish values come back through the bridge as strings often enough that every one
292
+ // of them is put through Number() here rather than trusted.
293
+ var w = Number(rep.pixelsWide), h = Number(rep.pixelsHigh);
294
+ if (!(w > 1) || !(h > 1)) return -1;
295
+ var lit = 0;
296
+ // Two big primes as strides, so the 200 samples are spread over the whole picture rather
297
+ // than over one band of it, and so the SAME pixels are sampled on both builds.
298
+ for (var i = 0; i < 200; i++) {
299
+ var c = rep.colorAtXY((i * 7919) % w, (i * 104729) % h);
300
+ if (c && !c.isNil() && (Number(c.redComponent) + Number(c.greenComponent) + Number(c.blueComponent)) > 0.12) lit++;
301
+ }
302
+ return lit;
303
+ }
304
+
305
+ function run(argv) {
306
+ var req;
307
+ try { req = JSON.parse(argv[0]); } catch (e) { return JSON.stringify({ ok: false, error: 'the request was not readable json' }); }
308
+ var t0 = $.NSDate.date;
309
+ var reply = { ok: true, op: req.op };
310
+ try {
311
+ if (req.op === 'running') {
312
+ // NSWorkspace is the only thing that knows a running process's BUNDLE IDENTIFIER, and the
313
+ // bundle identifier is what actually collides: two builds of one app live in two folders
314
+ // and run two different executables, but they declare the same identifier, and that is
315
+ // what makes one of them stop answering the accessibility layer.
316
+ var apps = $.NSWorkspace.sharedWorkspace.runningApplications;
317
+ var rows = [];
318
+ for (var a = 0; a < apps.count; a++) {
319
+ var ra = apps.objectAtIndex(a);
320
+ var bid = ra.bundleIdentifier;
321
+ if (!bid || bid.isNil()) continue;
322
+ rows.push({
323
+ pid: Number(ra.processIdentifier),
324
+ bundleId: ObjC.unwrap(bid),
325
+ path: ra.bundleURL && !ra.bundleURL.isNil() ? ObjC.unwrap(ra.bundleURL.path) : null,
326
+ });
327
+ }
328
+ reply.apps = rows;
329
+
330
+ } else if (req.op === 'hello') {
331
+ reply.axTrusted = $.AXIsProcessTrusted();
332
+ reply.user = ObjC.unwrap($.NSUserName());
333
+ reply.screens = $.NSScreen.screens.count;
334
+ var s = $.NSScreen.mainScreen;
335
+ reply.screen = s && !s.isNil() ? (Math.round(s.frame.size.width) + 'x' + Math.round(s.frame.size.height)) : 'unknown';
336
+ reply.macos = ObjC.unwrap($.NSProcessInfo.processInfo.operatingSystemVersionString);
337
+
338
+ } else if (req.op === 'windows') {
339
+ var app = appFor(req.pid);
340
+ var wins = axWindows(app);
341
+ var out = [];
342
+ var n = wins === null ? 0 : wins.count;
343
+ for (var i = 0; i < n; i++) {
344
+ var b = batch(wins.objectAtIndex(i));
345
+ out.push({
346
+ title: b === null ? null : plain(b.objectAtIndex(2)),
347
+ role: b === null ? null : plain(b.objectAtIndex(0)),
348
+ sub: b === null ? null : plain(b.objectAtIndex(1)),
349
+ index: i,
350
+ });
351
+ }
352
+ reply.windows = out;
353
+ reply.axCount = n;
354
+ reply.onScreen = cgWindowsFor(req.pid);
355
+
356
+ } else if (req.op === 'settle') {
357
+ var app2 = appFor(req.pid);
358
+ var wins2 = axWindows(app2);
359
+ if (wins2 === null || req.index >= wins2.count) {
360
+ reply.ok = false;
361
+ reply.error = 'that window is not on the screen any more';
362
+ } else {
363
+ var win = wins2.objectAtIndex(req.index);
364
+ // A cheap signature, repeatedly, until two in a row match. Then ONE full read with
365
+ // the action lists, which is the expensive part and is only worth paying for once.
366
+ var last = '';
367
+ var agreed = false;
368
+ var reads = 0;
369
+ while (reads < req.tries) {
370
+ reads++;
371
+ var sig = signature(win, req.limit, req.budgetMs);
372
+ if (reads > 1 && sig.key === last) { agreed = true; break; }
373
+ last = sig.key;
374
+ $.NSThread.sleepForTimeInterval(req.gapMs / 1000);
375
+ }
376
+ var full = readTree(win, req.limit, req.budgetMs, true);
377
+ // The count from a different call than the one that built the tree. When a window
378
+ // hands back a tree of one node while claiming a dozen children, the read is broken.
379
+ var cr = Ref();
380
+ var childCount = $.AXUIElementGetAttributeValueCount(win, $('AXChildren'), cr) === 0 ? cr[0] : -1;
381
+ reply.agreed = agreed;
382
+ reply.reads = reads;
383
+ reply.nodes = full.nodes;
384
+ reply.truncated = full.truncated;
385
+ reply.ranOut = full.ranOut;
386
+ reply.readMs = full.ms;
387
+ reply.childCount = childCount;
388
+ reply.onScreen = cgWindowsFor(req.pid);
389
+ }
390
+
391
+ } else if (req.op === 'press') {
392
+ var app3 = appFor(req.pid);
393
+ var target = findInApp(app3, req.control);
394
+ if (target === null) {
395
+ reply.ok = false;
396
+ reply.error = 'no control on screen is called "' + req.control + '"';
397
+ } else {
398
+ var actions = actionsOf(target);
399
+ var wantAction = req.action || 'AXPress';
400
+ if (actions.indexOf(wantAction) === -1) {
401
+ reply.ok = false;
402
+ reply.error = 'the control "' + req.control + '" cannot be asked to ' + wantAction
403
+ + '; it can only be asked to ' + (actions.length === 0 ? 'do nothing' : actions.join(', '));
404
+ } else {
405
+ var e = $.AXUIElementPerformAction(target, $(wantAction));
406
+ reply.ok = e === 0;
407
+ if (e !== 0) reply.error = 'the app refused the ' + wantAction + ' (accessibility error ' + e + ')';
408
+ }
409
+ }
410
+
411
+ } else if (req.op === 'set') {
412
+ var app4 = appFor(req.pid);
413
+ var target2 = findInApp(app4, req.control);
414
+ if (target2 === null) {
415
+ reply.ok = false;
416
+ reply.error = 'no control on screen is called "' + req.control + '"';
417
+ } else {
418
+ var settable = Ref();
419
+ $.AXUIElementIsAttributeSettable(target2, $('AXValue'), settable);
420
+ if (!settable[0]) {
421
+ reply.ok = false;
422
+ reply.error = 'the control "' + req.control + '" will not let anything change its value';
423
+ } else {
424
+ var e2 = $.AXUIElementSetAttributeValue(target2, $('AXValue'), $(String(req.value)));
425
+ reply.ok = e2 === 0;
426
+ if (e2 !== 0) reply.error = 'the app refused the new value (accessibility error ' + e2 + ')';
427
+ }
428
+ }
429
+
430
+ } else if (req.op === 'lit') {
431
+ var lit = {};
432
+ for (var f = 0; f < req.files.length; f++) lit[req.files[f]] = litPixels(req.files[f]);
433
+ reply.lit = lit;
434
+
435
+ } else {
436
+ reply.ok = false;
437
+ reply.error = 'nothing here knows how to do ' + req.op;
438
+ }
439
+ } catch (err) {
440
+ reply.ok = false;
441
+ reply.error = String(err && err.message ? err.message : err);
442
+ }
443
+ reply.ms = Math.round(-t0.timeIntervalSinceNow * 1000);
444
+ return JSON.stringify(reply);
445
+ }
446
+ `;
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Running things
451
+ // ---------------------------------------------------------------------------
452
+
453
+ /**
454
+ * @typedef {object} Ran
455
+ * @property {number|null} code
456
+ * @property {string} stdout
457
+ * @property {string} stderr
458
+ * @property {boolean} timedOut
459
+ * @property {string} why
460
+ * @property {number} ms
461
+ */
462
+
463
+ /**
464
+ * Run a program with a limit on it and no shell in the way.
465
+ *
466
+ * Deliberately not `runCommand` from process.js, which goes through a shell. Everything this
467
+ * adapter runs takes an argument that came from somebody's config — an app path, a control
468
+ * name, a whole JSON request — and a shell between here and there is a quoting bug waiting to
469
+ * become an executed command.
470
+ *
471
+ * @param {string} file
472
+ * @param {string[]} args
473
+ * @param {object} [opts]
474
+ * @param {number} [opts.limitMs]
475
+ * @param {string} [opts.stdin]
476
+ * @param {string} [opts.what]
477
+ * @returns {Promise<Ran>}
478
+ */
479
+ export async function runQuietly(file, args, opts = {}) {
480
+ const started = Date.now();
481
+ const child = spawn(file, args, { stdio: ['pipe', 'pipe', 'pipe'] });
482
+ /** @type {Buffer[]} */
483
+ const out = [];
484
+ /** @type {Buffer[]} */
485
+ const err = [];
486
+ child.stdout?.on('data', (c) => out.push(c));
487
+ child.stderr?.on('data', (c) => err.push(c));
488
+ child.on('error', (e) => err.push(Buffer.from(`${e.message}\n`)));
489
+ if (opts.stdin !== undefined) child.stdin?.end(opts.stdin);
490
+ else child.stdin?.end();
491
+
492
+ const ended = await endOfChild(child, {
493
+ limitMs: opts.limitMs ?? PROBE_LIMIT_MS,
494
+ what: opts.what ?? `${file} ${args[0] ?? ''}`.trim(),
495
+ });
496
+ letGoOf(child);
497
+ return {
498
+ code: ended.code,
499
+ stdout: Buffer.concat(out).toString('utf8'),
500
+ stderr: Buffer.concat(err).toString('utf8'),
501
+ timedOut: ended.gaveUp,
502
+ why: ended.why,
503
+ ms: Date.now() - started,
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Read one reply out of what `osascript` printed.
509
+ *
510
+ * Split out from the call so it can be tested with a fixture. It matters that a probe which
511
+ * printed nothing, printed a syntax error, or was killed for taking too long all come back as
512
+ * DIFFERENT sentences: they are three different faults and lumping them together is how a
513
+ * person ends up staring at "the probe failed" for an hour.
514
+ *
515
+ * @param {Ran} ran
516
+ * @returns {Record<string, any>}
517
+ */
518
+ export function readProbeReply(ran) {
519
+ if (ran.timedOut) {
520
+ return { ok: false, error: `the screen reader was stopped for taking too long: ${ran.why}` };
521
+ }
522
+ const text = ran.stdout.trim();
523
+ if (text === '') {
524
+ const complaint = ran.stderr.trim().split('\n').slice(-2).join(' ').slice(0, 300);
525
+ return { ok: false, error: complaint === '' ? 'the screen reader printed nothing at all' : `the screen reader stopped: ${complaint}` };
526
+ }
527
+ // `osascript` prints the returned value on the last line; a script that logged on the way
528
+ // through puts its noise before it, and taking the whole thing would fail to parse.
529
+ const last = text.split('\n').filter((l) => l.trim() !== '').pop() ?? '';
530
+ try {
531
+ return JSON.parse(last);
532
+ } catch {
533
+ return { ok: false, error: `the screen reader answered something that was not readable: ${last.slice(0, 200)}` };
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Ask the screen one question.
539
+ *
540
+ * The script goes in on standard input and the request goes in as one argument, which is what
541
+ * `osascript -l JavaScript -` supports and what keeps the request out of any shell's hands.
542
+ *
543
+ * @param {Record<string, any>} request
544
+ * @param {object} [opts]
545
+ * @param {number} [opts.limitMs]
546
+ * @returns {Promise<Record<string, any>>}
547
+ */
548
+ export async function askTheScreen(request, opts = {}) {
549
+ const ran = await runQuietly('/usr/bin/osascript', ['-l', 'JavaScript', '-', JSON.stringify(request)], {
550
+ stdin: macosProbeScript(),
551
+ limitMs: opts.limitMs ?? PROBE_LIMIT_MS,
552
+ what: `reading the screen (${request.op})`,
553
+ });
554
+ return readProbeReply(ran);
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // The app bundle
559
+ // ---------------------------------------------------------------------------
560
+
561
+ /**
562
+ * What is inside a `.app`, as far as this adapter cares.
563
+ *
564
+ * @typedef {object} BundleFacts
565
+ * @property {boolean} ok
566
+ * @property {string} why
567
+ * @property {string} [executable] Full path to the program inside the bundle.
568
+ * @property {string} [name] What the bundle calls itself.
569
+ * @property {boolean} [electron] True when this is a Chromium shell wearing a Mac icon.
570
+ * @property {string} [bundleId] What the app calls itself to the system. Two builds of one
571
+ * app share this, and sharing it is what breaks them.
572
+ */
573
+
574
+ /**
575
+ * Decide what a bundle is from the two files that actually say so.
576
+ *
577
+ * Split from the disk read so a test can hand it a plist and a file listing.
578
+ *
579
+ * @param {string} appPath
580
+ * @param {string} plistXml Contents of Contents/Info.plist.
581
+ * @param {string[]} frameworkNames Names inside Contents/Frameworks, when there are any.
582
+ * @returns {BundleFacts}
583
+ */
584
+ export function readBundle(appPath, plistXml, frameworkNames) {
585
+ // Electron on macOS ships a framework with exactly this name, and it has done for every
586
+ // version anybody still runs. Finding it is not a failure — it is this adapter working out
587
+ // that a better tool for the job is already in the box and saying which one.
588
+ const electron = frameworkNames.some((n) => /^Electron Framework\.framework$/i.test(n));
589
+ const match = /<key>CFBundleExecutable<\/key>\s*<string>([^<]+)<\/string>/.exec(plistXml);
590
+ if (!match) {
591
+ return {
592
+ ok: false,
593
+ electron,
594
+ why: `${appPath} has no CFBundleExecutable in its Info.plist, so nothing here knows which program inside it to open.`,
595
+ };
596
+ }
597
+ const nameMatch = /<key>CFBundleName<\/key>\s*<string>([^<]+)<\/string>/.exec(plistXml);
598
+ // The bundle identifier matters more here than anywhere else in this adapter. Two builds
599
+ // being compared sit in two folders and run two different files, so nothing about their paths
600
+ // says they are the same app — but they declare the same identifier, and THAT is what makes
601
+ // one of them go silent when both are open. It is read out here so `prepare` can look for
602
+ // other copies of the same app rather than other copies of the same file.
603
+ const idMatch = /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/.exec(plistXml);
604
+ return {
605
+ ok: true,
606
+ electron,
607
+ executable: path.join(appPath, 'Contents', 'MacOS', match[1]),
608
+ name: nameMatch ? nameMatch[1] : path.basename(appPath, '.app'),
609
+ bundleId: idMatch ? idMatch[1] : undefined,
610
+ why: `${appPath} opens ${match[1]}.`,
611
+ };
612
+ }
613
+
614
+ /**
615
+ * Look inside a `.app` on disk.
616
+ * @param {string} appPath
617
+ * @returns {Promise<BundleFacts>}
618
+ */
619
+ export async function inspectBundle(appPath) {
620
+ /** @type {string} */
621
+ let plist;
622
+ try {
623
+ plist = await fsp.readFile(path.join(appPath, 'Contents', 'Info.plist'), 'utf8');
624
+ } catch {
625
+ return { ok: false, electron: false, why: `There is no readable app bundle at ${appPath}.` };
626
+ }
627
+ /** @type {string[]} */
628
+ let frameworks = [];
629
+ try {
630
+ frameworks = await fsp.readdir(path.join(appPath, 'Contents', 'Frameworks'));
631
+ } catch { /* plenty of apps have no frameworks folder at all */ }
632
+ return readBundle(appPath, plist, frameworks);
633
+ }
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // Starting and stopping
637
+ // ---------------------------------------------------------------------------
638
+
639
+ /**
640
+ * Which running processes are this exact program.
641
+ *
642
+ * Matched on the FULL executable path, never on the app's name. Two builds of the same app
643
+ * being compared have the same name and different paths, and picking the wrong one produces a
644
+ * run that reads the old build and reports it as the new one — a pass that means nothing.
645
+ *
646
+ * @param {string} psOutput Output of `ps -axo pid=,comm=`.
647
+ * @param {string} executable
648
+ * @returns {number[]}
649
+ */
650
+ export function pidsRunning(psOutput, executable) {
651
+ /** @type {number[]} */
652
+ const found = [];
653
+ for (const line of psOutput.split('\n')) {
654
+ const trimmed = line.trim();
655
+ if (trimmed === '') continue;
656
+ const space = trimmed.indexOf(' ');
657
+ if (space < 0) continue;
658
+ const pid = Number(trimmed.slice(0, space));
659
+ const command = trimmed.slice(space + 1).trim();
660
+ if (Number.isInteger(pid) && command === executable) found.push(pid);
661
+ }
662
+ return found;
663
+ }
664
+
665
+ /**
666
+ * Every process this run is responsible for, walked down from the ones it started.
667
+ *
668
+ * The command line is kept, because "it now launches its updater with a different flag" is
669
+ * exactly the kind of change no screenshot has ever caught.
670
+ *
671
+ * @param {string} psOutput Output of `ps -axo pid=,ppid=,comm=`.
672
+ * @param {number[]} roots
673
+ * @returns {{pid: number, parent: number, command: string}[]}
674
+ */
675
+ export function descendantsOf(psOutput, roots) {
676
+ /** @type {{pid: number, parent: number, command: string}[]} */
677
+ const all = [];
678
+ for (const line of psOutput.split('\n')) {
679
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
680
+ if (!m) continue;
681
+ all.push({ pid: Number(m[1]), parent: Number(m[2]), command: m[3].trim() });
682
+ }
683
+ const mine = new Set(roots);
684
+ // Repeated passes rather than one, because `ps` lists in pid order and a grandchild can be
685
+ // listed before its parent. Bounded by the depth of the tree, which is never deep.
686
+ for (let pass = 0; pass < 8; pass++) {
687
+ let grew = false;
688
+ for (const row of all) {
689
+ if (mine.has(row.parent) && !mine.has(row.pid)) { mine.add(row.pid); grew = true; }
690
+ }
691
+ if (!grew) break;
692
+ }
693
+ return all.filter((row) => mine.has(row.pid) && !roots.includes(row.pid));
694
+ }
695
+
696
+ /**
697
+ * Open a Mac app WITHOUT taking the screen away from whoever is using this machine.
698
+ *
699
+ * `open -g` is the whole reason this goes through LaunchServices rather than spawning the
700
+ * program directly. Measured on 2026-08-31: the same binary, spawned directly twice, left the
701
+ * foreground alone the first time and TOOK it the second. Non-deterministic foreground theft
702
+ * is worse than deterministic foreground theft, because it passes a test. `-g` is a promise
703
+ * from the system rather than a hope about the app.
704
+ *
705
+ * `-n` makes a second copy open even when one is already running, which is what comparing two
706
+ * builds of the same app needs. `-o` and `--stderr` catch what the app prints, which is the
707
+ * only reason not spawning it directly costs anything at all.
708
+ *
709
+ * @param {object} spec
710
+ * @param {string} spec.appPath
711
+ * @param {string[]} [spec.args]
712
+ * @param {string} spec.stdoutFile
713
+ * @param {string} spec.stderrFile
714
+ * @returns {Promise<Ran>}
715
+ */
716
+ export async function openInTheBackground(spec) {
717
+ const args = ['-g', '-n', '-o', spec.stdoutFile, '--stderr', spec.stderrFile, '-a', spec.appPath];
718
+ if (spec.args && spec.args.length > 0) args.push('--args', ...spec.args);
719
+ return runQuietly('/usr/bin/open', args, { limitMs: 30_000, what: `opening ${path.basename(spec.appPath)}` });
720
+ }
721
+
722
+ /**
723
+ * Ask one process to quit, then insist, then give up — and only ever one this run started.
724
+ *
725
+ * @param {number} pid
726
+ * @param {(m: string) => void} [log]
727
+ * @returns {Promise<'quit'|'forced'|'was already gone'>}
728
+ */
729
+ export async function stopOne(pid, log) {
730
+ const alive = () => { try { process.kill(pid, 0); return true; } catch { return false; } };
731
+ if (!alive()) return 'was already gone';
732
+ try { process.kill(pid, 'SIGTERM'); } catch { return 'was already gone'; }
733
+ for (let i = 0; i < 40; i++) {
734
+ if (!alive()) return 'quit';
735
+ await new Promise((r) => setTimeout(r, 100));
736
+ }
737
+ log?.(`The app would not quit when asked, so it was stopped outright (pid ${pid}).`);
738
+ try { process.kill(pid, 'SIGKILL'); } catch { /* it went in the meantime */ }
739
+ return 'forced';
740
+ }
741
+
742
+ // ---------------------------------------------------------------------------
743
+ // Pictures
744
+ // ---------------------------------------------------------------------------
745
+
746
+ /**
747
+ * Take a picture of one window by its window-server id.
748
+ *
749
+ * `-x` keeps the shutter sound quiet, `-o` leaves the drop shadow off so two builds are not
750
+ * compared through different amounts of blur, and `-l` names one window rather than grabbing
751
+ * the screen. Measured at 190ms for a 63KB picture, and the frontmost app did not change —
752
+ * which is the property that matters, because a check that takes the screen is a check nobody
753
+ * can run while they are working.
754
+ *
755
+ * @param {number} windowId
756
+ * @param {string} file
757
+ * @returns {Promise<{ok: boolean, why: string, bytes: number}>}
758
+ */
759
+ export async function pictureOfWindow(windowId, file) {
760
+ const ran = await runQuietly('/usr/sbin/screencapture', ['-x', '-o', '-l', String(windowId), file], {
761
+ limitMs: 20_000,
762
+ what: `a picture of window ${windowId}`,
763
+ });
764
+ try {
765
+ const stat = await fsp.stat(file);
766
+ if (stat.size === 0) return { ok: false, why: 'the picture came back empty', bytes: 0 };
767
+ return { ok: true, why: `A picture ${sizeBucket(stat.size)} was taken.`, bytes: stat.size };
768
+ } catch {
769
+ const complaint = `${ran.stderr}`.trim().split('\n').pop() ?? '';
770
+ return {
771
+ ok: false,
772
+ bytes: 0,
773
+ why: complaint === ''
774
+ ? 'no picture file was written, which on a Mac usually means this program has not been given Screen Recording permission'
775
+ : complaint.slice(0, 200),
776
+ };
777
+ }
778
+ }
779
+
780
+ // ---------------------------------------------------------------------------
781
+ // Complaints
782
+ // ---------------------------------------------------------------------------
783
+
784
+ /** Where macOS files the report when a program falls over. */
785
+ export const CRASH_FOLDER = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
786
+
787
+ /**
788
+ * Pull the two lines out of a crash report that say what happened.
789
+ *
790
+ * A `.ips` file is a JSON header line followed by a second JSON body, and the body is
791
+ * enormous — every thread, every frame, every loaded binary. None of that belongs in a
792
+ * comparison: the addresses differ on every run and would report a regression every time. What
793
+ * is kept is the exception type and the reason, which is the part that changes when the crash
794
+ * changes.
795
+ *
796
+ * @param {string} text
797
+ * @returns {{app: string|null, when: string|null, exception: string|null, reason: string|null}}
798
+ */
799
+ export function readCrashReport(text) {
800
+ const lines = text.split('\n');
801
+ /** @type {any} */
802
+ let head = {};
803
+ try { head = JSON.parse(lines[0]); } catch { /* an unreadable header still leaves a body */ }
804
+ /** @type {any} */
805
+ let body = {};
806
+ try { body = JSON.parse(lines.slice(1).join('\n')); } catch { /* older reports are plain text */ }
807
+ const exception = body?.exception?.type ?? body?.termination?.indicator ?? null;
808
+ const reason = body?.exception?.signal ?? body?.termination?.reason ?? head?.bug_type ?? null;
809
+ return {
810
+ app: head?.app_name ?? head?.procname ?? null,
811
+ when: head?.timestamp ?? null,
812
+ exception: exception === null ? null : String(exception),
813
+ reason: reason === null ? null : String(reason).slice(0, 300),
814
+ };
815
+ }
816
+
817
+ /**
818
+ * Crash reports filed for one app since a moment.
819
+ *
820
+ * @param {string} appName
821
+ * @param {number} sinceMs
822
+ * @param {string} [folder]
823
+ * @returns {Promise<{file: string, app: string|null, exception: string|null, reason: string|null}[]>}
824
+ */
825
+ export async function crashesSince(appName, sinceMs, folder = CRASH_FOLDER) {
826
+ /** @type {string[]} */
827
+ let names = [];
828
+ try { names = await fsp.readdir(folder); } catch { return []; }
829
+ /** @type {{file: string, app: string|null, exception: string|null, reason: string|null}[]} */
830
+ const out = [];
831
+ for (const name of names) {
832
+ if (!name.endsWith('.ips')) continue;
833
+ if (!name.toLowerCase().startsWith(`${appName.toLowerCase()}-`)) continue;
834
+ const full = path.join(folder, name);
835
+ try {
836
+ const stat = await fsp.stat(full);
837
+ if (stat.mtimeMs < sinceMs) continue;
838
+ const parsed = readCrashReport(await fsp.readFile(full, 'utf8'));
839
+ out.push({ file: full, app: parsed.app, exception: parsed.exception, reason: parsed.reason });
840
+ } catch { /* a report being written as we read it is not a finding */ }
841
+ }
842
+ return out.sort((a, b) => (a.file < b.file ? -1 : 1));
843
+ }
844
+
845
+ /**
846
+ * Keep only the log lines worth comparing, and say how many were thrown away.
847
+ *
848
+ * The unified log is a firehose: a five-second run of a trivial AppKit app produced pages of
849
+ * XPC connection chatter with pointer addresses in it, and every one of those addresses is
850
+ * different on every run. Comparing them would make every run differ from every other run,
851
+ * which is the fastest way to teach somebody to ignore this tool. So only what the app itself
852
+ * complained about is kept, the addresses are rubbed out, and the number dropped is reported
853
+ * rather than hidden.
854
+ *
855
+ * @param {string} text Output of `log show --style compact`.
856
+ * @returns {{kept: string[], dropped: number}}
857
+ */
858
+ export function worthKeepingFromLog(text) {
859
+ const lines = text.split('\n').filter((l) => l.trim() !== '');
860
+ /** @type {string[]} */
861
+ const kept = [];
862
+ let dropped = 0;
863
+ for (const line of lines) {
864
+ // The compact style puts a two-letter level after the timestamp: Df debug, I info,
865
+ // Er error, Fa fault. Only the last two are ever a complaint about the product.
866
+ const level = / (Er|Fa) /.exec(line);
867
+ if (!level) { dropped++; continue; }
868
+ const cleaned = line
869
+ // Pointers, object addresses and thread ids differ on every run of identical code.
870
+ .replace(/0x[0-9a-f]{4,}/gi, '0x…')
871
+ .replace(/\[\d+:[0-9a-f]+\]/g, '[…]')
872
+ // The timestamp at the front is the run, not the product.
873
+ .replace(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d+\s*/, '');
874
+ kept.push(cleaned.slice(0, 300));
875
+ }
876
+ return { kept: [...new Set(kept)].sort(), dropped };
877
+ }
878
+
879
+ /**
880
+ * What one process complained about into the unified log while it ran.
881
+ *
882
+ * Measured at 1.93 seconds on this machine for a two-minute window on one pid, which is real
883
+ * but affordable once per journey.
884
+ *
885
+ * Called as `/usr/bin/log`, spelt out, because `log` is a shell builtin in zsh and calling it
886
+ * by name gets a builtin that does something else entirely.
887
+ *
888
+ * @param {number} pid
889
+ * @param {number} sinceMs
890
+ * @returns {Promise<{kept: string[], dropped: number, ok: boolean, why: string}>}
891
+ */
892
+ export async function loggedBy(pid, sinceMs) {
893
+ const seconds = Math.max(1, Math.ceil((Date.now() - sinceMs) / 1000) + 5);
894
+ const ran = await runQuietly('/usr/bin/log', [
895
+ 'show', '--last', `${seconds}s`, '--predicate', `processIdentifier == ${pid}`, '--style', 'compact',
896
+ ], { limitMs: 45_000, what: `what the app logged (pid ${pid})` });
897
+ if (ran.timedOut || (ran.code !== 0 && ran.stdout.trim() === '')) {
898
+ return { kept: [], dropped: 0, ok: false, why: ran.timedOut ? ran.why : (ran.stderr.trim().slice(0, 200) || 'the log could not be read') };
899
+ }
900
+ return { ...worthKeepingFromLog(ran.stdout), ok: true, why: 'read' };
901
+ }
902
+
903
+ /**
904
+ * Everything running on this Mac, once, so both `pidsRunning` and `descendantsOf` can read it.
905
+ * @returns {Promise<{byPath: string, byParent: string}>}
906
+ */
907
+ export async function processList() {
908
+ const [byPath, byParent] = await Promise.all([
909
+ runQuietly('/bin/ps', ['-axo', 'pid=,comm='], { limitMs: 15_000, what: 'the list of running programs' }),
910
+ runQuietly('/bin/ps', ['-axo', 'pid=,ppid=,comm='], { limitMs: 15_000, what: 'the list of running programs' }),
911
+ ]);
912
+ return { byPath: byPath.stdout, byParent: byParent.stdout };
913
+ }