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/graph.js CHANGED
@@ -396,7 +396,18 @@ export function describe(node) {
396
396
  if (tabs.length) return tabs.slice(0, 3).join(' / ');
397
397
  const anyChrome = labels(/:(nav-bar|tab-bar):/);
398
398
  if (anyChrome.length) return anyChrome.slice(0, 3).join(' ');
399
- return node.hash.slice(0, 8);
399
+ // No name. Say so, rather than handing back a hash dressed as one.
400
+ //
401
+ // This fell back to `node.hash.slice(0, 8)`, and the map prints the name in
402
+ // quotes after the identity hash — so an unnamed screen read
403
+ // `screen 299dd147 "a9505378"`: two hashes, one of them looking like a title,
404
+ // beside the `screen 089bec77 "time sheets"` a named screen produces.
405
+ // Reported from the field, with the right fix attached: omit the quoted part
406
+ // rather than echo a second hash.
407
+ //
408
+ // Callers that need *something* to print in a list supply their own fallback,
409
+ // which is a decision about presentation and belongs at the point of display.
410
+ return null;
400
411
  }
401
412
 
402
413
  /** Find a known screen by what a human would call it. */
@@ -404,7 +415,9 @@ export function findScreen(udid, query) {
404
415
  const wanted = String(query ?? '').trim();
405
416
  if (!wanted) return null;
406
417
  const scored = allNodes(udid)
407
- .map((node) => ({ node, name: describe(node) }))
418
+ // The short hash stays searchable: `goto 089bec77` worked before `describe`
419
+ // stopped inventing names and must keep working.
420
+ .map((node) => ({ node, name: describe(node) ?? node.hash.slice(0, 8) }))
408
421
  .map((c) => ({ ...c, score: matching.nameScore(c.name, wanted) }))
409
422
  .filter((c) => c.score > 0)
410
423
  .sort((a, b) => b.score - a.score);
package/src/matching.js CHANGED
@@ -170,7 +170,23 @@ export function rank(targets, intent, { screen } = {}) {
170
170
  // An icon-only control has no readable name, so a synonym is the only way
171
171
  // to reach it — this is how "back" finds a bare chevron.
172
172
  if (group && base < 0.5 && !t.label && t.rawLabel) base = 0.55;
173
- if (group && base < 0.5 && names.some((n) => group.words.includes(norm(n)))) base = 0.9;
173
+ // Genuine synonymy only: the name must be a *different* word in the group.
174
+ //
175
+ // This branch fires only when `base < 0.5`, which means its whole job is to
176
+ // overrule the coverage scaling the two branches in `nameScore` were taught
177
+ // — and when the query already contains the name literally, that scaling was
178
+ // the right answer and this flat 0.9 throws it away. Reported (138): a long
179
+ // descriptive phrase ending "…under Settings" scored a *heading* labelled
180
+ // "Settings" at 0.9 and the row the caller meant at 0.265, tapped the
181
+ // heading, and returned `ok [no visible change]` with an `or` list untried.
182
+ //
183
+ // A name the query spells out has already been scored on how much of the
184
+ // query it covers. What this branch is for is the case that scoring cannot
185
+ // see at all: "back" reaching a control labelled "Previous", "settings"
186
+ // reaching "Preferences". That is synonymy, and it is unaffected.
187
+ const spelledOut = (n) => bare.includes(norm(n)) || norm(intent).includes(norm(n));
188
+ if (group && base < 0.5
189
+ && names.some((n) => group.words.includes(norm(n)) && !spelledOut(n))) base = 0.9;
174
190
  if (base <= 0) continue;
175
191
 
176
192
  const reasons = [matched ? `label "${matched}"` : 'icon-only'];
package/src/mcp.js CHANGED
@@ -15,7 +15,8 @@ import * as api from './index.js';
15
15
  import * as input from './input.js';
16
16
  import * as metrics from './metrics.js';
17
17
  import * as navigate from './navigate.js';
18
- import { bootedDevices, permissionServices } from './platform/index.js';
18
+ import { bootedDevices, listDevices, permissionServices, resolveDevice } from './platform/index.js';
19
+ import * as storage from './storage.js';
19
20
  import * as store from './store.js';
20
21
  import * as view from './view.js';
21
22
 
@@ -132,7 +133,7 @@ const TOOLS = [
132
133
  steps: {
133
134
  type: 'array',
134
135
  description:
135
- 'Ordered steps. Every selector below accepts "Save" | "#3" | "@120,400", in that order of preference. Act: {"tap":"Save"} (add "index" if a label is ambiguous), {"type":{"into":"Name","text":"Fryer 3"}}, {"paste":{"into":"Notes","text":"long text"}}, {"clear":"Notes"} to empty a field and "clear":true on a type/paste to replace rather than append (drop "into" to type into whatever already has focus, which is how you follow a browser next-field chevron — nothing can be read back then, and the step says so), {"scroll":"down"}, {"scrollTo":"Delete account"}, {"swipe":{"from":[x,y],"to":[x,y]}}, {"button":"HOME"}, {"key":"return"} (the keyboard return/enter key, which is how a mobile search field submits — also escape, tab, space, backspace, and the arrows), {"launch":{"value":"com.example.app","relaunch":true,"args":["-uiTest","1"]}}, {"openUrl":"myapp://x"}, {"permission":{"value":"photos","grant":"grant","bundleId":"com.example.app"}}. Check: {"assert":{"value":"Saved","is":"visible"}} (also gone | enabled | disabled | value with "equals"), {"waitFor":{"value":"Saved","timeoutMs":5000}}, {"settle":{"stableMs":600}}, {"pause":300}. Recover without a round trip: add "or" to any step for fallback selectors tried locally — {"tap":"Save","or":["Done","Confirm"]} — and {"seek":"change username","budget":6} explores for something not on this screen: it OPENS containers (a real action — state changes), checks, and returns to where it started, refusing to open anything that commits, abandons or answers. It does not tap the target; it leaves you on the screen where the target resolves so you tap it next. Do not point it into a flow whose progress you cannot afford to lose. A long screen is only knowable a viewport at a time, so {"sweep":"all","fill":{"Last Name":"Asadi","Email":"a@b.c"}} goes to the top, then reads and fills section by section to the bottom — filling each field while it is on screen, which beats finding one and scrolling back. Add "from":"here" to sweep down from where you are. It reports which section each element was in, what it filled, and what it never found at any scroll position. Prefer it to scrollTo on forms and long lists. Brief the supervisor from the plan: top-level "supervise" is standing guidance for the whole batch ("lists here render a count header before rows; REVIEW stays disabled until a provider is chosen") and per-step "expect" adds to it. When it stops a run the result names the steps it did not attempt — re-issue them with a corrected "supervise" note if the judgement was wrong.',
136
+ 'Ordered steps. Every selector below accepts "Save" | "#3" | "@120,400", in that order of preference. Act: {"tap":"Save"} (add "index" if a label is ambiguous), {"type":{"into":"Name","text":"Fryer 3"}}, {"paste":{"into":"Notes","text":"long text"}}, {"clear":"Notes"} to empty a field and "clear":true on a type/paste to replace rather than append (drop "into" to type into whatever already has focus, which is how you follow a browser next-field chevron — nothing can be read back then, and the step says so), {"scroll":"down"}, {"scrollTo":"Delete account"}, {"swipe":{"from":[x,y],"to":[x,y]}}, {"button":"HOME"}, {"key":"return"} (the keyboard return/enter key, which is how a mobile search field submits — also escape, tab, space, backspace, and the arrows), {"launch":{"value":"com.example.app","relaunch":true,"args":["-uiTest","1"]}}, {"openUrl":"myapp://x"}, {"permission":{"value":"photos","grant":"grant","bundleId":"com.example.app"}}. Check: {"assert":{"value":"Saved","is":"visible"}} (also gone | enabled | disabled | value with "equals"), {"waitFor":{"value":"Saved","timeoutMs":5000}} (add "failIfStillFor":15000 to stop early once the screen has plainly stopped changing — a 180s wait once burned three minutes on an app that had logged itself out; without it a timeout still reports how long the screen had been still), {"settle":{"stableMs":600}}, {"pause":300}. Recover without a round trip: add "or" to any step for fallback selectors tried locally — {"tap":"Save","or":["Done","Confirm"]} — and {"seek":"change username","budget":6} explores for something not on this screen: it OPENS containers (a real action — state changes), checks, and returns to where it started, refusing to open anything that commits, abandons or answers. It does not tap the target; it leaves you on the screen where the target resolves so you tap it next. Do not point it into a flow whose progress you cannot afford to lose. A long screen is only knowable a viewport at a time, so {"sweep":"all","fill":{"Last Name":"Asadi","Email":"a@b.c"}} goes to the top, then reads and fills section by section to the bottom — filling each field while it is on screen, which beats finding one and scrolling back. Add "from":"here" to sweep down from where you are. It reports which section each element was in, what it filled, and what it never found at any scroll position. Prefer it to scrollTo on forms and long lists. Brief the supervisor from the plan: top-level "supervise" is standing guidance for the whole batch ("lists here render a count header before rows; REVIEW stays disabled until a provider is chosen") and per-step "expect" adds to it. When it stops a run the result names the steps it did not attempt — re-issue them with a corrected "supervise" note if the judgement was wrong.',
136
137
  items: { type: 'object' },
137
138
  },
138
139
  autoSettle: {
@@ -192,7 +193,19 @@ const TOOLS = [
192
193
  description: 'Block until something appears on screen, then return the screen map. Use this instead of pausing and re-reading.',
193
194
  inputSchema: {
194
195
  type: 'object',
195
- properties: { ...deviceProp, ...modeProps, ...selectorProp('What to wait for'), timeoutMs: { type: 'number', description: 'Default 8000.' } },
196
+ properties: {
197
+ ...deviceProp,
198
+ ...modeProps,
199
+ ...selectorProp('What to wait for'),
200
+ timeoutMs: { type: 'number', description: 'Default 8000.' },
201
+ failIfStillFor: {
202
+ type: 'number',
203
+ description: 'Give up early once the screen has not moved for this long and the target is still absent.'
204
+ + ' Off by default, because a still screen is also what a pending network call looks like —'
205
+ + ' use it when the thing you await would arrive with a visible change or not at all.'
206
+ + ' A timeout reports the stillness either way.',
207
+ },
208
+ },
196
209
  required: ['sel'],
197
210
  },
198
211
  },
@@ -386,10 +399,42 @@ const TOOLS = [
386
399
  required: ['action'],
387
400
  },
388
401
  },
402
+ {
403
+ name: 'sim_storage',
404
+ description:
405
+ 'What the app saved, as text: its UserDefaults and (for React Native) its AsyncStorage, read straight out of'
406
+ + ' the data container. sim_ui says what is drawn; sim_storage says what the app believes — use it when the'
407
+ + ' screen and the behaviour disagree, or to check a value without driving the UI to it.'
408
+ + ' Works on a device that is NOT running, so it can answer before anything is booted.'
409
+ + ' Call with no bundleId to list the apps that have a container (match filters that list).',
410
+ inputSchema: {
411
+ type: 'object',
412
+ properties: {
413
+ bundleId: { type: 'string', description: 'The app to read, e.g. com.example.myapp. Omit to list apps instead.' },
414
+ match: { type: 'string', description: 'When listing, show only bundle ids containing this string.' },
415
+ ...deviceProp,
416
+ ...modeProps,
417
+ },
418
+ },
419
+ },
389
420
  {
390
421
  name: 'sim_devices',
391
- description: 'List the booted devices simframe can drive — iOS simulators and Android emulators.',
392
- inputSchema: { type: 'object', properties: {} },
422
+ description: 'List the devices simframe can drive — iOS simulators and Android emulators.'
423
+ + ' Booted ones by default; pass all to see every device on the host and its state.'
424
+ + ' Also reports which simframe build is answering.',
425
+ inputSchema: {
426
+ type: 'object',
427
+ properties: {
428
+ all: {
429
+ type: 'boolean',
430
+ description: 'Also account for devices that are shut down, grouped by runtime.',
431
+ },
432
+ match: {
433
+ type: 'string',
434
+ description: 'With all, list shut-down devices whose name or runtime contains this, in full.',
435
+ },
436
+ },
437
+ },
393
438
  },
394
439
  ];
395
440
 
@@ -560,7 +605,7 @@ export async function serve({ device: defaultDevice, options: baseOptions = {} }
560
605
  options,
561
606
  );
562
607
  case 'sim_wait_for':
563
- return await oneStep(target, { waitFor: args.sel, timeoutMs: args.timeoutMs }, args, options);
608
+ return await oneStep(target, { waitFor: args.sel, timeoutMs: args.timeoutMs, failIfStillFor: args.failIfStillFor }, args, options);
564
609
  case 'sim_assert':
565
610
  return await oneStep(target, { assert: args.sel, is: args.is, equals: args.equals }, args, options);
566
611
  case 'sim_launch':
@@ -585,8 +630,10 @@ export async function serve({ device: defaultDevice, options: baseOptions = {} }
585
630
  return await flowRun(target, args, options);
586
631
  case 'sim_capture':
587
632
  return await capture(target, args, options);
633
+ case 'sim_storage':
634
+ return await appStorage(args);
588
635
  case 'sim_devices':
589
- return await devices();
636
+ return await devices(args);
590
637
  default:
591
638
  throw new Error(`unknown tool ${req.params.name}`);
592
639
  }
@@ -935,7 +982,7 @@ function verdictLineFor(results) {
935
982
 
936
983
  function stepLines(res) {
937
984
  const lines = [
938
- `${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${res.totalMs}ms`,
985
+ actions.flowSummary(res),
939
986
  ];
940
987
  for (const r of res.results) {
941
988
  const settle = r.settled
@@ -1142,12 +1189,94 @@ function listStateDirs() {
1142
1189
  }
1143
1190
  }
1144
1191
 
1145
- async function devices() {
1192
+ /**
1193
+ * What an app has persisted.
1194
+ *
1195
+ * Deliberately not a daemon call and deliberately not a `simctl` call. Measured
1196
+ * on this Xcode, `simctl get_app_container` and `simctl listapps` both refuse on
1197
+ * a device that is not running — so the one property that made the field
1198
+ * reporter rate this the highest-leverage thing in their session, answering
1199
+ * *before the device is booted*, is only reachable by reading the container off
1200
+ * the host filesystem. That is what the backend does.
1201
+ */
1202
+ async function appStorage({ bundleId, match: query, device } = {}) {
1203
+ const resolved = await resolveDevice(device);
1204
+ if (!bundleId) {
1205
+ const list = await storage.apps(resolved.udid);
1206
+ const needle = query ? String(query).toLowerCase() : null;
1207
+ const shown = needle ? list.filter((a) => a.bundleId.toLowerCase().includes(needle)) : list;
1208
+ return { content: [text(storage.formatApps(shown))] };
1209
+ }
1210
+ const result = await storage.read(resolved.udid, bundleId);
1211
+ return { content: [text(storage.format(result))] };
1212
+ }
1213
+
1214
+ async function devices({ all = false, match: query } = {}) {
1146
1215
  const booted = await bootedDevices();
1147
- if (!booted.length) return { content: [text('no booted devices')] };
1148
1216
  // Noticing a name collision here is what lets every later header disambiguate
1149
1217
  // itself, and it costs nothing: this listing is already being made.
1150
1218
  const clash = noteBooted(booted);
1151
- const list = booted.map((d) => `${d.name} · ${d.runtime} · ${d.udid}`).join('\n');
1152
- return { content: [text(clash ? `${clash}\n\n${list}` : list)] };
1219
+ // The build that is answering, on the one call every session starts with.
1220
+ //
1221
+ // Reported from the field: a session told to test 0.13.0 could not find out
1222
+ // what it was running. The globally installed CLI said 0.12.2 while the MCP
1223
+ // server ran from a checkout, and answering "am I on the build under test?"
1224
+ // took three shell calls and a read of `~/.claude.json`. A tool being field
1225
+ // tested should be able to state its own build, and this is the cheapest
1226
+ // place to put it.
1227
+ const head = `simframe ${packageVersion()}`;
1228
+ if (!all) {
1229
+ if (!booted.length) {
1230
+ // Never a bare "no devices". The host almost always has some, they are
1231
+ // just off, and the reporter who hit this fell out of the tool entirely
1232
+ // and went to `xcrun simctl` — for a tool whose whole job is driving
1233
+ // simulators, that is the conspicuous hole.
1234
+ const every = await listDevices().catch(() => []);
1235
+ return { content: [text(`${head}\n\nno booted devices`
1236
+ + (every.length ? ` — the host has ${every.length}, all shut down. Pass all:true to see them.` : ''))] };
1237
+ }
1238
+ const list = booted.map((d) => `● ${d.name} · ${d.runtime} · ${d.udid}`).join('\n');
1239
+ return { content: [text([head, clash, list].filter(Boolean).join('\n\n'))] };
1240
+ }
1241
+ const every = await listDevices();
1242
+ if (!every.length) return { content: [text(`${head}\n\nno devices on this host`)] };
1243
+
1244
+ // Summarised, not dumped.
1245
+ //
1246
+ // The first version of this listed every device in full and produced **126
1247
+ // rows** on this host — two thousand tokens to answer "what else is here",
1248
+ // from a tool whose entire argument is that text beats a screenshot because
1249
+ // it is cheaper. A listing that costs more than the screenshot it replaces has
1250
+ // lost the plot.
1251
+ //
1252
+ // So: booted devices in full, because those are the ones a caller can act on,
1253
+ // and the rest grouped by runtime with counts. `match` lists in full, because
1254
+ // a caller who names what they are looking for has already narrowed it.
1255
+ const wanted = String(query ?? '').trim().toLowerCase();
1256
+ const off = every.filter((d) => d.state !== 'Booted');
1257
+ const lines = [head, clash].filter(Boolean);
1258
+ lines.push(booted.length
1259
+ ? booted.map((d) => `● ${d.name} · ${d.runtime} · ${d.udid}`).join('\n')
1260
+ : 'no booted devices');
1261
+
1262
+ const hits = wanted
1263
+ ? off.filter((d) => `${d.name} ${d.runtime}`.toLowerCase().includes(wanted))
1264
+ : [];
1265
+ if (wanted) {
1266
+ lines.push(hits.length
1267
+ ? `shut down, matching "${query}":\n`
1268
+ + hits.map((d) => `○ ${d.name} · ${d.runtime} · ${d.udid}`).join('\n')
1269
+ : `no shut-down device matches "${query}" (${off.length} are shut down)`);
1270
+ } else if (off.length) {
1271
+ const byRuntime = new Map();
1272
+ for (const d of off) byRuntime.set(d.runtime, (byRuntime.get(d.runtime) ?? 0) + 1);
1273
+ const summary = [...byRuntime.entries()]
1274
+ .sort((a, b) => b[1] - a[1])
1275
+ .map(([runtime, n]) => ` ${runtime} — ${n}`)
1276
+ .join('\n');
1277
+ lines.push(`${off.length} device(s) shut down, by runtime:\n${summary}\n`
1278
+ + 'pass match to list the ones you mean, e.g. match:"iPhone 17 Pro".');
1279
+ }
1280
+ lines.push('simframe cannot drive a device until it is booted.');
1281
+ return { content: [text(lines.join('\n\n'))] };
1153
1282
  }
package/src/navigate.js CHANGED
@@ -133,7 +133,10 @@ export async function goto(deviceQuery, target, { options, ...runOptions } = {})
133
133
  }
134
134
 
135
135
  export function knownScreens(udid) {
136
- return graph.allNodes(udid).map((n) => ({ name: graph.describe(n), hash: n.hash.slice(0, 8), edges: n.edges.length }));
136
+ // A listing needs a handle for every row, so an unnamed screen falls back to
137
+ // its own short hash here — where it is plainly the hash column's value and
138
+ // not a title in quotes.
139
+ return graph.allNodes(udid).map((n) => ({ name: graph.describe(n) ?? n.hash.slice(0, 8), hash: n.hash.slice(0, 8), edges: n.edges.length }));
137
140
  }
138
141
 
139
142
  /**
@@ -1018,6 +1018,37 @@ async function restartDevice(serial) {
1018
1018
  );
1019
1019
  }
1020
1020
 
1021
+ /**
1022
+ * Reading an app's own storage is not implemented for Android, and says so.
1023
+ *
1024
+ * Not a stub and not a borrowed answer. The iOS version reads a CoreSimulator
1025
+ * data container straight off the host filesystem, which an emulator has no
1026
+ * equivalent of: an app's files live inside the emulator's own userdata image,
1027
+ * and the way in is `adb shell run-as <package>` — which works only for a
1028
+ * debuggable build, needs the emulator running, and would be a different
1029
+ * feature with different guarantees rather than the same one.
1030
+ *
1031
+ * The standing rule is that a layer a platform does not have is declined with a
1032
+ * reason, never described in the other platform's vocabulary. Claiming a data
1033
+ * container here is how `doctor` once told an emulator its input driver was
1034
+ * idb.
1035
+ */
1036
+ const noStorage = (serial, what) => {
1037
+ throw new Error(
1038
+ `simframe cannot read ${what} on an emulator (${serial}) yet. The iOS version reads a`
1039
+ + ' simulator data container off the host filesystem and an emulator has no such thing —'
1040
+ + ' its app data lives inside the userdata image, reachable only through'
1041
+ + ' `adb shell run-as <package>` on a debuggable build, with the emulator running.'
1042
+ + ' That is a different feature and it has not been built.',
1043
+ );
1044
+ };
1045
+
1046
+ async function listApps(serial) { return noStorage(serial, 'the list of installed apps'); }
1047
+ async function appContainer(serial) { return noStorage(serial, "an app's data container"); }
1048
+ async function readPropertyList() {
1049
+ throw new Error('property lists are an iOS format; Android has no equivalent to read');
1050
+ }
1051
+
1021
1052
  /** @type {import('./index.js').Platform} */
1022
1053
  export const platform = {
1023
1054
  id: 'android',
@@ -1034,6 +1065,9 @@ export const platform = {
1034
1065
  terminateApp,
1035
1066
  openUrl,
1036
1067
  restartDevice,
1068
+ listApps,
1069
+ appContainer,
1070
+ readPropertyList,
1037
1071
  setPermission,
1038
1072
  setPasteboard,
1039
1073
  getPasteboard,
@@ -63,6 +63,7 @@ export const PLATFORM_SURFACE = Object.freeze([
63
63
  'geometry', 'inputDriver',
64
64
  'screenshot', 'launchApp', 'terminateApp', 'openUrl', 'restartDevice',
65
65
  'setPermission', 'setPasteboard', 'permissionServices', 'capabilities', 'toolchain',
66
+ 'listApps', 'appContainer', 'readPropertyList',
66
67
  'bootedAt',
67
68
  ]);
68
69
 
@@ -209,6 +210,12 @@ export const openUrl = (udid, ...args) => platformFor(udid).openUrl(udid, ...arg
209
210
  export const setPermission = (udid, ...args) => platformFor(udid).setPermission(udid, ...args);
210
211
  export const setPasteboard = (udid, ...args) => platformFor(udid).setPasteboard(udid, ...args);
211
212
 
213
+ // Reading what an app persisted. Routed like everything else, and declined by a
214
+ // backend that has no equivalent rather than answered in the other's terms.
215
+ export const listApps = (udid, ...args) => platformFor(udid).listApps(udid, ...args);
216
+ export const appContainer = (udid, ...args) => platformFor(udid).appContainer(udid, ...args);
217
+ export const readPropertyList = (udid, file) => platformFor(udid).readPropertyList(file);
218
+
212
219
  /**
213
220
  * The permission services a device understands, or every service any backend
214
221
  * understands when no device is named.
@@ -9,6 +9,7 @@ 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
 
@@ -334,6 +335,100 @@ function ownsUdid(udid) {
334
335
  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
336
  }
336
337
 
338
+ /**
339
+ * Where CoreSimulator keeps a device's data.
340
+ *
341
+ * Read straight off disk, and that is the whole point of this family rather
342
+ * than an optimisation. Measured on this Xcode: `simctl get_app_container` and
343
+ * `simctl listapps` **both** fail on a device that is not running —
344
+ * `Unable to lookup in current state: Shutdown`. The field report that asked
345
+ * for this feature rated it highest-leverage precisely because it answered
346
+ * "what did the app save?" *before the device was booted*, and simctl cannot do
347
+ * that. The filesystem can, so this reads the filesystem.
348
+ */
349
+ const deviceRoot = (udid) =>
350
+ path.join(os.homedir(), 'Library/Developer/CoreSimulator/Devices', String(udid));
351
+
352
+ const containerRoot = (udid) => path.join(deviceRoot(udid), 'data/Containers/Data/Application');
353
+
354
+ /** The per-container metadata file that says which app owns it. */
355
+ const METADATA = '.com.apple.mobile_container_manager.metadata.plist';
356
+
357
+ /**
358
+ * Every app with a data container on this device, booted or not.
359
+ *
360
+ * The bundle id lives in `MCMMetadataIdentifier` in each container's metadata
361
+ * plist. It is *not* recoverable by grepping the file — the binary plist
362
+ * encodes strings in a way that does not leave the id as a plain substring, and
363
+ * an early version of this that tried to pre-filter that way matched nothing.
364
+ * So each metadata file is asked properly. Measured at **0.52s for 150
365
+ * containers**, which is a listing cost rather than a per-read one.
366
+ */
367
+ async function listApps(udid) {
368
+ const root = containerRoot(udid);
369
+ let entries;
370
+ try {
371
+ entries = await fs.promises.readdir(root, { withFileTypes: true });
372
+ } catch (err) {
373
+ if (err.code === 'ENOENT') {
374
+ const exists = fs.existsSync(deviceRoot(udid));
375
+ throw new Error(exists
376
+ ? `device ${udid} has no app data containers yet — nothing has been installed on it`
377
+ : `no simulator data directory for ${udid} (looked in ${root})`);
378
+ }
379
+ throw err;
380
+ }
381
+ const apps = [];
382
+ await Promise.all(entries.filter((e) => e.isDirectory()).map(async (e) => {
383
+ const dir = path.join(root, e.name);
384
+ try {
385
+ const { stdout } = await run('plutil',
386
+ ['-extract', 'MCMMetadataIdentifier', 'raw', '-o', '-', path.join(dir, METADATA)],
387
+ { timeout: 10_000 });
388
+ const bundleId = stdout.trim();
389
+ if (bundleId) apps.push({ bundleId, container: dir });
390
+ } catch {
391
+ // A container without readable metadata is not an app we can name, and
392
+ // naming it by its UUID would be offering an id nobody can use.
393
+ }
394
+ }));
395
+ return apps.sort((a, b) => a.bundleId.localeCompare(b.bundleId));
396
+ }
397
+
398
+ /** The data container for one app, or a listing of what is there instead. */
399
+ async function appContainer(udid, bundleId) {
400
+ const apps = await listApps(udid);
401
+ const hit = apps.find((a) => a.bundleId === bundleId);
402
+ if (hit) return hit.container;
403
+ // Near misses first: the id is the thing people get wrong, and a bare "not
404
+ // installed" on a device with the app under a slightly different id is the
405
+ // least useful true sentence available.
406
+ const needle = String(bundleId).toLowerCase();
407
+ const near = apps.filter((a) => a.bundleId.toLowerCase().includes(needle)
408
+ || needle.includes(a.bundleId.toLowerCase())).slice(0, 5);
409
+ throw new Error(
410
+ `"${bundleId}" has no data container on ${udid}`
411
+ + (near.length ? ` — did you mean ${near.map((a) => a.bundleId).join(', ')}?` : '')
412
+ + ` (${apps.length} app(s) have one)`,
413
+ );
414
+ }
415
+
416
+ /**
417
+ * Read a property list, whatever it contains.
418
+ *
419
+ * `-convert xml1` and not `json`: six of the twenty real preference plists on
420
+ * the bench device cannot be represented as JSON at all, because `<data>` and
421
+ * `<date>` have no JSON form and plutil refuses rather than inventing one. See
422
+ * the note at the top of plist.js.
423
+ */
424
+ async function readPropertyList(file) {
425
+ const { stdout } = await run('plutil', ['-convert', 'xml1', '-o', '-', file], {
426
+ timeout: 20_000,
427
+ maxBuffer: 64 * 1024 * 1024,
428
+ });
429
+ return plist.parse(stdout);
430
+ }
431
+
337
432
  /**
338
433
  * The prerequisites `simframe doctor` reports for this backend. Returned rather
339
434
  * than printed so doctor stays one renderer: a backend says what it needs, and
@@ -440,6 +535,9 @@ export const platform = {
440
535
  terminateApp,
441
536
  openUrl,
442
537
  restartDevice,
538
+ listApps,
539
+ appContainer,
540
+ readPropertyList,
443
541
  setPermission,
444
542
  setPasteboard,
445
543
  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
+ }