simframe 0.6.1 → 0.7.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 +93 -6
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +7 -2
- package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +34 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +20 -0
- package/native/simframed/Sources/simframed/main.swift +30 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +39 -0
- package/package.json +1 -1
- package/scripts/analyse-fingerprint.mjs +149 -0
- package/scripts/bench-flow.mjs +1 -1
- package/scripts/ci-memory.mjs +48 -1
- package/scripts/eval-ax-tier.mjs +191 -0
- package/scripts/eval-fingerprint.mjs +161 -6
- package/skills/simframe/SKILL.md +10 -3
- package/src/actions.js +36 -5
- package/src/cli.js +62 -26
- package/src/daemon.js +36 -1
- package/src/engine.js +19 -6
- package/src/fingerprint.js +58 -0
- package/src/graph.js +89 -5
- package/src/index.js +81 -12
- package/src/input.js +52 -0
- package/src/mcp.js +6 -6
- package/src/platform/android.js +968 -0
- package/src/platform/host.js +15 -0
- package/src/platform/index.js +248 -0
- package/src/{simctl.js → platform/ios.js} +111 -18
- package/src/screenmap.js +1 -1
- package/src/store.js +32 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Host-side tools, shared by every backend.
|
|
2
|
+
//
|
|
3
|
+
// `resize` was on the Phase 8 list of eleven platform functions and does not
|
|
4
|
+
// belong there: it downscales a PNG file with sips and never touches a device.
|
|
5
|
+
// Behind the platform boundary it would have to be declared once per backend,
|
|
6
|
+
// identically, for no reason. Here both backends get it and neither owns it.
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
/** Resample with sips, which ships with macOS, so simframe needs no image deps. */
|
|
13
|
+
export async function resize(inFile, outFile, maxDim) {
|
|
14
|
+
await run('sips', ['-Z', String(maxDim), inFile, '--out', outFile], { timeout: 10_000 });
|
|
15
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// The platform boundary.
|
|
2
|
+
//
|
|
3
|
+
// Above this line nothing knows what a simulator is. `src/index.js`,
|
|
4
|
+
// `daemon.js`, `actions.js`, `mcp.js` and `cli.js` used to import
|
|
5
|
+
// `src/simctl.js` directly — eleven functions and a constant, none of them
|
|
6
|
+
// conceptually iOS: list devices, resolve one, launch and terminate an app,
|
|
7
|
+
// open a URL, set the pasteboard, grant a permission, take a screenshot. Every
|
|
8
|
+
// one has an `adb` equivalent, which is exactly why the boundary had to exist
|
|
9
|
+
// before a second backend rather than beside it.
|
|
10
|
+
//
|
|
11
|
+
// The Swift half has had this since Phase 0: a 21-method `SimulatorPlatform`
|
|
12
|
+
// protocol in `PrivateAPI`, with a stub implementation proving the protocol is
|
|
13
|
+
// satisfiable by something that is not a simulator. `PLATFORM_SURFACE` below is
|
|
14
|
+
// the same idea for JavaScript, and `test/unit.test.mjs` holds every registered
|
|
15
|
+
// backend to it.
|
|
16
|
+
//
|
|
17
|
+
// **Dispatch is by device, not by a process-wide default.** A device is iOS or
|
|
18
|
+
// Android; nothing about the host decides which. So `listDevices` unions the
|
|
19
|
+
// backends and stamps each record with the platform it came from, and every
|
|
20
|
+
// function that takes a udid routes on that udid — `ownsUdid` answers that from
|
|
21
|
+
// the id's own shape, because the question is asked from inside a capture loop
|
|
22
|
+
// in a process that never listed anything.
|
|
23
|
+
import { platform as android } from './android.js';
|
|
24
|
+
import { platform as ios } from './ios.js';
|
|
25
|
+
|
|
26
|
+
export { resize } from './host.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} Device
|
|
30
|
+
* @property {string} udid
|
|
31
|
+
* @property {string} name
|
|
32
|
+
* @property {string} runtime
|
|
33
|
+
* @property {string} state 'Booted' when the device can be driven
|
|
34
|
+
* @property {string} platform stamped by the seam, never by the backend
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} Platform
|
|
39
|
+
* @property {string} id
|
|
40
|
+
* @property {string} deviceNoun what to call a device in user-facing text
|
|
41
|
+
* @property {(opts?: object) => Promise<Device[]>} listDevices
|
|
42
|
+
* @property {(opts?: object) => Promise<Device[]>} bootedDevices
|
|
43
|
+
* @property {(query?: string, opts?: object) => Promise<Device>} resolveDevice
|
|
44
|
+
* @property {(udid: string) => boolean} isBootedSync
|
|
45
|
+
* @property {(udid: string) => boolean} ownsUdid sync, no I/O — see ios.js
|
|
46
|
+
* @property {Function} geometry the device's real point size, or null if this backend cannot say
|
|
47
|
+
* @property {Function} inputDriver this backend's own input path, or null if input comes from above
|
|
48
|
+
* @property {Function} screenshot
|
|
49
|
+
* @property {Function} launchApp
|
|
50
|
+
* @property {Function} terminateApp
|
|
51
|
+
* @property {Function} openUrl
|
|
52
|
+
* @property {Function} setPermission
|
|
53
|
+
* @property {Function} setPasteboard
|
|
54
|
+
* @property {() => string[]} permissionServices
|
|
55
|
+
* @property {() => {captureEngines: string[], input: object, ax: object}} capabilities
|
|
56
|
+
* @property {() => Array<{name: string, level: string, detail: string}>} toolchain
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** Every member a backend must provide. A backend missing one fails a test, not a user. */
|
|
60
|
+
export const PLATFORM_SURFACE = Object.freeze([
|
|
61
|
+
'id', 'deviceNoun',
|
|
62
|
+
'listDevices', 'bootedDevices', 'resolveDevice', 'isBootedSync', 'ownsUdid',
|
|
63
|
+
'geometry', 'inputDriver',
|
|
64
|
+
'screenshot', 'launchApp', 'terminateApp', 'openUrl',
|
|
65
|
+
'setPermission', 'setPasteboard', 'permissionServices', 'capabilities', 'toolchain',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** @type {Record<string, Platform>} */
|
|
69
|
+
export const PLATFORMS = Object.freeze({ ios, android });
|
|
70
|
+
|
|
71
|
+
/** @returns {Platform[]} in registration order, which is the order devices are listed in. */
|
|
72
|
+
export function backends() {
|
|
73
|
+
return Object.values(PLATFORMS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// udid → platform id, learned from every listing and every resolve. A device
|
|
77
|
+
// cannot change platform, so this never goes stale and never needs clearing.
|
|
78
|
+
const learned = new Map();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Which backend owns this device.
|
|
82
|
+
*
|
|
83
|
+
* Synchronous by requirement: `isBootedSync` is called from the capture loop,
|
|
84
|
+
* in a daemon process that was handed a udid and never listed anything. So the
|
|
85
|
+
* answer comes from what a listing already taught us, or from the id's own
|
|
86
|
+
* shape, and never from the device.
|
|
87
|
+
*
|
|
88
|
+
* With one backend registered an unrecognised id still routes there, so a
|
|
89
|
+
* typo'd udid gets simctl's own error rather than one from the seam — which is
|
|
90
|
+
* what it got before the seam existed.
|
|
91
|
+
*
|
|
92
|
+
* @returns {Platform}
|
|
93
|
+
*/
|
|
94
|
+
export function platformFor(udid) {
|
|
95
|
+
return chooseBackend(udid, backends());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The routing decision itself, over a given set of backends, so it can be
|
|
100
|
+
* tested against two of them before a second one exists. Everything here is a
|
|
101
|
+
* decision about *which* backend; nothing here talks to a device.
|
|
102
|
+
*/
|
|
103
|
+
export function chooseBackend(udid, all) {
|
|
104
|
+
const remembered = learned.get(udid);
|
|
105
|
+
const known = all.find((b) => b.id === remembered);
|
|
106
|
+
if (known) return known;
|
|
107
|
+
const claimed = all.filter((b) => b.ownsUdid(udid));
|
|
108
|
+
if (claimed.length === 1) return claimed[0];
|
|
109
|
+
if (claimed.length === 0 && all.length === 1) return all[0];
|
|
110
|
+
throw new Error(
|
|
111
|
+
claimed.length > 1
|
|
112
|
+
? `device id "${udid}" is claimed by ${claimed.map((b) => b.id).join(' and ')}`
|
|
113
|
+
: `no platform recognises the device id "${udid}" (tried ${all.map((b) => b.id).join(', ')})`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stamp the platform onto a record without mutating a backend's cached copy. */
|
|
118
|
+
function stamp(device, backend) {
|
|
119
|
+
learned.set(device.udid, backend.id);
|
|
120
|
+
return { ...device, platform: backend.id };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every device every backend can see, in registration order. @returns {Promise<Device[]>} */
|
|
124
|
+
export async function listDevices(opts) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const backend of backends()) {
|
|
127
|
+
for (const device of await backend.listDevices(opts)) out.push(stamp(device, backend));
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @returns {Promise<Device[]>} */
|
|
133
|
+
export async function bootedDevices(opts) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const backend of backends()) {
|
|
136
|
+
for (const device of await backend.bootedDevices(opts)) out.push(stamp(device, backend));
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve a device string against every backend.
|
|
143
|
+
*
|
|
144
|
+
* A backend that finds nothing is passed over. A backend that finds the query
|
|
145
|
+
* *ambiguous* is not: that error wins even when another backend matched
|
|
146
|
+
* exactly, because answering an ambiguous query with the other platform's
|
|
147
|
+
* device is the wrong-device bug wearing a different hat.
|
|
148
|
+
*
|
|
149
|
+
* Cross-platform ambiguity — one name matching a simulator and an emulator — is
|
|
150
|
+
* reported rather than guessed at. Which of them a bare query should prefer is
|
|
151
|
+
* a question for the step that adds the second backend, not one to invent an
|
|
152
|
+
* answer to here.
|
|
153
|
+
*
|
|
154
|
+
* @returns {Promise<Device>}
|
|
155
|
+
*/
|
|
156
|
+
export async function resolveDevice(query, opts) {
|
|
157
|
+
return resolveAcross(query, opts, backends());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** As above, over a given set of backends — the testable half. @returns {Promise<Device>} */
|
|
161
|
+
export async function resolveAcross(query, opts, all) {
|
|
162
|
+
const hits = [];
|
|
163
|
+
const misses = [];
|
|
164
|
+
for (const backend of all) {
|
|
165
|
+
try {
|
|
166
|
+
hits.push(stamp(await backend.resolveDevice(query, opts), backend));
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err?.ambiguous) throw err;
|
|
169
|
+
misses.push(err);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (hits.length === 1) return hits[0];
|
|
173
|
+
if (hits.length > 1) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`"${query}" matches a device on more than one platform: ` +
|
|
176
|
+
`${hits.map((d) => `${d.name} (${d.platform})`).join(', ')} — name one by its id`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
// One backend: its own message, unchanged. It is the better message, because
|
|
180
|
+
// it knows what it looked in.
|
|
181
|
+
if (misses.length === 1) throw misses[0];
|
|
182
|
+
throw new Error(misses.map((e) => e.message).join('; '));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Device-keyed dispatch. The udid is passed through as well as routed on:
|
|
186
|
+
// backends keep the signatures they always had, and the seam decides which
|
|
187
|
+
// backend a call reaches, never what the call means.
|
|
188
|
+
export const isBootedSync = (udid, ...args) => platformFor(udid).isBootedSync(udid, ...args);
|
|
189
|
+
export const screenshot = (udid, ...args) => platformFor(udid).screenshot(udid, ...args);
|
|
190
|
+
export const launchApp = (udid, ...args) => platformFor(udid).launchApp(udid, ...args);
|
|
191
|
+
export const terminateApp = (udid, ...args) => platformFor(udid).terminateApp(udid, ...args);
|
|
192
|
+
export const openUrl = (udid, ...args) => platformFor(udid).openUrl(udid, ...args);
|
|
193
|
+
export const setPermission = (udid, ...args) => platformFor(udid).setPermission(udid, ...args);
|
|
194
|
+
export const setPasteboard = (udid, ...args) => platformFor(udid).setPasteboard(udid, ...args);
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The permission services a device understands, or every service any backend
|
|
198
|
+
* understands when no device is named.
|
|
199
|
+
*
|
|
200
|
+
* simctl's list has no Android meaning and Android's has no iOS meaning, so the
|
|
201
|
+
* unioned form is only honest as a menu — `setPermission` validates against the
|
|
202
|
+
* backend that will actually run it. `docs/DEFERRED.md` has the open question:
|
|
203
|
+
* the two platforms do not have the same permissions, and the MCP tool
|
|
204
|
+
* description is written once, before any device is chosen.
|
|
205
|
+
*/
|
|
206
|
+
export function permissionServices(udid) {
|
|
207
|
+
if (udid !== undefined) return platformFor(udid).permissionServices();
|
|
208
|
+
const all = [];
|
|
209
|
+
for (const backend of backends()) {
|
|
210
|
+
for (const service of backend.permissionServices()) if (!all.includes(service)) all.push(service);
|
|
211
|
+
}
|
|
212
|
+
return all;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The device's real point size and scale, or null when the backend cannot say
|
|
217
|
+
* and something above the boundary has to.
|
|
218
|
+
*
|
|
219
|
+
* Android's only source for this is the backend, and without it the geometry
|
|
220
|
+
* fell through to a guess derived from the capture image — an emulator reported
|
|
221
|
+
* "393x700pt", which is the ring size and not any coordinate space the device
|
|
222
|
+
* knows. Tap points computed from that are wrong, and nothing says so.
|
|
223
|
+
*/
|
|
224
|
+
export const geometryFor = (udid) => platformFor(udid).geometry(udid);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The backend's own input path, or null when input comes from above the
|
|
228
|
+
* boundary. iOS is null: Indigo HID lives in the daemon, which is simframe's
|
|
229
|
+
* engine rather than the platform's. Android is the emulator console.
|
|
230
|
+
*/
|
|
231
|
+
export const inputDriverFor = (udid) => platformFor(udid).inputDriver(udid);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* What a device's platform can currently do: which capture engines it has, and
|
|
235
|
+
* whether input and the accessibility tree are implemented for it at all.
|
|
236
|
+
*
|
|
237
|
+
* This exists because doctor, asked about an Android emulator, reported "input
|
|
238
|
+
* driver: idb" and "accessibility tree: idb" — a claim about a tool that has
|
|
239
|
+
* never spoken to an Android device. A layer above the boundary must not
|
|
240
|
+
* describe a device in the other platform's terms, and the only way it can
|
|
241
|
+
* avoid that is to ask.
|
|
242
|
+
*/
|
|
243
|
+
export const capabilitiesFor = (udid) => platformFor(udid).capabilities();
|
|
244
|
+
|
|
245
|
+
/** What `simframe doctor` should check: each registered backend's own toolchain. */
|
|
246
|
+
export function toolchainChecks() {
|
|
247
|
+
return backends().flatMap((backend) => backend.toolchain());
|
|
248
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The iOS backend: everything that shells out to Xcode's command line tools.
|
|
2
|
+
//
|
|
3
|
+
// Nothing outside src/platform/ may import this file. Callers go through
|
|
4
|
+
// src/platform/index.js, which is why every function here is module-private
|
|
5
|
+
// and reachable only through the `platform` object at the bottom — the
|
|
6
|
+
// JavaScript counterpart of the `SimulatorPlatform` protocol in Swift.
|
|
2
7
|
import { execFile, execFileSync } from 'node:child_process';
|
|
3
8
|
import { promisify } from 'node:util';
|
|
4
9
|
|
|
@@ -10,7 +15,7 @@ const DEVICE_CACHE_MS = 4000;
|
|
|
10
15
|
let deviceCache = { at: 0, devices: null, inflight: null };
|
|
11
16
|
|
|
12
17
|
/** @returns {Promise<Array<{udid: string, name: string, runtime: string, state: string}>>} */
|
|
13
|
-
|
|
18
|
+
async function listDevices({ maxAgeMs = DEVICE_CACHE_MS } = {}) {
|
|
14
19
|
if (deviceCache.devices && Date.now() - deviceCache.at <= maxAgeMs) return deviceCache.devices;
|
|
15
20
|
if (deviceCache.inflight) return deviceCache.inflight;
|
|
16
21
|
deviceCache.inflight = fetchDevices()
|
|
@@ -47,7 +52,7 @@ async function fetchDevices() {
|
|
|
47
52
|
return out;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
async function bootedDevices(opts) {
|
|
51
56
|
return (await listDevices(opts)).filter((d) => d.state === 'Booted');
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -56,7 +61,7 @@ export async function bootedDevices(opts) {
|
|
|
56
61
|
* booted device. Prefers booted devices; falls back to a clear error listing
|
|
57
62
|
* what is actually available.
|
|
58
63
|
*/
|
|
59
|
-
|
|
64
|
+
async function resolveDevice(query, opts) {
|
|
60
65
|
const all = await listDevices(opts);
|
|
61
66
|
const booted = all.filter((d) => d.state === 'Booted');
|
|
62
67
|
if (!query) {
|
|
@@ -71,13 +76,19 @@ export async function resolveDevice(query, opts) {
|
|
|
71
76
|
const partial = pool.filter((d) => d.name.toLowerCase().includes(q));
|
|
72
77
|
if (partial.length === 1) return partial[0];
|
|
73
78
|
if (partial.length > 1) {
|
|
74
|
-
|
|
79
|
+
// Marked, because the seam asks every backend to resolve and must not
|
|
80
|
+
// answer with an Android device when the query was ambiguous here. A
|
|
81
|
+
// plain "no match" may be passed over; an ambiguity may not.
|
|
82
|
+
throw Object.assign(
|
|
83
|
+
new Error(`"${query}" matches ${partial.length} devices: ${partial.map((d) => d.name).join(', ')}`),
|
|
84
|
+
{ ambiguous: true },
|
|
85
|
+
);
|
|
75
86
|
}
|
|
76
87
|
}
|
|
77
88
|
throw new Error(`no simulator matches "${query}"; booted: ${booted.map((d) => d.name).join(', ') || 'none'}`);
|
|
78
89
|
}
|
|
79
90
|
|
|
80
|
-
|
|
91
|
+
function isBootedSync(udid) {
|
|
81
92
|
try {
|
|
82
93
|
const out = execFileSync('xcrun', ['simctl', 'list', 'devices', '--json'], {
|
|
83
94
|
maxBuffer: 8 << 20,
|
|
@@ -95,7 +106,7 @@ export function isBootedSync(udid) {
|
|
|
95
106
|
return false;
|
|
96
107
|
}
|
|
97
108
|
|
|
98
|
-
|
|
109
|
+
async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
|
|
99
110
|
try {
|
|
100
111
|
await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
|
|
101
112
|
timeout: 10_000,
|
|
@@ -109,11 +120,6 @@ export async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
|
|
|
109
120
|
}
|
|
110
121
|
}
|
|
111
122
|
|
|
112
|
-
/** Resample with sips, which ships with macOS, so simframe needs no image deps. */
|
|
113
|
-
export async function resize(inFile, outFile, maxDim) {
|
|
114
|
-
await run('sips', ['-Z', String(maxDim), inFile, '--out', outFile], { timeout: 10_000 });
|
|
115
|
-
}
|
|
116
|
-
|
|
117
123
|
/**
|
|
118
124
|
* Launch, optionally with arguments and environment.
|
|
119
125
|
*
|
|
@@ -121,7 +127,7 @@ export async function resize(inFile, outFile, maxDim) {
|
|
|
121
127
|
* `SIMCTL_CHILD_`-prefixed variables of its own process — which is why env has
|
|
122
128
|
* to be set on the child rather than passed as flags.
|
|
123
129
|
*/
|
|
124
|
-
|
|
130
|
+
async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst = false } = {}) {
|
|
125
131
|
if (terminateFirst) {
|
|
126
132
|
// A launch against an already-running app is a no-op that reports success,
|
|
127
133
|
// which is how a flow "relaunched" an app and tested the screen it was
|
|
@@ -148,15 +154,15 @@ export async function launchApp(udid, bundleId, { args = [], env = {}, terminate
|
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
156
|
|
|
151
|
-
|
|
157
|
+
async function terminateApp(udid, bundleId) {
|
|
152
158
|
await run('xcrun', ['simctl', 'terminate', udid, bundleId], { timeout: 20_000 });
|
|
153
159
|
}
|
|
154
160
|
|
|
155
|
-
|
|
161
|
+
async function openUrl(udid, url) {
|
|
156
162
|
await run('xcrun', ['simctl', 'openurl', udid, url], { timeout: 20_000 });
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
|
|
165
|
+
const PERMISSION_SERVICES = [
|
|
160
166
|
'all', 'calendar', 'contacts-limited', 'contacts', 'location', 'location-always',
|
|
161
167
|
'photos-add', 'photos', 'media-library', 'microphone', 'motion', 'reminders', 'siri',
|
|
162
168
|
];
|
|
@@ -168,7 +174,7 @@ export const PERMISSION_SERVICES = [
|
|
|
168
174
|
* tapping a system alert, and a system alert is not part of the app under test:
|
|
169
175
|
* its buttons move between iOS versions and its appearance is a race.
|
|
170
176
|
*/
|
|
171
|
-
|
|
177
|
+
async function setPermission(udid, action, service, bundleId) {
|
|
172
178
|
const verb = String(action).toLowerCase();
|
|
173
179
|
if (!['grant', 'revoke', 'reset'].includes(verb)) {
|
|
174
180
|
throw new Error(`permission action must be grant, revoke or reset (got "${action}")`);
|
|
@@ -188,7 +194,7 @@ export async function setPermission(udid, action, service, bundleId) {
|
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
/** Put text on the device pasteboard — far faster than typing a long string. */
|
|
191
|
-
|
|
197
|
+
async function setPasteboard(udid, value) {
|
|
192
198
|
const child = execFile('xcrun', ['simctl', 'pbcopy', udid], { timeout: 10_000 });
|
|
193
199
|
child.stdin.end(value);
|
|
194
200
|
await new Promise((resolve, reject) => {
|
|
@@ -196,3 +202,90 @@ export async function setPasteboard(udid, value) {
|
|
|
196
202
|
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`pbcopy exited ${code}`))));
|
|
197
203
|
});
|
|
198
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Does this backend own that device id, judged without touching the device?
|
|
208
|
+
*
|
|
209
|
+
* The routing question is asked from inside a capture loop and from a daemon
|
|
210
|
+
* process that never resolved the device itself, so it has to be answered
|
|
211
|
+
* synchronously and for free. A simulator udid is a UUID; an emulator serial
|
|
212
|
+
* (`emulator-5554`) is not, and cannot be mistaken for one.
|
|
213
|
+
*/
|
|
214
|
+
function ownsUdid(udid) {
|
|
215
|
+
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 ?? ''));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The prerequisites `simframe doctor` reports for this backend. Returned rather
|
|
220
|
+
* than printed so doctor stays one renderer: a backend says what it needs, and
|
|
221
|
+
* a machine missing it is told which tool, not which platform.
|
|
222
|
+
*/
|
|
223
|
+
function toolchain() {
|
|
224
|
+
try {
|
|
225
|
+
const version = execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0];
|
|
226
|
+
return [{ name: 'xcrun', level: 'ok', detail: version }];
|
|
227
|
+
} catch (err) {
|
|
228
|
+
return [{ name: 'xcrun', level: 'fail', detail: err.message }];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* iOS geometry does not come from here.
|
|
234
|
+
*
|
|
235
|
+
* The daemon holds the device's own point size and scale and is both
|
|
236
|
+
* authoritative and free, and idb can answer when the daemon cannot. Both sit
|
|
237
|
+
* above this boundary, so this backend has nothing to add — and returning null
|
|
238
|
+
* says that, where a guess would have been believed.
|
|
239
|
+
*/
|
|
240
|
+
function geometry() {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Nor does iOS input.
|
|
246
|
+
*
|
|
247
|
+
* It is Indigo HID inside `simframed`, reached over the control socket: that is
|
|
248
|
+
* simframe's own engine, not something the platform provides. Android's input
|
|
249
|
+
* *is* the platform's — the emulator console — which is why this is a question
|
|
250
|
+
* a backend gets asked at all.
|
|
251
|
+
*/
|
|
252
|
+
function inputDriver() {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* What this backend can currently do, so nothing above the boundary has to
|
|
258
|
+
* assume. iOS has both capture engines, input through Indigo HID and the
|
|
259
|
+
* accessibility tree through AXPTranslator — which is to say, everything, and
|
|
260
|
+
* that is exactly why the shape of this was invisible until a second backend
|
|
261
|
+
* turned up without it.
|
|
262
|
+
*/
|
|
263
|
+
function capabilities() {
|
|
264
|
+
return {
|
|
265
|
+
captureEngines: ['simframed', 'screenshot'],
|
|
266
|
+
input: { supported: true, via: 'daemon' },
|
|
267
|
+
ax: { supported: true },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @type {import('./index.js').Platform} */
|
|
272
|
+
export const platform = {
|
|
273
|
+
id: 'ios',
|
|
274
|
+
deviceNoun: 'simulator',
|
|
275
|
+
listDevices,
|
|
276
|
+
bootedDevices,
|
|
277
|
+
resolveDevice,
|
|
278
|
+
isBootedSync,
|
|
279
|
+
ownsUdid,
|
|
280
|
+
geometry,
|
|
281
|
+
inputDriver,
|
|
282
|
+
screenshot,
|
|
283
|
+
launchApp,
|
|
284
|
+
terminateApp,
|
|
285
|
+
openUrl,
|
|
286
|
+
setPermission,
|
|
287
|
+
setPasteboard,
|
|
288
|
+
permissionServices: () => PERMISSION_SERVICES,
|
|
289
|
+
capabilities,
|
|
290
|
+
toolchain,
|
|
291
|
+
};
|
package/src/screenmap.js
CHANGED
|
@@ -16,7 +16,7 @@ import * as regions from './regions.js';
|
|
|
16
16
|
import { informative } from './refs.js';
|
|
17
17
|
import * as store from './store.js';
|
|
18
18
|
|
|
19
|
-
const MAP_VERSION =
|
|
19
|
+
const MAP_VERSION = 7; // footprintless elements and containers no longer enter identity
|
|
20
20
|
|
|
21
21
|
function mapDir(udid) {
|
|
22
22
|
return path.join(store.deviceDir(udid), 'screens');
|
package/src/store.js
CHANGED
|
@@ -30,9 +30,41 @@ export function paths(udid) {
|
|
|
30
30
|
heartbeat: path.join(dir, 'heartbeat'),
|
|
31
31
|
log: path.join(dir, 'daemon.log'),
|
|
32
32
|
lock: path.join(dir, 'daemon.lock'),
|
|
33
|
+
// Written by whichever capture loop is running, and only when capture is
|
|
34
|
+
// wedged. It cannot ride in state.json: that is written when a frame is
|
|
35
|
+
// recorded, and a stall is the absence of frames.
|
|
36
|
+
captureHealth: path.join(dir, 'capture-health.json'),
|
|
33
37
|
};
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
/** What the capture loop last said about its own health, or null if it has no complaint. */
|
|
41
|
+
export function captureHealth(udid) {
|
|
42
|
+
return readJson(paths(udid).captureHealth);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Publish a complaint, or clear it with `null`.
|
|
47
|
+
*
|
|
48
|
+
* The directory is created rather than assumed. A capture loop has always made
|
|
49
|
+
* it already, so the first version of this left it out and swallowed the
|
|
50
|
+
* failure — which meant the write silently did nothing for every other caller,
|
|
51
|
+
* and the test that caught it was the first thing to ask.
|
|
52
|
+
*/
|
|
53
|
+
export function writeCaptureHealth(udid, health) {
|
|
54
|
+
const p = paths(udid);
|
|
55
|
+
try {
|
|
56
|
+
if (health) {
|
|
57
|
+
fs.mkdirSync(p.dir, { recursive: true });
|
|
58
|
+
writeAtomic(p.captureHealth, JSON.stringify(health));
|
|
59
|
+
} else {
|
|
60
|
+
fs.rmSync(p.captureHealth, { force: true });
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Never let bookkeeping stop capture. Anything that reaches here has
|
|
64
|
+
// already failed to make a directory, which capture itself will report.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
36
68
|
export function ensureDirs(udid) {
|
|
37
69
|
const p = paths(udid);
|
|
38
70
|
fs.mkdirSync(p.ring, { recursive: true });
|