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.
@@ -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: 20_000,
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
- const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
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
- await run('xcrun', ['simctl', 'terminate', udid, bundleId], { timeout: 20_000 });
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
- await run('xcrun', ['simctl', 'openurl', udid, url], { timeout: 20_000 });
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: 20_000 });
357
+ await run('xcrun', args, { timeout: SIMCTL_TIMEOUT_MS });
308
358
  } catch (err) {
309
- const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
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
+ }