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/index.js ADDED
@@ -0,0 +1,2532 @@
1
+ /**
2
+ * dsh-apk-vm — Host half.
3
+ *
4
+ * Mounts the APK Virtual Machine bridge behind the Web server:
5
+ * GET /apk-vm/state VM lifecycle snapshot (adb/emulator state, display size)
6
+ * GET /apk-vm/apks discover *.apk files under the configured roots
7
+ * POST /apk-vm/vm { action: 'start' | 'stop' | 'restart' }
8
+ * POST /apk-vm/install { path } install + launch one APK inside the VM
9
+ * GET /apk-vm/frame latest PNG frame (screencap; polled by the sidebar panel)
10
+ * POST /apk-vm/input tap / swipe / key / text forwarded to the VM
11
+ *
12
+ * The Android runtime is an open-source AOSP Android Emulator (software TCG when
13
+ * no hypervisor is available), driven through adb by lib/android.js. The design
14
+ * and the open-source framework choices are documented in README.md / DESIGN.md.
15
+ */
16
+ import { randomUUID } from 'node:crypto';
17
+ import { createReadStream, existsSync } from 'node:fs';
18
+ import { promises as fsp } from 'node:fs';
19
+ import net from 'node:net';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ import { AndroidVm } from './lib/android.js';
25
+ import { InstanceManager } from './lib/instances.js';
26
+ import { ScrcpySession, annexbToAvcc, CONTROL_MESSAGE_TYPE } from './lib/scrcpy.js';
27
+ import { detectEnvironment, createAvd, installSystemImage, installSdkPackages, bootstrapSdk, DEFAULT_SYSTEM_IMAGE } from './lib/setup.js';
28
+ import { acceptWebSocket } from './lib/ws.js';
29
+
30
+ export const name = 'dsh-apk-vm';
31
+
32
+ const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url));
33
+ const SCRCPY_JAR = path.join(PLUGIN_DIR, 'lib', 'vendor', 'scrcpy-server-v5.0.1.jar');
34
+
35
+ /** Body limit for the JSON control endpoints. */
36
+ const BODY_LIMIT_BYTES = 64 * 1024;
37
+ /** Upload limit for browser APK uploads (500 MiB; Android emulator system apps are smaller). */
38
+ const UPLOAD_LIMIT_BYTES = 500 * 1024 * 1024;
39
+ /** Reuse one screencap for this long; a TCG frame is expensive to capture. */
40
+ const FRAME_TTL_MS = 200;
41
+ /** Cache `wm size` for a while to keep the 2 s status poll cheap. */
42
+ const SCREEN_TTL_MS = 15_000;
43
+
44
+ function isReady(vm) {
45
+ return vm && vm.state === 'ready';
46
+ }
47
+
48
+ function sendJson(res, status, body) {
49
+ const text = JSON.stringify(body);
50
+ res.writeHead(status, {
51
+ 'Content-Type': 'application/json; charset=utf-8',
52
+ 'Cache-Control': 'no-store',
53
+ 'Content-Length': Buffer.byteLength(text),
54
+ 'X-Content-Type-Options': 'nosniff',
55
+ });
56
+ res.end(text);
57
+ }
58
+
59
+ function sendError(res, status, message) {
60
+ sendJson(res, status, { ok: false, error: String(message || 'unknown error') });
61
+ }
62
+
63
+ function readJsonBody(req) {
64
+ return new Promise((resolve, reject) => {
65
+ let size = 0;
66
+ const chunks = [];
67
+ req.on('data', (chunk) => {
68
+ size += chunk.length;
69
+ if (size > BODY_LIMIT_BYTES) {
70
+ reject(new Error(`request body exceeds ${BODY_LIMIT_BYTES} bytes`));
71
+ req.destroy();
72
+ return;
73
+ }
74
+ chunks.push(chunk);
75
+ });
76
+ req.on('end', () => {
77
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
78
+ if (!raw) {
79
+ resolve({});
80
+ return;
81
+ }
82
+ try {
83
+ const parsed = JSON.parse(raw);
84
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
85
+ reject(new Error('request body must be a JSON object'));
86
+ return;
87
+ }
88
+ resolve(parsed);
89
+ } catch (error) {
90
+ reject(new Error(`invalid JSON body: ${error.message}`));
91
+ }
92
+ });
93
+ req.on('error', reject);
94
+ });
95
+ }
96
+
97
+ /** Read a raw request body with a hard byte limit (browser APK upload). */
98
+ function readRawBody(req, limitBytes) {
99
+ return new Promise((resolve, reject) => {
100
+ let size = 0;
101
+ let settled = false;
102
+ const chunks = [];
103
+ const fail = (error) => {
104
+ if (settled) return;
105
+ settled = true;
106
+ reject(error);
107
+ try {
108
+ req.destroy();
109
+ } catch {
110
+ /* ignore */
111
+ }
112
+ };
113
+ req.on('data', (chunk) => {
114
+ if (settled) return;
115
+ size += chunk.length;
116
+ if (size > limitBytes) {
117
+ fail(new Error(`upload exceeds ${limitBytes} bytes`));
118
+ return;
119
+ }
120
+ chunks.push(chunk);
121
+ });
122
+ req.on('end', () => {
123
+ if (settled) return;
124
+ settled = true;
125
+ resolve(Buffer.concat(chunks));
126
+ });
127
+ req.on('error', (error) => fail(error && error.message ? error : new Error(String(error))));
128
+ });
129
+ }
130
+
131
+ /** Reject browser cross-origin requests to the local debug surface. */
132
+ /** When false (default), only requests originating on loopback may use the VM APIs. */
133
+ let ALLOW_REMOTE = false;
134
+
135
+ function isLoopbackAddress(address) {
136
+ const value = String(address || '').toLowerCase();
137
+ return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
138
+ }
139
+
140
+ function isSameOrigin(req) {
141
+ if (!ALLOW_REMOTE && !isLoopbackAddress(req.socket && req.socket.remoteAddress)) return false;
142
+ const host = String(req.headers.host || '').toLowerCase();
143
+ const origin = req.headers.origin;
144
+ if (origin) {
145
+ try {
146
+ if (new URL(origin).host.toLowerCase() !== host) return false;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+ const site = String(req.headers['sec-fetch-site'] || '').toLowerCase();
152
+ if (site && site !== 'same-origin' && site !== 'same-site' && site !== 'none') return false;
153
+ return true;
154
+ }
155
+
156
+ /** Check whether a local TCP port is free for the scrcpy forward tunnel. */
157
+ function isPortFree(port) {
158
+ return new Promise((resolve) => {
159
+ const server = net.createServer();
160
+ server.once('error', () => resolve(false));
161
+ server.once('listening', () => server.close(() => resolve(true)));
162
+ try {
163
+ server.listen(port, '127.0.0.1');
164
+ } catch {
165
+ resolve(false);
166
+ }
167
+ });
168
+ }
169
+
170
+ async function pickScrcpyPort(preferred) {
171
+ if (await isPortFree(preferred)) return preferred;
172
+ for (let attempt = 0; attempt < 32; attempt += 1) {
173
+ const candidate = 28000 + Math.floor(Math.random() * 2000);
174
+ if (await isPortFree(candidate)) return candidate;
175
+ }
176
+ return preferred;
177
+ }
178
+
179
+ function resolveRoots(ctx, configured) {
180
+ const roots = [];
181
+ const push = (candidate) => {
182
+ if (typeof candidate === 'string' && candidate.trim() && !roots.includes(candidate)) {
183
+ roots.push(path.resolve(candidate));
184
+ }
185
+ };
186
+ if (Array.isArray(configured)) configured.forEach(push);
187
+ try {
188
+ const workingDirectory = typeof ctx.get === 'function' ? ctx.get('workingDirectory') : undefined;
189
+ if (workingDirectory && typeof workingDirectory.defaultDirectory === 'string') {
190
+ push(workingDirectory.defaultDirectory);
191
+ }
192
+ } catch {
193
+ /* optional service */
194
+ }
195
+ push(process.cwd());
196
+ push(os.homedir());
197
+ return roots;
198
+ }
199
+
200
+ function resolveApkPath(candidate, roots) {
201
+ const raw = String(candidate || '').trim();
202
+ if (!raw) throw new Error('APK path is required');
203
+ const paths = [raw];
204
+ if (!path.isAbsolute(raw)) {
205
+ for (const root of roots) paths.push(path.join(root, raw));
206
+ }
207
+ for (const p of paths) {
208
+ try {
209
+ const resolved = path.resolve(p);
210
+ if (!resolved.toLowerCase().endsWith('.apk')) continue;
211
+ if (existsSync(resolved)) return resolved;
212
+ } catch {
213
+ /* try next */
214
+ }
215
+ }
216
+ throw new Error(`APK file not found: ${raw}`);
217
+ }
218
+
219
+ /**
220
+ * Mount the routes and own the AndroidVm instance.
221
+ * @param ctx cordis host context.
222
+ * @param config optional row config: { roots, adbPath, sdkRoot, emulatorPath, avdName, serial, spawnEmulator }.
223
+ */
224
+ export function apply(ctx, config = {}) {
225
+ const cfg = config && typeof config === 'object' ? config : {};
226
+ ALLOW_REMOTE = cfg.allowRemote === true;
227
+ const vm = new AndroidVm({
228
+ sdkRoot: cfg.sdkRoot || process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || undefined,
229
+ adbPath: cfg.adbPath || undefined,
230
+ emulatorPath: cfg.emulatorPath || undefined,
231
+ avdName: cfg.avdName || undefined,
232
+ serial: cfg.serial || undefined,
233
+ port: cfg.port || undefined,
234
+ emulatorArgs: Array.isArray(cfg.emulatorArgs) ? cfg.emulatorArgs : undefined,
235
+ spawnEmulator: cfg.spawnEmulator !== false,
236
+ apkRoots: Array.isArray(cfg.roots) ? cfg.roots : undefined,
237
+ bootTimeoutMs: cfg.bootTimeoutMs || undefined,
238
+ // Fast-boot strategy: host GPU + hypervisor detection + quick-boot snapshots.
239
+ gpuMode: cfg.gpuMode || undefined,
240
+ accel: cfg.accel || undefined,
241
+ requireAccel: cfg.requireAccel !== false,
242
+ quickBoot: cfg.quickBoot !== false,
243
+ snapshotName: cfg.snapshotName || undefined,
244
+ saveSnapshotAfterBoot: cfg.saveSnapshotAfterBoot !== false,
245
+ });
246
+
247
+ const instanceManager =
248
+ cfg.multiInstance === false
249
+ ? null
250
+ : new InstanceManager({
251
+ sdkRoot: vm.sdkRoot || cfg.sdkRoot || undefined,
252
+ adbPath: vm.adbPath,
253
+ emulatorPath: vm.emulatorPath,
254
+ systemImage: cfg.systemImage || DEFAULT_SYSTEM_IMAGE,
255
+ baseAvdName: cfg.avdName || 'dsh_apk',
256
+ maxInstances: cfg.maxInstances || 3,
257
+ gpuMode: cfg.gpuMode || 'host',
258
+ snapshotName: cfg.snapshotName || 'default_boot',
259
+ scrcpyJarPath: SCRCPY_JAR,
260
+ scrcpyMaxSize: cfg.scrcpyMaxSize || 960,
261
+ scrcpyBitrate: cfg.scrcpyBitrate || 6_000_000,
262
+ scrcpyMaxFps: cfg.scrcpyMaxFps || 60,
263
+ idleTimeoutMs: cfg.instanceIdleMs || 15 * 60 * 1000,
264
+ log: (line) => vm._pushLog(line),
265
+ });
266
+
267
+ let lastApk = null;
268
+ let lastClientReport = null;
269
+ let serverOrigin = null;
270
+ let registerInstanceRoutes = null;
271
+ let disposeInstanceRoutes = null;
272
+ if (instanceManager) {
273
+ instanceManager.onCreated = (id) => {
274
+ registerInstanceRoutes?.(id);
275
+ };
276
+ }
277
+ let installInFlight = null;
278
+ let startKick = null;
279
+ let autoStartKicked = false;
280
+ const frame = { buffer: null, at: 0, inFlight: null, receivedAt: [] };
281
+ const screen = { value: null, at: 0 };
282
+ const preferAutoStart = cfg.autoStart !== false;
283
+ let streamFps = Number.isFinite(Number(cfg.frameFps)) ? Math.min(10, Math.max(1, Math.round(Number(cfg.frameFps)))) : 2;
284
+
285
+ function measuredFrameFps() {
286
+ const now = Date.now();
287
+ const cutoff = now - 5000;
288
+ while (frame.receivedAt.length && frame.receivedAt[0] < cutoff) frame.receivedAt.shift();
289
+ if (frame.receivedAt.length < 2) return null;
290
+ const span = now - frame.receivedAt[0];
291
+ if (span <= 0) return null;
292
+ return Math.round((frame.receivedAt.length / (span / 1000)) * 10) / 10;
293
+ }
294
+
295
+ /* ---- H.264 video stream (scrcpy) ---------------------------------- *
296
+ * The PNG screencap path is capped near 1-2 fps by adb. For real frame
297
+ * rates the plugin runs the Apache-2.0 scrcpy server (vendored jar), parses
298
+ * its H.264 stream with lib/scrcpy.js, converts Annex-B → AVCC, and relays
299
+ * the packets over a no-dependency WebSocket (/apk-vm/ws) to the panel,
300
+ * which decodes them with WebCodecs VideoDecoder. Input still goes through
301
+ * the existing /apk-vm/input routes. */
302
+
303
+ const scrcpyConfig = {
304
+ enabled: cfg.scrcpy !== false,
305
+ jarPath: cfg.scrcpyJarPath ? String(cfg.scrcpyJarPath) : SCRCPY_JAR,
306
+ maxSize: Math.min(4096, Math.max(240, Number(cfg.scrcpyMaxSize) || 960)),
307
+ bitrate: Math.max(500_000, Number(cfg.scrcpyBitrate) || 6_000_000),
308
+ maxFps: Math.min(60, Math.max(1, Number(cfg.scrcpyMaxFps) || 60)),
309
+ port: Number(cfg.scrcpyPort) || 27183,
310
+ };
311
+ let scrcpyState = null;
312
+ let scrcpyIdleTimer = null;
313
+ let scrcpyInitClients = null; // WeakSet of clients that already got the init JSON
314
+
315
+ let setupCache = null;
316
+ let setupCacheAt = 0;
317
+ async function loadSetup(force = false) {
318
+ const now = Date.now();
319
+ if (!force && setupCache && now - setupCacheAt < 15000) return setupCache;
320
+ setupCache = await detectEnvironment({
321
+ sdkRoot: cfg.sdkRoot || undefined,
322
+ adbPath: cfg.adbPath || undefined,
323
+ emulatorPath: cfg.emulatorPath || undefined,
324
+ avdName: cfg.avdName || undefined,
325
+ systemImage: cfg.systemImage || DEFAULT_SYSTEM_IMAGE,
326
+ });
327
+ setupCacheAt = now;
328
+ return setupCache;
329
+ }
330
+
331
+ function getScrcpyState() {
332
+ if (scrcpyState) return scrcpyState;
333
+ scrcpyInitClients = new WeakSet();
334
+ scrcpyState = {
335
+ session: null,
336
+ startPromise: null,
337
+ clients: new Set(),
338
+ init: null,
339
+ initSent: false,
340
+ initSignature: null,
341
+ dims: null,
342
+ videoConfig: null,
343
+ pending: [],
344
+ lastError: null,
345
+ fps: 0,
346
+ frames: 0,
347
+ fpsAt: 0,
348
+ };
349
+ return scrcpyState;
350
+ }
351
+
352
+ function scrcpyBroadcastText(payload) {
353
+ const st = getScrcpyState();
354
+ const text = JSON.stringify(payload);
355
+ for (const client of st.clients) client.sendText(text);
356
+ }
357
+
358
+ function scrcpyBroadcastBinary(payload) {
359
+ const st = getScrcpyState();
360
+ for (const client of st.clients) client.sendBinary(payload);
361
+ }
362
+
363
+ function sendScrcpyInit(st) {
364
+ if (!st.dims || !st.videoConfig) return;
365
+ const signature = `${st.dims.width}x${st.dims.height}:${st.videoConfig.codecString}:${Buffer.from(st.videoConfig.avcC).toString('base64')}`;
366
+ const changed = Boolean(st.initSignature && st.initSignature !== signature);
367
+ if (changed) {
368
+ // Rotation / encoder reset: every client must reconfigure its decoder.
369
+ st.init = null;
370
+ st.initSent = false;
371
+ st.initSignature = null;
372
+ st.pending = [];
373
+ scrcpyInitClients = new WeakSet();
374
+ }
375
+ if (!st.init) {
376
+ st.init = {
377
+ type: 'init',
378
+ codec: 'h264',
379
+ codecString: st.videoConfig.codecString,
380
+ description: Buffer.from(st.videoConfig.avcC).toString('base64'),
381
+ width: st.dims.width,
382
+ height: st.dims.height,
383
+ maxFps: scrcpyConfig.maxFps,
384
+ bitrate: scrcpyConfig.bitrate,
385
+ };
386
+ }
387
+ if (scrcpyInitClients) {
388
+ for (const client of st.clients) {
389
+ if (scrcpyInitClients.has(client)) continue;
390
+ scrcpyInitClients.add(client);
391
+ client.sendText(JSON.stringify(st.init));
392
+ }
393
+ }
394
+ st.initSignature = signature;
395
+ if (st.initSent) return;
396
+ st.initSent = true;
397
+ const pending = st.pending.splice(0);
398
+ for (const packet of pending) scrcpyBroadcastBinary(packet);
399
+ }
400
+
401
+ function scrcpyRecordFrame(st) {
402
+ const now = Date.now();
403
+ st.frames += 1;
404
+ if (!st.fpsAt) st.fpsAt = now;
405
+ if (now - st.fpsAt >= 1000) {
406
+ st.fps = Math.round((st.frames / (now - st.fpsAt)) * 1000 * 10) / 10;
407
+ st.frames = 0;
408
+ st.fpsAt = now;
409
+ }
410
+ }
411
+
412
+ function scrcpyStatus() {
413
+ const st = scrcpyState;
414
+ return {
415
+ enabled: scrcpyConfig.enabled,
416
+ active: Boolean(st && st.session && st.session.state === 'running'),
417
+ clients: st ? st.clients.size : 0,
418
+ fps: st ? st.fps : 0,
419
+ maxFps: scrcpyConfig.maxFps,
420
+ width: st && st.dims ? st.dims.width : null,
421
+ height: st && st.dims ? st.dims.height : null,
422
+ error: st && st.lastError ? String(st.lastError.message || st.lastError) : null,
423
+ };
424
+ }
425
+
426
+ async function stopScrcpy() {
427
+ if (scrcpyIdleTimer) {
428
+ clearTimeout(scrcpyIdleTimer);
429
+ scrcpyIdleTimer = null;
430
+ }
431
+ const st = scrcpyState;
432
+ if (!st) return;
433
+ const session = st.session;
434
+ st.session = null;
435
+ st.startPromise = null;
436
+ st.init = null;
437
+ st.initSent = false;
438
+ st.initSignature = null;
439
+ st.dims = null;
440
+ st.videoConfig = null;
441
+ st.pending = [];
442
+ st.frames = 0;
443
+ st.fps = 0;
444
+ st.fpsAt = 0;
445
+ for (const client of st.clients) {
446
+ try {
447
+ client.close(1001, 'video stream stopped');
448
+ } catch {
449
+ /* ignore */
450
+ }
451
+ }
452
+ st.clients.clear();
453
+ if (session) {
454
+ try {
455
+ await session.stop();
456
+ } catch {
457
+ /* ignore */
458
+ }
459
+ }
460
+ }
461
+
462
+ function scheduleScrcpyIdleStop() {
463
+ if (scrcpyIdleTimer) clearTimeout(scrcpyIdleTimer);
464
+ scrcpyIdleTimer = setTimeout(() => {
465
+ scrcpyIdleTimer = null;
466
+ const st = scrcpyState;
467
+ if (st && st.clients.size === 0) void stopScrcpy();
468
+ }, 15000);
469
+ if (typeof scrcpyIdleTimer.unref === 'function') scrcpyIdleTimer.unref();
470
+ }
471
+
472
+ function ensureScrcpy() {
473
+ const st = getScrcpyState();
474
+ if (!scrcpyConfig.enabled) return Promise.reject(new Error('H.264 video stream is disabled'));
475
+ if (st.session && st.session.state === 'running') return Promise.resolve(st);
476
+ if (st.startPromise) return st.startPromise;
477
+ st.startPromise = (async () => {
478
+ try {
479
+ if (!existsSync(scrcpyConfig.jarPath)) {
480
+ throw new Error(`scrcpy server jar not found: ${scrcpyConfig.jarPath}`);
481
+ }
482
+ const status = await vm.status();
483
+ if (!isReady(status)) throw new Error(`VM is not ready (state: ${status.state})`);
484
+ try {
485
+ await vm.ensureRoot();
486
+ } catch {
487
+ /* scrcpy server works as shell too */
488
+ }
489
+ const session = new ScrcpySession({
490
+ adbPath: vm.adbPath,
491
+ serial: status.serial,
492
+ serverJarPath: scrcpyConfig.jarPath,
493
+ port: await pickScrcpyPort(scrcpyConfig.port),
494
+ maxSize: scrcpyConfig.maxSize,
495
+ videoBitRate: scrcpyConfig.bitrate,
496
+ maxFps: scrcpyConfig.maxFps,
497
+ logLevel: 'warn',
498
+ });
499
+ st.session = session;
500
+ st.init = null;
501
+ st.initSent = false;
502
+ st.initSignature = null;
503
+ st.dims = null;
504
+ st.videoConfig = null;
505
+ st.pending = [];
506
+ st.lastError = null;
507
+ st.fps = 0;
508
+ st.frames = 0;
509
+ st.fpsAt = 0;
510
+ session.onInit((payload) => {
511
+ if (!payload || !payload.width || !payload.height) return;
512
+ st.dims = { width: payload.width, height: payload.height };
513
+ sendScrcpyInit(st);
514
+ });
515
+ session.onConfig((config) => {
516
+ if (!config || !config.avcC) return;
517
+ st.videoConfig = {
518
+ avcC: Buffer.isBuffer(config.avcC) ? config.avcC : Buffer.from(config.avcC),
519
+ codecString: config.codecString,
520
+ };
521
+ sendScrcpyInit(st);
522
+ });
523
+ session.onSession((payload) => {
524
+ if (!payload || !payload.width || !payload.height) return;
525
+ if (st.dims && st.dims.width === payload.width && st.dims.height === payload.height) return;
526
+ st.dims = { width: payload.width, height: payload.height };
527
+ sendScrcpyInit(st);
528
+ });
529
+ session.onVideo((packet) => {
530
+ if (!packet || !packet.data || packet.isConfig) return;
531
+ let avcc;
532
+ try {
533
+ avcc = annexbToAvcc(packet.data);
534
+ } catch {
535
+ return;
536
+ }
537
+ const pts = Math.max(0, Number(packet.pts) || 0);
538
+ const buffer = Buffer.allocUnsafe(10 + avcc.length);
539
+ buffer[0] = 1; // video packet
540
+ buffer[1] = packet.isKeyFrame ? 1 : 0;
541
+ buffer.writeBigInt64LE(BigInt(Math.round(pts)), 2);
542
+ avcc.copy(buffer, 10);
543
+ scrcpyRecordFrame(st);
544
+ if (st.initSent) scrcpyBroadcastBinary(buffer);
545
+ else if (st.pending.length < 240) st.pending.push(buffer);
546
+ });
547
+ session.onError((error) => {
548
+ st.lastError = error instanceof Error ? error : new Error(String(error));
549
+ scrcpyBroadcastText({ type: 'error', error: String(st.lastError.message || st.lastError) });
550
+ });
551
+ await session.start();
552
+ return st;
553
+ } catch (error) {
554
+ const failure = error instanceof Error ? error : new Error(String(error));
555
+ st.lastError = failure;
556
+ const failed = st.session;
557
+ st.session = null;
558
+ if (failed) {
559
+ try {
560
+ await failed.stop();
561
+ } catch {
562
+ /* ignore */
563
+ }
564
+ }
565
+ scrcpyBroadcastText({ type: 'error', error: String(failure.message || failure) });
566
+ throw failure;
567
+ } finally {
568
+ st.startPromise = null;
569
+ }
570
+ })();
571
+ return st.startPromise;
572
+ }
573
+
574
+ function handleScrcpyClient(client) {
575
+ const st = getScrcpyState();
576
+ st.clients.add(client);
577
+ sendScrcpyInit(st);
578
+ if (st.initSent && st.session && st.session.state === 'running') {
579
+ // A late client only has delta frames; ask the encoder for a fresh keyframe.
580
+ void Promise.resolve(st.session.sendEmptyMessage(CONTROL_MESSAGE_TYPE.RESET_VIDEO)).catch(() => {});
581
+ }
582
+ client.onClose(() => {
583
+ st.clients.delete(client);
584
+ if (st.clients.size === 0) scheduleScrcpyIdleStop();
585
+ });
586
+ client.onError(() => {
587
+ st.clients.delete(client);
588
+ if (st.clients.size === 0) scheduleScrcpyIdleStop();
589
+ });
590
+ ensureScrcpy().catch((error) => {
591
+ const message = String((error && error.message) || error);
592
+ client.sendText(JSON.stringify({ type: 'error', error: message }));
593
+ client.close(1011, 'stream unavailable');
594
+ });
595
+ }
596
+
597
+ const rootsFor = () => resolveRoots(ctx, cfg.roots);
598
+ const snapshotApk = () => (lastApk ? { ...lastApk } : null);
599
+
600
+ /** Resolve, install and launch one APK; shared by the HTTP route, upload and Agent tool. */
601
+ async function installApkFromPath(candidate, options = {}) {
602
+ let apkPath;
603
+ if (options.trustedPath === true) {
604
+ apkPath = path.resolve(String(candidate || ''));
605
+ if (!apkPath.toLowerCase().endsWith('.apk') || !existsSync(apkPath)) {
606
+ throw new Error(`uploaded APK is missing or invalid: ${apkPath}`);
607
+ }
608
+ } else {
609
+ apkPath = resolveApkPath(candidate, rootsFor());
610
+ }
611
+ const status = await vm.status();
612
+ if (!isReady(status)) throw new Error(`VM is not ready (state: ${status.state}); start it first`);
613
+ const meta = await vm.installApk(apkPath);
614
+ lastApk = {
615
+ path: apkPath,
616
+ packageName: meta.packageName || null,
617
+ label: meta.label || path.basename(apkPath),
618
+ launchActivity: meta.launchActivity || null,
619
+ launched: meta.launched !== false,
620
+ installedAt: Date.now(),
621
+ };
622
+ frame.buffer = null;
623
+ frame.at = 0;
624
+ // Persist the installed app into the quick-boot image so the next start
625
+ // restores straight into the installed state.
626
+ void vm.saveSnapshot().catch(() => {});
627
+ return { ...lastApk };
628
+ }
629
+
630
+ const uploadDir = path.join(os.tmpdir(), 'dsh-apk-vm-uploads');
631
+ async function prepareUploadDir() {
632
+ await fsp.mkdir(uploadDir, { recursive: true });
633
+ // Best-effort stale cleanup; uploaded APKs are only needed for this session.
634
+ try {
635
+ const entries = await fsp.readdir(uploadDir);
636
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
637
+ for (const entry of entries) {
638
+ const file = path.join(uploadDir, entry);
639
+ const stat = await fsp.stat(file).catch(() => null);
640
+ if (stat && stat.isFile() && stat.mtimeMs < cutoff) await fsp.rm(file, { force: true }).catch(() => {});
641
+ }
642
+ } catch {
643
+ /* cleanup is best effort */
644
+ }
645
+ return uploadDir;
646
+ }
647
+ async function cleanupUploads() {
648
+ await fsp.rm(uploadDir, { recursive: true, force: true }).catch(() => {});
649
+ }
650
+
651
+ /** Resolve a host-side path for debug push/pull, confined to the scan roots + temp. */
652
+ function resolveDebugLocal(candidate, mustExist) {
653
+ const raw = String(candidate || '').trim();
654
+ if (!raw) throw new Error('local path is required');
655
+ const roots = [...rootsFor(), os.tmpdir()];
656
+ const candidates = [raw];
657
+ if (!path.isAbsolute(raw)) for (const root of roots) candidates.push(path.join(root, raw));
658
+ for (const value of candidates) {
659
+ try {
660
+ const resolved = path.resolve(value);
661
+ const allowed = roots.some((root) => {
662
+ const base = path.resolve(root);
663
+ return resolved === base || resolved.startsWith(base + path.sep);
664
+ });
665
+ if (!allowed) continue;
666
+ if (!mustExist || existsSync(resolved)) return resolved;
667
+ } catch {
668
+ /* try next */
669
+ }
670
+ }
671
+ throw new Error(
672
+ mustExist ? `local file not found under an allowed root: ${raw}` : `local path must be under ${roots.join(', ')}`,
673
+ );
674
+ }
675
+
676
+ /** Idempotent background start used by prewarm / auto-start / the Start button. */
677
+ function ensureStarted() {
678
+ if (startKick) return startKick;
679
+ startKick = vm
680
+ .start()
681
+ .catch((error) => {
682
+ if (typeof console !== 'undefined' && typeof console.log === 'function') {
683
+ console.log(`[apk-vm] background start failed: ${error && error.message ? error.message : error}`);
684
+ }
685
+ })
686
+ .finally(() => {
687
+ startKick = null;
688
+ });
689
+ return startKick;
690
+ }
691
+
692
+ function maybeAutoStart(status) {
693
+ if (!preferAutoStart || autoStartKicked) return;
694
+ if (!status || status.state !== 'stopped') return;
695
+ if (!cfg.emulatorPath && !cfg.serial) return; // nothing this plugin can start
696
+ if (status.serial) return;
697
+ autoStartKicked = true;
698
+ void ensureStarted();
699
+ }
700
+
701
+ async function getFrame() {
702
+ if (frame.buffer && Date.now() - frame.at < FRAME_TTL_MS) return frame.buffer;
703
+ if (frame.inFlight) return frame.inFlight;
704
+ frame.inFlight = vm
705
+ .screenshot()
706
+ .then((buffer) => {
707
+ frame.buffer = buffer;
708
+ frame.at = Date.now();
709
+ return buffer;
710
+ })
711
+ .finally(() => {
712
+ frame.inFlight = null;
713
+ });
714
+ return frame.inFlight;
715
+ }
716
+
717
+ async function getScreen(serial) {
718
+ if (screen.value && Date.now() - screen.at < SCREEN_TTL_MS) return screen.value;
719
+ try {
720
+ const size = await vm.screenSize();
721
+ if (size && size.width > 0 && size.height > 0) {
722
+ screen.value = { width: size.width, height: size.height, serial };
723
+ screen.at = Date.now();
724
+ }
725
+ return screen.value;
726
+ } catch {
727
+ return screen.value;
728
+ }
729
+ }
730
+
731
+ async function stateBody() {
732
+ const status = await vm.status();
733
+ const ready = status && status.state === 'ready';
734
+ let display = null;
735
+ if (ready) display = await getScreen(status.serial);
736
+ maybeAutoStart(status);
737
+ return {
738
+ ok: true,
739
+ vm: {
740
+ state: status.state,
741
+ serial: status.serial || null,
742
+ avd: status.avd || null,
743
+ bootMs: status.bootMs ?? null,
744
+ bootKind: status.bootKind || null,
745
+ quickBoot: status.quickBoot !== false,
746
+ snapshotName: status.snapshotName || null,
747
+ snapshotAvailable: status.snapshotAvailable === true,
748
+ gpuMode: status.gpuMode || null,
749
+ accel: status.accel === true ? 'hardware' : status.accel === false ? 'software' : null,
750
+ root: status.root === true,
751
+ debugPrepared: status.debugPrepared === true,
752
+ debugToolkit: status.debugToolkit || null,
753
+ identity: status.identity || null,
754
+ error: status.error || null,
755
+ logTail: Array.isArray(status.logTail) ? status.logTail.slice(-40) : [],
756
+ },
757
+ screen: display ? { width: display.width, height: display.height } : null,
758
+ apk: snapshotApk(),
759
+ installing: Boolean(installInFlight),
760
+ warming: Boolean(startKick),
761
+ stream: { fps: streamFps, hostFps: measuredFrameFps(), h264: scrcpyStatus() },
762
+ origin: serverOrigin,
763
+ multiInstance: Boolean(instanceManager),
764
+ client: lastClientReport,
765
+ instances: [
766
+ {
767
+ id: 'default',
768
+ kind: 'default',
769
+ avdName: status.avd || vm.avdName || null,
770
+ port: vm.port || null,
771
+ state: status.state,
772
+ serial: status.serial || null,
773
+ root: status.root === true,
774
+ apk: snapshotApk(),
775
+ owner: instanceManager ? instanceManager.defaultOwner : null,
776
+ lastUsedAt: null,
777
+ idleMs: null,
778
+ videoClients: null,
779
+ },
780
+ ...(instanceManager ? instanceManager.list() : []),
781
+ ],
782
+ roots: rootsFor(),
783
+ };
784
+ }
785
+
786
+ function mountRoutes(webServer) {
787
+ try {
788
+ serverOrigin = webServer.getOrigin();
789
+ } catch {
790
+ serverOrigin = null;
791
+ }
792
+ const disposers = [];
793
+ const wrapHandler = (handler) => async (req, res) => {
794
+ if (!isSameOrigin(req)) {
795
+ sendError(res, 403, 'cross-origin request rejected');
796
+ return;
797
+ }
798
+ try {
799
+ await handler(req, res);
800
+ } catch (error) {
801
+ sendError(res, 500, error && error.message ? error.message : error);
802
+ }
803
+ };
804
+ const register = (pathname, handler) => {
805
+ disposers.push(
806
+ webServer.register({
807
+ kind: 'exact',
808
+ path: pathname,
809
+ handler: wrapHandler(handler),
810
+ }),
811
+ );
812
+ };
813
+
814
+ register('/apk-vm/state', async (req, res) => {
815
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
816
+ sendError(res, 405, 'GET only');
817
+ return;
818
+ }
819
+ sendJson(res, 200, await stateBody());
820
+ });
821
+
822
+ register('/apk-vm/setup', async (req, res) => {
823
+ if (req.method === 'GET') {
824
+ const url = new URL(req.url || '/', 'http://localhost');
825
+ sendJson(res, 200, await loadSetup(url.searchParams.get('force') === '1'));
826
+ return;
827
+ }
828
+ if (req.method !== 'POST') {
829
+ sendError(res, 405, 'GET or POST only');
830
+ return;
831
+ }
832
+ const body = await readJsonBody(req);
833
+ const action = String(body.action || '').toLowerCase();
834
+ if (action === 'refresh') {
835
+ sendJson(res, 200, { ok: true, setup: await loadSetup(true) });
836
+ return;
837
+ }
838
+ if (action === 'create-avd') {
839
+ const result = await createAvd({
840
+ sdkRoot: cfg.sdkRoot || undefined,
841
+ emulatorPath: cfg.emulatorPath || undefined,
842
+ avdName: body.avdName || cfg.avdName || 'dsh_apk',
843
+ systemImage: cfg.systemImage || DEFAULT_SYSTEM_IMAGE,
844
+ });
845
+ setupCache = null;
846
+ sendJson(res, 200, { ok: result.ok === true, action, result });
847
+ return;
848
+ }
849
+ if (action === 'install-image') {
850
+ if (cfg.allowSetupDownloads !== true) {
851
+ sendError(res, 403, 'downloading Android system images is disabled; set allowSetupDownloads: true to enable it');
852
+ return;
853
+ }
854
+ const result = await installSystemImage({
855
+ sdkRoot: cfg.sdkRoot || undefined,
856
+ systemImage: cfg.systemImage || DEFAULT_SYSTEM_IMAGE,
857
+ acceptLicenses: true,
858
+ });
859
+ setupCache = null;
860
+ sendJson(res, 200, { ok: result.ok === true, action, result });
861
+ return;
862
+ }
863
+ if (action === 'install-tools') {
864
+ if (cfg.allowSetupDownloads !== true) {
865
+ sendError(res, 403, 'downloading Android SDK components is disabled; set allowSetupDownloads: true to enable it');
866
+ return;
867
+ }
868
+ const image = String(cfg.systemImage || DEFAULT_SYSTEM_IMAGE);
869
+ const apiLevel = (image.split(';')[1] || 'android-34').replace(/^android-/, '');
870
+ const packages = Array.isArray(body.packages) && body.packages.length
871
+ ? body.packages
872
+ : ['platform-tools', 'emulator', `build-tools;${apiLevel}.0.0`, `platforms;android-${apiLevel}`];
873
+ const result = await installSdkPackages({
874
+ sdkRoot: cfg.sdkRoot || undefined,
875
+ packages,
876
+ acceptLicenses: true,
877
+ });
878
+ setupCache = null;
879
+ sendJson(res, 200, { ok: result.ok === true, action, result });
880
+ return;
881
+ }
882
+ if (action === 'bootstrap-sdk') {
883
+ if (cfg.allowSetupDownloads !== true) {
884
+ sendError(res, 403, 'downloading Android SDK components is disabled; set allowSetupDownloads: true to enable it');
885
+ return;
886
+ }
887
+ const result = await bootstrapSdk({
888
+ sdkRoot: cfg.sdkRoot || undefined,
889
+ packages: Array.isArray(body.packages) ? body.packages : undefined,
890
+ acceptLicenses: true,
891
+ });
892
+ setupCache = null;
893
+ sendJson(res, 200, { ok: result.ok === true, action, result });
894
+ return;
895
+ }
896
+ sendError(res, 400, `unsupported setup action: ${action || '(missing)'}`);
897
+ });
898
+
899
+ register('/apk-vm/instances', async (req, res) => {
900
+ if (!instanceManager) {
901
+ sendError(res, 503, 'multi-instance support is disabled');
902
+ return;
903
+ }
904
+ if (req.method === 'GET') {
905
+ const defaultStatus = await vm.status();
906
+ if (instanceManager) {
907
+ for (const instance of instanceManager.instances.values()) {
908
+ try {
909
+ ensureExtraRouteSet(instance.id);
910
+ } catch {
911
+ /* route registration is best effort */
912
+ }
913
+ }
914
+ }
915
+ sendJson(res, 200, {
916
+ ok: true,
917
+ maxInstances: instanceManager.maxInstances,
918
+ instances: [
919
+ {
920
+ id: 'default',
921
+ kind: 'default',
922
+ avdName: defaultStatus.avd || vm.avdName || null,
923
+ port: vm.port || null,
924
+ state: defaultStatus.state,
925
+ serial: defaultStatus.serial || null,
926
+ root: defaultStatus.root === true,
927
+ apk: snapshotApk(),
928
+ owner: instanceManager.defaultOwner,
929
+ lastUsedAt: null,
930
+ idleMs: null,
931
+ videoClients: null,
932
+ },
933
+ ...instanceManager.list(),
934
+ ],
935
+ });
936
+ return;
937
+ }
938
+ if (req.method === 'POST') {
939
+ const body = await readJsonBody(req);
940
+ try {
941
+ const instance = await instanceManager.create({ name: body.name });
942
+ registerInstanceRoutes?.(instance.id);
943
+ if (body.start === true) void instance.start();
944
+ sendJson(res, 201, {
945
+ ok: true,
946
+ instance: {
947
+ id: instance.id,
948
+ kind: 'extra',
949
+ avdName: instance.avdName,
950
+ port: instance.port,
951
+ state: instance.vm.state,
952
+ serial: instance.vm._serialActive || null,
953
+ },
954
+ maxInstances: instanceManager.maxInstances,
955
+ });
956
+ } catch (error) {
957
+ sendError(res, 400, error.message);
958
+ }
959
+ return;
960
+ }
961
+ sendError(res, 405, 'GET or POST only');
962
+ });
963
+
964
+ async function handleExtraRequest(req, res, forcedId, forcedRest) {
965
+ if (!instanceManager) {
966
+ sendError(res, 503, 'multi-instance support is disabled');
967
+ return;
968
+ }
969
+ const url = new URL(req.url || '/', 'http://localhost');
970
+ const id = String(forcedId || '');
971
+ const rest = String(forcedRest || '');
972
+ const instance = instanceManager.get(id);
973
+ if (!instance) {
974
+ sendError(res, 404, `unknown instance: ${id || '(missing)'}`);
975
+ return;
976
+ }
977
+ const ready = async () => {
978
+ const status = await instance.status();
979
+ if (status.state !== 'ready') {
980
+ sendJson(res, 409, { ok: false, error: `instance ${id} is not ready (state: ${status.state})` });
981
+ return false;
982
+ }
983
+ return true;
984
+ };
985
+ const instanceState = async () => {
986
+ const status = await instance.status();
987
+ let display = null;
988
+ if (status.state === 'ready') {
989
+ try {
990
+ display = await instance.vm.screenSize();
991
+ } catch {
992
+ display = null;
993
+ }
994
+ }
995
+ return {
996
+ ok: true,
997
+ instance: { id: instance.id, kind: 'extra', avdName: instance.avdName, port: instance.port },
998
+ vm: {
999
+ state: status.state,
1000
+ serial: status.serial || null,
1001
+ avd: instance.avdName,
1002
+ bootMs: status.bootMs ?? null,
1003
+ bootKind: status.bootKind || null,
1004
+ quickBoot: true,
1005
+ snapshotName: instance.vm.snapshotName,
1006
+ snapshotAvailable: Boolean(instance.vm._snapshotPresent()),
1007
+ gpuMode: instance.vm.gpuMode,
1008
+ accel: instance.vm.accel?.available === false ? 'software' : 'hardware',
1009
+ root: status.root === true,
1010
+ debugPrepared: status.debugPrepared === true,
1011
+ error: status.error || null,
1012
+ logTail: Array.isArray(status.logTail) ? status.logTail.slice(-40) : [],
1013
+ },
1014
+ screen: display ? { width: display.width, height: display.height } : null,
1015
+ apk: instance.snapshotApk(),
1016
+ installing: Boolean(instance.installInFlight),
1017
+ origin: serverOrigin,
1018
+ stream: { fps: 2, hostFps: null, h264: instance.videoStatus ? instance.videoStatus() : { enabled: false, active: false, clients: 0, maxFps: 0 } },
1019
+ };
1020
+ };
1021
+
1022
+ if (!rest) {
1023
+ if (req.method === 'DELETE') {
1024
+ await instanceManager.destroy(id);
1025
+ disposeInstanceRoutes?.(id);
1026
+ sendJson(res, 200, { ok: true, id, destroyed: true });
1027
+ return;
1028
+ }
1029
+ if (req.method === 'GET') {
1030
+ sendJson(res, 200, await instanceState());
1031
+ return;
1032
+ }
1033
+ sendError(res, 405, 'GET or DELETE only');
1034
+ return;
1035
+ }
1036
+ if (rest === 'state' && req.method === 'GET') {
1037
+ sendJson(res, 200, await instanceState());
1038
+ return;
1039
+ }
1040
+ if (rest === 'frame' && (req.method === 'GET' || req.method === 'HEAD')) {
1041
+ if (!(await ready())) return;
1042
+ const png = await instance.framePng();
1043
+ res.writeHead(200, {
1044
+ 'Content-Type': 'image/png',
1045
+ 'Content-Length': png.length,
1046
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
1047
+ 'X-Content-Type-Options': 'nosniff',
1048
+ });
1049
+ if (req.method === 'HEAD') res.end();
1050
+ else res.end(png);
1051
+ return;
1052
+ }
1053
+ if (rest === 'vm' && req.method === 'POST') {
1054
+ const body = await readJsonBody(req);
1055
+ const action = String(body.action || '').toLowerCase();
1056
+ if (action === 'start' || action === 'warm') {
1057
+ void instance.start();
1058
+ sendJson(res, 202, { ok: true, action, accepted: true, instance: id });
1059
+ return;
1060
+ }
1061
+ if (action === 'stop') {
1062
+ const snapshot = await instance.stop();
1063
+ sendJson(res, 200, { ok: true, action, vm: snapshot, instance: id });
1064
+ return;
1065
+ }
1066
+ if (action === 'restart') {
1067
+ void instance.restart();
1068
+ sendJson(res, 202, { ok: true, action, accepted: true, instance: id });
1069
+ return;
1070
+ }
1071
+ if (action === 'snapshot') {
1072
+ sendJson(res, 200, { ok: true, action, ...(await instance.saveSnapshot(body.name)) });
1073
+ return;
1074
+ }
1075
+ sendError(res, 400, `unsupported action: ${action || '(missing)'}`);
1076
+ return;
1077
+ }
1078
+ if (rest === 'input' && req.method === 'POST') {
1079
+ if (!(await ready())) return;
1080
+ await instance.input(await readJsonBody(req));
1081
+ sendJson(res, 200, { ok: true });
1082
+ return;
1083
+ }
1084
+ if (rest === 'touch' && req.method === 'POST') {
1085
+ const body = await readJsonBody(req);
1086
+ const action = String(body.action || '').toLowerCase();
1087
+ if (!['down', 'move', 'up', 'cancel'].includes(action)) {
1088
+ sendError(res, 400, 'action must be down|move|up|cancel');
1089
+ return;
1090
+ }
1091
+ await instance.sendTouch({ action, pointerId: body.pointerId, x: body.x, y: body.y });
1092
+ sendJson(res, 200, { ok: true });
1093
+ return;
1094
+ }
1095
+ if (rest === 'clipboard' && (req.method === 'GET' || req.method === 'POST')) {
1096
+ if (req.method === 'GET') {
1097
+ try {
1098
+ sendJson(res, 200, { ok: true, text: await instance.getClipboard() });
1099
+ } catch (error) {
1100
+ sendError(res, 504, error && error.message ? error.message : String(error));
1101
+ }
1102
+ return;
1103
+ }
1104
+ const body = await readJsonBody(req);
1105
+ if (typeof body.text !== 'string') {
1106
+ sendError(res, 400, 'text must be a string');
1107
+ return;
1108
+ }
1109
+ const result = await instance.setClipboard(body.text, body.paste === true);
1110
+ sendJson(res, 200, { ok: true, ...result });
1111
+ return;
1112
+ }
1113
+ if (rest === 'install' && req.method === 'POST') {
1114
+ if (!(await ready())) return;
1115
+ const body = await readJsonBody(req);
1116
+ const apkPath = resolveApkPath(body.path, rootsFor());
1117
+ sendJson(res, 200, { ok: true, ...(await instance.installApk(apkPath)) });
1118
+ return;
1119
+ }
1120
+ if (rest === 'upload' && req.method === 'POST') {
1121
+ if (!(await ready())) return;
1122
+ let name = 'upload.apk';
1123
+ try {
1124
+ name = decodeURIComponent(String(req.headers['x-apk-name'] || 'upload.apk'));
1125
+ } catch {
1126
+ name = 'upload.apk';
1127
+ }
1128
+ name = path.basename(name).replace(/[^A-Za-z0-9._-]+/g, '_') || 'upload.apk';
1129
+ if (!/\.apk$/i.test(name)) name += '.apk';
1130
+ const data = await readRawBody(req, UPLOAD_LIMIT_BYTES);
1131
+ if (data.length < 4 || data[0] !== 0x50 || data[1] !== 0x4b) {
1132
+ sendError(res, 400, 'uploaded file is not a ZIP/APK');
1133
+ return;
1134
+ }
1135
+ const dir = await prepareUploadDir();
1136
+ const target = path.join(dir, `${Date.now()}-${randomUUID().slice(0, 8)}-${name}`);
1137
+ await fsp.writeFile(target, data);
1138
+ sendJson(res, 200, { ok: true, ...(await instance.installApk(target)), upload: { fileName: name, bytes: data.length } });
1139
+ return;
1140
+ }
1141
+ if (rest.startsWith('debug/')) {
1142
+ const op = rest.slice('debug/'.length);
1143
+ if (op === 'info' && req.method === 'GET') {
1144
+ if (!(await ready())) return;
1145
+ sendJson(res, 200, await instance.deviceInfo());
1146
+ return;
1147
+ }
1148
+ if (op === 'shell' && req.method === 'POST') {
1149
+ if (!(await ready())) return;
1150
+ const body = await readJsonBody(req);
1151
+ sendJson(res, 200, await instance.shell(body.command, { timeoutMs: body.timeoutMs, root: body.root !== false }));
1152
+ return;
1153
+ }
1154
+ if (op === 'logcat' && req.method === 'GET') {
1155
+ if (!(await ready())) return;
1156
+ sendJson(res, 200, await instance.logcat({ lines: url.searchParams.get('lines'), filter: url.searchParams.get('filter') || '', buffer: url.searchParams.get('buffer') || '' }));
1157
+ return;
1158
+ }
1159
+ if (op === 'prop') {
1160
+ if (!(await ready())) return;
1161
+ if (req.method === 'GET') {
1162
+ sendJson(res, 200, await instance.getprop(url.searchParams.get('name') || ''));
1163
+ return;
1164
+ }
1165
+ if (req.method === 'POST') {
1166
+ const body = await readJsonBody(req);
1167
+ sendJson(res, 200, await instance.setprop(body.name, body.value));
1168
+ return;
1169
+ }
1170
+ }
1171
+ if (op === 'packages' && req.method === 'GET') {
1172
+ if (!(await ready())) return;
1173
+ sendJson(res, 200, await instance.listPackages({ thirdPartyOnly: url.searchParams.get('all') !== '1' }));
1174
+ return;
1175
+ }
1176
+ if (op === 'root' && req.method === 'POST') {
1177
+ if (!(await ready())) return;
1178
+ sendJson(res, 200, { ok: await instance.ensureRoot(), root: instance.vm._root === true });
1179
+ return;
1180
+ }
1181
+ if (op === 'rotate' && req.method === 'POST') {
1182
+ if (!(await ready())) return;
1183
+ sendJson(res, 200, await instance.rotate((await readJsonBody(req)).orientation || 'landscape'));
1184
+ return;
1185
+ }
1186
+ if (op === 'ui' && req.method === 'GET') {
1187
+ if (!(await ready())) return;
1188
+ sendJson(res, 200, await instance.uiDump());
1189
+ return;
1190
+ }
1191
+ if (op === 'record' && req.method === 'POST') {
1192
+ if (!(await ready())) return;
1193
+ sendJson(res, 200, await instance.screenRecord({ seconds: (await readJsonBody(req)).seconds }));
1194
+ return;
1195
+ }
1196
+ if (op === 'perf' && req.method === 'GET') {
1197
+ if (!(await ready())) return;
1198
+ sendJson(res, 200, await instance.perfSnapshot());
1199
+ return;
1200
+ }
1201
+ if (op === 'screenshot' && req.method === 'GET') {
1202
+ if (!(await ready())) return;
1203
+ const png = await instance.screenshot();
1204
+ res.writeHead(200, {
1205
+ 'Content-Type': 'image/png',
1206
+ 'Content-Length': png.length,
1207
+ 'Cache-Control': 'no-store',
1208
+ 'Content-Disposition': `attachment; filename="apk-vm-${id}-${Date.now()}.png"`,
1209
+ });
1210
+ res.end(png);
1211
+ return;
1212
+ }
1213
+ if (op === 'action' && req.method === 'POST') {
1214
+ if (!(await ready())) return;
1215
+ const body = await readJsonBody(req);
1216
+ const action = String(body.action || '').toLowerCase();
1217
+ let result;
1218
+ if (action === 'force-stop') result = await instance.forceStop(body.packageName);
1219
+ else if (action === 'clear-data') result = await instance.clearData(body.packageName);
1220
+ else if (action === 'uninstall') result = await instance.uninstall(body.packageName);
1221
+ else {
1222
+ sendError(res, 400, `unsupported debug action: ${action || '(missing)'}`);
1223
+ return;
1224
+ }
1225
+ sendJson(res, 200, { ok: result.ok === true, action, result });
1226
+ return;
1227
+ }
1228
+ if (op === 'push' && req.method === 'POST') {
1229
+ if (!(await ready())) return;
1230
+ const body = await readJsonBody(req);
1231
+ const local = resolveDebugLocal(body.localPath, true);
1232
+ sendJson(res, 200, await instance.pushFile(local, body.remotePath));
1233
+ return;
1234
+ }
1235
+ if (op === 'pull' && req.method === 'POST') {
1236
+ if (!(await ready())) return;
1237
+ const body = await readJsonBody(req);
1238
+ const local = resolveDebugLocal(body.localPath, false);
1239
+ sendJson(res, 200, await instance.pullFile(body.remotePath, local));
1240
+ return;
1241
+ }
1242
+ if (op === 'reboot' && req.method === 'POST') {
1243
+ if (!(await ready())) return;
1244
+ sendJson(res, 200, await instance.reboot((await readJsonBody(req)).mode || ''));
1245
+ return;
1246
+ }
1247
+ sendError(res, 404, `unsupported extra-instance debug endpoint: ${op}`);
1248
+ return;
1249
+ }
1250
+ sendError(res, 404, `unsupported extra-instance endpoint: ${rest}`);
1251
+ }
1252
+
1253
+ const extraRouteDisposers = new Map();
1254
+ function ensureExtraRouteSet(id) {
1255
+ if (extraRouteDisposers.has(id)) return;
1256
+ const suffixes = [
1257
+ '',
1258
+ 'state',
1259
+ 'frame',
1260
+ 'vm',
1261
+ 'input',
1262
+ 'touch',
1263
+ 'clipboard',
1264
+ 'install',
1265
+ 'upload',
1266
+ 'debug/info',
1267
+ 'debug/shell',
1268
+ 'debug/logcat',
1269
+ 'debug/prop',
1270
+ 'debug/packages',
1271
+ 'debug/root',
1272
+ 'debug/rotate',
1273
+ 'debug/ui',
1274
+ 'debug/record',
1275
+ 'debug/perf',
1276
+ 'debug/screenshot',
1277
+ 'debug/action',
1278
+ 'debug/push',
1279
+ 'debug/pull',
1280
+ 'debug/reboot',
1281
+ ];
1282
+ const disposers = [];
1283
+ for (const suffix of suffixes) {
1284
+ const pathname = `/apk-vm/instances/${id}${suffix ? `/${suffix}` : ''}`;
1285
+ disposers.push(
1286
+ webServer.register({
1287
+ kind: 'exact',
1288
+ path: pathname,
1289
+ handler: wrapHandler((req, res) => handleExtraRequest(req, res, id, suffix)),
1290
+ }),
1291
+ );
1292
+ }
1293
+ disposers.push(
1294
+ webServer.registerUpgrade({
1295
+ path: `/apk-vm/instances/${id}/ws`,
1296
+ handler: (req, socket, head) => {
1297
+ if (!isSameOrigin(req)) {
1298
+ try {
1299
+ socket.destroy();
1300
+ } catch {
1301
+ /* ignore */
1302
+ }
1303
+ return;
1304
+ }
1305
+ const instance = instanceManager && instanceManager.get(id);
1306
+ if (!instance) {
1307
+ try {
1308
+ socket.destroy();
1309
+ } catch {
1310
+ /* ignore */
1311
+ }
1312
+ return;
1313
+ }
1314
+ const client = acceptWebSocket(req, socket, head);
1315
+ if (!client) return;
1316
+ instance.handleVideoClient(client);
1317
+ },
1318
+ }),
1319
+ );
1320
+ extraRouteDisposers.set(id, disposers);
1321
+ }
1322
+ function disposeExtraRouteSet(id) {
1323
+ const disposers = extraRouteDisposers.get(id);
1324
+ if (!disposers) return;
1325
+ extraRouteDisposers.delete(id);
1326
+ for (const dispose of disposers.reverse()) {
1327
+ try {
1328
+ dispose();
1329
+ } catch {
1330
+ /* ignore */
1331
+ }
1332
+ }
1333
+ }
1334
+ registerInstanceRoutes = ensureExtraRouteSet;
1335
+ disposeInstanceRoutes = disposeExtraRouteSet;
1336
+ if (instanceManager) {
1337
+ for (const instance of instanceManager.instances.values()) ensureExtraRouteSet(instance.id);
1338
+ }
1339
+
1340
+ register('/apk-vm/avds', async (req, res) => {
1341
+ if (req.method !== 'GET') {
1342
+ sendError(res, 405, 'GET only');
1343
+ return;
1344
+ }
1345
+ const avds = await vm.listAvds();
1346
+ sendJson(res, 200, { ok: true, current: vm.avdName, avds });
1347
+ });
1348
+
1349
+ register('/apk-vm/avd', async (req, res) => {
1350
+ if (req.method !== 'POST') {
1351
+ sendError(res, 405, 'POST only');
1352
+ return;
1353
+ }
1354
+ const body = await readJsonBody(req);
1355
+ const avds = await vm.listAvds();
1356
+ const target = String(body.avdName || '').trim();
1357
+ if (!avds.includes(target)) {
1358
+ sendError(res, 400, `unknown AVD: ${target || '(missing)'}`);
1359
+ return;
1360
+ }
1361
+ await stopScrcpy();
1362
+ const result = await vm.setAvd(target);
1363
+ autoStartKicked = true;
1364
+ void ensureStarted();
1365
+ sendJson(res, 202, { ok: true, action: 'switch-avd', ...result, accepted: true });
1366
+ });
1367
+
1368
+ register('/apk-vm/client-report', async (req, res) => {
1369
+ if (req.method !== 'POST') {
1370
+ sendError(res, 405, 'POST only');
1371
+ return;
1372
+ }
1373
+ const body = await readJsonBody(req);
1374
+ const text = (value, max) => (typeof value === 'string' ? value.slice(0, max) : '');
1375
+ lastClientReport = {
1376
+ version: text(body.version, 40),
1377
+ mode: text(body.mode, 20),
1378
+ webCodecs: body.webCodecs === true,
1379
+ h264Supported: body.h264Supported === true,
1380
+ h264Error: text(body.h264Error, 500),
1381
+ codec: text(body.codec, 80),
1382
+ decoderError: text(body.decoderError, 500),
1383
+ userAgent: text(body.userAgent, 300),
1384
+ location: text(body.location, 300),
1385
+ receivedAt: Date.now(),
1386
+ };
1387
+ vm._pushLog(
1388
+ `[client] v=${lastClientReport.version} mode=${lastClientReport.mode || '?'} ` +
1389
+ `webCodecs=${lastClientReport.webCodecs} h264=${lastClientReport.h264Supported}` +
1390
+ (lastClientReport.h264Error ? ` error=${lastClientReport.h264Error}` : ''),
1391
+ );
1392
+ sendJson(res, 200, { ok: true });
1393
+ });
1394
+
1395
+ register('/apk-vm/apk-info', async (req, res) => {
1396
+ if (req.method !== 'GET') {
1397
+ sendError(res, 405, 'GET only');
1398
+ return;
1399
+ }
1400
+ const url = new URL(req.url || '/', 'http://localhost');
1401
+ const apkPath = resolveApkPath(url.searchParams.get('path'), rootsFor());
1402
+ const info = await vm.inspectApk(apkPath);
1403
+ sendJson(res, 200, { ok: true, path: apkPath, ...info });
1404
+ });
1405
+
1406
+ register('/apk-vm/apk-icon', async (req, res) => {
1407
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1408
+ sendError(res, 405, 'GET or HEAD only');
1409
+ return;
1410
+ }
1411
+ const url = new URL(req.url || '/', 'http://localhost');
1412
+ const apkPath = resolveApkPath(url.searchParams.get('path'), rootsFor());
1413
+ const icon = await vm.inspectApkIcon(apkPath);
1414
+ res.writeHead(200, {
1415
+ 'content-type': icon.mime,
1416
+ 'content-length': icon.data.length,
1417
+ 'cache-control': 'no-store',
1418
+ });
1419
+ if (req.method === 'HEAD') {
1420
+ res.end();
1421
+ return;
1422
+ }
1423
+ res.end(icon.data);
1424
+ });
1425
+
1426
+ register('/apk-vm/apks', async (req, res) => {
1427
+ if (req.method !== 'GET') {
1428
+ sendError(res, 405, 'GET only');
1429
+ return;
1430
+ }
1431
+ const roots = rootsFor();
1432
+ const apks = await vm.listApks(roots);
1433
+ sendJson(res, 200, { ok: true, roots, apks });
1434
+ });
1435
+
1436
+ register('/apk-vm/devices', async (req, res) => {
1437
+ if (req.method !== 'GET') {
1438
+ sendError(res, 405, 'GET only');
1439
+ return;
1440
+ }
1441
+ sendJson(res, 200, { ok: true, ...(await vm.listDevices()), vm: await vm.status() });
1442
+ });
1443
+
1444
+ register('/apk-vm/device', async (req, res) => {
1445
+ if (req.method !== 'POST') {
1446
+ sendError(res, 405, 'POST only');
1447
+ return;
1448
+ }
1449
+ const body = await readJsonBody(req);
1450
+ const serial = typeof body.serial === 'string' ? body.serial.trim() : '';
1451
+ await stopScrcpy();
1452
+ if (!serial) {
1453
+ await vm.detachDevice();
1454
+ frame.at = 0;
1455
+ frame.buffer = null;
1456
+ sendJson(res, 200, { ok: true, detached: true, vm: await vm.status(), devices: await vm.listDevices() });
1457
+ return;
1458
+ }
1459
+ try {
1460
+ await vm.attachDevice(serial);
1461
+ } catch (error) {
1462
+ sendJson(res, 400, { ok: false, error: error && error.message ? error.message : String(error), devices: await vm.listDevices() });
1463
+ return;
1464
+ }
1465
+ frame.at = 0;
1466
+ frame.buffer = null;
1467
+ sendJson(res, 200, { ok: true, attached: serial, vm: await vm.status(), devices: await vm.listDevices() });
1468
+ });
1469
+
1470
+ register('/apk-vm/vm', async (req, res) => {
1471
+ if (req.method !== 'POST') {
1472
+ sendError(res, 405, 'POST only');
1473
+ return;
1474
+ }
1475
+ const body = await readJsonBody(req);
1476
+ const action = String(body.action || '').toLowerCase();
1477
+ if (action === 'start' || action === 'warm') {
1478
+ autoStartKicked = true;
1479
+ void ensureStarted();
1480
+ sendJson(res, 202, { ok: true, action, accepted: true, warm: true });
1481
+ return;
1482
+ }
1483
+ if (action === 'stop') {
1484
+ await stopScrcpy();
1485
+ const snapshot = await vm.stop();
1486
+ frame.buffer = null;
1487
+ frame.at = 0;
1488
+ screen.value = null;
1489
+ sendJson(res, 200, { ok: true, action, vm: snapshot });
1490
+ return;
1491
+ }
1492
+ if (action === 'restart') {
1493
+ await stopScrcpy();
1494
+ await vm.stop();
1495
+ frame.buffer = null;
1496
+ frame.at = 0;
1497
+ screen.value = null;
1498
+ autoStartKicked = true;
1499
+ void ensureStarted();
1500
+ sendJson(res, 202, { ok: true, action, accepted: true, warm: true });
1501
+ return;
1502
+ }
1503
+ if (action === 'snapshot') {
1504
+ try {
1505
+ const saved = await vm.saveSnapshot(body.name);
1506
+ sendJson(res, 200, { ok: true, action, ...saved });
1507
+ } catch (error) {
1508
+ sendError(res, 500, error.message);
1509
+ }
1510
+ return;
1511
+ }
1512
+ sendError(res, 400, `unsupported action: ${action || '(missing)'}`);
1513
+ });
1514
+
1515
+ register('/apk-vm/install', async (req, res) => {
1516
+ if (req.method !== 'POST') {
1517
+ sendError(res, 405, 'POST only');
1518
+ return;
1519
+ }
1520
+ const body = await readJsonBody(req);
1521
+ if (installInFlight) {
1522
+ sendError(res, 409, 'another APK install is already running');
1523
+ return;
1524
+ }
1525
+ installInFlight = installApkFromPath(body.path);
1526
+ try {
1527
+ const installed = await installInFlight;
1528
+ sendJson(res, 200, { ok: true, ...installed });
1529
+ } catch (error) {
1530
+ sendError(res, 500, error.message);
1531
+ } finally {
1532
+ installInFlight = null;
1533
+ }
1534
+ });
1535
+
1536
+ register('/apk-vm/upload', async (req, res) => {
1537
+ if (req.method !== 'POST') {
1538
+ sendError(res, 405, 'POST only');
1539
+ return;
1540
+ }
1541
+ if (installInFlight) {
1542
+ sendError(res, 409, 'another APK install is already running');
1543
+ return;
1544
+ }
1545
+ let rawName = '';
1546
+ try {
1547
+ rawName = decodeURIComponent(String(req.headers['x-apk-name'] || ''));
1548
+ } catch {
1549
+ rawName = String(req.headers['x-apk-name'] || '');
1550
+ }
1551
+ let safeName = path.basename(rawName || 'upload.apk').replace(/[^A-Za-z0-9._-]+/g, '_');
1552
+ if (!safeName) safeName = 'upload.apk';
1553
+ if (!/\.apk$/i.test(safeName)) safeName += '.apk';
1554
+ let data;
1555
+ try {
1556
+ data = await readRawBody(req, UPLOAD_LIMIT_BYTES);
1557
+ } catch (error) {
1558
+ const tooLarge = /exceeds/.test(String(error && error.message));
1559
+ sendError(res, tooLarge ? 413 : 400, tooLarge ? 'APK upload is too large' : String(error && error.message));
1560
+ return;
1561
+ }
1562
+ if (data.length < 4 || data[0] !== 0x50 || data[1] !== 0x4b) {
1563
+ sendError(res, 400, 'uploaded file is not a ZIP/APK');
1564
+ return;
1565
+ }
1566
+ const dir = await prepareUploadDir();
1567
+ const target = path.join(dir, `${Date.now()}-${randomUUID().slice(0, 8)}-${safeName}`);
1568
+ await fsp.writeFile(target, data);
1569
+ installInFlight = installApkFromPath(target, { trustedPath: true });
1570
+ try {
1571
+ const installed = await installInFlight;
1572
+ sendJson(res, 200, {
1573
+ ok: true,
1574
+ ...installed,
1575
+ upload: { fileName: safeName, bytes: data.length },
1576
+ });
1577
+ } catch (error) {
1578
+ sendError(res, 500, error.message);
1579
+ } finally {
1580
+ installInFlight = null;
1581
+ }
1582
+ });
1583
+
1584
+ register('/apk-vm/frame', async (req, res) => {
1585
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1586
+ sendError(res, 405, 'GET only');
1587
+ return;
1588
+ }
1589
+ const status = await vm.status();
1590
+ if (!isReady(status)) {
1591
+ maybeAutoStart(status);
1592
+ sendJson(res, 409, { ok: false, error: `VM is not ready (state: ${status.state})` });
1593
+ return;
1594
+ }
1595
+ const png = await getFrame();
1596
+ frame.receivedAt.push(Date.now());
1597
+ res.writeHead(200, {
1598
+ 'Content-Type': 'image/png',
1599
+ 'Content-Length': png.length,
1600
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
1601
+ 'X-Apk-Vm-Serial': status.serial || '',
1602
+ 'X-Apk-Vm-Fps': String(streamFps),
1603
+ });
1604
+ if (req.method === 'HEAD') res.end();
1605
+ else res.end(png);
1606
+ });
1607
+
1608
+ register('/apk-vm/input', async (req, res) => {
1609
+ if (req.method !== 'POST') {
1610
+ sendError(res, 405, 'POST only');
1611
+ return;
1612
+ }
1613
+ const body = await readJsonBody(req);
1614
+ const status = await vm.status();
1615
+ if (!isReady(status)) {
1616
+ sendError(res, 409, `VM is not ready (state: ${status.state})`);
1617
+ return;
1618
+ }
1619
+ await vm.input(body);
1620
+ sendJson(res, 200, { ok: true });
1621
+ });
1622
+
1623
+ register('/apk-vm/touch', async (req, res) => {
1624
+ if (req.method !== 'POST') {
1625
+ sendError(res, 405, 'POST only');
1626
+ return;
1627
+ }
1628
+ const session = scrcpyState && scrcpyState.session;
1629
+ if (!session || session.state !== 'running') {
1630
+ sendError(res, 409, 'H.264 control stream is not running; connect the video stream first');
1631
+ return;
1632
+ }
1633
+ const body = await readJsonBody(req);
1634
+ const action = String(body.action || '').toLowerCase();
1635
+ if (!['down', 'move', 'up', 'cancel'].includes(action)) {
1636
+ sendError(res, 400, 'action must be down|move|up|cancel');
1637
+ return;
1638
+ }
1639
+ const x = Number(body.x);
1640
+ const y = Number(body.y);
1641
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1642
+ sendError(res, 400, 'x and y must be finite numbers');
1643
+ return;
1644
+ }
1645
+ const pointerId = body.pointerId === undefined ? undefined : Number(body.pointerId);
1646
+ if (pointerId !== undefined && !Number.isInteger(pointerId)) {
1647
+ sendError(res, 400, 'pointerId must be an integer');
1648
+ return;
1649
+ }
1650
+ await session.sendTouch({ action, pointerId, x, y });
1651
+ sendJson(res, 200, { ok: true });
1652
+ });
1653
+
1654
+ register('/apk-vm/clipboard', async (req, res) => {
1655
+ const session = scrcpyState && scrcpyState.session;
1656
+ if (req.method === 'GET') {
1657
+ if (!session || session.state !== 'running') {
1658
+ sendError(res, 409, 'H.264 control stream is not running');
1659
+ return;
1660
+ }
1661
+ try {
1662
+ const text = await session.requestClipboard(2000);
1663
+ sendJson(res, 200, { ok: true, text });
1664
+ } catch (error) {
1665
+ sendError(res, 504, error && error.message ? error.message : String(error));
1666
+ }
1667
+ return;
1668
+ }
1669
+ if (req.method !== 'POST') {
1670
+ sendError(res, 405, 'GET or POST only');
1671
+ return;
1672
+ }
1673
+ if (!session || session.state !== 'running') {
1674
+ sendError(res, 409, 'H.264 control stream is not running');
1675
+ return;
1676
+ }
1677
+ const body = await readJsonBody(req);
1678
+ if (typeof body.text !== 'string') {
1679
+ sendError(res, 400, 'text must be a string');
1680
+ return;
1681
+ }
1682
+ const result = await session.setClipboard(body.text, { paste: body.paste === true });
1683
+ sendJson(res, 200, { ok: true, ...result });
1684
+ });
1685
+
1686
+ register('/apk-vm/stream', async (req, res) => {
1687
+ if (req.method !== 'POST') {
1688
+ sendError(res, 405, 'POST only');
1689
+ return;
1690
+ }
1691
+ const body = await readJsonBody(req);
1692
+ const next = Number(body.fps);
1693
+ if (!Number.isFinite(next)) {
1694
+ sendError(res, 400, 'fps must be a number between 1 and 10');
1695
+ return;
1696
+ }
1697
+ streamFps = Math.min(10, Math.max(1, Math.round(next)));
1698
+ sendJson(res, 200, { ok: true, fps: streamFps });
1699
+ });
1700
+
1701
+ /* ---- rooted debug surface ---------------------------------------- *
1702
+ * These routes (and the `apk_vm` Agent tool) expose the guest's root
1703
+ * shell and low-level device operations to DSH. They are local-only,
1704
+ * same-origin routes; paths and output are bounded. */
1705
+
1706
+ async function ensureDebugReady(res) {
1707
+ const status = await vm.status();
1708
+ if (!isReady(status)) {
1709
+ sendError(res, 409, `VM is not ready (state: ${status.state})`);
1710
+ return false;
1711
+ }
1712
+ return true;
1713
+ }
1714
+
1715
+ register('/apk-vm/debug/info', async (req, res) => {
1716
+ if (req.method !== 'GET') {
1717
+ sendError(res, 405, 'GET only');
1718
+ return;
1719
+ }
1720
+ if (!(await ensureDebugReady(res))) return;
1721
+ sendJson(res, 200, await vm.deviceInfo());
1722
+ });
1723
+
1724
+ register('/apk-vm/debug/shell', async (req, res) => {
1725
+ if (req.method !== 'POST') {
1726
+ sendError(res, 405, 'POST only');
1727
+ return;
1728
+ }
1729
+ if (!(await ensureDebugReady(res))) return;
1730
+ const body = await readJsonBody(req);
1731
+ const result = await vm.shell(body.command, {
1732
+ timeoutMs: body.timeoutMs,
1733
+ root: body.root !== false,
1734
+ });
1735
+ sendJson(res, 200, result);
1736
+ });
1737
+
1738
+ register('/apk-vm/debug/logcat', async (req, res) => {
1739
+ if (req.method !== 'GET') {
1740
+ sendError(res, 405, 'GET only');
1741
+ return;
1742
+ }
1743
+ if (!(await ensureDebugReady(res))) return;
1744
+ const url = new URL(req.url || '/', 'http://localhost');
1745
+ sendJson(
1746
+ res,
1747
+ 200,
1748
+ await vm.logcat({
1749
+ lines: url.searchParams.get('lines'),
1750
+ filter: url.searchParams.get('filter') || '',
1751
+ buffer: url.searchParams.get('buffer') || '',
1752
+ }),
1753
+ );
1754
+ });
1755
+
1756
+ register('/apk-vm/debug/prop', async (req, res) => {
1757
+ if (!(await ensureDebugReady(res))) return;
1758
+ if (req.method === 'GET') {
1759
+ const url = new URL(req.url || '/', 'http://localhost');
1760
+ sendJson(res, 200, await vm.getprop(url.searchParams.get('name') || ''));
1761
+ return;
1762
+ }
1763
+ if (req.method === 'POST') {
1764
+ const body = await readJsonBody(req);
1765
+ sendJson(res, 200, await vm.setprop(body.name, body.value));
1766
+ return;
1767
+ }
1768
+ sendError(res, 405, 'GET or POST only');
1769
+ });
1770
+
1771
+ register('/apk-vm/debug/packages', async (req, res) => {
1772
+ if (req.method !== 'GET') {
1773
+ sendError(res, 405, 'GET only');
1774
+ return;
1775
+ }
1776
+ if (!(await ensureDebugReady(res))) return;
1777
+ const url = new URL(req.url || '/', 'http://localhost');
1778
+ sendJson(res, 200, await vm.listPackages({ thirdPartyOnly: url.searchParams.get('all') !== '1' }));
1779
+ });
1780
+
1781
+ register('/apk-vm/debug/root', async (req, res) => {
1782
+ if (req.method !== 'POST') {
1783
+ sendError(res, 405, 'POST only');
1784
+ return;
1785
+ }
1786
+ if (!(await ensureDebugReady(res))) return;
1787
+ const ok = await vm.ensureRoot();
1788
+ sendJson(res, 200, { ok, root: ok, debug: vm.debugStatus() });
1789
+ });
1790
+
1791
+ register('/apk-vm/debug/push', async (req, res) => {
1792
+ if (req.method !== 'POST') {
1793
+ sendError(res, 405, 'POST only');
1794
+ return;
1795
+ }
1796
+ if (!(await ensureDebugReady(res))) return;
1797
+ const body = await readJsonBody(req);
1798
+ const localPath = resolveDebugLocal(body.localPath, true);
1799
+ sendJson(res, 200, await vm.pushFile(localPath, body.remotePath));
1800
+ });
1801
+
1802
+ register('/apk-vm/debug/pull', async (req, res) => {
1803
+ if (req.method !== 'POST') {
1804
+ sendError(res, 405, 'POST only');
1805
+ return;
1806
+ }
1807
+ if (!(await ensureDebugReady(res))) return;
1808
+ const body = await readJsonBody(req);
1809
+ const localPath = resolveDebugLocal(body.localPath, false);
1810
+ sendJson(res, 200, await vm.pullFile(body.remotePath, localPath));
1811
+ });
1812
+
1813
+ register('/apk-vm/debug/reboot', async (req, res) => {
1814
+ if (req.method !== 'POST') {
1815
+ sendError(res, 405, 'POST only');
1816
+ return;
1817
+ }
1818
+ if (!(await ensureDebugReady(res))) return;
1819
+ const body = await readJsonBody(req);
1820
+ sendJson(res, 200, await vm.reboot(body.mode || ''));
1821
+ });
1822
+
1823
+ register('/apk-vm/debug/action', async (req, res) => {
1824
+ if (req.method !== 'POST') {
1825
+ sendError(res, 405, 'POST only');
1826
+ return;
1827
+ }
1828
+ if (!(await ensureDebugReady(res))) return;
1829
+ const body = await readJsonBody(req);
1830
+ const action = String(body.action || '').toLowerCase();
1831
+ const packageName = body.packageName;
1832
+ let result;
1833
+ if (action === 'force-stop') result = await vm.forceStop(packageName);
1834
+ else if (action === 'clear-data') result = await vm.clearData(packageName);
1835
+ else if (action === 'uninstall') result = await vm.uninstall(packageName);
1836
+ else {
1837
+ sendError(res, 400, `unsupported debug action: ${action || '(missing)'}`);
1838
+ return;
1839
+ }
1840
+ sendJson(res, 200, { ok: result.ok === true, action, result });
1841
+ });
1842
+
1843
+ register('/apk-vm/debug/rotate', async (req, res) => {
1844
+ if (req.method !== 'POST') {
1845
+ sendError(res, 405, 'POST only');
1846
+ return;
1847
+ }
1848
+ if (!(await ensureDebugReady(res))) return;
1849
+ const body = await readJsonBody(req);
1850
+ sendJson(res, 200, await vm.rotate(body.orientation || 'landscape'));
1851
+ });
1852
+
1853
+ register('/apk-vm/debug/ui', async (req, res) => {
1854
+ if (req.method !== 'GET') {
1855
+ sendError(res, 405, 'GET only');
1856
+ return;
1857
+ }
1858
+ if (!(await ensureDebugReady(res))) return;
1859
+ sendJson(res, 200, await vm.uiDump());
1860
+ });
1861
+
1862
+ register('/apk-vm/debug/record', async (req, res) => {
1863
+ if (req.method !== 'POST') {
1864
+ sendError(res, 405, 'POST only');
1865
+ return;
1866
+ }
1867
+ if (!(await ensureDebugReady(res))) return;
1868
+ const body = await readJsonBody(req);
1869
+ sendJson(res, 200, await vm.screenRecord({ seconds: body.seconds }));
1870
+ });
1871
+
1872
+ register('/apk-vm/artifacts', async (req, res) => {
1873
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1874
+ sendError(res, 405, 'GET or HEAD only');
1875
+ return;
1876
+ }
1877
+ const url = new URL(req.url || '/', 'http://localhost');
1878
+ const name = String(url.searchParams.get('name') || '');
1879
+ // Only files this plugin wrote into the OS temp dir; never arbitrary paths.
1880
+ if (!/^(apk-vm-shot-|dsh-apk-vm-record-)[A-Za-z0-9._-]+\.(png|mp4)$/.test(name)) {
1881
+ sendError(res, 400, 'invalid artifact name');
1882
+ return;
1883
+ }
1884
+ const target = path.join(os.tmpdir(), name);
1885
+ if (path.dirname(target) !== os.tmpdir() || !existsSync(target)) {
1886
+ sendError(res, 404, 'artifact not found');
1887
+ return;
1888
+ }
1889
+ const stat = await fsp.stat(target);
1890
+ res.writeHead(200, {
1891
+ 'content-type': name.endsWith('.mp4') ? 'video/mp4' : 'image/png',
1892
+ 'content-length': stat.size,
1893
+ 'cache-control': 'no-store',
1894
+ 'content-disposition': `attachment; filename="${name}"`,
1895
+ });
1896
+ if (req.method === 'HEAD') {
1897
+ res.end();
1898
+ return;
1899
+ }
1900
+ createReadStream(target).pipe(res);
1901
+ });
1902
+
1903
+ register('/apk-vm/debug/perf', async (req, res) => {
1904
+ if (req.method !== 'GET') {
1905
+ sendError(res, 405, 'GET only');
1906
+ return;
1907
+ }
1908
+ if (!(await ensureDebugReady(res))) return;
1909
+ sendJson(res, 200, await vm.perfSnapshot());
1910
+ });
1911
+
1912
+ register('/apk-vm/debug/screenshot', async (req, res) => {
1913
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1914
+ sendError(res, 405, 'GET only');
1915
+ return;
1916
+ }
1917
+ if (!(await ensureDebugReady(res))) return;
1918
+ const png = await vm.screenshot();
1919
+ res.writeHead(200, {
1920
+ 'Content-Type': 'image/png',
1921
+ 'Content-Length': png.length,
1922
+ 'Cache-Control': 'no-store',
1923
+ 'Content-Disposition': `attachment; filename="apk-vm-${Date.now()}.png"`,
1924
+ });
1925
+ if (req.method === 'HEAD') res.end();
1926
+ else res.end(png);
1927
+ });
1928
+
1929
+ disposers.push(
1930
+ webServer.registerUpgrade({
1931
+ path: '/apk-vm/ws',
1932
+ handler: (req, socket, head) => {
1933
+ if (!isSameOrigin(req)) {
1934
+ try {
1935
+ socket.destroy();
1936
+ } catch {
1937
+ /* ignore */
1938
+ }
1939
+ return;
1940
+ }
1941
+ const client = acceptWebSocket(req, socket, head);
1942
+ if (!client) return;
1943
+ handleScrcpyClient(client);
1944
+ },
1945
+ }),
1946
+ );
1947
+
1948
+ disposers.push(
1949
+ webServer.registerUpgrade({
1950
+ path: '/apk-vm/instances/ws',
1951
+ handler: (req, socket, head) => {
1952
+ if (!isSameOrigin(req)) {
1953
+ try {
1954
+ socket.destroy();
1955
+ } catch {
1956
+ /* ignore */
1957
+ }
1958
+ return;
1959
+ }
1960
+ const client = acceptWebSocket(req, socket, head);
1961
+ if (!client) return;
1962
+ client.sendText(
1963
+ JSON.stringify({
1964
+ type: 'error',
1965
+ error: 'extra instances use the PNG preview fallback; H.264 is currently reserved for the default VM',
1966
+ }),
1967
+ );
1968
+ client.close(1001, 'PNG fallback');
1969
+ },
1970
+ }),
1971
+ );
1972
+
1973
+ return () => {
1974
+ void stopScrcpy();
1975
+ void cleanupUploads();
1976
+ for (const id of [...extraRouteDisposers.keys()]) disposeExtraRouteSet(id);
1977
+ void instanceManager?.dispose?.();
1978
+ for (const dispose of disposers.reverse()) {
1979
+ try {
1980
+ dispose();
1981
+ } catch {
1982
+ /* already disposed */
1983
+ }
1984
+ }
1985
+ void vm.dispose().catch(() => {});
1986
+ };
1987
+ }
1988
+
1989
+ ctx.inject(['webServer'], (host) => {
1990
+ host.effect(() => mountRoutes(host.webServer), 'dsh-apk-vm: http routes');
1991
+ });
1992
+
1993
+ // Optional warm start: boot the VM right after the plugin activates so the
1994
+ // first panel/tab open lands on a running (or nearly booted) device. This is
1995
+ // what makes the visible "open the VM" step effectively instant; opt in with
1996
+ // `prewarm: true` because it consumes CPU/GPU while the plugin is loaded.
1997
+ if (cfg.prewarm === true && (cfg.emulatorPath || cfg.serial)) {
1998
+ ctx.effect(() => {
1999
+ const delay = Number.isFinite(cfg.prewarmDelayMs) && cfg.prewarmDelayMs >= 0 ? cfg.prewarmDelayMs : 3000;
2000
+ const timer = setTimeout(() => {
2001
+ autoStartKicked = true;
2002
+ void ensureStarted();
2003
+ }, delay);
2004
+ if (typeof timer.unref === 'function') timer.unref();
2005
+ return () => clearTimeout(timer);
2006
+ }, 'dsh-apk-vm: prewarm timer');
2007
+ }
2008
+
2009
+ // Idle reaper for extra multi-agent instances: a VM whose owner/panel has
2010
+ // not touched it for `instanceIdleMs` is stopped (AVD kept for reuse). The
2011
+ // default VM is managed by the normal prewarm/auto-start lifecycle.
2012
+ if (instanceManager) {
2013
+ ctx.effect(() => {
2014
+ const intervalMs = Math.max(30_000, Number(cfg.instanceReapIntervalMs) || 60_000);
2015
+ const timer = setInterval(() => {
2016
+ void instanceManager.reapIdle().catch(() => {});
2017
+ }, intervalMs);
2018
+ if (typeof timer.unref === 'function') timer.unref();
2019
+ return () => clearInterval(timer);
2020
+ }, 'dsh-apk-vm: instance idle reaper');
2021
+ }
2022
+
2023
+ /* ---- Agent tool: the same rooted debug surface the UI/HTTP use ---- *
2024
+ * Registered globally through the Host tools registry, so every DSH agent
2025
+ * can call `apk_vm` for root shell / logcat / props / packages / APK install
2026
+ * / push-pull / reboot, without duplicating plugin logic. */
2027
+
2028
+ function defaultToolApi() {
2029
+ return {
2030
+ kind: 'default',
2031
+ id: 'default',
2032
+ status: () => vm.status(),
2033
+ start: () => vm.start(),
2034
+ stop: () => vm.stop(),
2035
+ deviceInfo: () => vm.deviceInfo(),
2036
+ ensureRoot: () => vm.ensureRoot(),
2037
+ shell: (command, options) => vm.shell(command, options),
2038
+ logcat: (options) => vm.logcat(options),
2039
+ getprop: (name) => vm.getprop(name),
2040
+ setprop: (name, value) => vm.setprop(name, value),
2041
+ listPackages: (options) => vm.listPackages(options),
2042
+ input: (payload) => vm.input(payload),
2043
+ getClipboard: async () => {
2044
+ const session = scrcpyState && scrcpyState.session;
2045
+ if (!session || session.state !== 'running') throw new Error('H.264 control stream is not running');
2046
+ return session.requestClipboard(2000);
2047
+ },
2048
+ setClipboard: async (text, paste) => {
2049
+ const session = scrcpyState && scrcpyState.session;
2050
+ if (!session || session.state !== 'running') throw new Error('H.264 control stream is not running');
2051
+ return session.setClipboard(text, { paste: paste === true });
2052
+ },
2053
+ install: (apkPath) => installApkFromPath(apkPath),
2054
+ pushFile: (local, remote) => vm.pushFile(local, remote),
2055
+ pullFile: (remote, local) => vm.pullFile(remote, local),
2056
+ reboot: (mode) => vm.reboot(mode),
2057
+ forceStop: (pkg) => vm.forceStop(pkg),
2058
+ clearData: (pkg) => vm.clearData(pkg),
2059
+ uninstall: (pkg) => vm.uninstall(pkg),
2060
+ rotate: (orientation) => vm.rotate(orientation),
2061
+ uiDump: () => vm.uiDump(),
2062
+ screenRecord: (options) => vm.screenRecord(options),
2063
+ perfSnapshot: () => vm.perfSnapshot(),
2064
+ screenshot: async () => {
2065
+ const buffer = await vm.screenshot();
2066
+ const target = path.join(os.tmpdir(), `apk-vm-shot-${Date.now()}.png`);
2067
+ await fsp.writeFile(target, buffer);
2068
+ return { path: target, bytes: buffer.length };
2069
+ },
2070
+ };
2071
+ }
2072
+
2073
+ function extraToolApi(instance) {
2074
+ return {
2075
+ kind: 'extra',
2076
+ id: instance.id,
2077
+ status: () => instance.status(),
2078
+ start: () => instance.start(),
2079
+ stop: () => instance.stop(),
2080
+ deviceInfo: () => instance.deviceInfo(),
2081
+ ensureRoot: () => instance.ensureRoot(),
2082
+ shell: (command, options) => instance.shell(command, options),
2083
+ logcat: (options) => instance.logcat(options),
2084
+ getprop: (name) => instance.getprop(name),
2085
+ setprop: (name, value) => instance.setprop(name, value),
2086
+ listPackages: (options) => instance.listPackages(options),
2087
+ input: (payload) => instance.input(payload),
2088
+ getClipboard: () => instance.getClipboard(),
2089
+ setClipboard: (text, paste) => instance.setClipboard(text, paste),
2090
+ install: async (apkPath) => instance.installApk(resolveApkPath(apkPath, rootsFor())),
2091
+ pushFile: (local, remote) => instance.pushFile(local, remote),
2092
+ pullFile: (remote, local) => instance.pullFile(remote, local),
2093
+ reboot: (mode) => instance.reboot(mode),
2094
+ forceStop: (pkg) => instance.forceStop(pkg),
2095
+ clearData: (pkg) => instance.clearData(pkg),
2096
+ uninstall: (pkg) => instance.uninstall(pkg),
2097
+ rotate: (orientation) => instance.rotate(orientation),
2098
+ uiDump: () => instance.uiDump(),
2099
+ screenRecord: (options) => instance.screenRecord(options),
2100
+ perfSnapshot: () => instance.perfSnapshot(),
2101
+ screenshot: async () => {
2102
+ const buffer = await instance.screenshot();
2103
+ const target = path.join(os.tmpdir(), `apk-vm-shot-${instance.id}-${Date.now()}.png`);
2104
+ await fsp.writeFile(target, buffer);
2105
+ return { path: target, bytes: buffer.length };
2106
+ },
2107
+ };
2108
+ }
2109
+
2110
+ async function resolveToolApi(args, exec) {
2111
+ const requested = String((args && args.instance) || '').trim();
2112
+ if (requested) {
2113
+ if (requested === 'default') return defaultToolApi();
2114
+ if (!instanceManager) throw new Error('multi-instance support is disabled');
2115
+ const instance = instanceManager.get(requested);
2116
+ if (!instance) throw new Error(`unknown apk_vm instance: ${requested}`);
2117
+ return extraToolApi(instance);
2118
+ }
2119
+ if (instanceManager && exec && exec.agent) {
2120
+ const session = exec.agent.session;
2121
+ const header = session && session.header ? session.header : {};
2122
+ const key = header.id || header.sessionId || (session && session.id) || 'anonymous';
2123
+ const assigned = await instanceManager.forAgent(key);
2124
+ if (assigned && assigned.default === true) return defaultToolApi();
2125
+ if (assigned && assigned.id) registerInstanceRoutes?.(assigned.id);
2126
+ return extraToolApi(assigned);
2127
+ }
2128
+ return defaultToolApi();
2129
+ }
2130
+
2131
+ async function ensureToolApiReady(api, action) {
2132
+ let status = await api.status();
2133
+ if (isReady(status)) return status;
2134
+ if (action !== 'stop' && action !== 'start') {
2135
+ // Tool calls are expected to operate on a running VM; boot on demand.
2136
+ await api.start();
2137
+ status = await api.status();
2138
+ }
2139
+ if (!isReady(status)) {
2140
+ throw new Error(`APK VM instance ${api.id} is not ready (state: ${status.state}); start it first`);
2141
+ }
2142
+ return status;
2143
+ }
2144
+
2145
+ /** Parse UIAutomator XML into lightweight nodes for Agent UI assertions. */
2146
+ function parseUiNodes(xml) {
2147
+ const source = String(xml || '');
2148
+ const nodes = [];
2149
+ const nodeRe = /<node\b([^>]*?)\/?>/g;
2150
+ const attrRe = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*"([^"]*)"/g;
2151
+ let match;
2152
+ while ((match = nodeRe.exec(source))) {
2153
+ const attrs = {};
2154
+ let attr;
2155
+ while ((attr = attrRe.exec(match[1]))) {
2156
+ attrs[attr[1]] = attr[2]
2157
+ .replace(/&quot;/g, '"')
2158
+ .replace(/&apos;/g, "'")
2159
+ .replace(/&lt;/g, '<')
2160
+ .replace(/&gt;/g, '>')
2161
+ .replace(/&amp;/g, '&');
2162
+ }
2163
+ const bounds = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/.exec(attrs.bounds || '');
2164
+ nodes.push({
2165
+ text: attrs.text || '',
2166
+ resourceId: attrs['resource-id'] || '',
2167
+ contentDesc: attrs['content-desc'] || '',
2168
+ className: attrs.class || '',
2169
+ clickable: attrs.clickable === 'true',
2170
+ enabled: attrs.enabled === 'true',
2171
+ bounds: bounds
2172
+ ? { x1: Number(bounds[1]), y1: Number(bounds[2]), x2: Number(bounds[3]), y2: Number(bounds[4]) }
2173
+ : null,
2174
+ center: bounds
2175
+ ? {
2176
+ x: Math.round((Number(bounds[1]) + Number(bounds[3])) / 2),
2177
+ y: Math.round((Number(bounds[2]) + Number(bounds[4])) / 2),
2178
+ }
2179
+ : null,
2180
+ });
2181
+ }
2182
+ return nodes;
2183
+ }
2184
+
2185
+ function uiNodeMatches(node, args) {
2186
+ if (!node) return false;
2187
+ if (args.text && !String(node.text || '').includes(String(args.text))) return false;
2188
+ if (args.resourceId && !String(node.resourceId || '').includes(String(args.resourceId))) return false;
2189
+ if (args.contentDesc && !String(node.contentDesc || '').includes(String(args.contentDesc))) return false;
2190
+ if (args.className && !String(node.className || '').includes(String(args.className))) return false;
2191
+ return Boolean(args.text || args.resourceId || args.contentDesc || args.className);
2192
+ }
2193
+
2194
+ const apkVmTool = {
2195
+ name: 'apk_vm',
2196
+ description:
2197
+ 'Operate the local rooted Android APK virtual machine (Android emulator) that the APK VM panel manages. ' +
2198
+ 'The VM is usually already running (warm start); adbd runs as root once ready. ' +
2199
+ 'Actions: info, shell, logcat, getprop, setprop, packages, install, push, pull, reboot, root, ' +
2200
+ 'force_stop, clear_data, uninstall, rotate, ui_dump, record, perf, screenshot, tap, swipe, long_press, key, text, ' +
2201
+ 'apk_info, find_ui, assert_text, tap_text, clipboard_get, clipboard_set, devices, attach_device, detach_device, start, stop, list_instances, create_instance, destroy_instance. ' +
2202
+ 'Multi-instance: pass `instance` to target an isolated VM; omit it to use the calling Agent\'s ' +
2203
+ 'assigned instance (first Agent gets the default VM, later Agents get isolated instances). ' +
2204
+ 'Use shell for low-level Android commands, logcat for logs, ' +
2205
+ 'setprop for system properties, packages to list apps, install to install+launch an APK by local path, ' +
2206
+ 'push/pull to move files (under /data/local/tmp, /sdcard or /storage/emulated/0), reboot to restart the guest, ' +
2207
+ 'rotate to lock orientation, ui_dump for the accessibility tree, record for a short MP4, perf for CPU/memory.',
2208
+ parameters: {
2209
+ type: 'object',
2210
+ additionalProperties: false,
2211
+ properties: {
2212
+ action: {
2213
+ type: 'string',
2214
+ description: 'info|shell|logcat|getprop|setprop|packages|install|push|pull|reboot|root|force_stop|clear_data|uninstall|rotate|ui_dump|record|perf|screenshot|tap|swipe|long_press|key|text|apk_info|find_ui|assert_text|tap_text|clipboard_get|clipboard_set|devices|attach_device|detach_device|start|stop|list_instances|create_instance|destroy_instance',
2215
+ },
2216
+ instance: { type: 'string', description: 'Target VM instance id (default or an extra instance from list_instances). Omit for the calling Agent\'s assigned instance.' },
2217
+ instanceName: { type: 'string', description: 'create_instance: optional name for the new isolated instance.' },
2218
+ start: { type: 'boolean', description: 'create_instance: start the new instance immediately when true.' },
2219
+ command: { type: 'string', description: 'Shell command for action=shell.' },
2220
+ timeoutMs: { type: 'integer', description: 'Optional shell timeout in ms (max 120000).' },
2221
+ lines: { type: 'integer', description: 'logcat line count (1-1000).' },
2222
+ filter: { type: 'string', description: 'Optional logcat filter/spec argument.' },
2223
+ buffer: { type: 'string', description: 'logcat buffer: main|system|crash|events|all (default main).' },
2224
+ name: { type: 'string', description: 'System property name for getprop/setprop.' },
2225
+ value: { type: 'string', description: 'System property value for setprop.' },
2226
+ path: { type: 'string', description: 'Local file path: APK for install, source for push, destination for pull.' },
2227
+ remotePath: { type: 'string', description: 'Device path for push/pull; must be under /data/local/tmp, /sdcard or /storage/emulated/0.' },
2228
+ packageName: { type: 'string', description: 'Package name for force_stop/clear_data/uninstall.' },
2229
+ mode: { type: 'string', description: 'Optional reboot mode (system|recovery|bootloader) or rotate orientation (portrait|landscape|left|right).' },
2230
+ seconds: { type: 'integer', description: 'record: clip length in seconds (1-60, default 5).' },
2231
+ all: { type: 'boolean', description: 'packages: include system packages when true.' },
2232
+ x: { type: 'integer', description: 'tap/long_press/swipe: device x coordinate.' },
2233
+ y: { type: 'integer', description: 'tap/long_press/swipe: device y coordinate.' },
2234
+ x1: { type: 'integer', description: 'swipe: start device x coordinate.' },
2235
+ y1: { type: 'integer', description: 'swipe: start device y coordinate.' },
2236
+ x2: { type: 'integer', description: 'swipe: end device x coordinate.' },
2237
+ y2: { type: 'integer', description: 'swipe: end device y coordinate.' },
2238
+ durationMs: { type: 'integer', description: 'swipe/long_press duration in ms (1-60000; long_press default 800).' },
2239
+ key: { type: 'string', description: 'key: Android keycode name or number, e.g. HOME, BACK, ENTER, 3.' },
2240
+ text: { type: 'string', description: 'text: text to type into the focused field (max 4096 chars).' },
2241
+ resourceId: { type: 'string', description: 'find_ui/assert_text/tap_text: substring of the Android resource-id.' },
2242
+ contentDesc: { type: 'string', description: 'find_ui/assert_text/tap_text: substring of content-desc.' },
2243
+ className: { type: 'string', description: 'find_ui: substring of the Android class name.' },
2244
+ paste: { type: 'boolean', description: 'clipboard_set: also paste immediately (scrcpy paste flag).' },
2245
+ serial: { type: 'string', description: 'attach_device: adb serial of an online physical/remote device; detach_device ignores it.' },
2246
+ },
2247
+ required: ['action'],
2248
+ },
2249
+ output: {
2250
+ schema: {
2251
+ type: 'object',
2252
+ additionalProperties: true,
2253
+ properties: {
2254
+ ok: { type: 'boolean' },
2255
+ summary: { type: 'string' },
2256
+ result: { type: 'object' },
2257
+ },
2258
+ required: ['ok'],
2259
+ },
2260
+ render: (_args, value) => [
2261
+ {
2262
+ type: 'text',
2263
+ text:
2264
+ (value && value.summary) ||
2265
+ JSON.stringify((value && value.result) || value, null, 2).slice(0, 20000),
2266
+ },
2267
+ ],
2268
+ },
2269
+ timeoutMs: 180000,
2270
+ async execute(args, exec) {
2271
+ const action = String((args && args.action) || '').toLowerCase();
2272
+
2273
+ if (action === 'list_instances') {
2274
+ const defaultStatus = await vm.status();
2275
+ const instances = [
2276
+ {
2277
+ id: 'default',
2278
+ kind: 'default',
2279
+ avdName: defaultStatus.avd || vm.avdName || null,
2280
+ port: vm.port || null,
2281
+ state: defaultStatus.state,
2282
+ serial: defaultStatus.serial || null,
2283
+ root: defaultStatus.root === true,
2284
+ },
2285
+ ...(instanceManager ? instanceManager.list() : []),
2286
+ ];
2287
+ return {
2288
+ ok: true,
2289
+ summary: instances.map((entry) => `${entry.id}:${entry.state}${entry.serial ? '@' + entry.serial : ''}`).join(', '),
2290
+ result: { instances, maxInstances: instanceManager ? instanceManager.maxInstances : 1 },
2291
+ };
2292
+ }
2293
+ if (action === 'create_instance') {
2294
+ if (!instanceManager) throw new Error('multi-instance support is disabled');
2295
+ const instance = await instanceManager.create({ name: args.instanceName || args.name });
2296
+ registerInstanceRoutes?.(instance.id);
2297
+ if (args.start === true) void instance.start();
2298
+ return {
2299
+ ok: true,
2300
+ summary: `created instance ${instance.id} (avd=${instance.avdName}, port=${instance.port})`,
2301
+ result: { id: instance.id, avdName: instance.avdName, port: instance.port },
2302
+ };
2303
+ }
2304
+ if (action === 'destroy_instance') {
2305
+ if (!instanceManager) throw new Error('multi-instance support is disabled');
2306
+ const id = args.instance || args.name;
2307
+ if (!id) throw new Error('destroy_instance requires instance');
2308
+ if (id === 'default') throw new Error('the default instance cannot be destroyed');
2309
+ const result = await instanceManager.destroy(id);
2310
+ disposeInstanceRoutes?.(id);
2311
+ return { ok: true, summary: `destroyed instance ${id}`, result };
2312
+ }
2313
+
2314
+ if (action === 'apk_info') {
2315
+ if (!args.path) throw new Error('action=apk_info requires path (local .apk file)');
2316
+ const apkPath = resolveApkPath(args.path, rootsFor());
2317
+ const info = await vm.inspectApk(apkPath);
2318
+ return {
2319
+ ok: true,
2320
+ summary: `${info.label} (${info.packageName}) v${info.versionName || info.versionCode || '?'} targetSdk=${info.targetSdkVersion || '?'} permissions=${info.permissions.length}`,
2321
+ result: { path: apkPath, ...info },
2322
+ };
2323
+ }
2324
+ if (action === 'devices') {
2325
+ const listed = await vm.listDevices();
2326
+ return {
2327
+ ok: listed.ok !== false,
2328
+ summary: (listed.devices || []).map((entry) => `${entry.serial}:${entry.state}${entry.kind === 'physical' ? ' (physical)' : ''}`).join(', ') || '(no adb devices)',
2329
+ result: listed,
2330
+ };
2331
+ }
2332
+ if (action === 'attach_device') {
2333
+ if (!args.serial) throw new Error('action=attach_device requires serial');
2334
+ await stopScrcpy();
2335
+ await vm.attachDevice(args.serial);
2336
+ frame.at = 0;
2337
+ frame.buffer = null;
2338
+ return { ok: true, summary: `attached external device ${args.serial}`, result: await vm.status() };
2339
+ }
2340
+ if (action === 'detach_device') {
2341
+ await stopScrcpy();
2342
+ await vm.detachDevice();
2343
+ frame.at = 0;
2344
+ frame.buffer = null;
2345
+ return { ok: true, summary: 'detached external device; managed AVD identity restored', result: await vm.status() };
2346
+ }
2347
+
2348
+ const api = await resolveToolApi(args, exec);
2349
+ if (action === 'start') {
2350
+ const snapshot = await api.start();
2351
+ return { ok: true, summary: `started ${api.id}`, result: snapshot || {} };
2352
+ }
2353
+ if (action === 'stop') {
2354
+ const snapshot = await api.stop();
2355
+ return { ok: true, summary: `stopped ${api.id}`, result: snapshot || {} };
2356
+ }
2357
+ const status = await ensureToolApiReady(api, action);
2358
+ switch (action) {
2359
+ case 'info': {
2360
+ const result = await api.deviceInfo();
2361
+ await api.ensureRoot();
2362
+ return { ok: true, summary: `${api.id} ${status.serial}: Android ${result.identity?.release || '?'} root=${result.root}`, result };
2363
+ }
2364
+ case 'shell': {
2365
+ if (!args.command) throw new Error('action=shell requires command');
2366
+ const result = await api.shell(args.command, { timeoutMs: Math.min(120000, Number(args.timeoutMs) || 30000) });
2367
+ return { ok: result.ok, summary: `$ ${args.command}\n${result.stdout || result.stderr || ''}`.slice(0, 20000), result };
2368
+ }
2369
+ case 'logcat': {
2370
+ const result = await api.logcat({ lines: args.lines, filter: args.filter || '', buffer: args.buffer || '' });
2371
+ return { ok: result.ok, summary: result.lines.slice(-40).join('\n').slice(0, 20000), result };
2372
+ }
2373
+ case 'getprop': {
2374
+ const result = await api.getprop(args.name);
2375
+ return { ok: true, summary: `${result.name}=${result.value}`, result };
2376
+ }
2377
+ case 'setprop': {
2378
+ const result = await api.setprop(args.name, args.value);
2379
+ return { ok: true, summary: `set ${result.name}=${result.value} (root=${result.root})`, result };
2380
+ }
2381
+ case 'packages': {
2382
+ const result = await api.listPackages({ thirdPartyOnly: args.all !== true });
2383
+ return { ok: true, summary: `${result.packages.length} package(s): ${result.packages.slice(0, 20).map((p) => p.packageName).join(', ')}`, result };
2384
+ }
2385
+ case 'tap': {
2386
+ const x = Number(args.x);
2387
+ const y = Number(args.y);
2388
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('action=tap requires numeric x and y (device coordinates)');
2389
+ const result = await api.input({ type: 'tap', x, y });
2390
+ return { ok: result.ok, summary: `tap ${Math.round(x)},${Math.round(y)} on ${api.id}`, result };
2391
+ }
2392
+ case 'swipe': {
2393
+ const coords = [args.x1, args.y1, args.x2, args.y2].map(Number);
2394
+ if (!coords.every(Number.isFinite)) throw new Error('action=swipe requires numeric x1,y1,x2,y2');
2395
+ const durationMs = Math.max(1, Math.min(60000, Number(args.durationMs) || 300));
2396
+ const result = await api.input({ type: 'swipe', x1: coords[0], y1: coords[1], x2: coords[2], y2: coords[3], durationMs });
2397
+ return { ok: result.ok, summary: `swipe ${coords.slice(0, 2).join(',')} -> ${coords.slice(2).join(',')} (${durationMs}ms) on ${api.id}`, result };
2398
+ }
2399
+ case 'long_press': {
2400
+ const x = Number(args.x);
2401
+ const y = Number(args.y);
2402
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('action=long_press requires numeric x and y');
2403
+ const durationMs = Math.max(300, Math.min(60000, Number(args.durationMs) || 800));
2404
+ const result = await api.input({ type: 'swipe', x1: x, y1: y, x2: x, y2: y, durationMs });
2405
+ return { ok: result.ok, summary: `long-press ${Math.round(x)},${Math.round(y)} (${durationMs}ms) on ${api.id}`, result };
2406
+ }
2407
+ case 'key': {
2408
+ if (!args.key) throw new Error('action=key requires key (e.g. HOME, BACK, ENTER)');
2409
+ const result = await api.input({ type: 'key', key: args.key });
2410
+ return { ok: result.ok, summary: `key ${args.key} on ${api.id}`, result };
2411
+ }
2412
+ case 'text': {
2413
+ if (typeof args.text !== 'string' || !args.text) throw new Error('action=text requires a non-empty text string');
2414
+ const result = await api.input({ type: 'text', text: args.text });
2415
+ return { ok: result.ok, summary: `typed ${args.text.length} chars on ${api.id}`, result };
2416
+ }
2417
+ case 'install': {
2418
+ const installed = await api.install(args.path);
2419
+ return { ok: true, summary: `${api.id}: installed ${installed.packageName} (${installed.label})`, result: installed };
2420
+ }
2421
+ case 'push': {
2422
+ const localPath = resolveDebugLocal(args.path, true);
2423
+ const result = await api.pushFile(localPath, args.remotePath);
2424
+ return { ok: true, summary: `pushed ${localPath} -> ${result.remotePath}`, result };
2425
+ }
2426
+ case 'pull': {
2427
+ const localPath = resolveDebugLocal(args.path, false);
2428
+ const result = await api.pullFile(args.remotePath, localPath);
2429
+ return { ok: true, summary: `pulled ${args.remotePath} -> ${result.localPath}`, result };
2430
+ }
2431
+ case 'reboot': {
2432
+ const result = await api.reboot(args.mode || '');
2433
+ return { ok: true, summary: `rebooting ${api.id} (${result.mode})`, result };
2434
+ }
2435
+ case 'root': {
2436
+ const ok = await api.ensureRoot();
2437
+ const debug = await api.status();
2438
+ return { ok, summary: `${api.id}: adb root ${ok ? 'enabled' : 'unavailable'}`, result: debug };
2439
+ }
2440
+ case 'force_stop': {
2441
+ const result = await api.forceStop(args.packageName);
2442
+ return { ok: result.ok, summary: `force-stopped ${args.packageName}`, result };
2443
+ }
2444
+ case 'clear_data': {
2445
+ const result = await api.clearData(args.packageName);
2446
+ return { ok: result.ok, summary: `cleared data ${args.packageName}`, result };
2447
+ }
2448
+ case 'uninstall': {
2449
+ const result = await api.uninstall(args.packageName);
2450
+ return { ok: result.ok, summary: `uninstalled ${args.packageName}`, result };
2451
+ }
2452
+ case 'screenshot': {
2453
+ const result = await api.screenshot();
2454
+ return { ok: true, summary: `${api.id}: screenshot saved to ${result.path} (${result.bytes} bytes)`, result };
2455
+ }
2456
+ case 'rotate': {
2457
+ const result = await api.rotate(args.mode || args.orientation || 'landscape');
2458
+ return { ok: true, summary: `rotation locked to ${result.rotation}`, result };
2459
+ }
2460
+ case 'ui_dump': {
2461
+ const result = await api.uiDump();
2462
+ return { ok: true, summary: `UI dump: ${result.nodes} nodes, ${result.bytes} bytes`, result };
2463
+ }
2464
+ case 'find_ui': {
2465
+ const dump = await api.uiDump();
2466
+ const matches = parseUiNodes(dump.xml).filter((node) => uiNodeMatches(node, args)).slice(0, 20);
2467
+ return {
2468
+ ok: matches.length > 0,
2469
+ summary: matches.length
2470
+ ? matches.map((node) => `${node.text || node.contentDesc || node.resourceId || node.className} @ ${node.center ? `${node.center.x},${node.center.y}` : '?'}`).join('\n')
2471
+ : 'no matching UI node',
2472
+ result: { count: matches.length, matches },
2473
+ };
2474
+ }
2475
+ case 'assert_text': {
2476
+ const dump = await api.uiDump();
2477
+ const matches = parseUiNodes(dump.xml).filter((node) => uiNodeMatches(node, args));
2478
+ const found = matches.length > 0;
2479
+ return {
2480
+ ok: found,
2481
+ summary: found ? `found ${matches.length} matching UI node(s)` : `assertion failed: no matching UI node for ${args.text || args.resourceId || args.contentDesc || args.className || '?'}`,
2482
+ result: { found, count: matches.length, matches: matches.slice(0, 20) },
2483
+ };
2484
+ }
2485
+ case 'tap_text': {
2486
+ const dump = await api.uiDump();
2487
+ const target = parseUiNodes(dump.xml).find((node) => uiNodeMatches(node, args) && node.center);
2488
+ if (!target) throw new Error(`tap_text: no matching UI node for ${args.text || args.resourceId || args.contentDesc || ''}`);
2489
+ const result = await api.input({ type: 'tap', x: target.center.x, y: target.center.y });
2490
+ return {
2491
+ ok: result.ok,
2492
+ summary: `tapped ${target.text || target.contentDesc || target.resourceId || target.className} at ${target.center.x},${target.center.y}`,
2493
+ result: { target, input: result },
2494
+ };
2495
+ }
2496
+ case 'clipboard_get': {
2497
+ const text = await api.getClipboard();
2498
+ return { ok: true, summary: `clipboard (${String(text).length} chars): ${String(text).slice(0, 2000)}`, result: { text } };
2499
+ }
2500
+ case 'clipboard_set': {
2501
+ if (typeof args.text !== 'string') throw new Error('action=clipboard_set requires text');
2502
+ const result = await api.setClipboard(args.text, args.paste === true);
2503
+ return { ok: true, summary: `clipboard set (${args.text.length} chars${args.paste === true ? ', paste' : ''})`, result };
2504
+ }
2505
+ case 'record': {
2506
+ const result = await api.screenRecord({ seconds: args.seconds });
2507
+ return { ok: true, summary: `recorded ${result.seconds}s to ${result.localPath}`, result };
2508
+ }
2509
+ case 'perf': {
2510
+ const result = await api.perfSnapshot();
2511
+ return { ok: true, summary: `load ${result.loadavg}, mem ${result.memFreeKb || '?'}/${result.memTotalKb || '?'} kB free`, result };
2512
+ }
2513
+ default:
2514
+ throw new Error(`unsupported apk_vm action: ${action || '(missing)'}`);
2515
+ }
2516
+ },
2517
+
2518
+ };
2519
+
2520
+ ctx.inject(['tools'], (host) => {
2521
+ host.effect(() => {
2522
+ try {
2523
+ return host.tools.register(apkVmTool);
2524
+ } catch (error) {
2525
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
2526
+ console.error('[apk-vm] apk_vm tool registration failed', error);
2527
+ }
2528
+ throw error;
2529
+ }
2530
+ }, 'dsh-apk-vm: apk_vm tool');
2531
+ });
2532
+ }