neoagent 2.3.1-beta.70 → 2.3.1-beta.72

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.
@@ -21,6 +21,8 @@ const AVD_HOME = path.join(ANDROID_ROOT, 'avd');
21
21
  const STATE_DIR = path.join(ARTIFACTS_DIR, 'state');
22
22
  const STATE_FILE = path.join(ARTIFACTS_DIR, 'state.json');
23
23
  const OWNERSHIP_FILE = path.join(ARTIFACTS_DIR, 'device-ownership.json');
24
+ const ANDROID_BOOTSTRAP_WORKER = path.join(__dirname, 'android_bootstrap_worker.js');
25
+ const ANDROID_JAVA_TOOL_TIMEOUT_MS = 20 * 60 * 1000;
24
26
  const DEFAULT_AVD_NAME = 'neoagent-default';
25
27
  const DEFAULT_DATA_PARTITION = '1024M';
26
28
  const DEFAULT_SDCARD_SIZE = '128M';
@@ -283,6 +285,7 @@ function sdkEnv() {
283
285
  ANDROID_SDK_ROOT: SDK_ROOT,
284
286
  ANDROID_AVD_HOME: AVD_HOME,
285
287
  AVD_HOME,
288
+ JAVA_TOOL_OPTIONS: process.env.JAVA_TOOL_OPTIONS || '-Xint',
286
289
  };
287
290
  const pathParts = [
288
291
  path.join(SDK_ROOT, 'platform-tools'),
@@ -349,7 +352,7 @@ function downloadFile(url, dest) {
349
352
  if (res.statusCode !== 200) {
350
353
  out.close();
351
354
  fs.rmSync(dest, { force: true });
352
- reject(new Error(`Download failed with status ${res.statusCode}`));
355
+ reject(new Error(`Download failed for ${url} with status ${res.statusCode}`));
353
356
  return;
354
357
  }
355
358
  res.pipe(out);
@@ -357,7 +360,7 @@ function downloadFile(url, dest) {
357
360
  }).on('error', (err) => {
358
361
  out.close();
359
362
  fs.rmSync(dest, { force: true });
360
- reject(err);
363
+ reject(new Error(`Download failed for ${url}: ${err.message}`));
361
364
  });
362
365
  });
363
366
  }
@@ -436,6 +439,163 @@ function parseLatestCmdlineToolsUrl(xml) {
436
439
  throw new Error(`Could not find a command line tools archive for ${tag}`);
437
440
  }
438
441
 
442
+ function parseLatestEmulatorUrl(xml) {
443
+ const tag = platformTag() === 'mac' ? 'macosx' : 'linux';
444
+ const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="emulator">([\\s\\S]*?)<\\/remotePackage>`));
445
+ if (!packageMatch) throw new Error('Could not locate emulator in Android repository metadata');
446
+
447
+ const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
448
+ for (const block of archiveBlocks) {
449
+ if (!new RegExp(`<host-os>${tag}<\\/host-os>`).test(block)) continue;
450
+ if (!/<host-bits>64<\/host-bits>/.test(block)) continue;
451
+ const urlMatch = block.match(/<url>\s*([^<]*emulator-[^<]+\.zip)\s*<\/url>/);
452
+ if (urlMatch) return `https://dl.google.com/android/repository/${urlMatch[1]}`;
453
+ }
454
+
455
+ throw new Error(`Could not find an Android emulator archive for ${tag}`);
456
+ }
457
+
458
+ function findDirectoryContainingFiles(rootDir, requiredFiles) {
459
+ const stack = [rootDir];
460
+ while (stack.length > 0) {
461
+ const dir = stack.pop();
462
+ let entries;
463
+ try {
464
+ entries = fs.readdirSync(dir, { withFileTypes: true });
465
+ } catch {
466
+ continue;
467
+ }
468
+ const names = new Set(entries.map((entry) => entry.name));
469
+ if (requiredFiles.every((name) => names.has(name))) {
470
+ return dir;
471
+ }
472
+ for (const entry of entries) {
473
+ if (entry.isDirectory()) {
474
+ stack.push(path.join(dir, entry.name));
475
+ }
476
+ }
477
+ }
478
+ return null;
479
+ }
480
+
481
+ function parseLatestPlatformToolsUrl(xml) {
482
+ const tag = platformTag() === 'mac' ? 'darwin' : 'linux';
483
+ const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="platform-tools">([\\s\\S]*?)<\\/remotePackage>`));
484
+ if (!packageMatch) throw new Error('Could not locate platform-tools in Android repository metadata');
485
+
486
+ const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
487
+ for (const block of archiveBlocks) {
488
+ if (!new RegExp(`<host-os>${tag}<\\/host-os>`).test(block)) continue;
489
+ const urlMatch = block.match(/<url>\s*([^<]*platform-tools[^<]+\.zip)\s*<\/url>/);
490
+ if (urlMatch) return `https://dl.google.com/android/repository/${urlMatch[1]}`;
491
+ }
492
+
493
+ throw new Error(`Could not find a platform-tools archive for ${tag}`);
494
+ }
495
+
496
+ function parseRepositorySystemImages(xml) {
497
+ const matches = [];
498
+ const regex = /<remotePackage\s+path="(system-images;[^"]+)">/g;
499
+ let match = regex.exec(xml);
500
+ while (match) {
501
+ matches.push({
502
+ packageName: match[1],
503
+ platformId: match[1].split(';')[1] || '',
504
+ tag: match[1].split(';')[2] || '',
505
+ arch: match[1].split(';')[3] || '',
506
+ });
507
+ match = regex.exec(xml);
508
+ }
509
+ return parseSystemImageCandidates(matches);
510
+ }
511
+
512
+ function parseLatestSystemImageUrl(xml, packageName) {
513
+ const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="${packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}">([\\s\\S]*?)<\\/remotePackage>`));
514
+ if (!packageMatch) throw new Error(`Could not locate ${packageName} in Android repository metadata`);
515
+
516
+ const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
517
+ for (const block of archiveBlocks) {
518
+ const urlMatch = block.match(/<url>\s*([^<]*\.zip)\s*<\/url>/);
519
+ if (urlMatch) {
520
+ const urlPart = urlMatch[1];
521
+ if (urlPart.startsWith('http')) return urlPart;
522
+ return `https://dl.google.com/android/repository/sys-img/android/${urlPart}`;
523
+ }
524
+ }
525
+
526
+ throw new Error(`Could not find a system image archive for ${packageName}`);
527
+ }
528
+
529
+ async function installEmulatorArchive(metadata) {
530
+ const url = parseLatestEmulatorUrl(metadata);
531
+ const zipPath = path.join(TMP_DIR, path.basename(url));
532
+ const extractDir = path.join(TMP_DIR, `emulator-${Date.now()}`);
533
+
534
+ fs.mkdirSync(extractDir, { recursive: true });
535
+ await downloadFile(url, zipPath);
536
+ extractZip(zipPath, extractDir);
537
+
538
+ const extractedRoot = findDirectoryContainingFiles(extractDir, [
539
+ process.platform === 'win32' ? 'emulator.exe' : 'emulator',
540
+ ]);
541
+ if (!extractedRoot) {
542
+ throw new Error('Downloaded Android emulator archive did not contain an emulator binary');
543
+ }
544
+
545
+ fs.rmSync(path.join(SDK_ROOT, 'emulator'), { recursive: true, force: true });
546
+ fs.mkdirSync(path.join(SDK_ROOT, 'emulator'), { recursive: true });
547
+ fs.cpSync(extractedRoot, path.join(SDK_ROOT, 'emulator'), { recursive: true });
548
+ fs.rmSync(zipPath, { force: true });
549
+ fs.rmSync(extractDir, { recursive: true, force: true });
550
+ }
551
+
552
+ async function installPlatformToolsArchive(metadata) {
553
+ const url = parseLatestPlatformToolsUrl(metadata);
554
+ const zipPath = path.join(TMP_DIR, path.basename(url));
555
+ const extractDir = path.join(TMP_DIR, `platform-tools-${Date.now()}`);
556
+
557
+ fs.mkdirSync(extractDir, { recursive: true });
558
+ await downloadFile(url, zipPath);
559
+ extractZip(zipPath, extractDir);
560
+
561
+ const extractedRoot = findDirectoryContainingFiles(extractDir, [
562
+ process.platform === 'win32' ? 'adb.exe' : 'adb',
563
+ ]);
564
+ if (!extractedRoot) {
565
+ throw new Error('Downloaded platform-tools archive did not contain adb');
566
+ }
567
+
568
+ fs.rmSync(path.join(SDK_ROOT, 'platform-tools'), { recursive: true, force: true });
569
+ fs.mkdirSync(path.join(SDK_ROOT, 'platform-tools'), { recursive: true });
570
+ fs.cpSync(extractedRoot, path.join(SDK_ROOT, 'platform-tools'), { recursive: true });
571
+ fs.rmSync(zipPath, { force: true });
572
+ fs.rmSync(extractDir, { recursive: true, force: true });
573
+ }
574
+
575
+ async function installSystemImageArchive(metadata, packageName) {
576
+ const url = parseLatestSystemImageUrl(metadata, packageName);
577
+ const zipPath = path.join(TMP_DIR, path.basename(url));
578
+ const extractDir = path.join(TMP_DIR, `system-image-${Date.now()}`);
579
+ const targetRoot = path.join(SDK_ROOT, ...String(packageName).split(';').filter(Boolean));
580
+
581
+ fs.mkdirSync(extractDir, { recursive: true });
582
+ await downloadFile(url, zipPath);
583
+ extractZip(zipPath, extractDir);
584
+
585
+ const extractedRoot = findDirectoryContainingFiles(extractDir, ['userdata.img']) ||
586
+ findDirectoryContainingFiles(extractDir, ['system.img']) ||
587
+ findDirectoryContainingFiles(extractDir, ['package.xml']);
588
+ if (!extractedRoot) {
589
+ throw new Error(`Downloaded Android system image archive for ${packageName} did not contain the expected files`);
590
+ }
591
+
592
+ fs.rmSync(targetRoot, { recursive: true, force: true });
593
+ fs.mkdirSync(targetRoot, { recursive: true });
594
+ fs.cpSync(extractedRoot, targetRoot, { recursive: true });
595
+ fs.rmSync(zipPath, { force: true });
596
+ fs.rmSync(extractDir, { recursive: true, force: true });
597
+ }
598
+
439
599
  function systemImageTagScore(tag) {
440
600
  const value = String(tag || '').toLowerCase();
441
601
  if (value.startsWith('google_apis_playstore')) return 50;
@@ -860,7 +1020,6 @@ class AndroidController {
860
1020
  async ensureBootstrapped() {
861
1021
  const binariesReady =
862
1022
  isExecutable(adbBinary()) &&
863
- isExecutable(sdkManagerBinary()) &&
864
1023
  isExecutable(emulatorBinary());
865
1024
 
866
1025
  if (!binariesReady) {
@@ -922,28 +1081,20 @@ class AndroidController {
922
1081
  }
923
1082
 
924
1083
  this.#appendState({ bootstrapped: true });
925
- const sdkmanager = sdkManagerBinary();
926
- const available = await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --list`, { timeout: 300000 });
1084
+ const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
1085
+ const available = parseRepositorySystemImages(systemImageMetadata);
927
1086
  const latestSystemImage = chooseConfiguredSystemImage(available) || chooseLatestSystemImage(available);
928
1087
  if (!latestSystemImage) throw new Error(formatSystemImageError(available));
929
-
930
- const refreshedState = this.#readState();
931
- const currentApiLevel = parseApiLevelFromSystemImage(refreshedState.systemImage);
932
- const shouldUpgrade =
933
- refreshedState.systemImage !== latestSystemImage.packageName ||
934
- currentApiLevel < latestSystemImage.apiLevel;
935
-
936
- if (shouldUpgrade) {
937
- await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "${latestSystemImage.packageName}"`, {
938
- timeout: 300000,
939
- });
940
- this.#appendState({
941
- bootstrapped: true,
942
- systemImage: latestSystemImage.packageName,
943
- apiLevel: latestSystemImage.apiLevel,
944
- systemImageArch: latestSystemImage.arch,
945
- });
946
- }
1088
+ const metadata = await fetchText('https://dl.google.com/android/repository/repository2-1.xml');
1089
+ await installPlatformToolsArchive(metadata);
1090
+ await installEmulatorArchive(metadata);
1091
+ await installSystemImageArchive(systemImageMetadata, latestSystemImage.packageName);
1092
+ this.#appendState({
1093
+ bootstrapped: true,
1094
+ systemImage: latestSystemImage.packageName,
1095
+ apiLevel: latestSystemImage.apiLevel,
1096
+ systemImageArch: latestSystemImage.arch,
1097
+ });
947
1098
  }
948
1099
 
949
1100
  async #bootstrapRuntime() {
@@ -969,16 +1120,15 @@ class AndroidController {
969
1120
  fs.cpSync(extractedRoot, CMDLINE_LATEST, { recursive: true });
970
1121
  fs.rmSync(zipPath, { force: true });
971
1122
  fs.rmSync(extractDir, { recursive: true, force: true });
1123
+ await installPlatformToolsArchive(metadata);
1124
+ await installEmulatorArchive(metadata);
972
1125
 
973
- const sdkmanager = sdkManagerBinary();
974
- await this.#run(`yes | ${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --licenses`, { timeout: 300000 });
975
- await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "platform-tools" "emulator"`, { timeout: 300000 });
976
-
977
- const available = await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --list`, { timeout: 300000 });
1126
+ const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
1127
+ const available = parseRepositorySystemImages(systemImageMetadata);
978
1128
  const systemImage = chooseConfiguredSystemImage(available) || chooseLatestSystemImage(available);
979
1129
  if (!systemImage) throw new Error(formatSystemImageError(available));
980
1130
 
981
- await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "${systemImage.packageName}"`, { timeout: 300000 });
1131
+ await installSystemImageArchive(systemImageMetadata, systemImage.packageName);
982
1132
  this.#appendState({
983
1133
  bootstrapped: true,
984
1134
  systemImage: systemImage.packageName,
@@ -987,20 +1137,37 @@ class AndroidController {
987
1137
  });
988
1138
  }
989
1139
 
1140
+ async bootstrapEmulator(options = {}) {
1141
+ return this.#startEmulatorBlocking(options);
1142
+ }
1143
+
1144
+ markBootstrapFailure(error) {
1145
+ const state = this.#readState();
1146
+ const recentLogLines = state.logPath ? tailFile(state.logPath, 12) : [];
1147
+ const detailedMessage = recentLogLines[recentLogLines.length - 1] || error?.message || String(error || 'Android bootstrap failed.');
1148
+ this.#appendState({
1149
+ starting: false,
1150
+ startupPhase: 'Start failed',
1151
+ lastStartError: detailedMessage,
1152
+ lastLogLine: detailedMessage,
1153
+ });
1154
+ return detailedMessage;
1155
+ }
1156
+
990
1157
  async ensureAvd() {
991
1158
  await this.ensureBootstrapped();
992
1159
 
993
1160
  const state = this.#readState();
994
- const list = await this.#run(`${quoteShell(avdManagerBinary())} list avd`, { timeout: 120000 }).catch(() => '');
995
1161
  const pkg = state.systemImage;
996
1162
  if (!pkg) throw new Error('Android system image not installed');
997
- const avdExists = list.includes(`Name: ${this.avdName}`);
1163
+ const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1164
+ const configPath = path.join(avdDir, 'config.ini');
1165
+ const avdExists = fs.existsSync(configPath);
998
1166
  let avdNeedsRecreate = avdExists && (!state.avdSystemImage || state.avdSystemImage !== pkg);
999
1167
  const avdRecreateReasons = [];
1000
1168
  if (avdNeedsRecreate && state.avdSystemImage !== pkg) {
1001
1169
  avdRecreateReasons.push(`systemImage: ${state.avdSystemImage || 'null'} -> ${pkg}`);
1002
1170
  }
1003
- const configPath = path.join(AVD_HOME, `${this.avdName}.avd`, 'config.ini');
1004
1171
  if (avdExists && fs.existsSync(configPath)) {
1005
1172
  try {
1006
1173
  const config = fs.readFileSync(configPath, 'utf8');
@@ -1030,22 +1197,85 @@ class AndroidController {
1030
1197
  console.log(`[Android] Recreating AVD to repair config mismatch (${avdRecreateReasons.join(', ')})`);
1031
1198
  }
1032
1199
  await this.stopEmulator().catch(() => {});
1033
- await this.#run(`${quoteShell(avdManagerBinary())} delete avd -n ${quoteShell(this.avdName)}`, {
1034
- timeout: 120000,
1035
- }).catch(() => {});
1036
- fs.rmSync(path.join(AVD_HOME, `${this.avdName}.avd`), { recursive: true, force: true });
1200
+ fs.rmSync(avdDir, { recursive: true, force: true });
1037
1201
  fs.rmSync(path.join(AVD_HOME, `${this.avdName}.ini`), { force: true });
1038
1202
  } else if (avdExists) {
1039
1203
  return;
1040
1204
  }
1041
1205
 
1042
- await this.#run(`printf 'no\\n' | ${quoteShell(avdManagerBinary())} create avd -n ${quoteShell(this.avdName)} -k "${pkg}" --force`, {
1043
- timeout: 120000,
1044
- });
1206
+ this.#writeAvdFiles(pkg);
1045
1207
  this.#normalizeAvdConfig();
1046
1208
  this.#appendState({ avdSystemImage: pkg });
1047
1209
  }
1048
1210
 
1211
+ #writeAvdFiles(packageName) {
1212
+ const parts = String(packageName || '').split(';').filter(Boolean);
1213
+ if (parts.length !== 4 || parts[0] !== 'system-images') {
1214
+ throw new Error(`Invalid Android system image package: ${packageName}`);
1215
+ }
1216
+ const apiLevel = parts[1].replace(/^android-/, '');
1217
+ const tagId = parts[2];
1218
+ const tagDisplay = tagId === 'google_apis' ? 'Google APIs' : tagId.replace(/_/g, ' ');
1219
+ const abi = parts[3];
1220
+ const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1221
+ const imageSysDir = systemImagePackageToRelativeDir(packageName);
1222
+ if (!imageSysDir) {
1223
+ throw new Error(`Invalid Android system image directory for package: ${packageName}`);
1224
+ }
1225
+
1226
+ fs.mkdirSync(avdDir, { recursive: true });
1227
+ fs.writeFileSync(
1228
+ path.join(AVD_HOME, `${this.avdName}.ini`),
1229
+ [
1230
+ 'avd.ini.encoding=UTF-8',
1231
+ `path=${avdDir}`,
1232
+ `path.rel=avd/${this.avdName}.avd`,
1233
+ `target=android-${apiLevel}`,
1234
+ '',
1235
+ ].join('\n')
1236
+ );
1237
+
1238
+ const configLines = [
1239
+ 'avd.ini.encoding=UTF-8',
1240
+ `AvdId=${this.avdName}`,
1241
+ `avd.ini.displayname=${this.avdName}`,
1242
+ 'PlayStore.enabled=false',
1243
+ `image.sysdir.1=${imageSysDir}`,
1244
+ `abi.type=${abi}`,
1245
+ `hw.cpu.arch=${systemImagePackageToCpuArch(packageName) || abi}`,
1246
+ 'hw.cpu.ncore=2',
1247
+ 'hw.dPad=no',
1248
+ 'hw.gps=yes',
1249
+ 'hw.gpu.enabled=yes',
1250
+ 'hw.gpu.mode=auto',
1251
+ 'hw.initialOrientation=Portrait',
1252
+ 'hw.keyboard=yes',
1253
+ 'hw.lcd.density=440',
1254
+ 'hw.lcd.height=1920',
1255
+ 'hw.lcd.width=1080',
1256
+ 'hw.mainKeys=no',
1257
+ `hw.ramSize=${DEFAULT_RAM_SIZE}`,
1258
+ 'hw.sensors.orientation=yes',
1259
+ 'hw.sensors.proximity=yes',
1260
+ 'hw.trackBall=no',
1261
+ `disk.dataPartition.size=${DEFAULT_DATA_PARTITION}`,
1262
+ `sdcard.size=${DEFAULT_SDCARD_SIZE}`,
1263
+ 'runtime.network.latency=none',
1264
+ 'runtime.network.speed=full',
1265
+ 'vm.heapSize=256',
1266
+ `tag.display=${tagDisplay}`,
1267
+ `tag.id=${tagId}`,
1268
+ '',
1269
+ ];
1270
+ fs.writeFileSync(path.join(avdDir, 'config.ini'), configLines.join('\n'));
1271
+
1272
+ const systemImageRoot = path.join(SDK_ROOT, ...String(packageName).split(';').filter(Boolean));
1273
+ const userdataImage = path.join(systemImageRoot, 'userdata.img');
1274
+ if (fs.existsSync(userdataImage)) {
1275
+ fs.copyFileSync(userdataImage, path.join(avdDir, 'userdata.img'));
1276
+ }
1277
+ }
1278
+
1049
1279
  #normalizeAvdConfig() {
1050
1280
  const configPath = path.join(AVD_HOME, `${this.avdName}.avd`, 'config.ini');
1051
1281
  if (!fs.existsSync(configPath)) return;
@@ -1272,25 +1502,34 @@ class AndroidController {
1272
1502
  startRequestedAt: requestedAt,
1273
1503
  lastLogLine: 'Android start requested.',
1274
1504
  });
1275
- const startPromise = this.#startEmulatorBlocking(options).catch((err) => {
1276
- const state = this.#readState();
1277
- const recentLogLines = state.logPath ? tailFile(state.logPath, 12) : [];
1278
- const detailedMessage = recentLogLines[recentLogLines.length - 1] || err.message;
1279
- this.#appendState({
1280
- starting: false,
1281
- startupPhase: 'Start failed',
1282
- lastStartError: detailedMessage,
1283
- lastLogLine: detailedMessage,
1505
+ const workerEnv = {
1506
+ ...process.env,
1507
+ NEOAGENT_ANDROID_BOOTSTRAP_WORKER: '1',
1508
+ NEOAGENT_ANDROID_BOOTSTRAP_USER_ID: this.userId || '',
1509
+ NEOAGENT_ANDROID_BOOTSTRAP_SCOPE_KEY: this.scopeKey,
1510
+ NEOAGENT_ANDROID_BOOTSTRAP_HEADLESS: String(options.headless !== false),
1511
+ NEOAGENT_ANDROID_BOOTSTRAP_TIMEOUT_MS: String(options.timeoutMs || 240000),
1512
+ };
1513
+ const child = spawn(process.execPath, [ANDROID_BOOTSTRAP_WORKER], {
1514
+ detached: true,
1515
+ stdio: 'ignore',
1516
+ env: workerEnv,
1517
+ });
1518
+ child.unref();
1519
+ this.startPromise = new Promise((resolve) => {
1520
+ child.once('exit', (code, signal) => {
1521
+ this.startPromise = null;
1522
+ if (code !== 0) {
1523
+ console.error('[Android] Emulator bootstrap worker exited', { code, signal });
1524
+ }
1525
+ resolve({ code, signal });
1284
1526
  });
1285
- console.error('[Android] Emulator start failed:', detailedMessage);
1286
- throw new Error(detailedMessage);
1287
- }).finally(() => {
1288
- if (this.startPromise === startPromise) {
1527
+ child.once('error', (error) => {
1289
1528
  this.startPromise = null;
1290
- }
1529
+ console.error('[Android] Emulator bootstrap worker failed to spawn', error);
1530
+ resolve({ code: null, signal: null, error });
1531
+ });
1291
1532
  });
1292
- this.startPromise = startPromise;
1293
- startPromise.catch(() => {});
1294
1533
  }
1295
1534
 
1296
1535
  const state = this.#readState();
@@ -1838,10 +2077,11 @@ class AndroidController {
1838
2077
  }
1839
2078
 
1840
2079
  async getStatus() {
1841
- const devices = isExecutable(adbBinary())
2080
+ const state = this.#readState();
2081
+ const shouldSkipDeviceProbe = state.starting === true || this.startPromise != null;
2082
+ const devices = !shouldSkipDeviceProbe && isExecutable(adbBinary())
1842
2083
  ? await this.listDevices({ ensureBootstrapped: false }).catch(() => [])
1843
2084
  : [];
1844
- const state = this.#readState();
1845
2085
  const serialInState = String(state.serial || '').trim();
1846
2086
  const serialOwnedByCurrentUser = serialInState
1847
2087
  ? !this.#isSerialOwnedByAnother(serialInState)
@@ -1,5 +1,6 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
+ const { spawn } = require('child_process');
3
4
  const { DATA_DIR } = require('../../../runtime/paths');
4
5
 
5
6
  const SCREENSHOTS_DIR = path.join(DATA_DIR, 'screenshots');
@@ -28,6 +29,19 @@ function resolveBrowserExecutablePath() {
28
29
 
29
30
  if (explicitPath && fs.existsSync(explicitPath)) return explicitPath;
30
31
 
32
+ const bundledCandidates = [
33
+ () => require('playwright-chromium').chromium.executablePath(),
34
+ () => require('playwright').chromium.executablePath(),
35
+ ];
36
+ for (const resolveBundled of bundledCandidates) {
37
+ try {
38
+ const bundledPath = resolveBundled();
39
+ if (bundledPath && fs.existsSync(bundledPath)) {
40
+ return bundledPath;
41
+ }
42
+ } catch {}
43
+ }
44
+
31
45
  const platformCandidates = process.platform === 'darwin'
32
46
  ? [
33
47
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
@@ -53,6 +67,37 @@ function resolveBrowserExecutablePath() {
53
67
  return platformCandidates.find((candidate) => fs.existsSync(candidate)) || null;
54
68
  }
55
69
 
70
+ function installPlaywrightChromiumBinary() {
71
+ const packageRoot = path.dirname(require.resolve('playwright-chromium/package.json'));
72
+ const cliPath = path.join(packageRoot, 'cli.js');
73
+ return new Promise((resolve, reject) => {
74
+ const child = spawn(process.execPath, [cliPath, 'install', 'chromium'], {
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ });
77
+ let stdout = '';
78
+ let stderr = '';
79
+
80
+ child.stdout.on('data', (data) => {
81
+ stdout += data.toString();
82
+ });
83
+ child.stderr.on('data', (data) => {
84
+ stderr += data.toString();
85
+ });
86
+ child.on('error', (error) => {
87
+ const detail = String(error?.message || 'playwright install chromium failed').trim();
88
+ reject(new Error(detail));
89
+ });
90
+ child.on('close', (code) => {
91
+ if (code === 0) {
92
+ resolve();
93
+ return;
94
+ }
95
+ const detail = String(stderr || stdout || `playwright install chromium exited with code ${code ?? 'unknown'}`).trim();
96
+ reject(new Error(detail));
97
+ });
98
+ });
99
+ }
100
+
56
101
  function rand(min, max) {
57
102
  return Math.floor(Math.random() * (max - min + 1)) + min;
58
103
  }
@@ -77,6 +122,7 @@ class BrowserController {
77
122
  this.browser = null;
78
123
  this.page = null;
79
124
  this.launching = false;
125
+ this.browserBinaryInstallPromise = null;
80
126
  this.headless = true;
81
127
  this._viewport = VIEWPORTS[0];
82
128
  this._userAgent = USER_AGENTS[0];
@@ -205,16 +251,31 @@ class BrowserController {
205
251
 
206
252
  this.launching = true;
207
253
  try {
208
- const puppeteer = require('puppeteer-extra');
209
- const StealthPlugin = require('puppeteer-extra-plugin-stealth');
210
- puppeteer.use(StealthPlugin());
254
+ const puppeteer = require('puppeteer-core');
211
255
 
212
256
  this._userAgent = USER_AGENTS[rand(0, USER_AGENTS.length - 1)];
213
257
  this._viewport = VIEWPORTS[rand(0, VIEWPORTS.length - 1)];
214
258
 
259
+ let executablePath = resolveBrowserExecutablePath();
260
+ if (!executablePath) {
261
+ if (!this.browserBinaryInstallPromise) {
262
+ this.browserBinaryInstallPromise = installPlaywrightChromiumBinary();
263
+ }
264
+ try {
265
+ await this.browserBinaryInstallPromise;
266
+ } finally {
267
+ this.browserBinaryInstallPromise = null;
268
+ }
269
+ executablePath = resolveBrowserExecutablePath();
270
+ }
271
+
272
+ if (!executablePath) {
273
+ throw new Error('No browser executable found for puppeteer-core; set PUPPETEER_EXECUTABLE_PATH or install a browser.');
274
+ }
275
+
215
276
  this.browser = await puppeteer.launch({
216
277
  headless: this.headless ? 'new' : false,
217
- executablePath: resolveBrowserExecutablePath() || undefined,
278
+ executablePath,
218
279
  args: [
219
280
  '--no-sandbox',
220
281
  '--disable-setuid-sandbox',
@@ -7,6 +7,38 @@ const FORCE_KILL_GRACE_MS = 5000;
7
7
  const MAX_STDOUT_CHARS = 50000;
8
8
  const MAX_STDERR_CHARS = 10000;
9
9
 
10
+ function resolveDefaultShell() {
11
+ const candidates = [
12
+ process.env.SHELL,
13
+ '/bin/zsh',
14
+ '/bin/bash',
15
+ '/bin/sh',
16
+ ].filter(Boolean);
17
+
18
+ for (const candidate of candidates) {
19
+ try {
20
+ execFileSync(candidate, ['-lc', 'printf ok'], {
21
+ timeout: 3000,
22
+ encoding: 'utf8',
23
+ stdio: ['ignore', 'pipe', 'pipe'],
24
+ });
25
+ return candidate;
26
+ } catch {}
27
+ }
28
+
29
+ try {
30
+ execFileSync('/bin/sh', ['-lc', 'printf ok'], {
31
+ timeout: 3000,
32
+ encoding: 'utf8',
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ });
35
+ return '/bin/sh';
36
+ } catch (error) {
37
+ console.warn('[CLI] No usable shell found for executor:', error?.message || error);
38
+ return null;
39
+ }
40
+ }
41
+
10
42
  function clampTimeout(value, fallback) {
11
43
  const parsed = Number(value);
12
44
  if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
@@ -46,7 +78,10 @@ function wrapCommandForShell(command, shellPath) {
46
78
  class CLIExecutor {
47
79
  constructor() {
48
80
  this.activeProcesses = new Map();
49
- this.defaultShell = process.env.SHELL || '/bin/zsh';
81
+ this.defaultShell = resolveDefaultShell();
82
+ if (!this.defaultShell) {
83
+ throw new Error('No usable shell found for CLI execution.');
84
+ }
50
85
  }
51
86
 
52
87
  _getLoginPath() {