dsh-apk-vm 0.3.13 → 0.4.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.
@@ -0,0 +1,81 @@
1
+ # Linux end-to-end validation
2
+
3
+ A real headless Linux run was performed on Ubuntu 22.04.5 LTS (kernel 6.8, x86_64),
4
+ using the plugin's own `AndroidVm` and `ScrcpySession` code. This is not a mock or a
5
+ CI-only check.
6
+
7
+ ## Host
8
+
9
+ | Item | Value |
10
+ | --- | --- |
11
+ | OS | Ubuntu 22.04.5 LTS, x86_64 |
12
+ | CPU | 192 cores, KVM v12 (`/dev/kvm` usable) |
13
+ | RAM | 94 GiB |
14
+ | Display | none (`DISPLAY` unset) |
15
+ | Node | v24.22.1 |
16
+ | Java | OpenJDK 17 JRE |
17
+ | Android SDK | cmdline-tools 12.0, platform-tools 37.0.1, emulator 37.2.12.0, build-tools 34.0.0 |
18
+ | System image | `system-images;android-34;default;x86_64` |
19
+ | AVD | `dsh_linux_test` (1080×2340, pixel_5) |
20
+
21
+ The regular `OPENCODE` account is **not in the `kvm` group** and has no passwordless sudo;
22
+ `/dev/kvm` is denied for it. The emulator portion was therefore run as the provided
23
+ `OPENCODE_100` account (same SSH key), which is in the `kvm` group. The plugin itself never
24
+ requires sudo or system-wide changes.
25
+
26
+ ## Emulator launch
27
+
28
+ ```text
29
+ qemu-system-x86_64-headless -avd dsh_linux_test -no-window -no-audio -no-boot-anim \
30
+ -no-snapshot -gpu swiftshader_indirect -port 5558
31
+ ```
32
+
33
+ - KVM detected: **`accel=true`**
34
+ - Cold boot: **35.2 s** (first run 49.0 s)
35
+ - adb serial: `emulator-5558`, API 34, userdebug, 1080×2340
36
+ - `adb root`: **uid=0**
37
+
38
+ ## Verified on Linux
39
+
40
+ - `AndroidVm` lifecycle: start → attach → boot probe → ready; state and boot time.
41
+ - `adb root` and the on-device debug toolkit.
42
+ - Real signed APK install + auto-launch; UIAutomator dump; input tap; result text assertion.
43
+ - PNG screenshot (`exec-out screencap -p`, 46,650 bytes in 415 ms).
44
+ - HTTP proxy set / clear.
45
+ - CA certificate installation (`73393196.0`, user CA store).
46
+ - `ScrcpySession` 5.0.1 H.264: `avc1.42C029`, 332×720, 30 media packets, 1 keyframe,
47
+ 9,057-byte capture; scrcpy control swipe produced new video.
48
+ - `screenrecord` 3 s MP4.
49
+ - `stop()` safety: a pre-existing/auto-attached emulator is left running, not killed.
50
+
51
+ ## Test suite
52
+
53
+ On the validated snapshot:
54
+
55
+ - `npm run check` — pass
56
+ - `npm test` — pass (UI/sidebar, setup 32/32, mock-device 25/25, launch-plan, instances,
57
+ features 4/4)
58
+ - `spike/scrcpy/test-fake-device.js` — 160/160 pass
59
+
60
+ The final tree should be re-run in CI for the release tag.
61
+
62
+ ## Known limitations
63
+
64
+ - `quickBoot` snapshots were not exercised on Linux (tests used `-no-snapshot` to save disk).
65
+ - Headless `swiftshader_indirect` can print non-fatal `ColorBuffer` warnings while taking
66
+ screenshots; the screenshots still succeed.
67
+ - The emulator prints a metrics-collection warning; the launch args now include
68
+ `-no-metrics`.
69
+ - `OPENCODE` without `kvm` group membership gets the documented "No usable CPU hypervisor"
70
+ error instead of a hung boot. Add the user to `kvm` (or use an account that has it) for
71
+ hardware acceleration.
72
+ - The platform still requires an Android SDK and AVD; only the SDK install and test harness
73
+ were performed as a separate account on this shared host.
74
+
75
+ ## Cross-platform status
76
+
77
+ | Platform | Status |
78
+ | --- | --- |
79
+ | Windows x64 | end-to-end verified (AEHD, live panel, H.264, Agent tool) |
80
+ | Linux x86_64 | end-to-end verified headless (KVM + SwiftShader, H.264, root, APK install) |
81
+ | macOS | SDK discovery + headless CI only; no live emulator run in this repository |
package/docs/README.md CHANGED
@@ -14,3 +14,18 @@ Still optional:
14
14
 
15
15
  The assets are not fabricated. Before adding a new screenshot, crop out or redact the DSH
16
16
  sidebar conversation names, local paths and any LAN IPs.
17
+
18
+ ## Reproducible demo source
19
+
20
+ [`examples/`](../examples/README.md) contains a self-contained, dependency-free demo:
21
+
22
+ - a tiny MIT-licensed sample APK whose sources and deterministic SDK-only build script are
23
+ checked in (`examples/sample-apk/`),
24
+ - `examples/demo.mjs`, which drives the documented local HTTP API through state → install →
25
+ UI inspection → tap → text assertion → screenshot,
26
+ - exact commands, expected output and troubleshooting in
27
+ [`examples/README.md`](../examples/README.md).
28
+
29
+ The sample screenshot at [`examples/sample-app.png`](../examples/sample-app.png) is the app
30
+ running in the VM, not a desktop capture.
31
+
@@ -25,6 +25,8 @@ npm 包安装(插件中心/社区市场推荐命令):
25
25
  dsh plugin --profile web add dsh-apk-vm
26
26
  ```
27
27
 
28
+ npm 包:<https://www.npmjs.com/package/dsh-apk-vm>
29
+
28
30
  源码 fallback:
29
31
 
30
32
  ```bash
@@ -36,8 +38,9 @@ DSH Desktop(`desktop` profile)通过 **Plugins → add/install local bundle*
36
38
  ### 兼容与运行要求
37
39
 
38
40
  - DSH Desktop `0.2.1-rc.3` 已实测;静态 bundle/plugin API。
39
- - Windows x64 为唯一端到端验证平台(启动、H.264、root 调试、Agent tool);macOS/Linux 有
40
- headless setup/CI 覆盖,但未做真实模拟器运行验证。
41
+ - Windows x64 与 Linux x86_64(headless,KVM + SwiftShader)为端到端验证平台(启动、
42
+ H.264、root 调试、APK 安装、Agent tool);macOS 有 SDK 探测/headless CI 覆盖,但未做
43
+ 真实模拟器运行验证。详见 `docs/LINUX-E2E.md`。
41
44
  - 需要 Android SDK/emulator/platform-tools、Android 14 x86_64 system image、AVD,以及
42
45
  AEHD/WHPX(Windows)、KVM(Linux)或 HVF(macOS)硬件虚拟化。
43
46
  - 每个运行中的 VM 约占 2.5 GB 内存;默认最多 3 个额外实例。
package/index.js CHANGED
@@ -26,6 +26,8 @@ import { InstanceManager } from './lib/instances.js';
26
26
  import { ScrcpySession, annexbToAvcc, CONTROL_MESSAGE_TYPE } from './lib/scrcpy.js';
27
27
  import { detectEnvironment, createAvd, installSystemImage, installSdkPackages, bootstrapSdk, DEFAULT_SYSTEM_IMAGE } from './lib/setup.js';
28
28
  import { acceptWebSocket } from './lib/ws.js';
29
+ import { parseUiHierarchy, decorateUiNodes, buildInspectorPayload } from './lib/ui-inspector.js';
30
+ import { FlowRecorder } from './lib/flows.js';
29
31
 
30
32
  export const name = 'dsh-apk-vm';
31
33
 
@@ -207,7 +209,7 @@ function resolveApkPath(candidate, roots) {
207
209
  for (const p of paths) {
208
210
  try {
209
211
  const resolved = path.resolve(p);
210
- if (!resolved.toLowerCase().endsWith('.apk')) continue;
212
+ if (!/\.(?:apk|apks|xapk)$/i.test(resolved)) continue;
211
213
  if (existsSync(resolved)) return resolved;
212
214
  } catch {
213
215
  /* try next */
@@ -269,6 +271,7 @@ export function apply(ctx, config = {}) {
269
271
  let serverOrigin = null;
270
272
  let registerInstanceRoutes = null;
271
273
  let disposeInstanceRoutes = null;
274
+ const flowRecorder = new FlowRecorder();
272
275
  if (instanceManager) {
273
276
  instanceManager.onCreated = (id) => {
274
277
  registerInstanceRoutes?.(id);
@@ -602,8 +605,8 @@ export function apply(ctx, config = {}) {
602
605
  let apkPath;
603
606
  if (options.trustedPath === true) {
604
607
  apkPath = path.resolve(String(candidate || ''));
605
- if (!apkPath.toLowerCase().endsWith('.apk') || !existsSync(apkPath)) {
606
- throw new Error(`uploaded APK is missing or invalid: ${apkPath}`);
608
+ if (!/\.(?:apk|apks|xapk)$/i.test(apkPath) || !existsSync(apkPath)) {
609
+ throw new Error(`uploaded APK/bundle is missing or invalid: ${apkPath}`);
607
610
  }
608
611
  } else {
609
612
  apkPath = resolveApkPath(candidate, rootsFor());
@@ -618,6 +621,7 @@ export function apply(ctx, config = {}) {
618
621
  launchActivity: meta.launchActivity || null,
619
622
  launched: meta.launched !== false,
620
623
  installedAt: Date.now(),
624
+ bundle: meta.bundle || null,
621
625
  };
622
626
  frame.buffer = null;
623
627
  frame.at = 0;
@@ -811,6 +815,59 @@ export function apply(ctx, config = {}) {
811
815
  );
812
816
  };
813
817
 
818
+ async function handleFlowRequest(req, res, instanceId, inputFn) {
819
+ if (req.method === 'GET') {
820
+ sendJson(res, 200, {
821
+ ok: true,
822
+ instance: instanceId,
823
+ recording: flowRecorder.isRecording(instanceId),
824
+ flows: await flowRecorder.list(instanceId),
825
+ });
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
+ try {
835
+ if (action === 'start') {
836
+ const flow = flowRecorder.start(instanceId, body.name);
837
+ sendJson(res, 200, { ok: true, action, ...flow });
838
+ return;
839
+ }
840
+ if (action === 'stop') {
841
+ const flow = await flowRecorder.stop(instanceId);
842
+ sendJson(res, 200, { ok: true, action, ...flow });
843
+ return;
844
+ }
845
+ if (action === 'replay') {
846
+ if (!body.id) throw new Error('replay requires id');
847
+ const result = await flowRecorder.replay(body.id, inputFn, { instanceId });
848
+ sendJson(res, 200, { ok: true, action, ...result });
849
+ return;
850
+ }
851
+ if (action === 'delete') {
852
+ if (!body.id) throw new Error('delete requires id');
853
+ const removed = await flowRecorder.remove(body.id, instanceId);
854
+ sendJson(res, 200, { ok: true, action, removed });
855
+ return;
856
+ }
857
+ throw new Error(`unsupported flow action: ${action || '(missing)'}`);
858
+ } catch (error) {
859
+ sendError(res, 400, error.message);
860
+ }
861
+ }
862
+
863
+ register('/apk-vm/flows', async (req, res) => {
864
+ await handleFlowRequest(req, res, 'default', async (payload) => {
865
+ const status = await vm.status();
866
+ if (!isReady(status)) throw new Error(`VM is not ready (state: ${status.state})`);
867
+ return vm.input(payload);
868
+ });
869
+ });
870
+
814
871
  register('/apk-vm/state', async (req, res) => {
815
872
  if (req.method !== 'GET' && req.method !== 'HEAD') {
816
873
  sendError(res, 405, 'GET only');
@@ -1077,10 +1134,20 @@ export function apply(ctx, config = {}) {
1077
1134
  }
1078
1135
  if (rest === 'input' && req.method === 'POST') {
1079
1136
  if (!(await ready())) return;
1080
- await instance.input(await readJsonBody(req));
1137
+ const body = await readJsonBody(req);
1138
+ flowRecorder.record(id, body);
1139
+ await instance.input(body);
1081
1140
  sendJson(res, 200, { ok: true });
1082
1141
  return;
1083
1142
  }
1143
+ if (rest === 'flows') {
1144
+ await handleFlowRequest(req, res, id, async (payload) => {
1145
+ const status = await instance.status();
1146
+ if (status.state !== 'ready') throw new Error(`instance ${id} is not ready (state: ${status.state})`);
1147
+ return instance.input(payload);
1148
+ });
1149
+ return;
1150
+ }
1084
1151
  if (rest === 'touch' && req.method === 'POST') {
1085
1152
  const body = await readJsonBody(req);
1086
1153
  const action = String(body.action || '').toLowerCase();
@@ -1188,6 +1255,39 @@ export function apply(ctx, config = {}) {
1188
1255
  sendJson(res, 200, await instance.uiDump());
1189
1256
  return;
1190
1257
  }
1258
+ if (op === 'inspect' && req.method === 'GET') {
1259
+ if (!(await ready())) return;
1260
+ const dump = await instance.uiDump();
1261
+ let screen = null;
1262
+ try {
1263
+ screen = await instance.vm.screenSize();
1264
+ } catch {
1265
+ screen = null;
1266
+ }
1267
+ sendJson(res, 200, buildInspectorPayload(dump, screen));
1268
+ return;
1269
+ }
1270
+ if (op === 'proxy' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
1271
+ if (!(await ready())) return;
1272
+ try {
1273
+ if (req.method === 'GET') sendJson(res, 200, await instance.vm.getProxy());
1274
+ else if (req.method === 'DELETE') sendJson(res, 200, await instance.vm.setProxy({ enabled: false }));
1275
+ else sendJson(res, 200, await instance.vm.setProxy(await readJsonBody(req)));
1276
+ } catch (error) {
1277
+ sendError(res, 400, error.message);
1278
+ }
1279
+ return;
1280
+ }
1281
+ if (op === 'ca' && req.method === 'POST') {
1282
+ if (!(await ready())) return;
1283
+ try {
1284
+ const body = await readJsonBody(req);
1285
+ sendJson(res, 200, await instance.vm.installCaCertificate(body.path));
1286
+ } catch (error) {
1287
+ sendError(res, 400, error.message);
1288
+ }
1289
+ return;
1290
+ }
1191
1291
  if (op === 'record' && req.method === 'POST') {
1192
1292
  if (!(await ready())) return;
1193
1293
  sendJson(res, 200, await instance.screenRecord({ seconds: (await readJsonBody(req)).seconds }));
@@ -1271,6 +1371,9 @@ export function apply(ctx, config = {}) {
1271
1371
  'debug/root',
1272
1372
  'debug/rotate',
1273
1373
  'debug/ui',
1374
+ 'debug/inspect',
1375
+ 'debug/proxy',
1376
+ 'debug/ca',
1274
1377
  'debug/record',
1275
1378
  'debug/perf',
1276
1379
  'debug/screenshot',
@@ -1278,6 +1381,7 @@ export function apply(ctx, config = {}) {
1278
1381
  'debug/push',
1279
1382
  'debug/pull',
1280
1383
  'debug/reboot',
1384
+ 'flows',
1281
1385
  ];
1282
1386
  const disposers = [];
1283
1387
  for (const suffix of suffixes) {
@@ -1550,7 +1654,7 @@ export function apply(ctx, config = {}) {
1550
1654
  }
1551
1655
  let safeName = path.basename(rawName || 'upload.apk').replace(/[^A-Za-z0-9._-]+/g, '_');
1552
1656
  if (!safeName) safeName = 'upload.apk';
1553
- if (!/\.apk$/i.test(safeName)) safeName += '.apk';
1657
+ if (!/\.(?:apk|apks|xapk)$/i.test(safeName)) safeName += '.apk';
1554
1658
  let data;
1555
1659
  try {
1556
1660
  data = await readRawBody(req, UPLOAD_LIMIT_BYTES);
@@ -1616,6 +1720,7 @@ export function apply(ctx, config = {}) {
1616
1720
  sendError(res, 409, `VM is not ready (state: ${status.state})`);
1617
1721
  return;
1618
1722
  }
1723
+ flowRecorder.record('default', body);
1619
1724
  await vm.input(body);
1620
1725
  sendJson(res, 200, { ok: true });
1621
1726
  });
@@ -1859,6 +1964,58 @@ export function apply(ctx, config = {}) {
1859
1964
  sendJson(res, 200, await vm.uiDump());
1860
1965
  });
1861
1966
 
1967
+ register('/apk-vm/debug/inspect', async (req, res) => {
1968
+ if (req.method !== 'GET') {
1969
+ sendError(res, 405, 'GET only');
1970
+ return;
1971
+ }
1972
+ if (!(await ensureDebugReady(res))) return;
1973
+ const dump = await vm.uiDump();
1974
+ let screen = null;
1975
+ try {
1976
+ screen = await vm.screenSize();
1977
+ } catch {
1978
+ screen = null;
1979
+ }
1980
+ sendJson(res, 200, buildInspectorPayload(dump, screen));
1981
+ });
1982
+
1983
+ register('/apk-vm/debug/proxy', async (req, res) => {
1984
+ if (!['GET', 'POST', 'DELETE'].includes(req.method)) {
1985
+ sendError(res, 405, 'GET, POST or DELETE only');
1986
+ return;
1987
+ }
1988
+ if (!(await ensureDebugReady(res))) return;
1989
+ try {
1990
+ if (req.method === 'GET') {
1991
+ sendJson(res, 200, await vm.getProxy());
1992
+ return;
1993
+ }
1994
+ if (req.method === 'DELETE') {
1995
+ sendJson(res, 200, await vm.setProxy({ enabled: false }));
1996
+ return;
1997
+ }
1998
+ const body = await readJsonBody(req);
1999
+ sendJson(res, 200, await vm.setProxy(body));
2000
+ } catch (error) {
2001
+ sendError(res, 400, error.message);
2002
+ }
2003
+ });
2004
+
2005
+ register('/apk-vm/debug/ca', async (req, res) => {
2006
+ if (req.method !== 'POST') {
2007
+ sendError(res, 405, 'POST only');
2008
+ return;
2009
+ }
2010
+ if (!(await ensureDebugReady(res))) return;
2011
+ try {
2012
+ const body = await readJsonBody(req);
2013
+ sendJson(res, 200, await vm.installCaCertificate(body.path));
2014
+ } catch (error) {
2015
+ sendError(res, 400, error.message);
2016
+ }
2017
+ });
2018
+
1862
2019
  register('/apk-vm/debug/record', async (req, res) => {
1863
2020
  if (req.method !== 'POST') {
1864
2021
  sendError(res, 405, 'POST only');
@@ -2039,7 +2196,10 @@ export function apply(ctx, config = {}) {
2039
2196
  getprop: (name) => vm.getprop(name),
2040
2197
  setprop: (name, value) => vm.setprop(name, value),
2041
2198
  listPackages: (options) => vm.listPackages(options),
2042
- input: (payload) => vm.input(payload),
2199
+ input: (payload) => {
2200
+ flowRecorder.record('default', payload);
2201
+ return vm.input(payload);
2202
+ },
2043
2203
  getClipboard: async () => {
2044
2204
  const session = scrcpyState && scrcpyState.session;
2045
2205
  if (!session || session.state !== 'running') throw new Error('H.264 control stream is not running');
@@ -2059,6 +2219,10 @@ export function apply(ctx, config = {}) {
2059
2219
  uninstall: (pkg) => vm.uninstall(pkg),
2060
2220
  rotate: (orientation) => vm.rotate(orientation),
2061
2221
  uiDump: () => vm.uiDump(),
2222
+ screenSize: () => vm.screenSize(),
2223
+ getProxy: () => vm.getProxy(),
2224
+ setProxy: (options) => vm.setProxy(options),
2225
+ installCaCertificate: (certPath) => vm.installCaCertificate(certPath),
2062
2226
  screenRecord: (options) => vm.screenRecord(options),
2063
2227
  perfSnapshot: () => vm.perfSnapshot(),
2064
2228
  screenshot: async () => {
@@ -2084,7 +2248,10 @@ export function apply(ctx, config = {}) {
2084
2248
  getprop: (name) => instance.getprop(name),
2085
2249
  setprop: (name, value) => instance.setprop(name, value),
2086
2250
  listPackages: (options) => instance.listPackages(options),
2087
- input: (payload) => instance.input(payload),
2251
+ input: (payload) => {
2252
+ flowRecorder.record(instance.id, payload);
2253
+ return instance.input(payload);
2254
+ },
2088
2255
  getClipboard: () => instance.getClipboard(),
2089
2256
  setClipboard: (text, paste) => instance.setClipboard(text, paste),
2090
2257
  install: async (apkPath) => instance.installApk(resolveApkPath(apkPath, rootsFor())),
@@ -2096,6 +2263,10 @@ export function apply(ctx, config = {}) {
2096
2263
  uninstall: (pkg) => instance.uninstall(pkg),
2097
2264
  rotate: (orientation) => instance.rotate(orientation),
2098
2265
  uiDump: () => instance.uiDump(),
2266
+ screenSize: () => instance.vm.screenSize(),
2267
+ getProxy: () => instance.vm.getProxy(),
2268
+ setProxy: (options) => instance.vm.setProxy(options),
2269
+ installCaCertificate: (certPath) => instance.vm.installCaCertificate(certPath),
2099
2270
  screenRecord: (options) => instance.screenRecord(options),
2100
2271
  perfSnapshot: () => instance.perfSnapshot(),
2101
2272
  screenshot: async () => {
@@ -2197,8 +2368,8 @@ export function apply(ctx, config = {}) {
2197
2368
  'Operate the local rooted Android APK virtual machine (Android emulator) that the APK VM panel manages. ' +
2198
2369
  'The VM is usually already running (warm start); adbd runs as root once ready. ' +
2199
2370
  '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. ' +
2371
+ 'force_stop, clear_data, uninstall, rotate, ui_dump, inspect_ui, proxy, install_ca, record, perf, screenshot, tap, swipe, long_press, key, text, ' +
2372
+ '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, flow_start, flow_stop, flow_list, flow_replay. ' +
2202
2373
  'Multi-instance: pass `instance` to target an isolated VM; omit it to use the calling Agent\'s ' +
2203
2374
  'assigned instance (first Agent gets the default VM, later Agents get isolated instances). ' +
2204
2375
  'Use shell for low-level Android commands, logcat for logs, ' +
@@ -2211,7 +2382,7 @@ export function apply(ctx, config = {}) {
2211
2382
  properties: {
2212
2383
  action: {
2213
2384
  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',
2385
+ description: 'info|shell|logcat|getprop|setprop|packages|install|push|pull|reboot|root|force_stop|clear_data|uninstall|rotate|ui_dump|inspect_ui|proxy|install_ca|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|flow_start|flow_stop|flow_list|flow_replay',
2215
2386
  },
2216
2387
  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
2388
  instanceName: { type: 'string', description: 'create_instance: optional name for the new isolated instance.' },
@@ -2243,6 +2414,10 @@ export function apply(ctx, config = {}) {
2243
2414
  className: { type: 'string', description: 'find_ui: substring of the Android class name.' },
2244
2415
  paste: { type: 'boolean', description: 'clipboard_set: also paste immediately (scrcpy paste flag).' },
2245
2416
  serial: { type: 'string', description: 'attach_device: adb serial of an online physical/remote device; detach_device ignores it.' },
2417
+ host: { type: 'string', description: 'proxy: HTTP proxy host (the emulator host alias reaches the host machine).' },
2418
+ port: { type: 'integer', description: 'proxy: HTTP proxy port (1-65535).' },
2419
+ bypass: { type: 'string', description: 'proxy: optional no-proxy/exclusion list.' },
2420
+ flowId: { type: 'string', description: 'flow_replay: saved flow id from flow_list.' },
2246
2421
  },
2247
2422
  required: ['action'],
2248
2423
  },
@@ -2346,6 +2521,29 @@ export function apply(ctx, config = {}) {
2346
2521
  }
2347
2522
 
2348
2523
  const api = await resolveToolApi(args, exec);
2524
+ if (action === 'flow_list') {
2525
+ return {
2526
+ ok: true,
2527
+ summary: `${(await flowRecorder.list(api.id)).length} saved flow(s) on ${api.id}`,
2528
+ result: { instance: api.id, recording: flowRecorder.isRecording(api.id), flows: await flowRecorder.list(api.id) },
2529
+ };
2530
+ }
2531
+ if (action === 'flow_start') {
2532
+ const flow = flowRecorder.start(api.id, args.name);
2533
+ return { ok: true, summary: `recording flow ${flow.name} (${flow.id}) on ${api.id}`, result: flow };
2534
+ }
2535
+ if (action === 'flow_stop') {
2536
+ const flow = await flowRecorder.stop(api.id);
2537
+ return { ok: true, summary: `saved flow ${flow.name} (${flow.id}, ${flow.events} events)`, result: flow };
2538
+ }
2539
+ if (action === 'flow_replay') {
2540
+ if (!args.flowId) throw new Error('action=flow_replay requires flowId from flow_list');
2541
+ await ensureToolApiReady(api, action);
2542
+ const replayed = await flowRecorder.replay(args.flowId, async (payload) => api.input(payload), {
2543
+ instanceId: api.id,
2544
+ });
2545
+ return { ok: true, summary: `replayed ${replayed.steps} step(s) from ${replayed.name}`, result: replayed };
2546
+ }
2349
2547
  if (action === 'start') {
2350
2548
  const snapshot = await api.start();
2351
2549
  return { ok: true, summary: `started ${api.id}`, result: snapshot || {} };
@@ -2461,6 +2659,44 @@ export function apply(ctx, config = {}) {
2461
2659
  const result = await api.uiDump();
2462
2660
  return { ok: true, summary: `UI dump: ${result.nodes} nodes, ${result.bytes} bytes`, result };
2463
2661
  }
2662
+ case 'inspect_ui': {
2663
+ const dump = await api.uiDump();
2664
+ let screen = null;
2665
+ try {
2666
+ screen = await api.screenSize();
2667
+ } catch {
2668
+ screen = null;
2669
+ }
2670
+ const inspector = buildInspectorPayload(dump, screen);
2671
+ const interactive = inspector.nodes.filter((node) => node.interactive);
2672
+ return {
2673
+ ok: true,
2674
+ summary:
2675
+ `${inspector.nodes.length} UI node(s), ${interactive.length} interactive; ` +
2676
+ `screen ${screen ? `${screen.width}x${screen.height}` : '?'}`,
2677
+ result: inspector,
2678
+ };
2679
+ }
2680
+ case 'proxy': {
2681
+ if (args.enabled === false || args.clear === true) {
2682
+ const cleared = await api.setProxy({ enabled: false });
2683
+ return { ok: true, summary: 'HTTP proxy cleared', result: cleared };
2684
+ }
2685
+ if (args.host !== undefined) {
2686
+ const applied = await api.setProxy({ host: args.host, port: args.port, bypass: args.bypass, enabled: true });
2687
+ return { ok: true, summary: `HTTP proxy set to ${applied.proxy}`, result: applied };
2688
+ }
2689
+ return { ok: true, summary: 'current HTTP proxy settings', result: await api.getProxy() };
2690
+ }
2691
+ case 'install_ca': {
2692
+ if (!args.path) throw new Error('action=install_ca requires path to a .pem/.crt certificate');
2693
+ const installed = await api.installCaCertificate(args.path);
2694
+ return {
2695
+ ok: true,
2696
+ summary: `installed CA ${installed.fileName}${installed.systemCa ? ' (system+user)' : ' (user store)'}`,
2697
+ result: installed,
2698
+ };
2699
+ }
2464
2700
  case 'find_ui': {
2465
2701
  const dump = await api.uiDump();
2466
2702
  const matches = parseUiNodes(dump.xml).filter((node) => uiNodeMatches(node, args)).slice(0, 20);