dsh-apk-vm 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * and the open-source framework choices are documented in README.md / DESIGN.md.
15
15
  */
16
16
  import { randomUUID } from 'node:crypto';
17
- import { createReadStream, existsSync } from 'node:fs';
17
+ import { createReadStream, existsSync, readFileSync } from 'node:fs';
18
18
  import { promises as fsp } from 'node:fs';
19
19
  import net from 'node:net';
20
20
  import os from 'node:os';
@@ -29,6 +29,17 @@ import { acceptWebSocket } from './lib/ws.js';
29
29
  import { parseUiHierarchy, decorateUiNodes, buildInspectorPayload } from './lib/ui-inspector.js';
30
30
  import { FlowRecorder } from './lib/flows.js';
31
31
  import { collectStorage, cleanTempFiles, cleanSnapshots, cleanOrphanAvds, formatBytes } from './lib/storage.js';
32
+ import { buildApksFromAab, aabInfo, detectBundletool } from './lib/aab.js';
33
+ import { readHarFile, summarizeHar, filterHar, toCurl } from './lib/har.js';
34
+ import { runAssertions, compareScreenshot, createTestReport, reportMarkdown } from './lib/ui-tests.js';
35
+
36
+ const PLUGIN_VERSION = (() => {
37
+ try {
38
+ return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || 'unknown';
39
+ } catch {
40
+ return 'unknown';
41
+ }
42
+ })();
32
43
 
33
44
  export const name = 'dsh-apk-vm';
34
45
 
@@ -318,6 +329,42 @@ export function apply(ctx, config = {}) {
318
329
  maxFps: Math.min(60, Math.max(1, Number(cfg.scrcpyMaxFps) || 60)),
319
330
  port: Number(cfg.scrcpyPort) || 27183,
320
331
  };
332
+ const STREAM_PRESETS = Object.freeze({
333
+ low: { maxSize: 540, bitrate: 2_500_000, maxFps: 30 },
334
+ balanced: { maxSize: 720, bitrate: 4_000_000, maxFps: 60 },
335
+ high: { maxSize: 960, bitrate: 6_000_000, maxFps: 60 },
336
+ });
337
+ let streamPreset = 'custom';
338
+ for (const [name, preset] of Object.entries(STREAM_PRESETS)) {
339
+ if (
340
+ preset.maxSize === scrcpyConfig.maxSize &&
341
+ preset.bitrate === scrcpyConfig.bitrate &&
342
+ preset.maxFps === scrcpyConfig.maxFps
343
+ ) {
344
+ streamPreset = name;
345
+ break;
346
+ }
347
+ }
348
+
349
+ async function setStreamQuality(preset) {
350
+ const name = String(preset || '').toLowerCase();
351
+ const config = STREAM_PRESETS[name];
352
+ if (!config) throw new Error(`unknown stream preset: ${preset || '(missing)'}`);
353
+ scrcpyConfig.maxSize = config.maxSize;
354
+ scrcpyConfig.bitrate = config.bitrate;
355
+ scrcpyConfig.maxFps = config.maxFps;
356
+ streamPreset = name;
357
+ const wasActive = Boolean(scrcpyState && scrcpyState.session && scrcpyState.session.state === 'running');
358
+ if (wasActive) await stopScrcpy();
359
+ return {
360
+ ok: true,
361
+ preset: name,
362
+ maxSize: config.maxSize,
363
+ bitrate: config.bitrate,
364
+ maxFps: config.maxFps,
365
+ restarted: wasActive,
366
+ };
367
+ }
321
368
  let scrcpyState = null;
322
369
  let scrcpyIdleTimer = null;
323
370
  let scrcpyInitClients = null; // WeakSet of clients that already got the init JSON
@@ -427,6 +474,9 @@ export function apply(ctx, config = {}) {
427
474
  clients: st ? st.clients.size : 0,
428
475
  fps: st ? st.fps : 0,
429
476
  maxFps: scrcpyConfig.maxFps,
477
+ maxSize: scrcpyConfig.maxSize,
478
+ bitrate: scrcpyConfig.bitrate,
479
+ preset: streamPreset,
430
480
  width: st && st.dims ? st.dims.width : null,
431
481
  height: st && st.dims ? st.dims.height : null,
432
482
  error: st && st.lastError ? String(st.lastError.message || st.lastError) : null,
@@ -612,12 +662,23 @@ export function apply(ctx, config = {}) {
612
662
  let apkPath;
613
663
  if (options.trustedPath === true) {
614
664
  apkPath = path.resolve(String(candidate || ''));
615
- if (!/\.(?:apk|apks|xapk)$/i.test(apkPath) || !existsSync(apkPath)) {
665
+ if (!/\.(?:apk|apks|xapk|aab)$/i.test(apkPath) || !existsSync(apkPath)) {
616
666
  throw new Error(`uploaded APK/bundle is missing or invalid: ${apkPath}`);
617
667
  }
618
668
  } else {
619
669
  apkPath = resolveApkPath(candidate, rootsFor());
620
670
  }
671
+ if (/\.aab$/i.test(apkPath)) {
672
+ const built = await buildApksFromAab({
673
+ aabPath: apkPath,
674
+ outDir: path.join(os.tmpdir(), 'dsh-apk-vm-aab'),
675
+ mode: 'universal',
676
+ bundletoolPath: options.bundletoolPath,
677
+ javaPath: options.javaPath,
678
+ });
679
+ if (!built.ok) throw new Error(built.error || 'AAB conversion failed; install bundletool and retry');
680
+ apkPath = built.apksPath;
681
+ }
621
682
  const status = await vm.status();
622
683
  if (!isReady(status)) throw new Error(`VM is not ready (state: ${status.state}); start it first`);
623
684
  const meta = await vm.installApk(apkPath);
@@ -747,6 +808,7 @@ export function apply(ctx, config = {}) {
747
808
  maybeAutoStart(status);
748
809
  return {
749
810
  ok: true,
811
+ pluginVersion: PLUGIN_VERSION,
750
812
  vm: {
751
813
  state: status.state,
752
814
  serial: status.serial || null,
@@ -980,7 +1042,7 @@ export function apply(ctx, config = {}) {
980
1042
  return;
981
1043
  }
982
1044
  if (action === 'install-image') {
983
- if (cfg.allowSetupDownloads !== true) {
1045
+ if (cfg.allowSetupDownloads !== true && body.confirmDownload !== true) {
984
1046
  sendError(res, 403, 'downloading Android system images is disabled; set allowSetupDownloads: true to enable it');
985
1047
  return;
986
1048
  }
@@ -994,7 +1056,7 @@ export function apply(ctx, config = {}) {
994
1056
  return;
995
1057
  }
996
1058
  if (action === 'install-tools') {
997
- if (cfg.allowSetupDownloads !== true) {
1059
+ if (cfg.allowSetupDownloads !== true && body.confirmDownload !== true) {
998
1060
  sendError(res, 403, 'downloading Android SDK components is disabled; set allowSetupDownloads: true to enable it');
999
1061
  return;
1000
1062
  }
@@ -1013,7 +1075,7 @@ export function apply(ctx, config = {}) {
1013
1075
  return;
1014
1076
  }
1015
1077
  if (action === 'bootstrap-sdk') {
1016
- if (cfg.allowSetupDownloads !== true) {
1078
+ if (cfg.allowSetupDownloads !== true && body.confirmDownload !== true) {
1017
1079
  sendError(res, 403, 'downloading Android SDK components is disabled; set allowSetupDownloads: true to enable it');
1018
1080
  return;
1019
1081
  }
@@ -1127,6 +1189,7 @@ export function apply(ctx, config = {}) {
1127
1189
  }
1128
1190
  return {
1129
1191
  ok: true,
1192
+ pluginVersion: PLUGIN_VERSION,
1130
1193
  instance: { id: instance.id, kind: 'extra', avdName: instance.avdName, port: instance.port },
1131
1194
  vm: {
1132
1195
  state: status.state,
@@ -1224,6 +1287,27 @@ export function apply(ctx, config = {}) {
1224
1287
  });
1225
1288
  return;
1226
1289
  }
1290
+ if (rest === 'snapshots') {
1291
+ if (req.method === 'GET') {
1292
+ sendJson(res, 200, instance.vm.listNamedSnapshots());
1293
+ return;
1294
+ }
1295
+ if (req.method !== 'POST') {
1296
+ sendError(res, 405, 'GET or POST only');
1297
+ return;
1298
+ }
1299
+ const body = await readJsonBody(req);
1300
+ const action = String(body.action || '').toLowerCase();
1301
+ try {
1302
+ if (action === 'save') sendJson(res, 200, await instance.vm.saveNamedSnapshot(body.name));
1303
+ else if (action === 'load') sendJson(res, 200, await instance.vm.loadNamedSnapshot(body.name));
1304
+ else if (action === 'delete') sendJson(res, 200, await instance.vm.deleteNamedSnapshot(body.name));
1305
+ else throw new Error(`unsupported snapshot action: ${action || '(missing)'}`);
1306
+ } catch (error) {
1307
+ sendError(res, 400, error.message);
1308
+ }
1309
+ return;
1310
+ }
1227
1311
  if (rest === 'network') {
1228
1312
  if (!(await ready())) return;
1229
1313
  if (req.method === 'GET') {
@@ -1318,7 +1402,7 @@ export function apply(ctx, config = {}) {
1318
1402
  name = 'upload.apk';
1319
1403
  }
1320
1404
  name = path.basename(name).replace(/[^A-Za-z0-9._-]+/g, '_') || 'upload.apk';
1321
- if (!/\.apk$/i.test(name)) name += '.apk';
1405
+ if (!/\.(?:apk|aab)$/i.test(name)) name += '.apk';
1322
1406
  const data = await readRawBody(req, UPLOAD_LIMIT_BYTES);
1323
1407
  if (data.length < 4 || data[0] !== 0x50 || data[1] !== 0x4b) {
1324
1408
  sendError(res, 400, 'uploaded file is not a ZIP/APK');
@@ -1443,6 +1527,10 @@ export function apply(ctx, config = {}) {
1443
1527
  if (action === 'force-stop') result = await instance.forceStop(body.packageName);
1444
1528
  else if (action === 'clear-data') result = await instance.clearData(body.packageName);
1445
1529
  else if (action === 'uninstall') result = await instance.uninstall(body.packageName);
1530
+ else if (action === 'launch') result = await instance.vm.launchPackage(body.packageName, body.activity);
1531
+ else if (action === 'open-url') result = await instance.vm.openUrl(body.url);
1532
+ else if (action === 'grant') result = await instance.vm.setPermission(body.packageName, body.permission, true);
1533
+ else if (action === 'revoke') result = await instance.vm.setPermission(body.packageName, body.permission, false);
1446
1534
  else {
1447
1535
  sendError(res, 400, `unsupported debug action: ${action || '(missing)'}`);
1448
1536
  return;
@@ -1509,6 +1597,7 @@ export function apply(ctx, config = {}) {
1509
1597
  'flows',
1510
1598
  'network',
1511
1599
  'network/pcap',
1600
+ 'snapshots',
1512
1601
  ];
1513
1602
  const disposers = [];
1514
1603
  for (const suffix of suffixes) {
@@ -1781,7 +1870,7 @@ export function apply(ctx, config = {}) {
1781
1870
  }
1782
1871
  let safeName = path.basename(rawName || 'upload.apk').replace(/[^A-Za-z0-9._-]+/g, '_');
1783
1872
  if (!safeName) safeName = 'upload.apk';
1784
- if (!/\.(?:apk|apks|xapk)$/i.test(safeName)) safeName += '.apk';
1873
+ if (!/\.(?:apk|apks|xapk|aab)$/i.test(safeName)) safeName += '.apk';
1785
1874
  let data;
1786
1875
  try {
1787
1876
  data = await readRawBody(req, UPLOAD_LIMIT_BYTES);
@@ -1930,6 +2019,19 @@ export function apply(ctx, config = {}) {
1930
2019
  sendJson(res, 200, { ok: true, fps: streamFps });
1931
2020
  });
1932
2021
 
2022
+ register('/apk-vm/stream/quality', async (req, res) => {
2023
+ if (req.method !== 'POST') {
2024
+ sendError(res, 405, 'POST only');
2025
+ return;
2026
+ }
2027
+ const body = await readJsonBody(req);
2028
+ try {
2029
+ sendJson(res, 200, await setStreamQuality(body.preset));
2030
+ } catch (error) {
2031
+ sendError(res, 400, error.message);
2032
+ }
2033
+ });
2034
+
1933
2035
  /* ---- rooted debug surface ---------------------------------------- *
1934
2036
  * These routes (and the `apk_vm` Agent tool) expose the guest's root
1935
2037
  * shell and low-level device operations to DSH. They are local-only,
@@ -2000,6 +2102,43 @@ export function apply(ctx, config = {}) {
2000
2102
  sendError(res, 405, 'GET or POST only');
2001
2103
  });
2002
2104
 
2105
+ register('/apk-vm/ui-test', async (req, res) => {
2106
+ if (req.method !== 'POST') {
2107
+ sendError(res, 405, 'POST only');
2108
+ return;
2109
+ }
2110
+ if (!(await ensureDebugReady(res))) return;
2111
+ const body = await readJsonBody(req);
2112
+ const name = String(body.name || 'ui-check').slice(0, 80);
2113
+ const started = Date.now();
2114
+ try {
2115
+ const dump = await vm.uiDump();
2116
+ const assertions = runAssertions(dump.xml, Array.isArray(body.assertions) ? body.assertions : []);
2117
+ const png = await vm.screenshot();
2118
+ const visual = await compareScreenshot(
2119
+ png,
2120
+ path.join(os.tmpdir(), 'dsh-apk-vm-baselines'),
2121
+ name,
2122
+ { update: body.updateBaseline === true },
2123
+ );
2124
+ const report = createTestReport({
2125
+ name,
2126
+ instanceId: 'default',
2127
+ assertions,
2128
+ visual,
2129
+ durationMs: Date.now() - started,
2130
+ });
2131
+ const reportBody = report && report.report ? report.report : report;
2132
+ sendJson(res, 200, {
2133
+ ok: assertions.ok !== false && visual.ok !== false,
2134
+ report: reportBody,
2135
+ markdown: (report && report.markdown) || reportMarkdown(reportBody),
2136
+ });
2137
+ } catch (error) {
2138
+ sendError(res, 400, error.message);
2139
+ }
2140
+ });
2141
+
2003
2142
  register('/apk-vm/debug/packages', async (req, res) => {
2004
2143
  if (req.method !== 'GET') {
2005
2144
  sendError(res, 405, 'GET only');
@@ -2065,6 +2204,10 @@ export function apply(ctx, config = {}) {
2065
2204
  if (action === 'force-stop') result = await vm.forceStop(packageName);
2066
2205
  else if (action === 'clear-data') result = await vm.clearData(packageName);
2067
2206
  else if (action === 'uninstall') result = await vm.uninstall(packageName);
2207
+ else if (action === 'launch') result = await vm.launchPackage(packageName, body.activity);
2208
+ else if (action === 'open-url') result = await vm.openUrl(body.url);
2209
+ else if (action === 'grant') result = await vm.setPermission(packageName, body.permission, true);
2210
+ else if (action === 'revoke') result = await vm.setPermission(packageName, body.permission, false);
2068
2211
  else {
2069
2212
  sendError(res, 400, `unsupported debug action: ${action || '(missing)'}`);
2070
2213
  return;
@@ -2072,6 +2215,27 @@ export function apply(ctx, config = {}) {
2072
2215
  sendJson(res, 200, { ok: result.ok === true, action, result });
2073
2216
  });
2074
2217
 
2218
+ register('/apk-vm/snapshots', async (req, res) => {
2219
+ if (req.method === 'GET') {
2220
+ sendJson(res, 200, vm.listNamedSnapshots());
2221
+ return;
2222
+ }
2223
+ if (req.method !== 'POST') {
2224
+ sendError(res, 405, 'GET or POST only');
2225
+ return;
2226
+ }
2227
+ const body = await readJsonBody(req);
2228
+ const action = String(body.action || '').toLowerCase();
2229
+ try {
2230
+ if (action === 'save') sendJson(res, 200, await vm.saveNamedSnapshot(body.name));
2231
+ else if (action === 'load') sendJson(res, 200, await vm.loadNamedSnapshot(body.name));
2232
+ else if (action === 'delete') sendJson(res, 200, await vm.deleteNamedSnapshot(body.name));
2233
+ else throw new Error(`unsupported snapshot action: ${action || '(missing)'}`);
2234
+ } catch (error) {
2235
+ sendError(res, 400, error.message);
2236
+ }
2237
+ });
2238
+
2075
2239
  register('/apk-vm/debug/rotate', async (req, res) => {
2076
2240
  if (req.method !== 'POST') {
2077
2241
  sendError(res, 405, 'POST only');
@@ -2143,6 +2307,37 @@ export function apply(ctx, config = {}) {
2143
2307
  }
2144
2308
  });
2145
2309
 
2310
+ register('/apk-vm/network/har', async (req, res) => {
2311
+ if (req.method !== 'POST') {
2312
+ sendError(res, 405, 'POST only');
2313
+ return;
2314
+ }
2315
+ const body = await readJsonBody(req);
2316
+ try {
2317
+ const loaded = await readHarFile(body.path);
2318
+ const entries = filterHar(loaded.entries, {
2319
+ method: body.method,
2320
+ status: body.status,
2321
+ domain: body.domain,
2322
+ search: body.search,
2323
+ });
2324
+ const sliced = entries.slice(0, 500).map((entry) => ({
2325
+ ...entry,
2326
+ curl: toCurl(entry),
2327
+ }));
2328
+ sendJson(res, 200, {
2329
+ ok: true,
2330
+ path: loaded.file,
2331
+ bytes: loaded.bytes,
2332
+ summary: summarizeHar(loaded.entries),
2333
+ filtered: entries.length,
2334
+ entries: sliced,
2335
+ });
2336
+ } catch (error) {
2337
+ sendError(res, 400, error.message);
2338
+ }
2339
+ });
2340
+
2146
2341
  register('/apk-vm/network', async (req, res) => {
2147
2342
  if (req.method === 'GET') {
2148
2343
  if (!(await ensureDebugReady(res))) return;