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.
- package/README.md +227 -53
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +357 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
- package/package.json +10 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +91 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/src/actions.js +82 -5
- package/src/cli.js +347 -31
- package/src/control.js +76 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +150 -0
- package/src/graph.js +409 -0
- package/src/index.js +292 -23
- package/src/input.js +80 -2
- package/src/matching.js +194 -0
- package/src/mcp.js +45 -1
- package/src/navigate.js +120 -0
- package/src/regions.js +90 -0
- package/src/screenmap.js +77 -21
- package/src/simctl.js +20 -4
- 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
|
|
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
|
-
--
|
|
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
|
|
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})
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
605
|
+
add('node', 'ok', process.version);
|
|
606
|
+
const { execFileSync } = await import('node:child_process');
|
|
390
607
|
try {
|
|
391
|
-
|
|
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',
|
|
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',
|
|
614
|
+
add('sips', 'ok', 'available');
|
|
400
615
|
} catch (err) {
|
|
401
|
-
add('sips',
|
|
616
|
+
add('sips', 'fail', err.message);
|
|
402
617
|
}
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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',
|
|
645
|
+
add('on-device OCR', 'warn', err.message, { key: 'ocr.available', value: false });
|
|
411
646
|
}
|
|
647
|
+
|
|
412
648
|
try {
|
|
413
|
-
|
|
414
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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,
|