dsh-apk-vm 0.3.13 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +57 -12
- package/client.js +555 -5
- package/docs/LINUX-E2E.md +81 -0
- package/docs/README.md +15 -0
- package/docs/SUBMISSION.md +5 -2
- package/index.js +246 -10
- package/lib/android.js +219 -55
- package/lib/apk-bundle.js +165 -0
- package/lib/flows.js +198 -0
- package/lib/ui-inspector.js +174 -0
- package/lib/x509.js +107 -0
- package/package.json +8 -3
package/lib/android.js
CHANGED
|
@@ -50,6 +50,8 @@ import { promises as fsp } from 'node:fs';
|
|
|
50
50
|
import os from 'node:os';
|
|
51
51
|
import path from 'node:path';
|
|
52
52
|
import { inflateRawSync } from 'node:zlib';
|
|
53
|
+
import { inspectCertificate } from './x509.js';
|
|
54
|
+
import { extractApkBundle, cleanupBundle } from './apk-bundle.js';
|
|
53
55
|
|
|
54
56
|
const IS_WIN = process.platform === 'win32';
|
|
55
57
|
const EXE = IS_WIN ? '.exe' : '';
|
|
@@ -470,6 +472,17 @@ export class AndroidVm {
|
|
|
470
472
|
typeof opts.gpuMode === 'string' && opts.gpuMode.trim()
|
|
471
473
|
? opts.gpuMode.trim()
|
|
472
474
|
: DEFAULT_OPTIONS.gpuMode;
|
|
475
|
+
// Headless Linux servers usually have no usable EGL/GL display for the
|
|
476
|
+
// emulator's host GPU mode; fall back to the software renderer so CI and
|
|
477
|
+
// remote validation can still boot (slowly).
|
|
478
|
+
if (
|
|
479
|
+
process.platform === 'linux' &&
|
|
480
|
+
!process.env.DISPLAY &&
|
|
481
|
+
!process.env.WAYLAND_DISPLAY &&
|
|
482
|
+
this.gpuMode === 'host'
|
|
483
|
+
) {
|
|
484
|
+
this.gpuMode = 'swiftshader_indirect';
|
|
485
|
+
}
|
|
473
486
|
this.accel = opts.accel === 'on' || opts.accel === 'off' ? opts.accel : 'auto';
|
|
474
487
|
this.requireAccel = opts.requireAccel !== false;
|
|
475
488
|
this.accelCheckTimeoutMs = asPositiveInt(opts.accelCheckTimeoutMs, DEFAULT_OPTIONS.accelCheckTimeoutMs);
|
|
@@ -503,6 +516,7 @@ export class AndroidVm {
|
|
|
503
516
|
this._serialActive = null;
|
|
504
517
|
this._child = null; // spawned emulator process, if any
|
|
505
518
|
this._spawnedByUs = false;
|
|
519
|
+
this._suppressAutoAttach = false;
|
|
506
520
|
this._bootStartAt = null;
|
|
507
521
|
this._sawNotBooted = false;
|
|
508
522
|
this._bootProbe = { serial: null, at: 0, booted: false };
|
|
@@ -558,6 +572,7 @@ export class AndroidVm {
|
|
|
558
572
|
*/
|
|
559
573
|
async start() {
|
|
560
574
|
if (this._disposed) throw new Error('AndroidVm has been disposed');
|
|
575
|
+
this._suppressAutoAttach = false;
|
|
561
576
|
// Serialize with a start that a stop() may have interrupted: the stale run aborts
|
|
562
577
|
// via _waitEpoch, then this call performs a clean attach/boot.
|
|
563
578
|
while (this._starting) {
|
|
@@ -575,11 +590,15 @@ export class AndroidVm {
|
|
|
575
590
|
return this._starting;
|
|
576
591
|
}
|
|
577
592
|
|
|
578
|
-
/**
|
|
593
|
+
/**
|
|
594
|
+
* Stop the attached VM: kill only an emulator this process spawned; for any
|
|
595
|
+
* pre-existing (auto-attached or explicitly attached) device, detach instead.
|
|
596
|
+
*/
|
|
579
597
|
async stop() {
|
|
580
598
|
this._waitEpoch += 1; // abort an in-flight boot wait
|
|
581
599
|
this._bootWait = null; // let a subsequent start() begin a fresh wait
|
|
582
600
|
this._pushLog('[vm] stop requested');
|
|
601
|
+
this._suppressAutoAttach = true;
|
|
583
602
|
if (this._external) {
|
|
584
603
|
// Never kill or disconnect a user's physical/remote device; just detach.
|
|
585
604
|
this._serialActive = null;
|
|
@@ -589,8 +608,12 @@ export class AndroidVm {
|
|
|
589
608
|
}
|
|
590
609
|
const serial = this._serialActive || this.serial;
|
|
591
610
|
const child = this._child;
|
|
611
|
+
// Critical safety rule: only stop an emulator that this process spawned.
|
|
612
|
+
// An auto-attached pre-existing emulator (for example on a shared Linux
|
|
613
|
+
// host) must never receive `adb emu kill` or `adb disconnect`.
|
|
614
|
+
const spawnedHere = this._spawnedByUs === true || Boolean(child);
|
|
592
615
|
|
|
593
|
-
if (serial) {
|
|
616
|
+
if (serial && spawnedHere) {
|
|
594
617
|
try {
|
|
595
618
|
const result = await this._adb(['-s', serial, 'emu', 'kill'], { timeoutMs: 10000 });
|
|
596
619
|
if (result && result.code === 0) this._pushLog(`[vm] sent emulator kill to ${serial}`);
|
|
@@ -612,6 +635,8 @@ export class AndroidVm {
|
|
|
612
635
|
if (stillConnected && isNetworkSerial(serial)) {
|
|
613
636
|
await this._adb(['disconnect', serial], { timeoutMs: 10000 }).catch(() => {});
|
|
614
637
|
}
|
|
638
|
+
} else if (serial) {
|
|
639
|
+
this._pushLog(`[vm] leaving pre-existing device ${serial} running (not spawned by this process)`);
|
|
615
640
|
}
|
|
616
641
|
|
|
617
642
|
if (child && child.exitCode === null) {
|
|
@@ -758,7 +783,7 @@ export class AndroidVm {
|
|
|
758
783
|
if (depth + 1 <= this.maxApkDepth) stack.push({ dir: fullPath, depth: depth + 1 });
|
|
759
784
|
continue;
|
|
760
785
|
}
|
|
761
|
-
if (!entry.isFile() || !/\.apk$/i.test(name)) continue;
|
|
786
|
+
if (!entry.isFile() || !/\.(?:apk|apks|xapk)$/i.test(name)) continue;
|
|
762
787
|
const key = IS_WIN ? fullPath.toLowerCase() : fullPath;
|
|
763
788
|
if (seen.has(key)) continue;
|
|
764
789
|
seen.add(key);
|
|
@@ -769,6 +794,7 @@ export class AndroidVm {
|
|
|
769
794
|
name,
|
|
770
795
|
size: stat.size,
|
|
771
796
|
mtime: new Date(stat.mtimeMs).toISOString(),
|
|
797
|
+
kind: path.extname(name).slice(1).toLowerCase(),
|
|
772
798
|
});
|
|
773
799
|
} catch {
|
|
774
800
|
/* file vanished mid-scan */
|
|
@@ -826,62 +852,75 @@ export class AndroidVm {
|
|
|
826
852
|
}
|
|
827
853
|
|
|
828
854
|
/**
|
|
829
|
-
* Install
|
|
855
|
+
* Install a plain APK or an APKS/XAPK bundle with `adb install-multiple`,
|
|
830
856
|
* then resolve its metadata and best-effort launch it so the panel shows the app.
|
|
831
|
-
* Returns { packageName, label, launchActivity }.
|
|
857
|
+
* Returns { packageName, label, launchActivity, bundle? }.
|
|
832
858
|
*/
|
|
833
859
|
async installApk(apkPath) {
|
|
834
|
-
const file = await this.
|
|
860
|
+
const file = await this._assertPackageFile(apkPath);
|
|
835
861
|
const serial = await this._requireSerial();
|
|
836
862
|
const stat = await fsp.stat(file);
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
`[${
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
this._pushLog('[vm] package is test-only; retrying with adb install -r -t');
|
|
851
|
-
result = await this._adb(['-s', serial, 'install', '-r', '-t', file], {
|
|
863
|
+
const bundle = await extractApkBundle(file);
|
|
864
|
+
try {
|
|
865
|
+
const metadata = await this.inspectApk(bundle.basePath);
|
|
866
|
+
this._pushLog(
|
|
867
|
+
`[vm] installing ${path.basename(file)} (${stat.size} bytes, ${bundle.kind}, ` +
|
|
868
|
+
`${bundle.apkPaths.length} apk) on ${serial} [${metadata.packageName}]`,
|
|
869
|
+
);
|
|
870
|
+
const startedAt = Date.now();
|
|
871
|
+
const installArgs =
|
|
872
|
+
bundle.apkPaths.length > 1
|
|
873
|
+
? ['install-multiple', '-r', ...bundle.apkPaths]
|
|
874
|
+
: ['install', '-r', bundle.basePath];
|
|
875
|
+
let result = await this._adb(['-s', serial, ...installArgs], {
|
|
852
876
|
timeoutMs: this.installTimeoutMs,
|
|
853
877
|
maxBytes: 8 * 1024 * 1024,
|
|
854
878
|
});
|
|
855
|
-
output = `${result.stdout.toString('utf8')}\n${result.stderr}`;
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
`adb install
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
879
|
+
let output = `${result.stdout.toString('utf8')}\n${result.stderr}`;
|
|
880
|
+
if (/INSTALL_FAILED_TEST_ONLY/i.test(output)) {
|
|
881
|
+
this._pushLog('[vm] package is test-only; retrying with -t');
|
|
882
|
+
const retryArgs =
|
|
883
|
+
bundle.apkPaths.length > 1
|
|
884
|
+
? ['install-multiple', '-r', '-t', ...bundle.apkPaths]
|
|
885
|
+
: ['install', '-r', '-t', bundle.basePath];
|
|
886
|
+
result = await this._adb(['-s', serial, ...retryArgs], {
|
|
887
|
+
timeoutMs: this.installTimeoutMs,
|
|
888
|
+
maxBytes: 8 * 1024 * 1024,
|
|
889
|
+
});
|
|
890
|
+
output = `${result.stdout.toString('utf8')}\n${result.stderr}`;
|
|
891
|
+
}
|
|
892
|
+
if (result.timedOut) {
|
|
893
|
+
throw new Error(`adb install timed out after ${this.installTimeoutMs}ms`);
|
|
894
|
+
}
|
|
895
|
+
const failure = /Failure\s*\[([^\]]+)\]\s*:?\s*([^\r\n]*)/i.exec(output);
|
|
896
|
+
if (failure) {
|
|
897
|
+
throw new Error(
|
|
898
|
+
`adb install failed: ${failure[1]}${failure[2] && failure[2].trim() ? `: ${failure[2].trim()}` : ''}`,
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
if (result.code !== 0 || !/Success/i.test(output)) {
|
|
902
|
+
throw new Error(
|
|
903
|
+
`adb install did not report success (exit ${result.code}): ${output.trim().slice(0, 400)}`,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
this._pushLog(`[vm] installed ${metadata.packageName} in ${Date.now() - startedAt}ms`);
|
|
873
907
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
908
|
+
if (metadata.launchActivity) {
|
|
909
|
+
try {
|
|
910
|
+
await this.launchPackage(metadata.packageName, metadata.launchActivity);
|
|
911
|
+
this._pushLog(`[vm] launched ${metadata.packageName}/${metadata.launchActivity}`);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
this._pushLog(`[vm] install succeeded but launch failed: ${error.message}`);
|
|
914
|
+
}
|
|
915
|
+
} else {
|
|
916
|
+
this._pushLog(`[vm] ${metadata.packageName} exposes no launchable activity; not launched`);
|
|
880
917
|
}
|
|
881
|
-
|
|
882
|
-
|
|
918
|
+
return bundle.kind === 'apk'
|
|
919
|
+
? metadata
|
|
920
|
+
: { ...metadata, bundle: { kind: bundle.kind, apkCount: bundle.apkPaths.length } };
|
|
921
|
+
} finally {
|
|
922
|
+
await cleanupBundle(bundle);
|
|
883
923
|
}
|
|
884
|
-
return metadata;
|
|
885
924
|
}
|
|
886
925
|
|
|
887
926
|
/** Launch a package/activity via `adb shell am start -n` (monkey fallback without activity). */
|
|
@@ -1368,18 +1407,128 @@ export class AndroidVm {
|
|
|
1368
1407
|
return { ok: true, rotation, orientation: String(orientation) };
|
|
1369
1408
|
}
|
|
1370
1409
|
|
|
1410
|
+
/** Configure or clear the guest's global HTTP proxy. */
|
|
1411
|
+
async setProxy(options = {}) {
|
|
1412
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
1413
|
+
const enabled = opts.enabled !== false;
|
|
1414
|
+
const serial = this._debugSerial();
|
|
1415
|
+
const run = (command) =>
|
|
1416
|
+
this._adbText(['-s', serial, 'shell', command], { timeoutMs: 20000, maxBytes: 64 * 1024 }).then((text) =>
|
|
1417
|
+
text.trim(),
|
|
1418
|
+
);
|
|
1419
|
+
if (!enabled) {
|
|
1420
|
+
await run('settings delete global http_proxy 2>/dev/null || true');
|
|
1421
|
+
await run('settings delete global global_http_proxy_host 2>/dev/null || true');
|
|
1422
|
+
await run('settings delete global global_http_proxy_port 2>/dev/null || true');
|
|
1423
|
+
await run('settings delete global global_http_proxy_exclusion_list 2>/dev/null || true');
|
|
1424
|
+
const current = await run('settings get global http_proxy');
|
|
1425
|
+
this._pushLog('[vm] HTTP proxy cleared');
|
|
1426
|
+
return { ok: true, proxy: null, host: null, port: null, current: current || null };
|
|
1427
|
+
}
|
|
1428
|
+
const host = String(opts.host || '').trim();
|
|
1429
|
+
const port = Number(opts.port);
|
|
1430
|
+
if (!/^[A-Za-z0-9._:-]{1,253}$/.test(host)) throw new Error(`invalid proxy host: ${JSON.stringify(opts.host)}`);
|
|
1431
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid proxy port: ${opts.port}`);
|
|
1432
|
+
const proxy = `${host}:${port}`;
|
|
1433
|
+
await run(`settings put global http_proxy ${proxy}`);
|
|
1434
|
+
await run(`settings put global global_http_proxy_host ${host}`);
|
|
1435
|
+
await run(`settings put global global_http_proxy_port ${port}`);
|
|
1436
|
+
if (typeof opts.bypass === 'string' && opts.bypass.trim()) {
|
|
1437
|
+
await run(`settings put global global_http_proxy_exclusion_list ${opts.bypass.trim()}`);
|
|
1438
|
+
}
|
|
1439
|
+
const current = await run('settings get global http_proxy');
|
|
1440
|
+
this._pushLog(`[vm] HTTP proxy set to ${proxy}`);
|
|
1441
|
+
return { ok: true, proxy, host, port, current: current || null };
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** Read the guest's current global HTTP proxy settings. */
|
|
1445
|
+
async getProxy() {
|
|
1446
|
+
const serial = this._debugSerial();
|
|
1447
|
+
const text = await this._adbText(['-s', serial, 'shell', 'settings get global http_proxy'], {
|
|
1448
|
+
timeoutMs: 15000,
|
|
1449
|
+
maxBytes: 64 * 1024,
|
|
1450
|
+
});
|
|
1451
|
+
const proxy = String(text).trim();
|
|
1452
|
+
return { ok: true, proxy: proxy && proxy !== 'null' && proxy !== ':0' ? proxy : null };
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Install a PEM/DER CA certificate into the rooted guest's user CA store and,
|
|
1457
|
+
* when the system partition is writable, into the system store as well.
|
|
1458
|
+
* Returns the Android-compatible subject hash filename.
|
|
1459
|
+
*/
|
|
1460
|
+
async installCaCertificate(localPath) {
|
|
1461
|
+
const file = path.resolve(String(localPath || ''));
|
|
1462
|
+
if (!file) throw new Error('installCaCertificate requires a local certificate path');
|
|
1463
|
+
if (!/\.(pem|crt|cer|der)$/i.test(file)) throw new Error('certificate must be .pem, .crt, .cer or .der');
|
|
1464
|
+
if (!existsSync(file)) throw new Error(`certificate not found: ${file}`);
|
|
1465
|
+
const input = await fsp.readFile(file);
|
|
1466
|
+
const cert = inspectCertificate(input);
|
|
1467
|
+
await this.ensureRoot();
|
|
1468
|
+
const serial = this._debugSerial();
|
|
1469
|
+
const remote = `/data/local/tmp/dsh-ca-${cert.fileName}`;
|
|
1470
|
+
const push = await this._adb(['-s', serial, 'push', file, remote], {
|
|
1471
|
+
timeoutMs: 60000,
|
|
1472
|
+
maxBytes: 64 * 1024,
|
|
1473
|
+
});
|
|
1474
|
+
if (push.code !== 0 || push.timedOut) {
|
|
1475
|
+
throw new Error(`failed to push certificate: ${push.stderr.toString('utf8').slice(0, 300)}`);
|
|
1476
|
+
}
|
|
1477
|
+
const userDir = '/data/misc/user/0/cacerts-added';
|
|
1478
|
+
const installUser = await this._adb(
|
|
1479
|
+
[
|
|
1480
|
+
'-s',
|
|
1481
|
+
serial,
|
|
1482
|
+
'shell',
|
|
1483
|
+
`mkdir -p ${userDir} && cp ${remote} ${userDir}/${cert.fileName} && ` +
|
|
1484
|
+
`chmod 644 ${userDir}/${cert.fileName} && chown system:system ${userDir}/${cert.fileName} && ` +
|
|
1485
|
+
`(restorecon ${userDir}/${cert.fileName} 2>/dev/null || true) && ls -l ${userDir}/${cert.fileName}`,
|
|
1486
|
+
],
|
|
1487
|
+
{ timeoutMs: 20000, maxBytes: 64 * 1024 },
|
|
1488
|
+
);
|
|
1489
|
+
if (installUser.code !== 0) {
|
|
1490
|
+
throw new Error(`failed to install user CA: ${installUser.stderr.toString('utf8').slice(0, 300)}`);
|
|
1491
|
+
}
|
|
1492
|
+
let systemCa = false;
|
|
1493
|
+
const remount = await this._adb(
|
|
1494
|
+
['-s', serial, 'shell', 'mount -o rw,remount / 2>/dev/null || true; cp ' + remote + ' /system/etc/security/cacerts/' + cert.fileName + ' 2>/dev/null'],
|
|
1495
|
+
{ timeoutMs: 20000, maxBytes: 64 * 1024 },
|
|
1496
|
+
);
|
|
1497
|
+
if (remount.code === 0) {
|
|
1498
|
+
const verify = await this._adb(['-s', serial, 'shell', `test -f /system/etc/security/cacerts/${cert.fileName} && echo yes`], {
|
|
1499
|
+
timeoutMs: 10000,
|
|
1500
|
+
maxBytes: 16 * 1024,
|
|
1501
|
+
});
|
|
1502
|
+
systemCa = /yes/.test(verify.stdout.toString('utf8'));
|
|
1503
|
+
}
|
|
1504
|
+
await this._adb(['-s', serial, 'shell', 'rm', '-f', remote], { timeoutMs: 10000, maxBytes: 16 * 1024 }).catch(() => {});
|
|
1505
|
+
this._pushLog(`[vm] installed CA ${cert.hash}.0 (user=${true}, system=${systemCa})`);
|
|
1506
|
+
return {
|
|
1507
|
+
ok: true,
|
|
1508
|
+
hash: cert.hash,
|
|
1509
|
+
fileName: cert.fileName,
|
|
1510
|
+
subject: cert.subject,
|
|
1511
|
+
installedPath: `${userDir}/${cert.fileName}`,
|
|
1512
|
+
systemCa,
|
|
1513
|
+
note: systemCa
|
|
1514
|
+
? 'user and system CA stores updated; restart the target app'
|
|
1515
|
+
: 'user CA store updated; apps targeting API 24+ may still require a system CA or a network-security-config override',
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1371
1519
|
/** Dump the current UIAutomator hierarchy XML (for agent/UI inspection). */
|
|
1372
1520
|
async uiDump() {
|
|
1373
1521
|
await this.ensureRoot();
|
|
1374
1522
|
const serial = this._debugSerial();
|
|
1375
1523
|
const remote = '/data/local/tmp/dsh-window.xml';
|
|
1376
1524
|
const result = await this._adb(
|
|
1377
|
-
['-s', serial, 'shell', `uiautomator dump ${remote}
|
|
1525
|
+
['-s', serial, 'shell', `uiautomator dump ${remote} 2>&1; cat ${remote} 2>/dev/null`],
|
|
1378
1526
|
{ timeoutMs: 45000, maxBytes: 1024 * 1024 },
|
|
1379
1527
|
);
|
|
1380
1528
|
let xml = result.stdout.toString('utf8').trim();
|
|
1381
1529
|
if (!/<hierarchy[\s>]/.test(xml)) {
|
|
1382
|
-
|
|
1530
|
+
const detail = `${result.stderr.toString('utf8')}\n${xml}`.trim().slice(0, 500);
|
|
1531
|
+
throw new Error(`uiautomator dump failed: ${detail || 'empty hierarchy'}`);
|
|
1383
1532
|
}
|
|
1384
1533
|
const nodes = (xml.match(/<node\b/g) || []).length;
|
|
1385
1534
|
if (xml.length > 500000) xml = xml.slice(0, 500000);
|
|
@@ -1510,6 +1659,7 @@ export class AndroidVm {
|
|
|
1510
1659
|
|
|
1511
1660
|
this.serial = target;
|
|
1512
1661
|
this._serialActive = target;
|
|
1662
|
+
this._suppressAutoAttach = false;
|
|
1513
1663
|
this._pushLog(`[vm] connecting to ${target}`);
|
|
1514
1664
|
|
|
1515
1665
|
try {
|
|
@@ -1670,6 +1820,7 @@ export class AndroidVm {
|
|
|
1670
1820
|
this._managedPort = managed.port;
|
|
1671
1821
|
this._external = true;
|
|
1672
1822
|
this._externalSerial = target;
|
|
1823
|
+
this._suppressAutoAttach = false;
|
|
1673
1824
|
this.avdName = '';
|
|
1674
1825
|
this.serial = target;
|
|
1675
1826
|
this.port = null;
|
|
@@ -1687,6 +1838,7 @@ export class AndroidVm {
|
|
|
1687
1838
|
this._serialActive = null;
|
|
1688
1839
|
this._external = false;
|
|
1689
1840
|
this._externalSerial = null;
|
|
1841
|
+
this._suppressAutoAttach = false;
|
|
1690
1842
|
this.avdName = this._managedAvdName;
|
|
1691
1843
|
this.serial = this._managedSerial;
|
|
1692
1844
|
this.port = this._managedPort;
|
|
@@ -1878,7 +2030,7 @@ export class AndroidVm {
|
|
|
1878
2030
|
return { args: [], bootKind: 'cold', accelAvailable, snapshotAvailable, fatal: message };
|
|
1879
2031
|
}
|
|
1880
2032
|
const bootKind = snapshotAvailable ? 'snapshot' : 'cold';
|
|
1881
|
-
const args = ['-avd', this.avdName, '-no-window', '-no-audio', '-no-boot-anim'];
|
|
2033
|
+
const args = ['-avd', this.avdName, '-no-window', '-no-audio', '-no-boot-anim', '-no-metrics'];
|
|
1882
2034
|
if (snapshotAvailable) {
|
|
1883
2035
|
args.push('-snapshot', this.snapshotName);
|
|
1884
2036
|
} else if (!this.quickBoot) {
|
|
@@ -2107,6 +2259,9 @@ export class AndroidVm {
|
|
|
2107
2259
|
}
|
|
2108
2260
|
|
|
2109
2261
|
async _refresh() {
|
|
2262
|
+
// After stop(), keep reporting `stopped` instead of immediately auto-attaching
|
|
2263
|
+
// a pre-existing device again on the next status poll. start() clears this.
|
|
2264
|
+
if (this._suppressAutoAttach && this._state === 'stopped') return;
|
|
2110
2265
|
const devices = await this._listDevices();
|
|
2111
2266
|
|
|
2112
2267
|
if (this._lastProbeError) {
|
|
@@ -2348,15 +2503,24 @@ export class AndroidVm {
|
|
|
2348
2503
|
}
|
|
2349
2504
|
|
|
2350
2505
|
async _assertApkFile(apkPath) {
|
|
2351
|
-
|
|
2352
|
-
|
|
2506
|
+
const file = await this._assertPackageFile(apkPath);
|
|
2507
|
+
if (!/\.apk$/i.test(file)) throw new Error(`not an .apk file: ${file}`);
|
|
2508
|
+
return file;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
async _assertPackageFile(packagePath) {
|
|
2512
|
+
if (typeof packagePath !== 'string' || packagePath.trim() === '') {
|
|
2513
|
+
throw new Error('an APK/APKS/XAPK path is required');
|
|
2514
|
+
}
|
|
2515
|
+
const file = path.resolve(packagePath);
|
|
2516
|
+
if (!/\.(?:apk|apks|xapk)$/i.test(file)) {
|
|
2517
|
+
throw new Error(`package must be .apk, .apks or .xapk (AAB must be converted first): ${file}`);
|
|
2353
2518
|
}
|
|
2354
|
-
const file = path.resolve(apkPath);
|
|
2355
2519
|
let stat;
|
|
2356
2520
|
try {
|
|
2357
2521
|
stat = await fsp.stat(file);
|
|
2358
2522
|
} catch {
|
|
2359
|
-
throw new Error(`
|
|
2523
|
+
throw new Error(`package not found: ${file}`);
|
|
2360
2524
|
}
|
|
2361
2525
|
if (!stat.isFile()) throw new Error(`not a regular file: ${file}`);
|
|
2362
2526
|
return file;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APK bundle support: plain .apk, Android App Bundle archives (.apks) and
|
|
3
|
+
* XAPK packages. Uses only Node built-ins; split APKs are extracted to a temp
|
|
4
|
+
* directory and installed with `adb install-multiple`.
|
|
5
|
+
*/
|
|
6
|
+
import { inflateRawSync } from 'node:zlib';
|
|
7
|
+
import { promises as fsp } from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
function readUInt64(buffer, offset) {
|
|
13
|
+
const low = buffer.readUInt32LE(offset);
|
|
14
|
+
const high = buffer.readUInt32LE(offset + 4);
|
|
15
|
+
return high * 0x100000000 + low;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** List entries in an APK/ZIP/APKS/XAPK archive from its central directory. */
|
|
19
|
+
export function listZipEntries(buffer) {
|
|
20
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('not a ZIP archive');
|
|
21
|
+
let eocd = -1;
|
|
22
|
+
const min = Math.max(0, buffer.length - 65557);
|
|
23
|
+
for (let i = buffer.length - 22; i >= min; i -= 1) {
|
|
24
|
+
if (buffer.readUInt32LE(i) === 0x06054b50) {
|
|
25
|
+
eocd = i;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (eocd < 0) throw new Error('not a ZIP archive: end-of-central-directory not found');
|
|
30
|
+
const total = buffer.readUInt16LE(eocd + 10);
|
|
31
|
+
let offset = buffer.readUInt32LE(eocd + 16);
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (let index = 0; index < total; index += 1) {
|
|
34
|
+
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) {
|
|
35
|
+
throw new Error('corrupt ZIP central directory');
|
|
36
|
+
}
|
|
37
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
38
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
39
|
+
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
|
40
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
41
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
42
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
43
|
+
const localOffset = buffer.readUInt32LE(offset + 42);
|
|
44
|
+
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength).replace(/\\/g, '/');
|
|
45
|
+
if (name && !name.endsWith('/')) {
|
|
46
|
+
entries.push({
|
|
47
|
+
name,
|
|
48
|
+
method,
|
|
49
|
+
compressedSize,
|
|
50
|
+
uncompressedSize,
|
|
51
|
+
localOffset,
|
|
52
|
+
dataOffset: -1,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.localOffset + 30 > buffer.length || buffer.readUInt32LE(entry.localOffset) !== 0x04034b50) {
|
|
59
|
+
throw new Error(`corrupt ZIP local header for ${entry.name}`);
|
|
60
|
+
}
|
|
61
|
+
const nameLength = buffer.readUInt16LE(entry.localOffset + 26);
|
|
62
|
+
const extraLength = buffer.readUInt16LE(entry.localOffset + 28);
|
|
63
|
+
entry.dataOffset = entry.localOffset + 30 + nameLength + extraLength;
|
|
64
|
+
if (entry.dataOffset + entry.compressedSize > buffer.length) throw new Error(`truncated ZIP entry ${entry.name}`);
|
|
65
|
+
}
|
|
66
|
+
return entries;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readZipEntry(buffer, entry) {
|
|
70
|
+
const data = buffer.subarray(entry.dataOffset, entry.dataOffset + entry.compressedSize);
|
|
71
|
+
if (entry.method === 0) return Buffer.from(data);
|
|
72
|
+
if (entry.method === 8) return inflateRawSync(data);
|
|
73
|
+
throw new Error(`unsupported ZIP compression method ${entry.method} for ${entry.name}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function safeEntryPath(name) {
|
|
77
|
+
const normalized = String(name || '')
|
|
78
|
+
.replace(/\\/g, '/')
|
|
79
|
+
.replace(/^\/+/, '')
|
|
80
|
+
.split('/')
|
|
81
|
+
.filter((part) => part && part !== '.' && part !== '..')
|
|
82
|
+
.join('/');
|
|
83
|
+
if (!normalized || normalized.includes('\0')) throw new Error(`unsafe ZIP entry name: ${JSON.stringify(name)}`);
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isApkEntry(name) {
|
|
88
|
+
return /\.apk$/i.test(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function bundleKind(filePath) {
|
|
92
|
+
const ext = path.extname(String(filePath || '')).toLowerCase();
|
|
93
|
+
if (ext === '.apk') return 'apk';
|
|
94
|
+
if (ext === '.apks') return 'apks';
|
|
95
|
+
if (ext === '.xapk') return 'xapk';
|
|
96
|
+
if (ext === '.aab') return 'aab';
|
|
97
|
+
return 'unknown';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function chooseBase(apks) {
|
|
101
|
+
const byName = apks.find((entry) => /(^|\/)(splits\/)?base-master\.apk$/i.test(entry.name));
|
|
102
|
+
if (byName) return byName;
|
|
103
|
+
const plainBase = apks.find((entry) => /(^|\/)(base|universal|app)\.apk$/i.test(entry.name));
|
|
104
|
+
if (plainBase) return plainBase;
|
|
105
|
+
return [...apks].sort((a, b) => b.uncompressedSize - a.uncompressedSize)[0] || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract a bundle to a temp directory.
|
|
110
|
+
* Returns { kind, basePath, apkPaths, extractedDir, entries }.
|
|
111
|
+
*/
|
|
112
|
+
export async function extractApkBundle(filePath, options = {}) {
|
|
113
|
+
const source = path.resolve(String(filePath || ''));
|
|
114
|
+
const kind = bundleKind(source);
|
|
115
|
+
if (kind === 'unknown') throw new Error(`unsupported package extension: ${path.extname(source) || '(none)'}`);
|
|
116
|
+
if (kind === 'aab') {
|
|
117
|
+
throw new Error(
|
|
118
|
+
'AAB files are App Bundles and cannot be installed directly; build device APKs first ' +
|
|
119
|
+
'(e.g. `bundletool build-apks --bundle app.aab --output app.apks`) and upload the .apks file',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (kind === 'apk') return { kind, basePath: source, apkPaths: [source], extractedDir: null, entries: [] };
|
|
123
|
+
const buffer = await fsp.readFile(source);
|
|
124
|
+
const entries = listZipEntries(buffer);
|
|
125
|
+
const apks = entries.filter((entry) => isApkEntry(entry.name));
|
|
126
|
+
if (!apks.length) throw new Error(`${kind.toUpperCase()} archive contains no .apk entries`);
|
|
127
|
+
const base = chooseBase(apks);
|
|
128
|
+
if (!base) throw new Error(`${kind.toUpperCase()} archive has no candidate base APK`);
|
|
129
|
+
// Install the base plus all split/config APKs. In .apks archives the
|
|
130
|
+
// standalone variant is intentionally ignored: it is a complete fallback, not
|
|
131
|
+
// a split that belongs next to base-master.
|
|
132
|
+
let selected = apks;
|
|
133
|
+
if (kind === 'apks') {
|
|
134
|
+
selected = apks.filter((entry) => !/^standalones\//i.test(entry.name));
|
|
135
|
+
if (!selected.some((entry) => entry.name === base.name)) selected.push(base);
|
|
136
|
+
}
|
|
137
|
+
const dir =
|
|
138
|
+
options.dir ||
|
|
139
|
+
path.join(os.tmpdir(), `dsh-apk-bundle-${Date.now()}-${randomUUID().slice(0, 8)}`);
|
|
140
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
141
|
+
const apkPaths = [];
|
|
142
|
+
let basePath = null;
|
|
143
|
+
for (const entry of selected) {
|
|
144
|
+
const relative = safeEntryPath(entry.name);
|
|
145
|
+
const target = path.join(dir, relative);
|
|
146
|
+
if (!target.startsWith(dir + path.sep)) throw new Error(`unsafe bundle path: ${entry.name}`);
|
|
147
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
148
|
+
await fsp.writeFile(target, readZipEntry(buffer, entry));
|
|
149
|
+
apkPaths.push(target);
|
|
150
|
+
if (entry.name === base.name) basePath = target;
|
|
151
|
+
}
|
|
152
|
+
if (!basePath) basePath = apkPaths[0];
|
|
153
|
+
return {
|
|
154
|
+
kind,
|
|
155
|
+
basePath,
|
|
156
|
+
apkPaths,
|
|
157
|
+
extractedDir: dir,
|
|
158
|
+
entries: selected.map((entry) => entry.name),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function cleanupBundle(bundle) {
|
|
163
|
+
if (!bundle || !bundle.extractedDir) return;
|
|
164
|
+
await fsp.rm(bundle.extractedDir, { recursive: true, force: true }).catch(() => {});
|
|
165
|
+
}
|