simframe 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ // Property lists, parsed without a dependency.
2
+ //
3
+ // Below the platform boundary on purpose. A plist is not a neutral file format
4
+ // this project happens to read — it is how one platform stores what an app
5
+ // believes, and `plutil` is that platform's tool. Android's answer to the same
6
+ // question is a different file in a different shape, which is why the parsing
7
+ // lives beside the backend that needs it rather than above the seam.
8
+ //
9
+ // **Why XML and not JSON.** `plutil -convert json` is the obvious route and it
10
+ // does not work: measured across the twenty real `Library/Preferences` plists
11
+ // on the bench device, **six of them failed to convert** — 30%, because JSON
12
+ // has no representation for `<data>` or `<date>` and plutil refuses rather than
13
+ // inventing one. `-convert xml1` succeeded on all twenty. A format that drops
14
+ // three in ten real files is not a parser, it is a sampler.
15
+ //
16
+ // The XML here is machine-written by plutil, so this is a reader for that
17
+ // output and not a general XML parser: no namespaces, no processing
18
+ // instructions beyond the declaration, no mixed content. It is strict about
19
+ // what it does not understand — an unknown tag throws rather than being skipped,
20
+ // because a silently dropped key in a store read is a wrong answer about what
21
+ // an app believes, and this whole feature exists to be trusted on that point.
22
+
23
+ const ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
24
+
25
+ /** Decode the five XML entities plutil emits, plus numeric escapes. */
26
+ export function decodeEntities(s) {
27
+ return String(s).replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (whole, body) => {
28
+ if (body[0] === '#') {
29
+ const code = body[1] === 'x' || body[1] === 'X'
30
+ ? parseInt(body.slice(2), 16)
31
+ : parseInt(body.slice(1), 10);
32
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
33
+ }
34
+ return ENTITIES[body] ?? whole;
35
+ });
36
+ }
37
+
38
+ /**
39
+ * A `<data>` value.
40
+ *
41
+ * Kept as a tagged object rather than decoded to a Buffer or dropped. The
42
+ * caller is usually a human asking what an app persisted, and "a 4 KB blob"
43
+ * is a real and often sufficient answer — while silently omitting the key
44
+ * would misreport the store as not having it.
45
+ */
46
+ const dataValue = (base64) => {
47
+ const clean = base64.replace(/\s+/g, '');
48
+ return {
49
+ __type: 'data',
50
+ bytes: Math.floor((clean.length * 3) / 4) - (clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0),
51
+ base64: clean,
52
+ };
53
+ };
54
+
55
+ /**
56
+ * Parse the XML property list plutil writes.
57
+ *
58
+ * @param {string} xml output of `plutil -convert xml1 -o -`
59
+ * @returns {*} the plist's root value — normally an object
60
+ */
61
+ export function parse(xml) {
62
+ const src = String(xml);
63
+ // Everything before <plist> is the declaration and the DOCTYPE, neither of
64
+ // which carries data.
65
+ const start = src.indexOf('<plist');
66
+ if (start < 0) throw new Error('not an XML property list (no <plist> element)');
67
+ let i = src.indexOf('>', start);
68
+ if (i < 0) throw new Error('not an XML property list (unterminated <plist>)');
69
+ i += 1;
70
+
71
+ /** The next tag at or after `i`, skipping text that is only whitespace. */
72
+ const nextTag = () => {
73
+ const open = src.indexOf('<', i);
74
+ if (open < 0) return null;
75
+ const close = src.indexOf('>', open);
76
+ if (close < 0) throw new Error('unterminated tag in property list');
77
+ const raw = src.slice(open + 1, close);
78
+ i = close + 1;
79
+ const selfClosing = raw.endsWith('/');
80
+ const name = raw.replace(/\/$/, '').trim().split(/\s/)[0];
81
+ return { name: name.replace(/^\//, ''), closing: raw.startsWith('/'), selfClosing };
82
+ };
83
+
84
+ /** Text up to the matching close tag, which plutil never nests inside a leaf. */
85
+ const textUntilClose = (tag) => {
86
+ const close = src.indexOf(`</${tag}>`, i);
87
+ if (close < 0) throw new Error(`unterminated <${tag}> in property list`);
88
+ const text = src.slice(i, close);
89
+ i = close + tag.length + 3;
90
+ return text;
91
+ };
92
+
93
+ const readValue = (tag) => {
94
+ switch (tag.name) {
95
+ case 'true': return true;
96
+ case 'false': return false;
97
+ case 'string': return tag.selfClosing ? '' : decodeEntities(textUntilClose('string'));
98
+ case 'integer': {
99
+ const text = textUntilClose('integer').trim();
100
+ const n = Number(text);
101
+ // A plist integer is 64-bit and JavaScript's is not. Returning a
102
+ // silently-rounded number would be a wrong answer about a stored value,
103
+ // so the exact digits survive as a string and the shape says why.
104
+ if (!Number.isSafeInteger(n)) return { __type: 'integer', exact: text };
105
+ return n;
106
+ }
107
+ case 'real': return Number(textUntilClose('real').trim());
108
+ case 'date': return { __type: 'date', iso: textUntilClose('date').trim() };
109
+ case 'data': return dataValue(textUntilClose('data'));
110
+ case 'dict': {
111
+ if (tag.selfClosing) return {};
112
+ const out = {};
113
+ for (;;) {
114
+ const t = nextTag();
115
+ if (!t) throw new Error('unterminated <dict> in property list');
116
+ if (t.closing && t.name === 'dict') return out;
117
+ if (t.name !== 'key') throw new Error(`expected <key> in <dict>, found <${t.name}>`);
118
+ const key = t.selfClosing ? '' : decodeEntities(textUntilClose('key'));
119
+ const vt = nextTag();
120
+ if (!vt) throw new Error(`<key>${key}</key> has no value`);
121
+ out[key] = readValue(vt);
122
+ }
123
+ }
124
+ case 'array': {
125
+ if (tag.selfClosing) return [];
126
+ const out = [];
127
+ for (;;) {
128
+ const t = nextTag();
129
+ if (!t) throw new Error('unterminated <array> in property list');
130
+ if (t.closing && t.name === 'array') return out;
131
+ out.push(readValue(t));
132
+ }
133
+ }
134
+ default:
135
+ // Deliberately not a skip. See the note at the top of this file.
136
+ throw new Error(`unsupported property-list element <${tag.name}>`);
137
+ }
138
+ };
139
+
140
+ const root = nextTag();
141
+ if (!root || root.closing) return null;
142
+ return readValue(root);
143
+ }
144
+
145
+ /**
146
+ * What kind of thing a parsed value is, in one word, for a listing.
147
+ *
148
+ * `typeof` is not enough: the tagged shapes above are objects, and calling a
149
+ * date "object" in a store listing tells the reader nothing they wanted.
150
+ */
151
+ export function typeOf(value) {
152
+ if (value === null) return 'null';
153
+ if (Array.isArray(value)) return 'array';
154
+ if (typeof value === 'object') return value.__type ?? 'dict';
155
+ return typeof value;
156
+ }
package/src/refs.js CHANGED
@@ -149,7 +149,18 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, s
149
149
  // numbers. Refusing costs a re-read; guessing taps whatever is at those
150
150
  // coordinates now.
151
151
  if (screenKnown === false) {
152
- throw new Error(`#${n} cannot be trusted here — simframe does not recognise this screen. Read it again (sim_ui) to renumber.`);
152
+ // Flagged, like every other refusal in this function, and it was the one
153
+ // that was not.
154
+ //
155
+ // We tell callers to read `staleRef` rather than the sentence — the CI check
156
+ // for this very guard carries a comment saying it matched on prose twice and
157
+ // went red twice, so it reads the contract now. Then it went red a third
158
+ // time, on a refusal that was correct, well worded, and carried no field at
159
+ // all: `#1 cannot be trusted here — simframe does not recognise this
160
+ // screen`, reported by the harness as `no reason`. A refusal a human can
161
+ // read and a program cannot is the same defect as a failure that reads like
162
+ // a success, one level down.
163
+ throw staleError('simframe does not recognise this screen', 'unknown-screen');
153
164
  }
154
165
  // The pixel check stays, but only as a backstop, and only where it means
155
166
  // something. A dark or near-uniform screen produces a layout hash of almost
package/src/regions.js CHANGED
@@ -470,6 +470,60 @@ export function offViewport(t, screen) {
470
470
  return false;
471
471
  }
472
472
 
473
+ /**
474
+ * The smallest sliver of an element that is worth offering as a tap target.
475
+ *
476
+ * Apple's own minimum touch target is 44pt; this is deliberately smaller,
477
+ * because the question here is not "is this comfortable to tap" but "is this a
478
+ * real control a person can see and reach". A filter chip showing 29pt of
479
+ * itself at the edge of a horizontal strip is both. Below this, what is on
480
+ * screen is bleed rather than a control.
481
+ */
482
+ export const MIN_VISIBLE_PT = 24;
483
+
484
+ /**
485
+ * How much of an element is actually on screen, and where to tap what is.
486
+ *
487
+ * `offViewport` answers a yes/no question about the element's *centre*, which
488
+ * is right for "should I scroll to reach this" and wrong for "is this here at
489
+ * all". A chip at the end of a horizontal strip showed **29pt of itself** on a
490
+ * 402pt screen — real, visible, tappable — while its centre sat at x=416, so it
491
+ * was dropped from the map entirely. What the caller got in its place was OCR's
492
+ * reading of the visible sliver: a `text` element labelled **"Flc"** at x=392,
493
+ * which passes every filter because its own box is inside the viewport.
494
+ *
495
+ * So the map did not merely omit a control. It offered a different, meaningless
496
+ * name for it, at a coordinate that looks perfectly ordinary. That is the shape
497
+ * of item 121 — a caller who cannot trust what the map says about the edge of
498
+ * the screen — and the item's own words are "say so or clamp". This does both.
499
+ */
500
+ export function clipping(t, screen) {
501
+ const f = t?.frame;
502
+ const w = screen?.width;
503
+ const h = screen?.height;
504
+ if (!f || !Number.isFinite(w) || !Number.isFinite(h)) return null;
505
+ const left = Math.max(0, f.x);
506
+ const right = Math.min(w, f.x + f.width);
507
+ const top = Math.max(0, f.y);
508
+ const bottom = Math.min(h, f.y + f.height);
509
+ const visibleWidth = right - left;
510
+ const visibleHeight = bottom - top;
511
+ if (visibleWidth <= 0 || visibleHeight <= 0) {
512
+ return { visibleWidth: 0, visibleHeight: 0, clipped: true, usable: false, point: null };
513
+ }
514
+ const clipped = f.x < 0 || f.y < 0 || f.x + f.width > w || f.y + f.height > h;
515
+ return {
516
+ visibleWidth,
517
+ visibleHeight,
518
+ clipped,
519
+ usable: visibleWidth >= MIN_VISIBLE_PT && visibleHeight >= MIN_VISIBLE_PT,
520
+ // The centre of what can be seen, not the centre of the element. Tapping
521
+ // the latter would aim off the screen, which is the clamp the item asked
522
+ // for and the reason a caller could not simply be handed the real centre.
523
+ point: { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) },
524
+ };
525
+ }
526
+
473
527
  /** Annotate a target list with region and nav slot. Mutates and returns it. */
474
528
  export function annotate(targets, screen) {
475
529
  const band = bands(targets, screen);
package/src/screenmap.js CHANGED
@@ -296,15 +296,64 @@ export async function build(udid, {
296
296
  try {
297
297
  // The tree is already in hand when the daemon answered; describeAll would
298
298
  // only ask for it a second time.
299
- const nodes = daemonScreen?.sources?.includes('ax')
300
- ? daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode)
301
- : await input.describeAll(udid);
299
+ //
300
+ // And when the daemon answered *without* a tree, asking again is asking
301
+ // the thing that just failed. That path cost a red CI run and a wrong
302
+ // diagnosis: the daemon's ax read timed out, `describeAll` re-asked it
303
+ // (another 20s), got the same nothing, fell through to idb, and the map
304
+ // reported **"idb is not installed, so simframe can observe the screen but
305
+ // cannot touch it"**. A sentence about a tool this project deliberately
306
+ // does not use on CI, printed because a different tool had a bad read —
307
+ // and it points whoever reads it at installing idb, which would change
308
+ // nothing. The same read worked a minute later.
309
+ //
310
+ // The daemon has sent `axError` since it learned to time its own reads,
311
+ // and nothing here had ever read it. The OCR branch below reads
312
+ // `ocrError`, which is what makes the asymmetry visible: one sensor could
313
+ // say why it failed and the other borrowed a different tool's excuse.
314
+ let nodes;
315
+ if (daemonScreen?.sources?.includes('ax')) {
316
+ nodes = daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode);
317
+ } else if (daemonScreen) {
318
+ throw new Error(daemonScreen.axError ?? 'the daemon read the screen and the accessibility tree did not answer');
319
+ } else {
320
+ nodes = await input.describeAll(udid);
321
+ }
302
322
 
303
323
  sources.push('ax');
324
+ // A control the tree gives no name to is still a control — item 122.
325
+ //
326
+ // This line used to read `!n.label`, on the reasoning that a row nobody
327
+ // can name is a row nobody can tap. The whole of item 122 is that the
328
+ // opposite is true, and three field reports in a row said so
329
+ // independently: icon-only overflow menus on every card, a bottom-sheet
330
+ // drag handle, a back chevron. Real, tappable, on screen, and absent from
331
+ // the map — so every one of them needed a raw `@x,y` read off a
332
+ // screenshot, which is the exact round trip the text map exists to
333
+ // remove. Knowing that *a hit target is there* is most of the value even
334
+ // with no semantics attached to it.
335
+ //
336
+ // Measured on the testbed before it was written, because the premise
337
+ // could have been false: on the list screen, of 39 accessibility nodes
338
+ // exactly **one** is nameless — and it is the one control on that screen
339
+ // no caller could reach. This does not flood the map.
340
+ //
341
+ // It also settles a thing the reports could not: those controls were
342
+ // called "absent from the tree", and AXPTranslator had them all along.
343
+ // What is genuinely absent is the drag handle, a plain view holding a
344
+ // responder that UIKit is never told is accessible. No filter here can
345
+ // recover that one; see 122's second half.
304
346
  for (const n of nodes) {
305
- if (!n.frame || !n.label || isContainer(n)) continue;
347
+ if (!n.frame || isContainer(n)) continue;
348
+ if (nameless(n) && !namelessHitTarget(n, nodes)) continue;
306
349
  targets.push({
307
- label: n.label,
350
+ label: n.label ?? undefined,
351
+ // A `testID` arrives as AXIdentifier, and `tap` has matched on it
352
+ // since long before this — but only down the tree path, because the
353
+ // map never carried it. So the one name an unlabelled React Native
354
+ // control usually does have was invisible in the map and worked if
355
+ // you guessed it.
356
+ identifier: n.identifier ?? undefined,
308
357
  // What the control *contains*, whether it is on, and whether it has
309
358
  // focus. All three come off the accessibility tree, the daemon has
310
359
  // asked for all three since 0.6.0, and all three were dropped before
@@ -506,6 +555,36 @@ export function isInteractive(target) {
506
555
  return INTERACTIVE.test(target.type || '');
507
556
  }
508
557
 
558
+ /** No name from any source: not a label, not an identifier. */
559
+ export const nameless = (n) => !n?.label && !n?.identifier;
560
+
561
+ /**
562
+ * Whether a nameless accessibility node is worth printing as a hit target.
563
+ *
564
+ * Two guards, because "has no name" is not the same as "is a control".
565
+ *
566
+ * The role has to be one the tree calls interactive. A nameless `Other` is a
567
+ * layout view and a real screen has hundreds of them; emitting those would bury
568
+ * the one node that matters under the scenery it sits in.
569
+ *
570
+ * And it must not enclose two or more other elements. That is the same test the
571
+ * fingerprint uses for scenery, reused deliberately rather than invented here:
572
+ * a table cell holding its own labels is not the target, its labels are.
573
+ */
574
+ export function namelessHitTarget(node, nodes) {
575
+ const f = node?.frame;
576
+ if (!f || !(f.width > 0) || !(f.height > 0)) return false;
577
+ if (!INTERACTIVE.test(node.type || '')) return false;
578
+ const encloses = nodes.filter((o) => {
579
+ const g = o.frame;
580
+ if (!g || o === node) return false;
581
+ const cx = g.x + (g.width ?? 0) / 2;
582
+ const cy = g.y + (g.height ?? 0) / 2;
583
+ return cx > f.x && cx < f.x + f.width && cy > f.y && cy < f.y + f.height;
584
+ }).length;
585
+ return encloses < 2;
586
+ }
587
+
509
588
  /**
510
589
  * Rank candidates for a label. Exact beats substring, and a real control beats
511
590
  * a caption that happens to read the same — a screen title and a tab are often
@@ -514,7 +593,7 @@ export function isInteractive(target) {
514
593
  export function rank(entry, query) {
515
594
  if (!entry) return [];
516
595
  const q = norm(query);
517
- const names = (t) => [t.label, ...(t.aliases || [])].map(norm);
596
+ const names = (t) => [t.label, t.identifier, ...(t.aliases || [])].map(norm);
518
597
  const exact = entry.targets.filter((t) => names(t).includes(q));
519
598
  const pool = exact.length
520
599
  ? exact
package/src/storage.js ADDED
@@ -0,0 +1,201 @@
1
+ // What the app believes.
2
+ //
3
+ // The perception tools answer "what is drawn". This answers "what did the app
4
+ // save", and the field report that asked for it put the pairing better than we
5
+ // did: *"`sim_ui` says what is drawn, `sim_storage` says what the app
6
+ // believes."* Their highest-leverage moment in a whole session was not a
7
+ // simframe call at all — they read the persisted store straight out of the data
8
+ // container, found the exact wrong value the app had written, and proved the bug
9
+ // with no live session, no login, and the device not yet booted.
10
+ //
11
+ // That last property is the design constraint, not a bonus. Measured on this
12
+ // Xcode, `simctl get_app_container` and `simctl listapps` both refuse on a
13
+ // device that is not running. So nothing here goes through the device: the
14
+ // backend reads the container off the host filesystem, and a shut-down device
15
+ // answers exactly as well as a running one.
16
+ //
17
+ // Nothing in this file knows which platform it is on. Where a container lives
18
+ // and how a property list is decoded are the backend's business; what a store
19
+ // is, and how to say what is in one, are this file's.
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import crypto from 'node:crypto';
23
+ import * as platform from './platform/index.js';
24
+
25
+ /**
26
+ * How much of a single value is printed before the rest is summarised.
27
+ *
28
+ * Generous on purpose — the whole point is to see what the app actually wrote,
29
+ * and a value clipped to eighty characters answers nothing. What this must
30
+ * never do is clip *silently*: item 141 in DEFERRED is a harness that cut a
31
+ * failure report one character before the only content that mattered, and the
32
+ * lesson was that a reader who is not told about a cut reads the fragment as
33
+ * the whole. So past this, the text says how many bytes it is not showing and
34
+ * how to get them.
35
+ */
36
+ export const VALUE_PREVIEW_BYTES = 4096;
37
+
38
+ /** React Native's own store, in the layout its iOS implementation writes. */
39
+ const ASYNC_STORAGE_DIR = 'RCTAsyncLocalStorage_V1';
40
+ const ASYNC_STORAGE_MANIFEST = 'manifest.json';
41
+
42
+ /** Files that are plainly a store but that nothing here can decode yet. */
43
+ const OPAQUE_STORES = /\.(sqlite3?|db|realm|leveldb|mmkv)$/i;
44
+
45
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
46
+ const sizeOf = (file) => { try { return fs.statSync(file).size; } catch { return null; } };
47
+
48
+ /** Every app with a data container on the device, whether or not it is running. */
49
+ export async function apps(udid) {
50
+ return platform.listApps(udid);
51
+ }
52
+
53
+ /**
54
+ * React Native's AsyncStorage.
55
+ *
56
+ * The manifest holds small values inline. A value past RN's inline threshold is
57
+ * stored as `null` in the manifest and written to a file beside it named by the
58
+ * MD5 of the key — so a manifest full of nulls is not an empty store, and
59
+ * reporting it as one would be the exact class of wrong answer this feature
60
+ * exists to stop.
61
+ */
62
+ export function readAsyncStorage(container) {
63
+ const dir = path.join(container, 'Documents', ASYNC_STORAGE_DIR);
64
+ const manifestPath = path.join(dir, ASYNC_STORAGE_MANIFEST);
65
+ if (!fs.existsSync(manifestPath)) return null;
66
+ const manifest = readJson(manifestPath);
67
+ const entries = [];
68
+ for (const [key, inline] of Object.entries(manifest)) {
69
+ if (inline !== null && inline !== undefined) {
70
+ entries.push({ key, value: inline, type: typeof inline, where: 'manifest' });
71
+ continue;
72
+ }
73
+ const spilled = path.join(dir, crypto.createHash('md5').update(key).digest('hex'));
74
+ if (fs.existsSync(spilled)) {
75
+ const value = fs.readFileSync(spilled, 'utf8');
76
+ entries.push({ key, value, type: 'string', bytes: sizeOf(spilled), where: 'spilled to its own file' });
77
+ } else {
78
+ // Say which of the two this is. "Null" and "too big to inline, and the
79
+ // file is missing" are different facts about the app.
80
+ entries.push({ key, value: null, type: 'null', where: 'manifest says null and no spill file exists' });
81
+ }
82
+ }
83
+ return { name: 'AsyncStorage', source: dir, entries };
84
+ }
85
+
86
+ /**
87
+ * Preference plists in the container.
88
+ *
89
+ * The one named after the bundle id is the app's own `UserDefaults`; the others
90
+ * are real and are named rather than hidden, because a framework writing its
91
+ * state beside the app's is often exactly what the reader is hunting.
92
+ */
93
+ async function readPreferences(udid, container, bundleId) {
94
+ const dir = path.join(container, 'Library', 'Preferences');
95
+ let files;
96
+ try {
97
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.plist'));
98
+ } catch {
99
+ return [];
100
+ }
101
+ const stores = [];
102
+ for (const file of files.sort()) {
103
+ const full = path.join(dir, file);
104
+ const own = file === `${bundleId}.plist`;
105
+ try {
106
+ const parsed = await platform.readPropertyList(udid, full);
107
+ stores.push({
108
+ name: own ? 'UserDefaults' : `UserDefaults (${file.replace(/\.plist$/, '')})`,
109
+ source: full,
110
+ entries: Object.entries(parsed ?? {}).map(([key, value]) => ({ key, value, type: typeOf(value) })),
111
+ });
112
+ } catch (err) {
113
+ // Degrade rather than fail: one unreadable plist must not cost the
114
+ // reader every other store in the container.
115
+ stores.push({ name: own ? 'UserDefaults' : file, source: full, entries: [], error: err.message });
116
+ }
117
+ }
118
+ // The app's own defaults first; that is what was asked about.
119
+ return stores.sort((a, b) => Number(b.name === 'UserDefaults') - Number(a.name === 'UserDefaults'));
120
+ }
121
+
122
+ /** Stores that are plainly present and that nothing here can decode. */
123
+ export function opaqueStores(container) {
124
+ const found = [];
125
+ const walk = (dir, depth) => {
126
+ if (depth > 3) return;
127
+ let entries;
128
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
129
+ for (const e of entries) {
130
+ const full = path.join(dir, e.name);
131
+ if (e.isDirectory()) walk(full, depth + 1);
132
+ else if (OPAQUE_STORES.test(e.name)) found.push({ file: path.relative(container, full), bytes: sizeOf(full) });
133
+ }
134
+ };
135
+ walk(container, 0);
136
+ return found;
137
+ }
138
+
139
+ /** One word for what a value is, including the tagged shapes a plist produces. */
140
+ export function typeOf(value) {
141
+ if (value === null || value === undefined) return 'null';
142
+ if (Array.isArray(value)) return 'array';
143
+ if (typeof value === 'object') return value.__type ?? 'dict';
144
+ return typeof value;
145
+ }
146
+
147
+ /**
148
+ * Everything one app has persisted that this can read, and an honest list of
149
+ * what it could not.
150
+ */
151
+ export async function read(udid, bundleId) {
152
+ const container = await platform.appContainer(udid, bundleId);
153
+ const stores = await readPreferences(udid, container, bundleId);
154
+ const async_ = readAsyncStorage(container);
155
+ if (async_) stores.push(async_);
156
+ return { bundleId, container, stores, opaque: opaqueStores(container) };
157
+ }
158
+
159
+ /** One value, rendered for reading, saying so whenever it is not the whole thing. */
160
+ export function renderValue(value) {
161
+ if (value && typeof value === 'object' && value.__type === 'data') {
162
+ return `<${value.bytes} bytes of data> ${value.base64.slice(0, 64)}${value.base64.length > 64 ? '…' : ''}`;
163
+ }
164
+ if (value && typeof value === 'object' && value.__type === 'date') return value.iso;
165
+ if (value && typeof value === 'object' && value.__type === 'integer') return value.exact;
166
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
167
+ if (text == null) return String(value);
168
+ if (text.length <= VALUE_PREVIEW_BYTES) return text;
169
+ // Announced, never silent. See VALUE_PREVIEW_BYTES.
170
+ return `${text.slice(0, VALUE_PREVIEW_BYTES)}\n … ${text.length - VALUE_PREVIEW_BYTES} more character(s) not shown`
171
+ + ' — read the file named under `from:` for the whole value';
172
+ }
173
+
174
+ /** The text form: what the app believes, one store at a time. */
175
+ export function format(result) {
176
+ const lines = [`${result.bundleId}`, ` container ${result.container}`];
177
+ if (!result.stores.length) lines.push(' no readable store — the app has persisted nothing this can decode');
178
+ for (const store of result.stores) {
179
+ lines.push('');
180
+ lines.push(` ${store.name} — ${store.entries.length} key(s)`);
181
+ lines.push(` from: ${store.source}`);
182
+ if (store.error) lines.push(` unreadable: ${store.error}`);
183
+ for (const e of store.entries) {
184
+ const where = e.where && e.where !== 'manifest' ? ` (${e.where})` : '';
185
+ lines.push(` ${e.key} [${e.type}]${where}`);
186
+ lines.push(` ${renderValue(e.value).split('\n').join('\n ')}`);
187
+ }
188
+ }
189
+ if (result.opaque?.length) {
190
+ lines.push('');
191
+ lines.push(` ${result.opaque.length} store(s) present that this cannot decode yet:`);
192
+ for (const o of result.opaque) lines.push(` ${o.file} ${o.bytes} bytes`);
193
+ }
194
+ return lines.join('\n');
195
+ }
196
+
197
+ /** The listing form. */
198
+ export function formatApps(list) {
199
+ if (!list.length) return 'no app has a data container on this device';
200
+ return [`${list.length} app(s) with a data container`, ...list.map((a) => ` ${a.bundleId}`)].join('\n');
201
+ }
package/src/store.js CHANGED
@@ -34,10 +34,63 @@ export function paths(udid) {
34
34
  // wedged. It cannot ride in state.json: that is written when a frame is
35
35
  // recorded, and a stall is the absence of frames.
36
36
  captureHealth: path.join(dir, 'capture-health.json'),
37
+ lastInput: path.join(dir, 'last-input'),
37
38
  };
38
39
  }
39
40
 
40
41
  /** What the capture loop last said about its own health, or null if it has no complaint. */
42
+ /**
43
+ * When input was last delivered to this device.
44
+ *
45
+ * Written by every input path and read by `liveness`, which needs it to catch
46
+ * the one wedge shape nothing else can see: a capture loop that is alive,
47
+ * incrementing its frame counter, and re-reading a **dead surface**. Field
48
+ * report, 0.12.2: `age=268ms` next to `stable=159753ms` while three screen
49
+ * transitions had just happened, and no warning fired because every signal we
50
+ * had was green. A screen that has not moved since before we last touched it,
51
+ * over and over, is a contradiction the daemon can notice locally.
52
+ */
53
+ const INPUT_MEMORY = 8;
54
+
55
+ export function noteInput(udid, at = Date.now()) {
56
+ try {
57
+ writeAtomic(paths(udid).lastInput, [...inputTimes(udid), at].slice(-INPUT_MEMORY).join(','));
58
+ } catch {
59
+ /* a timestamp nothing depends on for correctness must not fail an action */
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The last few input timestamps, oldest first.
65
+ *
66
+ * A list rather than a single timestamp, and that is the whole correction.
67
+ * The first version kept only the latest, so the only question it could ask was
68
+ * "has the screen been still for a long time?" — which needed a duration
69
+ * threshold, and the threshold is what defeated it. A field report caught a
70
+ * three-hour-stale frame on a screen that had been still for **8.2 seconds**,
71
+ * under a 20-second gate, so the check could not fire on the case it was
72
+ * written for.
73
+ *
74
+ * What actually says "dead surface" is not duration. It is **several gestures
75
+ * delivered with no pixel moving at all** — one tap that changes nothing is
76
+ * ordinary, and three in a row are not.
77
+ */
78
+ export function inputTimes(udid) {
79
+ try {
80
+ return fs.readFileSync(paths(udid).lastInput, 'utf8')
81
+ .split(',')
82
+ .map(Number)
83
+ .filter(Number.isFinite);
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ export function lastInputAt(udid) {
90
+ const times = inputTimes(udid);
91
+ return times.length ? times[times.length - 1] : null;
92
+ }
93
+
41
94
  export function captureHealth(udid) {
42
95
  return readJson(paths(udid).captureHealth);
43
96
  }
package/src/supervisor.js CHANGED
@@ -84,6 +84,18 @@ function backendFor(want) {
84
84
  * text — so it broke when the branch grew an else, with nothing actually wrong.
85
85
  * A property this important deserves an assertion that runs it.
86
86
  */
87
+ /**
88
+ * The fourth word, and why it is not in `DECISIONS`.
89
+ *
90
+ * `abstain` means "I cannot tell from what I was given", and the only correct
91
+ * thing to do with it is exactly what this module already does with every
92
+ * failure: return `null`, and behave as if there is no supervisor. So it is a
93
+ * recognised *answer* and not a recognised *decision*, and keeping those apart
94
+ * is the point — a caller must never be able to act on it, and a log must be
95
+ * able to tell it from a timeout, a refusal and a model that was never there.
96
+ */
97
+ export const ABSTAIN = 'abstain';
98
+
87
99
  export function decisionOf(answer) {
88
100
  // A string, checked rather than coerced. `String(["wait"])` is `"wait"`, so a
89
101
  // `String(...)` coercion here let `{decision: ["wait"]}` through the one gate
@@ -113,7 +125,7 @@ export function requested(options) {
113
125
  */
114
126
  export async function judge({
115
127
  goal, step, expected, failure, screen, stillMs, note, options, timeoutMs = 2500,
116
- detail,
128
+ detail, mayAbstain = false,
117
129
  } = {}) {
118
130
  const want = requested(options);
119
131
  if (!want) return null;
@@ -131,6 +143,7 @@ export async function judge({
131
143
  screen: (screen ?? []).filter(Boolean).map((s) => String(s).slice(0, 40)).slice(0, 25),
132
144
  stillMs: Number.isFinite(stillMs) ? Math.round(stillMs) : null,
133
145
  note: note ? String(note).slice(0, 200) : null,
146
+ mayAbstain: Boolean(mayAbstain),
134
147
  }, timeoutMs);
135
148
  const decision = decisionOf(answer);
136
149
  if (decision == null) {
@@ -144,7 +157,12 @@ export async function judge({
144
157
  // supervisor did not answer": a timeout, a guardrail refusal and a model
145
158
  // that was never installed were one indistinguishable line.
146
159
  if (detail && typeof detail === 'object') {
147
- detail.kind = answer?.kind
160
+ // An abstention is an answer, and the least interesting thing a log can
161
+ // say about it is that the supervisor "did not answer". It is the model
162
+ // declining on purpose, which is the one failure mode worth encouraging.
163
+ detail.kind = answer?.decision === ABSTAIN
164
+ ? 'abstained'
165
+ : answer?.kind
148
166
  ?? (answer == null ? 'no answer' : answer.decision ? 'outside the vocabulary' : 'unparseable');
149
167
  if (answer?.error) detail.error = String(answer.error).slice(0, 200);
150
168
  }