dsh-apk-vm 0.0.0-stage → 0.3.13
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/CHANGELOG.md +345 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +177 -0
- package/DEMO.md +85 -0
- package/DESIGN.md +335 -0
- package/LICENSE +204 -0
- package/NOTICE +74 -0
- package/README.md +920 -2
- package/SECURITY.md +108 -0
- package/client.js +3780 -0
- package/cordis.patch.yml +56 -0
- package/docs/PRIVACY.md +36 -0
- package/docs/README.md +16 -0
- package/docs/RELEASING.md +104 -0
- package/docs/SUBMISSION.md +75 -0
- package/docs/screenshot-panel.png +0 -0
- package/docs/screenshot-sidebar.png +0 -0
- package/icon.svg +8 -0
- package/index.js +2532 -0
- package/lib/android.js +2502 -0
- package/lib/instances.js +559 -0
- package/lib/scrcpy.js +2370 -0
- package/lib/setup.js +1624 -0
- package/lib/vendor/scrcpy-server-v5.0.1.jar +0 -0
- package/lib/video-stream.js +329 -0
- package/lib/ws.js +198 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
- package/scripts/privacy-scan.mjs +94 -0
package/lib/android.js
ADDED
|
@@ -0,0 +1,2502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/android.js — dependency-free Android device backend for the APK VM preview panel.
|
|
3
|
+
*
|
|
4
|
+
* Scope
|
|
5
|
+
* -----
|
|
6
|
+
* Drives *any* adb-reachable open-source Android runtime (QEMU/Android-x86 VM reached
|
|
7
|
+
* through `adb connect host:port`, a locally spawned AOSP emulator, or a USB device)
|
|
8
|
+
* using only Node.js built-in modules. Exposes VM lifecycle state, APK discovery /
|
|
9
|
+
* inspection / install / launch, PNG screenshots and input injection.
|
|
10
|
+
*
|
|
11
|
+
* Device selection is ATTACH-FIRST:
|
|
12
|
+
* - if `options.serial` is set, that serial is authoritative (network serials such
|
|
13
|
+
* as `127.0.0.1:5555` are brought up with `adb connect` on start());
|
|
14
|
+
* - otherwise the first device reported by `adb devices` in state `device` is used;
|
|
15
|
+
* - an AOSP emulator is spawned ONLY when `options.emulatorPath` AND `options.avdName`
|
|
16
|
+
* are explicitly configured, no device exists, and `options.spawnEmulator !== false`.
|
|
17
|
+
*
|
|
18
|
+
* No shell is ever used on the host: every external program is launched with
|
|
19
|
+
* child_process.spawn(exe, argvArray). The only string command is the *remote* command
|
|
20
|
+
* passed as one argv element to `adb shell`, and its arguments are strictly validated.
|
|
21
|
+
*
|
|
22
|
+
* Export contract (binding):
|
|
23
|
+
* export class AndroidVm {
|
|
24
|
+
* constructor(options = {}) // see DEFAULT_OPTIONS below
|
|
25
|
+
* async status() // { state, serial, avd, bootMs, error, logTail }
|
|
26
|
+
* async start() // idempotent, never throws for expected boot failures
|
|
27
|
+
* async stop()
|
|
28
|
+
* async screenSize() // { width, height }
|
|
29
|
+
* async screenshot() // Buffer (PNG)
|
|
30
|
+
* async listApks(roots = []) // [{ path, name, size, mtime }]
|
|
31
|
+
* async inspectApk(path) // { packageName, label, launchActivity }
|
|
32
|
+
* async installApk(path) // { packageName, label, launchActivity }
|
|
33
|
+
* async launchPackage(packageName, launchActivity)
|
|
34
|
+
* async input(payload) // { ok: true }
|
|
35
|
+
* async dispose()
|
|
36
|
+
* // additions requested by the VM runtime pivot:
|
|
37
|
+
* async connect(serial) // adb connect + wait online; returns status snapshot
|
|
38
|
+
* async waitBooted(timeoutMs) // wait for sys.boot_completed=1; returns status snapshot
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* States: 'stopped' | 'starting' | 'booting' | 'ready' | 'error'
|
|
42
|
+
*
|
|
43
|
+
* Timing note: software-emulated Android (TCG) is very slow; every externally visible
|
|
44
|
+
* operation is bounded by a timeout and the event loop is never blocked.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { spawn } from 'node:child_process';
|
|
48
|
+
import { existsSync } from 'node:fs';
|
|
49
|
+
import { promises as fsp } from 'node:fs';
|
|
50
|
+
import os from 'node:os';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
import { inflateRawSync } from 'node:zlib';
|
|
53
|
+
|
|
54
|
+
const IS_WIN = process.platform === 'win32';
|
|
55
|
+
const EXE = IS_WIN ? '.exe' : '';
|
|
56
|
+
|
|
57
|
+
/** Default tuning; every value can be overridden via constructor options. */
|
|
58
|
+
export const DEFAULT_OPTIONS = Object.freeze({
|
|
59
|
+
// adb / SDK discovery
|
|
60
|
+
sdkRoot: null, // e.g. 'D:\\Android\\sdk'; otherwise ANDROID_HOME / ANDROID_SDK_ROOT / known path
|
|
61
|
+
adbPath: null, // explicit adb executable
|
|
62
|
+
aapt2Path: null, // explicit aapt2 executable
|
|
63
|
+
emulatorPath: null, // explicit emulator executable — required to enable spawning
|
|
64
|
+
avdName: null, // AVD name — required to enable spawning
|
|
65
|
+
spawnEmulator: true, // spawn only when emulatorPath+avdName are configured
|
|
66
|
+
emulatorArgs: [], // extra argv appended to the emulator command line
|
|
67
|
+
|
|
68
|
+
// device selection
|
|
69
|
+
serial: null, // authoritative adb serial (e.g. '127.0.0.1:5555', 'emulator-5558')
|
|
70
|
+
port: 5558, // console port used only when spawning the AOSP emulator
|
|
71
|
+
|
|
72
|
+
// boot speed / acceleration
|
|
73
|
+
gpuMode: 'host', // 'host' uses the host GPU; 'swiftshader_indirect' is the software fallback
|
|
74
|
+
accel: 'auto', // 'auto' detects AEHD/WHPX/KVM/HAXM; 'on' forces it; 'off' forces TCG
|
|
75
|
+
requireAccel: true, // fail fast instead of hanging forever when x86_64 has no hypervisor
|
|
76
|
+
accelCheckTimeoutMs: 15 * 1000,
|
|
77
|
+
quickBoot: true, // load the saved AVD snapshot when present
|
|
78
|
+
snapshotName: 'default_boot',
|
|
79
|
+
saveSnapshotAfterBoot: true, // save a cold-boot snapshot so the next start is a quick boot
|
|
80
|
+
|
|
81
|
+
// timing (ms)
|
|
82
|
+
bootTimeoutMs: 20 * 60 * 1000,
|
|
83
|
+
connectTimeoutMs: 45 * 1000,
|
|
84
|
+
pollIntervalMs: 2000,
|
|
85
|
+
commandTimeoutMs: 20 * 1000,
|
|
86
|
+
launchTimeoutMs: 60 * 1000,
|
|
87
|
+
installTimeoutMs: 5 * 60 * 1000,
|
|
88
|
+
screenshotTimeoutMs: 120 * 1000,
|
|
89
|
+
apkDumpTimeoutMs: 60 * 1000,
|
|
90
|
+
shutdownTimeoutMs: 20 * 1000,
|
|
91
|
+
bootProbeCacheMs: 1000,
|
|
92
|
+
|
|
93
|
+
// bounds
|
|
94
|
+
maxLogLines: 200,
|
|
95
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
96
|
+
screenshotMaxBytes: 48 * 1024 * 1024,
|
|
97
|
+
maxApkDepth: 6,
|
|
98
|
+
maxApkResults: 500,
|
|
99
|
+
apkRoots: [], // default roots for listApks(); empty => [process.cwd()]
|
|
100
|
+
|
|
101
|
+
log: null, // optional (line: string) => void sink in addition to the bounded logTail
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const SKIP_DIR_NAMES = new Set([
|
|
105
|
+
'node_modules',
|
|
106
|
+
'.git',
|
|
107
|
+
'.svn',
|
|
108
|
+
'.hg',
|
|
109
|
+
'$RECYCLE.BIN',
|
|
110
|
+
'System Volume Information',
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const NETWORK_SERIAL_RE = /^[A-Za-z0-9._\-]+:\d{1,5}$/;
|
|
114
|
+
const SERIAL_RE = /^[A-Za-z0-9._\-:]+$/;
|
|
115
|
+
const PACKAGE_RE = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+$/;
|
|
116
|
+
const COMPONENT_RE = /^[A-Za-z0-9_.]+$/;
|
|
117
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
118
|
+
|
|
119
|
+
/** Friendly key names used by UI/HTTP callers -> Android keycodes. */
|
|
120
|
+
const KEYCODE_ALIASES = Object.freeze({
|
|
121
|
+
recents: 'APP_SWITCH',
|
|
122
|
+
recent: 'APP_SWITCH',
|
|
123
|
+
appswitch: 'APP_SWITCH',
|
|
124
|
+
app_switch: 'APP_SWITCH',
|
|
125
|
+
menu: 'MENU',
|
|
126
|
+
backspace: 'DEL',
|
|
127
|
+
delete: 'DEL',
|
|
128
|
+
del: 'DEL',
|
|
129
|
+
return: 'ENTER',
|
|
130
|
+
escape: 'ESCAPE',
|
|
131
|
+
volumeup: 'VOLUME_UP',
|
|
132
|
+
volumedown: 'VOLUME_DOWN',
|
|
133
|
+
mute: 'VOLUME_MUTE',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
function sleep(ms) {
|
|
137
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function asPositiveInt(value, fallback) {
|
|
141
|
+
const number = typeof value === 'string' ? Number(value) : value;
|
|
142
|
+
if (typeof number === 'number' && Number.isFinite(number) && number > 0) return Math.floor(number);
|
|
143
|
+
return fallback;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isNetworkSerial(serial) {
|
|
147
|
+
return typeof serial === 'string' && NETWORK_SERIAL_RE.test(serial);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isPng(buffer) {
|
|
151
|
+
return (
|
|
152
|
+
Buffer.isBuffer(buffer) &&
|
|
153
|
+
buffer.length > PNG_SIGNATURE.length &&
|
|
154
|
+
buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function splitLines(text) {
|
|
159
|
+
return String(text).split(/\r?\n/);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Compare two Android build-tools version directory names, newest first. */
|
|
163
|
+
function compareVersionsDesc(a, b) {
|
|
164
|
+
const parse = (value) => {
|
|
165
|
+
const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(value));
|
|
166
|
+
return m ? [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)] : [0, 0, 0];
|
|
167
|
+
};
|
|
168
|
+
const left = parse(a);
|
|
169
|
+
const right = parse(b);
|
|
170
|
+
for (let index = 0; index < 3; index += 1) {
|
|
171
|
+
if (left[index] !== right[index]) return right[index] - left[index];
|
|
172
|
+
}
|
|
173
|
+
return String(b).localeCompare(String(a));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Candidate Android SDK roots, most explicit first. */
|
|
177
|
+
function candidateSdkRoots(explicitRoot) {
|
|
178
|
+
const roots = [];
|
|
179
|
+
if (explicitRoot) roots.push(String(explicitRoot));
|
|
180
|
+
for (const key of ['ANDROID_HOME', 'ANDROID_SDK_ROOT']) {
|
|
181
|
+
const value = process.env[key];
|
|
182
|
+
if (value && value.trim()) roots.push(value.trim());
|
|
183
|
+
}
|
|
184
|
+
const home = os.homedir();
|
|
185
|
+
if (process.env.LOCALAPPDATA) {
|
|
186
|
+
roots.push(path.join(process.env.LOCALAPPDATA, 'Android', 'Sdk'));
|
|
187
|
+
}
|
|
188
|
+
// Linux / macOS / generic per-user installs.
|
|
189
|
+
roots.push(path.join(home, 'Android', 'Sdk'));
|
|
190
|
+
roots.push(path.join(home, 'Library', 'Android', 'sdk'));
|
|
191
|
+
if (process.env.USERPROFILE) {
|
|
192
|
+
roots.push(path.join(process.env.USERPROFILE, 'AppData', 'Local', 'Android', 'Sdk'));
|
|
193
|
+
}
|
|
194
|
+
// Common system-wide installs.
|
|
195
|
+
roots.push('/usr/lib/android-sdk');
|
|
196
|
+
roots.push('/opt/android-sdk');
|
|
197
|
+
roots.push('/opt/android/sdk');
|
|
198
|
+
// Verified location on this host; still validated with existsSync before use.
|
|
199
|
+
if (process.platform === 'win32') roots.push(path.join('D:\\', 'Android', 'sdk'));
|
|
200
|
+
const unique = [];
|
|
201
|
+
for (const root of roots) {
|
|
202
|
+
const normalized = path.resolve(root);
|
|
203
|
+
const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
204
|
+
if (!unique.some((existing) => (process.platform === 'win32' ? existing.toLowerCase() === key : existing === key))) {
|
|
205
|
+
unique.push(normalized);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return unique;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveAdbPath({ explicitPath, sdkRoot }) {
|
|
212
|
+
if (explicitPath) return String(explicitPath);
|
|
213
|
+
for (const root of candidateSdkRoots(sdkRoot)) {
|
|
214
|
+
const candidate = path.join(root, 'platform-tools', `adb${EXE}`);
|
|
215
|
+
if (existsSync(candidate)) return candidate;
|
|
216
|
+
}
|
|
217
|
+
return `adb${EXE}`; // last resort: rely on PATH; errors surface via spawn ENOENT
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Resolve the emulator executable from an explicit path, SDK root or PATH. */
|
|
221
|
+
function resolveEmulatorPath({ explicitPath, sdkRoot }) {
|
|
222
|
+
if (explicitPath) return String(explicitPath);
|
|
223
|
+
for (const root of candidateSdkRoots(sdkRoot)) {
|
|
224
|
+
const candidate = path.join(root, 'emulator', `emulator${EXE}`);
|
|
225
|
+
if (existsSync(candidate)) return candidate;
|
|
226
|
+
}
|
|
227
|
+
// Last resort: rely on PATH.
|
|
228
|
+
try {
|
|
229
|
+
const pathValue = process.env.PATH || '';
|
|
230
|
+
const entries = pathValue.split(path.delimiter).filter(Boolean);
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
const candidate = path.join(entry, `emulator${EXE}`);
|
|
233
|
+
if (existsSync(candidate)) return candidate;
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
/* ignore */
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Single-quote a string for the *remote* Android shell (mksh/toybox sh). */
|
|
242
|
+
function quoteForDeviceShell(value) {
|
|
243
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `adb shell input text` needs spaces encoded as %s and a single device-shell token.
|
|
248
|
+
* Newlines/tabs collapse to spaces (the input command cannot type them reliably).
|
|
249
|
+
*/
|
|
250
|
+
function encodeInputText(rawText) {
|
|
251
|
+
const flattened = String(rawText)
|
|
252
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
253
|
+
.replace(/[\u0000-\u001f]/g, '');
|
|
254
|
+
return quoteForDeviceShell(flattened.replace(/ /g, '%s'));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseCoordinate(value, field, { min = 0, max = 100000 } = {}) {
|
|
258
|
+
const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
259
|
+
if (typeof number !== 'number' || !Number.isFinite(number)) {
|
|
260
|
+
throw new Error(`input() requires a finite numeric "${field}" (received ${JSON.stringify(value)})`);
|
|
261
|
+
}
|
|
262
|
+
const rounded = Math.round(number);
|
|
263
|
+
if (rounded < min || rounded > max) {
|
|
264
|
+
throw new Error(`input() "${field}" must be between ${min} and ${max} (received ${number})`);
|
|
265
|
+
}
|
|
266
|
+
return rounded;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function normalizeKeycode(value) {
|
|
270
|
+
if (value === undefined || value === null || value === '') {
|
|
271
|
+
throw new Error('input({ type: "key" }) requires "key" or "keycode"');
|
|
272
|
+
}
|
|
273
|
+
const text = String(value).trim();
|
|
274
|
+
if (/^\d+$/.test(text)) {
|
|
275
|
+
const code = Number(text);
|
|
276
|
+
if (!Number.isSafeInteger(code) || code < 0 || code > 100000) {
|
|
277
|
+
throw new Error(`input() keycode out of range: ${text}`);
|
|
278
|
+
}
|
|
279
|
+
return String(code);
|
|
280
|
+
}
|
|
281
|
+
const upper = text.toUpperCase();
|
|
282
|
+
if (!/^[A-Z0-9_]+$/.test(upper)) {
|
|
283
|
+
throw new Error(`input() invalid key: ${JSON.stringify(value)}`);
|
|
284
|
+
}
|
|
285
|
+
const alias = KEYCODE_ALIASES[text.toLowerCase().replace(/[-_\s]/g, '')];
|
|
286
|
+
if (alias) return `KEYCODE_${alias}`;
|
|
287
|
+
return upper.startsWith('KEYCODE_') ? upper : `KEYCODE_${upper}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Minimal ZIP entry reader for extracting an APK icon without extra deps. */
|
|
291
|
+
function readZipEntry(buffer, entryName) {
|
|
292
|
+
const wanted = String(entryName || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
293
|
+
if (!wanted) throw new Error('empty ZIP entry name');
|
|
294
|
+
let eocd = -1;
|
|
295
|
+
const min = Math.max(0, buffer.length - 65557);
|
|
296
|
+
for (let i = buffer.length - 22; i >= min; i -= 1) {
|
|
297
|
+
if (buffer.readUInt32LE(i) === 0x06054b50) {
|
|
298
|
+
eocd = i;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (eocd < 0) throw new Error('not a ZIP/APK: end-of-central-directory not found');
|
|
303
|
+
const total = buffer.readUInt16LE(eocd + 10);
|
|
304
|
+
let offset = buffer.readUInt32LE(eocd + 16);
|
|
305
|
+
for (let index = 0; index < total; index += 1) {
|
|
306
|
+
if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error('corrupt ZIP central directory');
|
|
307
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
308
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
309
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
310
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
311
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
312
|
+
const localOffset = buffer.readUInt32LE(offset + 42);
|
|
313
|
+
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength).replace(/\\/g, '/');
|
|
314
|
+
if (name === wanted) {
|
|
315
|
+
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('corrupt ZIP local header');
|
|
316
|
+
const localNameLength = buffer.readUInt16LE(localOffset + 26);
|
|
317
|
+
const localExtraLength = buffer.readUInt16LE(localOffset + 28);
|
|
318
|
+
const dataStart = localOffset + 30 + localNameLength + localExtraLength;
|
|
319
|
+
const data = buffer.subarray(dataStart, dataStart + compressedSize);
|
|
320
|
+
if (method === 0) return Buffer.from(data);
|
|
321
|
+
if (method === 8) return inflateRawSync(data);
|
|
322
|
+
throw new Error(`unsupported ZIP compression method ${method}`);
|
|
323
|
+
}
|
|
324
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
325
|
+
}
|
|
326
|
+
throw new Error(`ZIP entry not found: ${wanted}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Parse `aapt2 dump badging` output. Exported shape is the module's public one. */
|
|
330
|
+
function parseBadging(text) {
|
|
331
|
+
let packageName = null;
|
|
332
|
+
let versionName = null;
|
|
333
|
+
let versionCode = null;
|
|
334
|
+
let sdkVersion = null;
|
|
335
|
+
let targetSdkVersion = null;
|
|
336
|
+
let applicationLabel = null;
|
|
337
|
+
let launchActivity = null;
|
|
338
|
+
let launchableLabel = null;
|
|
339
|
+
let iconPath = null;
|
|
340
|
+
let iconDensity = -1;
|
|
341
|
+
const permissions = [];
|
|
342
|
+
const features = [];
|
|
343
|
+
const nativeCode = [];
|
|
344
|
+
|
|
345
|
+
for (const rawLine of splitLines(text)) {
|
|
346
|
+
const line = rawLine.trim();
|
|
347
|
+
if (!packageName && line.startsWith('package:')) {
|
|
348
|
+
const match = /^package:\s+name='([^']*)'/.exec(line);
|
|
349
|
+
if (match) packageName = match[1];
|
|
350
|
+
const versionCodeMatch = /\bversionCode='([^']*)'/.exec(line);
|
|
351
|
+
const versionNameMatch = /\bversionName='([^']*)'/.exec(line);
|
|
352
|
+
if (versionCodeMatch) versionCode = versionCodeMatch[1];
|
|
353
|
+
if (versionNameMatch) versionName = versionNameMatch[1];
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (line.startsWith('sdkVersion:')) {
|
|
357
|
+
const match = /sdkVersion:'([^']*)'/.exec(line);
|
|
358
|
+
if (match) sdkVersion = Number(match[1]);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (line.startsWith('targetSdkVersion:')) {
|
|
362
|
+
const match = /targetSdkVersion:'([^']*)'/.exec(line);
|
|
363
|
+
if (match) targetSdkVersion = Number(match[1]);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (line.startsWith('uses-permission:')) {
|
|
367
|
+
const match = /name='((?:[^'\\]|\\.)*)'/.exec(line);
|
|
368
|
+
if (match) permissions.push(unescapeAapt(match[1]));
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (line.startsWith('uses-feature:')) {
|
|
372
|
+
const match = /name='((?:[^'\\]|\\.)*)'/.exec(line);
|
|
373
|
+
if (match) features.push(unescapeAapt(match[1]));
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (line.startsWith('native-code:')) {
|
|
377
|
+
const matches = line.match(/'([^']*)'/g) || [];
|
|
378
|
+
for (const quoted of matches) nativeCode.push(quoted.slice(1, -1));
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (line.startsWith('application:')) {
|
|
382
|
+
if (!applicationLabel) {
|
|
383
|
+
const match = /^application:.*?\blabel='((?:[^'\\]|\\.)*)'/.exec(line);
|
|
384
|
+
if (match) applicationLabel = unescapeAapt(match[1]);
|
|
385
|
+
}
|
|
386
|
+
const iconMatch = /^application:.*?\bicon='((?:[^'\\]|\\.)*)'/.exec(line);
|
|
387
|
+
if (iconMatch && !iconPath) iconPath = unescapeAapt(iconMatch[1]);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (line.startsWith('application-label:')) {
|
|
391
|
+
if (!applicationLabel) {
|
|
392
|
+
const match = /^application-label:'((?:[^'\\]|\\.)*)'/.exec(line);
|
|
393
|
+
if (match) applicationLabel = unescapeAapt(match[1]);
|
|
394
|
+
}
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (line.startsWith('application-icon-')) {
|
|
398
|
+
const match = /^application-icon-(\d+):'((?:[^'\\]|\\.)*)'/.exec(line);
|
|
399
|
+
if (match) {
|
|
400
|
+
const density = Number(match[1]);
|
|
401
|
+
if (density > iconDensity) {
|
|
402
|
+
iconDensity = density;
|
|
403
|
+
iconPath = unescapeAapt(match[2]);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (!launchActivity && line.startsWith('launchable-activity:')) {
|
|
409
|
+
const nameMatch = /name='((?:[^'\\]|\\.)*)'/.exec(line);
|
|
410
|
+
const labelMatch = /label='((?:[^'\\]|\\.)*)'/.exec(line);
|
|
411
|
+
if (nameMatch) launchActivity = unescapeAapt(nameMatch[1]);
|
|
412
|
+
if (labelMatch) launchableLabel = unescapeAapt(labelMatch[1]);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!packageName) {
|
|
417
|
+
throw new Error('aapt2 badging output has no package: line (not a valid APK?)');
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
packageName,
|
|
421
|
+
label: applicationLabel || launchableLabel || packageName,
|
|
422
|
+
launchActivity: launchActivity || null,
|
|
423
|
+
versionName: versionName || null,
|
|
424
|
+
versionCode: versionCode || null,
|
|
425
|
+
sdkVersion,
|
|
426
|
+
targetSdkVersion,
|
|
427
|
+
permissions: [...new Set(permissions)],
|
|
428
|
+
features: [...new Set(features)],
|
|
429
|
+
nativeCode: [...new Set(nativeCode)],
|
|
430
|
+
iconPath: iconPath || null,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function unescapeAapt(value) {
|
|
435
|
+
return String(value).replace(/\\(['"\\])/g, '$1');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function parseWmSize(text) {
|
|
439
|
+
const override = /Override size:\s*(\d+)\s*x\s*(\d+)/i.exec(text);
|
|
440
|
+
const physical = /Physical size:\s*(\d+)\s*x\s*(\d+)/i.exec(text);
|
|
441
|
+
const match = override || physical;
|
|
442
|
+
if (!match) return null;
|
|
443
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export class AndroidVm {
|
|
447
|
+
constructor(options = {}) {
|
|
448
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
449
|
+
this.options = opts;
|
|
450
|
+
|
|
451
|
+
this.sdkRoot = opts.sdkRoot ? String(opts.sdkRoot) : null;
|
|
452
|
+
this.adbPath = resolveAdbPath({ explicitPath: opts.adbPath, sdkRoot: this.sdkRoot });
|
|
453
|
+
this.emulatorPath = opts.emulatorPath
|
|
454
|
+
? String(opts.emulatorPath)
|
|
455
|
+
: resolveEmulatorPath({ sdkRoot: this.sdkRoot });
|
|
456
|
+
this.avdName = opts.avdName ? String(opts.avdName) : null;
|
|
457
|
+
this.emulatorArgs = Array.isArray(opts.emulatorArgs) ? opts.emulatorArgs.map(String) : [];
|
|
458
|
+
this.spawnEmulatorEnabled = opts.spawnEmulator !== false;
|
|
459
|
+
|
|
460
|
+
this.serial = opts.serial ? String(opts.serial) : null;
|
|
461
|
+
this.port = asPositiveInt(opts.port, DEFAULT_OPTIONS.port);
|
|
462
|
+
this._external = false;
|
|
463
|
+
this._externalSerial = null;
|
|
464
|
+
this._managedAvdName = this.avdName;
|
|
465
|
+
this._managedSerial = this.serial;
|
|
466
|
+
this._managedPort = this.port;
|
|
467
|
+
|
|
468
|
+
// Boot-speed knobs: host GPU, detected hypervisor, quick-boot snapshots.
|
|
469
|
+
this.gpuMode =
|
|
470
|
+
typeof opts.gpuMode === 'string' && opts.gpuMode.trim()
|
|
471
|
+
? opts.gpuMode.trim()
|
|
472
|
+
: DEFAULT_OPTIONS.gpuMode;
|
|
473
|
+
this.accel = opts.accel === 'on' || opts.accel === 'off' ? opts.accel : 'auto';
|
|
474
|
+
this.requireAccel = opts.requireAccel !== false;
|
|
475
|
+
this.accelCheckTimeoutMs = asPositiveInt(opts.accelCheckTimeoutMs, DEFAULT_OPTIONS.accelCheckTimeoutMs);
|
|
476
|
+
this.quickBoot = opts.quickBoot !== false;
|
|
477
|
+
this.snapshotName = opts.snapshotName ? String(opts.snapshotName) : DEFAULT_OPTIONS.snapshotName;
|
|
478
|
+
this.saveSnapshotAfterBoot = opts.saveSnapshotAfterBoot !== false;
|
|
479
|
+
|
|
480
|
+
this.bootTimeoutMs = asPositiveInt(opts.bootTimeoutMs, DEFAULT_OPTIONS.bootTimeoutMs);
|
|
481
|
+
this.connectTimeoutMs = asPositiveInt(opts.connectTimeoutMs, DEFAULT_OPTIONS.connectTimeoutMs);
|
|
482
|
+
this.pollIntervalMs = asPositiveInt(opts.pollIntervalMs, DEFAULT_OPTIONS.pollIntervalMs);
|
|
483
|
+
this.commandTimeoutMs = asPositiveInt(opts.commandTimeoutMs, DEFAULT_OPTIONS.commandTimeoutMs);
|
|
484
|
+
this.launchTimeoutMs = asPositiveInt(opts.launchTimeoutMs, DEFAULT_OPTIONS.launchTimeoutMs);
|
|
485
|
+
this.installTimeoutMs = asPositiveInt(opts.installTimeoutMs, DEFAULT_OPTIONS.installTimeoutMs);
|
|
486
|
+
this.screenshotTimeoutMs = asPositiveInt(opts.screenshotTimeoutMs, DEFAULT_OPTIONS.screenshotTimeoutMs);
|
|
487
|
+
this.apkDumpTimeoutMs = asPositiveInt(opts.apkDumpTimeoutMs, DEFAULT_OPTIONS.apkDumpTimeoutMs);
|
|
488
|
+
this.shutdownTimeoutMs = asPositiveInt(opts.shutdownTimeoutMs, DEFAULT_OPTIONS.shutdownTimeoutMs);
|
|
489
|
+
this.bootProbeCacheMs = asPositiveInt(opts.bootProbeCacheMs, DEFAULT_OPTIONS.bootProbeCacheMs);
|
|
490
|
+
|
|
491
|
+
this.maxLogLines = asPositiveInt(opts.maxLogLines, DEFAULT_OPTIONS.maxLogLines);
|
|
492
|
+
this.maxOutputBytes = asPositiveInt(opts.maxOutputBytes, DEFAULT_OPTIONS.maxOutputBytes);
|
|
493
|
+
this.screenshotMaxBytes = asPositiveInt(opts.screenshotMaxBytes, DEFAULT_OPTIONS.screenshotMaxBytes);
|
|
494
|
+
this.maxApkDepth = asPositiveInt(opts.maxApkDepth, DEFAULT_OPTIONS.maxApkDepth);
|
|
495
|
+
this.maxApkResults = asPositiveInt(opts.maxApkResults, DEFAULT_OPTIONS.maxApkResults);
|
|
496
|
+
this.apkRoots = Array.isArray(opts.apkRoots) ? opts.apkRoots.map(String) : [];
|
|
497
|
+
this.logSink = typeof opts.log === 'function' ? opts.log : null;
|
|
498
|
+
|
|
499
|
+
// Runtime state.
|
|
500
|
+
this._state = 'stopped';
|
|
501
|
+
this._error = null;
|
|
502
|
+
this._bootMs = null;
|
|
503
|
+
this._serialActive = null;
|
|
504
|
+
this._child = null; // spawned emulator process, if any
|
|
505
|
+
this._spawnedByUs = false;
|
|
506
|
+
this._bootStartAt = null;
|
|
507
|
+
this._sawNotBooted = false;
|
|
508
|
+
this._bootProbe = { serial: null, at: 0, booted: false };
|
|
509
|
+
this._bootProbeInFlight = null;
|
|
510
|
+
this._screenshotInFlight = null;
|
|
511
|
+
this._devicesCache = { at: 0, devices: new Map() };
|
|
512
|
+
this._lastProbeError = null;
|
|
513
|
+
this._logTail = [];
|
|
514
|
+
this._aapt2Resolved = undefined;
|
|
515
|
+
this._starting = null;
|
|
516
|
+
this._bootWait = null;
|
|
517
|
+
this._waitEpoch = 0;
|
|
518
|
+
this._disposed = false;
|
|
519
|
+
this._disposing = null;
|
|
520
|
+
|
|
521
|
+
// Boot-speed bookkeeping (exposed by status()).
|
|
522
|
+
this._bootKind = null; // 'cold' | 'snapshot' | 'attach'
|
|
523
|
+
this._snapshotAvailable = null;
|
|
524
|
+
this._accelAvailable = null;
|
|
525
|
+
this._snapshotSave = null;
|
|
526
|
+
this._snapshotLoadFailed = false;
|
|
527
|
+
this._lastLaunchArgs = null;
|
|
528
|
+
|
|
529
|
+
// Rooted / debuggable guest state (exposed by status()).
|
|
530
|
+
this._root = false;
|
|
531
|
+
this._debugPrepared = false;
|
|
532
|
+
this._debugToolkitPath = null;
|
|
533
|
+
this._identity = null;
|
|
534
|
+
this._preparePromise = null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Public API
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
/** Current VM snapshot. Refreshes device/boot state from adb when possible. */
|
|
542
|
+
async status() {
|
|
543
|
+
if (this._disposed) return this._snapshot();
|
|
544
|
+
try {
|
|
545
|
+
await this._refresh();
|
|
546
|
+
} catch (error) {
|
|
547
|
+
this._lastProbeError = error && error.message ? error.message : String(error);
|
|
548
|
+
this._setState('error', `status() probe failed: ${this._lastProbeError}`);
|
|
549
|
+
}
|
|
550
|
+
return this._snapshot();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Idempotent bring-up: attach to a live device, `adb connect` a configured network
|
|
555
|
+
* serial, or (only when explicitly configured) spawn the AOSP emulator, then wait
|
|
556
|
+
* for Android to finish booting. Expected failures are stored in status().error and
|
|
557
|
+
* never thrown.
|
|
558
|
+
*/
|
|
559
|
+
async start() {
|
|
560
|
+
if (this._disposed) throw new Error('AndroidVm has been disposed');
|
|
561
|
+
// Serialize with a start that a stop() may have interrupted: the stale run aborts
|
|
562
|
+
// via _waitEpoch, then this call performs a clean attach/boot.
|
|
563
|
+
while (this._starting) {
|
|
564
|
+
await this._starting.catch(() => {});
|
|
565
|
+
}
|
|
566
|
+
const epoch = this._waitEpoch;
|
|
567
|
+
this._starting = this._startInner(epoch)
|
|
568
|
+
.catch((error) => {
|
|
569
|
+
const message = error && error.message ? error.message : String(error);
|
|
570
|
+
if (epoch === this._waitEpoch) this._setState('error', `start() failed: ${message}`);
|
|
571
|
+
})
|
|
572
|
+
.finally(() => {
|
|
573
|
+
this._starting = null;
|
|
574
|
+
});
|
|
575
|
+
return this._starting;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Stop the attached VM: graceful emulator shutdown when possible, then adb detach. */
|
|
579
|
+
async stop() {
|
|
580
|
+
this._waitEpoch += 1; // abort an in-flight boot wait
|
|
581
|
+
this._bootWait = null; // let a subsequent start() begin a fresh wait
|
|
582
|
+
this._pushLog('[vm] stop requested');
|
|
583
|
+
if (this._external) {
|
|
584
|
+
// Never kill or disconnect a user's physical/remote device; just detach.
|
|
585
|
+
this._serialActive = null;
|
|
586
|
+
this._setState('stopped');
|
|
587
|
+
this._pushLog(`[vm] detached external device ${this._externalSerial || ''}`.trim());
|
|
588
|
+
return this._snapshot();
|
|
589
|
+
}
|
|
590
|
+
const serial = this._serialActive || this.serial;
|
|
591
|
+
const child = this._child;
|
|
592
|
+
|
|
593
|
+
if (serial) {
|
|
594
|
+
try {
|
|
595
|
+
const result = await this._adb(['-s', serial, 'emu', 'kill'], { timeoutMs: 10000 });
|
|
596
|
+
if (result && result.code === 0) this._pushLog(`[vm] sent emulator kill to ${serial}`);
|
|
597
|
+
} catch {
|
|
598
|
+
/* not an emulator console device — ignore */
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const deadline = Date.now() + this.shutdownTimeoutMs;
|
|
602
|
+
let stillConnected = true;
|
|
603
|
+
while (Date.now() < deadline) {
|
|
604
|
+
const devices = await this._listDevices({ timeoutMs: 5000 });
|
|
605
|
+
stillConnected = devices.has(serial);
|
|
606
|
+
if (!stillConnected) break;
|
|
607
|
+
if (isNetworkSerial(serial)) {
|
|
608
|
+
await this._adb(['disconnect', serial], { timeoutMs: 10000 }).catch(() => {});
|
|
609
|
+
}
|
|
610
|
+
await sleep(500);
|
|
611
|
+
}
|
|
612
|
+
if (stillConnected && isNetworkSerial(serial)) {
|
|
613
|
+
await this._adb(['disconnect', serial], { timeoutMs: 10000 }).catch(() => {});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (child && child.exitCode === null) {
|
|
618
|
+
killProcessTree(child);
|
|
619
|
+
const exited = await waitForChildExit(child, 5000);
|
|
620
|
+
if (!exited && child.exitCode === null) killProcessTree(child);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
this._child = null;
|
|
624
|
+
this._spawnedByUs = false;
|
|
625
|
+
this._serialActive = null;
|
|
626
|
+
this._bootMs = null;
|
|
627
|
+
this._bootKind = null;
|
|
628
|
+
this._bootStartAt = null;
|
|
629
|
+
this._sawNotBooted = false;
|
|
630
|
+
this._bootProbe = { serial: null, at: 0, booted: false };
|
|
631
|
+
this._root = false;
|
|
632
|
+
this._debugPrepared = false;
|
|
633
|
+
this._debugToolkitPath = null;
|
|
634
|
+
this._identity = null;
|
|
635
|
+
this._devicesCache = { at: 0, devices: new Map() };
|
|
636
|
+
this._lastProbeError = null;
|
|
637
|
+
this._setState('stopped');
|
|
638
|
+
this._pushLog('[vm] stopped');
|
|
639
|
+
return this._snapshot();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Device display size from `adb shell wm size` (override size preferred). */
|
|
643
|
+
async screenSize() {
|
|
644
|
+
const serial = await this._requireSerial();
|
|
645
|
+
const output = await this._adbText(['-s', serial, 'shell', 'wm', 'size'], {
|
|
646
|
+
timeoutMs: this.commandTimeoutMs,
|
|
647
|
+
});
|
|
648
|
+
const size = parseWmSize(output);
|
|
649
|
+
if (!size) {
|
|
650
|
+
throw new Error(`could not parse screen size from "wm size": ${output.trim().slice(0, 300)}`);
|
|
651
|
+
}
|
|
652
|
+
return size;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** PNG screenshot as a Buffer (primary: `adb exec-out screencap -p`; file/pull fallback). */
|
|
656
|
+
async screenshot() {
|
|
657
|
+
// Coalesce concurrent requests (the panel may poll faster than screencap on TCG).
|
|
658
|
+
if (this._screenshotInFlight) return this._screenshotInFlight;
|
|
659
|
+
this._screenshotInFlight = this._screenshotInner().finally(() => {
|
|
660
|
+
this._screenshotInFlight = null;
|
|
661
|
+
});
|
|
662
|
+
return this._screenshotInFlight;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async _screenshotInner() {
|
|
666
|
+
const serial = await this._requireSerial();
|
|
667
|
+
const startedAt = Date.now();
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
const result = await this._adb(['-s', serial, 'exec-out', 'screencap', '-p'], {
|
|
671
|
+
timeoutMs: this.screenshotTimeoutMs,
|
|
672
|
+
maxBytes: this.screenshotMaxBytes,
|
|
673
|
+
});
|
|
674
|
+
if (!result.timedOut && result.code === 0 && isPng(result.stdout)) {
|
|
675
|
+
this._pushLog(
|
|
676
|
+
`[vm] screenshot ${result.stdout.length} bytes in ${Date.now() - startedAt}ms (exec-out, ${serial})`,
|
|
677
|
+
);
|
|
678
|
+
return result.stdout;
|
|
679
|
+
}
|
|
680
|
+
this._pushLog(
|
|
681
|
+
`[vm] exec-out screencap unusable (code=${result.code}${result.timedOut ? ', timeout' : ''}, ` +
|
|
682
|
+
`${result.stdout.length} bytes, png=${isPng(result.stdout)}); falling back to file+pull`,
|
|
683
|
+
);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
this._pushLog(`[vm] exec-out screencap failed (${error.message}); falling back to file+pull`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const remotePath = `/data/local/tmp/dsh-screencap-${Date.now()}.png`;
|
|
689
|
+
const localPath = path.join(
|
|
690
|
+
os.tmpdir(),
|
|
691
|
+
`dsh-screencap-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`,
|
|
692
|
+
);
|
|
693
|
+
try {
|
|
694
|
+
await this._adbText(['-s', serial, 'shell', 'screencap', '-p', remotePath], {
|
|
695
|
+
timeoutMs: this.screenshotTimeoutMs,
|
|
696
|
+
});
|
|
697
|
+
const pull = await this._adb(['-s', serial, 'pull', remotePath, localPath], {
|
|
698
|
+
timeoutMs: this.screenshotTimeoutMs,
|
|
699
|
+
});
|
|
700
|
+
if (pull.timedOut || pull.code !== 0) {
|
|
701
|
+
const detail = pull.timedOut
|
|
702
|
+
? `timed out after ${this.screenshotTimeoutMs}ms`
|
|
703
|
+
: `exit ${pull.code}: ${(pull.stderr || '').trim().slice(0, 300)}`;
|
|
704
|
+
throw new Error(`adb pull failed (${detail})`);
|
|
705
|
+
}
|
|
706
|
+
const buffer = await fsp.readFile(localPath);
|
|
707
|
+
if (!isPng(buffer)) {
|
|
708
|
+
throw new Error(`pulled screencap is not a PNG (${buffer.length} bytes)`);
|
|
709
|
+
}
|
|
710
|
+
this._pushLog(
|
|
711
|
+
`[vm] screenshot ${buffer.length} bytes in ${Date.now() - startedAt}ms (file+pull, ${serial})`,
|
|
712
|
+
);
|
|
713
|
+
return buffer;
|
|
714
|
+
} catch (error) {
|
|
715
|
+
throw new Error(`screenshot failed on ${serial}: ${error.message}`);
|
|
716
|
+
} finally {
|
|
717
|
+
await fsp.rm(localPath, { force: true }).catch(() => {});
|
|
718
|
+
this._adb(['-s', serial, 'shell', 'rm', '-f', remotePath], { timeoutMs: 10000 }).catch(() => {});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Recursively find *.apk files. Depth-bounded, skips node_modules/.git and junk
|
|
724
|
+
* VCS/OS directories, returns newest first: [{ path, name, size, mtime(ISO) }].
|
|
725
|
+
*/
|
|
726
|
+
async listApks(roots = []) {
|
|
727
|
+
const requested =
|
|
728
|
+
Array.isArray(roots) && roots.length > 0
|
|
729
|
+
? roots
|
|
730
|
+
: this.apkRoots.length > 0
|
|
731
|
+
? this.apkRoots
|
|
732
|
+
: [process.cwd()];
|
|
733
|
+
|
|
734
|
+
const results = [];
|
|
735
|
+
const seen = new Set();
|
|
736
|
+
const stack = [];
|
|
737
|
+
for (const root of requested) {
|
|
738
|
+
if (typeof root !== 'string' || root.trim() === '') continue;
|
|
739
|
+
const absolute = path.resolve(root);
|
|
740
|
+
stack.push({ dir: absolute, depth: 0 });
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
while (stack.length > 0 && results.length < this.maxApkResults) {
|
|
744
|
+
const { dir, depth } = stack.pop();
|
|
745
|
+
let entries;
|
|
746
|
+
try {
|
|
747
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
748
|
+
} catch {
|
|
749
|
+
continue; // unreadable / vanished directory
|
|
750
|
+
}
|
|
751
|
+
for (const entry of entries) {
|
|
752
|
+
if (results.length >= this.maxApkResults) break;
|
|
753
|
+
const name = entry.name;
|
|
754
|
+
if (entry.isSymbolicLink()) continue; // avoid cycles / outside-tree links
|
|
755
|
+
const fullPath = path.join(dir, name);
|
|
756
|
+
if (entry.isDirectory()) {
|
|
757
|
+
if (SKIP_DIR_NAMES.has(name) || /^\$/.test(name)) continue;
|
|
758
|
+
if (depth + 1 <= this.maxApkDepth) stack.push({ dir: fullPath, depth: depth + 1 });
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (!entry.isFile() || !/\.apk$/i.test(name)) continue;
|
|
762
|
+
const key = IS_WIN ? fullPath.toLowerCase() : fullPath;
|
|
763
|
+
if (seen.has(key)) continue;
|
|
764
|
+
seen.add(key);
|
|
765
|
+
try {
|
|
766
|
+
const stat = await fsp.stat(fullPath);
|
|
767
|
+
results.push({
|
|
768
|
+
path: fullPath,
|
|
769
|
+
name,
|
|
770
|
+
size: stat.size,
|
|
771
|
+
mtime: new Date(stat.mtimeMs).toISOString(),
|
|
772
|
+
});
|
|
773
|
+
} catch {
|
|
774
|
+
/* file vanished mid-scan */
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
results.sort(
|
|
780
|
+
(left, right) =>
|
|
781
|
+
right.mtime.localeCompare(left.mtime) || left.name.localeCompare(right.name),
|
|
782
|
+
);
|
|
783
|
+
return results;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Read packageName / label / launchActivity from an APK with aapt2 `dump badging`. */
|
|
787
|
+
async inspectApk(apkPath) {
|
|
788
|
+
const file = await this._assertApkFile(apkPath);
|
|
789
|
+
const aapt2 = await this._findAapt2();
|
|
790
|
+
if (!aapt2) {
|
|
791
|
+
throw new Error(
|
|
792
|
+
'aapt2 not found: set options.aapt2Path or install Android SDK build-tools ' +
|
|
793
|
+
'(searched ANDROID_HOME/ANDROID_SDK_ROOT/build-tools/*/aapt2)',
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
const result = await this._run(aapt2, ['dump', 'badging', file], {
|
|
797
|
+
timeoutMs: this.apkDumpTimeoutMs,
|
|
798
|
+
maxBytes: 4 * 1024 * 1024,
|
|
799
|
+
});
|
|
800
|
+
if (result.timedOut) {
|
|
801
|
+
throw new Error(`aapt2 dump badging timed out after ${this.apkDumpTimeoutMs}ms for ${file}`);
|
|
802
|
+
}
|
|
803
|
+
if (result.code !== 0) {
|
|
804
|
+
const detail = (result.stderr || result.stdout.toString('utf8')).trim().slice(0, 400);
|
|
805
|
+
throw new Error(`aapt2 dump badging failed (exit ${result.code}): ${detail}`);
|
|
806
|
+
}
|
|
807
|
+
return parseBadging(result.stdout.toString('utf8'));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Extract the launcher icon (PNG/WebP/JPEG) referenced by an APK's badging. */
|
|
811
|
+
async inspectApkIcon(apkPath) {
|
|
812
|
+
const file = await this._assertApkFile(apkPath);
|
|
813
|
+
const metadata = await this.inspectApk(file);
|
|
814
|
+
if (!metadata.iconPath) throw new Error('APK has no launcher icon');
|
|
815
|
+
const archive = await fsp.readFile(file);
|
|
816
|
+
const icon = readZipEntry(archive, metadata.iconPath);
|
|
817
|
+
const lower = metadata.iconPath.toLowerCase();
|
|
818
|
+
const mime = lower.endsWith('.webp')
|
|
819
|
+
? 'image/webp'
|
|
820
|
+
: lower.endsWith('.jpg') || lower.endsWith('.jpeg')
|
|
821
|
+
? 'image/jpeg'
|
|
822
|
+
: lower.endsWith('.xml')
|
|
823
|
+
? 'application/xml'
|
|
824
|
+
: 'image/png';
|
|
825
|
+
return { data: icon, mime, entry: metadata.iconPath, packageName: metadata.packageName };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Install an APK with `adb install -r` (retries with -t for test-only packages),
|
|
830
|
+
* then resolve its metadata and best-effort launch it so the panel shows the app.
|
|
831
|
+
* Returns { packageName, label, launchActivity }.
|
|
832
|
+
*/
|
|
833
|
+
async installApk(apkPath) {
|
|
834
|
+
const file = await this._assertApkFile(apkPath);
|
|
835
|
+
const serial = await this._requireSerial();
|
|
836
|
+
const stat = await fsp.stat(file);
|
|
837
|
+
const metadata = await this.inspectApk(file);
|
|
838
|
+
|
|
839
|
+
this._pushLog(
|
|
840
|
+
`[vm] installing ${path.basename(file)} (${stat.size} bytes) on ${serial} ` +
|
|
841
|
+
`[${metadata.packageName}]`,
|
|
842
|
+
);
|
|
843
|
+
const startedAt = Date.now();
|
|
844
|
+
let result = await this._adb(['-s', serial, 'install', '-r', file], {
|
|
845
|
+
timeoutMs: this.installTimeoutMs,
|
|
846
|
+
maxBytes: 8 * 1024 * 1024,
|
|
847
|
+
});
|
|
848
|
+
let output = `${result.stdout.toString('utf8')}\n${result.stderr}`;
|
|
849
|
+
if (/INSTALL_FAILED_TEST_ONLY/i.test(output)) {
|
|
850
|
+
this._pushLog('[vm] package is test-only; retrying with adb install -r -t');
|
|
851
|
+
result = await this._adb(['-s', serial, 'install', '-r', '-t', file], {
|
|
852
|
+
timeoutMs: this.installTimeoutMs,
|
|
853
|
+
maxBytes: 8 * 1024 * 1024,
|
|
854
|
+
});
|
|
855
|
+
output = `${result.stdout.toString('utf8')}\n${result.stderr}`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (result.timedOut) {
|
|
859
|
+
throw new Error(`adb install timed out after ${this.installTimeoutMs}ms`);
|
|
860
|
+
}
|
|
861
|
+
const failure = /Failure\s*\[([^\]]+)\]\s*:?\s*([^\r\n]*)/i.exec(output);
|
|
862
|
+
if (failure) {
|
|
863
|
+
throw new Error(
|
|
864
|
+
`adb install failed: ${failure[1]}${failure[2] && failure[2].trim() ? `: ${failure[2].trim()}` : ''}`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
if (result.code !== 0 || !/Success/i.test(output)) {
|
|
868
|
+
throw new Error(
|
|
869
|
+
`adb install did not report success (exit ${result.code}): ${output.trim().slice(0, 400)}`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
this._pushLog(`[vm] installed ${metadata.packageName} in ${Date.now() - startedAt}ms`);
|
|
873
|
+
|
|
874
|
+
if (metadata.launchActivity) {
|
|
875
|
+
try {
|
|
876
|
+
await this.launchPackage(metadata.packageName, metadata.launchActivity);
|
|
877
|
+
this._pushLog(`[vm] launched ${metadata.packageName}/${metadata.launchActivity}`);
|
|
878
|
+
} catch (error) {
|
|
879
|
+
this._pushLog(`[vm] install succeeded but launch failed: ${error.message}`);
|
|
880
|
+
}
|
|
881
|
+
} else {
|
|
882
|
+
this._pushLog(`[vm] ${metadata.packageName} exposes no launchable activity; not launched`);
|
|
883
|
+
}
|
|
884
|
+
return metadata;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Launch a package/activity via `adb shell am start -n` (monkey fallback without activity). */
|
|
888
|
+
async launchPackage(packageName, launchActivity) {
|
|
889
|
+
const pkg = String(packageName || '').trim();
|
|
890
|
+
if (!PACKAGE_RE.test(pkg)) {
|
|
891
|
+
throw new Error(`invalid package name: ${JSON.stringify(packageName)}`);
|
|
892
|
+
}
|
|
893
|
+
const serial = await this._requireSerial();
|
|
894
|
+
|
|
895
|
+
let command;
|
|
896
|
+
if (launchActivity) {
|
|
897
|
+
let activity = String(launchActivity).trim();
|
|
898
|
+
if (!COMPONENT_RE.test(activity)) {
|
|
899
|
+
throw new Error(`invalid activity name: ${JSON.stringify(launchActivity)}`);
|
|
900
|
+
}
|
|
901
|
+
if (activity.startsWith('.')) activity = pkg + activity;
|
|
902
|
+
else if (!activity.includes('.')) activity = `${pkg}.${activity}`;
|
|
903
|
+
command = `am start -n ${pkg}/${activity}`;
|
|
904
|
+
} else {
|
|
905
|
+
command = `monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const result = await this._adb(['-s', serial, 'shell', command], {
|
|
909
|
+
timeoutMs: this.launchTimeoutMs,
|
|
910
|
+
maxBytes: 512 * 1024,
|
|
911
|
+
});
|
|
912
|
+
const output = `${result.stdout.toString('utf8')}\n${result.stderr}`.trim();
|
|
913
|
+
if (result.timedOut) {
|
|
914
|
+
throw new Error(`"${command}" timed out after ${this.launchTimeoutMs}ms`);
|
|
915
|
+
}
|
|
916
|
+
if (
|
|
917
|
+
result.code !== 0 ||
|
|
918
|
+
/Error:|Exception|does not exist|No activities found|not found/i.test(output)
|
|
919
|
+
) {
|
|
920
|
+
throw new Error(`launch failed (${command}): ${output.slice(0, 500)}`);
|
|
921
|
+
}
|
|
922
|
+
return { ok: true };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Inject input: { type: 'tap', x, y } | { type: 'swipe', x1, y1, x2, y2, durationMs? }
|
|
927
|
+
* | { type: 'key', key } | { type: 'text', text }. Resolves { ok: true }.
|
|
928
|
+
*/
|
|
929
|
+
async input(payload) {
|
|
930
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
931
|
+
throw new Error('input(payload) requires an object payload');
|
|
932
|
+
}
|
|
933
|
+
const type = String(payload.type || payload.action || payload.kind || '').toLowerCase();
|
|
934
|
+
|
|
935
|
+
let command;
|
|
936
|
+
if (type === 'tap') {
|
|
937
|
+
const x = parseCoordinate(payload.x, 'x');
|
|
938
|
+
const y = parseCoordinate(payload.y, 'y');
|
|
939
|
+
command = `input tap ${x} ${y}`;
|
|
940
|
+
} else if (type === 'swipe') {
|
|
941
|
+
const x1 = parseCoordinate(payload.x1, 'x1');
|
|
942
|
+
const y1 = parseCoordinate(payload.y1, 'y1');
|
|
943
|
+
const x2 = parseCoordinate(payload.x2, 'x2');
|
|
944
|
+
const y2 = parseCoordinate(payload.y2, 'y2');
|
|
945
|
+
const duration = parseCoordinate(
|
|
946
|
+
payload.durationMs !== undefined ? payload.durationMs : payload.duration !== undefined ? payload.duration : 300,
|
|
947
|
+
'durationMs',
|
|
948
|
+
{ min: 1, max: 60000 },
|
|
949
|
+
);
|
|
950
|
+
command = `input swipe ${x1} ${y1} ${x2} ${y2} ${duration}`;
|
|
951
|
+
} else if (type === 'key' || type === 'keyevent') {
|
|
952
|
+
const keycode = normalizeKeycode(
|
|
953
|
+
payload.key !== undefined ? payload.key : payload.keycode !== undefined ? payload.keycode : payload.code,
|
|
954
|
+
);
|
|
955
|
+
command = `input keyevent ${keycode}`;
|
|
956
|
+
} else if (type === 'text') {
|
|
957
|
+
const rawText = payload.text !== undefined ? payload.text : payload.value;
|
|
958
|
+
if (typeof rawText !== 'string' || rawText.length === 0) {
|
|
959
|
+
throw new Error('input({ type: "text" }) requires a non-empty string "text"');
|
|
960
|
+
}
|
|
961
|
+
if (rawText.length > 4096) {
|
|
962
|
+
throw new Error('input({ type: "text" }) text is limited to 4096 characters');
|
|
963
|
+
}
|
|
964
|
+
command = `input text ${encodeInputText(rawText)}`;
|
|
965
|
+
} else {
|
|
966
|
+
throw new Error(
|
|
967
|
+
`unsupported input type ${JSON.stringify(type || '(missing)')}; expected tap|swipe|key|text`,
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const serial = await this._requireSerial();
|
|
972
|
+
const result = await this._adb(['-s', serial, 'shell', command], {
|
|
973
|
+
timeoutMs: this.commandTimeoutMs,
|
|
974
|
+
maxBytes: 256 * 1024,
|
|
975
|
+
});
|
|
976
|
+
const output = `${result.stdout.toString('utf8')}\n${result.stderr}`.trim();
|
|
977
|
+
if (result.timedOut) {
|
|
978
|
+
throw new Error(`"${command}" timed out after ${this.commandTimeoutMs}ms`);
|
|
979
|
+
}
|
|
980
|
+
if (result.code !== 0 || /^(?:Error|Exception|java\.lang\.)/im.test(output)) {
|
|
981
|
+
throw new Error(`input ${type} failed (${command}): ${output.slice(0, 500) || `exit ${result.code}`}`);
|
|
982
|
+
}
|
|
983
|
+
return { ok: true };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ---------------------------------------------------------------------------
|
|
987
|
+
// Root / debug operations (DSH debug surface)
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
|
|
990
|
+
/** Serial of the active device or a clear error. */
|
|
991
|
+
_debugSerial() {
|
|
992
|
+
const serial = this._serialActive || this.serial;
|
|
993
|
+
if (!serial) throw new Error('No Android device is attached');
|
|
994
|
+
return serial;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Bring up the rooted debug surface: `adb root`, debug global settings and a
|
|
999
|
+
* small on-device debug helper. Idempotent per boot; safe to call repeatedly.
|
|
1000
|
+
*/
|
|
1001
|
+
async _prepareDebugSystem() {
|
|
1002
|
+
if (!(this._serialActive || this.serial)) return null;
|
|
1003
|
+
if (this._preparePromise) return this._preparePromise;
|
|
1004
|
+
this._preparePromise = (async () => {
|
|
1005
|
+
this._debugPrepared = true;
|
|
1006
|
+
await this._enableRoot();
|
|
1007
|
+
if (this._root) {
|
|
1008
|
+
await this._applyDebugSettings();
|
|
1009
|
+
await this._installDebugToolkit();
|
|
1010
|
+
}
|
|
1011
|
+
await this._readIdentity();
|
|
1012
|
+
this._pushLog(
|
|
1013
|
+
`[vm] debug interface ready: root=${this._root === true}` +
|
|
1014
|
+
(this._debugToolkitPath ? ` toolkit=${this._debugToolkitPath}` : ''),
|
|
1015
|
+
);
|
|
1016
|
+
return this.debugStatus();
|
|
1017
|
+
})()
|
|
1018
|
+
.catch((error) => {
|
|
1019
|
+
this._pushLog(`[vm] debug interface preparation failed: ${error && error.message ? error.message : error}`);
|
|
1020
|
+
return this.debugStatus();
|
|
1021
|
+
})
|
|
1022
|
+
.finally(() => {
|
|
1023
|
+
this._preparePromise = null;
|
|
1024
|
+
});
|
|
1025
|
+
return this._preparePromise;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** Ensure adbd is running as root (userdebug images support `adb root`). */
|
|
1029
|
+
async ensureRoot() {
|
|
1030
|
+
if (this._root === true) return true;
|
|
1031
|
+
if (!(this._serialActive || this.serial)) return false;
|
|
1032
|
+
await this._prepareDebugSystem();
|
|
1033
|
+
return this._root === true;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async _enableRoot() {
|
|
1037
|
+
if (this._root === true) return true;
|
|
1038
|
+
const serial = this._debugSerial();
|
|
1039
|
+
try {
|
|
1040
|
+
const result = await this._adb(['-s', serial, 'root'], { timeoutMs: 20 * 1000, maxBytes: 64 * 1024 });
|
|
1041
|
+
const text = `${result.stdout.toString('utf8')}\n${result.stderr}`.trim();
|
|
1042
|
+
if (/already running as root/i.test(text)) {
|
|
1043
|
+
this._root = true;
|
|
1044
|
+
} else if (/restarting adbd as root/i.test(text) || result.code === 0) {
|
|
1045
|
+
await sleep(800);
|
|
1046
|
+
await this._adb(['-s', serial, 'wait-for-device'], { timeoutMs: this.commandTimeoutMs });
|
|
1047
|
+
const id = (await this._adbText(['-s', serial, 'shell', 'id'], { timeoutMs: 10 * 1000 })).trim();
|
|
1048
|
+
this._root = /uid=0\(root\)/.test(id);
|
|
1049
|
+
} else {
|
|
1050
|
+
this._pushLog(`[vm] adb root rejected: ${text.slice(0, 240) || `exit ${result.code}`}`);
|
|
1051
|
+
this._root = false;
|
|
1052
|
+
}
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
this._root = false;
|
|
1055
|
+
this._pushLog(`[vm] adb root failed: ${error && error.message ? error.message : error}`);
|
|
1056
|
+
}
|
|
1057
|
+
if (this._root) this._pushLog('[vm] adb root enabled (uid=0)');
|
|
1058
|
+
return this._root === true;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async _applyDebugSettings() {
|
|
1062
|
+
const serial = this._debugSerial();
|
|
1063
|
+
const commands = [
|
|
1064
|
+
'settings put global adb_enabled 1',
|
|
1065
|
+
'settings put global development_settings_enabled 1',
|
|
1066
|
+
'settings put global verifier_verify_adb_installs 0',
|
|
1067
|
+
'settings put global package_verifier_enable 0',
|
|
1068
|
+
'setprop persist.sys.usb.config adb',
|
|
1069
|
+
'setprop persist.sys.root_access 3',
|
|
1070
|
+
];
|
|
1071
|
+
for (const command of commands) {
|
|
1072
|
+
try {
|
|
1073
|
+
await this._adbText(['-s', serial, 'shell', command], { timeoutMs: 15 * 1000 });
|
|
1074
|
+
} catch {
|
|
1075
|
+
/* best effort: some settings are read-only on some images */
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** Push a small root-ready helper script + README into /data/local/tmp/dsh. */
|
|
1081
|
+
async _installDebugToolkit() {
|
|
1082
|
+
const serial = this._debugSerial();
|
|
1083
|
+
const dir = '/data/local/tmp/dsh';
|
|
1084
|
+
const scriptPath = `${dir}/debug.sh`;
|
|
1085
|
+
const readmePath = `${dir}/README.txt`;
|
|
1086
|
+
const script = [
|
|
1087
|
+
'#!/system/bin/sh',
|
|
1088
|
+
'# DSH APK VM debug helper. adbd is root, so all commands are uid=0.',
|
|
1089
|
+
'cmd="$1"; shift',
|
|
1090
|
+
'case "$cmd" in',
|
|
1091
|
+
' info) getprop ro.build.version.release; getprop ro.build.version.sdk; getprop ro.product.name; id; wm size; wm density ;;',
|
|
1092
|
+
' logs) logcat -d -t "${1:-200}" $2 $3 $4 ;;',
|
|
1093
|
+
' prop) if [ -n "$2" ]; then setprop "$1" "$2"; else getprop "$1"; fi ;;',
|
|
1094
|
+
' pkgs) pm list packages -3 -f ;;',
|
|
1095
|
+
' install) pm install -r -t "$1" ;;',
|
|
1096
|
+
' help|*) cat /data/local/tmp/dsh/README.txt ;;',
|
|
1097
|
+
'esac',
|
|
1098
|
+
'',
|
|
1099
|
+
].join('\n');
|
|
1100
|
+
const readme = [
|
|
1101
|
+
'DSH APK VM debug toolkit',
|
|
1102
|
+
'',
|
|
1103
|
+
'debug.sh info Android/root/display info',
|
|
1104
|
+
'debug.sh logs [lines] [tag] dump logcat',
|
|
1105
|
+
'debug.sh prop <name> [value] get/set a system property',
|
|
1106
|
+
'debug.sh pkgs list third-party packages',
|
|
1107
|
+
'debug.sh install <apk> install an APK on-device',
|
|
1108
|
+
'',
|
|
1109
|
+
'adbd is running as root; DSH also exposes these operations over',
|
|
1110
|
+
'/apk-vm/debug/* and the apk_vm tool.',
|
|
1111
|
+
'',
|
|
1112
|
+
].join('\n');
|
|
1113
|
+
const tempDir = os.tmpdir();
|
|
1114
|
+
const localScript = path.join(tempDir, `dsh-debug-${process.pid}.sh`);
|
|
1115
|
+
const localReadme = path.join(tempDir, `dsh-debug-${process.pid}.txt`);
|
|
1116
|
+
try {
|
|
1117
|
+
await fsp.writeFile(localScript, script, 'utf8');
|
|
1118
|
+
await fsp.writeFile(localReadme, readme, 'utf8');
|
|
1119
|
+
await this._adbText(['-s', serial, 'shell', 'mkdir', '-p', dir], { timeoutMs: 10 * 1000 });
|
|
1120
|
+
for (const [local, remote] of [
|
|
1121
|
+
[localScript, scriptPath],
|
|
1122
|
+
[localReadme, readmePath],
|
|
1123
|
+
]) {
|
|
1124
|
+
const result = await this._adb(['-s', serial, 'push', local, remote], {
|
|
1125
|
+
timeoutMs: 30 * 1000,
|
|
1126
|
+
maxBytes: 128 * 1024,
|
|
1127
|
+
});
|
|
1128
|
+
if (result.code !== 0) throw new Error(`adb push ${remote} failed: ${result.stderr.slice(0, 240)}`);
|
|
1129
|
+
}
|
|
1130
|
+
await this._adbText(['-s', serial, 'shell', 'chmod', '755', scriptPath], { timeoutMs: 10 * 1000 });
|
|
1131
|
+
this._debugToolkitPath = scriptPath;
|
|
1132
|
+
} finally {
|
|
1133
|
+
await fsp.rm(localScript, { force: true }).catch(() => {});
|
|
1134
|
+
await fsp.rm(localReadme, { force: true }).catch(() => {});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async _readIdentity() {
|
|
1139
|
+
const serial = this._debugSerial();
|
|
1140
|
+
try {
|
|
1141
|
+
const text = await this._adbText(['-s', serial, 'shell', 'getprop'], { timeoutMs: 15 * 1000 });
|
|
1142
|
+
const props = {};
|
|
1143
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1144
|
+
const match = /^\[([^\]]+)\]: \[(.*)\]$/.exec(line.trim());
|
|
1145
|
+
if (match) props[match[1]] = match[2];
|
|
1146
|
+
}
|
|
1147
|
+
this._identity = {
|
|
1148
|
+
release: props['ro.build.version.release'] || null,
|
|
1149
|
+
sdk: props['ro.build.version.sdk'] ? Number(props['ro.build.version.sdk']) : null,
|
|
1150
|
+
model: props['ro.product.model'] || null,
|
|
1151
|
+
device: props['ro.product.device'] || null,
|
|
1152
|
+
abi: props['ro.product.cpu.abi'] || props['ro.product.cpu.abilist'] || null,
|
|
1153
|
+
debugBuild: props['ro.debuggable'] === '1',
|
|
1154
|
+
adbSecure: props['ro.adb.secure'] === '1',
|
|
1155
|
+
root: this._root === true,
|
|
1156
|
+
};
|
|
1157
|
+
} catch {
|
|
1158
|
+
this._identity = null;
|
|
1159
|
+
}
|
|
1160
|
+
return this._identity;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Rooted shell, bounded output. `root:false` runs as the current adbd user. */
|
|
1164
|
+
async shell(command, options = {}) {
|
|
1165
|
+
if (typeof command !== 'string' || !command.trim()) throw new Error('shell command must be a non-empty string');
|
|
1166
|
+
if (command.length > 8192) throw new Error('shell command is limited to 8192 characters');
|
|
1167
|
+
if (options.root !== false) {
|
|
1168
|
+
try {
|
|
1169
|
+
await this.ensureRoot();
|
|
1170
|
+
} catch {
|
|
1171
|
+
/* fall back to the current adbd user */
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const serial = this._debugSerial();
|
|
1175
|
+
const result = await this._adb(['-s', serial, 'shell', command], {
|
|
1176
|
+
timeoutMs: asPositiveInt(options.timeoutMs, this.commandTimeoutMs),
|
|
1177
|
+
maxBytes: asPositiveInt(options.maxBytes, 256 * 1024),
|
|
1178
|
+
});
|
|
1179
|
+
return {
|
|
1180
|
+
ok: !result.timedOut && result.code === 0,
|
|
1181
|
+
code: result.code,
|
|
1182
|
+
root: this._root === true,
|
|
1183
|
+
timedOut: result.timedOut === true,
|
|
1184
|
+
stdout: result.stdout.toString('utf8').slice(0, 100000),
|
|
1185
|
+
stderr: result.stderr.slice(0, 20000),
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async logcat(options = {}) {
|
|
1190
|
+
const serial = this._debugSerial();
|
|
1191
|
+
const lines = Math.min(1000, Math.max(1, Number(options.lines) || 200));
|
|
1192
|
+
const args = ['-s', serial, 'logcat'];
|
|
1193
|
+
const buffer = String(options.buffer || '').toLowerCase();
|
|
1194
|
+
if (buffer) {
|
|
1195
|
+
if (!['main', 'system', 'crash', 'events', 'all', 'radio', 'kernel'].includes(buffer)) {
|
|
1196
|
+
throw new Error(`invalid logcat buffer: ${buffer}`);
|
|
1197
|
+
}
|
|
1198
|
+
args.push('-b', buffer);
|
|
1199
|
+
}
|
|
1200
|
+
args.push('-d', '-t', String(lines));
|
|
1201
|
+
if (options.filter) args.push(String(options.filter).slice(0, 200));
|
|
1202
|
+
const result = await this._adb(args, { timeoutMs: 30 * 1000, maxBytes: 1024 * 1024 });
|
|
1203
|
+
const text = result.stdout.toString('utf8');
|
|
1204
|
+
return {
|
|
1205
|
+
ok: result.code === 0 && !result.timedOut,
|
|
1206
|
+
lines: splitLines(text).slice(-lines),
|
|
1207
|
+
text: text.slice(-400000),
|
|
1208
|
+
timedOut: result.timedOut === true,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async getprop(name) {
|
|
1213
|
+
if (typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)) throw new Error('invalid property name');
|
|
1214
|
+
const serial = this._debugSerial();
|
|
1215
|
+
const out = await this._adbText(['-s', serial, 'shell', 'getprop', name], { timeoutMs: 10 * 1000 });
|
|
1216
|
+
return { name, value: out.trim() };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async setprop(name, value) {
|
|
1220
|
+
if (typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)) throw new Error('invalid property name');
|
|
1221
|
+
const text = String(value == null ? '' : value);
|
|
1222
|
+
if (text.length > 512) throw new Error('property value is limited to 512 characters');
|
|
1223
|
+
if (/[\r\n\0]/.test(text)) throw new Error('property value must be a single line');
|
|
1224
|
+
await this.ensureRoot();
|
|
1225
|
+
const serial = this._debugSerial();
|
|
1226
|
+
const quoted = `'${text.replace(/'/g, `'\\''`)}'`;
|
|
1227
|
+
const result = await this._adb(['-s', serial, 'shell', `setprop ${name} ${quoted}`], {
|
|
1228
|
+
timeoutMs: 15 * 1000,
|
|
1229
|
+
maxBytes: 64 * 1024,
|
|
1230
|
+
});
|
|
1231
|
+
if (result.code !== 0 || result.timedOut) {
|
|
1232
|
+
throw new Error(`setprop ${name} failed: ${result.stderr.toString('utf8').slice(0, 300) || `exit ${result.code}`}`);
|
|
1233
|
+
}
|
|
1234
|
+
return { ok: true, name, value: text, root: this._root === true };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async listPackages(options = {}) {
|
|
1238
|
+
const serial = this._debugSerial();
|
|
1239
|
+
const thirdParty = options.thirdPartyOnly !== false;
|
|
1240
|
+
const args = ['-s', serial, 'shell', 'pm', 'list', 'packages', '-f'];
|
|
1241
|
+
if (thirdParty) args.push('-3');
|
|
1242
|
+
const text = await this._adbText(args, { timeoutMs: 20 * 1000 });
|
|
1243
|
+
const packages = [];
|
|
1244
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1245
|
+
const match = /^package:(.*)=([A-Za-z0-9_.]+)$/.exec(line.trim());
|
|
1246
|
+
if (match) packages.push({ apkPath: match[1], packageName: match[2] });
|
|
1247
|
+
}
|
|
1248
|
+
packages.sort((a, b) => a.packageName.localeCompare(b.packageName));
|
|
1249
|
+
return { ok: true, thirdPartyOnly: thirdParty, packages };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async deviceInfo() {
|
|
1253
|
+
const serial = this._debugSerial();
|
|
1254
|
+
const identity = this._identity || (await this._readIdentity());
|
|
1255
|
+
let size = null;
|
|
1256
|
+
let density = null;
|
|
1257
|
+
let uptime = null;
|
|
1258
|
+
try {
|
|
1259
|
+
const wm = await this._adbText(['-s', serial, 'shell', 'wm', 'size'], { timeoutMs: 10 * 1000 });
|
|
1260
|
+
const match = /(\d+)\s*x\s*(\d+)/.exec(wm);
|
|
1261
|
+
if (match) size = { width: Number(match[1]), height: Number(match[2]) };
|
|
1262
|
+
const wmDensity = await this._adbText(['-s', serial, 'shell', 'wm', 'density'], { timeoutMs: 10 * 1000 });
|
|
1263
|
+
const densityMatch = /(\d+)/.exec(wmDensity);
|
|
1264
|
+
if (densityMatch) density = Number(densityMatch[1]);
|
|
1265
|
+
uptime = (await this._adbText(['-s', serial, 'shell', 'cat', '/proc/uptime'], { timeoutMs: 10 * 1000 })).trim();
|
|
1266
|
+
} catch {
|
|
1267
|
+
/* optional extras */
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
ok: true,
|
|
1271
|
+
serial,
|
|
1272
|
+
state: this._state,
|
|
1273
|
+
root: this._root === true,
|
|
1274
|
+
debugToolkit: this._debugToolkitPath,
|
|
1275
|
+
avatar: undefined,
|
|
1276
|
+
identity,
|
|
1277
|
+
size,
|
|
1278
|
+
density,
|
|
1279
|
+
uptime,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async pushFile(localPath, remotePath) {
|
|
1284
|
+
const serial = this._debugSerial();
|
|
1285
|
+
const local = path.resolve(String(localPath));
|
|
1286
|
+
if (!existsSync(local)) throw new Error(`local file not found: ${local}`);
|
|
1287
|
+
const remote = String(remotePath || '');
|
|
1288
|
+
if (!/^\/(?:data\/local\/tmp|sdcard|storage\/emulated\/0)(?:\/|$)/.test(remote)) {
|
|
1289
|
+
throw new Error('remote path must be under /data/local/tmp, /sdcard or /storage/emulated/0');
|
|
1290
|
+
}
|
|
1291
|
+
const result = await this._adb(['-s', serial, 'push', local, remote], {
|
|
1292
|
+
timeoutMs: 5 * 60 * 1000,
|
|
1293
|
+
maxBytes: 256 * 1024,
|
|
1294
|
+
});
|
|
1295
|
+
if (result.code !== 0) throw new Error(`adb push failed: ${result.stderr.toString('utf8').slice(0, 300)}`);
|
|
1296
|
+
return { ok: true, localPath: local, remotePath: remote, root: this._root === true };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async pullFile(remotePath, localPath) {
|
|
1300
|
+
const serial = this._debugSerial();
|
|
1301
|
+
const remote = String(remotePath || '');
|
|
1302
|
+
if (!/^\/(?:data\/local\/tmp|sdcard|storage\/emulated\/0)(?:\/|$)/.test(remote)) {
|
|
1303
|
+
throw new Error('remote path must be under /data/local/tmp, /sdcard or /storage/emulated/0');
|
|
1304
|
+
}
|
|
1305
|
+
const local = path.resolve(String(localPath));
|
|
1306
|
+
await fsp.mkdir(path.dirname(local), { recursive: true }).catch(() => {});
|
|
1307
|
+
const result = await this._adb(['-s', serial, 'pull', remote, local], {
|
|
1308
|
+
timeoutMs: 5 * 60 * 1000,
|
|
1309
|
+
maxBytes: 256 * 1024,
|
|
1310
|
+
});
|
|
1311
|
+
if (result.code !== 0) throw new Error(`adb pull failed: ${result.stderr.toString('utf8').slice(0, 300)}`);
|
|
1312
|
+
let size = null;
|
|
1313
|
+
try {
|
|
1314
|
+
size = (await fsp.stat(local)).size;
|
|
1315
|
+
} catch {
|
|
1316
|
+
/* ignore */
|
|
1317
|
+
}
|
|
1318
|
+
return { ok: true, remotePath: remote, localPath: local, bytes: size };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async reboot(mode = '') {
|
|
1322
|
+
const serial = this._debugSerial();
|
|
1323
|
+
const args = ['-s', serial, 'reboot'];
|
|
1324
|
+
if (mode) args.push(String(mode));
|
|
1325
|
+
const result = await this._adb(args, { timeoutMs: 20 * 1000, maxBytes: 64 * 1024 });
|
|
1326
|
+
if (result.code !== 0 && !result.timedOut) {
|
|
1327
|
+
throw new Error(`adb reboot failed: ${result.stderr.toString('utf8').slice(0, 300)}`);
|
|
1328
|
+
}
|
|
1329
|
+
this._pushLog(`[vm] adb reboot${mode ? ` ${mode}` : ''} requested`);
|
|
1330
|
+
return { ok: true, mode: mode || 'system' };
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
async forceStop(packageName) {
|
|
1334
|
+
this._assertPackageName(packageName);
|
|
1335
|
+
return this.shell(`am force-stop ${packageName}`, { timeoutMs: 15 * 1000 });
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
async clearData(packageName) {
|
|
1339
|
+
this._assertPackageName(packageName);
|
|
1340
|
+
return this.shell(`pm clear ${packageName}`, { timeoutMs: 30 * 1000 });
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
async uninstall(packageName) {
|
|
1344
|
+
this._assertPackageName(packageName);
|
|
1345
|
+
return this.shell(`pm uninstall ${packageName}`, { timeoutMs: 30 * 1000 });
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Lock the display rotation. Accepts 'portrait' | 'landscape' | 'left' |
|
|
1350
|
+
* 'right' | 'upside-down' or an Android user_rotation number 0..3.
|
|
1351
|
+
*/
|
|
1352
|
+
async rotate(orientation = 'landscape') {
|
|
1353
|
+
const map = { portrait: 0, landscape: 1, left: 1, right: 3, 'upside-down': 2, upside_down: 2 };
|
|
1354
|
+
const rotation =
|
|
1355
|
+
typeof orientation === 'number'
|
|
1356
|
+
? Math.min(3, Math.max(0, Math.round(orientation)))
|
|
1357
|
+
: map[String(orientation || '').toLowerCase()];
|
|
1358
|
+
if (rotation === undefined) throw new Error(`invalid rotation: ${JSON.stringify(orientation)}`);
|
|
1359
|
+
await this.ensureRoot();
|
|
1360
|
+
const serial = this._debugSerial();
|
|
1361
|
+
const result = await this._adb(
|
|
1362
|
+
['-s', serial, 'shell', `settings put system accelerometer_rotation 0; settings put system user_rotation ${rotation}`],
|
|
1363
|
+
{ timeoutMs: 15000, maxBytes: 64 * 1024 },
|
|
1364
|
+
);
|
|
1365
|
+
if (result.code !== 0 || result.timedOut) {
|
|
1366
|
+
throw new Error(`rotate failed: ${result.stderr.toString('utf8').slice(0, 300) || `exit ${result.code}`}`);
|
|
1367
|
+
}
|
|
1368
|
+
return { ok: true, rotation, orientation: String(orientation) };
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** Dump the current UIAutomator hierarchy XML (for agent/UI inspection). */
|
|
1372
|
+
async uiDump() {
|
|
1373
|
+
await this.ensureRoot();
|
|
1374
|
+
const serial = this._debugSerial();
|
|
1375
|
+
const remote = '/data/local/tmp/dsh-window.xml';
|
|
1376
|
+
const result = await this._adb(
|
|
1377
|
+
['-s', serial, 'shell', `uiautomator dump ${remote} >/dev/null 2>&1; cat ${remote}`],
|
|
1378
|
+
{ timeoutMs: 45000, maxBytes: 1024 * 1024 },
|
|
1379
|
+
);
|
|
1380
|
+
let xml = result.stdout.toString('utf8').trim();
|
|
1381
|
+
if (!/<hierarchy[\s>]/.test(xml)) {
|
|
1382
|
+
throw new Error(`uiautomator dump failed: ${result.stderr.toString('utf8').slice(0, 300) || 'empty hierarchy'}`);
|
|
1383
|
+
}
|
|
1384
|
+
const nodes = (xml.match(/<node\b/g) || []).length;
|
|
1385
|
+
if (xml.length > 500000) xml = xml.slice(0, 500000);
|
|
1386
|
+
return {
|
|
1387
|
+
ok: true,
|
|
1388
|
+
rotation: (xml.match(/<hierarchy rotation="(\d+)"/) || [])[1] || null,
|
|
1389
|
+
nodes,
|
|
1390
|
+
bytes: Buffer.byteLength(xml),
|
|
1391
|
+
xml,
|
|
1392
|
+
remotePath: remote,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** Record a short MP4 from the guest display and pull it to the host temp dir. */
|
|
1397
|
+
async screenRecord(options = {}) {
|
|
1398
|
+
const seconds = Math.min(60, Math.max(1, Math.round(Number(options.seconds) || 5)));
|
|
1399
|
+
const serial = this._debugSerial();
|
|
1400
|
+
const stamp = Date.now();
|
|
1401
|
+
const remote = `/sdcard/dsh-record-${stamp}.mp4`;
|
|
1402
|
+
const command = `screenrecord --time-limit ${seconds} --size 540x960 ${remote}`;
|
|
1403
|
+
const result = await this._adb(['-s', serial, 'shell', command], {
|
|
1404
|
+
timeoutMs: (seconds + 25) * 1000,
|
|
1405
|
+
maxBytes: 128 * 1024,
|
|
1406
|
+
});
|
|
1407
|
+
if (result.timedOut || result.code !== 0) {
|
|
1408
|
+
throw new Error(`screenrecord failed: ${result.stderr.toString('utf8').slice(0, 300) || `exit ${result.code}`}`);
|
|
1409
|
+
}
|
|
1410
|
+
const localPath = path.join(os.tmpdir(), `dsh-apk-vm-record-${stamp}.mp4`);
|
|
1411
|
+
await this.pullFile(remote, localPath);
|
|
1412
|
+
await this._adbText(['-s', serial, 'shell', 'rm', '-f', remote], { timeoutMs: 10000 }).catch(() => {});
|
|
1413
|
+
let bytes = null;
|
|
1414
|
+
try {
|
|
1415
|
+
bytes = (await fsp.stat(localPath)).size;
|
|
1416
|
+
} catch {
|
|
1417
|
+
/* ignore */
|
|
1418
|
+
}
|
|
1419
|
+
this._pushLog(`[vm] screenrecord ${seconds}s -> ${localPath} (${bytes || '?'} bytes)`);
|
|
1420
|
+
return { ok: true, seconds, localPath, bytes };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** Small performance snapshot: load average, memory and top CPU processes. */
|
|
1424
|
+
async perfSnapshot() {
|
|
1425
|
+
const serial = this._debugSerial();
|
|
1426
|
+
const text = await this._adbText(
|
|
1427
|
+
[
|
|
1428
|
+
'-s',
|
|
1429
|
+
serial,
|
|
1430
|
+
'shell',
|
|
1431
|
+
'cat /proc/loadavg; echo ---MEM---; head -n 3 /proc/meminfo; echo ---CPU---; dumpsys cpuinfo | head -n 24',
|
|
1432
|
+
],
|
|
1433
|
+
{ timeoutMs: 30000, maxBytes: 128 * 1024 },
|
|
1434
|
+
);
|
|
1435
|
+
const [loadPart, rest = ''] = text.split('---MEM---');
|
|
1436
|
+
const [memPart, cpuPart = ''] = rest.split('---CPU---');
|
|
1437
|
+
const memNums = {};
|
|
1438
|
+
for (const line of memPart.split(/\r?\n/)) {
|
|
1439
|
+
const match = /^(MemTotal|MemFree|MemAvailable|Buffers|Cached):\s+(\d+)\s+kB/.exec(line.trim());
|
|
1440
|
+
if (match) memNums[match[1]] = Number(match[2]);
|
|
1441
|
+
}
|
|
1442
|
+
return {
|
|
1443
|
+
ok: true,
|
|
1444
|
+
sampledAt: Date.now(),
|
|
1445
|
+
loadavg: loadPart.trim().split(/\s+/).slice(0, 3).join(' '),
|
|
1446
|
+
memTotalKb: memNums.MemTotal || null,
|
|
1447
|
+
memFreeKb: memNums.MemFree || null,
|
|
1448
|
+
memAvailableKb: memNums.MemAvailable || null,
|
|
1449
|
+
cpuinfo: cpuPart.trim().split(/\r?\n/).slice(0, 24),
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/** List AVD names available to the configured emulator. */
|
|
1454
|
+
async listAvds() {
|
|
1455
|
+
if (!this.emulatorPath) return [];
|
|
1456
|
+
try {
|
|
1457
|
+
const result = await this._run(this.emulatorPath, ['-list-avds'], { timeoutMs: 15000, maxBytes: 64 * 1024 });
|
|
1458
|
+
if (result.code !== 0 || result.timedOut) return [];
|
|
1459
|
+
return String(result.stdout)
|
|
1460
|
+
.split(/\r?\n/)
|
|
1461
|
+
.map((line) => line.trim())
|
|
1462
|
+
.filter((line) => line && !line.startsWith('INFO') && !line.startsWith('WARNING'));
|
|
1463
|
+
} catch {
|
|
1464
|
+
return [];
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/** Switch the configured AVD (stops the current emulator first). */
|
|
1469
|
+
async setAvd(avdName) {
|
|
1470
|
+
const target = String(avdName || '').trim();
|
|
1471
|
+
if (!/^[A-Za-z0-9._-]{1,80}$/.test(target)) throw new Error(`invalid AVD name: ${JSON.stringify(avdName)}`);
|
|
1472
|
+
if (target === this.avdName) return { ok: true, avdName: target, changed: false };
|
|
1473
|
+
await this.stop();
|
|
1474
|
+
this.avdName = target;
|
|
1475
|
+
this._snapshotAvailable = null;
|
|
1476
|
+
this._bootKind = null;
|
|
1477
|
+
this._identity = null;
|
|
1478
|
+
this._root = false;
|
|
1479
|
+
this._debugPrepared = false;
|
|
1480
|
+
this._debugToolkitPath = null;
|
|
1481
|
+
this._pushLog(`[vm] AVD switched to ${target}`);
|
|
1482
|
+
return { ok: true, avdName: target, changed: true };
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
_assertPackageName(packageName) {
|
|
1486
|
+
if (typeof packageName !== 'string' || !PACKAGE_RE.test(packageName)) {
|
|
1487
|
+
throw new Error(`invalid package name: ${JSON.stringify(packageName)}`);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
debugStatus() {
|
|
1492
|
+
return {
|
|
1493
|
+
root: this._root === true,
|
|
1494
|
+
prepared: this._debugPrepared === true,
|
|
1495
|
+
toolkit: this._debugToolkitPath,
|
|
1496
|
+
identity: this._identity,
|
|
1497
|
+
adb: this._serialActive || this.serial || null,
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Connect to a network adb device (`adb connect host:port`) and wait until adb
|
|
1503
|
+
* reports it in state `device`. Non-network serials are only waited on.
|
|
1504
|
+
* Returns a status snapshot. Throws on connection failure/timeout.
|
|
1505
|
+
*/
|
|
1506
|
+
async connect(serial) {
|
|
1507
|
+
const target = serial ? String(serial).trim() : this.serial;
|
|
1508
|
+
if (!target) throw new Error('connect(serial) requires a serial or a configured serial option');
|
|
1509
|
+
if (!SERIAL_RE.test(target)) throw new Error(`invalid adb serial: ${JSON.stringify(target)}`);
|
|
1510
|
+
|
|
1511
|
+
this.serial = target;
|
|
1512
|
+
this._serialActive = target;
|
|
1513
|
+
this._pushLog(`[vm] connecting to ${target}`);
|
|
1514
|
+
|
|
1515
|
+
try {
|
|
1516
|
+
if (isNetworkSerial(target)) {
|
|
1517
|
+
const result = await this._adb(['connect', target], { timeoutMs: this.connectTimeoutMs });
|
|
1518
|
+
const output = `${result.stdout.toString('utf8')}\n${result.stderr}`.trim();
|
|
1519
|
+
if (result.timedOut) {
|
|
1520
|
+
throw new Error(`adb connect ${target} timed out after ${this.connectTimeoutMs}ms`);
|
|
1521
|
+
}
|
|
1522
|
+
const failed = /(?:cannot|failed|unable|refused|does not match|unknown host)/i.test(output);
|
|
1523
|
+
if (failed && !/already connected/i.test(output)) {
|
|
1524
|
+
throw new Error(`adb connect ${target} failed: ${output || `exit ${result.code}`}`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
} catch (error) {
|
|
1528
|
+
this._setState('error', error.message);
|
|
1529
|
+
this._pushLog(`[vm] ${error.message}`);
|
|
1530
|
+
throw error;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const deadline = Date.now() + this.connectTimeoutMs;
|
|
1534
|
+
let lastState = null;
|
|
1535
|
+
for (;;) {
|
|
1536
|
+
const devices = await this._listDevices();
|
|
1537
|
+
if (this._lastProbeError) {
|
|
1538
|
+
// keep polling; a transient adb server hiccup should not abort a connect
|
|
1539
|
+
}
|
|
1540
|
+
const deviceState = devices.get(target) || null;
|
|
1541
|
+
lastState = deviceState;
|
|
1542
|
+
if (deviceState === 'device') {
|
|
1543
|
+
if (this._state !== 'ready') this._setState('starting');
|
|
1544
|
+
this._pushLog(`[vm] connected: ${target}`);
|
|
1545
|
+
return this._snapshot();
|
|
1546
|
+
}
|
|
1547
|
+
if (deviceState === 'unauthorized') {
|
|
1548
|
+
this._setState('error', `adb device ${target} is unauthorized; accept the debugging prompt`);
|
|
1549
|
+
throw new Error(`adb device ${target} is unauthorized`);
|
|
1550
|
+
}
|
|
1551
|
+
if (Date.now() >= deadline) {
|
|
1552
|
+
const message = `timed out after ${this.connectTimeoutMs}ms waiting for ${target} ` +
|
|
1553
|
+
`(last adb state: ${lastState || 'absent'})`;
|
|
1554
|
+
this._setState('error', message);
|
|
1555
|
+
throw new Error(message);
|
|
1556
|
+
}
|
|
1557
|
+
await sleep(500);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Wait until `sys.boot_completed` reports 1 for the attached device. Expected
|
|
1563
|
+
* failure (timeout) is stored in status().error; this method does not throw.
|
|
1564
|
+
* Returns a status snapshot.
|
|
1565
|
+
*/
|
|
1566
|
+
async waitBooted(timeoutMs) {
|
|
1567
|
+
const epoch = this._waitEpoch;
|
|
1568
|
+
if (this._bootWait && this._bootWait.epoch === epoch) return this._bootWait.promise;
|
|
1569
|
+
const entry = { epoch, promise: null };
|
|
1570
|
+
entry.promise = this._waitBootInner(timeoutMs, epoch)
|
|
1571
|
+
.catch((error) => {
|
|
1572
|
+
if (entry.epoch === this._waitEpoch) {
|
|
1573
|
+
this._setState('error', `waitBooted failed: ${error && error.message ? error.message : error}`);
|
|
1574
|
+
}
|
|
1575
|
+
return this._snapshot();
|
|
1576
|
+
})
|
|
1577
|
+
.finally(() => {
|
|
1578
|
+
if (this._bootWait === entry) this._bootWait = null;
|
|
1579
|
+
});
|
|
1580
|
+
this._bootWait = entry;
|
|
1581
|
+
return entry.promise;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* List devices visible to adb (emulators and physical/remote devices) with
|
|
1586
|
+
* model/product metadata for the device picker.
|
|
1587
|
+
*/
|
|
1588
|
+
async listDevices() {
|
|
1589
|
+
let result;
|
|
1590
|
+
try {
|
|
1591
|
+
result = await this._adb(['devices', '-l'], { timeoutMs: 15000 });
|
|
1592
|
+
} catch (error) {
|
|
1593
|
+
return { ok: false, error: error && error.message ? error.message : String(error), devices: [], current: this._serialActive || this.serial || null, external: this._external };
|
|
1594
|
+
}
|
|
1595
|
+
if (result.timedOut) return { ok: false, error: 'adb devices timed out', devices: [], current: this._serialActive || this.serial || null, external: this._external };
|
|
1596
|
+
if (result.code !== 0) {
|
|
1597
|
+
const detail = (result.stderr || result.stdout.toString('utf8')).trim().slice(0, 300);
|
|
1598
|
+
return { ok: false, error: `adb devices failed (exit ${result.code}): ${detail}`, devices: [], current: this._serialActive || this.serial || null, external: this._external };
|
|
1599
|
+
}
|
|
1600
|
+
const devices = [];
|
|
1601
|
+
for (const line of splitLines(result.stdout.toString('utf8'))) {
|
|
1602
|
+
const trimmed = line.trim();
|
|
1603
|
+
if (!trimmed || trimmed.startsWith('List of devices') || trimmed.startsWith('*')) continue;
|
|
1604
|
+
const match = /^(\S+)\s+(\S+)(.*)$/.exec(trimmed);
|
|
1605
|
+
if (!match) continue;
|
|
1606
|
+
const serial = match[1];
|
|
1607
|
+
const state = match[2];
|
|
1608
|
+
const fields = {};
|
|
1609
|
+
for (const part of match[3].trim().split(/\s+/)) {
|
|
1610
|
+
const colon = part.indexOf(':');
|
|
1611
|
+
if (colon > 0) fields[part.slice(0, colon)] = part.slice(colon + 1);
|
|
1612
|
+
}
|
|
1613
|
+
const kind = serial.startsWith('emulator-') ? 'emulator' : serial.includes(':') ? 'network' : 'physical';
|
|
1614
|
+
devices.push({
|
|
1615
|
+
serial,
|
|
1616
|
+
state,
|
|
1617
|
+
kind,
|
|
1618
|
+
model: fields.model || null,
|
|
1619
|
+
product: fields.product || null,
|
|
1620
|
+
device: fields.device || null,
|
|
1621
|
+
transportId: fields.transport_id || null,
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
return {
|
|
1625
|
+
ok: true,
|
|
1626
|
+
devices,
|
|
1627
|
+
current: this._serialActive || this.serial || null,
|
|
1628
|
+
external: this._external === true,
|
|
1629
|
+
externalSerial: this._externalSerial || null,
|
|
1630
|
+
managedAvdName: this._managedAvdName || null,
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* Attach to an already-online adb device (physical USB or network) for this
|
|
1636
|
+
* plugin session. The managed AVD identity is preserved for detachDevice().
|
|
1637
|
+
*/
|
|
1638
|
+
async attachDevice(serial) {
|
|
1639
|
+
const target = String(serial || '').trim();
|
|
1640
|
+
if (!target) throw new Error('attachDevice(serial) requires a device serial');
|
|
1641
|
+
if (!SERIAL_RE.test(target)) throw new Error(`invalid adb serial: ${JSON.stringify(target)}`);
|
|
1642
|
+
const listed = await this.listDevices();
|
|
1643
|
+
const entry = listed.devices.find((device) => device.serial === target);
|
|
1644
|
+
if (!entry) throw new Error(`adb device ${target} is not visible; connect it first`);
|
|
1645
|
+
if (entry.state === 'unauthorized') throw new Error(`adb device ${target} is unauthorized; accept the debugging prompt`);
|
|
1646
|
+
if (entry.state !== 'device') throw new Error(`adb device ${target} is ${entry.state}, not online`);
|
|
1647
|
+
|
|
1648
|
+
if (this._serialActive === target && this._state === 'ready') {
|
|
1649
|
+
// Same device already attached: only switch the identity to external.
|
|
1650
|
+
if (!this._external) {
|
|
1651
|
+
this._managedAvdName = this.avdName;
|
|
1652
|
+
this._managedSerial = this.serial;
|
|
1653
|
+
this._managedPort = this.port;
|
|
1654
|
+
}
|
|
1655
|
+
this._external = true;
|
|
1656
|
+
this._externalSerial = target;
|
|
1657
|
+
this.avdName = '';
|
|
1658
|
+
this.serial = target;
|
|
1659
|
+
this.port = null;
|
|
1660
|
+
this._pushLog(`[vm] external device ${target} marked as active`);
|
|
1661
|
+
return this._snapshot();
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const managed = this._external
|
|
1665
|
+
? { avdName: this._managedAvdName, serial: this._managedSerial, port: this._managedPort }
|
|
1666
|
+
: { avdName: this.avdName, serial: this.serial, port: this.port };
|
|
1667
|
+
if (this._state !== 'stopped') await this.stop();
|
|
1668
|
+
this._managedAvdName = managed.avdName;
|
|
1669
|
+
this._managedSerial = managed.serial;
|
|
1670
|
+
this._managedPort = managed.port;
|
|
1671
|
+
this._external = true;
|
|
1672
|
+
this._externalSerial = target;
|
|
1673
|
+
this.avdName = '';
|
|
1674
|
+
this.serial = target;
|
|
1675
|
+
this.port = null;
|
|
1676
|
+
this._serialActive = null;
|
|
1677
|
+
this._state = 'stopped';
|
|
1678
|
+
this._pushLog(`[vm] attaching external device ${target}`);
|
|
1679
|
+
await this.start();
|
|
1680
|
+
return this._snapshot();
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/** Restore the managed AVD identity after an external-device session. */
|
|
1684
|
+
async detachDevice() {
|
|
1685
|
+
if (!this._external) return this._snapshot();
|
|
1686
|
+
this._waitEpoch += 1;
|
|
1687
|
+
this._serialActive = null;
|
|
1688
|
+
this._external = false;
|
|
1689
|
+
this._externalSerial = null;
|
|
1690
|
+
this.avdName = this._managedAvdName;
|
|
1691
|
+
this.serial = this._managedSerial;
|
|
1692
|
+
this.port = this._managedPort;
|
|
1693
|
+
this._setState('stopped');
|
|
1694
|
+
this._pushLog('[vm] external device detached; managed AVD identity restored');
|
|
1695
|
+
return this._snapshot();
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** Stop the VM (if we own it) and mark this instance disposed. */
|
|
1699
|
+
async dispose() {
|
|
1700
|
+
if (this._disposing) return this._disposing;
|
|
1701
|
+
this._disposing = (async () => {
|
|
1702
|
+
try {
|
|
1703
|
+
await this.stop();
|
|
1704
|
+
} catch {
|
|
1705
|
+
/* dispose must never throw */
|
|
1706
|
+
}
|
|
1707
|
+
this._disposed = true;
|
|
1708
|
+
this._logTail = [];
|
|
1709
|
+
this._setState('stopped');
|
|
1710
|
+
})();
|
|
1711
|
+
return this._disposing;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// ---------------------------------------------------------------------------
|
|
1715
|
+
// start() internals
|
|
1716
|
+
// ---------------------------------------------------------------------------
|
|
1717
|
+
|
|
1718
|
+
async _startInner(epoch = this._waitEpoch) {
|
|
1719
|
+
this._bootMs = null;
|
|
1720
|
+
this._bootStartAt = null;
|
|
1721
|
+
this._sawNotBooted = false;
|
|
1722
|
+
this._bootProbe = { serial: null, at: 0, booted: false };
|
|
1723
|
+
if (this._error) this._error = null;
|
|
1724
|
+
this._pushLog(`[vm] start requested (adb=${this.adbPath})`);
|
|
1725
|
+
|
|
1726
|
+
let devices = await this._listDevices();
|
|
1727
|
+
if (epoch !== this._waitEpoch) return;
|
|
1728
|
+
let target = this._resolveTarget(devices);
|
|
1729
|
+
|
|
1730
|
+
// A configured network serial that is present-but-not-online (or absent) is
|
|
1731
|
+
// re-established with `adb connect`; this is the normal path for a QEMU VM.
|
|
1732
|
+
if (this.serial && isNetworkSerial(this.serial) && (!target || devices.get(this.serial) !== 'device')) {
|
|
1733
|
+
try {
|
|
1734
|
+
await this.connect(this.serial);
|
|
1735
|
+
target = this.serial;
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
if (epoch !== this._waitEpoch) return;
|
|
1738
|
+
this._setState('error', error.message);
|
|
1739
|
+
this._pushLog(`[vm] ${error.message}`);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
if (epoch !== this._waitEpoch) return;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// A stale/offline emulator device (e.g. an emulator that crashed and left
|
|
1746
|
+
// its adb entry behind) must not trap us in the attach path forever:
|
|
1747
|
+
// without a live child of our own, fall through and spawn a fresh VM.
|
|
1748
|
+
if (
|
|
1749
|
+
target &&
|
|
1750
|
+
devices.get(target) !== 'device' &&
|
|
1751
|
+
!this._child &&
|
|
1752
|
+
!this.serial &&
|
|
1753
|
+
this.emulatorPath &&
|
|
1754
|
+
this.avdName &&
|
|
1755
|
+
this.spawnEmulatorEnabled
|
|
1756
|
+
) {
|
|
1757
|
+
this._pushLog(`[vm] ignoring stale ${devices.get(target)} device ${target}; spawning a fresh emulator`);
|
|
1758
|
+
this._serialActive = null;
|
|
1759
|
+
target = null;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
if (!target) {
|
|
1763
|
+
if (this.emulatorPath && this.avdName && this.spawnEmulatorEnabled) {
|
|
1764
|
+
await this._spawnEmulatorAndWait(epoch);
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const checked = this.serial
|
|
1768
|
+
? `configured serial ${this.serial} is not connected`
|
|
1769
|
+
: 'no adb device is online';
|
|
1770
|
+
const hint =
|
|
1771
|
+
'Start the open-source Android VM (e.g. QEMU) and either call connect("host:port"), ' +
|
|
1772
|
+
'set options.serial, or explicitly configure options.emulatorPath + options.avdName.';
|
|
1773
|
+
if (epoch !== this._waitEpoch) return;
|
|
1774
|
+
this._setState('error', `No Android device available: ${checked}. ${hint}`);
|
|
1775
|
+
this._pushLog(`[vm] ${this._error}`);
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
this._serialActive = target;
|
|
1780
|
+
this._pushLog(`[vm] attaching to existing device ${target}`);
|
|
1781
|
+
if (this._state !== 'ready') this._setState('starting');
|
|
1782
|
+
if (!this._bootStartAt) this._bootStartAt = Date.now();
|
|
1783
|
+
await this.waitBooted(this.bootTimeoutMs);
|
|
1784
|
+
if (epoch !== this._waitEpoch) return; // stop() interrupted this run
|
|
1785
|
+
if (this._state === 'ready') await this._prepareDebugSystem();
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// ---------------------------------------------------------------------------
|
|
1789
|
+
// Boot-speed helpers: hypervisor detection, host GPU, quick-boot snapshots
|
|
1790
|
+
// ---------------------------------------------------------------------------
|
|
1791
|
+
|
|
1792
|
+
/** The AVD directory (`~/.android/avd/<name>.avd`), honoring AVD home env vars. */
|
|
1793
|
+
_avdDirectory() {
|
|
1794
|
+
if (!this.avdName) return null;
|
|
1795
|
+
const avdHome =
|
|
1796
|
+
process.env.ANDROID_AVD_HOME ||
|
|
1797
|
+
(process.env.ANDROID_EMULATOR_HOME
|
|
1798
|
+
? path.join(process.env.ANDROID_EMULATOR_HOME, 'avd')
|
|
1799
|
+
: path.join(os.homedir(), '.android', 'avd'));
|
|
1800
|
+
return path.join(avdHome, `${this.avdName}.avd`);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/** Whether a saved quick-boot snapshot directory looks usable. */
|
|
1804
|
+
_snapshotPresent(name) {
|
|
1805
|
+
if (!this.quickBoot) return false;
|
|
1806
|
+
const dir = this._avdDirectory();
|
|
1807
|
+
if (!dir) return false;
|
|
1808
|
+
const snap = path.join(dir, 'snapshots', name || this.snapshotName);
|
|
1809
|
+
try {
|
|
1810
|
+
// A real emulator quick-boot snapshot has snapshot.pb; a leftover
|
|
1811
|
+
// `ram.img` from a crashed `-mem-path` run is NOT a restorable snapshot.
|
|
1812
|
+
return existsSync(path.join(snap, 'snapshot.pb'));
|
|
1813
|
+
} catch {
|
|
1814
|
+
return false;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* Detect a usable CPU hypervisor (AEHD / WHPX / KVM / HAXM / HVF) through the
|
|
1820
|
+
* SDK's `emulator-check accel`. Returns false when only software TCG is
|
|
1821
|
+
* available. Result is cached for the lifetime of this AndroidVm.
|
|
1822
|
+
*/
|
|
1823
|
+
async _detectAcceleration() {
|
|
1824
|
+
if (this.accel === 'off') return false;
|
|
1825
|
+
if (this.accel === 'on') return true;
|
|
1826
|
+
if (this._accelAvailable !== null) return this._accelAvailable;
|
|
1827
|
+
if (!this.emulatorPath) {
|
|
1828
|
+
this._accelAvailable = false;
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
const checkName = IS_WIN ? 'emulator-check.exe' : 'emulator-check';
|
|
1832
|
+
const checkPath = path.join(path.dirname(this.emulatorPath), checkName);
|
|
1833
|
+
if (!existsSync(checkPath)) {
|
|
1834
|
+
// No checker: let the emulator decide (its own error is surfaced in logTail).
|
|
1835
|
+
this._accelAvailable = true;
|
|
1836
|
+
return true;
|
|
1837
|
+
}
|
|
1838
|
+
let available = false;
|
|
1839
|
+
let detail = '';
|
|
1840
|
+
try {
|
|
1841
|
+
const result = await this._run(checkPath, ['accel'], {
|
|
1842
|
+
timeoutMs: this.accelCheckTimeoutMs,
|
|
1843
|
+
maxBytes: 64 * 1024,
|
|
1844
|
+
});
|
|
1845
|
+
detail = `${result.stdout.toString('utf8')}\n${result.stderr}`.trim();
|
|
1846
|
+
available =
|
|
1847
|
+
result.code === 0 &&
|
|
1848
|
+
/(usable|installed|whpx|aehd|kvm|haxm|hvf)/i.test(detail) &&
|
|
1849
|
+
!/not installed|not supported|disabled/i.test(detail);
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
detail = error && error.message ? error.message : String(error);
|
|
1852
|
+
available = false;
|
|
1853
|
+
}
|
|
1854
|
+
this._accelAvailable = available;
|
|
1855
|
+
this._pushLog(
|
|
1856
|
+
`[vm] CPU acceleration: ${available ? 'hardware' : 'software TCG'}${detail ? ` (${detail.split(/\r?\n/).slice(0, 2).join('; ')})` : ''}`,
|
|
1857
|
+
);
|
|
1858
|
+
return available;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* Compose the emulator argv for the fastest viable boot:
|
|
1863
|
+
* - restore the saved snapshot when available (quick boot)
|
|
1864
|
+
* - otherwise cold boot and let the caller save a fresh snapshot afterwards
|
|
1865
|
+
* - use host GPU unless configured otherwise
|
|
1866
|
+
* - only force `-accel off` when no hypervisor is detected
|
|
1867
|
+
*/
|
|
1868
|
+
async _buildLaunchPlan() {
|
|
1869
|
+
const accelAvailable = await this._detectAcceleration();
|
|
1870
|
+
const snapshotAvailable = this.quickBoot && this._snapshotPresent();
|
|
1871
|
+
if (!accelAvailable && this.requireAccel && this.accel !== 'off') {
|
|
1872
|
+
const message =
|
|
1873
|
+
process.platform === 'win32'
|
|
1874
|
+
? 'No usable CPU hypervisor (AEHD/WHPX) was found: an x86_64 Android image cannot boot under pure TCG (the guest stays offline forever). Install/enable AEHD (or Windows Hypervisor Platform) and reboot Windows once, then retry.'
|
|
1875
|
+
: process.platform === 'darwin'
|
|
1876
|
+
? 'No usable CPU hypervisor (macOS Hypervisor.framework/HVF) was found: an x86_64 Android image cannot boot under pure TCG. Enable hardware virtualization and retry.'
|
|
1877
|
+
: 'No usable CPU hypervisor (KVM/HAXM) was found: an x86_64 Android image cannot boot under pure TCG. Enable KVM/HAXM and retry.';
|
|
1878
|
+
return { args: [], bootKind: 'cold', accelAvailable, snapshotAvailable, fatal: message };
|
|
1879
|
+
}
|
|
1880
|
+
const bootKind = snapshotAvailable ? 'snapshot' : 'cold';
|
|
1881
|
+
const args = ['-avd', this.avdName, '-no-window', '-no-audio', '-no-boot-anim'];
|
|
1882
|
+
if (snapshotAvailable) {
|
|
1883
|
+
args.push('-snapshot', this.snapshotName);
|
|
1884
|
+
} else if (!this.quickBoot) {
|
|
1885
|
+
args.push('-no-snapshot');
|
|
1886
|
+
}
|
|
1887
|
+
if (!accelAvailable) args.push('-accel', 'off');
|
|
1888
|
+
if (this.gpuMode) args.push('-gpu', this.gpuMode);
|
|
1889
|
+
// NOTE: the Android emulator only accepts `-prop qemu.*`; ro./service.
|
|
1890
|
+
// properties cannot be injected from the command line. Root/debug state is
|
|
1891
|
+
// therefore established right after boot (adb root + debug settings) and
|
|
1892
|
+
// captured by the quick-boot snapshot, so subsequent quick boots restore it.
|
|
1893
|
+
args.push('-port', String(this.port), ...this.emulatorArgs);
|
|
1894
|
+
return { args, bootKind, accelAvailable, snapshotAvailable };
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/** Save a quick-boot snapshot of the running device (deduplicated). */
|
|
1898
|
+
async saveSnapshot(name) {
|
|
1899
|
+
const serial = this._serialActive || this.serial;
|
|
1900
|
+
if (!serial) throw new Error('saveSnapshot requires an attached device');
|
|
1901
|
+
if (this._snapshotSave) return this._snapshotSave;
|
|
1902
|
+
const snapName = name ? String(name) : this.snapshotName;
|
|
1903
|
+
this._snapshotSave = (async () => {
|
|
1904
|
+
this._pushLog(`[vm] saving quick-boot snapshot "${snapName}"`);
|
|
1905
|
+
const result = await this._adb(['-s', serial, 'emu', 'avd', 'snapshot', 'save', snapName], {
|
|
1906
|
+
timeoutMs: 120 * 1000,
|
|
1907
|
+
maxBytes: 256 * 1024,
|
|
1908
|
+
});
|
|
1909
|
+
const output = `${result.stdout.toString('utf8')}\n${result.stderr}`.trim();
|
|
1910
|
+
if (result.timedOut || result.code !== 0) {
|
|
1911
|
+
throw new Error(`snapshot save failed: ${output.slice(0, 400) || `exit ${result.code}`}`);
|
|
1912
|
+
}
|
|
1913
|
+
this._snapshotAvailable = true;
|
|
1914
|
+
this._snapshotSavedAt = Date.now();
|
|
1915
|
+
this._pushLog(`[vm] quick-boot snapshot saved ("${snapName}")`);
|
|
1916
|
+
return { ok: true, name: snapName, output: output.slice(0, 400) };
|
|
1917
|
+
})().finally(() => {
|
|
1918
|
+
this._snapshotSave = null;
|
|
1919
|
+
});
|
|
1920
|
+
return this._snapshotSave;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
async _maybeSaveSnapshot() {
|
|
1924
|
+
if (!this.quickBoot || !this.saveSnapshotAfterBoot) return;
|
|
1925
|
+
try {
|
|
1926
|
+
await this.saveSnapshot();
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
this._pushLog(`[vm] snapshot save skipped: ${error && error.message ? error.message : error}`);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async _spawnEmulatorAndWait(epoch = this._waitEpoch) {
|
|
1933
|
+
this._spawnedByUs = true;
|
|
1934
|
+
this._serialActive = this.serial || `emulator-${this.port}`;
|
|
1935
|
+
this.serial = this._serialActive;
|
|
1936
|
+
this._bootStartAt = Date.now();
|
|
1937
|
+
this._sawNotBooted = false;
|
|
1938
|
+
this._bootProbe = { serial: null, at: 0, booted: false };
|
|
1939
|
+
this._snapshotLoadFailed = false;
|
|
1940
|
+
this._setState('starting');
|
|
1941
|
+
|
|
1942
|
+
const plan = await this._buildLaunchPlan();
|
|
1943
|
+
if (epoch !== this._waitEpoch) return;
|
|
1944
|
+
this._bootKind = plan.bootKind;
|
|
1945
|
+
this._accelAvailable = plan.accelAvailable;
|
|
1946
|
+
this._snapshotAvailable = plan.snapshotAvailable;
|
|
1947
|
+
if (plan.fatal) {
|
|
1948
|
+
this._setState('error', plan.fatal);
|
|
1949
|
+
this._pushLog(`[vm] ${plan.fatal}`);
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
this._lastLaunchArgs = plan.args.slice();
|
|
1953
|
+
this._pushLog(
|
|
1954
|
+
`[vm] spawning emulator (${plan.bootKind}${plan.snapshotAvailable ? ` snapshot=${this.snapshotName}` : ''}` +
|
|
1955
|
+
`, accel=${plan.accelAvailable ? 'hardware' : 'TCG'}, gpu=${this.gpuMode}) port=${this.port}`,
|
|
1956
|
+
);
|
|
1957
|
+
|
|
1958
|
+
const args = plan.args;
|
|
1959
|
+
|
|
1960
|
+
let child;
|
|
1961
|
+
try {
|
|
1962
|
+
child = spawn(this.emulatorPath, args, {
|
|
1963
|
+
windowsHide: true,
|
|
1964
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1965
|
+
});
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
this._setState('error', `failed to spawn emulator: ${error.message}`);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
this._child = child;
|
|
1971
|
+
|
|
1972
|
+
const forward = (prefix) => (chunk) => {
|
|
1973
|
+
for (const line of splitLines(chunk.toString('utf8'))) {
|
|
1974
|
+
const text = line.trimEnd();
|
|
1975
|
+
if (text.trim()) this._pushLog(`${prefix}${text}`);
|
|
1976
|
+
if (/failed to load snapshot|starting from scratch|different avd configuration|ignoring -snapshot option/i.test(text)) {
|
|
1977
|
+
this._snapshotLoadFailed = true;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
child.stdout.on('data', forward(''));
|
|
1982
|
+
child.stderr.on('data', forward('[emu] '));
|
|
1983
|
+
child.on('error', (error) => {
|
|
1984
|
+
if (epoch === this._waitEpoch && this._state !== 'stopped' && this._state !== 'ready') {
|
|
1985
|
+
this._setState('error', `emulator spawn error: ${error.message}`);
|
|
1986
|
+
}
|
|
1987
|
+
});
|
|
1988
|
+
child.on('exit', (code, signal) => {
|
|
1989
|
+
this._pushLog(`[vm] emulator exited (code=${code}, signal=${signal})`);
|
|
1990
|
+
const current = this._child === child;
|
|
1991
|
+
if (current) this._child = null;
|
|
1992
|
+
// Surface crashes for the run that still owns this VM instance.
|
|
1993
|
+
if (
|
|
1994
|
+
current &&
|
|
1995
|
+
epoch === this._waitEpoch &&
|
|
1996
|
+
!this._disposed &&
|
|
1997
|
+
this._state !== 'stopped' &&
|
|
1998
|
+
this._state !== 'ready'
|
|
1999
|
+
) {
|
|
2000
|
+
this._setState('error', `emulator exited (code ${code}, signal ${signal || 'none'}) before boot completed`);
|
|
2001
|
+
}
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
await this.waitBooted(this.bootTimeoutMs);
|
|
2005
|
+
if (epoch !== this._waitEpoch) return; // stop() interrupted this run
|
|
2006
|
+
if (this._state === 'ready') {
|
|
2007
|
+
if (this._bootKind === 'snapshot' && this._snapshotLoadFailed) {
|
|
2008
|
+
this._bootKind = 'cold';
|
|
2009
|
+
this._pushLog('[vm] snapshot load failed; continuing as cold boot and rebuilding the snapshot');
|
|
2010
|
+
}
|
|
2011
|
+
await this._warmUpSpawnedDevice();
|
|
2012
|
+
// Cold boot produced the first image of the system; persist it so every
|
|
2013
|
+
// later start takes the snapshot path (seconds instead of a boot).
|
|
2014
|
+
if (this._bootKind === 'cold') void this._maybeSaveSnapshot();
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
async _warmUpSpawnedDevice() {
|
|
2019
|
+
const serial = this._serialActive;
|
|
2020
|
+
if (!serial) return;
|
|
2021
|
+
try {
|
|
2022
|
+
await this._adbText(['-s', serial, 'shell', 'wm', 'dismiss-keyguard'], {
|
|
2023
|
+
timeoutMs: 15000,
|
|
2024
|
+
});
|
|
2025
|
+
this._pushLog('[vm] keyguard dismissed');
|
|
2026
|
+
} catch {
|
|
2027
|
+
/* best effort only */
|
|
2028
|
+
}
|
|
2029
|
+
// adb root + debug settings + on-device toolkit; included in the snapshot
|
|
2030
|
+
// saved by the caller, so a quick boot restores the rooted debug surface.
|
|
2031
|
+
await this._prepareDebugSystem();
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// ---------------------------------------------------------------------------
|
|
2035
|
+
// Boot-wait / status probing
|
|
2036
|
+
// ---------------------------------------------------------------------------
|
|
2037
|
+
|
|
2038
|
+
async _waitBootInner(timeoutMs, epoch = this._waitEpoch) {
|
|
2039
|
+
const budget = asPositiveInt(timeoutMs, this.bootTimeoutMs);
|
|
2040
|
+
const deadline = Date.now() + budget;
|
|
2041
|
+
if (!this._bootStartAt) this._bootStartAt = Date.now();
|
|
2042
|
+
this._pushLog(`[vm] waiting for Android boot (timeout ${Math.round(budget / 1000)}s)`);
|
|
2043
|
+
let lastLoggedState = null;
|
|
2044
|
+
|
|
2045
|
+
for (;;) {
|
|
2046
|
+
if (this._disposed) return this._snapshot();
|
|
2047
|
+
if (epoch !== this._waitEpoch) {
|
|
2048
|
+
// stop() was requested while we waited
|
|
2049
|
+
return this._snapshot();
|
|
2050
|
+
}
|
|
2051
|
+
if (this._child && this._child.exitCode !== null) {
|
|
2052
|
+
this._setState('error', `emulator exited (code ${this._child.exitCode}) before Android booted`);
|
|
2053
|
+
return this._snapshot();
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
const devices = await this._listDevices();
|
|
2057
|
+
if (epoch !== this._waitEpoch) return this._snapshot();
|
|
2058
|
+
const target = this._resolveTarget(devices);
|
|
2059
|
+
|
|
2060
|
+
if (target && devices.get(target) === 'device') {
|
|
2061
|
+
this._serialActive = target;
|
|
2062
|
+
const booted = await this._probeBootedCached(target);
|
|
2063
|
+
if (epoch !== this._waitEpoch) return this._snapshot();
|
|
2064
|
+
if (booted) {
|
|
2065
|
+
const bootMs = this._bootStartAt ? Date.now() - this._bootStartAt : null;
|
|
2066
|
+
this._bootMs = bootMs;
|
|
2067
|
+
this._setState('ready');
|
|
2068
|
+
const kind = this._bootKind || 'attach';
|
|
2069
|
+
this._pushLog(
|
|
2070
|
+
`[vm] boot completed on ${target}` +
|
|
2071
|
+
(bootMs !== null ? ` in ${(bootMs / 1000).toFixed(1)}s` : ' (device was already booted)') +
|
|
2072
|
+
` [${kind}]`,
|
|
2073
|
+
);
|
|
2074
|
+
return this._snapshot();
|
|
2075
|
+
}
|
|
2076
|
+
this._sawNotBooted = true;
|
|
2077
|
+
if (this._state !== 'booting') this._setState('booting');
|
|
2078
|
+
} else if (target) {
|
|
2079
|
+
const deviceState = devices.get(target);
|
|
2080
|
+
this._serialActive = target;
|
|
2081
|
+
if (deviceState === 'unauthorized') {
|
|
2082
|
+
this._setState('error', `adb device ${target} is unauthorized; accept the debugging prompt`);
|
|
2083
|
+
return this._snapshot();
|
|
2084
|
+
}
|
|
2085
|
+
if (this._state !== 'starting') this._setState('starting');
|
|
2086
|
+
} else {
|
|
2087
|
+
if (lastLoggedState !== 'waiting-for-device') {
|
|
2088
|
+
this._pushLog(
|
|
2089
|
+
`[vm] no adb device yet (${this.serial ? `waiting for ${this.serial}` : 'waiting for any device'})`,
|
|
2090
|
+
);
|
|
2091
|
+
lastLoggedState = 'waiting-for-device';
|
|
2092
|
+
}
|
|
2093
|
+
if (this._state !== 'starting' && this._state !== 'error') this._setState('starting');
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
if (Date.now() >= deadline) {
|
|
2097
|
+
const targetLabel = this.serial || this._serialActive || 'any adb device';
|
|
2098
|
+
const message =
|
|
2099
|
+
`timed out after ${Math.round(budget / 1000)}s waiting for Android boot on ${targetLabel}` +
|
|
2100
|
+
(this._lastProbeError ? ` (adb probe error: ${this._lastProbeError})` : '');
|
|
2101
|
+
this._setState('error', message);
|
|
2102
|
+
this._pushLog(`[vm] ${message}`);
|
|
2103
|
+
return this._snapshot();
|
|
2104
|
+
}
|
|
2105
|
+
await sleep(this.pollIntervalMs);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
async _refresh() {
|
|
2110
|
+
const devices = await this._listDevices();
|
|
2111
|
+
|
|
2112
|
+
if (this._lastProbeError) {
|
|
2113
|
+
if (this._state !== 'error') this._setState('error', this._lastProbeError);
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
const target = this._resolveTarget(devices);
|
|
2118
|
+
if (target) {
|
|
2119
|
+
this._serialActive = target;
|
|
2120
|
+
const deviceState = devices.get(target);
|
|
2121
|
+
if (deviceState === 'device') {
|
|
2122
|
+
if (this._state !== 'ready') {
|
|
2123
|
+
const booted = await this._probeBootedCached(target);
|
|
2124
|
+
if (booted) {
|
|
2125
|
+
if (this._bootStartAt) {
|
|
2126
|
+
this._bootMs = Date.now() - this._bootStartAt;
|
|
2127
|
+
}
|
|
2128
|
+
this._setState('ready');
|
|
2129
|
+
} else {
|
|
2130
|
+
this._sawNotBooted = true;
|
|
2131
|
+
if (this._state !== 'booting') this._setState('booting');
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
} else if (deviceState === 'unauthorized') {
|
|
2135
|
+
this._setState('error', `adb device ${target} is unauthorized; accept the debugging prompt`);
|
|
2136
|
+
} else {
|
|
2137
|
+
if (this._state === 'ready' || this._state === 'booting') {
|
|
2138
|
+
this._pushLog(`[vm] device ${target} went ${deviceState}`);
|
|
2139
|
+
}
|
|
2140
|
+
this._setState('starting');
|
|
2141
|
+
}
|
|
2142
|
+
} else {
|
|
2143
|
+
this._bootProbe = { serial: null, at: 0, booted: false };
|
|
2144
|
+
if (this._child && this._child.exitCode === null) {
|
|
2145
|
+
this._setState('starting');
|
|
2146
|
+
} else if (this._state !== 'error') {
|
|
2147
|
+
this._serialActive = null;
|
|
2148
|
+
this._setState('stopped');
|
|
2149
|
+
} else {
|
|
2150
|
+
// Keep the error message but do not report a stale serial.
|
|
2151
|
+
this._serialActive = null;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/** Run `getprop sys.boot_completed` with a short cache + single-flight coalescing. */
|
|
2157
|
+
async _probeBootedCached(serial) {
|
|
2158
|
+
const cache = this._bootProbe;
|
|
2159
|
+
if (cache.serial === serial && Date.now() - cache.at < this.bootProbeCacheMs) {
|
|
2160
|
+
return cache.booted;
|
|
2161
|
+
}
|
|
2162
|
+
if (this._bootProbeInFlight) return this._bootProbeInFlight;
|
|
2163
|
+
|
|
2164
|
+
this._bootProbeInFlight = (async () => {
|
|
2165
|
+
let booted = false;
|
|
2166
|
+
try {
|
|
2167
|
+
const result = await this._adb(['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], {
|
|
2168
|
+
timeoutMs: Math.min(this.commandTimeoutMs, 15000),
|
|
2169
|
+
maxBytes: 4096,
|
|
2170
|
+
});
|
|
2171
|
+
booted =
|
|
2172
|
+
result.code === 0 && !result.timedOut && result.stdout.toString('utf8').trim() === '1';
|
|
2173
|
+
} catch {
|
|
2174
|
+
booted = false;
|
|
2175
|
+
}
|
|
2176
|
+
this._bootProbe = { serial, booted, at: Date.now() };
|
|
2177
|
+
return booted;
|
|
2178
|
+
})().finally(() => {
|
|
2179
|
+
this._bootProbeInFlight = null;
|
|
2180
|
+
});
|
|
2181
|
+
return this._bootProbeInFlight;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// ---------------------------------------------------------------------------
|
|
2185
|
+
// adb plumbing
|
|
2186
|
+
// ---------------------------------------------------------------------------
|
|
2187
|
+
|
|
2188
|
+
_resolveTarget(devices) {
|
|
2189
|
+
if (this.serial) {
|
|
2190
|
+
return devices.has(this.serial) ? this.serial : null;
|
|
2191
|
+
}
|
|
2192
|
+
for (const [serial, state] of devices) {
|
|
2193
|
+
if (state === 'device') return serial;
|
|
2194
|
+
}
|
|
2195
|
+
for (const [serial] of devices) return serial;
|
|
2196
|
+
return null;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
async _adb(args, options = {}) {
|
|
2200
|
+
return this._run(this.adbPath, args, options);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
async _adbText(args, options = {}) {
|
|
2204
|
+
const result = await this._adb(args, options);
|
|
2205
|
+
if (result.timedOut) {
|
|
2206
|
+
throw new Error(`adb ${args.join(' ')} timed out after ${options.timeoutMs || this.commandTimeoutMs}ms`);
|
|
2207
|
+
}
|
|
2208
|
+
if (result.code !== 0) {
|
|
2209
|
+
const detail = `${result.stderr}\n${result.stdout.toString('utf8')}`.trim().slice(0, 500);
|
|
2210
|
+
throw new Error(`adb ${args.join(' ')} failed (exit ${result.code}): ${detail}`);
|
|
2211
|
+
}
|
|
2212
|
+
return result.stdout.toString('utf8');
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/** Bounded, non-blocking child process runner. Never uses a host shell. */
|
|
2216
|
+
_run(executable, args, options = {}) {
|
|
2217
|
+
const timeoutMs = asPositiveInt(options.timeoutMs, this.commandTimeoutMs);
|
|
2218
|
+
const maxBytes = asPositiveInt(options.maxBytes, this.maxOutputBytes);
|
|
2219
|
+
|
|
2220
|
+
return new Promise((resolve, reject) => {
|
|
2221
|
+
let child;
|
|
2222
|
+
try {
|
|
2223
|
+
child = spawn(executable, args, {
|
|
2224
|
+
windowsHide: true,
|
|
2225
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2226
|
+
});
|
|
2227
|
+
} catch (error) {
|
|
2228
|
+
reject(new Error(`failed to spawn ${executable}: ${error.message}`));
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
const stdoutChunks = [];
|
|
2233
|
+
let stdoutBytes = 0;
|
|
2234
|
+
let stdoutTruncated = false;
|
|
2235
|
+
const stderrChunks = [];
|
|
2236
|
+
let stderrBytes = 0;
|
|
2237
|
+
let settled = false;
|
|
2238
|
+
let timedOut = false;
|
|
2239
|
+
|
|
2240
|
+
const timer = setTimeout(() => {
|
|
2241
|
+
timedOut = true;
|
|
2242
|
+
try {
|
|
2243
|
+
child.kill('SIGKILL');
|
|
2244
|
+
} catch {
|
|
2245
|
+
/* already gone */
|
|
2246
|
+
}
|
|
2247
|
+
}, timeoutMs);
|
|
2248
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
2249
|
+
|
|
2250
|
+
child.stdout.on('data', (chunk) => {
|
|
2251
|
+
if (stdoutBytes >= maxBytes) {
|
|
2252
|
+
stdoutTruncated = true;
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
const room = maxBytes - stdoutBytes;
|
|
2256
|
+
const slice = chunk.length <= room ? chunk : chunk.subarray(0, room);
|
|
2257
|
+
stdoutChunks.push(slice);
|
|
2258
|
+
stdoutBytes += slice.length;
|
|
2259
|
+
if (slice.length < chunk.length) stdoutTruncated = true;
|
|
2260
|
+
});
|
|
2261
|
+
child.stderr.on('data', (chunk) => {
|
|
2262
|
+
if (stderrBytes >= 256 * 1024) return;
|
|
2263
|
+
const room = 256 * 1024 - stderrBytes;
|
|
2264
|
+
const slice = chunk.length <= room ? chunk : chunk.subarray(0, room);
|
|
2265
|
+
stderrChunks.push(slice);
|
|
2266
|
+
stderrBytes += slice.length;
|
|
2267
|
+
});
|
|
2268
|
+
child.on('error', (error) => {
|
|
2269
|
+
if (settled) return;
|
|
2270
|
+
settled = true;
|
|
2271
|
+
clearTimeout(timer);
|
|
2272
|
+
reject(new Error(`failed to run ${executable}: ${error.message}`));
|
|
2273
|
+
});
|
|
2274
|
+
child.on('close', (code, signal) => {
|
|
2275
|
+
if (settled) return;
|
|
2276
|
+
settled = true;
|
|
2277
|
+
clearTimeout(timer);
|
|
2278
|
+
resolve({
|
|
2279
|
+
code,
|
|
2280
|
+
signal,
|
|
2281
|
+
timedOut,
|
|
2282
|
+
truncated: stdoutTruncated,
|
|
2283
|
+
stdout: Buffer.concat(stdoutChunks),
|
|
2284
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
2285
|
+
});
|
|
2286
|
+
});
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/** Parse `adb devices -l`; returns Map<serial, state>. Never throws. */
|
|
2291
|
+
async _listDevices(options = {}) {
|
|
2292
|
+
let result;
|
|
2293
|
+
try {
|
|
2294
|
+
result = await this._adb(['devices', '-l'], { timeoutMs: options.timeoutMs || 15000 });
|
|
2295
|
+
} catch (error) {
|
|
2296
|
+
this._lastProbeError = `adb devices failed: ${error.message}`;
|
|
2297
|
+
this._devicesCache = { at: Date.now(), devices: new Map() };
|
|
2298
|
+
return new Map();
|
|
2299
|
+
}
|
|
2300
|
+
if (result.timedOut) {
|
|
2301
|
+
this._lastProbeError = 'adb devices timed out';
|
|
2302
|
+
this._devicesCache = { at: Date.now(), devices: new Map() };
|
|
2303
|
+
return new Map();
|
|
2304
|
+
}
|
|
2305
|
+
if (result.code !== 0) {
|
|
2306
|
+
const detail = (result.stderr || result.stdout.toString('utf8')).trim().slice(0, 300);
|
|
2307
|
+
this._lastProbeError = `adb devices failed (exit ${result.code}): ${detail}`;
|
|
2308
|
+
this._devicesCache = { at: Date.now(), devices: new Map() };
|
|
2309
|
+
return new Map();
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
const devices = new Map();
|
|
2313
|
+
for (const line of splitLines(result.stdout.toString('utf8'))) {
|
|
2314
|
+
const trimmed = line.trim();
|
|
2315
|
+
if (!trimmed) continue;
|
|
2316
|
+
if (trimmed.startsWith('List of devices') || trimmed.startsWith('*')) continue;
|
|
2317
|
+
const match = /^(\S+)\s+(\S+)/.exec(trimmed);
|
|
2318
|
+
if (!match) continue;
|
|
2319
|
+
devices.set(match[1], match[2]);
|
|
2320
|
+
}
|
|
2321
|
+
this._devicesCache = { at: Date.now(), devices };
|
|
2322
|
+
this._lastProbeError = null;
|
|
2323
|
+
return devices;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
async _requireSerial() {
|
|
2327
|
+
if (this._serialActive) {
|
|
2328
|
+
const devices = await this._listDevices();
|
|
2329
|
+
if (this._lastProbeError) {
|
|
2330
|
+
throw new Error(`adb unavailable: ${this._lastProbeError}`);
|
|
2331
|
+
}
|
|
2332
|
+
if (devices.get(this._serialActive) === 'device') return this._serialActive;
|
|
2333
|
+
this._serialActive = null;
|
|
2334
|
+
}
|
|
2335
|
+
const devices = await this._listDevices();
|
|
2336
|
+
if (this._lastProbeError) {
|
|
2337
|
+
throw new Error(`adb unavailable: ${this._lastProbeError}`);
|
|
2338
|
+
}
|
|
2339
|
+
const serial = this._resolveTarget(devices);
|
|
2340
|
+
if (!serial || devices.get(serial) !== 'device') {
|
|
2341
|
+
throw new Error(
|
|
2342
|
+
'no Android device is online; call start() or connect(serial) first ' +
|
|
2343
|
+
`(adb=${this.adbPath})`,
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
this._serialActive = serial;
|
|
2347
|
+
return serial;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
async _assertApkFile(apkPath) {
|
|
2351
|
+
if (typeof apkPath !== 'string' || apkPath.trim() === '') {
|
|
2352
|
+
throw new Error('an APK path is required');
|
|
2353
|
+
}
|
|
2354
|
+
const file = path.resolve(apkPath);
|
|
2355
|
+
let stat;
|
|
2356
|
+
try {
|
|
2357
|
+
stat = await fsp.stat(file);
|
|
2358
|
+
} catch {
|
|
2359
|
+
throw new Error(`APK not found: ${file}`);
|
|
2360
|
+
}
|
|
2361
|
+
if (!stat.isFile()) throw new Error(`not a regular file: ${file}`);
|
|
2362
|
+
return file;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
/** Locate aapt2: explicit option, then newest SDK build-tools, then PATH. */
|
|
2366
|
+
async _findAapt2() {
|
|
2367
|
+
if (this._aapt2Resolved !== undefined) return this._aapt2Resolved;
|
|
2368
|
+
if (this.options.aapt2Path) {
|
|
2369
|
+
const explicit = String(this.options.aapt2Path);
|
|
2370
|
+
this._aapt2Resolved = existsSync(explicit) ? explicit : null;
|
|
2371
|
+
if (this._aapt2Resolved) return this._aapt2Resolved;
|
|
2372
|
+
}
|
|
2373
|
+
for (const root of candidateSdkRoots(this.sdkRoot)) {
|
|
2374
|
+
const buildTools = path.join(root, 'build-tools');
|
|
2375
|
+
let entries;
|
|
2376
|
+
try {
|
|
2377
|
+
entries = await fsp.readdir(buildTools, { withFileTypes: true });
|
|
2378
|
+
} catch {
|
|
2379
|
+
continue;
|
|
2380
|
+
}
|
|
2381
|
+
const versions = entries
|
|
2382
|
+
.filter((entry) => entry.isDirectory() && /^\d/.test(entry.name))
|
|
2383
|
+
.map((entry) => entry.name)
|
|
2384
|
+
.sort(compareVersionsDesc);
|
|
2385
|
+
for (const version of versions) {
|
|
2386
|
+
const candidate = path.join(buildTools, version, `aapt2${EXE}`);
|
|
2387
|
+
if (existsSync(candidate)) {
|
|
2388
|
+
this._aapt2Resolved = candidate;
|
|
2389
|
+
return candidate;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
this._aapt2Resolved = null;
|
|
2394
|
+
return null;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// ---------------------------------------------------------------------------
|
|
2398
|
+
// Small helpers
|
|
2399
|
+
// ---------------------------------------------------------------------------
|
|
2400
|
+
|
|
2401
|
+
_setState(state, error = null) {
|
|
2402
|
+
const previous = this._state;
|
|
2403
|
+
const previousError = this._error;
|
|
2404
|
+
this._error = state === 'error' ? error || 'unknown error' : null;
|
|
2405
|
+
this._state = state;
|
|
2406
|
+
if (previous !== state) {
|
|
2407
|
+
const suffix = state === 'error' ? `: ${this._error}` : '';
|
|
2408
|
+
this._pushLog(`[vm] state ${previous} -> ${state}${suffix}`);
|
|
2409
|
+
} else if (state === 'error' && this._error !== previousError) {
|
|
2410
|
+
this._pushLog(`[vm] error: ${this._error}`);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
_snapshot() {
|
|
2415
|
+
return {
|
|
2416
|
+
state: this._state,
|
|
2417
|
+
serial: this._serialActive || null,
|
|
2418
|
+
avd: this._state === 'stopped' ? null : this.avdName || null,
|
|
2419
|
+
external: this._external === true,
|
|
2420
|
+
externalSerial: this._externalSerial || null,
|
|
2421
|
+
managedAvd: this._managedAvdName || null,
|
|
2422
|
+
bootMs: this._bootMs,
|
|
2423
|
+
bootKind: this._bootKind,
|
|
2424
|
+
quickBoot: this.quickBoot,
|
|
2425
|
+
snapshotName: this.snapshotName,
|
|
2426
|
+
snapshotAvailable: this._snapshotAvailable === true || this._snapshotPresent(),
|
|
2427
|
+
gpuMode: this.gpuMode,
|
|
2428
|
+
accel: this._accelAvailable,
|
|
2429
|
+
root: this._root === true,
|
|
2430
|
+
debugPrepared: this._debugPrepared === true,
|
|
2431
|
+
debugToolkit: this._debugToolkitPath,
|
|
2432
|
+
identity: this._identity,
|
|
2433
|
+
error: this._error,
|
|
2434
|
+
logTail: this._logTail.slice(),
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
_pushLog(line) {
|
|
2439
|
+
const text = String(line);
|
|
2440
|
+
for (const rawLine of splitLines(text)) {
|
|
2441
|
+
const entry = rawLine.trimEnd();
|
|
2442
|
+
if (!entry) continue;
|
|
2443
|
+
this._logTail.push(entry);
|
|
2444
|
+
if (this._logTail.length > this.maxLogLines) {
|
|
2445
|
+
this._logTail.splice(0, this._logTail.length - this.maxLogLines);
|
|
2446
|
+
}
|
|
2447
|
+
if (this.logSink) {
|
|
2448
|
+
try {
|
|
2449
|
+
this.logSink(entry);
|
|
2450
|
+
} catch {
|
|
2451
|
+
/* user log sink must not break the VM */
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// -----------------------------------------------------------------------------
|
|
2459
|
+
// Process helpers
|
|
2460
|
+
// -----------------------------------------------------------------------------
|
|
2461
|
+
|
|
2462
|
+
function waitForChildExit(child, timeoutMs) {
|
|
2463
|
+
return new Promise((resolve) => {
|
|
2464
|
+
if (!child || child.exitCode !== null) {
|
|
2465
|
+
resolve(true);
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
let done = false;
|
|
2469
|
+
const finish = (value) => {
|
|
2470
|
+
if (done) return;
|
|
2471
|
+
done = true;
|
|
2472
|
+
clearTimeout(timer);
|
|
2473
|
+
resolve(value);
|
|
2474
|
+
};
|
|
2475
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
2476
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
2477
|
+
child.once('exit', () => finish(true));
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
function killProcessTree(child) {
|
|
2482
|
+
if (!child || child.exitCode !== null) return;
|
|
2483
|
+
try {
|
|
2484
|
+
child.kill('SIGTERM');
|
|
2485
|
+
} catch {
|
|
2486
|
+
/* already dead */
|
|
2487
|
+
}
|
|
2488
|
+
if (IS_WIN && child.pid) {
|
|
2489
|
+
// argv array, no host shell; /T also reaps the emulator's qemu child.
|
|
2490
|
+
try {
|
|
2491
|
+
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
|
2492
|
+
windowsHide: true,
|
|
2493
|
+
stdio: 'ignore',
|
|
2494
|
+
});
|
|
2495
|
+
killer.on('error', () => {});
|
|
2496
|
+
} catch {
|
|
2497
|
+
/* ignore */
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
export default AndroidVm;
|