simframe 0.13.0 → 0.14.1
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 +15 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +33 -2
- package/package.json +1 -1
- package/scripts/ci-device-guard.mjs +82 -0
- package/scripts/ci-integration-local.sh +11 -8
- package/scripts/ci-memory.mjs +61 -6
- package/scripts/eval-fingerprint.mjs +249 -2
- package/src/actions.js +117 -4
- package/src/cli.js +23 -2
- package/src/graph.js +15 -2
- package/src/matching.js +17 -1
- package/src/mcp.js +141 -12
- package/src/navigate.js +4 -1
- package/src/platform/android.js +34 -0
- package/src/platform/index.js +7 -0
- package/src/platform/ios.js +158 -8
- package/src/platform/plist.js +156 -0
- package/src/storage.js +201 -0
package/src/platform/ios.js
CHANGED
|
@@ -9,9 +9,50 @@ import fs from 'node:fs';
|
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { promisify } from 'node:util';
|
|
12
|
+
import * as plist from './plist.js';
|
|
12
13
|
|
|
13
14
|
const run = promisify(execFile);
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* How long a `simctl` verb may take before we stop waiting.
|
|
18
|
+
*
|
|
19
|
+
* **It was 20s, and that was below what a loaded runner actually needs.** Item
|
|
20
|
+
* 142 recorded `simctl launch` "taking 47-55s" on a hosted runner and filed it
|
|
21
|
+
* under a boot that had not finished; the launches were real and the budget was
|
|
22
|
+
* simply shorter than they were. The same 20s sat on `openurl`, which is the
|
|
23
|
+
* failure class that item listed four runs of, and on `terminate`. One number,
|
|
24
|
+
* three symptoms, and every one of them read as the command refusing rather
|
|
25
|
+
* than as us leaving.
|
|
26
|
+
*
|
|
27
|
+
* 90s is chosen against that measurement — comfortably past the observed 55s
|
|
28
|
+
* worst case — and not for feel. A `simctl` verb that has not returned in
|
|
29
|
+
* ninety seconds is genuinely wrong, and says so below instead of being
|
|
30
|
+
* indistinguishable from a rejection.
|
|
31
|
+
*/
|
|
32
|
+
const SIMCTL_TIMEOUT_MS = 90_000;
|
|
33
|
+
|
|
34
|
+
/** A local file decode, which owes nothing to device latency. */
|
|
35
|
+
const PLUTIL_TIMEOUT_MS = 20_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What actually went wrong, including the case that has been invisible.
|
|
39
|
+
*
|
|
40
|
+
* On a timeout `execFile` kills the child, so `stderr` is EMPTY and `message`
|
|
41
|
+
* is the bare "Command failed: xcrun simctl ..." — which reads exactly like
|
|
42
|
+
* simctl rejecting the request. Three separate investigations have started from
|
|
43
|
+
* that sentence and gone looking for a broken device. The timeout has to name
|
|
44
|
+
* itself, or the next one starts in the same wrong place.
|
|
45
|
+
*/
|
|
46
|
+
function simctlFailure(err, what) {
|
|
47
|
+
const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
|
|
48
|
+
if (detail) return `${what}: ${detail}`;
|
|
49
|
+
if (err.killed || err.signal === 'SIGTERM') {
|
|
50
|
+
return `${what}: simctl did not return within ${Math.round(SIMCTL_TIMEOUT_MS / 1000)}s`
|
|
51
|
+
+ ' (killed by simframe, not refused by simctl — the host is loaded or the device is not answering)';
|
|
52
|
+
}
|
|
53
|
+
return `${what}: ${err.message}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
15
56
|
// `simctl list` costs ~130ms, which would otherwise dominate every warm read,
|
|
16
57
|
// so the parsed list is cached for a few seconds.
|
|
17
58
|
const DEVICE_CACHE_MS = 4000;
|
|
@@ -239,24 +280,33 @@ async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst =
|
|
|
239
280
|
for (const [k, v] of Object.entries(env)) childEnv[`SIMCTL_CHILD_${k}`] = String(v);
|
|
240
281
|
try {
|
|
241
282
|
await run('xcrun', ['simctl', 'launch', udid, bundleId, ...args.map(String)], {
|
|
242
|
-
timeout:
|
|
283
|
+
timeout: SIMCTL_TIMEOUT_MS,
|
|
243
284
|
env: childEnv,
|
|
244
285
|
});
|
|
245
286
|
} catch (err) {
|
|
246
287
|
// execFile's message is just "Command failed: ..." with simctl's actual
|
|
247
288
|
// complaint left in stderr. A CI run failed here and said nothing about
|
|
248
289
|
// why, which is the same sin as a silent fallback.
|
|
249
|
-
|
|
250
|
-
throw new Error(detail ? `could not launch ${bundleId}: ${detail}` : `could not launch ${bundleId}: ${err.message}`);
|
|
290
|
+
throw new Error(simctlFailure(err, `could not launch ${bundleId}`));
|
|
251
291
|
}
|
|
252
292
|
}
|
|
253
293
|
|
|
254
294
|
async function terminateApp(udid, bundleId) {
|
|
255
|
-
|
|
295
|
+
try {
|
|
296
|
+
await run('xcrun', ['simctl', 'terminate', udid, bundleId], { timeout: SIMCTL_TIMEOUT_MS });
|
|
297
|
+
} catch (err) {
|
|
298
|
+
throw new Error(simctlFailure(err, `could not terminate ${bundleId}`));
|
|
299
|
+
}
|
|
256
300
|
}
|
|
257
301
|
|
|
258
302
|
async function openUrl(udid, url) {
|
|
259
|
-
|
|
303
|
+
// The same budget and the same reporting as `launch`, because it was the same
|
|
304
|
+
// 20s and it is the failure class item 142 counted four runs of.
|
|
305
|
+
try {
|
|
306
|
+
await run('xcrun', ['simctl', 'openurl', udid, url], { timeout: SIMCTL_TIMEOUT_MS });
|
|
307
|
+
} catch (err) {
|
|
308
|
+
throw new Error(simctlFailure(err, 'could not open the url'));
|
|
309
|
+
}
|
|
260
310
|
}
|
|
261
311
|
|
|
262
312
|
/**
|
|
@@ -304,10 +354,9 @@ async function setPermission(udid, action, service, bundleId) {
|
|
|
304
354
|
const args = ['simctl', 'privacy', udid, verb, service];
|
|
305
355
|
if (bundleId) args.push(bundleId);
|
|
306
356
|
try {
|
|
307
|
-
await run('xcrun', args, { timeout:
|
|
357
|
+
await run('xcrun', args, { timeout: SIMCTL_TIMEOUT_MS });
|
|
308
358
|
} catch (err) {
|
|
309
|
-
|
|
310
|
-
throw new Error(`could not ${verb} ${service}: ${detail || err.message}`);
|
|
359
|
+
throw new Error(simctlFailure(err, `could not ${verb} ${service}`));
|
|
311
360
|
}
|
|
312
361
|
return `${verb === 'reset' ? 'reset' : verb + 'ed'} ${service}${bundleId ? ` for ${bundleId}` : ''}`;
|
|
313
362
|
}
|
|
@@ -334,6 +383,104 @@ function ownsUdid(udid) {
|
|
|
334
383
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(udid ?? ''));
|
|
335
384
|
}
|
|
336
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Where CoreSimulator keeps a device's data.
|
|
388
|
+
*
|
|
389
|
+
* Read straight off disk, and that is the whole point of this family rather
|
|
390
|
+
* than an optimisation. Measured on this Xcode: `simctl get_app_container` and
|
|
391
|
+
* `simctl listapps` **both** fail on a device that is not running —
|
|
392
|
+
* `Unable to lookup in current state: Shutdown`. The field report that asked
|
|
393
|
+
* for this feature rated it highest-leverage precisely because it answered
|
|
394
|
+
* "what did the app save?" *before the device was booted*, and simctl cannot do
|
|
395
|
+
* that. The filesystem can, so this reads the filesystem.
|
|
396
|
+
*/
|
|
397
|
+
const deviceRoot = (udid) =>
|
|
398
|
+
path.join(os.homedir(), 'Library/Developer/CoreSimulator/Devices', String(udid));
|
|
399
|
+
|
|
400
|
+
const containerRoot = (udid) => path.join(deviceRoot(udid), 'data/Containers/Data/Application');
|
|
401
|
+
|
|
402
|
+
/** The per-container metadata file that says which app owns it. */
|
|
403
|
+
const METADATA = '.com.apple.mobile_container_manager.metadata.plist';
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Every app with a data container on this device, booted or not.
|
|
407
|
+
*
|
|
408
|
+
* The bundle id lives in `MCMMetadataIdentifier` in each container's metadata
|
|
409
|
+
* plist. It is *not* recoverable by grepping the file — the binary plist
|
|
410
|
+
* encodes strings in a way that does not leave the id as a plain substring, and
|
|
411
|
+
* an early version of this that tried to pre-filter that way matched nothing.
|
|
412
|
+
* So each metadata file is asked properly. Measured at **0.52s for 150
|
|
413
|
+
* containers**, which is a listing cost rather than a per-read one.
|
|
414
|
+
*/
|
|
415
|
+
async function listApps(udid) {
|
|
416
|
+
const root = containerRoot(udid);
|
|
417
|
+
let entries;
|
|
418
|
+
try {
|
|
419
|
+
entries = await fs.promises.readdir(root, { withFileTypes: true });
|
|
420
|
+
} catch (err) {
|
|
421
|
+
if (err.code === 'ENOENT') {
|
|
422
|
+
const exists = fs.existsSync(deviceRoot(udid));
|
|
423
|
+
throw new Error(exists
|
|
424
|
+
? `device ${udid} has no app data containers yet — nothing has been installed on it`
|
|
425
|
+
: `no simulator data directory for ${udid} (looked in ${root})`);
|
|
426
|
+
}
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
const apps = [];
|
|
430
|
+
await Promise.all(entries.filter((e) => e.isDirectory()).map(async (e) => {
|
|
431
|
+
const dir = path.join(root, e.name);
|
|
432
|
+
try {
|
|
433
|
+
const { stdout } = await run('plutil',
|
|
434
|
+
['-extract', 'MCMMetadataIdentifier', 'raw', '-o', '-', path.join(dir, METADATA)],
|
|
435
|
+
{ timeout: 10_000 });
|
|
436
|
+
const bundleId = stdout.trim();
|
|
437
|
+
if (bundleId) apps.push({ bundleId, container: dir });
|
|
438
|
+
} catch {
|
|
439
|
+
// A container without readable metadata is not an app we can name, and
|
|
440
|
+
// naming it by its UUID would be offering an id nobody can use.
|
|
441
|
+
}
|
|
442
|
+
}));
|
|
443
|
+
return apps.sort((a, b) => a.bundleId.localeCompare(b.bundleId));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The data container for one app, or a listing of what is there instead. */
|
|
447
|
+
async function appContainer(udid, bundleId) {
|
|
448
|
+
const apps = await listApps(udid);
|
|
449
|
+
const hit = apps.find((a) => a.bundleId === bundleId);
|
|
450
|
+
if (hit) return hit.container;
|
|
451
|
+
// Near misses first: the id is the thing people get wrong, and a bare "not
|
|
452
|
+
// installed" on a device with the app under a slightly different id is the
|
|
453
|
+
// least useful true sentence available.
|
|
454
|
+
const needle = String(bundleId).toLowerCase();
|
|
455
|
+
const near = apps.filter((a) => a.bundleId.toLowerCase().includes(needle)
|
|
456
|
+
|| needle.includes(a.bundleId.toLowerCase())).slice(0, 5);
|
|
457
|
+
throw new Error(
|
|
458
|
+
`"${bundleId}" has no data container on ${udid}`
|
|
459
|
+
+ (near.length ? ` — did you mean ${near.map((a) => a.bundleId).join(', ')}?` : '')
|
|
460
|
+
+ ` (${apps.length} app(s) have one)`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Read a property list, whatever it contains.
|
|
466
|
+
*
|
|
467
|
+
* `-convert xml1` and not `json`: six of the twenty real preference plists on
|
|
468
|
+
* the bench device cannot be represented as JSON at all, because `<data>` and
|
|
469
|
+
* `<date>` have no JSON form and plutil refuses rather than inventing one. See
|
|
470
|
+
* the note at the top of plist.js.
|
|
471
|
+
*/
|
|
472
|
+
async function readPropertyList(file) {
|
|
473
|
+
// Its own budget, deliberately not the simctl one. This reads a local file
|
|
474
|
+
// and never speaks to a device, so it has none of the latency the simctl
|
|
475
|
+
// budget exists to absorb — and a plist that takes twenty seconds to decode
|
|
476
|
+
// is a problem worth hearing about promptly.
|
|
477
|
+
const { stdout } = await run('plutil', ['-convert', 'xml1', '-o', '-', file], {
|
|
478
|
+
timeout: PLUTIL_TIMEOUT_MS,
|
|
479
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
480
|
+
});
|
|
481
|
+
return plist.parse(stdout);
|
|
482
|
+
}
|
|
483
|
+
|
|
337
484
|
/**
|
|
338
485
|
* The prerequisites `simframe doctor` reports for this backend. Returned rather
|
|
339
486
|
* than printed so doctor stays one renderer: a backend says what it needs, and
|
|
@@ -440,6 +587,9 @@ export const platform = {
|
|
|
440
587
|
terminateApp,
|
|
441
588
|
openUrl,
|
|
442
589
|
restartDevice,
|
|
590
|
+
listApps,
|
|
591
|
+
appContainer,
|
|
592
|
+
readPropertyList,
|
|
443
593
|
setPermission,
|
|
444
594
|
setPasteboard,
|
|
445
595
|
permissionServices: () => PERMISSION_SERVICES,
|
|
@@ -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/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
|
+
}
|