simframe 0.13.0 → 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/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
+ }