dsh-apk-vm 0.4.1 → 0.6.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 +44 -0
- package/README.md +56 -6
- package/client.js +547 -1
- package/index.js +318 -5
- package/lib/android.js +277 -2
- package/lib/instances.js +6 -0
- package/lib/storage.js +300 -0
- package/package.json +3 -2
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
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage accounting and housekeeping for the Android VM.
|
|
3
|
+
*
|
|
4
|
+
* All reported sizes are logical bytes (sparse files are counted by length), so
|
|
5
|
+
* the panel distinguishes apparent AVD size from actual free space. Cleanup is
|
|
6
|
+
* explicit: this module never deletes an AVD or snapshot unless the caller asks
|
|
7
|
+
* for that exact action.
|
|
8
|
+
*/
|
|
9
|
+
import { promises as fsp } from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const MAX_FILES = 200000;
|
|
14
|
+
|
|
15
|
+
async function walkSize(root, options = {}) {
|
|
16
|
+
const result = { bytes: 0, files: 0, skipped: false };
|
|
17
|
+
const stack = [String(root)];
|
|
18
|
+
while (stack.length) {
|
|
19
|
+
const dir = stack.pop();
|
|
20
|
+
let entries;
|
|
21
|
+
try {
|
|
22
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
23
|
+
} catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (options.stopAt && result.bytes > options.stopAt) {
|
|
28
|
+
result.skipped = true;
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
if (result.files >= MAX_FILES) {
|
|
32
|
+
result.skipped = true;
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
const full = path.join(dir, entry.name);
|
|
36
|
+
if (entry.isSymbolicLink()) continue;
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
stack.push(full);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!entry.isFile()) continue;
|
|
42
|
+
try {
|
|
43
|
+
const stat = await fsp.stat(full);
|
|
44
|
+
result.bytes += stat.size;
|
|
45
|
+
result.files += 1;
|
|
46
|
+
} catch {
|
|
47
|
+
/* vanished */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readAvdIni(avdRoot, name) {
|
|
55
|
+
try {
|
|
56
|
+
const text = await fsp.readFile(path.join(avdRoot, `${name}.ini`), 'utf8');
|
|
57
|
+
const match = /^path=(.+)$/m.exec(text);
|
|
58
|
+
return match ? match[1].trim() : path.join(avdRoot, `${name}.avd`);
|
|
59
|
+
} catch {
|
|
60
|
+
return path.join(avdRoot, `${name}.avd`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatBytes(bytes) {
|
|
65
|
+
const value = Number(bytes) || 0;
|
|
66
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
67
|
+
let index = 0;
|
|
68
|
+
let size = value;
|
|
69
|
+
while (size >= 1024 && index < units.length - 1) {
|
|
70
|
+
size /= 1024;
|
|
71
|
+
index += 1;
|
|
72
|
+
}
|
|
73
|
+
return `${size >= 10 || index === 0 ? Math.round(size) : size.toFixed(1)} ${units[index]}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fileStatOrNull(file) {
|
|
77
|
+
try {
|
|
78
|
+
return await fsp.stat(file);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Collect AVD sizes, SDK sizes, plugin temp files and free disk space. */
|
|
85
|
+
export async function collectStorage(options = {}) {
|
|
86
|
+
const home = options.home || os.homedir();
|
|
87
|
+
const avdRoot = path.resolve(options.avdRoot || path.join(home, '.android', 'avd'));
|
|
88
|
+
const tempRoot = path.resolve(options.tempRoot || os.tmpdir());
|
|
89
|
+
const sdkRoot = options.sdkRoot ? path.resolve(options.sdkRoot) : null;
|
|
90
|
+
const activeAvds = new Set((options.activeAvds || []).map(String));
|
|
91
|
+
const knownAvds = new Set((options.knownAvds || []).map(String));
|
|
92
|
+
|
|
93
|
+
const avds = [];
|
|
94
|
+
let avdEntries = [];
|
|
95
|
+
try {
|
|
96
|
+
avdEntries = await fsp.readdir(avdRoot, { withFileTypes: true });
|
|
97
|
+
} catch {
|
|
98
|
+
avdEntries = [];
|
|
99
|
+
}
|
|
100
|
+
for (const entry of avdEntries) {
|
|
101
|
+
if (!entry.isDirectory() || !entry.name.endsWith('.avd')) continue;
|
|
102
|
+
const name = entry.name.slice(0, -4);
|
|
103
|
+
const avdPath = await readAvdIni(avdRoot, name);
|
|
104
|
+
const total = await walkSize(avdPath);
|
|
105
|
+
const snapshotsDir = path.join(avdPath, 'snapshots');
|
|
106
|
+
const snapshots = await walkSize(snapshotsDir);
|
|
107
|
+
avds.push({
|
|
108
|
+
name,
|
|
109
|
+
path: avdPath,
|
|
110
|
+
totalBytes: total.bytes,
|
|
111
|
+
totalFiles: total.files,
|
|
112
|
+
snapshotBytes: snapshots.bytes,
|
|
113
|
+
userdataBytes: Math.max(0, total.bytes - snapshots.bytes),
|
|
114
|
+
active: activeAvds.has(name),
|
|
115
|
+
registered: knownAvds.has(name),
|
|
116
|
+
managed: name === 'dsh_apk' || name.startsWith('dsh_apk_'),
|
|
117
|
+
orphan: name.startsWith('dsh_apk_') && !knownAvds.has(name) && !activeAvds.has(name),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
avds.sort((a, b) => b.totalBytes - a.totalBytes);
|
|
121
|
+
|
|
122
|
+
const temp = {
|
|
123
|
+
uploadsBytes: (await walkSize(path.join(tempRoot, 'dsh-apk-vm-uploads'))).bytes,
|
|
124
|
+
flowsBytes: (await walkSize(path.join(tempRoot, 'dsh-apk-vm-flows'))).bytes,
|
|
125
|
+
screenshotsBytes: 0,
|
|
126
|
+
recordingsBytes: 0,
|
|
127
|
+
files: 0,
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const entries = await fsp.readdir(tempRoot, { withFileTypes: true });
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (!entry.isFile()) continue;
|
|
133
|
+
if (!/^(apk-vm-shot-|dsh-apk-vm-record-)/.test(entry.name)) continue;
|
|
134
|
+
const stat = await fileStatOrNull(path.join(tempRoot, entry.name));
|
|
135
|
+
if (!stat) continue;
|
|
136
|
+
temp.files += 1;
|
|
137
|
+
if (entry.name.startsWith('apk-vm-shot-')) temp.screenshotsBytes += stat.size;
|
|
138
|
+
else temp.recordingsBytes += stat.size;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let sdk = null;
|
|
145
|
+
if (sdkRoot) {
|
|
146
|
+
const total = await walkSize(sdkRoot);
|
|
147
|
+
const parts = {};
|
|
148
|
+
for (const part of ['system-images', 'emulator', 'build-tools', 'platform-tools', 'platforms']) {
|
|
149
|
+
parts[part] = (await walkSize(path.join(sdkRoot, part))).bytes;
|
|
150
|
+
}
|
|
151
|
+
sdk = { root: sdkRoot, totalBytes: total.bytes, totalFiles: total.files, parts };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let disk = null;
|
|
155
|
+
try {
|
|
156
|
+
const stats = await fsp.statfs(avdRoot || home);
|
|
157
|
+
disk = {
|
|
158
|
+
totalBytes: Number(stats.blocks) * Number(stats.bsize),
|
|
159
|
+
freeBytes: Number(stats.bavail) * Number(stats.bsize),
|
|
160
|
+
availableBytes: Number(stats.bavail) * Number(stats.bsize),
|
|
161
|
+
path: avdRoot || home,
|
|
162
|
+
};
|
|
163
|
+
} catch {
|
|
164
|
+
disk = null;
|
|
165
|
+
}
|
|
166
|
+
let sdkDisk = null;
|
|
167
|
+
if (sdkRoot) {
|
|
168
|
+
try {
|
|
169
|
+
const stats = await fsp.statfs(sdkRoot);
|
|
170
|
+
sdkDisk = {
|
|
171
|
+
totalBytes: Number(stats.blocks) * Number(stats.bsize),
|
|
172
|
+
freeBytes: Number(stats.bavail) * Number(stats.bsize),
|
|
173
|
+
path: sdkRoot,
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
sdkDisk = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const snapshotTotal = avds.reduce((sum, item) => sum + item.snapshotBytes, 0);
|
|
181
|
+
const tempTotal = temp.uploadsBytes + temp.flowsBytes + temp.screenshotsBytes + temp.recordingsBytes;
|
|
182
|
+
const recommendations = [];
|
|
183
|
+
if (snapshotTotal > 1024 * 1024 * 1024) {
|
|
184
|
+
recommendations.push({
|
|
185
|
+
code: 'snapshots',
|
|
186
|
+
bytes: snapshotTotal,
|
|
187
|
+
text: `Quick-boot snapshots use ${formatBytes(snapshotTotal)}; disable quickBoot/saveSnapshotAfterBoot or delete a snapshot to free it.`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (tempTotal > 0) {
|
|
191
|
+
recommendations.push({ code: 'temp', bytes: tempTotal, text: `Plugin temp files use ${formatBytes(tempTotal)}; safe to clean.` });
|
|
192
|
+
}
|
|
193
|
+
const orphans = avds.filter((item) => item.orphan);
|
|
194
|
+
if (orphans.length) {
|
|
195
|
+
recommendations.push({
|
|
196
|
+
code: 'orphan-avds',
|
|
197
|
+
bytes: orphans.reduce((sum, item) => sum + item.totalBytes, 0),
|
|
198
|
+
text: `${orphans.length} orphan dsh_apk_* AVD(s) are not registered to a running instance.`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
sampledAt: new Date().toISOString(),
|
|
205
|
+
home,
|
|
206
|
+
avdRoot,
|
|
207
|
+
tempRoot,
|
|
208
|
+
sdk,
|
|
209
|
+
sdkDisk,
|
|
210
|
+
avds,
|
|
211
|
+
temp,
|
|
212
|
+
disk,
|
|
213
|
+
totals: {
|
|
214
|
+
avdBytes: avds.reduce((sum, item) => sum + item.totalBytes, 0),
|
|
215
|
+
snapshotBytes: snapshotTotal,
|
|
216
|
+
sdkBytes: sdk ? sdk.totalBytes : 0,
|
|
217
|
+
tempBytes: tempTotal,
|
|
218
|
+
freeBytes: disk ? disk.freeBytes : 0,
|
|
219
|
+
},
|
|
220
|
+
recommendations,
|
|
221
|
+
formatter: 'bytes',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function removeTree(target) {
|
|
226
|
+
const existed = await fsp.stat(target).catch(() => null);
|
|
227
|
+
if (!existed) return 0;
|
|
228
|
+
const measured = await walkSize(target);
|
|
229
|
+
await fsp.rm(target, { recursive: true, force: true }).catch(() => {});
|
|
230
|
+
return measured.bytes;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Delete only the plugin's own temporary artifacts. */
|
|
234
|
+
export async function cleanTempFiles(options = {}) {
|
|
235
|
+
const tempRoot = path.resolve(options.tempRoot || os.tmpdir());
|
|
236
|
+
let freed = 0;
|
|
237
|
+
let files = 0;
|
|
238
|
+
for (const name of ['dsh-apk-vm-uploads', 'dsh-apk-vm-flows']) {
|
|
239
|
+
const dir = path.join(tempRoot, name);
|
|
240
|
+
const measured = await walkSize(dir);
|
|
241
|
+
await removeTree(dir);
|
|
242
|
+
freed += measured.bytes;
|
|
243
|
+
files += measured.files;
|
|
244
|
+
}
|
|
245
|
+
const entries = await fsp.readdir(tempRoot, { withFileTypes: true }).catch(() => []);
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
if (!entry.isFile() || !/^(apk-vm-shot-|dsh-apk-vm-record-)/.test(entry.name)) continue;
|
|
248
|
+
const full = path.join(tempRoot, entry.name);
|
|
249
|
+
const stat = await fileStatOrNull(full);
|
|
250
|
+
if (!stat) continue;
|
|
251
|
+
await fsp.unlink(full).catch(() => {});
|
|
252
|
+
freed += stat.size;
|
|
253
|
+
files += 1;
|
|
254
|
+
}
|
|
255
|
+
return { ok: true, action: 'temp', freedBytes: freed, files };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Delete snapshots for one AVD by name. Refuses active or non-managed AVDs unless forced. */
|
|
259
|
+
export async function cleanSnapshots(options = {}) {
|
|
260
|
+
const avdRoot = path.resolve(options.avdRoot || path.join(os.homedir(), '.android', 'avd'));
|
|
261
|
+
const name = String(options.avdName || '').trim();
|
|
262
|
+
if (!/^[A-Za-z0-9._-]{1,80}$/.test(name)) throw new Error('invalid AVD name');
|
|
263
|
+
if (name !== 'dsh_apk' && !name.startsWith('dsh_apk_')) {
|
|
264
|
+
throw new Error('only plugin-managed dsh_apk* AVDs can have snapshots cleaned');
|
|
265
|
+
}
|
|
266
|
+
const avdPath = await readAvdIni(avdRoot, name);
|
|
267
|
+
if (!avdPath.startsWith(avdRoot + path.sep)) throw new Error('AVD path escapes the AVD root');
|
|
268
|
+
const snapshotsDir = path.join(avdPath, 'snapshots');
|
|
269
|
+
const measured = await walkSize(snapshotsDir);
|
|
270
|
+
await removeTree(snapshotsDir);
|
|
271
|
+
return { ok: true, action: 'snapshots', avdName: name, freedBytes: measured.bytes, files: measured.files };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Delete only orphan dsh_apk_* AVDs that are not registered/active. */
|
|
275
|
+
export async function cleanOrphanAvds(options = {}) {
|
|
276
|
+
const avdRoot = path.resolve(options.avdRoot || path.join(os.homedir(), '.android', 'avd'));
|
|
277
|
+
const known = new Set((options.knownAvds || []).map(String));
|
|
278
|
+
const active = new Set((options.activeAvds || []).map(String));
|
|
279
|
+
const entries = await fsp.readdir(avdRoot, { withFileTypes: true }).catch(() => []);
|
|
280
|
+
const removed = [];
|
|
281
|
+
let freed = 0;
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
if (!entry.isDirectory() || !entry.name.endsWith('.avd')) continue;
|
|
284
|
+
const name = entry.name.slice(0, -4);
|
|
285
|
+
if (!name.startsWith('dsh_apk_') || known.has(name) || active.has(name)) continue;
|
|
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;
|
|
291
|
+
const measured = await walkSize(avdPath);
|
|
292
|
+
await fsp.rm(avdPath, { recursive: true, force: true }).catch(() => {});
|
|
293
|
+
await fsp.unlink(path.join(avdRoot, `${name}.ini`)).catch(() => {});
|
|
294
|
+
freed += measured.bytes;
|
|
295
|
+
removed.push({ name, bytes: measured.bytes });
|
|
296
|
+
}
|
|
297
|
+
return { ok: true, action: 'orphan-avds', freedBytes: freed, removed };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export { formatBytes };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-apk-vm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,7 +25,7 @@
|
|
|
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/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",
|
|
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/storage.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
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
|
},
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"lib/apk-bundle.js",
|
|
45
45
|
"lib/flows.js",
|
|
46
46
|
"lib/scrcpy.js",
|
|
47
|
+
"lib/storage.js",
|
|
47
48
|
"lib/ui-inspector.js",
|
|
48
49
|
"lib/ws.js",
|
|
49
50
|
"lib/setup.js",
|