diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d3f2ee3..0fe588dd 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,181 @@ 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 and boot 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 + + - name: Android emulator integration tests (Windows) + if: runner.os == 'Windows' + run: npm run test:integration:android + env: + SIMDECK_INTEGRATION_ANDROID_AVD: SimDeck_Pixel_CI + SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID: "1" + SIMDECK_INTEGRATION_VERBOSE: "1" + + - 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..ff2e70aa 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()) @@ -774,6 +784,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 +793,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 +830,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}")) })?; @@ -1124,11 +1134,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 +1152,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 +1388,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 +1655,41 @@ 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 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 +1697,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 +1711,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 +2567,36 @@ 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") + ); + } } 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/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..bd8ba8ce 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,63 @@ 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 and boot Android emulator (Windows)", + "Android emulator integration tests (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.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, /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..bed31550 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"; @@ -509,11 +510,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 +525,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 : []; }