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.
- package/docs/configuration.md +2 -0
- package/docs/getting-started.md +17 -0
- package/package.json +1 -1
- package/runtime/paths.js +4 -3
- package/server/guest-agent.README.md +8 -0
- package/server/guest-agent.package.json +16 -0
- package/server/guest_agent.js +13 -1
- package/server/index.js +11 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/android.js +30 -1
- package/server/routes/browser.js +29 -0
- package/server/services/android/android_bootstrap_worker.js +47 -0
- package/server/services/android/controller.js +297 -57
- package/server/services/browser/controller.js +65 -4
- package/server/services/cli/executor.js +36 -1
- package/server/services/runtime/backends/local-vm.js +97 -20
- package/server/services/runtime/guest_bootstrap.js +450 -0
- package/server/services/runtime/manager.js +11 -0
- package/server/services/runtime/qemu.js +328 -41
- package/server/services/runtime/validation.js +0 -3
|
@@ -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
|
|
926
|
-
const available =
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
|
974
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
this
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
|
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 =
|
|
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() {
|