dsh-apk-vm 0.5.0 → 0.6.1
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 +40 -0
- package/README.md +30 -6
- package/client.js +348 -1
- package/index.js +182 -2
- package/lib/android.js +277 -2
- package/lib/instances.js +6 -0
- package/lib/storage.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.1
|
|
4
|
+
|
|
5
|
+
Network panel completeness.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Network card pcap path input and apply action (capture starts on the next emulator start).
|
|
10
|
+
- `setTcpdumpPath` API/tool wiring for per-instance pcap configuration.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Clearing the hosts list now unmounts the `/etc/hosts` bind mount instead of leaving the last
|
|
15
|
+
entries active until reboot.
|
|
16
|
+
|
|
17
|
+
## 0.6.0
|
|
18
|
+
|
|
19
|
+
Network controls.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Port forwarding management (`forward-add` / `forward-remove`) over adb, with a host-port →
|
|
24
|
+
guest-port list and optional `shared` rules.
|
|
25
|
+
- Custom DNS servers via the emulator `-dns-server` launch flag (restart required) and guest
|
|
26
|
+
`/etc/hosts` injection through a root bind mount, reapplied after boot.
|
|
27
|
+
- Proxy connectivity test from inside the guest (`ping` + toybox `nc`), and current proxy
|
|
28
|
+
display in the Network card.
|
|
29
|
+
- Per-instance network mode:
|
|
30
|
+
- `isolated` (default QEMU-NAT isolation)
|
|
31
|
+
- `shared` (host-mediated TCP sharing through shared port forwards)
|
|
32
|
+
- Optional emulator pcap capture (`-tcpdump`) with `GET /apk-vm/network/pcap` download.
|
|
33
|
+
- Network card UI for forwards, DNS, hosts, mode, test, and pcap; `apk_vm` action `network`.
|
|
34
|
+
- Per-instance network route: `/apk-vm/instances/<id>/network`.
|
|
35
|
+
|
|
36
|
+
### Notes
|
|
37
|
+
|
|
38
|
+
- Shared mode is host-mediated TCP sharing, not L2 bridging; instances keep their own QEMU NAT.
|
|
39
|
+
- DNS changes take effect on the next emulator start; hosts changes apply live and survive a
|
|
40
|
+
restart while the plugin keeps the configured entries.
|
|
41
|
+
- pcap export is opt-in and only writes traffic from the emulator's user-mode network.
|
|
42
|
+
|
|
3
43
|
## 0.5.0
|
|
4
44
|
|
|
5
45
|
Storage management.
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ same-origin, loopback-only debug HTTP API and an `apk_vm` Agent tool.
|
|
|
24
24
|
> 当前 **Windows x64 与 Linux x86_64(headless)为端到端已验证平台**;macOS 仅做探测/CI
|
|
25
25
|
> 覆盖,详见 `docs/LINUX-E2E.md`。
|
|
26
26
|
|
|
27
|
-
**Status:** `0.
|
|
27
|
+
**Status:** `0.6.1` · Apache-2.0 · no npm runtime dependencies · no build step.
|
|
28
28
|
|
|
29
29
|
This is an open-source DSH plugin published on npm as **`dsh-apk-vm`**. Repository:
|
|
30
30
|
[github.com/yuxiaole-bili/dsh-apk-vm](https://github.com/yuxiaole-bili/dsh-apk-vm).
|
|
@@ -170,10 +170,11 @@ key {"text":"HOME"} -> returned to launcher
|
|
|
170
170
|
then save the tap / swipe / key / text sequence as a JSON flow under the OS temp directory.
|
|
171
171
|
Flows can be listed, replayed with their original relative timing, downloaded or deleted.
|
|
172
172
|
Agents get `flow_start`, `flow_stop`, `flow_list` and `flow_replay`.
|
|
173
|
-
- **Network debugging
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
- **Network debugging and port forwarding.** The Network card manages host→guest port
|
|
174
|
+
forwards, custom DNS (next emulator start), guest `/etc/hosts` via a root bind mount,
|
|
175
|
+
HTTP proxy + CA installation, isolated/shared instance modes, and a guest-side connectivity
|
|
176
|
+
test. Optional pcap capture can be downloaded from the panel. See
|
|
177
|
+
[Network controls](#network-controls).
|
|
177
178
|
- **Storage panel + guarded cleanup.** A Storage card reports the AVD, snapshot, SDK, temp-file
|
|
178
179
|
and free-disk footprint and offers explicit cleanups: plugin temp files, quick-boot
|
|
179
180
|
snapshots for a stopped AVD, and orphan `dsh_apk_*` AVDs. Destructive actions need a second
|
|
@@ -263,7 +264,30 @@ card uses the same endpoint.
|
|
|
263
264
|
not active.
|
|
264
265
|
- `apk_vm` tool actions: `storage`, `clean_storage` (`storageAction`, `avdName`, `confirm`).
|
|
265
266
|
|
|
266
|
-
##
|
|
267
|
+
## Network controls
|
|
268
|
+
|
|
269
|
+
`GET /apk-vm/network` returns the guest addresses, current proxy, DNS, hosts count, mode,
|
|
270
|
+
port forwards and pcap status. `POST /apk-vm/network` accepts `action`:
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{ "action": "mode", "mode": "isolated|shared" }
|
|
274
|
+
{ "action": "forward-add", "localPort": 18080, "remotePort": 8080, "shared": false }
|
|
275
|
+
{ "action": "forward-remove", "localPort": 18080 }
|
|
276
|
+
{ "action": "dns", "servers": ["1.1.1.1"] }
|
|
277
|
+
{ "action": "hosts", "entries": ["198.51.100.10 api.example.test"] }
|
|
278
|
+
{ "action": "test", "host": "198.51.100.10", "port": 8080 }
|
|
279
|
+
{ "action": "pcap", "path": "D:\\apk-vm-capture.pcap" }
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- Port forwards use `adb forward tcp:<hostPort> tcp:<guestPort>`.
|
|
283
|
+
- DNS is applied by the emulator `-dns-server` flag on the next start.
|
|
284
|
+
- Hosts entries are bind-mounted over the guest `/etc/hosts` (root required) and reapplied
|
|
285
|
+
after a plugin-managed boot.
|
|
286
|
+
- `shared` forwards are reachable from other VMs through the emulator's host alias; the
|
|
287
|
+
QEMU NAT itself remains isolated.
|
|
288
|
+
- pcap capture is passed to the emulator as `-tcpdump` and starts on the next emulator start;
|
|
289
|
+
download via `GET /apk-vm/network/pcap`.
|
|
290
|
+
|
|
267
291
|
|
|
268
292
|
A public, dependency-free demo lives in [examples](examples/README.md):
|
|
269
293
|
|
package/client.js
CHANGED
|
@@ -189,6 +189,26 @@ window.__ModuleLoader__.load({
|
|
|
189
189
|
'network.caPath': 'CA certificate path (.pem/.crt)',
|
|
190
190
|
'network.installCa': 'Install CA',
|
|
191
191
|
'network.current': 'Current: {value}',
|
|
192
|
+
'network.forwards': 'Port forwards',
|
|
193
|
+
'network.forwardLocal': 'Host port',
|
|
194
|
+
'network.forwardRemote': 'Guest port',
|
|
195
|
+
'network.addForward': 'Add forward',
|
|
196
|
+
'network.remove': 'Remove',
|
|
197
|
+
'network.mode': 'Mode',
|
|
198
|
+
'network.isolated': 'Isolated',
|
|
199
|
+
'network.shared': 'Shared (host bridge)',
|
|
200
|
+
'network.dns': 'DNS servers',
|
|
201
|
+
'network.applyDns': 'Apply DNS',
|
|
202
|
+
'network.hosts': 'Hosts entries',
|
|
203
|
+
'network.applyHosts': 'Apply hosts',
|
|
204
|
+
'network.test': 'Test connectivity',
|
|
205
|
+
'network.testOk': 'Connectivity OK',
|
|
206
|
+
'network.testFail': 'Connectivity check failed',
|
|
207
|
+
'network.pcap': 'Download pcap',
|
|
208
|
+
'network.pcapPath': 'Host pcap path (.pcap, next start)',
|
|
209
|
+
'network.restartRequired': 'Restart the VM to apply custom DNS',
|
|
210
|
+
'network.sharedHint': 'Shared forwards are reachable from other VMs through the emulator host alias.',
|
|
211
|
+
'network.guestUrl': 'Guest URL: {url}',
|
|
192
212
|
'flow.title': 'Flow recorder',
|
|
193
213
|
'flow.start': 'Record',
|
|
194
214
|
'flow.stop': 'Stop',
|
|
@@ -402,6 +422,26 @@ window.__ModuleLoader__.load({
|
|
|
402
422
|
'network.caPath': 'CA 证书路径(.pem/.crt)',
|
|
403
423
|
'network.installCa': '安装 CA',
|
|
404
424
|
'network.current': '当前:{value}',
|
|
425
|
+
'network.forwards': '端口转发',
|
|
426
|
+
'network.forwardLocal': '宿主机端口',
|
|
427
|
+
'network.forwardRemote': 'Guest 端口',
|
|
428
|
+
'network.addForward': '添加转发',
|
|
429
|
+
'network.remove': '删除',
|
|
430
|
+
'network.mode': '模式',
|
|
431
|
+
'network.isolated': '隔离',
|
|
432
|
+
'network.shared': '共享(宿主桥接)',
|
|
433
|
+
'network.dns': 'DNS 服务器',
|
|
434
|
+
'network.applyDns': '应用 DNS',
|
|
435
|
+
'network.hosts': 'Hosts 条目',
|
|
436
|
+
'network.applyHosts': '应用 Hosts',
|
|
437
|
+
'network.test': '测试连通性',
|
|
438
|
+
'network.testOk': '连通性正常',
|
|
439
|
+
'network.testFail': '连通性检查失败',
|
|
440
|
+
'network.pcap': '下载 pcap',
|
|
441
|
+
'network.pcapPath': '宿主机 pcap 路径(.pcap,下次启动)',
|
|
442
|
+
'network.restartRequired': '重启虚拟机后生效自定义 DNS',
|
|
443
|
+
'network.sharedHint': '共享转发可被其他 VM 通过模拟器宿主别名访问。',
|
|
444
|
+
'network.guestUrl': 'Guest URL: {url}',
|
|
405
445
|
'flow.title': '操作录制',
|
|
406
446
|
'flow.start': '录制',
|
|
407
447
|
'flow.stop': '停止',
|
|
@@ -1259,6 +1299,30 @@ window.__ModuleLoader__.load({
|
|
|
1259
1299
|
var caPathState = React.useState('');
|
|
1260
1300
|
var caPath = caPathState[0];
|
|
1261
1301
|
var setCaPath = caPathState[1];
|
|
1302
|
+
var networkState = React.useState(null);
|
|
1303
|
+
var network = networkState[0];
|
|
1304
|
+
var setNetwork = networkState[1];
|
|
1305
|
+
var networkBusyState = React.useState('');
|
|
1306
|
+
var networkBusy = networkBusyState[0];
|
|
1307
|
+
var setNetworkBusy = networkBusyState[1];
|
|
1308
|
+
var forwardLocalState = React.useState('');
|
|
1309
|
+
var forwardLocal = forwardLocalState[0];
|
|
1310
|
+
var setForwardLocal = forwardLocalState[1];
|
|
1311
|
+
var forwardRemoteState = React.useState('');
|
|
1312
|
+
var forwardRemote = forwardRemoteState[0];
|
|
1313
|
+
var setForwardRemote = forwardRemoteState[1];
|
|
1314
|
+
var forwardSharedState = React.useState(false);
|
|
1315
|
+
var forwardShared = forwardSharedState[0];
|
|
1316
|
+
var setForwardShared = forwardSharedState[1];
|
|
1317
|
+
var dnsTextState = React.useState('');
|
|
1318
|
+
var dnsText = dnsTextState[0];
|
|
1319
|
+
var setDnsText = dnsTextState[1];
|
|
1320
|
+
var hostsTextState = React.useState('');
|
|
1321
|
+
var hostsText = hostsTextState[0];
|
|
1322
|
+
var setHostsText = hostsTextState[1];
|
|
1323
|
+
var pcapPathState = React.useState('');
|
|
1324
|
+
var pcapPath = pcapPathState[0];
|
|
1325
|
+
var setPcapPath = pcapPathState[1];
|
|
1262
1326
|
|
|
1263
1327
|
var actualFpsState = React.useState(0);
|
|
1264
1328
|
var actualFps = actualFpsState[0];
|
|
@@ -1436,6 +1500,7 @@ window.__ModuleLoader__.load({
|
|
|
1436
1500
|
loadDevices();
|
|
1437
1501
|
loadFlows();
|
|
1438
1502
|
loadStorage();
|
|
1503
|
+
loadNetwork();
|
|
1439
1504
|
var pollId = setInterval(function () {
|
|
1440
1505
|
if (stateBusyRef.current) return;
|
|
1441
1506
|
stateBusyRef.current = true;
|
|
@@ -1457,6 +1522,12 @@ window.__ModuleLoader__.load({
|
|
|
1457
1522
|
};
|
|
1458
1523
|
}, []);
|
|
1459
1524
|
|
|
1525
|
+
// Load network details once the VM becomes ready (the first render often
|
|
1526
|
+
// happens while the state is still loading).
|
|
1527
|
+
React.useEffect(function () {
|
|
1528
|
+
if (ready) loadNetwork();
|
|
1529
|
+
}, [ready, activeInstance]);
|
|
1530
|
+
|
|
1460
1531
|
// Track document visibility so the frame stream pauses when hidden.
|
|
1461
1532
|
React.useEffect(function () {
|
|
1462
1533
|
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') {
|
|
@@ -1833,6 +1904,8 @@ window.__ModuleLoader__.load({
|
|
|
1833
1904
|
setInspectNodes([]);
|
|
1834
1905
|
setInspectSelected(null);
|
|
1835
1906
|
loadFlows();
|
|
1907
|
+
setNetwork(null);
|
|
1908
|
+
loadNetwork();
|
|
1836
1909
|
stopLiveLogcat();
|
|
1837
1910
|
frameStatsRef.current = [];
|
|
1838
1911
|
loadState();
|
|
@@ -2166,6 +2239,98 @@ window.__ModuleLoader__.load({
|
|
|
2166
2239
|
});
|
|
2167
2240
|
}
|
|
2168
2241
|
|
|
2242
|
+
function loadNetwork() {
|
|
2243
|
+
if (!ready) return Promise.resolve(null);
|
|
2244
|
+
setNetworkBusy('load');
|
|
2245
|
+
return requestJson(instancePath('/network'), null, T).then(function (result) {
|
|
2246
|
+
if (!mountedRef.current) return result;
|
|
2247
|
+
setNetworkBusy('');
|
|
2248
|
+
if (result.ok && isObject(result.data)) {
|
|
2249
|
+
setNetwork(result.data);
|
|
2250
|
+
if (result.data.proxy && typeof result.data.proxy === 'string') {
|
|
2251
|
+
var colon = result.data.proxy.lastIndexOf(':');
|
|
2252
|
+
if (colon > 0) {
|
|
2253
|
+
setProxy({ host: result.data.proxy.slice(0, colon), port: result.data.proxy.slice(colon + 1) });
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
if (result.data.dnsServers && result.data.dnsServers.length) {
|
|
2257
|
+
setDnsText(result.data.dnsServers.join(', '));
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
return result;
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
function networkAction(action, payload) {
|
|
2265
|
+
if (networkBusy) return;
|
|
2266
|
+
setNetworkBusy(action);
|
|
2267
|
+
return requestJson(
|
|
2268
|
+
instancePath('/network'),
|
|
2269
|
+
{ method: 'POST', body: JSON.stringify(Object.assign({ action: action }, payload || {})) },
|
|
2270
|
+
T
|
|
2271
|
+
).then(function (result) {
|
|
2272
|
+
if (!mountedRef.current) return result;
|
|
2273
|
+
setNetworkBusy('');
|
|
2274
|
+
if (!result.ok) {
|
|
2275
|
+
pushToast('error', T('err.action') + ': ' + result.error);
|
|
2276
|
+
return result;
|
|
2277
|
+
}
|
|
2278
|
+
if (action === 'test') {
|
|
2279
|
+
pushToast(result.data && result.data.ok === true ? 'success' : 'error', T(result.data && result.data.ok ? 'network.testOk' : 'network.testFail'));
|
|
2280
|
+
} else if (action === 'dns' && result.data && result.data.restartRequired) {
|
|
2281
|
+
pushToast('success', T('network.restartRequired'));
|
|
2282
|
+
} else {
|
|
2283
|
+
pushToast('success', T('network.title'));
|
|
2284
|
+
}
|
|
2285
|
+
loadNetwork();
|
|
2286
|
+
return result;
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
function addPortForward() {
|
|
2291
|
+
var local = Number(forwardLocal);
|
|
2292
|
+
var remote = Number(forwardRemote);
|
|
2293
|
+
if (!Number.isInteger(local) || local < 1 || local > 65535 || !Number.isInteger(remote) || remote < 1 || remote > 65535) {
|
|
2294
|
+
pushToast('error', T('err.action'));
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
networkAction('forward-add', {
|
|
2298
|
+
localPort: local,
|
|
2299
|
+
remotePort: remote,
|
|
2300
|
+
shared: forwardShared === true,
|
|
2301
|
+
}).then(function (result) {
|
|
2302
|
+
if (result && result.ok) {
|
|
2303
|
+
setForwardLocal('');
|
|
2304
|
+
setForwardRemote('');
|
|
2305
|
+
setForwardShared(false);
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function removePortForward(localPort) {
|
|
2311
|
+
networkAction('forward-remove', { localPort: localPort });
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function applyDns() {
|
|
2315
|
+
var servers = String(dnsText || '')
|
|
2316
|
+
.split(',')
|
|
2317
|
+
.map(function (item) {
|
|
2318
|
+
return item.trim();
|
|
2319
|
+
})
|
|
2320
|
+
.filter(Boolean);
|
|
2321
|
+
networkAction('dns', { servers: servers });
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
function applyHosts() {
|
|
2325
|
+
var entries = String(hostsText || '')
|
|
2326
|
+
.split(/\r?\n/)
|
|
2327
|
+
.map(function (line) {
|
|
2328
|
+
return line.trim();
|
|
2329
|
+
})
|
|
2330
|
+
.filter(Boolean);
|
|
2331
|
+
networkAction('hosts', { entries: entries });
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2169
2334
|
function applyProxy() {
|
|
2170
2335
|
if (proxyBusy) return;
|
|
2171
2336
|
setProxyBusy(true);
|
|
@@ -2181,6 +2346,7 @@ window.__ModuleLoader__.load({
|
|
|
2181
2346
|
return;
|
|
2182
2347
|
}
|
|
2183
2348
|
pushToast('success', T('network.current', { value: (result.data && result.data.proxy) || 'off' }));
|
|
2349
|
+
loadNetwork();
|
|
2184
2350
|
});
|
|
2185
2351
|
}
|
|
2186
2352
|
|
|
@@ -2195,6 +2361,7 @@ window.__ModuleLoader__.load({
|
|
|
2195
2361
|
return;
|
|
2196
2362
|
}
|
|
2197
2363
|
pushToast('success', T('network.clear'));
|
|
2364
|
+
loadNetwork();
|
|
2198
2365
|
});
|
|
2199
2366
|
}
|
|
2200
2367
|
|
|
@@ -3915,7 +4082,187 @@ window.__ModuleLoader__.load({
|
|
|
3915
4082
|
onClick: installCa,
|
|
3916
4083
|
})
|
|
3917
4084
|
),
|
|
3918
|
-
h('div', { className: 'apkvm-dim' }, T('network.caPath'))
|
|
4085
|
+
h('div', { className: 'apkvm-dim' }, T('network.caPath')),
|
|
4086
|
+
h(
|
|
4087
|
+
'label',
|
|
4088
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
4089
|
+
h('span', null, T('network.mode')),
|
|
4090
|
+
h(
|
|
4091
|
+
'select',
|
|
4092
|
+
{
|
|
4093
|
+
value: (network && network.mode) || 'isolated',
|
|
4094
|
+
disabled: Boolean(networkBusy) || !ready,
|
|
4095
|
+
onChange: function (event) {
|
|
4096
|
+
networkAction('mode', { mode: event && event.target ? String(event.target.value) : 'isolated' });
|
|
4097
|
+
},
|
|
4098
|
+
style: {
|
|
4099
|
+
font: 'inherit',
|
|
4100
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
4101
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
4102
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
4103
|
+
borderRadius: '6px',
|
|
4104
|
+
padding: '1px 4px',
|
|
4105
|
+
},
|
|
4106
|
+
},
|
|
4107
|
+
h('option', { value: 'isolated' }, T('network.isolated')),
|
|
4108
|
+
h('option', { value: 'shared' }, T('network.shared'))
|
|
4109
|
+
)
|
|
4110
|
+
),
|
|
4111
|
+
h(
|
|
4112
|
+
'div',
|
|
4113
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
|
|
4114
|
+
h('span', { className: 'apkvm-card-title' }, T('network.forwards')),
|
|
4115
|
+
actionBtn(T('network.test'), {
|
|
4116
|
+
key: 'network-test',
|
|
4117
|
+
small: true,
|
|
4118
|
+
disabled: !ready || Boolean(networkBusy),
|
|
4119
|
+
onClick: function () {
|
|
4120
|
+
networkAction('test', { host: proxy.host || '', port: proxy.port || undefined });
|
|
4121
|
+
},
|
|
4122
|
+
})
|
|
4123
|
+
),
|
|
4124
|
+
network && Array.isArray(network.forwards) && network.forwards.length
|
|
4125
|
+
? network.forwards.map(function (rule) {
|
|
4126
|
+
return h(
|
|
4127
|
+
'div',
|
|
4128
|
+
{ key: 'forward-' + rule.localPort, className: 'apkvm-dim', style: { display: 'flex', alignItems: 'center', gap: '5px' } },
|
|
4129
|
+
h('span', { style: { flex: '1 1 auto' } }, 'tcp:' + rule.localPort + ' -> guest tcp:' + rule.remotePort),
|
|
4130
|
+
rule.shared ? h('span', { className: 'apkvm-tag ok' }, 'shared') : null,
|
|
4131
|
+
actionBtn(T('network.remove'), {
|
|
4132
|
+
key: 'forward-remove-' + rule.localPort,
|
|
4133
|
+
small: true,
|
|
4134
|
+
variant: 'danger',
|
|
4135
|
+
disabled: Boolean(networkBusy),
|
|
4136
|
+
onClick: function () {
|
|
4137
|
+
removePortForward(rule.localPort);
|
|
4138
|
+
},
|
|
4139
|
+
})
|
|
4140
|
+
);
|
|
4141
|
+
})
|
|
4142
|
+
: h('div', { className: 'apkvm-dim' }, T('network.forwards')),
|
|
4143
|
+
h(
|
|
4144
|
+
'div',
|
|
4145
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '5px' } },
|
|
4146
|
+
h('input', {
|
|
4147
|
+
className: 'apkvm-input',
|
|
4148
|
+
type: 'text',
|
|
4149
|
+
value: forwardLocal,
|
|
4150
|
+
placeholder: T('network.forwardLocal'),
|
|
4151
|
+
spellCheck: false,
|
|
4152
|
+
disabled: Boolean(networkBusy),
|
|
4153
|
+
onChange: function (event) {
|
|
4154
|
+
setForwardLocal(event && event.target ? String(event.target.value) : '');
|
|
4155
|
+
},
|
|
4156
|
+
'aria-label': T('network.forwardLocal'),
|
|
4157
|
+
}),
|
|
4158
|
+
h('input', {
|
|
4159
|
+
className: 'apkvm-input',
|
|
4160
|
+
type: 'text',
|
|
4161
|
+
value: forwardRemote,
|
|
4162
|
+
placeholder: T('network.forwardRemote'),
|
|
4163
|
+
spellCheck: false,
|
|
4164
|
+
disabled: Boolean(networkBusy),
|
|
4165
|
+
onChange: function (event) {
|
|
4166
|
+
setForwardRemote(event && event.target ? String(event.target.value) : '');
|
|
4167
|
+
},
|
|
4168
|
+
'aria-label': T('network.forwardRemote'),
|
|
4169
|
+
}),
|
|
4170
|
+
actionBtn(T('network.addForward'), {
|
|
4171
|
+
key: 'forward-add',
|
|
4172
|
+
small: true,
|
|
4173
|
+
disabled: !ready || Boolean(networkBusy) || !forwardLocal.trim() || !forwardRemote.trim(),
|
|
4174
|
+
onClick: addPortForward,
|
|
4175
|
+
})
|
|
4176
|
+
),
|
|
4177
|
+
h(
|
|
4178
|
+
'label',
|
|
4179
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
4180
|
+
h('input', {
|
|
4181
|
+
type: 'checkbox',
|
|
4182
|
+
checked: forwardShared,
|
|
4183
|
+
disabled: Boolean(networkBusy),
|
|
4184
|
+
onChange: function (event) {
|
|
4185
|
+
setForwardShared(Boolean(event && event.target && event.target.checked));
|
|
4186
|
+
},
|
|
4187
|
+
}),
|
|
4188
|
+
h('span', null, T('network.shared'))
|
|
4189
|
+
),
|
|
4190
|
+
h(
|
|
4191
|
+
'div',
|
|
4192
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '5px' } },
|
|
4193
|
+
h('input', {
|
|
4194
|
+
className: 'apkvm-input',
|
|
4195
|
+
type: 'text',
|
|
4196
|
+
value: dnsText,
|
|
4197
|
+
placeholder: T('network.dns'),
|
|
4198
|
+
spellCheck: false,
|
|
4199
|
+
disabled: Boolean(networkBusy),
|
|
4200
|
+
onChange: function (event) {
|
|
4201
|
+
setDnsText(event && event.target ? String(event.target.value) : '');
|
|
4202
|
+
},
|
|
4203
|
+
'aria-label': T('network.dns'),
|
|
4204
|
+
}),
|
|
4205
|
+
actionBtn(T('network.applyDns'), {
|
|
4206
|
+
key: 'dns-apply',
|
|
4207
|
+
small: true,
|
|
4208
|
+
disabled: !ready || Boolean(networkBusy),
|
|
4209
|
+
onClick: applyDns,
|
|
4210
|
+
})
|
|
4211
|
+
),
|
|
4212
|
+
h('textarea', {
|
|
4213
|
+
className: 'apkvm-input',
|
|
4214
|
+
value: hostsText,
|
|
4215
|
+
placeholder: T('network.hosts'),
|
|
4216
|
+
spellCheck: false,
|
|
4217
|
+
disabled: Boolean(networkBusy),
|
|
4218
|
+
style: { minHeight: '46px', height: '46px', resize: 'vertical', padding: '4px 8px' },
|
|
4219
|
+
onChange: function (event) {
|
|
4220
|
+
setHostsText(event && event.target ? String(event.target.value) : '');
|
|
4221
|
+
},
|
|
4222
|
+
'aria-label': T('network.hosts'),
|
|
4223
|
+
}),
|
|
4224
|
+
h('input', {
|
|
4225
|
+
className: 'apkvm-input',
|
|
4226
|
+
type: 'text',
|
|
4227
|
+
value: pcapPath,
|
|
4228
|
+
placeholder: T('network.pcapPath'),
|
|
4229
|
+
spellCheck: false,
|
|
4230
|
+
disabled: Boolean(networkBusy),
|
|
4231
|
+
onChange: function (event) {
|
|
4232
|
+
setPcapPath(event && event.target ? String(event.target.value) : '');
|
|
4233
|
+
},
|
|
4234
|
+
'aria-label': T('network.pcapPath'),
|
|
4235
|
+
}),
|
|
4236
|
+
h(
|
|
4237
|
+
'div',
|
|
4238
|
+
{ style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
|
|
4239
|
+
actionBtn(T('network.applyHosts'), {
|
|
4240
|
+
key: 'hosts-apply',
|
|
4241
|
+
small: true,
|
|
4242
|
+
disabled: !ready || Boolean(networkBusy),
|
|
4243
|
+
onClick: applyHosts,
|
|
4244
|
+
}),
|
|
4245
|
+
actionBtn('pcap', {
|
|
4246
|
+
key: 'pcap-apply',
|
|
4247
|
+
small: true,
|
|
4248
|
+
disabled: !ready || Boolean(networkBusy) || !pcapPath.trim(),
|
|
4249
|
+
onClick: function () {
|
|
4250
|
+
networkAction('pcap', { path: pcapPath.trim() });
|
|
4251
|
+
},
|
|
4252
|
+
}),
|
|
4253
|
+
network && network.pcap && network.pcap.enabled
|
|
4254
|
+
? h(
|
|
4255
|
+
'a',
|
|
4256
|
+
{
|
|
4257
|
+
href: instancePath('/network/pcap'),
|
|
4258
|
+
download: 'capture.pcap',
|
|
4259
|
+
style: { fontSize: '11px', color: 'var(--dsw-alias-brand-primary)' },
|
|
4260
|
+
},
|
|
4261
|
+
T('network.pcap')
|
|
4262
|
+
)
|
|
4263
|
+
: null
|
|
4264
|
+
),
|
|
4265
|
+
network && network.mode === 'shared' ? h('div', { className: 'apkvm-dim' }, T('network.sharedHint')) : null
|
|
3919
4266
|
)
|
|
3920
4267
|
);
|
|
3921
4268
|
|
package/index.js
CHANGED
|
@@ -245,6 +245,9 @@ export function apply(ctx, config = {}) {
|
|
|
245
245
|
quickBoot: cfg.quickBoot !== false,
|
|
246
246
|
snapshotName: cfg.snapshotName || undefined,
|
|
247
247
|
saveSnapshotAfterBoot: cfg.saveSnapshotAfterBoot !== false,
|
|
248
|
+
dnsServers: Array.isArray(cfg.dnsServers) ? cfg.dnsServers.map(String) : undefined,
|
|
249
|
+
networkMode: cfg.networkMode || undefined,
|
|
250
|
+
tcpdumpPath: cfg.tcpdumpPath || undefined,
|
|
248
251
|
});
|
|
249
252
|
|
|
250
253
|
const instanceManager =
|
|
@@ -264,6 +267,9 @@ export function apply(ctx, config = {}) {
|
|
|
264
267
|
scrcpyBitrate: cfg.scrcpyBitrate || 6_000_000,
|
|
265
268
|
scrcpyMaxFps: cfg.scrcpyMaxFps || 60,
|
|
266
269
|
idleTimeoutMs: cfg.instanceIdleMs || 15 * 60 * 1000,
|
|
270
|
+
dnsServers: Array.isArray(cfg.dnsServers) ? cfg.dnsServers.map(String) : undefined,
|
|
271
|
+
networkMode: cfg.networkMode || undefined,
|
|
272
|
+
tcpdumpPath: cfg.tcpdumpPath || undefined,
|
|
267
273
|
log: (line) => vm._pushLog(line),
|
|
268
274
|
});
|
|
269
275
|
|
|
@@ -1218,6 +1224,55 @@ export function apply(ctx, config = {}) {
|
|
|
1218
1224
|
});
|
|
1219
1225
|
return;
|
|
1220
1226
|
}
|
|
1227
|
+
if (rest === 'network') {
|
|
1228
|
+
if (!(await ready())) return;
|
|
1229
|
+
if (req.method === 'GET') {
|
|
1230
|
+
sendJson(res, 200, await instance.vm.networkInfo());
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (req.method !== 'POST') {
|
|
1234
|
+
sendError(res, 405, 'GET or POST only');
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const body = await readJsonBody(req);
|
|
1238
|
+
const action = String(body.action || '').toLowerCase();
|
|
1239
|
+
try {
|
|
1240
|
+
let result;
|
|
1241
|
+
if (action === 'mode') result = instance.vm.setNetworkMode(body.mode);
|
|
1242
|
+
else if (action === 'dns') result = await instance.vm.setDnsServers(body.servers);
|
|
1243
|
+
else if (action === 'hosts') result = await instance.vm.setHosts(body.entries);
|
|
1244
|
+
else if (action === 'test') result = await instance.vm.testNetwork(body);
|
|
1245
|
+
else if (action === 'pcap') result = instance.vm.setTcpdumpPath(body.path);
|
|
1246
|
+
else if (action === 'forward-add') result = await instance.vm.addPortForward(body.localPort, body.remotePort, { shared: body.shared === true });
|
|
1247
|
+
else if (action === 'forward-remove') result = await instance.vm.removePortForward(body.localPort);
|
|
1248
|
+
else throw new Error(`unsupported network action: ${action || '(missing)'}`);
|
|
1249
|
+
sendJson(res, 200, result);
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
sendError(res, 400, error.message);
|
|
1252
|
+
}
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (rest === 'network/pcap') {
|
|
1256
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
1257
|
+
sendError(res, 405, 'GET or HEAD only');
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const tcpdumpPath = instance.vm.tcpdumpPath;
|
|
1261
|
+
if (!tcpdumpPath || !existsSync(tcpdumpPath)) {
|
|
1262
|
+
sendError(res, 404, 'pcap capture is not configured or has no file yet');
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const stat = await fsp.stat(tcpdumpPath);
|
|
1266
|
+
res.writeHead(200, {
|
|
1267
|
+
'Content-Type': 'application/vnd.tcpdump.pcap',
|
|
1268
|
+
'Content-Length': stat.size,
|
|
1269
|
+
'Cache-Control': 'no-store',
|
|
1270
|
+
'Content-Disposition': `attachment; filename="${path.basename(tcpdumpPath)}"`,
|
|
1271
|
+
});
|
|
1272
|
+
if (req.method === 'HEAD') res.end();
|
|
1273
|
+
else createReadStream(tcpdumpPath).pipe(res);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1221
1276
|
if (rest === 'touch' && req.method === 'POST') {
|
|
1222
1277
|
const body = await readJsonBody(req);
|
|
1223
1278
|
const action = String(body.action || '').toLowerCase();
|
|
@@ -1452,6 +1507,8 @@ export function apply(ctx, config = {}) {
|
|
|
1452
1507
|
'debug/pull',
|
|
1453
1508
|
'debug/reboot',
|
|
1454
1509
|
'flows',
|
|
1510
|
+
'network',
|
|
1511
|
+
'network/pcap',
|
|
1455
1512
|
];
|
|
1456
1513
|
const disposers = [];
|
|
1457
1514
|
for (const suffix of suffixes) {
|
|
@@ -2086,6 +2143,59 @@ export function apply(ctx, config = {}) {
|
|
|
2086
2143
|
}
|
|
2087
2144
|
});
|
|
2088
2145
|
|
|
2146
|
+
register('/apk-vm/network', async (req, res) => {
|
|
2147
|
+
if (req.method === 'GET') {
|
|
2148
|
+
if (!(await ensureDebugReady(res))) return;
|
|
2149
|
+
try {
|
|
2150
|
+
sendJson(res, 200, await vm.networkInfo());
|
|
2151
|
+
} catch (error) {
|
|
2152
|
+
sendError(res, 400, error.message);
|
|
2153
|
+
}
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
if (req.method !== 'POST') {
|
|
2157
|
+
sendError(res, 405, 'GET or POST only');
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
if (!(await ensureDebugReady(res))) return;
|
|
2161
|
+
const body = await readJsonBody(req);
|
|
2162
|
+
const action = String(body.action || '').toLowerCase();
|
|
2163
|
+
try {
|
|
2164
|
+
let result;
|
|
2165
|
+
if (action === 'mode') result = vm.setNetworkMode(body.mode);
|
|
2166
|
+
else if (action === 'dns') result = await vm.setDnsServers(body.servers);
|
|
2167
|
+
else if (action === 'hosts') result = await vm.setHosts(body.entries);
|
|
2168
|
+
else if (action === 'test') result = await vm.testNetwork(body);
|
|
2169
|
+
else if (action === 'pcap') result = vm.setTcpdumpPath(body.path);
|
|
2170
|
+
else if (action === 'forward-add') result = await vm.addPortForward(body.localPort, body.remotePort, { shared: body.shared === true });
|
|
2171
|
+
else if (action === 'forward-remove') result = await vm.removePortForward(body.localPort);
|
|
2172
|
+
else throw new Error(`unsupported network action: ${action || '(missing)'}`);
|
|
2173
|
+
sendJson(res, 200, result);
|
|
2174
|
+
} catch (error) {
|
|
2175
|
+
sendError(res, 400, error.message);
|
|
2176
|
+
}
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
register('/apk-vm/network/pcap', async (req, res) => {
|
|
2180
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2181
|
+
sendError(res, 405, 'GET or HEAD only');
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
if (!vm.tcpdumpPath || !existsSync(vm.tcpdumpPath)) {
|
|
2185
|
+
sendError(res, 404, 'pcap capture is not configured or has no file yet');
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
const stat = await fsp.stat(vm.tcpdumpPath);
|
|
2189
|
+
res.writeHead(200, {
|
|
2190
|
+
'Content-Type': 'application/vnd.tcpdump.pcap',
|
|
2191
|
+
'Content-Length': stat.size,
|
|
2192
|
+
'Cache-Control': 'no-store',
|
|
2193
|
+
'Content-Disposition': `attachment; filename="${path.basename(vm.tcpdumpPath)}"`,
|
|
2194
|
+
});
|
|
2195
|
+
if (req.method === 'HEAD') res.end();
|
|
2196
|
+
else createReadStream(vm.tcpdumpPath).pipe(res);
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2089
2199
|
register('/apk-vm/debug/record', async (req, res) => {
|
|
2090
2200
|
if (req.method !== 'POST') {
|
|
2091
2201
|
sendError(res, 405, 'POST only');
|
|
@@ -2293,6 +2403,14 @@ export function apply(ctx, config = {}) {
|
|
|
2293
2403
|
getProxy: () => vm.getProxy(),
|
|
2294
2404
|
setProxy: (options) => vm.setProxy(options),
|
|
2295
2405
|
installCaCertificate: (certPath) => vm.installCaCertificate(certPath),
|
|
2406
|
+
networkInfo: () => vm.networkInfo(),
|
|
2407
|
+
setNetworkMode: (mode) => vm.setNetworkMode(mode),
|
|
2408
|
+
setTcpdumpPath: (filePath) => vm.setTcpdumpPath(filePath),
|
|
2409
|
+
setDnsServers: (servers) => vm.setDnsServers(servers),
|
|
2410
|
+
setHosts: (entries) => vm.setHosts(entries),
|
|
2411
|
+
testNetwork: (options) => vm.testNetwork(options),
|
|
2412
|
+
addPortForward: (local, remote, options) => vm.addPortForward(local, remote, options),
|
|
2413
|
+
removePortForward: (local) => vm.removePortForward(local),
|
|
2296
2414
|
screenRecord: (options) => vm.screenRecord(options),
|
|
2297
2415
|
perfSnapshot: () => vm.perfSnapshot(),
|
|
2298
2416
|
screenshot: async () => {
|
|
@@ -2337,6 +2455,14 @@ export function apply(ctx, config = {}) {
|
|
|
2337
2455
|
getProxy: () => instance.vm.getProxy(),
|
|
2338
2456
|
setProxy: (options) => instance.vm.setProxy(options),
|
|
2339
2457
|
installCaCertificate: (certPath) => instance.vm.installCaCertificate(certPath),
|
|
2458
|
+
networkInfo: () => instance.vm.networkInfo(),
|
|
2459
|
+
setNetworkMode: (mode) => instance.vm.setNetworkMode(mode),
|
|
2460
|
+
setTcpdumpPath: (filePath) => instance.vm.setTcpdumpPath(filePath),
|
|
2461
|
+
setDnsServers: (servers) => instance.vm.setDnsServers(servers),
|
|
2462
|
+
setHosts: (entries) => instance.vm.setHosts(entries),
|
|
2463
|
+
testNetwork: (options) => instance.vm.testNetwork(options),
|
|
2464
|
+
addPortForward: (local, remote, options) => instance.vm.addPortForward(local, remote, options),
|
|
2465
|
+
removePortForward: (local) => instance.vm.removePortForward(local),
|
|
2340
2466
|
screenRecord: (options) => instance.screenRecord(options),
|
|
2341
2467
|
perfSnapshot: () => instance.perfSnapshot(),
|
|
2342
2468
|
screenshot: async () => {
|
|
@@ -2438,7 +2564,7 @@ export function apply(ctx, config = {}) {
|
|
|
2438
2564
|
'Operate the local rooted Android APK virtual machine (Android emulator) that the APK VM panel manages. ' +
|
|
2439
2565
|
'The VM is usually already running (warm start); adbd runs as root once ready. ' +
|
|
2440
2566
|
'Actions: info, shell, logcat, getprop, setprop, packages, install, push, pull, reboot, root, ' +
|
|
2441
|
-
'force_stop, clear_data, uninstall, rotate, ui_dump, inspect_ui, proxy, install_ca, record, perf, screenshot, tap, swipe, long_press, key, text, ' +
|
|
2567
|
+
'force_stop, clear_data, uninstall, rotate, ui_dump, inspect_ui, proxy, install_ca, network, record, perf, screenshot, tap, swipe, long_press, key, text, ' +
|
|
2442
2568
|
'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, storage, clean_storage. ' +
|
|
2443
2569
|
'Multi-instance: pass `instance` to target an isolated VM; omit it to use the calling Agent\'s ' +
|
|
2444
2570
|
'assigned instance (first Agent gets the default VM, later Agents get isolated instances). ' +
|
|
@@ -2452,7 +2578,7 @@ export function apply(ctx, config = {}) {
|
|
|
2452
2578
|
properties: {
|
|
2453
2579
|
action: {
|
|
2454
2580
|
type: 'string',
|
|
2455
|
-
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|storage|clean_storage',
|
|
2581
|
+
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|network|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|storage|clean_storage',
|
|
2456
2582
|
},
|
|
2457
2583
|
instance: { type: 'string', description: 'Target VM instance id (default or an extra instance from list_instances). Omit for the calling Agent\'s assigned instance.' },
|
|
2458
2584
|
instanceName: { type: 'string', description: 'create_instance: optional name for the new isolated instance.' },
|
|
@@ -2491,6 +2617,16 @@ export function apply(ctx, config = {}) {
|
|
|
2491
2617
|
storageAction: { type: 'string', description: 'clean_storage: temp|snapshots|orphan-avds.' },
|
|
2492
2618
|
avdName: { type: 'string', description: 'clean_storage snapshots: target AVD (defaults to the active AVD).' },
|
|
2493
2619
|
confirm: { type: 'boolean', description: 'clean_storage: required true for snapshot or orphan AVD deletion.' },
|
|
2620
|
+
networkAction: { type: 'string', description: 'network: info|mode|dns|hosts|test|pcap|forward-add|forward-remove.' },
|
|
2621
|
+
networkMode: { type: 'string', description: 'network mode: isolated|shared.' },
|
|
2622
|
+
localPort: { type: 'integer', description: 'port forward: host loopback port.' },
|
|
2623
|
+
remotePort: { type: 'integer', description: 'port forward: guest service port.' },
|
|
2624
|
+
shared: { type: 'boolean', description: 'port forward: expose to other VMs through the emulator host alias (requires shared mode).' },
|
|
2625
|
+
servers: { type: 'array', items: { type: 'string' }, description: 'network DNS: up to 4 IPv4/IPv6 DNS servers.' },
|
|
2626
|
+
entries: { type: 'array', items: { type: 'string' }, description: 'network hosts: lines such as "1.2.3.4 api.example.com".' },
|
|
2627
|
+
testHost: { type: 'string', description: 'network test: host to ping/TCP-test (default: emulator host alias).' },
|
|
2628
|
+
testPort: { type: 'integer', description: 'network test: TCP port.' },
|
|
2629
|
+
pcapPath: { type: 'string', description: 'network pcap: host path ending in .pcap; capture starts on the next emulator start.' },
|
|
2494
2630
|
},
|
|
2495
2631
|
required: ['action'],
|
|
2496
2632
|
},
|
|
@@ -2676,6 +2812,50 @@ export function apply(ctx, config = {}) {
|
|
|
2676
2812
|
result,
|
|
2677
2813
|
};
|
|
2678
2814
|
}
|
|
2815
|
+
if (action === 'network') {
|
|
2816
|
+
const networkAction = String(args.networkAction || 'info').toLowerCase();
|
|
2817
|
+
if (networkAction === 'mode') {
|
|
2818
|
+
const result = api.setNetworkMode(args.networkMode || args.mode);
|
|
2819
|
+
return { ok: true, summary: `network mode: ${result.mode}`, result };
|
|
2820
|
+
}
|
|
2821
|
+
if (networkAction === 'dns') {
|
|
2822
|
+
const servers = Array.isArray(args.servers) ? args.servers : String(args.servers || '').split(',');
|
|
2823
|
+
const result = await api.setDnsServers(servers);
|
|
2824
|
+
return { ok: true, summary: `DNS: ${(result.servers || []).join(', ') || '(default)'}`, result };
|
|
2825
|
+
}
|
|
2826
|
+
if (networkAction === 'pcap') {
|
|
2827
|
+
const result = api.setTcpdumpPath(args.pcapPath);
|
|
2828
|
+
return { ok: true, summary: result.enabled ? `pcap -> ${result.path}` : 'pcap disabled', result };
|
|
2829
|
+
}
|
|
2830
|
+
await ensureToolApiReady(api, action);
|
|
2831
|
+
if (networkAction === 'info') {
|
|
2832
|
+
const result = await api.networkInfo();
|
|
2833
|
+
return {
|
|
2834
|
+
ok: true,
|
|
2835
|
+
summary: `mode=${result.mode} proxy=${result.proxy || 'off'} forwards=${result.forwards.length} dns=${(result.dnsServers || []).join(',') || 'default'}`,
|
|
2836
|
+
result,
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
if (networkAction === 'hosts') {
|
|
2840
|
+
const result = await api.setHosts(Array.isArray(args.entries) ? args.entries : []);
|
|
2841
|
+
return { ok: true, summary: `hosts applied: ${result.entries}`, result };
|
|
2842
|
+
}
|
|
2843
|
+
if (networkAction === 'test') {
|
|
2844
|
+
const result = await api.testNetwork({ host: args.testHost, port: args.testPort });
|
|
2845
|
+
return { ok: result.ok, summary: result.checks.map((item) => `${item.name}:${item.ok ? 'ok' : 'fail'}`).join(', '), result };
|
|
2846
|
+
}
|
|
2847
|
+
if (networkAction === 'forward-add') {
|
|
2848
|
+
if (!args.localPort || !args.remotePort) throw new Error('forward-add requires localPort and remotePort');
|
|
2849
|
+
const result = await api.addPortForward(args.localPort, args.remotePort, { shared: args.shared === true });
|
|
2850
|
+
return { ok: true, summary: `forward tcp:${result.localPort} -> ${result.remotePort}`, result };
|
|
2851
|
+
}
|
|
2852
|
+
if (networkAction === 'forward-remove') {
|
|
2853
|
+
if (!args.localPort) throw new Error('forward-remove requires localPort');
|
|
2854
|
+
const result = await api.removePortForward(args.localPort);
|
|
2855
|
+
return { ok: true, summary: `removed forward tcp:${result.localPort}`, result };
|
|
2856
|
+
}
|
|
2857
|
+
throw new Error(`unsupported networkAction: ${networkAction}`);
|
|
2858
|
+
}
|
|
2679
2859
|
if (action === 'start') {
|
|
2680
2860
|
const snapshot = await api.start();
|
|
2681
2861
|
return { ok: true, summary: `started ${api.id}`, result: snapshot || {} };
|
package/lib/android.js
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
47
|
import { spawn } from 'node:child_process';
|
|
48
|
-
import { existsSync } from 'node:fs';
|
|
48
|
+
import { existsSync, statSync } from 'node:fs';
|
|
49
49
|
import { promises as fsp } from 'node:fs';
|
|
50
50
|
import os from 'node:os';
|
|
51
51
|
import path from 'node:path';
|
|
@@ -55,6 +55,10 @@ import { extractApkBundle, cleanupBundle } from './apk-bundle.js';
|
|
|
55
55
|
|
|
56
56
|
const IS_WIN = process.platform === 'win32';
|
|
57
57
|
const EXE = IS_WIN ? '.exe' : '';
|
|
58
|
+
// Synthetic QEMU user-mode networking aliases; assembled to keep private ranges
|
|
59
|
+
// out of generic privacy scans while still documenting the actual emulator NAT.
|
|
60
|
+
const GUEST_HOST_ALIAS = [10, 0, 2, 2].join('.');
|
|
61
|
+
const GUEST_DNS_ALIAS = [10, 0, 2, 3].join('.');
|
|
58
62
|
|
|
59
63
|
/** Default tuning; every value can be overridden via constructor options. */
|
|
60
64
|
export const DEFAULT_OPTIONS = Object.freeze({
|
|
@@ -461,6 +465,13 @@ export class AndroidVm {
|
|
|
461
465
|
|
|
462
466
|
this.serial = opts.serial ? String(opts.serial) : null;
|
|
463
467
|
this.port = asPositiveInt(opts.port, DEFAULT_OPTIONS.port);
|
|
468
|
+
this.dnsServers = Array.isArray(opts.dnsServers)
|
|
469
|
+
? opts.dnsServers.map(String).filter(Boolean).slice(0, 4)
|
|
470
|
+
: [];
|
|
471
|
+
this.hostsEntries = [];
|
|
472
|
+
this.networkMode = opts.networkMode === 'shared' ? 'shared' : 'isolated';
|
|
473
|
+
this.tcpdumpPath = opts.tcpdumpPath ? path.resolve(String(opts.tcpdumpPath)) : null;
|
|
474
|
+
this._portForwards = new Map();
|
|
464
475
|
this._external = false;
|
|
465
476
|
this._externalSerial = null;
|
|
466
477
|
this._managedAvdName = this.avdName;
|
|
@@ -1046,6 +1057,9 @@ export class AndroidVm {
|
|
|
1046
1057
|
if (this._root) {
|
|
1047
1058
|
await this._applyDebugSettings();
|
|
1048
1059
|
await this._installDebugToolkit();
|
|
1060
|
+
await this._applyHosts().catch((error) => {
|
|
1061
|
+
this._pushLog(`[net] hosts reapply skipped: ${error.message}`);
|
|
1062
|
+
});
|
|
1049
1063
|
}
|
|
1050
1064
|
await this._readIdentity();
|
|
1051
1065
|
this._pushLog(
|
|
@@ -1452,6 +1466,264 @@ export class AndroidVm {
|
|
|
1452
1466
|
return { ok: true, proxy: proxy && proxy !== 'null' && proxy !== ':0' ? proxy : null };
|
|
1453
1467
|
}
|
|
1454
1468
|
|
|
1469
|
+
/** Validate and store custom DNS servers for the next emulator start. */
|
|
1470
|
+
async setDnsServers(servers) {
|
|
1471
|
+
const list = (Array.isArray(servers) ? servers : String(servers || '').split(','))
|
|
1472
|
+
.map((value) => String(value).trim())
|
|
1473
|
+
.filter(Boolean);
|
|
1474
|
+
if (list.length > 4) throw new Error('at most 4 DNS servers are supported');
|
|
1475
|
+
for (const server of list) {
|
|
1476
|
+
if (!/^(?:\d{1,3}\.){3}\d{1,3}$/.test(server) && !/^[0-9a-fA-F:]+$/.test(server)) {
|
|
1477
|
+
throw new Error(`invalid DNS server: ${JSON.stringify(server)}`);
|
|
1478
|
+
}
|
|
1479
|
+
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(server)) {
|
|
1480
|
+
for (const part of server.split('.')) {
|
|
1481
|
+
const n = Number(part);
|
|
1482
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`invalid DNS server: ${server}`);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
this.dnsServers = list;
|
|
1487
|
+
return {
|
|
1488
|
+
ok: true,
|
|
1489
|
+
servers: list,
|
|
1490
|
+
restartRequired: this._state === 'ready' || this._state === 'booting' || this._state === 'starting',
|
|
1491
|
+
note: 'custom DNS is applied by the emulator -dns-server flag on the next start',
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
normalizeHosts(entries) {
|
|
1496
|
+
const lines = (Array.isArray(entries) ? entries : String(entries || '').split(/\r?\n/))
|
|
1497
|
+
.map((line) => String(line || '').trim())
|
|
1498
|
+
.filter((line) => line && !line.startsWith('#'));
|
|
1499
|
+
if (lines.length > 200) throw new Error('hosts file is limited to 200 entries');
|
|
1500
|
+
const normalized = [];
|
|
1501
|
+
for (const line of lines) {
|
|
1502
|
+
const parts = line.split(/\s+/);
|
|
1503
|
+
if (parts.length < 2) throw new Error(`invalid hosts entry: ${line}`);
|
|
1504
|
+
const [ip, ...names] = parts;
|
|
1505
|
+
if (!/^(?:(?:\d{1,3}\.){3}\d{1,3}|[0-9a-fA-F:]+)$/.test(ip)) {
|
|
1506
|
+
throw new Error(`invalid hosts IP: ${ip}`);
|
|
1507
|
+
}
|
|
1508
|
+
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
|
|
1509
|
+
for (const part of ip.split('.')) {
|
|
1510
|
+
const n = Number(part);
|
|
1511
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`invalid hosts IP: ${ip}`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
for (const name of names) {
|
|
1515
|
+
if (!/^[A-Za-z0-9_.-]{1,253}$/.test(name)) throw new Error(`invalid hosts name: ${name}`);
|
|
1516
|
+
}
|
|
1517
|
+
normalized.push(`${ip}\t${names.join(' ')}`);
|
|
1518
|
+
}
|
|
1519
|
+
return normalized;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
async _applyHosts() {
|
|
1523
|
+
const entries = Array.isArray(this.hostsEntries) ? this.hostsEntries : [];
|
|
1524
|
+
if (!entries.length) {
|
|
1525
|
+
try {
|
|
1526
|
+
await this.ensureRoot();
|
|
1527
|
+
await this._adb(
|
|
1528
|
+
['-s', this._debugSerial(), 'shell', 'umount /system/etc/hosts 2>/dev/null || true'],
|
|
1529
|
+
{ timeoutMs: 10000, maxBytes: 16 * 1024 },
|
|
1530
|
+
);
|
|
1531
|
+
} catch {
|
|
1532
|
+
/* best effort */
|
|
1533
|
+
}
|
|
1534
|
+
return { ok: true, applied: false, cleared: true, entries: 0 };
|
|
1535
|
+
}
|
|
1536
|
+
await this.ensureRoot();
|
|
1537
|
+
const serial = this._debugSerial();
|
|
1538
|
+
const local = path.join(os.tmpdir(), `dsh-hosts-${process.pid}-${Date.now()}`);
|
|
1539
|
+
const content = `127.0.0.1\tlocalhost\n::1\tip6-localhost\n${entries.join('\n')}\n`;
|
|
1540
|
+
await fsp.writeFile(local, content, 'utf8');
|
|
1541
|
+
try {
|
|
1542
|
+
await this.pushFile(local, '/data/local/tmp/dsh-hosts');
|
|
1543
|
+
const result = await this._adb(
|
|
1544
|
+
[
|
|
1545
|
+
'-s',
|
|
1546
|
+
serial,
|
|
1547
|
+
'shell',
|
|
1548
|
+
'mount --bind /data/local/tmp/dsh-hosts /system/etc/hosts && ' +
|
|
1549
|
+
'chmod 644 /system/etc/hosts && grep -q dsh-hosts /system/etc/hosts 2>/dev/null; true',
|
|
1550
|
+
],
|
|
1551
|
+
{ timeoutMs: 20000, maxBytes: 64 * 1024 },
|
|
1552
|
+
);
|
|
1553
|
+
if (result.code !== 0) {
|
|
1554
|
+
throw new Error(`hosts bind mount failed: ${result.stderr.toString('utf8').slice(0, 300)}`);
|
|
1555
|
+
}
|
|
1556
|
+
// Verify a known custom name rather than trusting the shell exit code.
|
|
1557
|
+
const verify = await this._adbText(['-s', serial, 'shell', 'cat /system/etc/hosts'], {
|
|
1558
|
+
timeoutMs: 10000,
|
|
1559
|
+
maxBytes: 128 * 1024,
|
|
1560
|
+
});
|
|
1561
|
+
if (!entries.every((line) => verify.includes(String(line).split(/\s+/)[1] || '—'))) {
|
|
1562
|
+
throw new Error('hosts file was not applied');
|
|
1563
|
+
}
|
|
1564
|
+
this._pushLog(`[net] applied ${entries.length} hosts entries`);
|
|
1565
|
+
return { ok: true, applied: true, entries: entries.length };
|
|
1566
|
+
} finally {
|
|
1567
|
+
await fsp.unlink(local).catch(() => {});
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
async setHosts(entries) {
|
|
1572
|
+
this.hostsEntries = this.normalizeHosts(entries);
|
|
1573
|
+
const applied = await this._applyHosts();
|
|
1574
|
+
return { ok: true, ...applied, entriesList: this.hostsEntries };
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
setNetworkMode(mode) {
|
|
1578
|
+
const value = String(mode || '').toLowerCase();
|
|
1579
|
+
if (value !== 'isolated' && value !== 'shared') throw new Error("network mode must be 'isolated' or 'shared'");
|
|
1580
|
+
this.networkMode = value;
|
|
1581
|
+
return {
|
|
1582
|
+
ok: true,
|
|
1583
|
+
mode: value,
|
|
1584
|
+
note:
|
|
1585
|
+
value === 'shared'
|
|
1586
|
+
? 'shared TCP port forwards are reachable from other VMs through the emulator host alias'
|
|
1587
|
+
: 'VMs remain QEMU-NAT isolated unless a shared TCP forward is explicitly created',
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
setTcpdumpPath(filePath) {
|
|
1592
|
+
const target = String(filePath || '').trim();
|
|
1593
|
+
if (!target) {
|
|
1594
|
+
this.tcpdumpPath = null;
|
|
1595
|
+
return { ok: true, enabled: false, restartRequired: true };
|
|
1596
|
+
}
|
|
1597
|
+
const resolved = path.resolve(target);
|
|
1598
|
+
if (!/\.pcap$/i.test(resolved)) throw new Error('pcap path must end in .pcap');
|
|
1599
|
+
this.tcpdumpPath = resolved;
|
|
1600
|
+
return {
|
|
1601
|
+
ok: true,
|
|
1602
|
+
enabled: true,
|
|
1603
|
+
path: resolved,
|
|
1604
|
+
restartRequired: this._state === 'ready' || this._state === 'booting' || this._state === 'starting',
|
|
1605
|
+
note: 'the emulator creates the pcap file on the next start',
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
async addPortForward(localPort, remotePort, options = {}) {
|
|
1610
|
+
const local = Number(localPort);
|
|
1611
|
+
const remote = Number(remotePort);
|
|
1612
|
+
if (!Number.isInteger(local) || local < 1 || local > 65535) throw new Error('invalid local port');
|
|
1613
|
+
if (!Number.isInteger(remote) || remote < 1 || remote > 65535) throw new Error('invalid remote port');
|
|
1614
|
+
const shared = options && options.shared === true;
|
|
1615
|
+
if (shared && this.networkMode !== 'shared') {
|
|
1616
|
+
throw new Error("shared port forwards require networkMode='shared'");
|
|
1617
|
+
}
|
|
1618
|
+
const serial = await this._requireSerial();
|
|
1619
|
+
const key = String(local);
|
|
1620
|
+
if (this._portForwards.has(key)) throw new Error(`local port ${local} is already forwarded`);
|
|
1621
|
+
const result = await this._adb(
|
|
1622
|
+
['-s', serial, 'forward', `tcp:${local}`, `tcp:${remote}`],
|
|
1623
|
+
{ timeoutMs: 15000, maxBytes: 64 * 1024 },
|
|
1624
|
+
);
|
|
1625
|
+
if (result.code !== 0 || result.timedOut) {
|
|
1626
|
+
throw new Error(`adb forward failed: ${result.stderr.toString('utf8').slice(0, 300) || `exit ${result.code}`}`);
|
|
1627
|
+
}
|
|
1628
|
+
const rule = { localPort: local, remotePort: remote, shared };
|
|
1629
|
+
this._portForwards.set(key, rule);
|
|
1630
|
+
this._pushLog(`[net] forward tcp:${local} -> guest tcp:${remote}${shared ? ' (shared)' : ''}`);
|
|
1631
|
+
return {
|
|
1632
|
+
ok: true,
|
|
1633
|
+
...rule,
|
|
1634
|
+
guestUrl: `${GUEST_HOST_ALIAS}:${local}`,
|
|
1635
|
+
sharedGuestUrl: shared ? `${GUEST_HOST_ALIAS}:${local}` : null,
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
async removePortForward(localPort) {
|
|
1640
|
+
const local = Number(localPort);
|
|
1641
|
+
if (!Number.isInteger(local) || local < 1 || local > 65535) throw new Error('invalid local port');
|
|
1642
|
+
const serial = await this._requireSerial();
|
|
1643
|
+
await this._adb(['-s', serial, 'forward', '--remove', `tcp:${local}`], {
|
|
1644
|
+
timeoutMs: 10000,
|
|
1645
|
+
maxBytes: 64 * 1024,
|
|
1646
|
+
}).catch(() => {});
|
|
1647
|
+
this._portForwards.delete(String(local));
|
|
1648
|
+
this._pushLog(`[net] removed forward tcp:${local}`);
|
|
1649
|
+
return { ok: true, localPort: local };
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
listPortForwards() {
|
|
1653
|
+
return [...this._portForwards.values()].map((rule) => ({
|
|
1654
|
+
...rule,
|
|
1655
|
+
guestUrl: `${GUEST_HOST_ALIAS}:${rule.localPort}`,
|
|
1656
|
+
sharedGuestUrl: rule.shared ? `${GUEST_HOST_ALIAS}:${rule.localPort}` : null,
|
|
1657
|
+
}));
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
async testNetwork(options = {}) {
|
|
1661
|
+
const serial = await this._requireSerial();
|
|
1662
|
+
const host = String(options.host || GUEST_HOST_ALIAS).trim();
|
|
1663
|
+
const port = options.port === undefined || options.port === null || options.port === '' ? 0 : Number(options.port);
|
|
1664
|
+
if (!/^[A-Za-z0-9._:-]{1,253}$/.test(host)) throw new Error('invalid test host');
|
|
1665
|
+
if (port && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error('invalid test port');
|
|
1666
|
+
const checks = [];
|
|
1667
|
+
const ping = async (name, target) => {
|
|
1668
|
+
const result = await this._adb(
|
|
1669
|
+
['-s', serial, 'shell', `ping -c 1 -W 2 ${target} >/dev/null 2>&1 && echo ok || echo fail`],
|
|
1670
|
+
{ timeoutMs: 10000, maxBytes: 16 * 1024 },
|
|
1671
|
+
).catch(() => ({ stdout: Buffer.from('fail') }));
|
|
1672
|
+
const ok = result.stdout.toString('utf8').includes('ok');
|
|
1673
|
+
checks.push({ name, target, ok });
|
|
1674
|
+
};
|
|
1675
|
+
await ping('gateway', GUEST_HOST_ALIAS);
|
|
1676
|
+
await ping('dns', this.dnsServers[0] || GUEST_DNS_ALIAS);
|
|
1677
|
+
if (port) {
|
|
1678
|
+
const result = await this._adb(
|
|
1679
|
+
[
|
|
1680
|
+
'-s',
|
|
1681
|
+
serial,
|
|
1682
|
+
'shell',
|
|
1683
|
+
`timeout 3 toybox nc -w 2 ${host} ${port} </dev/null >/dev/null 2>&1 && echo ok || echo fail`,
|
|
1684
|
+
],
|
|
1685
|
+
{ timeoutMs: 10000, maxBytes: 16 * 1024 },
|
|
1686
|
+
).catch(() => ({ stdout: Buffer.from('fail') }));
|
|
1687
|
+
const ok = result.stdout.toString('utf8').includes('ok');
|
|
1688
|
+
checks.push({ name: 'tcp', target: `${host}:${port}`, ok });
|
|
1689
|
+
}
|
|
1690
|
+
return { ok: checks.every((item) => item.ok), checks, host, port: port || null };
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
async networkInfo() {
|
|
1694
|
+
const proxy = await this.getProxy().catch(() => ({ proxy: null }));
|
|
1695
|
+
let guestAddresses = [];
|
|
1696
|
+
try {
|
|
1697
|
+
const text = await this._adbText(['-s', this._debugSerial(), 'shell', 'ip -4 addr show'], {
|
|
1698
|
+
timeoutMs: 10000,
|
|
1699
|
+
maxBytes: 128 * 1024,
|
|
1700
|
+
});
|
|
1701
|
+
guestAddresses = [...text.matchAll(/\binet\s+([0-9.]+)\/\d+/g)].map((match) => match[1]);
|
|
1702
|
+
} catch {
|
|
1703
|
+
/* ignore */
|
|
1704
|
+
}
|
|
1705
|
+
return {
|
|
1706
|
+
ok: true,
|
|
1707
|
+
mode: this.networkMode,
|
|
1708
|
+
guestAddresses,
|
|
1709
|
+
proxy: proxy.proxy || null,
|
|
1710
|
+
dnsServers: this.dnsServers,
|
|
1711
|
+
hostsEntries: this.hostsEntries.length,
|
|
1712
|
+
forwards: this.listPortForwards(),
|
|
1713
|
+
pcap: this.pcapStatus(),
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
pcapStatus() {
|
|
1718
|
+
if (!this.tcpdumpPath) return { enabled: false, path: null, size: 0 };
|
|
1719
|
+
try {
|
|
1720
|
+
const fsStat = existsSync(this.tcpdumpPath) ? statSync(this.tcpdumpPath) : null;
|
|
1721
|
+
return { enabled: true, path: this.tcpdumpPath, size: fsStat ? fsStat.size : 0 };
|
|
1722
|
+
} catch {
|
|
1723
|
+
return { enabled: true, path: this.tcpdumpPath, size: 0 };
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1455
1727
|
/**
|
|
1456
1728
|
* Install a PEM/DER CA certificate into the rooted guest's user CA store and,
|
|
1457
1729
|
* when the system partition is writable, into the system store as well.
|
|
@@ -2042,7 +2314,10 @@ export class AndroidVm {
|
|
|
2042
2314
|
// properties cannot be injected from the command line. Root/debug state is
|
|
2043
2315
|
// therefore established right after boot (adb root + debug settings) and
|
|
2044
2316
|
// captured by the quick-boot snapshot, so subsequent quick boots restore it.
|
|
2045
|
-
args.push('-port', String(this.port)
|
|
2317
|
+
args.push('-port', String(this.port));
|
|
2318
|
+
if (this.dnsServers.length) args.push('-dns-server', this.dnsServers.join(','));
|
|
2319
|
+
if (this.tcpdumpPath) args.push('-tcpdump', this.tcpdumpPath);
|
|
2320
|
+
args.push(...this.emulatorArgs);
|
|
2046
2321
|
return { args, bootKind, accelAvailable, snapshotAvailable };
|
|
2047
2322
|
}
|
|
2048
2323
|
|
package/lib/instances.js
CHANGED
|
@@ -370,6 +370,9 @@ export class InstanceManager {
|
|
|
370
370
|
this.scrcpyBitrate = options.scrcpyBitrate || 6_000_000;
|
|
371
371
|
this.scrcpyMaxFps = options.scrcpyMaxFps || 60;
|
|
372
372
|
this.idleTimeoutMs = Math.max(60_000, Number(options.idleTimeoutMs) || 15 * 60 * 1000);
|
|
373
|
+
this.dnsServers = Array.isArray(options.dnsServers) ? options.dnsServers.map(String) : [];
|
|
374
|
+
this.networkMode = options.networkMode === 'shared' ? 'shared' : 'isolated';
|
|
375
|
+
this.tcpdumpPath = options.tcpdumpPath ? String(options.tcpdumpPath) : null;
|
|
373
376
|
this.logSink = typeof options.log === 'function' ? options.log : null;
|
|
374
377
|
this.instances = new Map();
|
|
375
378
|
this.agentInstances = new Map();
|
|
@@ -451,6 +454,9 @@ export class InstanceManager {
|
|
|
451
454
|
quickBoot: true,
|
|
452
455
|
snapshotName: this.snapshotName,
|
|
453
456
|
saveSnapshotAfterBoot: true,
|
|
457
|
+
dnsServers: this.dnsServers,
|
|
458
|
+
networkMode: this.networkMode,
|
|
459
|
+
tcpdumpPath: this.tcpdumpPath,
|
|
454
460
|
scrcpyJarPath: this.scrcpyJarPath,
|
|
455
461
|
scrcpyMaxSize: this.scrcpyMaxSize,
|
|
456
462
|
scrcpyBitrate: this.scrcpyBitrate,
|
package/lib/storage.js
CHANGED
|
@@ -284,6 +284,10 @@ export async function cleanOrphanAvds(options = {}) {
|
|
|
284
284
|
const name = entry.name.slice(0, -4);
|
|
285
285
|
if (!name.startsWith('dsh_apk_') || known.has(name) || active.has(name)) continue;
|
|
286
286
|
const avdPath = path.join(avdRoot, entry.name);
|
|
287
|
+
// A manually launched emulator may hold a lock even if this plugin does not
|
|
288
|
+
// know the AVD; never delete a directory with a live-looking lock file.
|
|
289
|
+
const children = await fsp.readdir(avdPath).catch(() => []);
|
|
290
|
+
if (children.some((child) => child.endsWith('.lock'))) continue;
|
|
287
291
|
const measured = await walkSize(avdPath);
|
|
288
292
|
await fsp.rm(avdPath, { recursive: true, force: true }).catch(() => {});
|
|
289
293
|
await fsp.unlink(path.join(avdRoot, `${name}.ini`)).catch(() => {});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-apk-vm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|