simframe 0.18.0 → 0.19.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/src/index.js CHANGED
@@ -1397,22 +1397,76 @@ export async function getFrameAt(deviceQuery, { msAgo = 0, options } = {}) {
1397
1397
  * Wait, briefly, for a frame that is holding still. Returns whatever the newest
1398
1398
  * frame is once the screen settles or the budget runs out, saying which.
1399
1399
  */
1400
- export async function settledState(udid, { settleMs = MEMORY_SETTLE_MS, timeoutMs = 1500 } = {}) {
1400
+ /**
1401
+ * When the stillness a state reports began, or null when that cannot be told.
1402
+ *
1403
+ * `stableForMs` is measured as of the frame's capture, so the quiet period
1404
+ * started `stableForMs` before `capturedAt`. Exported because the rule built on
1405
+ * it below is the interesting part and deserves to be testable without a device.
1406
+ */
1407
+ export function stillnessBegan(state) {
1408
+ if (!Number.isFinite(state?.capturedAt) || !Number.isFinite(state?.stableForMs)) return null;
1409
+ return state.capturedAt - state.stableForMs;
1410
+ }
1411
+
1412
+ /**
1413
+ * Has this screen been still *since we acted*, or was it still before we did?
1414
+ *
1415
+ * The distinction the settle detector was missing. It answers "how long has the
1416
+ * screen been quiet" honestly and has no idea that the quiet it is describing
1417
+ * belongs to the screen the caller has just left.
1418
+ *
1419
+ * Only a stillness we can **prove** predates the action is rejected. When the
1420
+ * timestamps are missing this returns true, which is the previous behaviour —
1421
+ * this may only ever add refusals it can demonstrate, never turn an unknown
1422
+ * into a wait.
1423
+ */
1424
+ export function stillSinceActing(state, actedAt) {
1425
+ if (!Number.isFinite(actedAt)) return true;
1426
+ const began = stillnessBegan(state);
1427
+ if (began == null) return true;
1428
+ return began >= actedAt;
1429
+ }
1430
+
1431
+ export async function settledState(udid, { settleMs = MEMORY_SETTLE_MS, timeoutMs = 1500, since } = {}) {
1401
1432
  const p = store.paths(udid);
1402
1433
  const deadline = Date.now() + timeoutMs;
1434
+ // What we are settling *after*. A caller that knows may say; otherwise it is
1435
+ // the last launch, openUrl or gesture this device was given.
1436
+ const actedAt = since ?? store.lastActionAt(udid);
1403
1437
  let state = store.readJson(p.state);
1438
+ let stale = false;
1404
1439
  while (Date.now() < deadline) {
1405
1440
  state = store.readJson(p.state) ?? state;
1406
- // The daemon runs a real settle detector that can tell a spinner from a
1407
- // still screen. Prefer it; the duration check is the fallback for the
1408
- // simctl engine, which has no such thing.
1409
- if (state?.settled === true) return { state, settled: true };
1410
- if (state && state.settled === undefined && state.stableForMs >= settleMs) {
1411
- return { state, settled: true };
1441
+ // **Stillness from before the action is not settlement.** Measured on the
1442
+ // bench device: 283ms after a Settings launch the state said `settled` with
1443
+ // `stableForMs: 4427` over a screen holding **zero** elements, which filled
1444
+ // 2.3 seconds later; 408ms after `tap General` it said `settled` with
1445
+ // `stableForMs: 7753` over the 23 elements of Settings root. Both readings
1446
+ // were honest about the number and wrong about the screen.
1447
+ //
1448
+ // Field-reported independently as the highest-priority class here: a read
1449
+ // that returns chrome with the content missing and no loading marker is
1450
+ // indistinguishable from a screen that is genuinely empty, so an agent
1451
+ // reports "this filter returns zero results" and means it.
1452
+ const fresh = stillSinceActing(state, actedAt);
1453
+ if (!fresh) stale = true;
1454
+ else {
1455
+ // The daemon runs a real settle detector that can tell a spinner from a
1456
+ // still screen. Prefer it; the duration check is the fallback for the
1457
+ // simctl engine, which has no such thing.
1458
+ if (state?.settled === true) return { state, settled: true, waitedForAction: stale };
1459
+ if (state && state.settled === undefined && state.stableForMs >= settleMs) {
1460
+ return { state, settled: true, waitedForAction: stale };
1461
+ }
1412
1462
  }
1413
1463
  await sleep(40);
1414
1464
  }
1415
- return { state, settled: false };
1465
+ // `settled: false` is not a failure and never was — callers use the state and
1466
+ // decline to persist a map built from it. What is new is that this can now be
1467
+ // false because the screen has not been seen to move since the action, which
1468
+ // is a different and more honest reason than "it is still moving".
1469
+ return { state, settled: false, ...(stale ? { stillnessPredatesAction: true } : {}) };
1416
1470
  }
1417
1471
 
1418
1472
  /**
@@ -1459,6 +1513,33 @@ export function offScreenMatch(targets, query, points) {
1459
1513
  return hit.status === 'ambiguous' && hit.alternatives?.length ? hit.alternatives[0] : null;
1460
1514
  }
1461
1515
 
1516
+ /**
1517
+ * Which way a target is outside the viewport, and by which measure.
1518
+ *
1519
+ * `offViewport` has checked both axes since it was written; the sentence
1520
+ * reporting it only ever named `y` and the screen *height*. So a tab in a
1521
+ * horizontally-scrolling strip was reported as *"it is at y=143 on a 874pt
1522
+ * screen"* — a coordinate plainly inside the screen, next to a conclusion that
1523
+ * it is not in view, with four of its siblings visible. Field-reported, and the
1524
+ * reporter's summary is the right one: the honest half was right and only the
1525
+ * axis was wrong.
1526
+ *
1527
+ * Vertical is checked first because it is overwhelmingly the common case, and
1528
+ * because `scrollTo` reasons vertically — a caller told "below the fold" and a
1529
+ * caller told "past the right edge" do different things next, which is the
1530
+ * whole reason to say which.
1531
+ */
1532
+ export function offScreenAxis(target, points) {
1533
+ const { width, height } = points ?? {};
1534
+ if (Number.isFinite(height) && (target.y < 0 || target.y > height)) {
1535
+ return { axis: 'y', at: Math.round(target.y), extent: Math.round(height), edge: target.y < 0 ? 'above' : 'below' };
1536
+ }
1537
+ if (Number.isFinite(width) && (target.x < 0 || target.x > width)) {
1538
+ return { axis: 'x', at: Math.round(target.x), extent: Math.round(width), edge: target.x < 0 ? 'left of' : 'right of' };
1539
+ }
1540
+ return null;
1541
+ }
1542
+
1462
1543
  /**
1463
1544
  * Which sensors a read asks for by default.
1464
1545
  *
@@ -1500,9 +1581,52 @@ export function sensorMode(options) {
1500
1581
  */
1501
1582
  const nameFor = (t) => t.label || t.identifier || null;
1502
1583
 
1584
+ /**
1585
+ * Did screen memory alone produce this miss?
1586
+ *
1587
+ * A recall that finds the screen and not the target tags `ambiguous_intent`
1588
+ * without `ambiguous` — "I know this screen, and what you asked for is not on
1589
+ * it". That is the only miss worth re-asking, because it is the only one whose
1590
+ * answer came from a file rather than from the device. A miss off a map that
1591
+ * was just built has already read both sensors, and a genuine ambiguity is not
1592
+ * settled by reading again.
1593
+ */
1594
+ export function memoryMiss(err) {
1595
+ const why = metrics.escalationOf(err);
1596
+ return Boolean(why && why.reason === 'ambiguous_intent' && !why.ambiguous);
1597
+ }
1598
+
1503
1599
  export async function locate(deviceQuery, query, opts = {}) {
1504
1600
  if (sensorMode(opts.options) !== 'ax-first' || opts.useOcr === false || opts.escalated) {
1505
- return locateWith(deviceQuery, query, opts);
1601
+ if (opts.refresh || opts.escalated) return locateWith(deviceQuery, query, opts);
1602
+ try {
1603
+ return await locateWith(deviceQuery, query, opts);
1604
+ } catch (err) {
1605
+ // **Memory may confirm, never deny.** Measured on the bench device, 8 of
1606
+ // 8 and 4 of 4 in two separate runs: at the instant a flow's next step
1607
+ // asks, a recall says "not on this screen" in 25 ms and a fresh read
1608
+ // finds the target 1.8 s later, on the same device, without anything
1609
+ // touching it in between.
1610
+ //
1611
+ // Why the recall is wrong, and it is not staleness in the usual sense:
1612
+ // the frame it keys on was captured 47-124 ms *after* the tap — the first
1613
+ // frame of the push animation, which still looks like the screen being
1614
+ // left. Capture is damage-driven, so that frame then goes still,
1615
+ // `settledState` calls it settled at 500-700 ms of stillness, and
1616
+ // `recallNearest` — deliberately tolerant, because a list with new rows is
1617
+ // still the same screen — matches it back to the previous screen and
1618
+ // answers out of that screen's stored element list. The screen itself
1619
+ // arrives about 750 ms later.
1620
+ //
1621
+ // The cost is paid only on a miss, which today aborts the batch and buys
1622
+ // a ~20 s model round trip. 1.8 s to be sure is the cheaper mistake. The
1623
+ // hit path — the one the speed argument rests on — is untouched.
1624
+ //
1625
+ // This recovery already existed for `ax-first` (below) and had never run
1626
+ // in the default sensor mode, which is `full`.
1627
+ if (!memoryMiss(err)) throw err;
1628
+ return locateWith(deviceQuery, query, { ...opts, refresh: true, escalated: true });
1629
+ }
1506
1630
  }
1507
1631
  const options = opts;
1508
1632
  try {
@@ -1723,8 +1847,12 @@ async function locateWith(
1723
1847
  if (offScreen) {
1724
1848
  throw metrics.tag(
1725
1849
  new Error(
1726
- `"${query}" is in the tree but not in view — it is at y=${Math.round(offScreen.y)}`
1727
- + ` on a ${Math.round(points.height)}pt screen. Scroll to it (sim_scroll_to) rather than waiting;`
1850
+ `"${query}" is in the tree but not in view — ${(() => {
1851
+ const off = offScreenAxis(offScreen, points);
1852
+ if (!off) return `it is at ${Math.round(offScreen.x)},${Math.round(offScreen.y)}`;
1853
+ return `it is ${off.edge} the viewport, at ${off.axis}=${off.at}`
1854
+ + ` on a ${off.extent}pt ${off.axis === 'y' ? 'tall' : 'wide'} screen`;
1855
+ })()}. Scroll to it (sim_scroll_to) rather than waiting;`
1728
1856
  + ' waiting cannot bring it into view.',
1729
1857
  ),
1730
1858
  from === 'memory' ? 'ambiguous_intent' : 'unknown_screen',
@@ -0,0 +1,242 @@
1
+ // A dependency-free Chrome DevTools Protocol client.
2
+ //
3
+ // Hand-rolled rather than `ws`, and not because of ideology: CLAUDE.md keeps
4
+ // the runtime dependency list at exactly one (the MCP SDK), and the alternative
5
+ // does not exist anyway. The global `WebSocket` is **absent on Node 18 and
6
+ // flag-only on Node 20** — verified here on v20.20.0, where `typeof WebSocket`
7
+ // is `undefined` — and this package supports 18, 20 and 22. So the choice is
8
+ // between a dependency and forty lines of RFC 6455, and forty lines is smaller
9
+ // than the surface a dependency brings.
10
+ //
11
+ // The other thing a probe on 2026-09-11 established, recorded because it costs
12
+ // an afternoon to rediscover: Chrome refuses the upgrade with **401
13
+ // Unauthorized** when an `Origin` header is present and does not match the
14
+ // inspector's own host. A browser's own WebSocket API always sends one and
15
+ // cannot change it, which is why a page cannot drive CDP — and why a
16
+ // hand-rolled client, which simply omits the header, can.
17
+ //
18
+ // Scope: enough CDP to be the transport under `src/platform/web.js`. It speaks
19
+ // one target at a time, it does not multiplex sessions, and it has no
20
+ // reconnection policy. Anything cleverer belongs above the boundary or nowhere.
21
+ import net from 'node:net';
22
+ import crypto from 'node:crypto';
23
+ import http from 'node:http';
24
+
25
+ /** RFC 6455's fixed GUID, concatenated with the client key to prove the handshake. */
26
+ export const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
27
+
28
+ const OPCODE = { text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa };
29
+
30
+ /** Ask a browser what it has open. `targetId` is the udid: a tab is a device. */
31
+ export function listTargets(port, host = '127.0.0.1', { timeoutMs = 2000 } = {}) {
32
+ return new Promise((resolve, reject) => {
33
+ const req = http.get({ host, port, path: '/json/list', timeout: timeoutMs }, (res) => {
34
+ let body = '';
35
+ res.on('data', (d) => { body += d; });
36
+ res.on('end', () => {
37
+ if (res.statusCode !== 200) {
38
+ reject(new Error(`the browser's debugging endpoint answered ${res.statusCode} — is it running with --remote-debugging-port=${port}?`));
39
+ return;
40
+ }
41
+ try { resolve(JSON.parse(body)); } catch (err) { reject(new Error(`the debugging endpoint returned something that is not JSON: ${err.message}`)); }
42
+ });
43
+ });
44
+ req.on('timeout', () => { req.destroy(new Error(`no answer from ${host}:${port} within ${timeoutMs}ms`)); });
45
+ req.on('error', (err) => reject(new Error(
46
+ `cannot reach a browser on ${host}:${port} — ${err.message}.`
47
+ + ' Start one with --remote-debugging-port, or pass --port.',
48
+ )));
49
+ });
50
+ }
51
+
52
+ /**
53
+ * One frame, encoded.
54
+ *
55
+ * Client frames must be masked — an unmasked client frame is a protocol error
56
+ * and Chrome closes the socket rather than answering, which reads as a hang.
57
+ */
58
+ function encodeFrame(payload, opcode = OPCODE.text) {
59
+ const data = Buffer.from(payload, 'utf8');
60
+ const mask = crypto.randomBytes(4);
61
+ const len = data.length;
62
+ const header = len < 126
63
+ ? Buffer.from([0x80 | opcode, 0x80 | len])
64
+ : len < 65536
65
+ ? Buffer.concat([Buffer.from([0x80 | opcode, 0x80 | 126]), (() => { const b = Buffer.alloc(2); b.writeUInt16BE(len); return b; })()])
66
+ : Buffer.concat([Buffer.from([0x80 | opcode, 0x80 | 127]), (() => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(len)); return b; })()]);
67
+ const masked = Buffer.allocUnsafe(len);
68
+ for (let i = 0; i < len; i += 1) masked[i] = data[i] ^ mask[i % 4];
69
+ return Buffer.concat([header, mask, masked]);
70
+ }
71
+
72
+ /**
73
+ * Pull whole frames out of a growing buffer.
74
+ *
75
+ * Returns the frames it could complete and what is left over. CDP replies
76
+ * routinely exceed one TCP segment — a full accessibility tree is tens of
77
+ * kilobytes — so a reader that assumes one frame per `data` event works on a
78
+ * hello-world and fails on the first real payload.
79
+ */
80
+ export function decodeFrames(buffer) {
81
+ const frames = [];
82
+ let rest = buffer;
83
+ for (;;) {
84
+ if (rest.length < 2) break;
85
+ const opcode = rest[0] & 0x0f;
86
+ const fin = Boolean(rest[0] & 0x80);
87
+ const masked = Boolean(rest[1] & 0x80);
88
+ let len = rest[1] & 0x7f;
89
+ let offset = 2;
90
+ if (len === 126) {
91
+ if (rest.length < 4) break;
92
+ len = rest.readUInt16BE(2);
93
+ offset = 4;
94
+ } else if (len === 127) {
95
+ if (rest.length < 10) break;
96
+ len = Number(rest.readBigUInt64BE(2));
97
+ offset = 10;
98
+ }
99
+ const maskKey = masked ? rest.subarray(offset, offset + 4) : null;
100
+ if (masked) offset += 4;
101
+ if (rest.length < offset + len) break;
102
+ let payload = rest.subarray(offset, offset + len);
103
+ if (maskKey) {
104
+ const out = Buffer.allocUnsafe(len);
105
+ for (let i = 0; i < len; i += 1) out[i] = payload[i] ^ maskKey[i % 4];
106
+ payload = out;
107
+ }
108
+ frames.push({ opcode, fin, payload });
109
+ rest = rest.subarray(offset + len);
110
+ }
111
+ return { frames, rest };
112
+ }
113
+
114
+ /**
115
+ * Open a CDP session against a websocket URL from `listTargets`.
116
+ *
117
+ * Resolves once the upgrade is accepted, so a caller that gets a connection has
118
+ * a usable one — the failure mode this avoids is a `send` that queues silently
119
+ * against a socket the browser refused.
120
+ */
121
+ export function connect(wsUrl, { timeoutMs = 5000 } = {}) {
122
+ const url = new URL(wsUrl);
123
+ return new Promise((resolve, reject) => {
124
+ const key = crypto.randomBytes(16).toString('base64');
125
+ const socket = net.connect({ host: url.hostname, port: Number(url.port || 80) });
126
+ let settled = false;
127
+ const fail = (err) => {
128
+ if (settled) return;
129
+ settled = true;
130
+ socket.destroy();
131
+ reject(err);
132
+ };
133
+ const timer = setTimeout(() => fail(new Error(`the browser did not complete the websocket upgrade within ${timeoutMs}ms`)), timeoutMs);
134
+
135
+ socket.on('error', (err) => fail(new Error(`cannot open a debugging socket: ${err.message}`)));
136
+ socket.on('connect', () => {
137
+ // No `Origin` header, deliberately. Chrome answers 401 when one is
138
+ // present and does not match the inspector's host, and nothing here needs
139
+ // to claim an origin.
140
+ socket.write(
141
+ `GET ${url.pathname}${url.search} HTTP/1.1\r\n`
142
+ + `Host: ${url.host}\r\n`
143
+ + 'Upgrade: websocket\r\nConnection: Upgrade\r\n'
144
+ + `Sec-WebSocket-Key: ${key}\r\n`
145
+ + 'Sec-WebSocket-Version: 13\r\n\r\n',
146
+ );
147
+ });
148
+
149
+ let handshake = Buffer.alloc(0);
150
+ const onHandshake = (chunk) => {
151
+ handshake = Buffer.concat([handshake, chunk]);
152
+ const end = handshake.indexOf('\r\n\r\n');
153
+ if (end === -1) return;
154
+ const head = handshake.subarray(0, end).toString('latin1');
155
+ const status = /^HTTP\/1\.1 (\d+)/.exec(head)?.[1];
156
+ if (status !== '101') {
157
+ fail(new Error(
158
+ `the browser refused the debugging socket with HTTP ${status ?? '(no status)'}.`
159
+ + (status === '401'
160
+ ? ' That is the Origin check: Chrome rejects an upgrade whose Origin header does not'
161
+ + ' match the inspector host. This client sends none, so something else set one.'
162
+ : ' Check the target still exists — a closed tab keeps its id but not its socket.'),
163
+ ));
164
+ return;
165
+ }
166
+ const accept = /sec-websocket-accept:\s*(\S+)/i.exec(head)?.[1];
167
+ const expected = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
168
+ if (accept !== expected) {
169
+ fail(new Error('the browser accepted the upgrade with a key that does not match — this is not a websocket peer'));
170
+ return;
171
+ }
172
+ clearTimeout(timer);
173
+ settled = true;
174
+ socket.removeListener('data', onHandshake);
175
+ resolve(session(socket, handshake.subarray(end + 4)));
176
+ };
177
+ socket.on('data', onHandshake);
178
+ });
179
+ }
180
+
181
+ /** A connected session: one id space, one pending map, text frames only. */
182
+ function session(socket, leftover) {
183
+ let buffer = leftover ?? Buffer.alloc(0);
184
+ let nextId = 1;
185
+ let closedWith = null;
186
+ const pending = new Map();
187
+ const listeners = new Set();
188
+
189
+ const rejectAll = (err) => {
190
+ for (const { reject } of pending.values()) reject(err);
191
+ pending.clear();
192
+ };
193
+
194
+ socket.on('data', (chunk) => {
195
+ buffer = Buffer.concat([buffer, chunk]);
196
+ const { frames, rest } = decodeFrames(buffer);
197
+ buffer = rest;
198
+ for (const frame of frames) {
199
+ if (frame.opcode === OPCODE.ping) { socket.write(encodeFrame(frame.payload.toString('utf8'), OPCODE.pong)); continue; }
200
+ if (frame.opcode === OPCODE.close) { closedWith = 'the browser closed the debugging socket'; socket.end(); continue; }
201
+ if (frame.opcode !== OPCODE.text) continue;
202
+ let message;
203
+ try { message = JSON.parse(frame.payload.toString('utf8')); } catch { continue; }
204
+ if (message.id != null && pending.has(message.id)) {
205
+ const { resolve, reject } = pending.get(message.id);
206
+ pending.delete(message.id);
207
+ // A CDP error is a reply, not a transport failure, and it carries the
208
+ // only sentence that says what was wrong with the call.
209
+ if (message.error) reject(new Error(`${message.error.message ?? 'CDP error'}${message.error.data ? ` — ${message.error.data}` : ''}`));
210
+ else resolve(message.result ?? {});
211
+ } else if (message.method) {
212
+ for (const fn of listeners) { try { fn(message); } catch { /* a listener must not break the socket */ } }
213
+ }
214
+ }
215
+ });
216
+
217
+ socket.on('close', () => { rejectAll(new Error(closedWith ?? 'the debugging socket closed')); });
218
+ socket.on('error', (err) => { rejectAll(new Error(`debugging socket error: ${err.message}`)); });
219
+
220
+ return {
221
+ /** One CDP call. Rejects with the browser's own message on a protocol error. */
222
+ send(method, params = {}, { timeoutMs = 10000 } = {}) {
223
+ if (socket.destroyed) return Promise.reject(new Error(closedWith ?? 'the debugging socket is closed'));
224
+ const id = nextId++;
225
+ return new Promise((resolve, reject) => {
226
+ const timer = setTimeout(() => {
227
+ pending.delete(id);
228
+ reject(new Error(`${method} did not answer within ${timeoutMs}ms`));
229
+ }, timeoutMs);
230
+ pending.set(id, {
231
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
232
+ reject: (e) => { clearTimeout(timer); reject(e); },
233
+ });
234
+ socket.write(encodeFrame(JSON.stringify({ id, method, params })));
235
+ });
236
+ },
237
+ /** Subscribe to CDP events. Returns an unsubscribe. */
238
+ on(fn) { listeners.add(fn); return () => listeners.delete(fn); },
239
+ close() { try { socket.end(encodeFrame('', OPCODE.close)); } catch { socket.destroy(); } },
240
+ get closed() { return socket.destroyed; },
241
+ };
242
+ }
@@ -20,6 +20,7 @@
20
20
  // function that takes a udid routes on that udid — `ownsUdid` answers that from
21
21
  // the id's own shape, because the question is asked from inside a capture loop
22
22
  // in a process that never listed anything.
23
+ import * as store from '../store.js';
23
24
  import { platform as android } from './android.js';
24
25
  import { platform as ios } from './ios.js';
25
26
 
@@ -204,9 +205,34 @@ export async function resolveAcross(query, opts, all) {
204
205
  // backend a call reaches, never what the call means.
205
206
  export const isBootedSync = (udid, ...args) => platformFor(udid).isBootedSync(udid, ...args);
206
207
  export const screenshot = (udid, ...args) => platformFor(udid).screenshot(udid, ...args);
207
- export const launchApp = (udid, ...args) => platformFor(udid).launchApp(udid, ...args);
208
+ /**
209
+ * Launch and openUrl stamp the action clock; nothing else here does.
210
+ *
211
+ * These two change the screen without touching the digitizer, so they are the
212
+ * only actions a gesture log cannot see — and a settle that cannot see them
213
+ * measures how long the screen being *left* has been sitting still. Measured:
214
+ * 283ms after a Settings launch, `settled: true` with `stableForMs: 4427` over
215
+ * a screen holding zero elements.
216
+ *
217
+ * **At the seam rather than in the `launch` step, and that placement is the
218
+ * point.** It was in the step first, which covered flows and missed everything
219
+ * else that launches an app — `baseline.resetFor`, `wedge.revive`, the bench,
220
+ * and the probe that found this. A rule enforced where you noticed it covers a
221
+ * symptom; this project's handoff has three worked examples of exactly that and
222
+ * this was very nearly a fourth.
223
+ *
224
+ * `terminateApp` is deliberately not stamped: it removes an app rather than
225
+ * presenting one, and the screen it leaves behind is whatever was underneath.
226
+ */
227
+ export const launchApp = (udid, ...args) => {
228
+ store.noteAction(udid);
229
+ return platformFor(udid).launchApp(udid, ...args);
230
+ };
208
231
  export const terminateApp = (udid, ...args) => platformFor(udid).terminateApp(udid, ...args);
209
- export const openUrl = (udid, ...args) => platformFor(udid).openUrl(udid, ...args);
232
+ export const openUrl = (udid, ...args) => {
233
+ store.noteAction(udid);
234
+ return platformFor(udid).openUrl(udid, ...args);
235
+ };
210
236
  export const setPermission = (udid, ...args) => platformFor(udid).setPermission(udid, ...args);
211
237
  export const setPasteboard = (udid, ...args) => platformFor(udid).setPasteboard(udid, ...args);
212
238
 
package/src/store.js CHANGED
@@ -35,6 +35,7 @@ export function paths(udid) {
35
35
  // recorded, and a stall is the absence of frames.
36
36
  captureHealth: path.join(dir, 'capture-health.json'),
37
37
  lastInput: path.join(dir, 'last-input'),
38
+ lastAction: path.join(dir, 'last-action'),
38
39
  };
39
40
  }
40
41
 
@@ -58,6 +59,44 @@ export function noteInput(udid, at = Date.now()) {
58
59
  } catch {
59
60
  /* a timestamp nothing depends on for correctness must not fail an action */
60
61
  }
62
+ noteAction(udid, at);
63
+ }
64
+
65
+ /**
66
+ * When we last did something that ought to change the screen.
67
+ *
68
+ * Deliberately separate from the input log above, which answers a different
69
+ * question — "have several *gestures* landed with no pixel moving" — and would
70
+ * be wrong to answer it about a launch, since a launch that paints nothing is
71
+ * not evidence of a dead digitizer.
72
+ *
73
+ * This one exists so a settle can tell its own stillness from the *previous*
74
+ * screen's. Measured on 2026-09-18: 283ms after a Settings launch the state
75
+ * read `settled: true` with `stableForMs: 4427` and **zero elements** — 4.4
76
+ * seconds of quiet that began before the launch was issued. 408ms after a
77
+ * `tap General`, `stableForMs: 7753` and the 23 elements of the screen being
78
+ * left. In both, the settle detector was honestly reporting how long the screen
79
+ * we had already abandoned had been sitting still.
80
+ *
81
+ * Every gesture writes it (via `noteInput`), and so does every launch and
82
+ * `openUrl`, because those change the screen without touching the digitizer.
83
+ */
84
+ export function noteAction(udid, at = Date.now()) {
85
+ try {
86
+ writeAtomic(paths(udid).lastAction, String(at));
87
+ } catch {
88
+ /* as above: a timestamp may not fail the action it describes */
89
+ }
90
+ }
91
+
92
+ /** When the last screen-changing action was issued, or null if none is recorded. */
93
+ export function lastActionAt(udid) {
94
+ try {
95
+ const n = Number(fs.readFileSync(paths(udid).lastAction, 'utf8').trim());
96
+ return Number.isFinite(n) ? n : null;
97
+ } catch {
98
+ return null;
99
+ }
61
100
  }
62
101
 
63
102
  /**