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.
- package/CHANGELOG.md +38 -0
- package/README.md +57 -12
- package/client.js +555 -5
- package/docs/LINUX-E2E.md +81 -0
- package/docs/README.md +15 -0
- package/docs/SUBMISSION.md +5 -2
- package/index.js +246 -10
- package/lib/android.js +219 -55
- package/lib/apk-bundle.js +165 -0
- package/lib/flows.js +198 -0
- package/lib/ui-inspector.js +174 -0
- package/lib/x509.js +107 -0
- package/package.json +8 -3
|
@@ -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
|
+
|
package/docs/SUBMISSION.md
CHANGED
|
@@ -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
|
|
40
|
-
headless
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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);
|