simframe 0.6.2 → 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/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 +12 -0
- 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 +29 -1
- 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/store.js +32 -0
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
// The Android backend: everything that shells out to the Android SDK, plus the
|
|
2
|
+
// emulator's own console socket where that is faster than adb.
|
|
3
|
+
//
|
|
4
|
+
// Nothing outside src/platform/ may import this file. Callers go through
|
|
5
|
+
// src/platform/index.js — see the note there about why every function here is
|
|
6
|
+
// module-private and reachable only through the `platform` object at the bottom.
|
|
7
|
+
//
|
|
8
|
+
// Measured on this machine (M-series, Android 16 / API 36, Small_Phone_API_36,
|
|
9
|
+
// 720x1280 @320dpi), medians of 5-7 runs — the numbers that shaped the choices
|
|
10
|
+
// below, and the reason two of them are not the obvious command:
|
|
11
|
+
//
|
|
12
|
+
// emulator console `screenrecord screenshot <dir>` 20 ms, written host-side
|
|
13
|
+
// adb exec-out screencap -p 113 ms, 9 KB over adb
|
|
14
|
+
// adb exec-out screencap (raw RGBA, 3.7 MB) 218 ms — the transfer, not the encode
|
|
15
|
+
// adb shell getprop x4 in one hop 28 ms
|
|
16
|
+
// adb shell dumpsys package <pkg> 130 ms
|
|
17
|
+
// uiautomator dump 2,012 ms ← see docs/DEFERRED.md
|
|
18
|
+
//
|
|
19
|
+
// The console path wins because the emulator writes the PNG to the host
|
|
20
|
+
// filesystem itself: there is no device-to-host transfer at all. adb screencap
|
|
21
|
+
// stays as the fallback for the case where the console is unreachable.
|
|
22
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import http2 from 'node:http2';
|
|
25
|
+
import net from 'node:net';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
29
|
+
|
|
30
|
+
const run = promisify(execFile);
|
|
31
|
+
|
|
32
|
+
// Same reasoning as the iOS backend: a listing costs enough to dominate a warm
|
|
33
|
+
// read, so the parsed list is cached for a few seconds.
|
|
34
|
+
const DEVICE_CACHE_MS = 4000;
|
|
35
|
+
let deviceCache = { at: 0, devices: null, inflight: null };
|
|
36
|
+
|
|
37
|
+
let adbCache = null;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where adb is.
|
|
41
|
+
*
|
|
42
|
+
* PATH first, because a developer who put it there meant it. Then the two
|
|
43
|
+
* environment variables Google has used, then the default install location on
|
|
44
|
+
* macOS. Resolved once: this is called on every device operation.
|
|
45
|
+
*/
|
|
46
|
+
function adbPath() {
|
|
47
|
+
if (adbCache) return adbCache;
|
|
48
|
+
const candidates = [];
|
|
49
|
+
const roots = [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT, path.join(os.homedir(), 'Library/Android/sdk')];
|
|
50
|
+
try {
|
|
51
|
+
candidates.push(execFileSync('command', ['-v', 'adb'], { encoding: 'utf8', shell: true }).trim());
|
|
52
|
+
} catch {
|
|
53
|
+
/* not on PATH; the SDK locations below are the usual case */
|
|
54
|
+
}
|
|
55
|
+
for (const root of roots) if (root) candidates.push(path.join(root, 'platform-tools', 'adb'));
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
if (candidate && fs.existsSync(candidate)) {
|
|
58
|
+
adbCache = candidate;
|
|
59
|
+
return adbCache;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw new Error(
|
|
63
|
+
'adb not found — install Android platform-tools, or set ANDROID_HOME to your SDK ' +
|
|
64
|
+
'(looked on PATH and in platform-tools/ under ANDROID_HOME, ANDROID_SDK_ROOT and ~/Library/Android/sdk)',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One adb invocation. `serial` is null for commands that are not about a device. */
|
|
69
|
+
async function adb(serial, args, opts = {}) {
|
|
70
|
+
const argv = serial ? ['-s', serial, ...args] : args;
|
|
71
|
+
return run(adbPath(), argv, { timeout: 20_000, maxBuffer: 8 << 20, ...opts });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** adb's own message, which is in stderr, rather than execFile's "Command failed". */
|
|
75
|
+
function detailOf(err) {
|
|
76
|
+
const stderr = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
|
|
77
|
+
return stderr || err.message;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @returns {Promise<Array<{udid: string, name: string, runtime: string, state: string}>>} */
|
|
81
|
+
async function listDevices({ maxAgeMs = DEVICE_CACHE_MS } = {}) {
|
|
82
|
+
if (deviceCache.devices && Date.now() - deviceCache.at <= maxAgeMs) return deviceCache.devices;
|
|
83
|
+
if (deviceCache.inflight) return deviceCache.inflight;
|
|
84
|
+
deviceCache.inflight = fetchDevices()
|
|
85
|
+
.then((devices) => {
|
|
86
|
+
deviceCache = { at: Date.now(), devices, inflight: null };
|
|
87
|
+
return devices;
|
|
88
|
+
})
|
|
89
|
+
.catch((err) => {
|
|
90
|
+
deviceCache.inflight = null;
|
|
91
|
+
throw err;
|
|
92
|
+
});
|
|
93
|
+
return deviceCache.inflight;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function fetchDevices() {
|
|
97
|
+
let stdout = '';
|
|
98
|
+
try {
|
|
99
|
+
({ stdout } = await adb(null, ['devices', '-l']));
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// No adb, or no adb server: no Android devices, which is not an error on a
|
|
102
|
+
// machine that only has simulators. A missing toolchain is doctor's business
|
|
103
|
+
// (see `toolchain` below), not something that should break `simframe devices`.
|
|
104
|
+
if (/adb not found/.test(err.message)) return [];
|
|
105
|
+
throw new Error(`adb devices failed: ${detailOf(err)}`);
|
|
106
|
+
}
|
|
107
|
+
const serials = [];
|
|
108
|
+
for (const line of stdout.split('\n').slice(1)) {
|
|
109
|
+
const [serial, state] = line.trim().split(/\s+/);
|
|
110
|
+
if (serial && state) serials.push({ serial, adbState: state });
|
|
111
|
+
}
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const { serial, adbState } of serials) {
|
|
114
|
+
// Every property in one hop. The iOS a11y read taught this the expensive
|
|
115
|
+
// way: eight attributes in one call instead of eight calls was 112 hops
|
|
116
|
+
// down to 14, and the same arithmetic applies to a device shell.
|
|
117
|
+
let props = [];
|
|
118
|
+
if (adbState === 'device') {
|
|
119
|
+
try {
|
|
120
|
+
const { stdout: raw } = await adb(serial, [
|
|
121
|
+
'shell',
|
|
122
|
+
'getprop ro.boot.qemu.avd_name; getprop ro.product.model; ' +
|
|
123
|
+
'getprop ro.build.version.release; getprop ro.build.version.sdk; getprop sys.boot_completed',
|
|
124
|
+
]);
|
|
125
|
+
props = raw.replace(/\r/g, '').split('\n');
|
|
126
|
+
} catch {
|
|
127
|
+
/* the device answered `adb devices` and not a shell: treat it as offline */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const [avdName, model, release, sdk, bootCompleted] = props;
|
|
131
|
+
out.push({
|
|
132
|
+
udid: serial,
|
|
133
|
+
name: avdName || model || serial,
|
|
134
|
+
runtime: release ? `Android ${release} (API ${sdk})` : 'Android',
|
|
135
|
+
// A device that is present but has not finished booting is not one a flow
|
|
136
|
+
// may be pointed at, and `Booting` says which of the two it is rather
|
|
137
|
+
// than flattening both to "not booted".
|
|
138
|
+
state: adbState !== 'device' ? 'Shutdown' : bootCompleted?.trim() === '1' ? 'Booted' : 'Booting',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function bootedDevices(opts) {
|
|
145
|
+
return (await listDevices(opts)).filter((d) => d.state === 'Booted');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** An AVD name is `Small_Phone_API_36`; nobody wants to type the underscores. */
|
|
149
|
+
const loose = (s) => String(s ?? '').toLowerCase().replace(/[\s_]+/g, ' ').trim();
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a user-supplied device string (serial, AVD name, or substring) to one
|
|
153
|
+
* booted device. Same shape as the iOS backend, including the marked ambiguity
|
|
154
|
+
* error the seam relies on to refuse rather than guess.
|
|
155
|
+
*/
|
|
156
|
+
async function resolveDevice(query, opts) {
|
|
157
|
+
const all = await listDevices(opts);
|
|
158
|
+
const booted = all.filter((d) => d.state === 'Booted');
|
|
159
|
+
if (!query) {
|
|
160
|
+
if (booted.length === 0) {
|
|
161
|
+
const booting = all.filter((d) => d.state === 'Booting');
|
|
162
|
+
throw new Error(
|
|
163
|
+
booting.length
|
|
164
|
+
? `no booted emulator yet — ${booting.map((d) => d.name).join(', ')} is still starting`
|
|
165
|
+
: 'no booted emulator (start one with `emulator -avd <name>`)',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return booted[0];
|
|
169
|
+
}
|
|
170
|
+
const q = loose(query);
|
|
171
|
+
for (const pool of [booted, all]) {
|
|
172
|
+
const exact = pool.find((d) => loose(d.udid) === q || loose(d.name) === q);
|
|
173
|
+
if (exact) return exact;
|
|
174
|
+
const partial = pool.filter((d) => loose(d.name).includes(q));
|
|
175
|
+
if (partial.length === 1) return partial[0];
|
|
176
|
+
if (partial.length > 1) {
|
|
177
|
+
throw Object.assign(
|
|
178
|
+
new Error(`"${query}" matches ${partial.length} devices: ${partial.map((d) => d.name).join(', ')}`),
|
|
179
|
+
{ ambiguous: true },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw new Error(`no emulator matches "${query}"; booted: ${booted.map((d) => d.name).join(', ') || 'none'}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isBootedSync(udid) {
|
|
187
|
+
try {
|
|
188
|
+
const state = execFileSync(adbPath(), ['-s', udid, 'get-state'], { encoding: 'utf8', timeout: 10_000 }).trim();
|
|
189
|
+
return state === 'device';
|
|
190
|
+
} catch {
|
|
191
|
+
/* same convention as the iOS backend: an unreadable state is not a death */
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Does this backend own that device id, judged without touching the device?
|
|
198
|
+
*
|
|
199
|
+
* Only emulators, and only the serial the emulator itself uses:
|
|
200
|
+
* `emulator-<console port>`. Deliberately narrow — physical devices are a
|
|
201
|
+
* non-goal, and a backend that claimed every non-UUID string would swallow a
|
|
202
|
+
* mistyped simulator udid and report it as a missing emulator.
|
|
203
|
+
*/
|
|
204
|
+
function ownsUdid(udid) {
|
|
205
|
+
return /^emulator-\d+$/.test(String(udid ?? ''));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- the emulator console --------------------------------------------------
|
|
209
|
+
//
|
|
210
|
+
// `emulator-5554` means console port 5554. The token in
|
|
211
|
+
// ~/.emulator_console_auth_token is what the emulator wrote there for whoever
|
|
212
|
+
// can read the file; it is not a secret of ours to handle, and it never leaves
|
|
213
|
+
// this process.
|
|
214
|
+
|
|
215
|
+
function consolePort(udid) {
|
|
216
|
+
const port = /^emulator-(\d+)$/.exec(String(udid))?.[1];
|
|
217
|
+
return port ? Number(port) : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function consoleToken() {
|
|
221
|
+
return fs.readFileSync(path.join(os.homedir(), '.emulator_console_auth_token'), 'utf8').trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The emulator console, held open per device.
|
|
226
|
+
*
|
|
227
|
+
* The console is a line protocol: every command is answered with `OK` or `KO`,
|
|
228
|
+
* and the greeting is itself terminated by an `OK`, so the handshake is two
|
|
229
|
+
* responses — the greeting, then the answer to `auth` — after which it is
|
|
230
|
+
* strictly request/response.
|
|
231
|
+
*
|
|
232
|
+
* Held open rather than opened per command, for two reasons. The capture loop
|
|
233
|
+
* asks for a frame several times a second, and a connect plus an auth is not
|
|
234
|
+
* free. And a
|
|
235
|
+
* gesture is not one command — a tap is a down, a hold and an up, a swipe is a
|
|
236
|
+
* run of moves with time between them — and paying for a handshake between the
|
|
237
|
+
* down and the up would make the timing a fiction.
|
|
238
|
+
*
|
|
239
|
+
* Commands are serialised on the session. Two callers sharing one socket
|
|
240
|
+
* interleaving their writes would each read the other's `OK`.
|
|
241
|
+
*/
|
|
242
|
+
class ConsoleSession {
|
|
243
|
+
constructor(sock, port) {
|
|
244
|
+
this.sock = sock;
|
|
245
|
+
this.port = port;
|
|
246
|
+
this.buf = '';
|
|
247
|
+
this.waiting = null;
|
|
248
|
+
this.tail = Promise.resolve();
|
|
249
|
+
this.dead = null;
|
|
250
|
+
sock.setEncoding('utf8');
|
|
251
|
+
sock.on('data', (chunk) => {
|
|
252
|
+
this.buf += chunk;
|
|
253
|
+
const done = /(OK|KO)([^\n]*)\r?\n/.exec(this.buf);
|
|
254
|
+
if (!done || !this.waiting) return;
|
|
255
|
+
const text = this.buf;
|
|
256
|
+
this.buf = '';
|
|
257
|
+
const settle = this.waiting;
|
|
258
|
+
this.waiting = null;
|
|
259
|
+
if (done[1] === 'KO') settle.reject(new Error(`emulator console refused: ${done[2].trim() || 'KO'}`));
|
|
260
|
+
else settle.resolve(text);
|
|
261
|
+
});
|
|
262
|
+
const die = (err) => {
|
|
263
|
+
this.dead = err ?? new Error(`emulator console on ${port} closed`);
|
|
264
|
+
// A close with a command outstanding is that command failing, not it
|
|
265
|
+
// succeeding. Anything else is nobody's error to hear about: a session
|
|
266
|
+
// closed on purpose must not surface as an unhandled rejection, which is
|
|
267
|
+
// exactly what a stored `close` promise did.
|
|
268
|
+
if (this.waiting) {
|
|
269
|
+
const settle = this.waiting;
|
|
270
|
+
this.waiting = null;
|
|
271
|
+
settle.reject(this.dead);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
sock.on('error', die);
|
|
275
|
+
sock.on('close', () => die());
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
get usable() {
|
|
279
|
+
return !this.dead && !this.sock.destroyed;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Wait for one OK/KO. Used for the greeting, and by `send`. */
|
|
283
|
+
answer({ timeoutMs = 10_000 } = {}) {
|
|
284
|
+
if (this.dead) return Promise.reject(this.dead);
|
|
285
|
+
return new Promise((resolve, reject) => {
|
|
286
|
+
const timer = setTimeout(() => {
|
|
287
|
+
this.waiting = null;
|
|
288
|
+
this.sock.destroy();
|
|
289
|
+
reject(new Error(`emulator console on ${this.port} did not answer within ${timeoutMs}ms`));
|
|
290
|
+
}, timeoutMs);
|
|
291
|
+
this.waiting = {
|
|
292
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
293
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** One command, queued behind anything already in flight on this socket. */
|
|
299
|
+
send(line, opts) {
|
|
300
|
+
const mine = this.tail.then(async () => {
|
|
301
|
+
if (!this.usable) throw this.dead ?? new Error('emulator console is closed');
|
|
302
|
+
const answer = this.answer(opts);
|
|
303
|
+
this.sock.write(`${line}\n`);
|
|
304
|
+
return answer;
|
|
305
|
+
});
|
|
306
|
+
// The queue must survive a failed command, or one refusal wedges the
|
|
307
|
+
// session for every caller behind it.
|
|
308
|
+
this.tail = mine.then(() => undefined, () => undefined);
|
|
309
|
+
return mine;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
close() {
|
|
313
|
+
try {
|
|
314
|
+
this.sock.write('quit\n');
|
|
315
|
+
} catch {
|
|
316
|
+
/* already gone */
|
|
317
|
+
}
|
|
318
|
+
this.sock.destroy();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const sessions = new Map();
|
|
323
|
+
|
|
324
|
+
/** The open session for a device, reconnected if the last one went away. */
|
|
325
|
+
async function sessionFor(udid, { timeoutMs = 10_000 } = {}) {
|
|
326
|
+
const existing = sessions.get(udid);
|
|
327
|
+
if (existing) {
|
|
328
|
+
const session = await existing;
|
|
329
|
+
if (session.usable) return session;
|
|
330
|
+
sessions.delete(udid);
|
|
331
|
+
}
|
|
332
|
+
const opening = (async () => {
|
|
333
|
+
const port = consolePort(udid);
|
|
334
|
+
if (!port) throw new Error(`${udid} is not an emulator serial`);
|
|
335
|
+
const session = new ConsoleSession(net.connect(port, '127.0.0.1'), port);
|
|
336
|
+
await session.answer({ timeoutMs });
|
|
337
|
+
await session.send(`auth ${consoleToken()}`, { timeoutMs });
|
|
338
|
+
return session;
|
|
339
|
+
})();
|
|
340
|
+
sessions.set(udid, opening);
|
|
341
|
+
try {
|
|
342
|
+
return await opening;
|
|
343
|
+
} catch (err) {
|
|
344
|
+
sessions.delete(udid);
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Run commands on the device's console, in order. */
|
|
350
|
+
async function consoleScript(udid, lines, { timeoutMs = 10_000 } = {}) {
|
|
351
|
+
const session = await sessionFor(udid, { timeoutMs });
|
|
352
|
+
const out = [];
|
|
353
|
+
for (const line of lines) out.push(await session.send(line, { timeoutMs }));
|
|
354
|
+
return out.join('');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// --- the emulator's gRPC endpoint -------------------------------------------
|
|
358
|
+
//
|
|
359
|
+
// Reached with nothing but `node:http2`, because a unary gRPC call is a plain
|
|
360
|
+
// HTTP/2 POST: a five-byte frame header in front of the message, `grpc-status`
|
|
361
|
+
// in the trailers, and that is the whole protocol for this purpose. No
|
|
362
|
+
// dependency, which is what makes it usable here at all.
|
|
363
|
+
//
|
|
364
|
+
// It exists for one thing so far: the clipboard. `cmd clipboard` does not exist
|
|
365
|
+
// on API 36 and `service call clipboard` depends on transaction numbers that
|
|
366
|
+
// move between platform versions, so this was written up as "no path to the
|
|
367
|
+
// Android clipboard" until the emulator's own service definitions turned out to
|
|
368
|
+
// declare `setClipboard`, `getClipboard` and `streamClipboard`. `ClipData` is
|
|
369
|
+
// the simplest message protobuf can express — one string field — so the encoder
|
|
370
|
+
// below is three lines rather than a library.
|
|
371
|
+
|
|
372
|
+
/** Where a running emulator writes its own port and token. */
|
|
373
|
+
function runningAvdDirs() {
|
|
374
|
+
return [
|
|
375
|
+
path.join(os.homedir(), 'Library/Caches/TemporaryItems/avd/running'),
|
|
376
|
+
process.env.XDG_RUNTIME_DIR ? path.join(process.env.XDG_RUNTIME_DIR, 'avd/running') : null,
|
|
377
|
+
path.join(os.tmpdir(), `android-${os.userInfo().username}`, 'avd/running'),
|
|
378
|
+
].filter(Boolean);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const endpointCache = new Map();
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The gRPC port and token for a device.
|
|
385
|
+
*
|
|
386
|
+
* The emulator writes both into a per-process ini alongside the AVD it is
|
|
387
|
+
* running, which is how this avoids hardcoding 8554 and works with a second
|
|
388
|
+
* emulator on another port. The token is a local credential the emulator wrote
|
|
389
|
+
* for whoever can read the file — the same status as the console auth token —
|
|
390
|
+
* and it is read at call time, never logged and never stored anywhere else.
|
|
391
|
+
*/
|
|
392
|
+
function grpcEndpoint(udid) {
|
|
393
|
+
const cached = endpointCache.get(udid);
|
|
394
|
+
if (cached && Date.now() - cached.at < DEVICE_CACHE_MS) return cached.endpoint;
|
|
395
|
+
const serial = consolePort(udid);
|
|
396
|
+
if (!serial) throw new Error(`${udid} is not an emulator serial`);
|
|
397
|
+
for (const dir of runningAvdDirs()) {
|
|
398
|
+
let names = [];
|
|
399
|
+
try {
|
|
400
|
+
names = fs.readdirSync(dir).filter((f) => /^pid_\d+\.ini$/.test(f));
|
|
401
|
+
} catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
for (const name of names) {
|
|
405
|
+
let text = '';
|
|
406
|
+
try {
|
|
407
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
408
|
+
} catch {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const field = (key) => new RegExp(`^${key.replace('.', '\\.')}=(.*)$`, 'm').exec(text)?.[1]?.trim();
|
|
412
|
+
if (field('port.serial') !== String(serial)) continue;
|
|
413
|
+
const port = Number(field('grpc.port'));
|
|
414
|
+
const token = field('grpc.token');
|
|
415
|
+
if (!port) continue;
|
|
416
|
+
const endpoint = { port, token: token || null };
|
|
417
|
+
endpointCache.set(udid, { at: Date.now(), endpoint });
|
|
418
|
+
return endpoint;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
throw new Error(
|
|
422
|
+
`could not find the gRPC endpoint for ${udid} — no running-AVD record names console port ${serial}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A length-delimited protobuf string field. */
|
|
427
|
+
function protoString(fieldNumber, value) {
|
|
428
|
+
const body = Buffer.from(String(value), 'utf8');
|
|
429
|
+
const length = [];
|
|
430
|
+
let remaining = body.length;
|
|
431
|
+
do {
|
|
432
|
+
length.push((remaining & 0x7f) | (remaining > 0x7f ? 0x80 : 0));
|
|
433
|
+
remaining >>>= 7;
|
|
434
|
+
} while (remaining > 0);
|
|
435
|
+
return Buffer.concat([Buffer.from([(fieldNumber << 3) | 2]), Buffer.from(length), body]);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Read the first length-delimited field out of a protobuf message. */
|
|
439
|
+
function firstString(message) {
|
|
440
|
+
if (!message.length || (message[0] >> 3) !== 1) return '';
|
|
441
|
+
let offset = 1;
|
|
442
|
+
let length = 0;
|
|
443
|
+
let shift = 0;
|
|
444
|
+
for (;;) {
|
|
445
|
+
const byte = message[offset];
|
|
446
|
+
offset += 1;
|
|
447
|
+
length |= (byte & 0x7f) << shift;
|
|
448
|
+
if (!(byte & 0x80)) break;
|
|
449
|
+
shift += 7;
|
|
450
|
+
}
|
|
451
|
+
return message.subarray(offset, offset + length).toString('utf8');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function grpcCall(udid, method, message, { timeoutMs = 10_000 } = {}) {
|
|
455
|
+
const { port, token } = grpcEndpoint(udid);
|
|
456
|
+
return new Promise((resolve, reject) => {
|
|
457
|
+
const client = http2.connect(`http://127.0.0.1:${port}`);
|
|
458
|
+
const fail = (err) => {
|
|
459
|
+
client.close();
|
|
460
|
+
reject(err);
|
|
461
|
+
};
|
|
462
|
+
client.on('error', fail);
|
|
463
|
+
const header = Buffer.alloc(5);
|
|
464
|
+
header.writeUInt8(0, 0);
|
|
465
|
+
header.writeUInt32BE(message.length, 1);
|
|
466
|
+
const req = client.request({
|
|
467
|
+
':method': 'POST',
|
|
468
|
+
':path': `/android.emulation.control.EmulatorController/${method}`,
|
|
469
|
+
'content-type': 'application/grpc',
|
|
470
|
+
te: 'trailers',
|
|
471
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
472
|
+
});
|
|
473
|
+
req.setTimeout(timeoutMs, () => fail(new Error(`emulator gRPC ${method} timed out after ${timeoutMs}ms`)));
|
|
474
|
+
const chunks = [];
|
|
475
|
+
let status = null;
|
|
476
|
+
let detail = '';
|
|
477
|
+
const readStatus = (headers) => {
|
|
478
|
+
if (headers['grpc-status'] == null) return;
|
|
479
|
+
status = Number(headers['grpc-status']);
|
|
480
|
+
detail = headers['grpc-message'] ?? '';
|
|
481
|
+
};
|
|
482
|
+
req.on('response', readStatus);
|
|
483
|
+
req.on('trailers', readStatus);
|
|
484
|
+
req.on('error', fail);
|
|
485
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
486
|
+
req.on('end', () => {
|
|
487
|
+
client.close();
|
|
488
|
+
if (status !== 0) {
|
|
489
|
+
// 16 is UNAUTHENTICATED, which here means the token was missing or
|
|
490
|
+
// stale rather than anything the caller did wrong.
|
|
491
|
+
const why = status === 16 ? 'the emulator refused the gRPC token' : `grpc-status ${status}`;
|
|
492
|
+
return reject(new Error(`${method} failed: ${why}${detail ? ` (${detail})` : ''}`));
|
|
493
|
+
}
|
|
494
|
+
// Strip the five-byte frame header the response carries too.
|
|
495
|
+
resolve(Buffer.concat(chunks).subarray(5));
|
|
496
|
+
});
|
|
497
|
+
req.end(Buffer.concat([header, message]));
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** A PNG that has not been written all the way to its IEND chunk is a truncated read. */
|
|
502
|
+
function completePng(file) {
|
|
503
|
+
try {
|
|
504
|
+
const fd = fs.openSync(file, 'r');
|
|
505
|
+
try {
|
|
506
|
+
const { size } = fs.fstatSync(fd);
|
|
507
|
+
if (size < 20) return false;
|
|
508
|
+
// The last chunk of a PNG is 12 bytes: a zero length, the type `IEND`,
|
|
509
|
+
// and a CRC. The type is therefore four bytes in from the end of those
|
|
510
|
+
// twelve — not four bytes in from the end of the file, which is the CRC
|
|
511
|
+
// and never spells anything.
|
|
512
|
+
const tail = Buffer.alloc(12);
|
|
513
|
+
fs.readSync(fd, tail, 0, 12, size - 12);
|
|
514
|
+
return tail.toString('latin1', 4, 8) === 'IEND';
|
|
515
|
+
} finally {
|
|
516
|
+
fs.closeSync(fd);
|
|
517
|
+
}
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* A screenshot, host-side where possible.
|
|
525
|
+
*
|
|
526
|
+
* `screenrecord screenshot <dir>` makes the emulator write the PNG onto the
|
|
527
|
+
* host filesystem itself — 20 ms against 113 ms for `adb exec-out screencap -p`,
|
|
528
|
+
* because nothing crosses the adb transport. Its filename carries a
|
|
529
|
+
* one-second-resolution timestamp, so it goes into a directory of its own per
|
|
530
|
+
* call rather than into a shared one where two frames in the same second would
|
|
531
|
+
* collide.
|
|
532
|
+
*
|
|
533
|
+
* The console answers OK before the file is necessarily complete, so the read
|
|
534
|
+
* waits for a PNG that ends in IEND rather than for a file that exists.
|
|
535
|
+
*
|
|
536
|
+
* `mask` is a simctl concept (the device bezel) and has no Android meaning.
|
|
537
|
+
*/
|
|
538
|
+
async function screenshot(udid, outFile, { mask: _mask = 'ignored' } = {}) {
|
|
539
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'simframe-shot-'));
|
|
540
|
+
try {
|
|
541
|
+
await consoleScript(udid, [`screenrecord screenshot ${dir}`]);
|
|
542
|
+
const deadline = Date.now() + 2000;
|
|
543
|
+
for (;;) {
|
|
544
|
+
const shot = fs.readdirSync(dir).map((f) => path.join(dir, f)).find(completePng);
|
|
545
|
+
if (shot) {
|
|
546
|
+
fs.renameSync(shot, outFile);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (Date.now() > deadline) throw new Error('the emulator console wrote no complete PNG within 2s');
|
|
550
|
+
await new Promise((r) => setTimeout(r, 3));
|
|
551
|
+
}
|
|
552
|
+
} catch (consoleErr) {
|
|
553
|
+
// Fall back to adb, and say what the faster path complained about: a silent
|
|
554
|
+
// fallback to a path five times slower is the failure mode this project has
|
|
555
|
+
// been bitten by twice.
|
|
556
|
+
try {
|
|
557
|
+
const { stdout } = await adb(udid, ['exec-out', 'screencap', '-p'], { encoding: 'buffer', timeout: 20_000 });
|
|
558
|
+
fs.writeFileSync(outFile, stdout);
|
|
559
|
+
process.env.SIMFRAME_QUIET === '1' ||
|
|
560
|
+
process.stderr.write(`simframe: emulator console unavailable (${consoleErr.message}); used adb screencap\n`);
|
|
561
|
+
} catch (adbErr) {
|
|
562
|
+
throw new Error(`screenshot failed: ${detailOf(adbErr)} (console path: ${consoleErr.message})`);
|
|
563
|
+
}
|
|
564
|
+
} finally {
|
|
565
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The device's real geometry.
|
|
571
|
+
*
|
|
572
|
+
* Without this, `deviceGeometry` fell through to a guess derived from the ring
|
|
573
|
+
* image — an Android emulator reported "393x700pt", which is the capture size
|
|
574
|
+
* and not a coordinate space anything on the device has ever heard of. Every
|
|
575
|
+
* tap point derived from it would have been wrong, silently, which is the worst
|
|
576
|
+
* available outcome for an input path.
|
|
577
|
+
*
|
|
578
|
+
* `wm size` is physical pixels and `wm density` is dpi; Android's density
|
|
579
|
+
* independent pixel is 1/160th of an inch, so the scale factor is dpi/160 and
|
|
580
|
+
* the point size is the pixel size divided by it. Both in one shell hop.
|
|
581
|
+
*/
|
|
582
|
+
const geometryCache = new Map();
|
|
583
|
+
|
|
584
|
+
async function geometry(udid) {
|
|
585
|
+
const cached = geometryCache.get(udid);
|
|
586
|
+
if (cached && Date.now() - cached.at < DEVICE_CACHE_MS) return cached.geo;
|
|
587
|
+
const { stdout } = await adb(udid, ['shell', 'wm size; wm density']);
|
|
588
|
+
const text = stdout.replace(/\r/g, '');
|
|
589
|
+
const size = /Physical size:\s*(\d+)x(\d+)/.exec(text);
|
|
590
|
+
const dpi = /Physical density:\s*(\d+)/.exec(text);
|
|
591
|
+
if (!size || !dpi) throw new Error(`could not read the screen geometry: ${text.trim() || 'no answer'}`);
|
|
592
|
+
const density = Number(dpi[1]) / 160;
|
|
593
|
+
const geo = {
|
|
594
|
+
pixelWidth: Number(size[1]),
|
|
595
|
+
pixelHeight: Number(size[2]),
|
|
596
|
+
density,
|
|
597
|
+
pointWidth: Math.round(Number(size[1]) / density),
|
|
598
|
+
pointHeight: Math.round(Number(size[2]) / density),
|
|
599
|
+
};
|
|
600
|
+
geometryCache.set(udid, { at: Date.now(), geo });
|
|
601
|
+
return geo;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// --- input ------------------------------------------------------------------
|
|
605
|
+
//
|
|
606
|
+
// `event mouse <x> <y> <device> <buttonstate>` with device 0 is the touch
|
|
607
|
+
// screen, and buttonstate 1 and 0 are down and up. It takes **device pixels**,
|
|
608
|
+
// which was settled by watching the kernel rather than by reading the help
|
|
609
|
+
// text: sending (360, 640) on a 720x1280 screen makes the touch driver report
|
|
610
|
+
// 0x3fff on both axes, exactly half of its 0-32767 range. In device-independent
|
|
611
|
+
// pixels 360 would have been the full width and reported the maximum.
|
|
612
|
+
//
|
|
613
|
+
// Everything above the boundary works in points, so the conversion happens
|
|
614
|
+
// here, at the only place that knows the density.
|
|
615
|
+
|
|
616
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
617
|
+
|
|
618
|
+
/** Points to device pixels, clamped to the screen so a bad point cannot be silently off-device. */
|
|
619
|
+
function toPixels(geo, x, y) {
|
|
620
|
+
const px = Math.round(x * geo.density);
|
|
621
|
+
const py = Math.round(y * geo.density);
|
|
622
|
+
if (px < 0 || py < 0 || px > geo.pixelWidth || py > geo.pixelHeight) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
`${Math.round(x)},${Math.round(y)}pt is off a ${geo.pointWidth}x${geo.pointHeight}pt screen`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
return { x: px, y: py };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* A tap: down, a hold, up.
|
|
632
|
+
*
|
|
633
|
+
* The hold is not decoration. A down and an up in the same millisecond is not
|
|
634
|
+
* something a finger can do, and Android's own gesture detectors time
|
|
635
|
+
* touches — a tap with no duration is exactly the "teleporting tap" this
|
|
636
|
+
* project rules out on iOS. 60 ms is a short human tap; anything over 500 ms is
|
|
637
|
+
* a long press, and that is the same code path.
|
|
638
|
+
*/
|
|
639
|
+
async function tap(udid, x, y, { durationMs = 60 } = {}) {
|
|
640
|
+
const geo = await geometry(udid);
|
|
641
|
+
const p = toPixels(geo, x, y);
|
|
642
|
+
const session = await sessionFor(udid);
|
|
643
|
+
await session.send(`event mouse ${p.x} ${p.y} 0 1`);
|
|
644
|
+
await sleep(Math.max(1, durationMs));
|
|
645
|
+
await session.send(`event mouse ${p.x} ${p.y} 0 0`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** How many moves a swipe is made of. Enough to be a gesture, few enough to keep the timing. */
|
|
649
|
+
const SWIPE_STEPS = 12;
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* A swipe: down, a run of moves with time between them, up.
|
|
653
|
+
*
|
|
654
|
+
* Eased rather than linear, because a real finger accelerates and decelerates
|
|
655
|
+
* and Android's fling detector reads velocity off the last few moves. A linear
|
|
656
|
+
* drag that stops dead reads as a drag; an eased one that is still moving at
|
|
657
|
+
* the end reads as a fling, and which of those you get changes where a list
|
|
658
|
+
* lands.
|
|
659
|
+
*/
|
|
660
|
+
async function swipe(udid, from, to, { durationMs = 300 } = {}) {
|
|
661
|
+
const geo = await geometry(udid);
|
|
662
|
+
const start = toPixels(geo, from.x, from.y);
|
|
663
|
+
const end = toPixels(geo, to.x, to.y);
|
|
664
|
+
const session = await sessionFor(udid);
|
|
665
|
+
const gap = Math.max(1, Math.round(durationMs / SWIPE_STEPS));
|
|
666
|
+
await session.send(`event mouse ${start.x} ${start.y} 0 1`);
|
|
667
|
+
for (let step = 1; step < SWIPE_STEPS; step += 1) {
|
|
668
|
+
const t = step / SWIPE_STEPS;
|
|
669
|
+
// Ease in-out: slow at both ends, quickest in the middle.
|
|
670
|
+
const eased = t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);
|
|
671
|
+
const x = Math.round(start.x + (end.x - start.x) * eased);
|
|
672
|
+
const y = Math.round(start.y + (end.y - start.y) * eased);
|
|
673
|
+
await sleep(gap);
|
|
674
|
+
await session.send(`event mouse ${x} ${y} 0 1`);
|
|
675
|
+
}
|
|
676
|
+
await sleep(gap);
|
|
677
|
+
await session.send(`event mouse ${end.x} ${end.y} 0 0`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Type text as keystrokes.
|
|
682
|
+
*
|
|
683
|
+
* `event text` takes the rest of the line, so a newline cannot be sent through
|
|
684
|
+
* it and is refused rather than silently dropped — `key enter` is the way to
|
|
685
|
+
* send one, and a caller that meant a line break should say so.
|
|
686
|
+
*/
|
|
687
|
+
async function text(udid, value) {
|
|
688
|
+
const string = String(value);
|
|
689
|
+
if (/[\r\n]/.test(string)) {
|
|
690
|
+
throw new Error('a newline cannot be typed through the emulator console — send an `enter` key instead');
|
|
691
|
+
}
|
|
692
|
+
const session = await sessionFor(udid);
|
|
693
|
+
await session.send(`event text ${string}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The hardware and system keys, by name.
|
|
698
|
+
*
|
|
699
|
+
* Through `adb shell input keyevent`, not through raw kernel codes. On iOS the
|
|
700
|
+
* unverified Indigo button codes are refused outright because a wrong one can
|
|
701
|
+
* crash `backboardd` — here the public API takes names, so there is nothing to
|
|
702
|
+
* guess at and the whole vocabulary is safe to offer. It costs about 35 ms.
|
|
703
|
+
*/
|
|
704
|
+
const KEYS = {
|
|
705
|
+
home: 'KEYCODE_HOME',
|
|
706
|
+
back: 'KEYCODE_BACK',
|
|
707
|
+
recents: 'KEYCODE_APP_SWITCH',
|
|
708
|
+
appswitch: 'KEYCODE_APP_SWITCH',
|
|
709
|
+
power: 'KEYCODE_POWER',
|
|
710
|
+
lock: 'KEYCODE_POWER',
|
|
711
|
+
volumeup: 'KEYCODE_VOLUME_UP',
|
|
712
|
+
volumedown: 'KEYCODE_VOLUME_DOWN',
|
|
713
|
+
enter: 'KEYCODE_ENTER',
|
|
714
|
+
tab: 'KEYCODE_TAB',
|
|
715
|
+
escape: 'KEYCODE_ESCAPE',
|
|
716
|
+
backspace: 'KEYCODE_DEL',
|
|
717
|
+
delete: 'KEYCODE_DEL',
|
|
718
|
+
menu: 'KEYCODE_MENU',
|
|
719
|
+
search: 'KEYCODE_SEARCH',
|
|
720
|
+
paste: 'KEYCODE_PASTE',
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
async function key(udid, name) {
|
|
724
|
+
const wanted = String(name).toLowerCase().replace(/[\s_-]+/g, '');
|
|
725
|
+
const keycode = KEYS[wanted]
|
|
726
|
+
?? (/^keycode_[a-z0-9_]+$/i.test(String(name)) ? String(name).toUpperCase() : null)
|
|
727
|
+
?? (/^\d+$/.test(String(name)) ? String(name) : null);
|
|
728
|
+
if (!keycode) {
|
|
729
|
+
throw new Error(`unknown key "${name}" on Android — one of: ${Object.keys(KEYS).join(', ')}`);
|
|
730
|
+
}
|
|
731
|
+
await adb(udid, ['shell', 'input', 'keyevent', keycode]);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* This backend's own input path, or null if it has none.
|
|
736
|
+
*
|
|
737
|
+
* iOS returns null here: its input is Indigo HID inside the daemon, which is
|
|
738
|
+
* simframe's own engine rather than anything the platform provides. Android's
|
|
739
|
+
* is the emulator console, host-side, with no adb in the gesture path at all.
|
|
740
|
+
*/
|
|
741
|
+
function inputDriver() {
|
|
742
|
+
return {
|
|
743
|
+
id: 'console',
|
|
744
|
+
detail: 'emulator console (event mouse/text), host-side',
|
|
745
|
+
tap,
|
|
746
|
+
swipe,
|
|
747
|
+
text,
|
|
748
|
+
key,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Launch a package's launcher activity.
|
|
754
|
+
*
|
|
755
|
+
* `am start` needs a component, not a package, so the launcher activity is
|
|
756
|
+
* resolved first. `args` and `env` are simctl concepts: an Android app has no
|
|
757
|
+
* argv and no environment of its own, and quietly dropping them would let a
|
|
758
|
+
* flow think it had launched an app in a mode it never launched in.
|
|
759
|
+
*/
|
|
760
|
+
async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst = false } = {}) {
|
|
761
|
+
if (args.length || Object.keys(env).length) {
|
|
762
|
+
throw new Error(
|
|
763
|
+
'launch arguments and environment are simctl-only — an Android app has no argv or environment ' +
|
|
764
|
+
'(use intent extras from the app side, or drop them for this platform)',
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
let component;
|
|
768
|
+
try {
|
|
769
|
+
const { stdout } = await adb(udid, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', bundleId]);
|
|
770
|
+
component = stdout.replace(/\r/g, '').trim().split('\n').pop();
|
|
771
|
+
} catch (err) {
|
|
772
|
+
throw new Error(`could not launch ${bundleId}: ${detailOf(err)}`);
|
|
773
|
+
}
|
|
774
|
+
if (!component || !component.includes('/')) {
|
|
775
|
+
throw new Error(`could not launch ${bundleId}: no launcher activity (is the package installed?)`);
|
|
776
|
+
}
|
|
777
|
+
// Relaunching means starting at the app's root, and on Android
|
|
778
|
+
// `force-stop` then `am start` does not: the platform restores the task's
|
|
779
|
+
// saved activity stack, so a "relaunched" Settings came back on the search
|
|
780
|
+
// screen a previous step had left it on — a flow testing the screen it was
|
|
781
|
+
// already on, which is the exact bug `relaunch` exists to prevent on iOS.
|
|
782
|
+
// `-S` stops the app and `--activity-clear-task` drops the restored stack.
|
|
783
|
+
const fresh = terminateFirst ? ['-S', '--activity-clear-task'] : [];
|
|
784
|
+
// `-W` waits for the activity to be idle and `-S` makes it a cold start:
|
|
785
|
+
// measured at 6.1 s for Settings on an idle emulator, and it exceeded the
|
|
786
|
+
// 20 s default while OCR and capture were competing for the same cores. The
|
|
787
|
+
// bound belongs to the app's start-up, not to adb.
|
|
788
|
+
const { stdout, stderr } = await adb(udid, ['shell', 'am', 'start', '-W', ...fresh, '-n', component], {
|
|
789
|
+
timeout: 60_000,
|
|
790
|
+
});
|
|
791
|
+
const said = `${stdout}${stderr}`;
|
|
792
|
+
// `am start` reports its failures on stdout and exits 0 — the same silent
|
|
793
|
+
// success `pm grant` has, and the reason setPermission below reads back.
|
|
794
|
+
const error = /^Error:.*$/m.exec(said);
|
|
795
|
+
if (error) throw new Error(`could not launch ${bundleId}: ${error[0].replace(/^Error:\s*/, '')}`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function terminateApp(udid, bundleId) {
|
|
799
|
+
await adb(udid, ['shell', 'am', 'force-stop', bundleId]);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function openUrl(udid, url) {
|
|
803
|
+
const { stdout, stderr } = await adb(udid, ['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url]);
|
|
804
|
+
const error = /^Error:.*$/m.exec(`${stdout}${stderr}`);
|
|
805
|
+
if (error) throw new Error(`could not open ${url}: ${error[0].replace(/^Error:\s*/, '')}`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* The permission names simframe accepts on Android, and what each one is.
|
|
810
|
+
*
|
|
811
|
+
* These are not simctl's names and could not be: the two platforms do not have
|
|
812
|
+
* the same permissions. `reminders`, `siri`, `motion` and `media-library` have
|
|
813
|
+
* no Android equivalent and are absent rather than mapped to something close;
|
|
814
|
+
* `notifications` is real here and has no iOS equivalent. A name that means
|
|
815
|
+
* different things on two platforms would be worse than a name that only exists
|
|
816
|
+
* on one.
|
|
817
|
+
*/
|
|
818
|
+
const PERMISSION_MAP = {
|
|
819
|
+
calendar: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'],
|
|
820
|
+
camera: ['android.permission.CAMERA'],
|
|
821
|
+
contacts: ['android.permission.READ_CONTACTS', 'android.permission.WRITE_CONTACTS'],
|
|
822
|
+
location: ['android.permission.ACCESS_COARSE_LOCATION', 'android.permission.ACCESS_FINE_LOCATION'],
|
|
823
|
+
'location-always': ['android.permission.ACCESS_BACKGROUND_LOCATION'],
|
|
824
|
+
microphone: ['android.permission.RECORD_AUDIO'],
|
|
825
|
+
notifications: ['android.permission.POST_NOTIFICATIONS'],
|
|
826
|
+
phone: ['android.permission.READ_PHONE_STATE'],
|
|
827
|
+
photos: ['android.permission.READ_MEDIA_IMAGES', 'android.permission.READ_MEDIA_VIDEO'],
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const PERMISSION_SERVICES = ['all', ...Object.keys(PERMISSION_MAP)];
|
|
831
|
+
|
|
832
|
+
/** What the device says is actually granted, which is not always what was asked for. */
|
|
833
|
+
async function grantedPermissions(udid, bundleId) {
|
|
834
|
+
const { stdout } = await adb(udid, ['shell', 'dumpsys', 'package', bundleId]);
|
|
835
|
+
const granted = new Map();
|
|
836
|
+
for (const [, name, value] of stdout.matchAll(/^\s+(android\.permission\.[A-Z_]+): granted=(true|false)/gm)) {
|
|
837
|
+
granted.set(name, value === 'true');
|
|
838
|
+
}
|
|
839
|
+
return granted;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Grant, revoke or reset a runtime permission, and then check that it happened.
|
|
844
|
+
*
|
|
845
|
+
* The read-back is the whole point. `pm grant` for a permission the package
|
|
846
|
+
* never declared prints nothing, writes nothing and exits 0 — measured — so a
|
|
847
|
+
* grant that reports success proves only that adb ran. This asks the device
|
|
848
|
+
* what it now believes and reports that instead.
|
|
849
|
+
*/
|
|
850
|
+
async function setPermission(udid, action, service, bundleId) {
|
|
851
|
+
const verb = String(action).toLowerCase();
|
|
852
|
+
if (!['grant', 'revoke', 'reset'].includes(verb)) {
|
|
853
|
+
throw new Error(`permission action must be grant, revoke or reset (got "${action}")`);
|
|
854
|
+
}
|
|
855
|
+
if (!PERMISSION_SERVICES.includes(service)) {
|
|
856
|
+
throw new Error(`unknown permission "${service}" on Android — one of: ${PERMISSION_SERVICES.join(', ')}`);
|
|
857
|
+
}
|
|
858
|
+
if (!bundleId) {
|
|
859
|
+
throw new Error('Android permissions are per app — name the package to grant it to');
|
|
860
|
+
}
|
|
861
|
+
const wanted = service === 'all' ? Object.values(PERMISSION_MAP).flat() : PERMISSION_MAP[service];
|
|
862
|
+
|
|
863
|
+
if (verb === 'reset') {
|
|
864
|
+
await adb(udid, ['shell', 'pm', 'reset-permissions', '-p', bundleId]);
|
|
865
|
+
return `reset all permissions for ${bundleId}`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const declared = await grantedPermissions(udid, bundleId);
|
|
869
|
+
const undeclared = wanted.filter((p) => !declared.has(p));
|
|
870
|
+
const actionable = wanted.filter((p) => declared.has(p));
|
|
871
|
+
if (!actionable.length) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`${bundleId} does not declare ${undeclared.join(', ')}, so ${verb} would do nothing ` +
|
|
874
|
+
'(an app can only be granted permissions it asks for)',
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
for (const permission of actionable) {
|
|
878
|
+
await adb(udid, ['shell', 'pm', verb, bundleId, permission]);
|
|
879
|
+
}
|
|
880
|
+
const after = await grantedPermissions(udid, bundleId);
|
|
881
|
+
const disagreed = actionable.filter((p) => after.get(p) !== (verb === 'grant'));
|
|
882
|
+
if (disagreed.length) {
|
|
883
|
+
throw new Error(`${verb} ${service} did not take effect for ${disagreed.join(', ')} — the device still disagrees`);
|
|
884
|
+
}
|
|
885
|
+
const skipped = undeclared.length ? ` (${bundleId} does not declare ${undeclared.join(', ')})` : '';
|
|
886
|
+
return `${verb === 'grant' ? 'granted' : 'revoked'} ${service} for ${bundleId}${skipped}`;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Put text on the device clipboard.
|
|
891
|
+
*
|
|
892
|
+
* Not over adb, which has no path to it: `cmd clipboard` does not exist on API
|
|
893
|
+
* 36 and `service call clipboard` depends on transaction numbers that move
|
|
894
|
+
* between platform versions. The emulator's gRPC endpoint declares
|
|
895
|
+
* `setClipboard(ClipData)` and that is the whole answer — about 48 ms, no
|
|
896
|
+
* dependency, no helper app on the device.
|
|
897
|
+
*/
|
|
898
|
+
async function setPasteboard(udid, value) {
|
|
899
|
+
await grpcCall(udid, 'setClipboard', protoString(1, String(value)));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** What the device currently holds. Mostly here to make the setter checkable. */
|
|
903
|
+
async function getPasteboard(udid) {
|
|
904
|
+
return firstString(await grpcCall(udid, 'getClipboard', Buffer.alloc(0)));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* The prerequisites `simframe doctor` reports for this backend. Returned rather
|
|
909
|
+
* than printed so doctor stays one renderer: a backend says what it needs, and
|
|
910
|
+
* a machine missing it is told which tool, not which platform.
|
|
911
|
+
*/
|
|
912
|
+
function toolchain() {
|
|
913
|
+
try {
|
|
914
|
+
const version = execFileSync(adbPath(), ['version'], { encoding: 'utf8' }).trim().split('\n')[0];
|
|
915
|
+
return [{ name: 'adb', level: 'ok', detail: version }];
|
|
916
|
+
} catch (err) {
|
|
917
|
+
// Optional, not broken: a machine with no Android SDK has not degraded from
|
|
918
|
+
// anything, and doctor's `optional` level exists for exactly this.
|
|
919
|
+
return [{ name: 'adb', level: 'optional', detail: `${err.message.split('—')[0].trim()} — Android devices unavailable` }];
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* What this backend can currently do.
|
|
925
|
+
*
|
|
926
|
+
* Frames: yes — the whole Node capture loop runs on Android unchanged, because
|
|
927
|
+
* it asks the boundary for a screenshot and a resize and both are real here.
|
|
928
|
+
* There is no framebuffer engine, so `screenshot` is not a downgrade on this
|
|
929
|
+
* platform and doctor must not report it as one.
|
|
930
|
+
*
|
|
931
|
+
* Input and the accessibility tree: not yet, and said so rather than answered
|
|
932
|
+
* with the iOS driver's name. Both paths are measured and unwired — the console's
|
|
933
|
+
* `event mouse` puts a real down/move/up on the touch screen in ~20 ms, and
|
|
934
|
+
* `uiautomator dump` costs 2 s a read, which is the interesting problem.
|
|
935
|
+
*/
|
|
936
|
+
function capabilities() {
|
|
937
|
+
return {
|
|
938
|
+
captureEngines: ['screenshot'],
|
|
939
|
+
input: { supported: true, via: 'console' },
|
|
940
|
+
ax: {
|
|
941
|
+
supported: false,
|
|
942
|
+
note: 'not built for Android yet — `uiautomator dump` costs ~2s a read; see docs/DEFERRED.md',
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** @type {import('./index.js').Platform} */
|
|
948
|
+
export const platform = {
|
|
949
|
+
id: 'android',
|
|
950
|
+
deviceNoun: 'emulator',
|
|
951
|
+
listDevices,
|
|
952
|
+
bootedDevices,
|
|
953
|
+
resolveDevice,
|
|
954
|
+
isBootedSync,
|
|
955
|
+
ownsUdid,
|
|
956
|
+
geometry,
|
|
957
|
+
inputDriver,
|
|
958
|
+
screenshot,
|
|
959
|
+
launchApp,
|
|
960
|
+
terminateApp,
|
|
961
|
+
openUrl,
|
|
962
|
+
setPermission,
|
|
963
|
+
setPasteboard,
|
|
964
|
+
getPasteboard,
|
|
965
|
+
permissionServices: () => PERMISSION_SERVICES,
|
|
966
|
+
capabilities,
|
|
967
|
+
toolchain,
|
|
968
|
+
};
|