simframe 0.4.1 → 0.5.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.
Files changed (40) hide show
  1. package/README.md +227 -53
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
  4. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  5. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
  8. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  9. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  10. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  11. package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
  12. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  13. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  14. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  15. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  16. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  17. package/native/simframed/Sources/simframed/main.swift +357 -0
  18. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
  19. package/package.json +10 -4
  20. package/scripts/bench-flow.mjs +54 -0
  21. package/scripts/bench.sh +98 -0
  22. package/scripts/check-package.mjs +91 -0
  23. package/scripts/smoke.mjs +76 -0
  24. package/scripts/verify-baseline.mjs +65 -0
  25. package/src/actions.js +82 -5
  26. package/src/cli.js +347 -31
  27. package/src/control.js +76 -0
  28. package/src/daemon.js +8 -1
  29. package/src/engine.js +99 -0
  30. package/src/fingerprint.js +150 -0
  31. package/src/graph.js +409 -0
  32. package/src/index.js +292 -23
  33. package/src/input.js +80 -2
  34. package/src/matching.js +194 -0
  35. package/src/mcp.js +45 -1
  36. package/src/navigate.js +120 -0
  37. package/src/regions.js +90 -0
  38. package/src/screenmap.js +77 -21
  39. package/src/simctl.js +20 -4
  40. package/src/store.js +8 -0
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@ import { bootedDevices, listDevices, resolveDevice } from './simctl.js';
6
6
  import * as actions from './actions.js';
7
7
  import * as api from './index.js';
8
8
  import * as input from './input.js';
9
+ import * as navigate from './navigate.js';
9
10
  import * as store from './store.js';
10
11
 
11
12
  const USAGE = `simframe — always-warm iOS Simulator frames
@@ -20,17 +21,30 @@ const USAGE = `simframe — always-warm iOS Simulator frames
20
21
  simframe wait [device] wait for the screen to react (see --mode)
21
22
  simframe strip [device] write a contact sheet of recent frames
22
23
  simframe recall [device] what happened in the last minute (--ago=<ms> for a frame)
24
+ simframe tapAt <x> <y> tap at a point, in points
25
+ simframe swipe <x1> <y1> <x2> <y2> swipe between two points
26
+ simframe type <text> enter text (exact; uses the pasteboard)
27
+ simframe keys <text> send key events instead (layout-dependent)
28
+ simframe press <button> a hardware button, e.g. home
23
29
  simframe ui [device] read the screen as an accessibility tree
24
30
  simframe tap <label> tap an element by its accessibility label
25
31
  simframe do <script.json> run a scripted flow (see below)
32
+ simframe screens [device] list screens this device has learned
33
+ simframe goto <screen> walk to a known screen through known steps
34
+ simframe flow save <name> <script.json> run a flow and save it if every step verifies
35
+ simframe flow run <name> replay a saved flow
36
+ simframe flow list list saved flows
26
37
  simframe devices list simulators
27
- simframe doctor check that this machine can capture
38
+ simframe doctor [--json] check that this machine can capture
39
+ (--strict, or SIMFRAME_STRICT=1, makes any
40
+ degraded layer a non-zero exit)
28
41
 
29
42
  Options
30
43
  --device=<udid|name> simulator to target (default: the booted one)
31
44
  --out=<file> output path for frame/strip
32
45
  --detail=low|normal|high|full or --detail=<max pixels>
33
- --fps=<n> capture rate while the screen is moving (default ${DEFAULTS.fps})
46
+ --engine=simframed|simctl capture engine (default simframed)
47
+ --fps=<n> capture rate while the screen is moving (simctl engine only)
34
48
  --count=<n> frames in a strip (default 5)
35
49
  --since=<hash|seq> compare against this frame (see: simframe mark)
36
50
  --mode=settle|change|stable what wait waits for (default settle)
@@ -45,7 +59,8 @@ A script is a JSON array of steps, run in one go with a settle between each:
45
59
  {"type":{"into":"Name","text":"Fryer 3"}},
46
60
  {"tap":"Save"},{"waitText":"Saved","timeoutMs":5000}]
47
61
 
48
- Input needs idb (brew tap facebook/fb && brew install idb-companion,
62
+ Input comes from the daemon. idb is needed only for the accessibility tree
63
+ (brew tap facebook/fb && brew install idb-companion,
49
64
  then pipx install fb-idb). Observation works without it.
50
65
 
51
66
  The reliable pattern around an action is:
@@ -81,6 +96,7 @@ async function main() {
81
96
  if (flags.fps) options.fps = num(flags.fps);
82
97
  if (flags.maxDim) options.maxDim = num(flags.maxDim);
83
98
  if (flags.ringSize) options.ringSize = num(flags.ringSize);
99
+ if (flags.engine) options.engine = String(flags.engine);
84
100
 
85
101
  switch (command) {
86
102
  case undefined:
@@ -112,16 +128,32 @@ async function main() {
112
128
 
113
129
  case 'start': {
114
130
  const { device: dev, state, started } = await api.ensureDaemon(device, options);
131
+ const engineModule = await import('./engine.js');
132
+ const running = engineModule.runningEngine(dev.udid) ?? 'simctl';
115
133
  console.log(
116
- `${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime}) frame #${state.seq} ${state.width}x${state.height}`,
134
+ `${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime}) ` +
135
+ `engine=${running} frame #${state.seq} ${state.width}x${state.height}`,
117
136
  );
137
+ // Say which engine, and if it is the slow one, say why. A downgrade that
138
+ // prints nothing is how this shipped broken twice.
139
+ if (running !== 'simframed') {
140
+ const why = api.fallbackReason(dev.udid);
141
+ console.log(
142
+ `WARN engine=simctl — roughly 30x slower per frame. ` +
143
+ (why ? `simframed unavailable: ${why}` : 'reason unrecorded; run simframe doctor'),
144
+ );
145
+ if (Boolean(flags.strict) || process.env.SIMFRAME_STRICT === '1') {
146
+ console.error('--strict: refusing to run on a degraded engine');
147
+ process.exitCode = 1;
148
+ }
149
+ }
118
150
  return;
119
151
  }
120
152
 
121
153
  case 'stop': {
122
154
  const targets = flags.all
123
155
  ? fs.existsSync(store.ROOT)
124
- ? fs.readdirSync(store.ROOT)
156
+ ? fs.readdirSync(store.ROOT).filter(store.isUdid)
125
157
  : []
126
158
  : [(await resolveDevice(device)).udid];
127
159
  let stopped = 0;
@@ -142,7 +174,7 @@ async function main() {
142
174
  const udids = device
143
175
  ? [(await resolveDevice(device)).udid]
144
176
  : fs.existsSync(store.ROOT)
145
- ? fs.readdirSync(store.ROOT)
177
+ ? fs.readdirSync(store.ROOT).filter(store.isUdid)
146
178
  : [];
147
179
  const rows = udids.map((udid) => {
148
180
  const { meta, pid, alive, stale } = api.daemonStatus(udid);
@@ -328,7 +360,7 @@ async function main() {
328
360
  const label = positional[0];
329
361
  if (!label) throw new Error('usage: simframe tap <label>');
330
362
  const res = await actions.runScript(flags.device, {
331
- steps: [{ tap: label, index: flags.index != null ? num(flags.index) : undefined }],
363
+ steps: [flags.index != null ? { tap: label, index: num(flags.index) } : { tap: label }],
332
364
  options,
333
365
  });
334
366
  const step = res.results[0];
@@ -358,6 +390,163 @@ async function main() {
358
390
  return;
359
391
  }
360
392
 
393
+ case 'goto': {
394
+ const target = positional.join(' ').trim();
395
+ if (!target) throw new Error('usage: simframe goto "<screen>"');
396
+ const res = await navigate.goto(flags.device, target, {
397
+ stableMs: num(flags.stableMs, 500),
398
+ timeoutMs: num(flags.timeoutMs, 8000),
399
+ options,
400
+ });
401
+ if (!res.ok && res.reason === 'unknown-screen') {
402
+ console.log(`no screen matching "${target}". known screens:`);
403
+ for (const s of res.known) console.log(` ${s.name} (${s.hash}, ${s.edges} edges)`);
404
+ process.exitCode = 1;
405
+ return;
406
+ }
407
+ if (!res.ok && res.reason === 'ambiguous') {
408
+ console.log(`"${target}" matches more than one screen:`);
409
+ for (const c of res.candidates) console.log(` ${c.name} (${c.hash.slice(0, 8)})`);
410
+ process.exitCode = 1;
411
+ return;
412
+ }
413
+ if (!res.ok && res.reason) {
414
+ console.log(`${res.reason}: cannot reach "${res.to ?? target}" from here`);
415
+ process.exitCode = 1;
416
+ return;
417
+ }
418
+ if (res.already) {
419
+ console.log(`already on ${res.screen}`);
420
+ return;
421
+ }
422
+ for (const r of res.results ?? []) {
423
+ console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.action}: ${r.verification?.verdict ?? (r.ok ? r.detail : r.error)}`);
424
+ }
425
+ console.log(res.ok ? `arrived at ${res.screen} in ${res.ranSteps} step(s)` : `ended at ${res.arrived}, wanted ${res.screen}`);
426
+ process.exitCode = res.ok ? 0 : 1;
427
+ return;
428
+ }
429
+
430
+ case 'screens': {
431
+ const { device } = await api.ensureDaemon(flags.device, options);
432
+ const known = navigate.knownScreens(device.udid);
433
+ if (!known.length) {
434
+ console.log('no screens known yet — run a flow first');
435
+ return;
436
+ }
437
+ for (const s of known) console.log(`${s.hash} ${s.edges} edges ${s.name}`);
438
+ return;
439
+ }
440
+
441
+ case 'flow': {
442
+ const [sub, name] = positional;
443
+ const { device } = await api.ensureDaemon(flags.device, options);
444
+ if (sub === 'list') {
445
+ const flows = navigate.listFlows(device.udid);
446
+ if (!flows.length) console.log('no saved flows');
447
+ for (const f of flows) console.log(`${f.name} ${f.steps} steps`);
448
+ return;
449
+ }
450
+ if (sub === 'save') {
451
+ const file = positional[2];
452
+ if (!name || !file) throw new Error('usage: simframe flow save <name> <script.json>');
453
+ const steps = JSON.parse(fs.readFileSync(file, 'utf8'));
454
+ const res = await actions.runScript(flags.device, {
455
+ steps,
456
+ stableMs: num(flags.stableMs, 500),
457
+ timeoutMs: num(flags.timeoutMs, 8000),
458
+ options,
459
+ });
460
+ const saved = navigate.saveFlow(device.udid, name, res, { force: Boolean(flags.force) });
461
+ if (!saved.ok) {
462
+ console.log(`not saved: ${saved.reason} (${saved.verdicts.join(', ')}) — re-run, or pass --force`);
463
+ process.exitCode = 1;
464
+ return;
465
+ }
466
+ console.log(`saved ${saved.name} — ${saved.steps} steps`);
467
+ return;
468
+ }
469
+ if (sub === 'run') {
470
+ if (!name) throw new Error('usage: simframe flow run <name>');
471
+ const res = await navigate.runFlow(flags.device, name, {
472
+ stableMs: num(flags.stableMs, 500),
473
+ timeoutMs: num(flags.timeoutMs, 8000),
474
+ options,
475
+ });
476
+ if (res.reason === 'unknown-flow') {
477
+ console.log(`no flow "${name}". known: ${res.known.join(', ') || '(none)'}`);
478
+ process.exitCode = 1;
479
+ return;
480
+ }
481
+ for (const r of res.results ?? []) {
482
+ console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.action}: ${r.verification?.verdict ?? (r.ok ? r.detail : r.error)}`);
483
+ }
484
+ console.log(`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps`);
485
+ process.exitCode = res.ok ? 0 : 1;
486
+ return;
487
+ }
488
+ throw new Error('usage: simframe flow <list|save|run>');
489
+ }
490
+
491
+ case 'tapAt':
492
+ case 'swipe':
493
+ case 'type':
494
+ case 'keys':
495
+ case 'press': {
496
+ const input = await import('./input.js');
497
+ const dev = await resolveDevice(flags.device);
498
+ const nums = positional.map(Number);
499
+ const t0 = Date.now();
500
+ switch (command) {
501
+ case 'tapAt': {
502
+ if (positional.length < 2 || nums.slice(0, 2).some(Number.isNaN)) {
503
+ throw new Error('usage: simframe tapAt <x> <y>');
504
+ }
505
+ await input.tapPoint(dev.udid, nums[0], nums[1], flags.durationMs ? { durationMs: num(flags.durationMs) } : {});
506
+ break;
507
+ }
508
+ case 'swipe': {
509
+ if (positional.length < 4 || nums.slice(0, 4).some(Number.isNaN)) {
510
+ throw new Error('usage: simframe swipe <x1> <y1> <x2> <y2>');
511
+ }
512
+ await input.swipe(dev.udid, { x: nums[0], y: nums[1] }, { x: nums[2], y: nums[3] }, { durationMs: num(flags.durationMs, 300) });
513
+ break;
514
+ }
515
+ case 'type':
516
+ if (!positional.length) throw new Error('usage: simframe type <text>');
517
+ await input.typeText(dev.udid, positional.join(' '));
518
+ break;
519
+ case 'keys':
520
+ if (!positional.length) throw new Error('usage: simframe keys <text>');
521
+ await input.typeKeys(dev.udid, positional.join(' '));
522
+ break;
523
+ default:
524
+ if (!positional.length) throw new Error('usage: simframe press <button>');
525
+ await input.pressButton(dev.udid, positional[0]);
526
+ }
527
+ const driver = await input.driverFor(dev.udid);
528
+ console.log(`${command} in ${Date.now() - t0}ms via ${driver.name}`);
529
+ return;
530
+ }
531
+
532
+ case 'find': {
533
+ const intent = positional.join(' ');
534
+ if (!intent) throw new Error('usage: simframe find "<intent>"');
535
+ try {
536
+ const r = await api.locate(flags.device, intent, { options });
537
+ console.log(
538
+ `${r.target.label ?? '(icon-only)'} @(${r.target.x},${r.target.y}) ` +
539
+ `${r.target.region ?? 'content'} ${r.target.type ?? '?'}/${r.target.source} score ${r.score ?? '-'}`,
540
+ );
541
+ if (r.reasons?.length) console.log(` because: ${r.reasons.join(', ')}`);
542
+ for (const a of r.alternatives ?? []) console.log(` also considered: "${a.label}" ${a.score}`);
543
+ } catch (err) {
544
+ console.log(err.message);
545
+ process.exitCode = 1;
546
+ }
547
+ return;
548
+ }
549
+
361
550
  case 'devices': {
362
551
  const all = await listDevices();
363
552
  const shown = flags.all ? all : all.filter((d) => d.state === 'Booted');
@@ -372,7 +561,11 @@ async function main() {
372
561
  }
373
562
 
374
563
  case 'doctor': {
375
- await doctor();
564
+ await doctor({
565
+ json: Boolean(flags.json),
566
+ strict: Boolean(flags.strict) || process.env.SIMFRAME_STRICT === '1',
567
+ device: flags.device,
568
+ });
376
569
  return;
377
570
  }
378
571
 
@@ -382,52 +575,175 @@ async function main() {
382
575
  }
383
576
  }
384
577
 
385
- async function doctor() {
578
+ /**
579
+ * Report every layer, and treat a silent downgrade as a problem.
580
+ *
581
+ * The tool's policy is to degrade rather than fail, which is right — a machine
582
+ * without a Swift toolchain should still capture frames. What was wrong was
583
+ * that degrading looked identical to working: a published package missing one
584
+ * file made every install fall back to the simctl engine, and another shipped
585
+ * OCR disabled. Both passed CI, and nothing printed a word.
586
+ *
587
+ * So a fallback is a `warn`, not an `ok`, and `--strict` (or SIMFRAME_STRICT=1)
588
+ * makes any warn a non-zero exit. CI runs strict; users see the warning.
589
+ *
590
+ * `optional` is a fourth level and a deliberate distinction, not a softer warn.
591
+ * A `warn` means this machine could be doing better and silently is not — the
592
+ * failure this whole mechanism exists to catch. `optional` means a dependency
593
+ * documented as optional is simply not installed, which doctor says plainly
594
+ * with install instructions. idb is the only one: it is optional, it is being
595
+ * removed, and a fresh machine without it has not degraded from anything.
596
+ * Strict fails on warn and fail, never on optional.
597
+ */
598
+ async function doctor({ json = false, strict = false, device } = {}) {
386
599
  const checks = [];
387
- const add = (name, ok, detail) => checks.push({ name, ok, detail });
600
+ // `level` is 'ok' | 'warn' | 'fail'. A warn means it works but not the way it
601
+ // should — the exact state that used to be invisible.
602
+ const add = (name, level, detail, extra = {}) => checks.push({ name, level, detail, ...extra });
603
+ const startedHere = [];
388
604
 
389
- add('node', true, process.version);
605
+ add('node', 'ok', process.version);
606
+ const { execFileSync } = await import('node:child_process');
390
607
  try {
391
- const { execFileSync } = await import('node:child_process');
392
- add('xcrun', true, execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0]);
608
+ add('xcrun', 'ok', execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0]);
393
609
  } catch (err) {
394
- add('xcrun', false, err.message);
610
+ add('xcrun', 'fail', err.message);
395
611
  }
396
612
  try {
397
- const { execFileSync } = await import('node:child_process');
398
613
  execFileSync('sips', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
399
- add('sips', true, 'available');
614
+ add('sips', 'ok', 'available');
400
615
  } catch (err) {
401
- add('sips', false, err.message);
616
+ add('sips', 'fail', err.message);
402
617
  }
403
- const driver = await input.detectDriver();
404
- add('input driver (idb)', driver.available, driver.available ? driver.version : driver.reason);
618
+
619
+ const engineModule = await import('./engine.js');
620
+ // Build first, then report. doctor compiles the daemon on demand, so
621
+ // reporting the state beforehand printed "present, not yet built" in output
622
+ // that was already false by the time it reached the terminal.
623
+ await engineModule.ensureBuilt().catch(() => {});
624
+ const build = engineModule.status();
625
+ if (!build.haveSource) {
626
+ add('simframed sources', 'fail', 'not present in this install — the daemon cannot be built', {
627
+ key: 'daemon.sources',
628
+ });
629
+ } else {
630
+ add('simframed sources', 'ok', build.haveBinary ? (build.stale ? 'present, binary stale' : 'present, built') : 'present, not yet built', {
631
+ key: 'daemon.sources',
632
+ });
633
+ }
634
+
635
+ let ocrAvailable = false;
405
636
  try {
406
637
  const ocr = await import('./ocr.js');
407
638
  const built = await ocr.ensureBinary();
408
- add('on-device OCR', built.available, built.available ? 'available' : built.reason);
639
+ ocrAvailable = Boolean(built.available);
640
+ add('on-device OCR', ocrAvailable ? 'ok' : 'warn', ocrAvailable ? 'available' : built.reason, {
641
+ key: 'ocr.available',
642
+ value: ocrAvailable,
643
+ });
409
644
  } catch (err) {
410
- add('on-device OCR', false, err.message);
645
+ add('on-device OCR', 'warn', err.message, { key: 'ocr.available', value: false });
411
646
  }
647
+
412
648
  try {
413
- const booted = await bootedDevices();
414
- add('booted simulator', booted.length > 0, booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
649
+ let booted = await bootedDevices();
650
+ // Respect --device. Without this, doctor reports on every booted simulator,
651
+ // which on a CI runner meant checking an Apple Vision Pro nobody asked
652
+ // about and failing strict on its layers.
653
+ if (device) {
654
+ const wanted = await resolveDevice(device);
655
+ booted = booted.filter((d) => d.udid === wanted.udid);
656
+ }
657
+ add('booted simulator', booted.length ? 'ok' : 'warn',
658
+ booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
659
+ for (const d of booted) {
660
+ const input = await import('./input.js');
661
+ const control = await import('./control.js');
662
+ // Start the engine before asking which engine is in use. Reading it first
663
+ // reports `simctl` on any machine where nothing happens to be running
664
+ // yet — a warning about a downgrade that has not occurred, and one that
665
+ // would have made the CI assertion fail for the wrong reason.
666
+ const wasRunning = Boolean(engineModule.runningEngine(d.udid));
667
+ await api.ensureDaemon(d.udid).catch(() => {});
668
+ if (!wasRunning) startedHere.push(d.udid);
669
+ // ensureDaemon waits for a frame; the control socket comes up a moment
670
+ // later. Asking immediately reports `idb` for a device whose own input
671
+ // path is seconds from ready — a race that would read as CI flake.
672
+ for (let i = 0; i < 40 && !control.available(d.udid); i += 1) {
673
+ await new Promise((r) => setTimeout(r, 50));
674
+ }
675
+ const driver = await input.driverFor(d.udid, { refresh: true });
676
+ // Which engine is actually capturing, from the daemon's own record.
677
+ // `control.available` answers a different question — whether the input
678
+ // socket is up — and using it here reported simctl on a machine that was
679
+ // capturing with simframed perfectly well.
680
+ const captureEngine = engineModule.runningEngine(d.udid) ?? 'simctl';
681
+ const daemon = captureEngine === 'simframed';
682
+ const why = captureEngine === 'simctl' ? api.fallbackReason(d.udid) : null;
683
+ add(`capture engine (${d.name})`, captureEngine === 'simframed' ? 'ok' : 'warn',
684
+ captureEngine === 'simframed'
685
+ ? 'simframed'
686
+ : `simctl — roughly 30x slower per frame${why ? `; simframed unavailable: ${why}` : '. Run simframe start to see why'}`,
687
+ { key: 'capture.engine', value: captureEngine });
688
+ add(`input driver (${d.name})`, driver.available ? (driver.name === 'simframed' ? 'ok' : 'warn') : 'warn',
689
+ driver.available ? `${driver.name}: ${driver.version}` : driver.reason,
690
+ { key: 'input.driver', value: driver.available ? driver.name : null });
691
+ add(`text recognition (${d.name})`, 'ok',
692
+ daemon ? 'simframed (in-process, off the framebuffer)' : 'sips + helper binary');
693
+ const ax = await input.detectDriver();
694
+ add(`accessibility tree (${d.name})`, ax.available ? 'ok' : 'optional',
695
+ ax.available ? 'idb — the only thing idb is still required for' : `not installed: ${ax.reason}`,
696
+ { key: 'ax.driver', value: ax.available ? 'idb' : null });
697
+ }
415
698
  if (booted.length) {
416
699
  const t0 = Date.now();
417
700
  const res = await api.getFrame(booted[0].udid);
418
- add('capture', true, `frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`);
701
+ add('capture', 'ok',
702
+ `frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`,
703
+ { key: 'capture.frames', value: res.state.seq });
419
704
  }
420
705
  } catch (err) {
421
- add('capture', false, err.message);
706
+ add('capture', 'fail', err.message);
707
+ }
708
+
709
+ // doctor is a diagnostic, not a way to start things. If it had to start a
710
+ // daemon to answer "which engine is in use", it stops it again rather than
711
+ // leaving a detached process behind.
712
+ for (const udid of startedHere) {
713
+ try { await api.stopDaemon(udid); } catch { /* best effort */ }
422
714
  }
423
715
 
424
- for (const c of checks) {
425
- const mark = c.ok ? 'ok ' : c.name.startsWith('input driver') ? 'none' : 'FAIL';
426
- console.log(`${mark} ${c.name.padEnd(18)} ${c.detail}`);
716
+ const failed = checks.filter((c) => c.level === 'fail');
717
+ const warned = checks.filter((c) => c.level === 'warn');
718
+ const optional = checks.filter((c) => c.level === 'optional');
719
+
720
+ if (json) {
721
+ const flat = {};
722
+ for (const c of checks) if (c.key) flat[c.key] = c.value;
723
+ console.log(JSON.stringify({
724
+ ok: failed.length === 0 && (!strict || warned.length === 0),
725
+ strict,
726
+ failures: failed.length,
727
+ warnings: warned.length,
728
+ optional: optional.length,
729
+ ...flat,
730
+ checks: checks.map(({ name, level, detail }) => ({ name, level, detail })),
731
+ }, null, 2));
732
+ } else {
733
+ const mark = { ok: 'ok ', warn: 'WARN', fail: 'FAIL', optional: '-- ' };
734
+ for (const c of checks) console.log(`${mark[c.level]} ${c.name.padEnd(24)} ${c.detail}`);
735
+ if (warned.length) {
736
+ console.log(`\n${warned.length} layer(s) degraded. simframe still works, but not at full speed or coverage:`);
737
+ for (const c of warned) console.log(` - ${c.name}: ${c.detail}`);
738
+ if (!strict) console.log('Use --strict to make this an error (CI does).');
739
+ }
740
+ if (optional.length) {
741
+ console.log(`\n${optional.length} optional layer(s) not installed (not a downgrade):`);
742
+ for (const c of optional) console.log(` - ${c.name}: ${c.detail}`);
743
+ }
427
744
  }
428
- // Input is optional: simframe is still useful as a pure observer.
429
- const required = checks.filter((c) => !c.name.startsWith('input driver'));
430
- process.exitCode = required.every((c) => c.ok) ? 0 : 1;
745
+
746
+ process.exitCode = failed.length || (strict && warned.length) ? 1 : 0;
431
747
  }
432
748
 
433
749
  main().catch((err) => {
package/src/control.js ADDED
@@ -0,0 +1,76 @@
1
+ // Client for the simframed control socket.
2
+ //
3
+ // One JSON object per line over a per-device Unix socket. The socket is mode
4
+ // 0600, so reachability is the whole authorisation model: if you can open it,
5
+ // you are the user who owns the simulator.
6
+ import fs from 'node:fs';
7
+ import net from 'node:net';
8
+ import path from 'node:path';
9
+ import * as store from './store.js';
10
+
11
+ export function socketPath(udid) {
12
+ return path.join(store.deviceDir(udid), 'control.sock');
13
+ }
14
+
15
+ /** Whether a daemon is listening for this device. */
16
+ export function available(udid) {
17
+ const meta = store.readJson(path.join(store.deviceDir(udid), 'meta.json'));
18
+ if (!meta || !store.isProcessAlive(meta.pid)) return false;
19
+ try {
20
+ return fs.statSync(socketPath(udid)).isSocket();
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function request(udid, payload, { timeoutMs = 30_000 } = {}) {
27
+ return new Promise((resolve, reject) => {
28
+ const socket = net.createConnection(socketPath(udid));
29
+ let buffer = '';
30
+ let settled = false;
31
+ const finish = (fn, value) => {
32
+ if (settled) return;
33
+ settled = true;
34
+ socket.destroy();
35
+ fn(value);
36
+ };
37
+ socket.setTimeout(timeoutMs, () => finish(reject, new Error('control socket timed out')));
38
+ socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`));
39
+ socket.on('data', (chunk) => {
40
+ buffer += chunk;
41
+ const line = buffer.indexOf('\n');
42
+ if (line < 0) return;
43
+ try {
44
+ const response = JSON.parse(buffer.slice(0, line));
45
+ if (response.ok === false) finish(reject, new Error(response.error || 'request failed'));
46
+ else finish(resolve, response);
47
+ } catch (err) {
48
+ finish(reject, err);
49
+ }
50
+ });
51
+ socket.on('error', (err) => {
52
+ finish(
53
+ reject,
54
+ err.code === 'ENOENT' || err.code === 'ECONNREFUSED'
55
+ ? new Error('no simframed daemon is listening for this device')
56
+ : err,
57
+ );
58
+ });
59
+ });
60
+ }
61
+
62
+ export const tap = (udid, x, y, opts = {}) => request(udid, { action: 'tap', x, y, ...opts });
63
+ export const swipe = (udid, from, to, opts = {}) =>
64
+ request(udid, { action: 'swipe', x1: from.x, y1: from.y, x2: to.x, y2: to.y, ...opts });
65
+ export const type = (udid, text) => request(udid, { action: 'type', text });
66
+ export const paste = (udid, text) => request(udid, { action: 'paste', text });
67
+ export const press = (udid, button) => request(udid, { action: 'press', button });
68
+ export const status = (udid) => request(udid, { action: 'status' });
69
+ export const longPress = (udid, x, y, opts = {}) => request(udid, { action: 'longPress', x, y, ...opts });
70
+ export const drag = (udid, from, to, opts = {}) =>
71
+ request(udid, { action: 'drag', x1: from.x, y1: from.y, x2: to.x, y2: to.y, ...opts });
72
+ export const launch = (udid, bundleId, opts = {}) => request(udid, { action: 'launch', bundleId, ...opts });
73
+ export const terminate = (udid, bundleId) => request(udid, { action: 'terminate', bundleId });
74
+ export const openUrl = (udid, url) => request(udid, { action: 'openUrl', url });
75
+ export const permission = (udid, permissionAction, service, bundleId) =>
76
+ request(udid, { action: 'permission', permissionAction, service, bundleId });
package/src/daemon.js CHANGED
@@ -16,7 +16,14 @@ import { isBootedSync, resize, screenshot } from './simctl.js';
16
16
 
17
17
  // Bump whenever the shape of state.json changes, so an upgraded client retires
18
18
  // a capture loop left running by an older install instead of misreading it.
19
- export const STATE_VERSION = 5;
19
+ // MUST equal SimframeCore.FrameStore.stateVersion in the Swift daemon. When
20
+ // these drifted — Node on 5, Swift writing 6 — every single CLI command judged
21
+ // the live daemon stale and spawned a replacement: 993 "superseded by another
22
+ // capture loop" lines in one log. Capture still worked, so nothing looked
23
+ // wrong, but each command lost the previous daemon's history, which silently
24
+ // broke `recall`, `state --since`, `wait --since` and every timing measured
25
+ // through a flow. A unit test asserts these two constants match.
26
+ export const STATE_VERSION = 6;
20
27
 
21
28
  export const DEFAULTS = {
22
29
  fps: 4,