dsh-apk-vm 0.3.13 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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
- /** Stop the attached VM: graceful emulator shutdown when possible, then adb detach. */
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 an APK with `adb install -r` (retries with -t for test-only packages),
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._assertApkFile(apkPath);
860
+ const file = await this._assertPackageFile(apkPath);
835
861
  const serial = await this._requireSerial();
836
862
  const stat = await fsp.stat(file);
837
- const metadata = await this.inspectApk(file);
838
-
839
- this._pushLog(
840
- `[vm] installing ${path.basename(file)} (${stat.size} bytes) on ${serial} ` +
841
- `[${metadata.packageName}]`,
842
- );
843
- const startedAt = Date.now();
844
- let result = await this._adb(['-s', serial, 'install', '-r', file], {
845
- timeoutMs: this.installTimeoutMs,
846
- maxBytes: 8 * 1024 * 1024,
847
- });
848
- let output = `${result.stdout.toString('utf8')}\n${result.stderr}`;
849
- if (/INSTALL_FAILED_TEST_ONLY/i.test(output)) {
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
- if (result.timedOut) {
859
- throw new Error(`adb install timed out after ${this.installTimeoutMs}ms`);
860
- }
861
- const failure = /Failure\s*\[([^\]]+)\]\s*:?\s*([^\r\n]*)/i.exec(output);
862
- if (failure) {
863
- throw new Error(
864
- `adb install failed: ${failure[1]}${failure[2] && failure[2].trim() ? `: ${failure[2].trim()}` : ''}`,
865
- );
866
- }
867
- if (result.code !== 0 || !/Success/i.test(output)) {
868
- throw new Error(
869
- `adb install did not report success (exit ${result.code}): ${output.trim().slice(0, 400)}`,
870
- );
871
- }
872
- this._pushLog(`[vm] installed ${metadata.packageName} in ${Date.now() - startedAt}ms`);
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
- if (metadata.launchActivity) {
875
- try {
876
- await this.launchPackage(metadata.packageName, metadata.launchActivity);
877
- this._pushLog(`[vm] launched ${metadata.packageName}/${metadata.launchActivity}`);
878
- } catch (error) {
879
- this._pushLog(`[vm] install succeeded but launch failed: ${error.message}`);
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
- } else {
882
- this._pushLog(`[vm] ${metadata.packageName} exposes no launchable activity; not launched`);
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} >/dev/null 2>&1; cat ${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
- throw new Error(`uiautomator dump failed: ${result.stderr.toString('utf8').slice(0, 300) || 'empty hierarchy'}`);
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
- if (typeof apkPath !== 'string' || apkPath.trim() === '') {
2352
- throw new Error('an APK path is required');
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(`APK not found: ${file}`);
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
+ }