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/lib/flows.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Interaction flow recorder for the APK VM.
3
+ *
4
+ * The Host records validated input payloads (tap/swipe/key/text) as they arrive
5
+ * through the panel or the apk_vm tool, then replays them with the original
6
+ * relative timing. Flows are plain JSON files under the OS temp dir, so they can
7
+ * be listed, downloaded, copied to another machine, or replayed later.
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { promises as fsp } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ const MAX_EVENTS = 2000;
15
+ const MAX_FLOW_FILES = 100;
16
+
17
+ function safePart(value, fallback) {
18
+ const text = String(value == null ? '' : value)
19
+ .trim()
20
+ .replace(/[^A-Za-z0-9._-]+/g, '_')
21
+ .slice(0, 64);
22
+ return text || fallback;
23
+ }
24
+
25
+ function normalizePayload(payload) {
26
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
27
+ const type = String(payload.type || payload.action || payload.kind || '').toLowerCase();
28
+ if (type === 'tap') {
29
+ const x = Number(payload.x);
30
+ const y = Number(payload.y);
31
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
32
+ return { type: 'tap', x: Math.round(x), y: Math.round(y) };
33
+ }
34
+ if (type === 'swipe') {
35
+ const values = [payload.x1, payload.y1, payload.x2, payload.y2].map(Number);
36
+ if (!values.every(Number.isFinite)) return null;
37
+ const durationMs = Math.max(1, Math.min(60000, Math.round(Number(payload.durationMs ?? payload.duration) || 300)));
38
+ return {
39
+ type: 'swipe',
40
+ x1: Math.round(values[0]),
41
+ y1: Math.round(values[1]),
42
+ x2: Math.round(values[2]),
43
+ y2: Math.round(values[3]),
44
+ durationMs,
45
+ };
46
+ }
47
+ if (type === 'key' || type === 'keyevent') {
48
+ const key = String(payload.key ?? payload.keycode ?? payload.code ?? '');
49
+ return key ? { type: 'key', key } : null;
50
+ }
51
+ if (type === 'text') {
52
+ const text = payload.text ?? payload.value;
53
+ if (typeof text !== 'string' || !text || text.length > 4096) return null;
54
+ return { type: 'text', text };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function publicFlow(flow) {
60
+ if (!flow) return null;
61
+ return {
62
+ id: flow.id,
63
+ name: flow.name,
64
+ instanceId: flow.instanceId,
65
+ createdAt: flow.createdAt,
66
+ updatedAt: flow.updatedAt,
67
+ durationMs: flow.durationMs || 0,
68
+ events: Array.isArray(flow.events) ? flow.events.length : 0,
69
+ };
70
+ }
71
+
72
+ export class FlowRecorder {
73
+ constructor(options = {}) {
74
+ const opts = options && typeof options === 'object' ? options : {};
75
+ this.dir = opts.dir || path.join(os.tmpdir(), 'dsh-apk-vm-flows');
76
+ this.active = new Map();
77
+ this._writeQueue = Promise.resolve();
78
+ }
79
+
80
+ _fileFor(id) {
81
+ return path.join(this.dir, `${id}.json`);
82
+ }
83
+
84
+ async _ensureDir() {
85
+ await fsp.mkdir(this.dir, { recursive: true });
86
+ }
87
+
88
+ start(instanceId, name) {
89
+ const instance = safePart(instanceId, 'default');
90
+ const id = `${instance}-${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`;
91
+ const flow = {
92
+ id,
93
+ name: safePart(name, 'flow') || 'flow',
94
+ instanceId: instance,
95
+ createdAt: new Date().toISOString(),
96
+ updatedAt: new Date().toISOString(),
97
+ events: [],
98
+ startedAtMs: Date.now(),
99
+ };
100
+ this.active.set(instance, flow);
101
+ return publicFlow(flow);
102
+ }
103
+
104
+ isRecording(instanceId) {
105
+ return this.active.has(safePart(instanceId, 'default'));
106
+ }
107
+
108
+ record(instanceId, payload) {
109
+ const instance = safePart(instanceId, 'default');
110
+ const flow = this.active.get(instance);
111
+ if (!flow) return false;
112
+ const normalized = normalizePayload(payload);
113
+ if (!normalized) return false;
114
+ if (flow.events.length >= MAX_EVENTS) return false;
115
+ flow.events.push({ at: Date.now() - flow.startedAtMs, payload: normalized });
116
+ return true;
117
+ }
118
+
119
+ async stop(instanceId) {
120
+ const instance = safePart(instanceId, 'default');
121
+ const flow = this.active.get(instance);
122
+ if (!flow) throw new Error(`no flow recording is active for instance ${instance}`);
123
+ this.active.delete(instance);
124
+ flow.updatedAt = new Date().toISOString();
125
+ flow.durationMs = flow.events.length ? flow.events[flow.events.length - 1].at : 0;
126
+ delete flow.startedAtMs;
127
+ await this._ensureDir();
128
+ await fsp.writeFile(this._fileFor(flow.id), `${JSON.stringify(flow, null, 2)}\n`, 'utf8');
129
+ return publicFlow(flow);
130
+ }
131
+
132
+ async list(instanceId) {
133
+ const instance = instanceId ? safePart(instanceId, 'default') : '';
134
+ await this._ensureDir();
135
+ let names = [];
136
+ try {
137
+ names = await fsp.readdir(this.dir);
138
+ } catch {
139
+ return [];
140
+ }
141
+ const jsonNames = names.filter((name) => name.endsWith('.json')).sort().reverse().slice(0, MAX_FLOW_FILES);
142
+ const flows = [];
143
+ for (const name of jsonNames) {
144
+ try {
145
+ const raw = await fsp.readFile(path.join(this.dir, name), 'utf8');
146
+ const flow = JSON.parse(raw);
147
+ if (!flow || typeof flow !== 'object') continue;
148
+ if (instance && flow.instanceId !== instance) continue;
149
+ flows.push(publicFlow(flow));
150
+ } catch {
151
+ /* ignore corrupt flow files */
152
+ }
153
+ }
154
+ return flows;
155
+ }
156
+
157
+ async get(id, instanceId) {
158
+ if (!/^[A-Za-z0-9._-]{1,160}$/.test(String(id || ''))) throw new Error('invalid flow id');
159
+ await this._ensureDir();
160
+ const raw = await fsp.readFile(this._fileFor(id), 'utf8');
161
+ const flow = JSON.parse(raw);
162
+ if (instanceId && flow.instanceId !== safePart(instanceId, 'default')) {
163
+ throw new Error(`flow ${id} belongs to another instance`);
164
+ }
165
+ return flow;
166
+ }
167
+
168
+ async remove(id, instanceId) {
169
+ const flow = await this.get(id, instanceId);
170
+ await fsp.unlink(this._fileFor(flow.id));
171
+ return publicFlow(flow);
172
+ }
173
+
174
+ /** Replay a saved flow through the provided async input function. */
175
+ async replay(id, inputFn, options = {}) {
176
+ if (typeof inputFn !== 'function') throw new Error('replay requires an input function');
177
+ const flow = await this.get(id, options.instanceId);
178
+ const delayCap = Math.max(0, Math.min(30000, Number(options.delayCapMs) || 5000));
179
+ const events = Array.isArray(flow.events) ? flow.events : [];
180
+ let previous = 0;
181
+ let replayed = 0;
182
+ for (let index = 0; index < events.length; index += 1) {
183
+ const event = events[index];
184
+ if (!event || !event.payload) continue;
185
+ const at = Math.max(0, Number(event.at) || 0);
186
+ const wait = Math.min(delayCap, Math.max(0, at - previous));
187
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
188
+ previous = at;
189
+ try {
190
+ await inputFn(event.payload);
191
+ } catch (error) {
192
+ throw new Error(`flow replay failed at step ${index + 1}/${events.length}: ${error.message}`);
193
+ }
194
+ replayed += 1;
195
+ }
196
+ return { id: flow.id, name: flow.name, steps: replayed, durationMs: flow.durationMs || 0 };
197
+ }
198
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * UIAutomator hierarchy parser + stable selector generation.
3
+ *
4
+ * Kept dependency-free (plain XML tokenizer) so the Host half can use it on every
5
+ * platform and the client receives a ready-to-use `apk_vm` command for each node.
6
+ */
7
+
8
+ function decodeEntities(value) {
9
+ return String(value || '')
10
+ .replace(/&quot;/g, '"')
11
+ .replace(/&apos;/g, "'")
12
+ .replace(/&lt;/g, '<')
13
+ .replace(/&gt;/g, '>')
14
+ .replace(/&amp;/g, '&');
15
+ }
16
+
17
+ function parseBounds(value) {
18
+ const match = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/.exec(String(value || ''));
19
+ if (!match) return null;
20
+ const bounds = {
21
+ x1: Number(match[1]),
22
+ y1: Number(match[2]),
23
+ x2: Number(match[3]),
24
+ y2: Number(match[4]),
25
+ };
26
+ return {
27
+ ...bounds,
28
+ width: Math.max(0, bounds.x2 - bounds.x1),
29
+ height: Math.max(0, bounds.y2 - bounds.y1),
30
+ center: {
31
+ x: Math.round((bounds.x1 + bounds.x2) / 2),
32
+ y: Math.round((bounds.y1 + bounds.y2) / 2),
33
+ },
34
+ };
35
+ }
36
+
37
+ /** Parse a UIAutomator XML document into lightweight node objects. */
38
+ export function parseUiHierarchy(xml) {
39
+ const source = String(xml || '');
40
+ const rotationMatch = /<hierarchy[^>]*\brotation="(-?\d+)"/i.exec(source);
41
+ const nodes = [];
42
+ const nodeRe = /<node\b([^>]*?)\/?>/g;
43
+ const attrRe = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*"([^"]*)"/g;
44
+ let match;
45
+ let order = 0;
46
+ while ((match = nodeRe.exec(source))) {
47
+ const attrs = {};
48
+ let attr;
49
+ while ((attr = attrRe.exec(match[1]))) {
50
+ attrs[attr[1]] = decodeEntities(attr[2]);
51
+ }
52
+ const bounds = parseBounds(attrs.bounds);
53
+ const text = attrs.text || '';
54
+ const contentDesc = attrs['content-desc'] || '';
55
+ const resourceId = attrs['resource-id'] || '';
56
+ const className = attrs.class || '';
57
+ if (!text && !contentDesc && !resourceId && !className && !bounds) continue;
58
+ nodes.push({
59
+ order: order++,
60
+ text,
61
+ resourceId,
62
+ contentDesc,
63
+ className,
64
+ packageName: attrs.package || '',
65
+ clickable: attrs.clickable === 'true',
66
+ longClickable: attrs['long-clickable'] === 'true',
67
+ enabled: attrs.enabled === 'true',
68
+ focusable: attrs.focusable === 'true',
69
+ focused: attrs.focused === 'true',
70
+ scrollable: attrs.scrollable === 'true',
71
+ selected: attrs.selected === 'true',
72
+ password: attrs.password === 'true',
73
+ checkable: attrs.checkable === 'true',
74
+ checked: attrs.checked === 'true',
75
+ bounds,
76
+ });
77
+ }
78
+ return {
79
+ rotation: rotationMatch ? Number(rotationMatch[1]) : null,
80
+ nodes,
81
+ };
82
+ }
83
+
84
+ function shortLabel(node) {
85
+ const text = String(node.text || '').trim();
86
+ if (text) return text.length > 60 ? `${text.slice(0, 57)}...` : text;
87
+ const desc = String(node.contentDesc || '').trim();
88
+ if (desc) return desc.length > 60 ? `${desc.slice(0, 57)}...` : desc;
89
+ const id = String(node.resourceId || '').trim();
90
+ if (id) return id.split('/').pop();
91
+ const cls = String(node.className || '').split('.').pop();
92
+ return cls || `node ${node.order}`;
93
+ }
94
+
95
+ /**
96
+ * Build the most stable selector for a node, in this order:
97
+ * resource-id > text > content-desc > class. The returned `args` can be passed
98
+ * directly to the `apk_vm` find_ui/assert_text/tap_text actions.
99
+ */
100
+ export function selectorForNode(node) {
101
+ if (!node || typeof node !== 'object') throw new Error('selectorForNode requires a node');
102
+ let args = null;
103
+ let by = '';
104
+ if (node.resourceId) {
105
+ args = { resourceId: node.resourceId };
106
+ by = 'resourceId';
107
+ } else if (node.text) {
108
+ args = { text: node.text };
109
+ by = 'text';
110
+ } else if (node.contentDesc) {
111
+ args = { contentDesc: node.contentDesc };
112
+ by = 'contentDesc';
113
+ } else if (node.className) {
114
+ args = { className: node.className };
115
+ by = 'className';
116
+ } else if (node.bounds) {
117
+ args = { x: node.bounds.center.x, y: node.bounds.center.y };
118
+ by = 'coordinates';
119
+ }
120
+ if (!args) return null;
121
+ const json = JSON.stringify(args);
122
+ const label = `${by}=${JSON.stringify(args[by] || `${args.x},${args.y}`)}`;
123
+ return {
124
+ by,
125
+ args,
126
+ label,
127
+ selector: label,
128
+ findCommand: `find_ui ${json}`,
129
+ assertCommand: `assert_text ${json}`,
130
+ tapCommand: `tap_text ${json}`,
131
+ tapCoordinates:
132
+ node.bounds && node.bounds.width > 0 && node.bounds.height > 0
133
+ ? { x: node.bounds.center.x, y: node.bounds.center.y }
134
+ : null,
135
+ };
136
+ }
137
+
138
+ /** Add stable labels/selectors and keep the result bounded for browser transport. */
139
+ export function decorateUiNodes(nodes, options = {}) {
140
+ const limit = Math.max(1, Math.min(1000, Number(options.limit) || 400));
141
+ const all = Array.isArray(nodes) ? nodes : [];
142
+ const decorated = [];
143
+ for (const node of all) {
144
+ if (!node) continue;
145
+ if (!node.bounds || node.bounds.width <= 0 || node.bounds.height <= 0) continue;
146
+ const selector = selectorForNode(node);
147
+ decorated.push({
148
+ ...node,
149
+ label: shortLabel(node),
150
+ selector,
151
+ // Overlay only needs interactive/meaningful boxes; containers are still
152
+ // returned to the inspector list so Agents can see hierarchy.
153
+ interactive: node.clickable || node.focusable || node.scrollable || Boolean(node.text),
154
+ });
155
+ if (decorated.length >= limit) break;
156
+ }
157
+ return { nodes: decorated, total: all.length, truncated: all.length > decorated.length };
158
+ }
159
+
160
+ /** Complete payload used by GET /apk-vm/debug/inspect and the apk_vm tool. */
161
+ export function buildInspectorPayload(dump, screen, options = {}) {
162
+ const parsed = parseUiHierarchy(dump && dump.xml);
163
+ const decorated = decorateUiNodes(parsed.nodes, options);
164
+ return {
165
+ ok: true,
166
+ rotation: parsed.rotation,
167
+ screen: screen || null,
168
+ nodes: decorated.nodes,
169
+ total: decorated.total,
170
+ truncated: decorated.truncated,
171
+ xmlBytes: dump && dump.bytes ? dump.bytes : null,
172
+ remotePath: dump && dump.remotePath ? dump.remotePath : null,
173
+ };
174
+ }
package/lib/x509.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Minimal X.509 helpers for installing a local proxy CA certificate in Android.
3
+ *
4
+ * Android names user CA files by the OpenSSL "subject hash" (`<hash>.0`). Modern
5
+ * Node has no OpenSSL CLI, so we parse the DER certificate here and compute the
6
+ * same legacy hash: MD5 of the DER-encoded subject, first four bytes as a
7
+ * little-endian 32-bit hex value.
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+
11
+ function readTlv(buffer, offset) {
12
+ if (offset + 2 > buffer.length) throw new Error('truncated DER');
13
+ const tag = buffer[offset];
14
+ let length = buffer[offset + 1];
15
+ let header = 2;
16
+ if (length & 0x80) {
17
+ const count = length & 0x7f;
18
+ if (count === 0 || count > 4 || offset + 2 + count > buffer.length) throw new Error('unsupported DER length');
19
+ length = 0;
20
+ for (let i = 0; i < count; i += 1) length = length * 256 + buffer[offset + 2 + i];
21
+ header += count;
22
+ }
23
+ const start = offset + header;
24
+ const end = start + length;
25
+ if (end > buffer.length) throw new Error('truncated DER value');
26
+ return { tag, start, end, der: buffer.subarray(offset, end) };
27
+ }
28
+
29
+ function children(buffer, tlv) {
30
+ const result = [];
31
+ let offset = tlv.start;
32
+ while (offset < tlv.end) {
33
+ const child = readTlv(buffer, offset);
34
+ result.push(child);
35
+ offset = child.end;
36
+ if (result.length > 64) throw new Error('unexpected DER structure');
37
+ }
38
+ return result;
39
+ }
40
+
41
+ function pemToDer(input) {
42
+ const text = String(input || '');
43
+ if (!/-----BEGIN CERTIFICATE-----/.test(text)) {
44
+ // Accept raw DER too.
45
+ if (text.length && text.charCodeAt(0) === 0x30) return Buffer.from(text, 'latin1');
46
+ throw new Error('certificate is not PEM/DER encoded');
47
+ }
48
+ const matches = text.match(/-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/g) || [];
49
+ if (!matches.length) throw new Error('no PEM certificate block found');
50
+ const body = matches[0]
51
+ .replace(/-----BEGIN CERTIFICATE-----/, '')
52
+ .replace(/-----END CERTIFICATE-----/, '')
53
+ .replace(/\s+/g, '');
54
+ return Buffer.from(body, 'base64');
55
+ }
56
+
57
+ function printableName(subjectDer) {
58
+ // Best effort only; this is diagnostic metadata, not used for trust decisions.
59
+ const parts = [];
60
+ for (const match of subjectDer.toString('latin1').matchAll(/([\x20-\x7e]{2,80})/g)) {
61
+ const text = match[1].trim();
62
+ if (text && /[A-Za-z]/.test(text)) parts.push(text);
63
+ }
64
+ return parts.slice(0, 6).join(' ');
65
+ }
66
+
67
+ /** Extract the DER-encoded subject Name from a certificate. */
68
+ export function extractSubjectDer(der) {
69
+ const cert = readTlv(der, 0);
70
+ if (cert.tag !== 0x30) throw new Error('certificate is not a DER SEQUENCE');
71
+ const certChildren = children(der, cert);
72
+ if (!certChildren.length) throw new Error('empty certificate');
73
+ const tbs = certChildren[0];
74
+ if (tbs.tag !== 0x30) throw new Error('certificate tbsCertificate is not a SEQUENCE');
75
+ const fields = children(der, tbs);
76
+ let index = 0;
77
+ // Optional [0] EXPLICIT version (0xA0).
78
+ if (fields[index] && fields[index].tag === 0xa0) index += 1;
79
+ // serialNumber, signatureAlgorithm, issuer, validity, subject
80
+ const subject = fields[index + 4];
81
+ if (!subject || subject.tag !== 0x30) throw new Error('certificate subject not found');
82
+ return subject.der;
83
+ }
84
+
85
+ /** OpenSSL-compatible subject_hash_old (used by Android's user CA directory). */
86
+ export function subjectHashOld(der) {
87
+ if (!Buffer.isBuffer(der) || !der.length) throw new Error('subjectHashOld requires DER bytes');
88
+ const subject = extractSubjectDer(der);
89
+ const digest = createHash('md5').update(subject).digest();
90
+ const value = digest.readUInt32LE(0);
91
+ return value.toString(16).padStart(8, '0');
92
+ }
93
+
94
+ /** Parse a PEM/DER certificate into the fields needed for CA installation. */
95
+ export function inspectCertificate(input) {
96
+ const der = pemToDer(input);
97
+ const subjectDer = extractSubjectDer(der);
98
+ const hash = subjectHashOld(der);
99
+ return {
100
+ der,
101
+ derBytes: der.length,
102
+ hash,
103
+ fileName: `${hash}.0`,
104
+ subject: printableName(subjectDer),
105
+ pem: `-----BEGIN CERTIFICATE-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n`,
106
+ };
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-apk-vm",
3
- "version": "0.3.13",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Local AOSP Android VM for DeepSeek Harness: preview rooted APKs at up to 60 fps, automate UIAutomator flows, and give each Agent an isolated VM.",
6
6
  "license": "Apache-2.0",
@@ -25,9 +25,9 @@
25
25
  "registry": "https://registry.npmjs.org/"
26
26
  },
27
27
  "scripts": {
28
- "check": "node --check index.js && node --check client.js && node --check lib/android.js && node --check lib/scrcpy.js && node --check lib/ws.js && node --check lib/setup.js && node --check lib/instances.js && node --check lib/video-stream.js",
28
+ "check": "node --check index.js && node --check client.js && node --check lib/android.js && node --check lib/apk-bundle.js && node --check lib/flows.js && node --check lib/ui-inspector.js && node --check lib/x509.js && node --check lib/scrcpy.js && node --check lib/ws.js && node --check lib/setup.js && node --check lib/instances.js && node --check lib/video-stream.js",
29
29
  "privacy": "node scripts/privacy-scan.mjs",
30
- "test": "node spike/ui-smoke.cjs && node spike/ui-better-sidebar-smoke.cjs && node spike/setup-smoke.mjs && node spike/android/mock-device.mjs && node spike/android/launch-plan.mjs && node spike/instances-smoke.mjs"
30
+ "test": "node spike/ui-smoke.cjs && node spike/ui-better-sidebar-smoke.cjs && node spike/setup-smoke.mjs && node spike/android/mock-device.mjs && node spike/android/launch-plan.mjs && node spike/instances-smoke.mjs && node spike/features.mjs"
31
31
  },
32
32
  "exports": {
33
33
  ".": "./index.js",
@@ -41,11 +41,15 @@
41
41
  "client.js",
42
42
  "cordis.patch.yml",
43
43
  "lib/android.js",
44
+ "lib/apk-bundle.js",
45
+ "lib/flows.js",
44
46
  "lib/scrcpy.js",
47
+ "lib/ui-inspector.js",
45
48
  "lib/ws.js",
46
49
  "lib/setup.js",
47
50
  "lib/instances.js",
48
51
  "lib/video-stream.js",
52
+ "lib/x509.js",
49
53
  "lib/vendor/scrcpy-server-v5.0.1.jar",
50
54
  "LICENSE",
51
55
  "NOTICE",
@@ -56,6 +60,7 @@
56
60
  "docs/RELEASING.md",
57
61
  "docs/PRIVACY.md",
58
62
  "docs/SUBMISSION.md",
63
+ "docs/LINUX-E2E.md",
59
64
  "docs/*.png",
60
65
  "scripts/privacy-scan.mjs",
61
66
  "locale/*.json",