diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef2718fb..780d0b3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,9 @@ jobs: - name: Test integration harness helpers run: npm run test:integration-harness + - name: Test workflow and CLI packaging guards + run: npm run test:github-actions + - name: Typecheck client run: npm run --prefix packages/client typecheck @@ -289,12 +292,16 @@ jobs: SIMDECK_INTEGRATION_DEVICE_TYPE: iPhone SE (3rd generation) integration-android: - name: Android emulator integration - runs-on: ubuntu-latest - timeout-minutes: 35 + name: Android emulator integration (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 65 needs: - client - packages + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -308,6 +315,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Enable KVM access + if: runner.os == 'Linux' run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules @@ -316,12 +324,13 @@ jobs: - name: Install root dependencies run: npm ci --ignore-scripts --force - - name: Build Linux Android integration artifacts + - name: Build Android integration artifacts run: | npm run build:cli npm run build:simdeck-test - - name: Android emulator integration tests + - name: Android emulator integration tests (Linux) + if: runner.os == 'Linux' uses: reactivecircus/android-emulator-runner@v2 with: api-level: 35 @@ -330,8 +339,182 @@ jobs: profile: pixel_6 avd-name: SimDeck_Pixel_CI disable-animations: true + emulator-options: -no-window -no-audio -no-boot-anim -no-snapshot -gpu swiftshader_indirect -grpc 8554 script: npm run test:integration:android env: SIMDECK_INTEGRATION_ANDROID_AVD: SimDeck_Pixel_CI SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID: "1" SIMDECK_INTEGRATION_VERBOSE: "1" + + - name: Create, boot, and test Android emulator (Windows) + if: runner.os == 'Windows' + shell: pwsh + timeout-minutes: 45 + run: | + $ErrorActionPreference = "Stop" + $sdk = $env:ANDROID_HOME + if (-not $sdk) { + $sdk = $env:ANDROID_SDK_ROOT + } + if (-not $sdk) { + $sdk = Join-Path $env:LOCALAPPDATA "Android\Sdk" + } + $env:ANDROID_HOME = $sdk + $env:ANDROID_SDK_ROOT = $sdk + $cmdlineTools = Get-ChildItem -Path (Join-Path $sdk "cmdline-tools") -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + Select-Object -First 1 + $toolsBin = if (Test-Path (Join-Path $sdk "cmdline-tools\latest\bin")) { + Join-Path $sdk "cmdline-tools\latest\bin" + } elseif ($cmdlineTools) { + Join-Path $cmdlineTools.FullName "bin" + } else { + Join-Path $sdk "tools\bin" + } + $sdkmanager = Join-Path $toolsBin "sdkmanager.bat" + $avdmanager = Join-Path $toolsBin "avdmanager.bat" + $adb = Join-Path $sdk "platform-tools\adb.exe" + $emulator = Join-Path $sdk "emulator\emulator.exe" + $windowsApi = "35" + $windowsPlatform = "platforms;android-$windowsApi" + $windowsSystemImage = "system-images;android-$windowsApi;google_atd;x86_64" + + $yesFile = Join-Path $env:RUNNER_TEMP "android-sdk-yes.txt" + 1..200 | ForEach-Object { "y" } | Set-Content -Path $yesFile -Encoding ascii + $noFile = Join-Path $env:RUNNER_TEMP "android-avd-no.txt" + "no" | Set-Content -Path $noFile -Encoding ascii + + function Invoke-AndroidToolWithInput($tool, $arguments, $inputFile) { + $line = "`"$tool`" $arguments < `"$inputFile`"" + Write-Host "cmd /c $line" + cmd /c $line + if ($LASTEXITCODE -ne 0) { + throw "$tool $arguments failed with exit code $LASTEXITCODE." + } + } + + Write-Host "Accepting Android SDK licenses" + Invoke-AndroidToolWithInput $sdkmanager "--licenses" $yesFile + Write-Host "Installing Android SDK emulator packages" + Invoke-AndroidToolWithInput $sdkmanager "--install `"platform-tools`" `"emulator`" `"$windowsPlatform`" `"$windowsSystemImage`"" $yesFile + Write-Host "Checking Android emulator acceleration" + & $emulator -accel-check + $accelSupported = $LASTEXITCODE -eq 0 + if (-not $accelSupported) { + Write-Host "Hosted Windows runner did not report VM acceleration; forcing software acceleration for the CI smoke emulator." + } + Write-Host "Creating Android AVD" + Invoke-AndroidToolWithInput $avdmanager "create avd --force --name SimDeck_Pixel_CI --package `"$windowsSystemImage`" --device `"pixel_6`"" $noFile + + $stdout = Join-Path $env:RUNNER_TEMP "simdeck-android-emulator.out.log" + $stderr = Join-Path $env:RUNNER_TEMP "simdeck-android-emulator.err.log" + $serial = "emulator-5554" + $args = @( + "-avd", "SimDeck_Pixel_CI", + "-qt-hide-window", + "-no-audio", + "-no-boot-anim", + "-no-snapshot-load", + "-no-snapshot-save", + "-wipe-data", + "-gpu", "swiftshader_indirect", + "-feature", "-Vulkan", + "-grpc", "8554", + "-port", "5554", + "-no-metrics", + "-skip-adb-auth", + "-camera-back", "none", + "-camera-front", "none", + "-cores", "2", + "-memory", "2048", + "-verbose" + ) + if ($accelSupported) { + $args += @("-accel", "on") + } else { + $args += @("-accel", "off") + } + function Write-EmulatorDiagnostics { + Write-Host "adb devices:" + & $adb devices -l + if (Test-Path $stdout) { + Write-Host "emulator stdout tail:" + Get-Content $stdout -Tail 80 + } + if (Test-Path $stderr) { + Write-Host "emulator stderr tail:" + Get-Content $stderr -Tail 120 + } + } + Write-Host "Starting Android emulator" + $process = Start-Process -FilePath $emulator -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + $process.Id | Out-File -FilePath emulator.pid -Encoding ascii + $deviceDeadline = (Get-Date).AddMinutes(10) + $deviceSeen = $false + do { + if ($process.HasExited) { + Write-EmulatorDiagnostics + throw "Android emulator exited early with code $($process.ExitCode)." + } + $devices = (& $adb devices) + if ($devices -match "$serial\s+device") { + $deviceSeen = $true + break + } + Start-Sleep -Seconds 5 + } while ((Get-Date) -lt $deviceDeadline) + if (-not $deviceSeen) { + Write-EmulatorDiagnostics + throw "Android emulator did not appear in adb before the timeout." + } + $deadline = (Get-Date).AddMinutes(20) + do { + if ($process.HasExited) { + Write-EmulatorDiagnostics + throw "Android emulator exited early with code $($process.ExitCode)." + } + $booted = (& $adb -s $serial shell getprop sys.boot_completed 2>$null | Out-String).Trim() + if ($booted -eq "1") { + break + } + Start-Sleep -Seconds 5 + } while ((Get-Date) -lt $deadline) + if ($booted -ne "1") { + Write-EmulatorDiagnostics + throw "Android emulator did not boot before the timeout." + } + & $adb -s $serial shell settings put global window_animation_scale 0 + & $adb -s $serial shell settings put global transition_animation_scale 0 + & $adb -s $serial shell settings put global animator_duration_scale 0 + $env:SIMDECK_INTEGRATION_ANDROID_AVD = "SimDeck_Pixel_CI" + $env:SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID = "1" + $env:SIMDECK_INTEGRATION_VERBOSE = "1" + npm run test:integration:android + $testExitCode = $LASTEXITCODE + if ($testExitCode -ne 0) { + Write-EmulatorDiagnostics + throw "Android integration tests failed with exit code $testExitCode." + } + + - name: Stop Android emulator (Windows) + if: always() && runner.os == 'Windows' + shell: pwsh + run: | + $sdk = $env:ANDROID_HOME + if (-not $sdk) { + $sdk = $env:ANDROID_SDK_ROOT + } + if ($sdk) { + $adb = Join-Path $sdk "platform-tools\adb.exe" + if (Test-Path $adb) { + & $adb emu kill + if ($LASTEXITCODE -ne 0) { + Write-Host "No Android emulator accepted adb emu kill." + $global:LASTEXITCODE = 0 + } + } + } + if (Test-Path emulator.pid) { + Stop-Process -Id (Get-Content emulator.pid) -Force -ErrorAction SilentlyContinue + } + exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c35c2f..c63570e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,7 +152,7 @@ jobs: if: ${{ steps.meta.outputs.kind == 'npm-cli' }} uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-darwin + targets: aarch64-apple-darwin,x86_64-apple-darwin - name: Install native build dependencies if: ${{ steps.meta.outputs.kind == 'npm-cli' }} @@ -265,21 +265,34 @@ jobs: # ---------- Build (kind-specific) ---------- - - name: Build root simdeck (arm64 native binary + client + simdeck-test) + - name: Build root simdeck (macOS arm64+x64 binaries + client + simdeck-test) if: ${{ steps.meta.outputs.kind == 'npm-cli' }} - env: - SIMDECK_BUILD_TARGET: aarch64-apple-darwin run: | - npm run build:cli + SIMDECK_BUILD_TARGET=aarch64-apple-darwin npm run build:cli + cp build/simdeck-bin build/simdeck-bin-darwin-arm64 + + SIMDECK_BUILD_TARGET=x86_64-apple-darwin npm run build:cli + cp build/simdeck-bin build/simdeck-bin-darwin-x64 + + lipo -create \ + -output build/simdeck-bin \ + build/simdeck-bin-darwin-arm64 \ + build/simdeck-bin-darwin-x64 + npm run build:client npm run build:simdeck-test - - name: Verify CLI artifact is an arm64 Mach-O + - name: Verify CLI artifacts are macOS arm64+x64 binaries if: ${{ steps.meta.outputs.kind == 'npm-cli' }} run: | test -x build/simdeck-bin + test -x build/simdeck-bin-darwin-arm64 + test -x build/simdeck-bin-darwin-x64 file build/simdeck-bin file build/simdeck-bin | grep -q 'arm64' + file build/simdeck-bin | grep -q 'x86_64' + file build/simdeck-bin-darwin-arm64 | grep -q 'arm64' + file build/simdeck-bin-darwin-x64 | grep -q 'x86_64' # ---------- Apple codesign + notarize (root simdeck only) ---------- @@ -474,7 +487,6 @@ jobs: NODE_AUTH_TOKEN: "" DIST_TAG: ${{ steps.tag.outputs.dist_tag }} PKG_DIR: ${{ steps.meta.outputs.dir }} - SIMDECK_BUILD_TARGET: aarch64-apple-darwin run: | set -euo pipefail unset NODE_AUTH_TOKEN @@ -493,7 +505,6 @@ jobs: NODE_AUTH_TOKEN: "" DIST_TAG: ${{ steps.tag.outputs.dist_tag }} PKG_DIR: ${{ steps.meta.outputs.dir }} - SIMDECK_BUILD_TARGET: aarch64-apple-darwin run: | set -euo pipefail unset NODE_AUTH_TOKEN diff --git a/package.json b/package.json index 0e281501..5a05f8ac 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,11 @@ "scripts/postinstall.mjs", "build/simdeck-bin", "build/camera/", + "build/simdeck-bin-darwin-arm64", + "build/simdeck-bin-darwin-x64", + "build/simdeck-bin-linux-arm64", + "build/simdeck-bin-linux-x64", + "build/simdeck-bin-win32-x64.exe", "packages/client/dist/", "packages/simdeck-test/dist/" ], @@ -34,16 +39,19 @@ "node": ">=18" }, "os": [ - "darwin" + "darwin", + "linux", + "win32" ], "cpu": [ - "arm64" + "arm64", + "x64" ], "publishConfig": { "access": "public" }, "scripts": { - "build:cli": "scripts/build-cli.sh", + "build:cli": "node scripts/build-cli.mjs", "build:client": "scripts/build-client.sh", "build:app": "npm run build:cli && npm run build:client", "build:inspectors": "npm run build:nativescript-inspector && npm run build:react-native-inspector", diff --git a/packages/cli/bin/simdeck.mjs b/packages/cli/bin/simdeck.mjs index 164babc0..5ad856fa 100755 --- a/packages/cli/bin/simdeck.mjs +++ b/packages/cli/bin/simdeck.mjs @@ -9,12 +9,14 @@ const RECOVERABLE_RESTART_EXIT_CODE = 75; const launcherDir = path.dirname(fileURLToPath(import.meta.url)); const packageRoot = findPackageRoot(launcherDir); -const binaryPath = path.join(packageRoot, "build", "simdeck-bin"); +const binaryPath = resolveBinaryPath(packageRoot); const childArgs = process.argv.slice(2); const isServiceRun = childArgs[0] === "service" && childArgs[1] === "run"; -if (process.platform !== "darwin") { - console.error("simdeck only supports macOS."); +if (!binaryPath) { + console.error( + "simdeck only supports macOS, Linux, and Windows on arm64/x64.", + ); process.exit(1); } @@ -39,6 +41,36 @@ function findPackageRoot(startDir) { } } +function resolveBinaryPath(rootDir) { + const platform = process.platform; + const arch = process.arch; + const binaryByHost = { + "darwin-arm64": "simdeck-bin-darwin-arm64", + "darwin-x64": "simdeck-bin-darwin-x64", + "linux-arm64": "simdeck-bin-linux-arm64", + "linux-x64": "simdeck-bin-linux-x64", + "win32-x64": "simdeck-bin-win32-x64.exe", + }; + + const binary = binaryByHost[`${platform}-${arch}`]; + if (!binary) { + return null; + } + + const platformBinaryPath = path.join(rootDir, "build", binary); + if (existsSync(platformBinaryPath)) { + return platformBinaryPath; + } + + for (const fallback of ["simdeck-bin.exe", "simdeck-bin"]) { + const fallbackBinaryPath = path.join(rootDir, "build", fallback); + if (existsSync(fallbackBinaryPath)) { + return fallbackBinaryPath; + } + } + return platformBinaryPath; +} + let child; let terminating = false; diff --git a/packages/server/build.rs b/packages/server/build.rs index 7606854e..7ecccf1d 100644 --- a/packages/server/build.rs +++ b/packages/server/build.rs @@ -9,8 +9,8 @@ fn main() { println!("cargo:rerun-if-changed={}", stub.display()); cc::Build::new() .file(&stub) - .flag("-Wall") - .flag("-Wextra") + .flag_if_supported("-Wall") + .flag_if_supported("-Wextra") .compile("xcw_native_bridge"); return; } @@ -42,8 +42,8 @@ fn main() { .include(&native) .flag("-fobjc-arc") .flag("-fmodules") - .flag("-Wall") - .flag("-Wextra"); + .flag_if_supported("-Wall") + .flag_if_supported("-Wextra"); apply_pkg_config_compile_flags(&mut build, &x264_flags); for file in &files { diff --git a/packages/server/native_stubs.c b/packages/server/native_stubs.c index 861881a6..7b3ae92a 100644 --- a/packages/server/native_stubs.c +++ b/packages/server/native_stubs.c @@ -2,7 +2,11 @@ #include #include #include +#ifdef _WIN32 +#include +#else #include +#endif typedef struct { uint8_t *data; @@ -65,6 +69,9 @@ void xcw_native_run_main_loop_slice(double duration_seconds) { if (duration_seconds <= 0.0) { return; } +#ifdef _WIN32 + Sleep((DWORD)(duration_seconds * 1000.0)); +#else time_t seconds = (time_t)duration_seconds; long nanos = (long)((duration_seconds - (double)seconds) * 1000000000.0); if (nanos < 0) { @@ -72,6 +79,7 @@ void xcw_native_run_main_loop_slice(double duration_seconds) { } struct timespec delay = {.tv_sec = seconds, .tv_nsec = nanos}; nanosleep(&delay, NULL); +#endif } char *simdeck_camera_list_webcams_json(char **error_message) { diff --git a/packages/server/src/android.rs b/packages/server/src/android.rs index 0688bf43..adc2c794 100644 --- a/packages/server/src/android.rs +++ b/packages/server/src/android.rs @@ -319,17 +319,27 @@ impl AndroidBridge { return Ok(false); } let grpc_port = self.grpc_port_for_avd(&avd_name)?; + let grpc_port = grpc_port.to_string(); + let is_windows = cfg!(target_os = "windows"); + let window_mode = if is_windows { + "-qt-hide-window" + } else { + "-no-window" + }; + let mut args = vec![ + "-avd", + &avd_name, + window_mode, + "-no-audio", + "-gpu", + "swiftshader_indirect", + ]; + if is_windows { + args.extend(["-feature", "-Vulkan"]); + } + args.extend(["-grpc", &grpc_port]); Command::new(self.emulator_path()) - .args([ - "-avd", - &avd_name, - "-no-window", - "-no-audio", - "-gpu", - "swiftshader_indirect", - "-grpc", - &grpc_port.to_string(), - ]) + .args(args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -437,6 +447,10 @@ impl AndroidBridge { pub fn launch_package(&self, id: &str, package: &str) -> Result<(), AppError> { let serial = self.serial_for_id(id)?; + if is_android_component_name(package) { + self.run_adb(["-s", &serial, "shell", "am", "start", "-n", package])?; + return Ok(()); + } self.run_adb([ "-s", &serial, @@ -774,6 +788,7 @@ impl AndroidBridge { ) -> Result { let avd_name = avd_from_id(id)?; let port = self.grpc_port_for_avd(&avd_name)?; + let serial = self.resolve_serial(&avd_name)?; let mut format = grpc::ImageFormat { format: grpc::image_format::ImgFormat::Rgba8888 as i32, width: 0, @@ -782,9 +797,8 @@ impl AndroidBridge { transport: None, }; let target = self - .resolve_serial(&avd_name) + .display_metrics_for_serial(&serial) .ok() - .and_then(|serial| self.display_metrics_for_serial(&serial).ok()) .map(|metrics| AndroidFrameTarget { width: metrics.width.round().max(1.0) as u32, height: metrics.height.round().max(1.0) as u32, @@ -820,7 +834,7 @@ impl AndroidBridge { "/android.emulation.control.EmulatorController/streamScreenshot", ); let mut request = tonic::Request::new(format); - if let Some(token) = emulator_grpc_token(port) { + if let Some(token) = self.emulator_grpc_token(&serial, port) { let value = MetadataValue::try_from(format!("Bearer {token}")).map_err(|error| { AppError::native(format!("Invalid Android emulator gRPC token: {error}")) })?; @@ -955,9 +969,16 @@ impl AndroidBridge { } fn resolve_serial(&self, avd_name: &str) -> Result { - self.running_emulators()?.remove(avd_name).ok_or_else(|| { - AppError::native(format!("Android emulator `{avd_name}` is not running.")) - }) + if let Some(serial) = self.running_emulators()?.remove(avd_name) { + return Ok(serial); + } + let serials = self.online_emulator_serials()?; + if serials.len() == 1 && self.known_avd(avd_name)? { + return Ok(serials[0].clone()); + } + Err(AppError::native(format!( + "Android emulator `{avd_name}` is not running." + ))) } fn running_emulators(&self) -> Result, AppError> { @@ -971,23 +992,28 @@ impl AndroidBridge { if !self.adb_path().exists() { return Ok(HashMap::new()); } - let output = self.run_adb(["devices"])?; let mut result = HashMap::new(); - for line in output.lines().skip(1) { - let mut parts = line.split_whitespace(); - let Some(serial) = parts.next() else { continue }; - let Some(state) = parts.next() else { continue }; - if state != "device" || !serial.starts_with("emulator-") { - continue; - } - if let Some(name) = self.avd_name_for_serial(serial) { - result.insert(name, serial.to_owned()); + for serial in self.online_emulator_serials()? { + if let Some(name) = self.avd_name_for_serial(&serial) { + result.insert(name, serial); } } *cache.lock().unwrap() = Some((Instant::now(), result.clone())); Ok(result) } + fn online_emulator_serials(&self) -> Result, AppError> { + Ok(parse_online_emulator_serials(&self.run_adb(["devices"])?)) + } + + fn known_avd(&self, avd_name: &str) -> Result { + Ok(self + .run_emulator(["-list-avds"])? + .lines() + .map(str::trim) + .any(|name| name == avd_name)) + } + fn avd_name_for_serial(&self, serial: &str) -> Option { for property in ["ro.boot.qemu.avd_name", "ro.kernel.qemu.avd_name"] { if let Ok(output) = self.run_adb(["-s", serial, "shell", "getprop", property]) { @@ -1124,11 +1150,11 @@ impl AndroidBridge { } fn adb_path(&self) -> PathBuf { - sdk_root().join("platform-tools/adb") + android_sdk_tool_path("platform-tools/adb") } fn emulator_path(&self) -> PathBuf { - sdk_root().join("emulator/emulator") + android_sdk_tool_path("emulator/emulator") } fn avdmanager_path(&self) -> PathBuf { @@ -1142,6 +1168,29 @@ impl AndroidBridge { fn avd_dir(&self, avd_name: &str) -> PathBuf { home_dir().join(format!(".android/avd/{avd_name}.avd")) } + + fn emulator_grpc_token(&self, serial: &str, port: u16) -> Option { + self.discovery_path_grpc_token(serial, port) + .or_else(|| per_instance_grpc_token(port)) + .or_else(global_grpc_token) + } + + fn discovery_path_grpc_token(&self, serial: &str, port: u16) -> Option { + let output = self + .run_adb(["-s", serial, "emu", "avd", "discoverypath"]) + .ok()?; + let path = output + .lines() + .map(str::trim) + .find(|line| { + !line.is_empty() + && *line != "OK" + && (line.ends_with(".ini") || line.contains("avd")) + }) + .map(PathBuf::from)?; + let contents = std::fs::read_to_string(path).ok()?; + grpc_token_from_discovery_ini(&contents, port) + } } impl AndroidGrpcFrameStream { @@ -1355,11 +1404,16 @@ fn run_command_with_stdin( program.display() ))); } - let mut child = Command::new(&program) + let sdk_root = sdk_root(); + let mut command = Command::new(&program); + command .args(args) - .env("ANDROID_HOME", sdk_root()) - .env("ANDROID_SDK_ROOT", sdk_root()) - .env("JAVA_HOME", java_home()) + .env("ANDROID_HOME", &sdk_root) + .env("ANDROID_SDK_ROOT", &sdk_root); + if let Some(java_home) = java_home() { + command.env("JAVA_HOME", java_home); + } + let mut child = command .stdin(if stdin_input.is_some() { Stdio::piped() } else { @@ -1617,12 +1671,61 @@ fn sdk_root() -> PathBuf { .or_else(|| env::var_os("ANDROID_SDK_ROOT")) .map(PathBuf::from) .filter(|path| path.exists()) - .unwrap_or_else(|| home_dir().join("Library/Android/sdk")) + .unwrap_or_else(default_sdk_root) +} + +fn default_sdk_root() -> PathBuf { + if cfg!(target_os = "macos") { + return home_dir().join("Library/Android/sdk"); + } + if cfg!(target_os = "windows") { + if let Some(local_app_data) = env::var_os("LOCALAPPDATA") { + return PathBuf::from(local_app_data).join("Android/Sdk"); + } + return home_dir().join("AppData/Local/Android/Sdk"); + } + home_dir().join("Android/Sdk") +} + +fn android_sdk_tool_path(relative_path: &str) -> PathBuf { + android_sdk_tool_path_for_os(sdk_root().as_path(), relative_path, std::env::consts::OS) +} + +fn android_sdk_tool_path_for_os(root: &Path, relative_path: &str, os: &str) -> PathBuf { + let mut path = root.join(relative_path); + if os == "windows" && path.extension().is_none() { + path.set_extension("exe"); + } + path +} + +fn parse_online_emulator_serials(output: &str) -> Vec { + output + .lines() + .skip(1) + .filter_map(|line| { + let mut parts = line.split_whitespace(); + let serial = parts.next()?; + let state = parts.next()?; + (state == "device" && serial.starts_with("emulator-")).then(|| serial.to_owned()) + }) + .collect() +} + +fn is_android_component_name(value: &str) -> bool { + value + .split_once('/') + .map(|(package, activity)| !package.is_empty() && !activity.is_empty()) + .unwrap_or(false) } fn android_cmdline_tool_path(name: &str) -> PathBuf { let root = sdk_root(); - let latest = root.join("cmdline-tools/latest/bin").join(name); + let latest = android_sdk_tool_path_for_os( + &root, + &format!("cmdline-tools/latest/bin/{name}"), + std::env::consts::OS, + ); if latest.exists() { return latest; } @@ -1630,7 +1733,13 @@ fn android_cmdline_tool_path(name: &str) -> PathBuf { if let Ok(entries) = std::fs::read_dir(&cmdline_tools) { let mut candidates = entries .filter_map(Result::ok) - .map(|entry| entry.path().join("bin").join(name)) + .map(|entry| { + android_sdk_tool_path_for_os( + entry.path().join("bin").as_path(), + name, + std::env::consts::OS, + ) + }) .filter(|path| path.exists()) .collect::>(); candidates.sort(); @@ -1638,47 +1747,85 @@ fn android_cmdline_tool_path(name: &str) -> PathBuf { return path; } } - let tools_bin = root.join("tools/bin").join(name); + let tools_bin = + android_sdk_tool_path_for_os(&root, &format!("tools/bin/{name}"), std::env::consts::OS); if tools_bin.exists() { return tools_bin; } latest } -fn java_home() -> OsString { - env::var_os("JAVA_HOME").unwrap_or_else(|| OsString::from("/opt/homebrew/opt/openjdk")) +fn java_home() -> Option { + env::var_os("JAVA_HOME") + .or_else(|| cfg!(target_os = "macos").then(|| OsString::from("/opt/homebrew/opt/openjdk"))) } fn home_dir() -> PathBuf { env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .or_else( + || match (env::var_os("HOMEDRIVE"), env::var_os("HOMEPATH")) { + (Some(drive), Some(path)) => { + let mut combined = PathBuf::from(drive); + combined.push(path); + Some(combined.into_os_string()) + } + _ => None, + }, + ) .map(PathBuf::from) .unwrap_or_else(|| Path::new("/").to_path_buf()) } -fn emulator_grpc_token(port: u16) -> Option { - per_instance_grpc_token(port).or_else(global_grpc_token) -} - fn per_instance_grpc_token(port: u16) -> Option { - let running_dir = home_dir().join("Library/Caches/TemporaryItems/avd/running"); - let entries = std::fs::read_dir(running_dir).ok()?; - let port_value = port.to_string(); - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("ini") { - continue; - } - let contents = std::fs::read_to_string(path).ok()?; - let fields = parse_ini(&contents); - if fields.get("grpc.port") == Some(&port_value) { - if let Some(token) = fields.get("grpc.token").filter(|token| !token.is_empty()) { - return Some(token.to_owned()); + for running_dir in emulator_discovery_dirs() { + let entries = match std::fs::read_dir(running_dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + let port_value = port.to_string(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("ini") { + continue; + } + let contents = std::fs::read_to_string(path).ok()?; + let fields = parse_ini(&contents); + if fields.get("grpc.port") == Some(&port_value) { + if let Some(token) = fields.get("grpc.token").filter(|token| !token.is_empty()) { + return Some(token.to_owned()); + } } } } None } +fn emulator_discovery_dirs() -> Vec { + let mut dirs = Vec::new(); + dirs.push(home_dir().join("Library/Caches/TemporaryItems/avd/running")); + dirs.push(std::env::temp_dir().join("avd/running")); + if cfg!(target_os = "linux") { + if let Some(user) = env::var_os("USER") { + dirs.push( + Path::new("/tmp").join(format!("android-{}/avd/running", user.to_string_lossy())), + ); + } + dirs.push(Path::new("/tmp").join("avd/running")); + } + dirs +} + +fn grpc_token_from_discovery_ini(contents: &str, port: u16) -> Option { + let port_value = port.to_string(); + let fields = parse_ini(contents); + (fields.get("grpc.port") == Some(&port_value)) + .then(|| fields.get("grpc.token")) + .flatten() + .filter(|token| !token.is_empty()) + .map(ToOwned::to_owned) +} + fn global_grpc_token() -> Option { std::fs::read_to_string(home_dir().join(".emulator_console_auth_token")) .ok() @@ -2456,6 +2603,59 @@ DisplayDeviceInfo{"Built-in Screen", 1080 x 2400, roundedCorners RoundedCorners{ ] ); } + + #[test] + fn grpc_token_from_discovery_ini_matches_requested_port() { + let contents = r#" +avd.name=Pixel_8 +grpc.port=8554 +grpc.token=secret-token +port.serial=5554 +"#; + + assert_eq!( + grpc_token_from_discovery_ini(contents, 8554).as_deref(), + Some("secret-token") + ); + assert_eq!(grpc_token_from_discovery_ini(contents, 8555), None); + } + + #[test] + fn android_tool_path_adds_windows_exe_suffix() { + let root = Path::new(r"C:\Android\Sdk"); + + assert_eq!( + android_sdk_tool_path_for_os(root, "platform-tools/adb", "windows"), + root.join("platform-tools").join("adb.exe") + ); + assert_eq!( + android_sdk_tool_path_for_os(root, "platform-tools/adb", "linux"), + root.join("platform-tools").join("adb") + ); + } + + #[test] + fn parse_online_emulator_serials_ignores_offline_devices() { + let output = "\ +List of devices attached +emulator-5554\tdevice +emulator-5556\toffline +abcd1234\tdevice +"; + + assert_eq!(parse_online_emulator_serials(output), vec!["emulator-5554"]); + } + + #[test] + fn android_component_names_require_package_and_activity() { + assert!(is_android_component_name("com.android.settings/.Settings")); + assert!(is_android_component_name( + "com.example/com.example.MainActivity" + )); + assert!(!is_android_component_name("com.example")); + assert!(!is_android_component_name("com.example/")); + assert!(!is_android_component_name("/.MainActivity")); + } } mod grpc { diff --git a/packages/server/src/config.rs b/packages/server/src/config.rs index e703d878..a85cd6b4 100644 --- a/packages/server/src/config.rs +++ b/packages/server/src/config.rs @@ -1,4 +1,5 @@ use sha2::{Digest, Sha256}; +#[cfg(unix)] use std::ffi::CStr; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; @@ -78,27 +79,39 @@ impl ServerKind { } fn local_host_name() -> String { + platform_host_name() + .and_then(|value| { + value + .trim() + .trim_end_matches(".local") + .trim_end_matches('.') + .split('.') + .next() + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| "localhost".to_owned()) +} + +#[cfg(unix)] +fn platform_host_name() -> Option { let mut buffer = [0 as libc::c_char; 256]; - let name = unsafe { + unsafe { if libc::gethostname(buffer.as_mut_ptr(), buffer.len()) != 0 { None } else { buffer[buffer.len() - 1] = 0; CStr::from_ptr(buffer.as_ptr()).to_str().ok() } - }; + } + .map(ToOwned::to_owned) +} - name.and_then(|value| { - value - .trim() - .trim_end_matches(".local") - .trim_end_matches('.') - .split('.') - .next() - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - }) - .unwrap_or_else(|| "localhost".to_owned()) +#[cfg(not(unix))] +fn platform_host_name() -> Option { + std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .ok() } fn host_identity(host_name: &str) -> String { diff --git a/packages/server/src/main.rs b/packages/server/src/main.rs index 9891e87e..0a8425bf 100644 --- a/packages/server/src/main.rs +++ b/packages/server/src/main.rs @@ -17,7 +17,64 @@ mod service; mod simulators; mod static_files; mod transport; +#[cfg(target_os = "macos")] mod webkit; +#[cfg(not(target_os = "macos"))] +mod webkit { + use crate::error::AppError; + use axum::extract::ws::WebSocket; + use serde::Serialize; + use std::path::PathBuf; + + #[derive(Clone, Debug, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct WebKitTarget { + pub id: String, + pub app_id: String, + pub app_name: Option, + pub app_active: bool, + pub page_active: bool, + pub page_id: u64, + pub title: Option, + pub url: Option, + pub kind: String, + pub inspector_url: String, + pub web_socket_url: String, + } + + #[derive(Clone, Debug, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct WebKitTargetDiscovery { + pub udid: String, + pub socket_path: Option, + pub targets: Vec, + pub warnings: Vec, + } + + pub async fn discover_targets( + udid: &str, + _http_origin: Option<&str>, + ) -> Result { + Ok(WebKitTargetDiscovery { + udid: udid.to_owned(), + socket_path: None, + targets: Vec::new(), + warnings: vec![ + "WebKit inspection is only available for iOS simulators on macOS.".to_owned(), + ], + }) + } + + pub async fn attach_websocket(_udid: String, _target_id: String, _socket: WebSocket) {} + + pub fn webkit_inspector_ui_root() -> Option { + None + } + + pub fn inject_frontend_host(main_html: &str) -> String { + main_html.to_owned() + } +} use accessibility::{interactive_accessibility_snapshot, AccessibilitySource}; use anyhow::Context; @@ -1279,6 +1336,7 @@ fn start_project_service(options: ServiceLaunchOptions) -> anyhow::Result/dev/null; wait "$child" 2>/dev/null; fi' TERM INT HUP @@ -1303,7 +1361,9 @@ done recoverable_restart_exit_code = RECOVERABLE_RESTART_EXIT_CODE ); + #[cfg(unix)] let mut command = ProcessCommand::new("/bin/sh"); + #[cfg(unix)] command .arg("-c") .arg(supervisor_script) @@ -1318,6 +1378,19 @@ done "0" }, ); + #[cfg(not(unix))] + let mut command = { + let mut command = ProcessCommand::new(&executable); + command.args(&args).env( + "SIMDECK_REALTIME_STREAM", + if options.realtime_stream || options.stream_quality_profile.is_some() { + "1" + } else { + "0" + }, + ); + command + }; if let Some(local_stream_fps) = options.local_stream_fps { command.env("SIMDECK_LOCAL_STREAM_FPS", local_stream_fps.to_string()); } diff --git a/packages/server/src/service.rs b/packages/server/src/service.rs index 7155ad6f..96996f00 100644 --- a/packages/server/src/service.rs +++ b/packages/server/src/service.rs @@ -27,6 +27,7 @@ pub struct ServiceInstallResult { } pub fn enable(mut options: ServiceOptions) -> anyhow::Result<()> { + ensure_launch_agent_supported()?; preserve_or_create_credentials(&mut options); if let Some(result) = reuse_running_service_if_matching(&options)? { return print_install_result(&result); @@ -36,18 +37,21 @@ pub fn enable(mut options: ServiceOptions) -> anyhow::Result<()> { } pub fn restart(mut options: ServiceOptions) -> anyhow::Result<()> { + ensure_launch_agent_supported()?; preserve_or_create_credentials(&mut options); let result = install(options)?; print_install_result(&result) } pub fn reset(mut options: ServiceOptions) -> anyhow::Result<()> { + ensure_launch_agent_supported()?; reset_credentials(&mut options); let result = install(options)?; print_install_result(&result) } pub fn pair(mut options: ServiceOptions) -> anyhow::Result { + ensure_launch_agent_supported()?; preserve_or_create_credentials(&mut options); if let Some(result) = reuse_running_service_if_matching(&options)? { return Ok(result); @@ -94,10 +98,16 @@ fn apply_credentials( } pub fn installed_port() -> anyhow::Result> { + if !launch_agent_supported() { + return Ok(None); + } Ok(installed_argument_value("--port")?.and_then(|value| value.parse::().ok())) } pub fn active() -> anyhow::Result> { + if !launch_agent_supported() { + return Ok(None); + } let domain = launchctl_domain()?; if launchagent_pid(&domain, SERVICE_LABEL).is_none() { return Ok(None); @@ -191,6 +201,7 @@ fn print_install_result(result: &ServiceInstallResult) -> anyhow::Result<()> { } pub fn disable() -> anyhow::Result<()> { + ensure_launch_agent_supported()?; let plist_path = plist_path()?; let _ = kill_installed()?; @@ -206,6 +217,7 @@ pub fn disable() -> anyhow::Result<()> { } pub fn kill_installed() -> anyhow::Result> { + ensure_launch_agent_supported()?; let domain = launchctl_domain()?; let killed = unload_existing_services(&domain)?; for path in service_plist_paths()? { @@ -216,6 +228,18 @@ pub fn kill_installed() -> anyhow::Result> { Ok(killed) } +fn launch_agent_supported() -> bool { + cfg!(target_os = "macos") +} + +fn ensure_launch_agent_supported() -> anyhow::Result<()> { + if launch_agent_supported() { + Ok(()) + } else { + bail!("SimDeck persistent LaunchAgent services are only available on macOS.") + } +} + fn plist_path() -> anyhow::Result { plist_path_for_label(SERVICE_LABEL) } diff --git a/packages/simdeck-test/dist/index.js b/packages/simdeck-test/dist/index.js index 942a4d03..6e883c53 100644 --- a/packages/simdeck-test/dist/index.js +++ b/packages/simdeck-test/dist/index.js @@ -366,7 +366,7 @@ export async function connect(options = {}) { if (result.child) { result.child.kill(); if (result.isolatedRoot) { - fs.rmSync(result.isolatedRoot, { recursive: true, force: true }); + removeIsolatedRoot(result.isolatedRoot); } return; } @@ -411,7 +411,7 @@ async function startIsolatedService(cliPath, options) { } catch (error) { child.kill(); - fs.rmSync(projectRoot, { recursive: true, force: true }); + removeIsolatedRoot(projectRoot); throw error; } return { @@ -424,6 +424,30 @@ async function startIsolatedService(cliPath, options) { isolatedRoot: projectRoot, }; } +function removeIsolatedRoot(projectRoot) { + try { + fs.rmSync(projectRoot, { + recursive: true, + force: true, + maxRetries: process.platform === "win32" ? 20 : 3, + retryDelay: 100, + }); + } + catch (error) { + if (process.platform === "win32" && isWindowsTransientRemoveError(error)) { + console.warn(`Unable to remove isolated SimDeck test project ${projectRoot}: ${error instanceof Error ? error.message : String(error)}`); + return; + } + throw error; + } +} +function isWindowsTransientRemoveError(error) { + if (!error || typeof error !== "object") { + return false; + } + const code = error.code; + return code === "EBUSY" || code === "ENOTEMPTY" || code === "EPERM"; +} async function waitForHealth(endpoint, child, output) { const deadline = Date.now() + 60_000; let lastError; diff --git a/packages/simdeck-test/src/index.ts b/packages/simdeck-test/src/index.ts index f564cc14..79aa8b08 100644 --- a/packages/simdeck-test/src/index.ts +++ b/packages/simdeck-test/src/index.ts @@ -699,7 +699,7 @@ export async function connect( if (result.child) { result.child.kill(); if (result.isolatedRoot) { - fs.rmSync(result.isolatedRoot, { recursive: true, force: true }); + removeIsolatedRoot(result.isolatedRoot); } return; } @@ -756,7 +756,7 @@ async function startIsolatedService( await waitForHealth(url, child, output); } catch (error) { child.kill(); - fs.rmSync(projectRoot, { recursive: true, force: true }); + removeIsolatedRoot(projectRoot); throw error; } return { @@ -770,6 +770,35 @@ async function startIsolatedService( }; } +function removeIsolatedRoot(projectRoot: string): void { + try { + fs.rmSync(projectRoot, { + recursive: true, + force: true, + maxRetries: process.platform === "win32" ? 20 : 3, + retryDelay: 100, + }); + } catch (error) { + if (process.platform === "win32" && isWindowsTransientRemoveError(error)) { + console.warn( + `Unable to remove isolated SimDeck test project ${projectRoot}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return; + } + throw error; + } +} + +function isWindowsTransientRemoveError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code === "EBUSY" || code === "ENOTEMPTY" || code === "EPERM"; +} + async function waitForHealth( endpoint: string, child: ChildProcess, diff --git a/scripts/build-cli.mjs b/scripts/build-cli.mjs new file mode 100644 index 00000000..5cf649f8 --- /dev/null +++ b/scripts/build-cli.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const buildDir = path.join(rootDir, "build"); +const manifestPath = path.join(rootDir, "packages", "server", "Cargo.toml"); +const serverTargetDir = path.join(rootDir, "packages", "server", "target"); +const target = process.env.SIMDECK_BUILD_TARGET?.trim(); +const hostExe = process.platform === "win32" ? ".exe" : ""; +const outputBin = path.join( + buildDir, + `simdeck-bin${targetExe(target) ?? hostExe}`, +); + +fs.mkdirSync(buildDir, { recursive: true }); + +if (target) { + const installedTargets = run("rustup", ["target", "list", "--installed"], { + encoding: "utf8", + }).stdout; + if (!installedTargets.split(/\r?\n/).includes(target)) { + console.log(`Installing missing Rust target: ${target}`); + run("rustup", ["target", "add", target]); + } +} + +const cargoArgs = ["build", "--release", "--manifest-path", manifestPath]; +if (target) { + cargoArgs.push("--target", target); +} +run("cargo", cargoArgs); + +const serverBin = path.join( + serverTargetDir, + ...(target ? [target] : []), + "release", + `simdeck-server${targetExe(target) ?? hostExe}`, +); +const tmpOutputBin = `${outputBin}.tmp.${process.pid}`; +fs.copyFileSync(serverBin, tmpOutputBin); +if (process.platform !== "win32") { + fs.chmodSync(tmpOutputBin, 0o755); +} +fs.renameSync(tmpOutputBin, outputBin); + +console.log(`Built ${outputBin}`); +run("file", [outputBin], { optional: true }); + +if (process.platform !== "win32") { + const output = path.join(buildDir, "simdeck"); + fs.writeFileSync( + output, + `#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if [[ "\${1:-}" == "service" ]] && [[ "\${2:-}" == "run" ]]; then + while true; do + set +e + "$SCRIPT_DIR/${path.basename(outputBin)}" "$@" + child_status=$? + set -e + if [[ "$child_status" == "75" ]]; then + sleep 0.5 + continue + fi + exit "$child_status" + done +fi + +exec "$SCRIPT_DIR/${path.basename(outputBin)}" "$@" +`, + ); + fs.chmodSync(output, 0o755); + console.log(`Built ${output}`); +} + +function targetExe(value) { + if (!value) { + return null; + } + return value.includes("windows") ? ".exe" : ""; +} + +function run(command, args, options = {}) { + const spawnOptions = { + cwd: rootDir, + stdio: options.encoding ? ["ignore", "pipe", "inherit"] : "inherit", + }; + if (options.encoding) { + spawnOptions.encoding = options.encoding; + } + const result = spawnSync(command, args, spawnOptions); + if (result.error) { + if (options.optional) { + return result; + } + throw result.error; + } + if (result.status !== 0 && !options.optional) { + process.exit(result.status ?? 1); + } + return result; +} diff --git a/scripts/github-actions.test.mjs b/scripts/github-actions.test.mjs index 604bdb75..0696ef9e 100644 --- a/scripts/github-actions.test.mjs +++ b/scripts/github-actions.test.mjs @@ -21,6 +21,21 @@ const androidAction = readFileSync( new URL("../actions/run-android-comment-session/action.yml", import.meta.url), "utf8", ); +const ciWorkflow = readFileSync( + new URL("../.github/workflows/ci.yml", import.meta.url), + "utf8", +); +const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); +const cliWrapper = readFileSync( + new URL("../packages/cli/bin/simdeck.mjs", import.meta.url), + "utf8", +); +const androidIntegration = readFileSync( + new URL("../scripts/integration/android.mjs", import.meta.url), + "utf8", +); function indexOfStep(action, name) { const index = action.indexOf(`- name: ${name}`); @@ -38,6 +53,76 @@ function stepSlice(action, name, nextName) { const darwinTest = process.platform === "darwin" ? test : test.skip; +test("npm package declares Windows Android host support", () => { + assert.ok(packageJson.os.includes("darwin")); + assert.ok(packageJson.os.includes("linux")); + assert.ok(packageJson.os.includes("win32")); + assert.ok(packageJson.files.includes("build/simdeck-bin-win32-x64.exe")); +}); + +test("npm CLI wrapper resolves Windows x64 native binary", () => { + assert.match(cliWrapper, /win32-x64/); + assert.match(cliWrapper, /simdeck-bin-win32-x64\.exe/); +}); + +test("CI runs Android emulator integration on Linux and Windows", () => { + assert.match(ciWorkflow, /integration-android:/); + assert.match(ciWorkflow, /os:\s*\[\s*ubuntu-latest,\s*windows-latest\s*\]/); + assert.match(ciWorkflow, /matrix\.os/); + assert.match(ciWorkflow, /SimDeck_Pixel_CI/); + assert.match(ciWorkflow, /test:integration:android/); +}); + +test("Windows Android CI boot path is bounded and diagnostic", () => { + const windowsBootStep = stepSlice( + ciWorkflow, + "Create, boot, and test Android emulator (Windows)", + "Stop Android emulator (Windows)", + ); + + assert.match(windowsBootStep, /\$accelSupported = \$LASTEXITCODE -eq 0/); + assert.match(windowsBootStep, /google_atd/); + assert.match(windowsBootStep, /"-qt-hide-window"/); + assert.match(windowsBootStep, /"-feature", "-Vulkan"/); + assert.match(windowsBootStep, /"-accel", "on"/); + assert.match(windowsBootStep, /"-accel", "off"/); + assert.match(windowsBootStep, /\$serial = "emulator-5554"/); + assert.match(windowsBootStep, /\$devices -match "\$serial\\s\+device"/); + assert.doesNotMatch(windowsBootStep, /device\|offline/); + assert.match(windowsBootStep, /-RedirectStandardOutput \$stdout/); + assert.match(windowsBootStep, /Write-EmulatorDiagnostics/); + assert.match( + windowsBootStep, + /deviceDeadline = \(Get-Date\)\.AddMinutes\(10\)/, + ); + assert.match( + windowsBootStep, + /\$env:SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID = "1"/, + ); + assert.match(windowsBootStep, /npm run test:integration:android/); + assert.doesNotMatch(windowsBootStep, /wait-for-device/); +}); + +test("Android integration runner resolves Windows executables", () => { + assert.match(androidIntegration, /fileURLToPath/); + assert.doesNotMatch( + androidIntegration, + /new URL\("\.\.\/\.\.", import\.meta\.url\)\.pathname/, + ); + assert.match(androidIntegration, /runningAndroidAvds\(avdName\)/); + assert.match( + androidIntegration, + /fallbackAvdName && avds\.size === 0 && devices\.length === 1/, + ); + assert.match(androidIntegration, /resolveAndroidLaunchTarget/); + assert.match(androidIntegration, /query-activities/); + assert.match(androidIntegration, /isAndroidIntentUnavailable/); + assert.match(androidIntegration, /simdeck-bin\.exe/); + assert.match(androidIntegration, /simdeck-bin-win32-x64\.exe/); + assert.match(androidIntegration, /AppData", "Local", "Android", "Sdk/); + assert.match(androidIntegration, /\.exe/); +}); + test("iOS PR comment waits for public simulator list access", () => { const prebootIndex = iosAction.indexOf( "- name: Select and preboot simulator", diff --git a/scripts/integration/android.mjs b/scripts/integration/android.mjs index 87e3db0d..6f006da9 100644 --- a/scripts/integration/android.mjs +++ b/scripts/integration/android.mjs @@ -4,10 +4,11 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { connect } from "simdeck/test"; -const root = path.resolve(new URL("../..", import.meta.url).pathname); -const simdeck = path.join(root, "build", "simdeck"); +const root = fileURLToPath(new URL("../..", import.meta.url)); +const simdeck = resolveSimDeckCli(); const verbose = process.env.SIMDECK_INTEGRATION_VERBOSE === "1"; const keepAndroid = process.env.SIMDECK_INTEGRATION_KEEP_ANDROID === "1"; const bootAndroid = process.env.SIMDECK_INTEGRATION_BOOT_ANDROID === "1"; @@ -15,12 +16,15 @@ const requireRunningAndroid = process.env.SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID === "1" || process.env.CI === "true"; const requestedAvd = process.env.SIMDECK_INTEGRATION_ANDROID_AVD; +const requestedAndroidLaunchTarget = + process.env.SIMDECK_INTEGRATION_ANDROID_LAUNCH_TARGET; const defaultStepTimeoutMs = Number( process.env.SIMDECK_INTEGRATION_STEP_TIMEOUT_MS ?? "180000", ); let session = null; let androidUDID = ""; +let androidLaunchTarget = null; let shutdownAndroidAfterRun = false; const stepTimings = []; @@ -102,6 +106,12 @@ async function main() { { timeoutMs: target.isBooted ? 120_000 : 300_000 }, ); + androidLaunchTarget = await measuredStep( + "resolve Android launch target", + () => resolveAndroidLaunchTarget(), + { timeoutMs: 60_000 }, + ); + await runCliSurface(); await runJsSurface(); console.log("SimDeck Android integration suite passed"); @@ -212,12 +222,35 @@ async function runCliSurface() { } }); await measuredStep("CLI app launch and URL", () => { - simdeckJson(["launch", androidUDID, "com.android.settings"], { - timeoutMs: 60_000, - }); - simdeckJson(["open-url", androidUDID, "https://example.com"], { - timeoutMs: 60_000, - }); + if (androidLaunchTarget) { + try { + simdeckJson(["launch", androidUDID, androidLaunchTarget], { + timeoutMs: 60_000, + }); + } catch (error) { + if ( + requestedAndroidLaunchTarget || + !isAndroidIntentUnavailable(error) + ) { + throw error; + } + console.log( + `Android image rejected discovered launch target ${androidLaunchTarget}; continuing with URL/home coverage.`, + ); + } + } else { + console.log("Android image did not expose a launchable activity."); + } + try { + simdeckJson(["open-url", androidUDID, "https://example.com"], { + timeoutMs: 60_000, + }); + } catch (error) { + if (!isAndroidIntentUnavailable(error)) { + throw error; + } + console.log("Android image did not expose an https URL handler."); + } simdeckJson(["home", androidUDID]); }); await measuredStep("CLI pointer gestures", () => { @@ -354,8 +387,29 @@ async function runJsSurface() { } }); await measuredStep("JS app launch and URL", async () => { - await session.launch(androidUDID, "com.android.settings"); - await session.openUrl(androidUDID, "https://example.com"); + if (androidLaunchTarget) { + try { + await session.launch(androidUDID, androidLaunchTarget); + } catch (error) { + if ( + requestedAndroidLaunchTarget || + !isAndroidIntentUnavailable(error) + ) { + throw error; + } + console.log( + `Android image rejected discovered launch target ${androidLaunchTarget}; continuing with URL/home coverage.`, + ); + } + } + try { + await session.openUrl(androidUDID, "https://example.com"); + } catch (error) { + if (!isAndroidIntentUnavailable(error)) { + throw error; + } + console.log("Android image did not expose an https URL handler."); + } await session.home(androidUDID); }); await measuredStep("JS pointer gestures", async () => { @@ -432,9 +486,9 @@ function resolveAndroidDevice() { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); - const running = runningAndroidAvds(); if (requestedAvd) { const avdName = requestedAvd.replace(/^android:/, ""); + const running = runningAndroidAvds(avdName); if (!avds.includes(avdName)) { throw new Error( `SIMDECK_INTEGRATION_ANDROID_AVD=${requestedAvd} was not found. Available Android AVDs: ${avds.join(", ")}`, @@ -446,6 +500,7 @@ function resolveAndroidDevice() { isBooted: running.has(avdName), }; } + const running = runningAndroidAvds(); const avdName = avds[0]; return avdName ? { @@ -456,19 +511,128 @@ function resolveAndroidDevice() { : null; } -function runningAndroidAvds() { +function resolveAndroidLaunchTarget() { + if (requestedAndroidLaunchTarget) { + return requestedAndroidLaunchTarget; + } + const adb = androidSdkTool("platform-tools/adb"); + if (!adb) { + return null; + } + const serial = onlineAndroidSerials(adb)[0]; + if (!serial) { + return null; + } + const queries = [ + [ + "cmd", + "package", + "query-activities", + "--brief", + "--components", + "-a", + "android.intent.action.MAIN", + "-c", + "android.intent.category.LAUNCHER", + ], + [ + "cmd", + "package", + "query-activities", + "--brief", + "--components", + "-a", + "android.intent.action.MAIN", + ], + [ + "cmd", + "package", + "resolve-activity", + "--brief", + "--components", + "-a", + "android.intent.action.MAIN", + "-c", + "android.intent.category.LAUNCHER", + ], + [ + "cmd", + "package", + "resolve-activity", + "--brief", + "--components", + "-a", + "android.intent.action.MAIN", + ], + ]; + for (const query of queries) { + try { + const output = runText(adb, ["-s", serial, "shell", ...query], { + timeoutMs: 30_000, + }); + const target = chooseAndroidLaunchComponent( + parseAndroidActivityComponents(output), + ); + if (target) { + if (verbose) { + console.log(`Android launch target ${target}`); + } + return target; + } + } catch (error) { + if (verbose) { + console.log( + `Android launch target query failed: ${error.message.split("\n")[0]}`, + ); + } + } + } + return null; +} + +function parseAndroidActivityComponents(output) { + const components = []; + for (const line of output.split(/\r?\n/)) { + for (const token of line.trim().split(/\s+/)) { + if ( + /^[A-Za-z0-9_.]+\/(?:[A-Za-z0-9_.$]+|\.[A-Za-z0-9_.$]+)$/.test(token) + ) { + components.push(token); + } + } + } + return [...new Set(components)]; +} + +function chooseAndroidLaunchComponent(components) { + const preferredPackages = [ + "com.android.settings/", + "com.google.android.apps.nexuslauncher/", + "com.android.launcher3/", + ]; + for (const packagePrefix of preferredPackages) { + const component = components.find((value) => + value.startsWith(packagePrefix), + ); + if (component) { + return component; + } + } + return ( + components.find( + (value) => + !value.startsWith("com.android.systemui/") && + !value.startsWith("android/"), + ) ?? null + ); +} + +function runningAndroidAvds(fallbackAvdName = "") { const adb = androidSdkTool("platform-tools/adb"); if (!adb) { return new Set(); } - const devices = runText(adb, ["devices"], { timeoutMs: 30_000 }) - .split(/\r?\n/) - .map((line) => line.trim().split(/\s+/)) - .filter( - ([serial, state]) => - serial?.startsWith("emulator-") && state === "device", - ) - .map(([serial]) => serial); + const devices = onlineAndroidSerials(adb); const avds = new Set(); for (const serial of devices) { const name = androidAvdNameForSerial(adb, serial); @@ -476,9 +640,23 @@ function runningAndroidAvds() { avds.add(name); } } + if (fallbackAvdName && avds.size === 0 && devices.length === 1) { + avds.add(fallbackAvdName); + } return avds; } +function onlineAndroidSerials(adb) { + return runText(adb, ["devices"], { timeoutMs: 30_000 }) + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/)) + .filter( + ([serial, state]) => + serial?.startsWith("emulator-") && state === "device", + ) + .map(([serial]) => serial); +} + function androidAvdNameForSerial(adb, serial) { for (const property of ["ro.boot.qemu.avd_name", "ro.kernel.qemu.avd_name"]) { try { @@ -509,11 +687,14 @@ function androidSdkTool(relativePath) { const roots = [ process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT, + process.platform === "win32" + ? path.join(os.homedir(), "AppData", "Local", "Android", "Sdk") + : null, path.join(os.homedir(), "Library", "Android", "sdk"), path.join(os.homedir(), "Android", "Sdk"), ].filter(Boolean); for (const root of roots) { - const candidate = path.join(root, relativePath); + const candidate = androidSdkToolCandidate(root, relativePath); if (fs.existsSync(candidate)) { return candidate; } @@ -521,6 +702,32 @@ function androidSdkTool(relativePath) { return null; } +function resolveSimDeckCli() { + const candidates = + process.platform === "win32" + ? ["simdeck-bin.exe", "simdeck-bin-win32-x64.exe", "simdeck"] + : ["simdeck", "simdeck-bin"]; + for (const candidate of candidates) { + const absolute = path.join(root, "build", candidate); + if (fs.existsSync(absolute)) { + return absolute; + } + } + return path.join( + root, + "build", + process.platform === "win32" ? "simdeck-bin.exe" : "simdeck", + ); +} + +function androidSdkToolCandidate(root, relativePath) { + const candidate = path.join(root, relativePath); + if (process.platform !== "win32" || path.extname(candidate)) { + return candidate; + } + return `${candidate}.exe`; +} + function simulatorList(payload) { return Array.isArray(payload?.simulators) ? payload.simulators : []; } @@ -679,6 +886,13 @@ function assertClipboardUnsupported(errorOrResult) { ); } +function isAndroidIntentUnavailable(error) { + const message = error instanceof Error ? error.message : String(error); + return /unable to resolve Intent|No Activity found|Activity class .* does not exist/i.test( + message, + ); +} + function assertAndroidListed(payload, udid) { assert.ok( simulatorList(payload).some( diff --git a/scripts/integration/cli.mjs b/scripts/integration/cli.mjs index a80c7ac5..42cdd603 100644 --- a/scripts/integration/cli.mjs +++ b/scripts/integration/cli.mjs @@ -180,42 +180,44 @@ async function main() { { phase: phaseSetup }, ); - const agentTree = await measuredStep("server describe agent", () => - simdeckText([ - "describe", - "--source", - "native-ax", - "--format", - "agent", - "--max-depth", - "1", - ]), - ); - if (!agentTree.includes("source:") || !agentTree.includes("- ")) { - throw new Error("agent describe output did not look like a hierarchy"); - } - const interactiveTree = await measuredStep( - "server describe agent interactive", - () => - simdeckText([ + await measuredStep("server describe agent", () => + retrySimdeckTextUntil( + [ "describe", "--source", "native-ax", "--format", "agent", "--max-depth", - "8", - "--interactive", - ]), + "1", + ], + "server describe agent", + looksLikeAgentHierarchy, + "agent describe output did not look like a hierarchy", + { attempts: 4, delayMs: 3_000, timeoutMs: 90_000 }, + ), + ); + const interactiveTree = await measuredStep( + "server describe agent interactive", + () => + retrySimdeckTextUntil( + [ + "describe", + "--source", + "native-ax", + "--format", + "agent", + "--max-depth", + "8", + "--interactive", + ], + "server describe agent interactive", + (output) => + looksLikeAgentHierarchy(output) && output.includes("Continue"), + "interactive agent describe did not include fixture controls", + { attempts: 4, delayMs: 3_000, timeoutMs: 90_000 }, + ), ); - if ( - !interactiveTree.includes("source:") || - !interactiveTree.includes("Continue") - ) { - throw new Error( - "interactive agent describe did not include fixture controls", - ); - } await runRestControls(); await runCliControls(); @@ -801,6 +803,47 @@ async function retrySimdeckText(args, label, options = {}) { ); } +async function retrySimdeckTextUntil( + args, + label, + predicate, + failureMessage, + options = {}, +) { + const attempts = options.attempts ?? 4; + const delayMs = options.delayMs ?? 2_000; + let lastDetail = ""; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const output = await retrySimdeckText(args, label, { + ...options, + attempts: 1, + }); + if (predicate(output)) { + return output; + } + lastDetail = summarizeText(output); + } catch (error) { + lastDetail = error?.message ?? String(error); + } + if (attempt < attempts) { + logStep(`${label} attempt ${attempt}/${attempts} did not pass; retrying`); + await sleep(delayMs); + } + } + throw new Error( + `${failureMessage} after ${attempts} attempts:\n${lastDetail}`, + ); +} + +function looksLikeAgentHierarchy(output) { + return output.includes("source:") && output.includes("- "); +} + +function summarizeText(output) { + return output.split("\n").slice(0, 12).join("\n").slice(0, 1000); +} + async function cliStep(label, args, commandOptions = {}, verifyOptions = {}) { return measuredStep( label, diff --git a/scripts/integration/webrtc.mjs b/scripts/integration/webrtc.mjs index 6dc3f1c8..f6f4dfb3 100644 --- a/scripts/integration/webrtc.mjs +++ b/scripts/integration/webrtc.mjs @@ -4,6 +4,10 @@ import fs from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; +import { + activationRecoveryReason, + shouldRecycleSimulatorForFixtureLaunch, +} from "./activation-recovery.mjs"; import { buildCachedFixtureApp } from "./fixture.mjs"; import { selectIntegrationSimulator } from "./simulator-selection.mjs"; @@ -91,21 +95,7 @@ async function main() { bundleId: fixtureBundleId, urlScheme: fixtureUrlScheme, }); - simdeckJson(["install", simulatorUDID, fixture.appPath], { - timeoutMs: 60_000, - }); - simdeckJson(["launch", simulatorUDID, fixtureBundleId], { - timeoutMs: 180_000, - }); - await retrySimdeckJson( - ["open-url", simulatorUDID, fixtureAnimateUrl], - "WebRTC start fixture animation", - { - attempts: 3, - delayMs: 5_000, - timeoutMs: 180_000, - }, - ); + await launchFixtureWithRecovery(fixture.appPath); const screenshotPath = path.join(tempRoot, "reference.png"); simdeckJson(["screenshot", simulatorUDID, "--output", screenshotPath], { @@ -196,6 +186,89 @@ async function waitForHealth() { throw new Error(`Timed out waiting for ${serverUrl}/api/health`); } +async function launchFixtureWithRecovery(appPath, options = {}) { + const recoveryCount = options.recoveryCount ?? 0; + const maxRecoveries = options.maxRecoveries ?? 1; + + simdeckJson(["install", simulatorUDID, appPath], { + timeoutMs: 60_000, + }); + + let launchError = null; + try { + simdeckJson(["launch", simulatorUDID, fixtureBundleId], { + timeoutMs: 180_000, + }); + } catch (error) { + launchError = error; + } + + let urlError = null; + if (launchError === null) { + try { + await retrySimdeckJson( + ["open-url", simulatorUDID, fixtureAnimateUrl], + "WebRTC start fixture animation", + { + attempts: 3, + delayMs: 5_000, + timeoutMs: 180_000, + }, + ); + return; + } catch (error) { + urlError = error; + } + } + + if ( + !shouldRecycleSimulatorForFixtureLaunch({ + launchError, + urlError, + recoveryCount, + maxRecoveries, + }) + ) { + throw urlError ?? launchError; + } + + console.warn( + `WebRTC fixture activation hit ${activationRecoveryReason({ + launchError, + urlError, + })}; recycling simulator and retrying once.`, + ); + await recycleSimulatorForFixtureLaunch(); + return launchFixtureWithRecovery(appPath, { + recoveryCount: recoveryCount + 1, + maxRecoveries, + }); +} + +async function recycleSimulatorForFixtureLaunch() { + try { + simdeckJson(["shutdown", simulatorUDID], { + timeoutMs: 180_000, + }); + } catch (error) { + console.warn( + `WebRTC fixture recovery shutdown failed; continuing with boot: ${error?.message ?? error}`, + ); + } + await retrySimdeckJson( + ["boot", simulatorUDID], + "WebRTC fixture recovery boot", + { + attempts: 3, + delayMs: 3_000, + timeoutMs: simdeckBootTimeoutMs, + }, + ); + runText("xcrun", ["simctl", "bootstatus", simulatorUDID, "-b"], { + timeoutMs: 600_000, + }); +} + function simdeckJson(args, options = {}) { return JSON.parse(runText(simdeck, args, options)); }