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.
- package/CHANGELOG.md +102 -0
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/how-v2-works.md +33 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- package/package.json +1 -1
- package/src/guard/api.js +115 -36
- package/src/v2/adapters/android.js +220 -11
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- package/src/v2/browsers.js +41 -2
- package/src/v2/check.js +133 -13
- package/src/v2/cli.js +2 -0
- package/src/v2/coverage.js +1 -1
- package/src/v2/detect.js +5 -2
- package/src/v2/doctor.js +164 -20
- package/src/v2/init.js +21 -3
- package/src/v2/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +8 -15
- package/src/v2/types.js +1 -1
- package/src/v2/watch/events.js +6 -0
|
@@ -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
|
+
}
|