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.
- package/README.md +138 -9
- package/native/simframed/Sources/SimframeCore/Motion.swift +33 -2
- package/native/supervise.swift +63 -4
- package/package.json +1 -1
- package/scripts/article-md.mjs +185 -0
- package/scripts/ci-device-guard.mjs +82 -0
- package/scripts/ci-integration-local.sh +33 -9
- package/scripts/ci-memory.mjs +117 -15
- package/scripts/eval-fingerprint.mjs +145 -3
- package/scripts/replay-rulings.mjs +60 -1
- package/src/actions.js +213 -13
- package/src/analyze.js +56 -0
- package/src/cli.js +205 -15
- package/src/fingerprint.js +10 -1
- package/src/graph.js +15 -2
- package/src/index.js +302 -16
- package/src/input.js +4 -0
- package/src/matching.js +17 -1
- package/src/mcp.js +148 -13
- package/src/metrics.js +49 -6
- package/src/navigate.js +4 -1
- package/src/ollama.js +37 -9
- package/src/platform/android.js +34 -0
- package/src/platform/index.js +7 -0
- package/src/platform/ios.js +128 -5
- package/src/platform/plist.js +156 -0
- package/src/refs.js +12 -1
- package/src/regions.js +54 -0
- package/src/screenmap.js +85 -6
- package/src/storage.js +201 -0
- package/src/store.js +53 -0
- package/src/supervisor.js +20 -2
- package/src/view.js +84 -9
|
@@ -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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
}
|