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,1374 @@
1
+ /**
2
+ * Native Mac applications — Swift or Objective-C, AppKit or SwiftUI — read on the Mac they
3
+ * already run on.
4
+ *
5
+ * ── WHAT WAS MEASURED BEFORE ANY OF THIS WAS WRITTEN ───────────────────────────────────────
6
+ *
7
+ * The plan for this surface assumed a small Swift probe would have to be built and shipped,
8
+ * the same assumption the Windows plan made about a .NET one. That was checked against a real
9
+ * Mac — macOS 27.0, Apple silicon — on 2026-08-31 before a line was written, and it is the
10
+ * wrong answer. Four measurements settled it.
11
+ *
12
+ * 1. `osascript -l JavaScript` is on every Mac and needs no developer tools. Its
13
+ * Objective-C bridge publishes the WHOLE accessibility C API through the BridgeSupport
14
+ * files that ship with the system: AXUIElementCreateApplication, CopyAttributeValue,
15
+ * CopyMultipleAttributeValues, CopyActionNames, PerformAction, SetAttributeValue,
16
+ * SetMessagingTimeout. A compiled Swift probe would have no more reach than this, and
17
+ * would need Xcode to build and a signature to run. So the probe is JavaScript, handed
18
+ * to `osascript` on standard input, living only in the memory of the process running it.
19
+ * 2. It is fast enough. A real AppKit window — sixteen controls with roles, names,
20
+ * identifiers, values, enabled states and action lists — read in 57 milliseconds.
21
+ * Finder's window: 17 controls, 79ms. TextEdit with one empty document: 204 controls,
22
+ * 788ms. That is about 3.9 milliseconds a control, against 2.0 measured for Windows UI
23
+ * Automation. macOS has no cached subtree read, so every attribute is a live message
24
+ * into the app; batching all twelve attributes and the child list into ONE message is
25
+ * what makes it affordable. The first version asked one at a time and could not finish a
26
+ * large window in two minutes.
27
+ * 3. It does not scale to a table. Activity Monitor's window is 5,821 controls and takes
28
+ * 15.6 seconds to read, and had not finished after twenty with action lists switched on.
29
+ * So this adapter caps a window at 1,500 controls and twelve seconds and REPORTS both
30
+ * caps rather than quietly returning a short tree.
31
+ * 4. Pressing works and clicking does not. `AXUIElementPerformAction(el, AXPress)` really
32
+ * fires the handler: an on-screen counter went 0-1-2-3 across three presses, the app's
33
+ * own output logged each one, and pressing Save wrote a real file. Synthetic mouse
34
+ * events do not do this in these apps, which is why nothing here sends one.
35
+ *
36
+ * ── THE ONE THAT MATTERS MOST ──────────────────────────────────────────────────────────────
37
+ *
38
+ * TWO COPIES OF THE SAME APP BREAK THE ACCESSIBILITY LAYER, AND IT LOOKS EXACTLY LIKE AN
39
+ * EMPTY WINDOW. Reproduced twice on 2026-08-31, on purpose. One build was read correctly for
40
+ * twenty minutes. A second build of the same app — same bundle identifier, different folder —
41
+ * was opened alongside it. The newer one answered in 98ms; THE OLDER ONE WENT DARK, returning
42
+ * zero windows after a two-second timeout, while CoreGraphics still showed its real window,
43
+ * on screen, 460 by 332, with its title. Killing the newer one brought the older one back
44
+ * immediately, in 178ms.
45
+ *
46
+ * A third attempt the same afternoon did not reproduce it: both copies answered fine. That
47
+ * makes this WORSE rather than better. A fault that happens every time gets found on the first
48
+ * run and fixed; one that happens sometimes gets found on the run somebody trusted.
49
+ *
50
+ * And it is the exact shape of the failure this whole product exists to prevent. Zero controls
51
+ * from one build and zero from the other reads as "nothing changed". Zero from the old build
52
+ * and a full tree from the new one reads as "every control in the app vanished". Both are lies
53
+ * and neither one announces itself.
54
+ *
55
+ * So: every window read is cross-checked against CoreGraphics' own on-screen window list,
56
+ * which is a completely different mechanism — the window server, which drew the window, rather
57
+ * than the app's accessibility responder, which is the thing that has gone quiet. When they
58
+ * disagree, nothing is recorded and the reason is. And this adapter runs ONE build at a time
59
+ * and stops the previous one first, and refuses to read at all when it finds another copy of
60
+ * the same app running that it did not start.
61
+ *
62
+ * ── WHAT IT WATCHES ────────────────────────────────────────────────────────────────────────
63
+ *
64
+ * meaning The window tree from the Accessibility API: what each control IS (a button, a
65
+ * checkbox, a pop-up), what it is CALLED, its accessibility identifier, whether
66
+ * it is on, off, selected or focused, what it currently says, and what actions
67
+ * it says it can be asked to perform. This is the channel that answers "what
68
+ * does this screen now do", and it is the reason this surface is worth having.
69
+ * effects Programs it started, and files that changed in the folders it was told to
70
+ * watch — by CONTENT, not by size, because this runs on the same machine.
71
+ * complaints Crash reports macOS filed for it, anything it logged at error or fault level,
72
+ * and whether it was still running at the end.
73
+ * results What it printed, and the titles of the windows it opened.
74
+ * counters How many windows, how many controls, how many files, in buckets.
75
+ * pixels A picture of each window, as evidence for something another channel already
76
+ * found. Never compared.
77
+ *
78
+ * ── WHAT IT CANNOT DO, SAID PLAINLY ────────────────────────────────────────────────────────
79
+ *
80
+ * ONE PERSON MUST CLICK ONE THING, ONCE, AND NOTHING HERE CAN DO IT FOR THEM. Reading another
81
+ * app's window needs Accessibility permission, and macOS deliberately makes that ungrantable
82
+ * from a script — that is the whole point of it. Until the terminal or editor running Stays
83
+ * Fixed is ticked under System Settings, Privacy & Security, Accessibility, this surface reads
84
+ * nothing at all, and it says so as a blocking gap rather than as an empty result.
85
+ *
86
+ * ONE DESKTOP, ONE BUILD AT A TIME. For the bundle-identifier reason above, and because the
87
+ * desktop is shared with whatever else that person has open. A notification arriving mid-run
88
+ * is a real source of difference that no amount of freezing removes. Running twice and
89
+ * subtracting the wobble absorbs some of it. It does not absorb all of it.
90
+ *
91
+ * THERE IS NO SAFETY BOUNDARY AT THE WIRE. The CLI adapter can watch a program ask to reach
92
+ * the internet and refuse it, because it loads a watcher inside a Node child. There is no
93
+ * equivalent for a compiled Mac application: everything that would really capture what it does
94
+ * — `fs_usage`, `dtrace`, an Endpoint Security client — needs root or an Apple entitlement. So
95
+ * a journey marked irreversible is REFUSED OUTRIGHT here rather than walked carefully. It is
96
+ * reported as missing coverage, and it never runs.
97
+ *
98
+ * FILES ARE WATCHED WHERE IT IS TOLD, NOT EVERYWHERE. Same limit as Windows, with one real
99
+ * improvement: this runs on the same machine, so the folders it watches are compared by the
100
+ * CONTENTS of every file rather than by size. A file rewritten with different bytes and the
101
+ * same length is caught here and is missed on Windows. A file written OUTSIDE those folders is
102
+ * still not seen, and that is reported as a hole rather than as a clean result.
103
+ *
104
+ * CONNECTIONS ARE SAMPLED, NOT CAPTURED. `lsof` lists what the app has open at the moment it
105
+ * is asked, without needing root. A connection that opens and closes between two samples is
106
+ * not seen, and nothing here could have stopped one.
107
+ *
108
+ * AN APP THAT INSISTS ON COMING TO THE FRONT WILL COME TO THE FRONT. Everything here opens in
109
+ * the background — `open -g`, never a direct spawn, because the same binary spawned directly
110
+ * twice left the foreground alone once and took it once, and unpredictable is worse than
111
+ * wrong. But an app that calls `activate` on itself takes the screen and no launcher option
112
+ * prevents it. That is the app's behaviour, not this tool's, and it is reported rather than
113
+ * hidden.
114
+ *
115
+ * MENUS AND MODAL SHEETS ARE NOT WALKED. Pressing a menu opens something that holds the event
116
+ * loop, and a modal sheet stops the window underneath answering. Both are readable if a
117
+ * journey deliberately opens them; neither is opened by this adapter on its own. An adapter
118
+ * that guessed which menu items to press on an unknown Mac app is an adapter that will one day
119
+ * press Delete.
120
+ *
121
+ * A PICTURE NEEDS A SECOND PERMISSION. Screen Recording, separately from Accessibility. Without
122
+ * it the picture comes back black or not at all, and that is recorded as a hole in the pixels
123
+ * channel with every other channel still reported in full.
124
+ *
125
+ * MOST MAC PRODUCTS DO NOT NEED THIS AT ALL. If the Mac build is Electron — and most desktop
126
+ * products are, including the one this tool was written alongside — it is already covered from
127
+ * any machine over its debug port by the Electron adapter, in full, with two builds able to run
128
+ * side by side. This adapter DECLINES an Electron bundle on purpose and says where to go
129
+ * instead. Reading one here would also mean switching on Chromium's accessibility engine, which
130
+ * changes the timing and the behaviour of the very thing being measured.
131
+ */
132
+
133
+ import fsp from 'node:fs/promises';
134
+ import os from 'node:os';
135
+ import path from 'node:path';
136
+ import {
137
+ countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
138
+ } from './contract.js';
139
+ import {
140
+ MAX_SHOT_BYTES, MAX_TREE_NODES, TREE_BUDGET_MS, WINDOW_WAIT_MS,
141
+ askTheScreen, crashesSince, descendantsOf, inspectBundle, loggedBy, openInTheBackground,
142
+ pictureOfWindow, pidsRunning, processList, runQuietly, stopOne,
143
+ } from './macos-driver.js';
144
+ import { compareTrees, snapshotTree } from './process.js';
145
+
146
+ /** @typedef {import('./contract.js').Build} Build */
147
+ /** @typedef {import('./contract.js').PreparedBuild} PreparedBuild */
148
+ /** @typedef {import('./contract.js').RunContext} RunContext */
149
+ /** @typedef {import('./contract.js').AdapterProject} AdapterProject */
150
+ /** @typedef {import('./contract.js').Detection} Detection */
151
+ /** @typedef {import('./contract.js').Missing} Missing */
152
+ /** @typedef {import('../types.js').Journey} Journey */
153
+ /** @typedef {import('../types.js').Observation} Observation */
154
+
155
+ /** How many times to re-read a window looking for two readings in a row that match. */
156
+ const SETTLE_TRIES = 5;
157
+
158
+ /** How long to wait between those readings. */
159
+ const SETTLE_GAP_MS = 250;
160
+
161
+ /** The exact click a person has to make once, spelt out wherever it is needed. */
162
+ export const HOW_TO_ALLOW =
163
+ 'Open System Settings, then Privacy & Security, then Accessibility, and switch on the app you '
164
+ + 'run Stays Fixed from — Terminal, iTerm, or your editor. macOS will not let any program grant '
165
+ + 'itself that, which is the point of it.';
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // One control, turned into something comparable
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * @typedef {object} TreeNode
173
+ * @property {number} d
174
+ * @property {string|null} role
175
+ * @property {string|null} sub
176
+ * @property {string|null} title
177
+ * @property {string|null} desc
178
+ * @property {string|number|null} value
179
+ * @property {string|null} id
180
+ * @property {boolean|number|null} on
181
+ * @property {string|null} help
182
+ * @property {string|null} hint
183
+ * @property {boolean|number|null} sel
184
+ * @property {boolean|number|null} foc
185
+ * @property {string[]} can
186
+ */
187
+
188
+ /**
189
+ * The address one control lives at.
190
+ *
191
+ * Built from what the control IS and what it is CALLED, never from where it sits in the tree.
192
+ * An index would be stable right up until somebody adds a control above it, at which point
193
+ * every control below would report as changed and the real change would be buried.
194
+ *
195
+ * The accessibility identifier comes first because it is the one thing a developer sets
196
+ * deliberately and a translator never touches. Then the title, then the description — which is
197
+ * what a toolbar button or an image button is actually called on a Mac, where the title is
198
+ * usually empty. Only when a control has none of those does its position get used, and that is
199
+ * marked in the address so a reader knows the address itself is fragile.
200
+ *
201
+ * @param {TreeNode} node
202
+ * @param {number} index
203
+ * @returns {string}
204
+ */
205
+ export function controlAddress(node, index) {
206
+ const role = String(node.role ?? 'unknown').replace(/^AX/, '').toLowerCase();
207
+ const called = node.id || node.title || node.desc;
208
+ if (called) return `${role}:${called}`;
209
+ return `${role}#${index}`;
210
+ }
211
+
212
+ /**
213
+ * What one control says it is, in one line, and it is the only thing compared.
214
+ *
215
+ * Deliberately excludes where it is on screen. A window that opens two pixels lower is not a
216
+ * difference anybody wants reported, and a control that MOVED without changing what it is or
217
+ * does is a pixel finding, not a meaning one. What is included is what would make somebody say
218
+ * the product behaves differently: what it is, what it is called, whether it works, what it is
219
+ * set to, and what it can be asked to do.
220
+ *
221
+ * Focus is left out on purpose and it is the subtle one. Which control has the keyboard on a
222
+ * Mac depends on whether that app happens to be the front app, and this adapter deliberately
223
+ * opens apps in the background — so focus would flip depending on what the person using the
224
+ * machine clicked on while the run was going, and report as a regression.
225
+ *
226
+ * @param {TreeNode} node
227
+ * @returns {string}
228
+ */
229
+ export function controlMeaning(node) {
230
+ const parts = [String(node.role ?? 'something with no role').replace(/^AX/, '')];
231
+ if (node.sub) parts.push(`(${String(node.sub).replace(/^AX/, '')})`);
232
+ const called = node.title || node.desc;
233
+ if (called) parts.push(`called "${called}"`);
234
+ // A checkbox's tick, a text field's contents and a slider's position all arrive as AXValue,
235
+ // and all three are exactly what a person means by "the screen changed".
236
+ if (node.value !== null && node.value !== undefined && node.value !== '') parts.push(`showing ${JSON.stringify(node.value)}`);
237
+ if (node.on === false || node.on === 0) parts.push('greyed out');
238
+ if (node.sel === true || node.sel === 1) parts.push('selected');
239
+ if (node.hint) parts.push(`placeholder "${node.hint}"`);
240
+ if (node.help) parts.push(`tooltip "${node.help}"`);
241
+ if (node.can && node.can.length > 0) parts.push(`can ${node.can.map((a) => a.replace(/^AX/, '')).join(', ')}`);
242
+ return parts.join(', ');
243
+ }
244
+
245
+ /**
246
+ * Turn one window's tree into observations, or into a hole when the read cannot be trusted.
247
+ *
248
+ * The cross-check is the important half, and it is why `onScreen` comes back from the probe at
249
+ * all. CoreGraphics and the accessibility layer are two different mechanisms answering the same
250
+ * question. When the window server says a window is up, 460 by 332, with a title, and the app's
251
+ * accessibility responder says the app has no windows, the accessibility answer is wrong —
252
+ * measured on 2026-08-31, caused by a second copy of the same app being open. Recording that as
253
+ * "this app has no controls" would put a confident zero into the reference, after which every
254
+ * later run would compare zero against zero and agree that nothing had changed.
255
+ *
256
+ * @param {object} spec
257
+ * @param {Journey} spec.journey
258
+ * @param {string} spec.window
259
+ * @param {TreeNode[]} spec.nodes
260
+ * @param {number} spec.onScreenCount How many windows the window server can see for this app.
261
+ * @param {number} spec.childCount Direct children, asked for through a different call.
262
+ * @param {boolean} [spec.settled]
263
+ * @param {boolean} [spec.truncated]
264
+ * @param {boolean} [spec.ranOut]
265
+ * @returns {Observation[]}
266
+ */
267
+ export function meaningFromTree(spec) {
268
+ const { journey, window: windowName, nodes, onScreenCount, childCount } = spec;
269
+ // The journey is in the address, not only the window. Every index in this engine keeps the
270
+ // FIRST observation at a path and drops the rest, so two journeys that both read the same
271
+ // window would collide: the second journey's answer would have no address of its own, would
272
+ // never be compared with anything, and the run would still say nothing had changed. The
273
+ // effects and counters paths already carried the journey for this reason; the screen ones
274
+ // did not, and that was found by running two journeys against one window on 2026-08-31.
275
+ const head = ['screen', journey.name, windowName];
276
+
277
+ if (nodes.length === 0 && onScreenCount > 0) {
278
+ return [notCovered({
279
+ channel: 'meaning',
280
+ path: joinPath(...head, 'controls'),
281
+ reason: 'not supported here',
282
+ says: `"${windowName}" is on the screen — macOS itself can see the window — but the app would not say what is `
283
+ + 'in it. On a Mac that almost always means a second copy of the same app is running, which makes the older '
284
+ + 'one stop answering. Nothing is recorded for it, because recording "no controls" would make the next run '
285
+ + 'agree that nothing had changed.',
286
+ })];
287
+ }
288
+ if (nodes.length === 0) {
289
+ return [notCovered({
290
+ channel: 'meaning',
291
+ path: joinPath(...head, 'controls'),
292
+ reason: 'not supported here',
293
+ says: `"${windowName}" reported no controls at all. Either it draws itself without telling macOS what it is `
294
+ + 'showing, or there was nothing on it yet. Either way it is unchecked, not empty.',
295
+ })];
296
+ }
297
+ if (nodes.length === 1 && childCount > 1) {
298
+ return [notCovered({
299
+ channel: 'meaning',
300
+ path: joinPath(...head, 'controls'),
301
+ reason: 'not supported here',
302
+ says: `"${windowName}" says it has ${childCount} things in it and then handed back none of them. That is a `
303
+ + 'half-answer, not an empty window, so nothing was recorded for it.',
304
+ })];
305
+ }
306
+
307
+ /** @type {Observation[]} */
308
+ const out = [];
309
+ /** @type {Map<string, number>} */
310
+ const used = new Map();
311
+ nodes.forEach((node, index) => {
312
+ let address = controlAddress(node, index);
313
+ // Two controls can honestly share a name — two "Close" buttons in two panels. Number the
314
+ // repeats rather than let the second quietly overwrite the first.
315
+ const seen = used.get(address) ?? 0;
316
+ used.set(address, seen + 1);
317
+ if (seen > 0) address = `${address}~${seen + 1}`;
318
+ const meaning = controlMeaning(node);
319
+ out.push(observation({
320
+ channel: 'meaning',
321
+ path: joinPath(...head, address),
322
+ value: meaning,
323
+ says: `On "${windowName}", ${meaning}.`,
324
+ journey: journey.name,
325
+ surface: 'macos',
326
+ }));
327
+ });
328
+
329
+ out.push(observation({
330
+ channel: 'counters',
331
+ path: joinPath('count', journey.name, windowName, 'controls'),
332
+ value: countBucket(nodes.length),
333
+ says: `"${windowName}" is showing ${nodes.length} control${nodes.length === 1 ? '' : 's'}.`,
334
+ journey: journey.name,
335
+ surface: 'macos',
336
+ }));
337
+
338
+ if (spec.settled === false) {
339
+ out.push(notCovered({
340
+ channel: 'meaning',
341
+ path: joinPath(...head, 'settled'),
342
+ reason: 'timed out',
343
+ says: `"${windowName}" never held still: two readings in a row never matched. What was recorded is one snapshot `
344
+ + 'of something still moving, so a difference found in it may be the movement rather than the change.',
345
+ }));
346
+ }
347
+ if (spec.truncated) {
348
+ out.push(notCovered({
349
+ channel: 'meaning',
350
+ path: joinPath(...head, 'all of it'),
351
+ reason: 'too big',
352
+ says: `"${windowName}" has more than ${MAX_TREE_NODES} controls, so only the first ${MAX_TREE_NODES} were read. `
353
+ + 'Anything past that is unchecked. Reading a Mac window costs about four milliseconds a control, so a table '
354
+ + 'with thousands of rows in it would take longer than the rest of the check put together.',
355
+ }));
356
+ }
357
+ if (spec.ranOut) {
358
+ out.push(notCovered({
359
+ channel: 'meaning',
360
+ path: joinPath(...head, 'the rest of it'),
361
+ reason: 'timed out',
362
+ says: `Reading "${windowName}" hit its ${timeBucket(TREE_BUDGET_MS)} limit with ${nodes.length} controls read. `
363
+ + 'The rest of that window is unchecked, not unchanged.',
364
+ }));
365
+ }
366
+ return out;
367
+ }
368
+
369
+ /**
370
+ * Programs the app started, as observations.
371
+ *
372
+ * The command line is kept and compared, because "it now launches its updater with a different
373
+ * flag" is exactly the kind of change no screenshot has ever caught. Helper processes are
374
+ * sorted by name so two runs that started the same helpers in a different order do not differ.
375
+ *
376
+ * @param {Journey} journey
377
+ * @param {{pid: number, parent: number, command: string}[]} procs
378
+ * @returns {Observation[]}
379
+ */
380
+ export function spawnedObservations(journey, procs) {
381
+ const named = procs
382
+ .map((p) => ({ name: path.basename(p.command), command: p.command }))
383
+ .sort((a, b) => (a.command < b.command ? -1 : a.command > b.command ? 1 : 0));
384
+ /** @type {Observation[]} */
385
+ const out = named.map((p, index) => observation({
386
+ channel: 'effects',
387
+ path: joinPath('proc', journey.name, `${p.name}#${index}`),
388
+ value: p.command,
389
+ says: `It started ${p.name}. That is a program running because this app ran.`,
390
+ journey: journey.name,
391
+ surface: 'macos',
392
+ }));
393
+ out.push(observation({
394
+ channel: 'counters',
395
+ path: joinPath('count', journey.name, 'programs started'),
396
+ value: countBucket(named.length),
397
+ says: `It started ${named.length} other program${named.length === 1 ? '' : 's'}.`,
398
+ journey: journey.name,
399
+ surface: 'macos',
400
+ }));
401
+ return out;
402
+ }
403
+
404
+ /**
405
+ * What changed on disk in the folders this run was told to watch.
406
+ *
407
+ * By content, because this runs on the same machine and reading the file back costs nothing
408
+ * next to running the whole product twice. That is a real improvement on the Windows adapter,
409
+ * which compares sizes over a network connection and openly misses a file rewritten to the
410
+ * same length.
411
+ *
412
+ * @param {Journey} journey
413
+ * @param {Map<string, string>} before
414
+ * @param {Map<string, string>} after
415
+ * @param {string} where Plain name of the folder, for the sentence.
416
+ * @returns {Observation[]}
417
+ */
418
+ export function fileObservations(journey, before, after, where) {
419
+ const changes = compareTrees(before, after);
420
+ /** @type {Observation[]} */
421
+ const out = changes
422
+ .slice()
423
+ .sort((a, b) => (a.file < b.file ? -1 : 1))
424
+ .map((change) => observation({
425
+ channel: 'effects',
426
+ path: joinPath('file', journey.name, change.file),
427
+ value: change.what === 'deleted' ? 'deleted' : `${change.what}, contents ${change.now}`,
428
+ says: change.what === 'deleted'
429
+ ? `It deleted ${change.file}.`
430
+ : change.what === 'created'
431
+ ? `It wrote ${change.file}.`
432
+ : `It changed what is inside ${change.file}.`,
433
+ journey: journey.name,
434
+ surface: 'macos',
435
+ }));
436
+ out.push(observation({
437
+ channel: 'counters',
438
+ path: joinPath('count', journey.name, 'files touched'),
439
+ value: countBucket(changes.length),
440
+ says: `${changes.length} file${changes.length === 1 ? '' : 's'} changed under ${where}.`,
441
+ journey: journey.name,
442
+ surface: 'macos',
443
+ }));
444
+ return out;
445
+ }
446
+
447
+ /**
448
+ * Crashes and log complaints, as observations.
449
+ *
450
+ * A crash report is kept down to its exception and its reason. The rest of a `.ips` file is
451
+ * every thread, every frame and every loaded address, and those differ on every run of
452
+ * identical code — comparing them would report a regression every single time.
453
+ *
454
+ * @param {Journey} journey
455
+ * @param {{file: string, exception: string|null, reason: string|null}[]} crashes
456
+ * @param {{kept: string[], dropped: number, ok: boolean, why: string}} logs
457
+ * @returns {Observation[]}
458
+ */
459
+ export function complaintObservations(journey, crashes, logs) {
460
+ /** @type {Observation[]} */
461
+ const out = crashes.map((crash, index) => observation({
462
+ channel: 'complaints',
463
+ path: joinPath('log', journey.name, 'crash', String(index)),
464
+ value: `${crash.exception ?? 'it fell over'}: ${crash.reason ?? 'no reason given'}`,
465
+ says: 'macOS filed a crash report for this app while the check was running.',
466
+ evidence: crash.file,
467
+ journey: journey.name,
468
+ surface: 'macos',
469
+ }));
470
+ out.push(observation({
471
+ channel: 'complaints',
472
+ path: joinPath('log', journey.name, 'crashed'),
473
+ value: crashes.length,
474
+ says: crashes.length === 0
475
+ ? 'macOS filed no crash report for this app while it ran.'
476
+ : `macOS filed ${crashes.length} crash report${crashes.length === 1 ? '' : 's'} for this app.`,
477
+ journey: journey.name,
478
+ surface: 'macos',
479
+ }));
480
+
481
+ if (!logs.ok) {
482
+ out.push(notCovered({
483
+ channel: 'complaints',
484
+ path: joinPath('log', journey.name, 'what it logged'),
485
+ reason: 'missing tool',
486
+ says: `What this app logged could not be read: ${logs.why}. Crashes are still reported; ordinary complaints are not.`,
487
+ }));
488
+ return out;
489
+ }
490
+ logs.kept.forEach((line, index) => {
491
+ out.push(observation({
492
+ channel: 'complaints',
493
+ path: joinPath('log', journey.name, 'complaint', String(index)),
494
+ value: line,
495
+ says: `While it ran, it logged: ${line}`,
496
+ journey: journey.name,
497
+ surface: 'macos',
498
+ }));
499
+ });
500
+ out.push(observation({
501
+ channel: 'counters',
502
+ path: joinPath('count', journey.name, 'complaints'),
503
+ value: countBucket(logs.kept.length),
504
+ says: logs.kept.length === 0
505
+ ? 'It logged nothing at error level or worse while it ran.'
506
+ : `It logged ${logs.kept.length} thing${logs.kept.length === 1 ? '' : 's'} at error level or worse.`,
507
+ journey: journey.name,
508
+ surface: 'macos',
509
+ }));
510
+ return out;
511
+ }
512
+
513
+ /**
514
+ * Connections the app had open, plus the honest note about what sampling misses.
515
+ * @param {Journey} journey
516
+ * @param {string} lsofOutput
517
+ * @returns {Observation[]}
518
+ */
519
+ export function networkObservations(journey, lsofOutput) {
520
+ /** @type {string[]} */
521
+ const reachable = [];
522
+ for (const line of lsofOutput.split('\n')) {
523
+ // `lsof -nP -i` prints "local->remote" in its NAME column for anything connected.
524
+ const m = /->([0-9a-fA-F.:[\]]+):(\d+)/.exec(line);
525
+ if (!m) continue;
526
+ if (m[1] === '127.0.0.1' || m[1] === '[::1]') continue;
527
+ reachable.push(`${m[1]}:${m[2]}`);
528
+ }
529
+ const unique = [...new Set(reachable)].sort();
530
+ /** @type {Observation[]} */
531
+ const out = unique.map((where, index) => observation({
532
+ channel: 'effects',
533
+ path: joinPath('net', journey.name, String(index)),
534
+ value: where,
535
+ says: `While it was running it had a connection open to ${where}.`,
536
+ journey: journey.name,
537
+ surface: 'macos',
538
+ }));
539
+ out.push(notCovered({
540
+ channel: 'effects',
541
+ path: joinPath('net', journey.name, 'everything it asked for'),
542
+ reason: 'missing tool',
543
+ says: 'Connections were sampled while the app ran, not captured. A request that opened and finished between two '
544
+ + 'samples was not seen, and nothing here could have stopped one. Capturing every call from a compiled Mac app '
545
+ + 'needs root or an Apple entitlement.',
546
+ }));
547
+ return out;
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // Finding the app
552
+ // ---------------------------------------------------------------------------
553
+
554
+ /**
555
+ * Where the Mac build is.
556
+ *
557
+ * A single `.app` folder, named in the config or found in the usual places a Mac build lands.
558
+ * Nothing is guessed beyond those places: pointing this at the wrong bundle produces a run that
559
+ * reads a different program and reports it as a pass.
560
+ *
561
+ * @param {AdapterProject} project
562
+ * @returns {{app: string|null, why: string}}
563
+ */
564
+ export function findMacApp(project) {
565
+ const config = project.config ?? {};
566
+ if (typeof config.app === 'string' && config.app.trim() !== '') {
567
+ const app = path.isAbsolute(config.app) ? config.app : path.join(project.root, config.app);
568
+ return { app, why: `The Mac app named in the config is at ${app}.` };
569
+ }
570
+ return { app: null, why: 'No Mac app was named, so there is nothing to open.' };
571
+ }
572
+
573
+ /**
574
+ * Places a Mac build normally lands, in the order worth looking.
575
+ *
576
+ * Xcode's own default first, then the two folders every packaging tool writes into. Read by
577
+ * `detect` so a project that has a build sitting there is told exactly what one line of config
578
+ * would turn on, rather than being told "nothing found".
579
+ */
580
+ export const WHERE_MAC_BUILDS_LAND = [
581
+ 'build/Build/Products/Release',
582
+ 'build/Release',
583
+ '.build/release',
584
+ 'dist/mac',
585
+ 'dist/mac-arm64',
586
+ 'dist',
587
+ 'build',
588
+ ];
589
+
590
+ /**
591
+ * Look for a `.app` in the usual places, so `detect` can name a real path in its advice.
592
+ * @param {string} root
593
+ * @returns {Promise<string|null>}
594
+ */
595
+ export async function lookForABundle(root) {
596
+ for (const where of WHERE_MAC_BUILDS_LAND) {
597
+ /** @type {string[]} */
598
+ let names = [];
599
+ try { names = await fsp.readdir(path.join(root, where)); } catch { continue; }
600
+ const app = names.find((n) => n.endsWith('.app'));
601
+ if (app) return path.join(root, where, app);
602
+ }
603
+ return null;
604
+ }
605
+
606
+ /**
607
+ * Does this project look like it is written in Swift or Objective-C at all.
608
+ *
609
+ * Cheap and deliberately shallow: it reads the top of the tree, not all of it, because a
610
+ * detection pass that walks a whole repository is a detection pass nobody waits for.
611
+ *
612
+ * @param {string[]} topLevelNames
613
+ * @returns {boolean}
614
+ */
615
+ export function looksLikeAMacProject(topLevelNames) {
616
+ return topLevelNames.some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace') || n === 'Package.swift');
617
+ }
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // The adapter
621
+ // ---------------------------------------------------------------------------
622
+
623
+ /**
624
+ * Everything this run started, so teardown puts back exactly that and nothing else.
625
+ *
626
+ * Somebody's own copy of the same app may well be running. The rule the whole tool obeys is
627
+ * never to kill what it did not start, and on a machine somebody is sitting at it is not an
628
+ * abstraction.
629
+ *
630
+ * @type {Map<string, number[]>}
631
+ */
632
+ const startedHere = new Map();
633
+
634
+ export const macosAdapter = defineAdapter({
635
+ name: 'macos',
636
+ title: 'native Mac apps',
637
+ describe:
638
+ 'Opens a native Mac app in the background on this Mac, reads what every control on screen says it is and does '
639
+ + 'through the Accessibility API, and watches what it starts, writes, prints and complains about. It needs '
640
+ + 'Accessibility permission, which a person has to grant once by hand. It runs one build at a time, because a '
641
+ + 'second copy of the same app makes the first stop answering. It declines Electron apps, which are covered '
642
+ + 'better and in pairs over their debug port.',
643
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
644
+
645
+ /**
646
+ * @param {AdapterProject} project
647
+ * @returns {Promise<Detection>}
648
+ */
649
+ async detect(project) {
650
+ /** @type {Missing[]} */
651
+ const missing = [];
652
+
653
+ if (process.platform !== 'darwin') {
654
+ return {
655
+ applies: false,
656
+ confidence: 0,
657
+ why: 'A native Mac window can only be read from a Mac. This is not one, and there is no way to reach one '
658
+ + 'over a network the way a Windows desktop can be reached over ssh — the Accessibility API only answers '
659
+ + 'inside a signed-in graphical session on the machine itself.',
660
+ missing: [],
661
+ notes: ['If you have a Mac, run the check there. Nothing needs installing on it.'],
662
+ };
663
+ }
664
+
665
+ const found = findMacApp(project);
666
+ let appPath = found.app;
667
+ if (!appPath) appPath = await lookForABundle(project.root);
668
+
669
+ const hello = await askTheScreen({ op: 'hello' }, { limitMs: 20_000 });
670
+ const allowed = hello.ok === true && hello.axTrusted === true;
671
+ if (!allowed) {
672
+ missing.push({
673
+ what: 'permission to read another app\'s window',
674
+ unlocks: 'everything on this surface — without it not one control can be read',
675
+ howToGet: HOW_TO_ALLOW,
676
+ blocking: true,
677
+ });
678
+ }
679
+
680
+ if (!found.app) {
681
+ missing.push({
682
+ what: 'the built Mac app',
683
+ unlocks: 'opening the app and reading what is on its screen',
684
+ howToGet: appPath
685
+ ? `There is one at ${path.relative(project.root, appPath)}. Put {"app": "${path.relative(project.root, appPath)}"} under "macos" in the config to use it.`
686
+ : 'Put {"app": "build/Release/YourApp.app"} under "macos" in the config, pointing at the built .app folder.',
687
+ blocking: true,
688
+ });
689
+ }
690
+
691
+ const bundle = appPath ? await inspectBundle(appPath) : null;
692
+ if (bundle?.electron) {
693
+ return {
694
+ applies: false,
695
+ confidence: 0,
696
+ why: 'This is an Electron app, so the Electron adapter covers its Mac build properly — over the debug port, '
697
+ + 'from any machine, with two builds able to run side by side. This adapter would be strictly worse: one '
698
+ + 'build at a time, and reading the window would switch on Chromium\'s accessibility engine and change the '
699
+ + 'timing of the thing being measured.',
700
+ missing: [],
701
+ notes: ['Nothing is missing. There is simply a better tool for this app already in the box.'],
702
+ };
703
+ }
704
+
705
+ if (!Array.isArray(project.config?.watchDirs) || project.config.watchDirs.length === 0) {
706
+ missing.push({
707
+ what: 'the folders this app writes into',
708
+ unlocks: 'seeing what it saved, which is otherwise invisible — watching everything a Mac app writes needs '
709
+ + 'root, which this does not have and will not ask for',
710
+ howToGet: 'Put {"watchDirs": ["~/Library/Application Support/YourApp"]} under "macos" in the config.',
711
+ });
712
+ }
713
+
714
+ const applies = allowed && Boolean(found.app) && bundle?.ok === true;
715
+ return {
716
+ applies,
717
+ confidence: applies ? 0.9 : 0,
718
+ why: applies
719
+ ? `${found.why} It will be opened in the background on this Mac, read through the Accessibility API, and `
720
+ + 'closed again — one build at a time.'
721
+ : !allowed
722
+ ? 'This Mac has not been told to let Stays Fixed read another app\'s window, so nothing on screen can be read yet.'
723
+ : found.app
724
+ ? `${bundle?.why ?? 'That is not a readable Mac app bundle.'}`
725
+ : 'A native Mac app needs a built .app folder, and none is named yet.',
726
+ missing,
727
+ notes: [
728
+ 'Nothing is installed to do this. The program that reads the screen is JavaScript handed to macOS\'s own '
729
+ + 'osascript on standard input, and it disappears when the run ends.',
730
+ 'The app is opened in the BACKGROUND and never brought to the front, so a check can run while somebody is '
731
+ + 'working. An app that brings itself to the front will still do so, and that is reported.',
732
+ 'One build at a time, always. Two copies of the same app make the older one stop answering the '
733
+ + 'Accessibility API entirely, which looks exactly like an app with no controls in it.',
734
+ 'Nothing irreversible can be stopped here. There is no way to refuse a compiled Mac app\'s network call '
735
+ + 'without root, so a journey marked irreversible is refused outright instead of walked.',
736
+ ],
737
+ };
738
+ },
739
+
740
+ /**
741
+ * @param {AdapterProject} project
742
+ * @returns {Promise<Journey[]>}
743
+ */
744
+ async journeys(project) {
745
+ const config = project.config ?? {};
746
+ const found = findMacApp(project);
747
+ if (!found.app) return [];
748
+
749
+ /** @type {Journey[]} */
750
+ const journeys = [{
751
+ name: 'open-the-app',
752
+ describe: 'open the Mac app in the background and read every control it puts on screen',
753
+ source: 'code',
754
+ surface: 'macos',
755
+ from: 'the app bundle named in the config',
756
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
757
+ steps: [{ act: 'launch' }, { act: 'settle' }, { act: 'read' }],
758
+ timeoutMs: 180_000,
759
+ }];
760
+
761
+ // Anything beyond opening it has to be described by somebody who knows the app. Read out of
762
+ // the config rather than invented here: an adapter that guesses which buttons to press on
763
+ // an unknown Mac app is an adapter that will one day press "Delete account".
764
+ for (const extra of Array.isArray(config.journeys) ? config.journeys : []) {
765
+ if (!extra || typeof extra.name !== 'string') continue;
766
+ journeys.push({
767
+ name: extra.name,
768
+ describe: typeof extra.describe === 'string' ? extra.describe : `walk "${extra.name}"`,
769
+ source: 'recorded',
770
+ surface: 'macos',
771
+ from: 'the project config',
772
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
773
+ steps: Array.isArray(extra.steps) ? extra.steps : [],
774
+ irreversible: Boolean(extra.irreversible),
775
+ timeoutMs: 180_000,
776
+ });
777
+ }
778
+ return journeys;
779
+ },
780
+
781
+ /**
782
+ * @param {Build} build
783
+ * @param {RunContext} ctx
784
+ * @returns {Promise<PreparedBuild>}
785
+ */
786
+ async prepare(build, ctx) {
787
+ const config = ctx.config ?? {};
788
+ const nothingReady = (/** @type {string} */ why) =>
789
+ ({ build, root: build.root, ready: false, why, dispose: async () => {} });
790
+
791
+ if (process.platform !== 'darwin') {
792
+ return nothingReady('A native Mac app can only be opened on a Mac, and this is not one.');
793
+ }
794
+ const found = findMacApp({ root: build.root, config });
795
+ if (!found.app) return nothingReady(found.why);
796
+
797
+ const bundle = await inspectBundle(found.app);
798
+ if (!bundle.ok || !bundle.executable) return nothingReady(bundle.why);
799
+ if (bundle.electron) {
800
+ return nothingReady('That is an Electron app. The Electron adapter drives it properly over its debug port, from '
801
+ + 'any machine, with both builds up at once — this adapter would be strictly worse.');
802
+ }
803
+
804
+ const hello = await askTheScreen({ op: 'hello' }, { limitMs: 20_000 });
805
+ if (hello.ok !== true || hello.axTrusted !== true) {
806
+ return nothingReady(`This Mac has not been told to let Stays Fixed read another app's window. ${HOW_TO_ALLOW}`);
807
+ }
808
+
809
+ // Anything already running that IS this app is the measured problem: a second copy makes one
810
+ // of them stop answering the accessibility layer entirely. Matched on the BUNDLE IDENTIFIER
811
+ // rather than on the path to the program, and that distinction is the whole point — two
812
+ // builds being compared live in two folders and run two different files, so nothing about
813
+ // their paths says they are the same app. The first version of this check matched paths,
814
+ // and a rival build sitting right next to the one under test walked straight past it.
815
+ //
816
+ // Only copies THIS run started are stopped. Somebody else's copy is reported and left alone,
817
+ // because the rule the whole tool obeys is never to kill what it did not start, and on a
818
+ // machine somebody is sitting at that is not an abstraction.
819
+ const ours = new Set([...startedHere.values()].flat());
820
+ const rivals = await otherCopiesOf(bundle.bundleId, bundle.executable, ours, ctx.log);
821
+
822
+ return {
823
+ build,
824
+ root: build.root,
825
+ ready: true,
826
+ why: `${found.why} ${bundle.why}${rivals.length > 0 ? ` ${warnAboutRivals(bundle.name ?? path.basename(found.app, '.app'), rivals)}` : ''}`,
827
+ facts: {
828
+ app: found.app,
829
+ executable: bundle.executable,
830
+ name: bundle.name,
831
+ bundleId: bundle.bundleId,
832
+ rivals: rivals.length,
833
+ macos: typeof hello.macos === 'string' ? hello.macos : undefined,
834
+ screen: typeof hello.screen === 'string' ? hello.screen : undefined,
835
+ },
836
+ dispose: async () => {
837
+ for (const pid of startedHere.get(build.id) ?? []) await stopOne(pid, ctx.log);
838
+ startedHere.delete(build.id);
839
+ },
840
+ };
841
+ },
842
+
843
+ /**
844
+ * @param {Journey} journey
845
+ * @param {PreparedBuild} prepared
846
+ * @param {RunContext} ctx
847
+ * @returns {Promise<Observation[]>}
848
+ */
849
+ async run(journey, prepared, ctx) {
850
+ const config = ctx.config ?? {};
851
+ const appPath = String(prepared.facts?.app ?? '');
852
+ const executable = String(prepared.facts?.executable ?? '');
853
+ const appName = String(prepared.facts?.name ?? path.basename(appPath, '.app'));
854
+
855
+ if (!prepared.ready || !executable) {
856
+ return [notCovered({
857
+ channel: 'meaning',
858
+ path: joinPath('screen', journey.name, 'anything at all'),
859
+ reason: 'missing tool',
860
+ says: `"${journey.describe}" was not walked: ${prepared.why}`,
861
+ })];
862
+ }
863
+
864
+ // Refused outright rather than walked carefully. There is no wire boundary on a compiled
865
+ // Mac app without root, so "watch it ask and then stop it" is not available, and a careful
866
+ // walk of an irreversible journey is a walk that really does the irreversible thing.
867
+ if (journey.irreversible) {
868
+ return [notCovered({
869
+ channel: 'effects',
870
+ path: joinPath('screen', journey.name, 'refused'),
871
+ reason: 'irreversible',
872
+ says: `"${journey.describe}" would spend money, send a message or destroy data, and on a Mac there is no way `
873
+ + 'to let it ask and then stop it — that needs root, which this will not take. It was not run at all. This '
874
+ + 'is a hole in what was checked, not a pass.',
875
+ })];
876
+ }
877
+
878
+ /** @type {Observation[]} */
879
+ const seen = [];
880
+ /** @type {string[]} */
881
+ const watchDirs = (Array.isArray(config.watchDirs) ? config.watchDirs : [])
882
+ .map((d) => String(d).replace(/^~(?=$|\/)/, os.homedir()));
883
+ const startedAt = Date.now();
884
+ /** @type {number|null} */
885
+ let pid = null;
886
+ const outFile = path.join(ctx.scratchDir, `macos-${journey.name}-printed.txt`);
887
+ const errFile = path.join(ctx.scratchDir, `macos-${journey.name}-complained.txt`);
888
+
889
+ try {
890
+ /** @type {Map<string, Map<string, string>>} */
891
+ const before = new Map();
892
+ for (const dir of watchDirs) before.set(dir, await snapshotTree(dir));
893
+
894
+ const opened = await openInTheBackground({
895
+ appPath,
896
+ args: Array.isArray(config.args) ? config.args.map(String) : [],
897
+ stdoutFile: outFile,
898
+ stderrFile: errFile,
899
+ });
900
+ if (opened.code !== 0) {
901
+ return [notCovered({
902
+ channel: 'meaning',
903
+ path: joinPath('screen', journey.name, 'anything at all'),
904
+ reason: 'crashed',
905
+ says: `The app would not open: ${(opened.stderr || opened.stdout).trim().slice(0, 300) || `open ended with ${opened.code}`}.`,
906
+ })];
907
+ }
908
+
909
+ // `open` hands back as soon as LaunchServices has taken the request, so the process id has
910
+ // to be looked up rather than returned. Matched on the FULL path to the program inside the
911
+ // bundle, never on its name: two builds being compared have the same name and different
912
+ // folders, and picking the wrong one reads the old build and calls it the new one.
913
+ const deadline = Date.now() + WINDOW_WAIT_MS;
914
+ while (Date.now() < deadline && pid === null) {
915
+ const { byPath } = await processList();
916
+ const running = pidsRunning(byPath, executable);
917
+ if (running.length > 0) pid = running[running.length - 1];
918
+ else await new Promise((r) => setTimeout(r, 300));
919
+ }
920
+ if (pid === null) {
921
+ return [notCovered({
922
+ channel: 'meaning',
923
+ path: joinPath('screen', journey.name, 'anything at all'),
924
+ reason: 'crashed',
925
+ says: `The app was asked to open and never appeared in the list of running programs within `
926
+ + `${timeBucket(WINDOW_WAIT_MS)}. Nothing about it was checked.`,
927
+ })];
928
+ }
929
+ startedHere.set(prepared.build.id, [...(startedHere.get(prepared.build.id) ?? []), pid]);
930
+
931
+ // Wait for a window rather than sleeping a fixed time. A machine under load takes longer,
932
+ // and a fixed sleep would turn that into a difference in the report.
933
+ /** @type {{title: string|null, index: number}[]} */
934
+ let windows = [];
935
+ /** @type {{id: number, title: string|null, w: number, h: number}[]} */
936
+ let onScreen = [];
937
+ const windowDeadline = Date.now() + WINDOW_WAIT_MS;
938
+ while (Date.now() < windowDeadline) {
939
+ const reply = await askTheScreen({ op: 'windows', pid }, { limitMs: 30_000 });
940
+ if (reply.ok) {
941
+ windows = Array.isArray(reply.windows) ? reply.windows : [];
942
+ onScreen = Array.isArray(reply.onScreen) ? reply.onScreen : [];
943
+ if (windows.length > 0 || onScreen.length > 0) break;
944
+ }
945
+ await new Promise((r) => setTimeout(r, 400));
946
+ }
947
+
948
+ if (Number(prepared.facts?.rivals ?? 0) > 0) {
949
+ seen.push(notCovered({
950
+ channel: 'meaning',
951
+ path: joinPath('screen', journey.name, 'read on a clean machine'),
952
+ reason: 'not supported here',
953
+ says: `Another copy of ${appName} was running while this was checked. Two copies of one Mac app make one of `
954
+ + 'them stop answering the Accessibility API, so anything read here may be a partial answer rather than '
955
+ + 'the whole screen. It is reported as unchecked on purpose: a short tree that looks complete is the one '
956
+ + 'thing this tool must never hand back.',
957
+ }));
958
+ }
959
+
960
+ if (windows.length === 0 && onScreen.length === 0) {
961
+ seen.push(notCovered({
962
+ channel: 'meaning',
963
+ path: joinPath('screen', journey.name, 'a window'),
964
+ reason: 'timed out',
965
+ says: `The app opened but put no window on screen within ${timeBucket(WINDOW_WAIT_MS)}. Nothing about its `
966
+ + 'screen was checked. It may be a menu-bar-only program, or it may have failed quietly.',
967
+ }));
968
+ }
969
+
970
+ for (const window of windows) {
971
+ const label = window.title || `window ${window.index + 1}`;
972
+ const tree = await askTheScreen({
973
+ op: 'settle',
974
+ pid,
975
+ index: window.index,
976
+ limit: MAX_TREE_NODES,
977
+ budgetMs: TREE_BUDGET_MS,
978
+ tries: SETTLE_TRIES,
979
+ gapMs: SETTLE_GAP_MS,
980
+ }, { limitMs: 120_000 });
981
+
982
+ if (!tree.ok) {
983
+ seen.push(notCovered({
984
+ channel: 'meaning',
985
+ path: joinPath('screen', journey.name, label, 'controls'),
986
+ reason: 'crashed',
987
+ says: `"${label}" could not be read: ${tree.error}.`,
988
+ }));
989
+ continue;
990
+ }
991
+
992
+ seen.push(...meaningFromTree({
993
+ journey,
994
+ window: label,
995
+ nodes: Array.isArray(tree.nodes) ? tree.nodes : [],
996
+ onScreenCount: Array.isArray(tree.onScreen) ? tree.onScreen.length : 0,
997
+ childCount: Number(tree.childCount ?? -1),
998
+ settled: Boolean(tree.agreed),
999
+ truncated: Boolean(tree.truncated),
1000
+ ranOut: Boolean(tree.ranOut),
1001
+ }));
1002
+ seen.push(observation({
1003
+ channel: 'results',
1004
+ path: joinPath('screen', journey.name, label, 'title'),
1005
+ value: window.title,
1006
+ says: `A window is open called "${window.title}".`,
1007
+ journey: journey.name,
1008
+ surface: 'macos',
1009
+ }));
1010
+ }
1011
+
1012
+ // The steps a project wrote down, walked in order. Only after the first read, so a journey
1013
+ // that presses something has a "before" in the reference to be different from.
1014
+ for (const step of journey.steps ?? []) {
1015
+ if (step.act === 'press' || step.act === 'set') {
1016
+ seen.push(...await walkOneStep(journey, step, pid, ctx));
1017
+ }
1018
+ }
1019
+ if ((journey.steps ?? []).some((s) => s.act === 'press' || s.act === 'set')) {
1020
+ for (const window of windows) {
1021
+ const label = window.title || `window ${window.index + 1}`;
1022
+ const after = await askTheScreen({
1023
+ op: 'settle', pid, index: window.index, limit: MAX_TREE_NODES,
1024
+ budgetMs: TREE_BUDGET_MS, tries: SETTLE_TRIES, gapMs: SETTLE_GAP_MS,
1025
+ }, { limitMs: 120_000 });
1026
+ if (!after.ok) continue;
1027
+ seen.push(...meaningFromTree({
1028
+ journey,
1029
+ window: `${label} after the steps`,
1030
+ nodes: Array.isArray(after.nodes) ? after.nodes : [],
1031
+ onScreenCount: Array.isArray(after.onScreen) ? after.onScreen.length : 0,
1032
+ childCount: Number(after.childCount ?? -1),
1033
+ settled: Boolean(after.agreed),
1034
+ truncated: Boolean(after.truncated),
1035
+ ranOut: Boolean(after.ranOut),
1036
+ }));
1037
+ }
1038
+ }
1039
+
1040
+ // Pictures last, and only as evidence. A picture is written to the evidence folder and
1041
+ // pointed at; it is never the thing compared.
1042
+ seen.push(...await picturesOfWindows(journey, onScreen, ctx));
1043
+
1044
+ // What it printed. A Mac app's output is block-buffered when it goes to a file rather
1045
+ // than a terminal, so an app that prints without flushing shows nothing here until it
1046
+ // exits — which is a property of the app, and is said rather than left as a silence.
1047
+ const printed = await readIfThere(outFile);
1048
+ const complained = await readIfThere(errFile);
1049
+ seen.push(observation({
1050
+ channel: 'results',
1051
+ path: joinPath('cli', journey.name, 'printed'),
1052
+ value: printed.trim(),
1053
+ says: printed.trim() === ''
1054
+ ? 'It printed nothing while the check was running. A Mac app that prints without flushing shows nothing '
1055
+ + 'here until it quits, so this is not proof that it printed nothing.'
1056
+ : `It printed: ${printed.trim().slice(0, 200)}`,
1057
+ journey: journey.name,
1058
+ surface: 'macos',
1059
+ }));
1060
+ if (complained.trim() !== '') {
1061
+ seen.push(observation({
1062
+ channel: 'complaints',
1063
+ path: joinPath('cli', journey.name, 'complained'),
1064
+ value: complained.trim(),
1065
+ says: `It complained: ${complained.trim().slice(0, 200)}`,
1066
+ journey: journey.name,
1067
+ surface: 'macos',
1068
+ }));
1069
+ }
1070
+
1071
+ const { byParent } = await processList();
1072
+ seen.push(...spawnedObservations(journey, descendantsOf(byParent, [pid])));
1073
+
1074
+ const conns = await runQuietly('/usr/sbin/lsof', ['-nP', '-i', '-a', '-p', String(pid)], {
1075
+ limitMs: 20_000, what: 'the connections this app had open',
1076
+ });
1077
+ seen.push(...networkObservations(journey, conns.stdout));
1078
+
1079
+ for (const dir of watchDirs) {
1080
+ seen.push(...fileObservations(journey, before.get(dir) ?? new Map(), await snapshotTree(dir), dir));
1081
+ }
1082
+ if (watchDirs.length === 0) {
1083
+ seen.push(notCovered({
1084
+ channel: 'effects',
1085
+ path: joinPath('file', journey.name, 'anything written'),
1086
+ reason: 'needs a sample',
1087
+ says: 'Nothing was watched on disk, because no folders were named. Add "watchDirs" under "macos" in the '
1088
+ + 'config and what this app saves becomes visible.',
1089
+ }));
1090
+ } else {
1091
+ seen.push(notCovered({
1092
+ channel: 'effects',
1093
+ path: joinPath('file', journey.name, 'everywhere else'),
1094
+ reason: 'missing tool',
1095
+ says: 'Only the folders this check was told to watch were compared. Watching everything a Mac app writes '
1096
+ + 'needs root, which this will not take, so a file written anywhere else was not seen. That is a hole, '
1097
+ + 'not a clean result.',
1098
+ }));
1099
+ }
1100
+
1101
+ const stillHere = isStillRunning(pid);
1102
+ seen.push(observation({
1103
+ channel: 'complaints',
1104
+ path: joinPath('proc', journey.name, 'still running'),
1105
+ value: stillHere,
1106
+ says: stillHere
1107
+ ? 'The app was still running when the check finished, which is what a window app should do.'
1108
+ : 'The app had already quit by the time the check finished. For a window app that usually means it fell over.',
1109
+ journey: journey.name,
1110
+ surface: 'macos',
1111
+ }));
1112
+
1113
+ seen.push(...complaintObservations(
1114
+ journey,
1115
+ await crashesSince(appName, startedAt),
1116
+ await loggedBy(pid, startedAt),
1117
+ ));
1118
+
1119
+ return seen;
1120
+ } catch (error) {
1121
+ // Something went wrong part way through. Keep everything really seen, and say plainly that
1122
+ // the rest is unchecked. Never let a short run look like a clean one.
1123
+ return [...seen, notCovered({
1124
+ channel: 'meaning',
1125
+ path: joinPath('screen', journey.name, 'the rest of it'),
1126
+ reason: 'crashed',
1127
+ says: `"${journey.describe}" stopped part way through: ${error instanceof Error ? error.message : String(error)}. `
1128
+ + 'Everything after that point is unchecked, not unchanged.',
1129
+ })];
1130
+ } finally {
1131
+ // Closed before the next build opens, because two copies of the same app make one of them
1132
+ // stop answering — which is the single worst failure this surface has.
1133
+ if (pid !== null) {
1134
+ await stopOne(pid, ctx.log);
1135
+ const left = (startedHere.get(prepared.build.id) ?? []).filter((p) => p !== pid);
1136
+ if (left.length > 0) startedHere.set(prepared.build.id, left);
1137
+ else startedHere.delete(prepared.build.id);
1138
+ }
1139
+ }
1140
+ },
1141
+
1142
+ /**
1143
+ * Put the Mac back the way it was found.
1144
+ *
1145
+ * Only ever stops what this run started. Somebody's own copy of the same app may be open, and
1146
+ * on a machine somebody is sitting at that is not an abstraction.
1147
+ */
1148
+ async teardown() {
1149
+ for (const [id, pids] of startedHere) {
1150
+ for (const pid of pids) await stopOne(pid);
1151
+ startedHere.delete(id);
1152
+ }
1153
+ },
1154
+ });
1155
+
1156
+ // ---------------------------------------------------------------------------
1157
+ // The pieces `run` leans on
1158
+ // ---------------------------------------------------------------------------
1159
+
1160
+ /**
1161
+ * Do one thing to the screen, and report what happened either way.
1162
+ *
1163
+ * Presses go through the accessibility layer's own press action, never a synthetic mouse
1164
+ * event. Measured on this machine: a synthetic click lands on the window and no handler fires,
1165
+ * while `AXPress` really runs the code — counter went up, output was logged, a file was
1166
+ * written. A step that pressed and silently did nothing would be the worst kind of pass.
1167
+ *
1168
+ * @param {Journey} journey
1169
+ * @param {import('../types.js').JourneyStep} step
1170
+ * @param {number} pid
1171
+ * @param {RunContext} ctx
1172
+ * @returns {Promise<Observation[]>}
1173
+ */
1174
+ async function walkOneStep(journey, step, pid, ctx) {
1175
+ const control = String(step.control ?? '');
1176
+ if (control === '') {
1177
+ return [notCovered({
1178
+ channel: 'meaning',
1179
+ path: joinPath('screen', journey.name, 'a step with no control named'),
1180
+ reason: 'needs a sample',
1181
+ says: `A "${step.act}" step in "${journey.name}" does not say which control it means, so it was skipped. `
1182
+ + 'Name it by its accessibility identifier, its title or its description.',
1183
+ })];
1184
+ }
1185
+ const reply = step.act === 'press'
1186
+ ? await askTheScreen({ op: 'press', pid, control, action: step.action }, { limitMs: 45_000 })
1187
+ : await askTheScreen({ op: 'set', pid, control, value: String(step.value ?? '') }, { limitMs: 45_000 });
1188
+
1189
+ if (!reply.ok) {
1190
+ return [notCovered({
1191
+ channel: 'meaning',
1192
+ path: joinPath('screen', journey.name, `${step.act} ${control}`),
1193
+ reason: 'crashed',
1194
+ says: `"${journey.name}" could not ${step.act} "${control}": ${reply.error}. Everything the app would have done `
1195
+ + 'afterwards is unchecked.',
1196
+ })];
1197
+ }
1198
+ ctx.log?.(`${step.act === 'press' ? 'Pressed' : 'Set'} "${control}".`);
1199
+ // A short settle after acting, because a Mac app updates its screen on the next turn of its
1200
+ // run loop and reading immediately reads the old state.
1201
+ await new Promise((r) => setTimeout(r, SETTLE_GAP_MS));
1202
+ return [observation({
1203
+ channel: 'effects',
1204
+ path: joinPath('screen', journey.name, `${step.act} ${control}`),
1205
+ value: step.act === 'press' ? 'accepted the press' : `accepted the value ${JSON.stringify(String(step.value ?? ''))}`,
1206
+ says: step.act === 'press'
1207
+ ? `"${control}" accepted being pressed.`
1208
+ : `"${control}" accepted being set to ${JSON.stringify(String(step.value ?? ''))}.`,
1209
+ journey: journey.name,
1210
+ surface: 'macos',
1211
+ })];
1212
+ }
1213
+
1214
+ /**
1215
+ * Take and keep a picture of every window, and never let a failed picture be a silence.
1216
+ *
1217
+ * Three ways out of here and two of them would be silent if this were written the obvious way.
1218
+ * A picture that failed, came back black, or came back over the size this keeps would produce
1219
+ * no observation at all — so the pixels channel would drop out of the run without a word, and
1220
+ * the coverage ledger would report the same coverage as a run where every window really was
1221
+ * photographed. A cap is a decision and a decision has to be visible; a failure is a hole and a
1222
+ * hole has to be named.
1223
+ *
1224
+ * @param {Journey} journey
1225
+ * @param {{id: number, title: string|null, w: number, h: number}[]} onScreen
1226
+ * @param {RunContext} ctx
1227
+ * @returns {Promise<Observation[]>}
1228
+ */
1229
+ async function picturesOfWindows(journey, onScreen, ctx) {
1230
+ /** @type {Observation[]} */
1231
+ const out = [];
1232
+ /** @type {string[]} */
1233
+ const files = [];
1234
+ /** @type {Map<string, string>} */
1235
+ const labelFor = new Map();
1236
+
1237
+ for (const window of onScreen) {
1238
+ const label = window.title || `window ${window.id}`;
1239
+ const file = path.join(ctx.evidenceDir, `macos-${journey.name}-${label.replace(/[^a-z0-9]+/gi, '-')}.png`);
1240
+ const shot = await pictureOfWindow(window.id, file);
1241
+ if (!shot.ok) {
1242
+ out.push(notCovered({
1243
+ channel: 'pixels',
1244
+ path: joinPath('screen', journey.name, label, 'picture'),
1245
+ reason: 'crashed',
1246
+ says: `No picture of "${label}" could be taken: ${shot.why}. Every other channel still looked at that window; `
1247
+ + 'only the picture is missing.',
1248
+ }));
1249
+ continue;
1250
+ }
1251
+ if (shot.bytes > MAX_SHOT_BYTES) {
1252
+ out.push(notCovered({
1253
+ channel: 'pixels',
1254
+ path: joinPath('screen', journey.name, label, 'picture'),
1255
+ reason: 'too big',
1256
+ says: `The picture of "${label}" came back at ${sizeBucket(shot.bytes)}, over the ${sizeBucket(MAX_SHOT_BYTES)} `
1257
+ + 'this keeps, so it was not stored. Every other channel still looked at that window.',
1258
+ }));
1259
+ await fsp.rm(file, { force: true });
1260
+ continue;
1261
+ }
1262
+ files.push(file);
1263
+ labelFor.set(file, label);
1264
+ out.push(observation({
1265
+ channel: 'pixels',
1266
+ path: joinPath('screen', journey.name, label, 'looks like'),
1267
+ value: `${window.w} by ${window.h}`,
1268
+ says: `A picture of "${label}" was kept as evidence. It is not compared — it is there to show a person `
1269
+ + 'something another channel already found.',
1270
+ evidence: file,
1271
+ journey: journey.name,
1272
+ surface: 'macos',
1273
+ }));
1274
+ }
1275
+
1276
+ if (files.length === 0) return out;
1277
+ // One call for all of them, because starting osascript costs about 130ms and a window's
1278
+ // brightness is not worth that each.
1279
+ const lit = await askTheScreen({ op: 'lit', files }, { limitMs: 45_000 });
1280
+ for (const file of files) {
1281
+ const count = Number(lit.lit?.[file] ?? -1);
1282
+ if (count === 0) {
1283
+ out.push(notCovered({
1284
+ channel: 'pixels',
1285
+ path: joinPath('screen', journey.name, labelFor.get(file) ?? file, 'picture is usable'),
1286
+ reason: 'not supported here',
1287
+ says: 'The picture came back completely black. On a Mac that means either the screen is locked or this '
1288
+ + 'program has not been given Screen Recording permission in System Settings, Privacy & Security. Every '
1289
+ + 'other channel still works; only the picture is lost.',
1290
+ }));
1291
+ }
1292
+ }
1293
+ return out;
1294
+ }
1295
+
1296
+ /**
1297
+ * Other copies of this same app that are running and were not started by this run.
1298
+ *
1299
+ * Copies this run started are stopped here rather than reported, because leaving one up is what
1300
+ * breaks the next build's read. Everything else is handed back so the caller can say so.
1301
+ *
1302
+ * @param {string|undefined} bundleId
1303
+ * @param {string} executable
1304
+ * @param {Set<number>} ours
1305
+ * @param {(m: string) => void} [log]
1306
+ * @returns {Promise<{pid: number, path: string|null}[]>}
1307
+ */
1308
+ async function otherCopiesOf(bundleId, executable, ours, log) {
1309
+ /** @type {{pid: number, path: string|null}[]} */
1310
+ const rivals = [];
1311
+ const reply = await askTheScreen({ op: 'running' }, { limitMs: 30_000 });
1312
+ /** @type {{pid: number, bundleId: string, path: string|null}[]} */
1313
+ const apps = reply.ok && Array.isArray(reply.apps) ? reply.apps : [];
1314
+ const sameApp = bundleId
1315
+ ? apps.filter((a) => a.bundleId === bundleId)
1316
+ // With no identifier to go on — a plist that did not name one — fall back to the path. It is
1317
+ // a weaker check and it is used only because the better one is unavailable.
1318
+ : (await processList().then(({ byPath }) => pidsRunning(byPath, executable))).map((pid) => ({ pid, path: null }));
1319
+ for (const app of sameApp) {
1320
+ if (ours.has(app.pid)) { await stopOne(app.pid, log); continue; }
1321
+ rivals.push({ pid: app.pid, path: 'path' in app ? app.path : null });
1322
+ }
1323
+ return rivals;
1324
+ }
1325
+
1326
+ /**
1327
+ * The sentence about a rival copy, in one place so `prepare` and `run` say the same thing.
1328
+ * @param {string} appName
1329
+ * @param {{pid: number, path: string|null}[]} rivals
1330
+ * @returns {string}
1331
+ */
1332
+ function warnAboutRivals(appName, rivals) {
1333
+ const where = rivals.map((r) => r.path ?? `process ${r.pid}`).join(', ');
1334
+ return `Another copy of ${appName} is already running (${where}) and it was left alone. Two copies of one Mac app `
1335
+ + 'make one of them stop answering the Accessibility API completely, which looks exactly like an app with no '
1336
+ + 'controls in it. Quit that copy before running this for a result you can trust.';
1337
+ }
1338
+
1339
+ /** @param {string} file */
1340
+ async function readIfThere(file) {
1341
+ try { return await fsp.readFile(file, 'utf8'); } catch { return ''; }
1342
+ }
1343
+
1344
+ /** @param {number} pid */
1345
+ function isStillRunning(pid) {
1346
+ try { process.kill(pid, 0); return true; } catch { return false; }
1347
+ }
1348
+
1349
+ /**
1350
+ * One paragraph about what this adapter can do on this machine, for `doctor` and for an agent
1351
+ * reading the tool's own description of itself.
1352
+ *
1353
+ * `allowed` is optional because on a machine that is not a Mac the question is never asked —
1354
+ * there is nothing to ask it of — and a signature that demanded an answer would force callers
1355
+ * to invent one.
1356
+ *
1357
+ * @param {{darwin: boolean, allowed?: boolean, macos?: string}} facts
1358
+ * @returns {string}
1359
+ */
1360
+ export function describeMacos(facts) {
1361
+ if (!facts.darwin) {
1362
+ return 'A native Mac app can only be read from a Mac, and this is not one. There is no remote option here the '
1363
+ + 'way there is for Windows: the Accessibility API only answers inside a signed-in graphical session on the '
1364
+ + 'machine itself. If the Mac product is Electron — most desktop products are — it is already covered over its '
1365
+ + 'debug port from anywhere, and nothing is missing.';
1366
+ }
1367
+ if (!facts.allowed) {
1368
+ return `${facts.macos ?? 'This Mac'} can read a native Mac app's screen, but it has not been told to let Stays `
1369
+ + `Fixed do it. ${HOW_TO_ALLOW} That is the only manual step on this surface; nothing needs installing.`;
1370
+ }
1371
+ return `${facts.macos ?? 'This Mac'} can open a native Mac app in the background and read every control on its `
1372
+ + 'screen through the Accessibility API. Nothing is installed to do it. One build at a time, always: two copies '
1373
+ + 'of the same Mac app make one of them stop answering.';
1374
+ }