Skip to content

Commit 2b96df9

Browse files
authored
feat: add Linux and Windows Android emulator support
Adds cross-platform SimDeck CLI packaging and Android emulator integration coverage for Linux and Windows CI. Includes Windows-safe Android SDK/emulator handling plus CI hardening for hosted emulator and simulator flakes.
1 parent 28e5bf1 commit 2b96df9

17 files changed

Lines changed: 1291 additions & 160 deletions

File tree

.github/workflows/ci.yml

Lines changed: 188 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ jobs:
8080
- name: Test integration harness helpers
8181
run: npm run test:integration-harness
8282

83+
- name: Test workflow and CLI packaging guards
84+
run: npm run test:github-actions
85+
8386
- name: Typecheck client
8487
run: npm run --prefix packages/client typecheck
8588

@@ -289,12 +292,16 @@ jobs:
289292
SIMDECK_INTEGRATION_DEVICE_TYPE: iPhone SE (3rd generation)
290293

291294
integration-android:
292-
name: Android emulator integration
293-
runs-on: ubuntu-latest
294-
timeout-minutes: 35
295+
name: Android emulator integration (${{ matrix.os }})
296+
runs-on: ${{ matrix.os }}
297+
timeout-minutes: 65
295298
needs:
296299
- client
297300
- packages
301+
strategy:
302+
fail-fast: false
303+
matrix:
304+
os: [ubuntu-latest, windows-latest]
298305

299306
steps:
300307
- uses: actions/checkout@v4
@@ -308,6 +315,7 @@ jobs:
308315
- uses: dtolnay/rust-toolchain@stable
309316

310317
- name: Enable KVM access
318+
if: runner.os == 'Linux'
311319
run: |
312320
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
313321
sudo udevadm control --reload-rules
@@ -316,12 +324,13 @@ jobs:
316324
- name: Install root dependencies
317325
run: npm ci --ignore-scripts --force
318326

319-
- name: Build Linux Android integration artifacts
327+
- name: Build Android integration artifacts
320328
run: |
321329
npm run build:cli
322330
npm run build:simdeck-test
323331
324-
- name: Android emulator integration tests
332+
- name: Android emulator integration tests (Linux)
333+
if: runner.os == 'Linux'
325334
uses: reactivecircus/android-emulator-runner@v2
326335
with:
327336
api-level: 35
@@ -330,8 +339,182 @@ jobs:
330339
profile: pixel_6
331340
avd-name: SimDeck_Pixel_CI
332341
disable-animations: true
342+
emulator-options: -no-window -no-audio -no-boot-anim -no-snapshot -gpu swiftshader_indirect -grpc 8554
333343
script: npm run test:integration:android
334344
env:
335345
SIMDECK_INTEGRATION_ANDROID_AVD: SimDeck_Pixel_CI
336346
SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID: "1"
337347
SIMDECK_INTEGRATION_VERBOSE: "1"
348+
349+
- name: Create, boot, and test Android emulator (Windows)
350+
if: runner.os == 'Windows'
351+
shell: pwsh
352+
timeout-minutes: 45
353+
run: |
354+
$ErrorActionPreference = "Stop"
355+
$sdk = $env:ANDROID_HOME
356+
if (-not $sdk) {
357+
$sdk = $env:ANDROID_SDK_ROOT
358+
}
359+
if (-not $sdk) {
360+
$sdk = Join-Path $env:LOCALAPPDATA "Android\Sdk"
361+
}
362+
$env:ANDROID_HOME = $sdk
363+
$env:ANDROID_SDK_ROOT = $sdk
364+
$cmdlineTools = Get-ChildItem -Path (Join-Path $sdk "cmdline-tools") -Directory -ErrorAction SilentlyContinue |
365+
Sort-Object Name -Descending |
366+
Select-Object -First 1
367+
$toolsBin = if (Test-Path (Join-Path $sdk "cmdline-tools\latest\bin")) {
368+
Join-Path $sdk "cmdline-tools\latest\bin"
369+
} elseif ($cmdlineTools) {
370+
Join-Path $cmdlineTools.FullName "bin"
371+
} else {
372+
Join-Path $sdk "tools\bin"
373+
}
374+
$sdkmanager = Join-Path $toolsBin "sdkmanager.bat"
375+
$avdmanager = Join-Path $toolsBin "avdmanager.bat"
376+
$adb = Join-Path $sdk "platform-tools\adb.exe"
377+
$emulator = Join-Path $sdk "emulator\emulator.exe"
378+
$windowsApi = "35"
379+
$windowsPlatform = "platforms;android-$windowsApi"
380+
$windowsSystemImage = "system-images;android-$windowsApi;google_atd;x86_64"
381+
382+
$yesFile = Join-Path $env:RUNNER_TEMP "android-sdk-yes.txt"
383+
1..200 | ForEach-Object { "y" } | Set-Content -Path $yesFile -Encoding ascii
384+
$noFile = Join-Path $env:RUNNER_TEMP "android-avd-no.txt"
385+
"no" | Set-Content -Path $noFile -Encoding ascii
386+
387+
function Invoke-AndroidToolWithInput($tool, $arguments, $inputFile) {
388+
$line = "`"$tool`" $arguments < `"$inputFile`""
389+
Write-Host "cmd /c $line"
390+
cmd /c $line
391+
if ($LASTEXITCODE -ne 0) {
392+
throw "$tool $arguments failed with exit code $LASTEXITCODE."
393+
}
394+
}
395+
396+
Write-Host "Accepting Android SDK licenses"
397+
Invoke-AndroidToolWithInput $sdkmanager "--licenses" $yesFile
398+
Write-Host "Installing Android SDK emulator packages"
399+
Invoke-AndroidToolWithInput $sdkmanager "--install `"platform-tools`" `"emulator`" `"$windowsPlatform`" `"$windowsSystemImage`"" $yesFile
400+
Write-Host "Checking Android emulator acceleration"
401+
& $emulator -accel-check
402+
$accelSupported = $LASTEXITCODE -eq 0
403+
if (-not $accelSupported) {
404+
Write-Host "Hosted Windows runner did not report VM acceleration; forcing software acceleration for the CI smoke emulator."
405+
}
406+
Write-Host "Creating Android AVD"
407+
Invoke-AndroidToolWithInput $avdmanager "create avd --force --name SimDeck_Pixel_CI --package `"$windowsSystemImage`" --device `"pixel_6`"" $noFile
408+
409+
$stdout = Join-Path $env:RUNNER_TEMP "simdeck-android-emulator.out.log"
410+
$stderr = Join-Path $env:RUNNER_TEMP "simdeck-android-emulator.err.log"
411+
$serial = "emulator-5554"
412+
$args = @(
413+
"-avd", "SimDeck_Pixel_CI",
414+
"-qt-hide-window",
415+
"-no-audio",
416+
"-no-boot-anim",
417+
"-no-snapshot-load",
418+
"-no-snapshot-save",
419+
"-wipe-data",
420+
"-gpu", "swiftshader_indirect",
421+
"-feature", "-Vulkan",
422+
"-grpc", "8554",
423+
"-port", "5554",
424+
"-no-metrics",
425+
"-skip-adb-auth",
426+
"-camera-back", "none",
427+
"-camera-front", "none",
428+
"-cores", "2",
429+
"-memory", "2048",
430+
"-verbose"
431+
)
432+
if ($accelSupported) {
433+
$args += @("-accel", "on")
434+
} else {
435+
$args += @("-accel", "off")
436+
}
437+
function Write-EmulatorDiagnostics {
438+
Write-Host "adb devices:"
439+
& $adb devices -l
440+
if (Test-Path $stdout) {
441+
Write-Host "emulator stdout tail:"
442+
Get-Content $stdout -Tail 80
443+
}
444+
if (Test-Path $stderr) {
445+
Write-Host "emulator stderr tail:"
446+
Get-Content $stderr -Tail 120
447+
}
448+
}
449+
Write-Host "Starting Android emulator"
450+
$process = Start-Process -FilePath $emulator -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
451+
$process.Id | Out-File -FilePath emulator.pid -Encoding ascii
452+
$deviceDeadline = (Get-Date).AddMinutes(10)
453+
$deviceSeen = $false
454+
do {
455+
if ($process.HasExited) {
456+
Write-EmulatorDiagnostics
457+
throw "Android emulator exited early with code $($process.ExitCode)."
458+
}
459+
$devices = (& $adb devices)
460+
if ($devices -match "$serial\s+device") {
461+
$deviceSeen = $true
462+
break
463+
}
464+
Start-Sleep -Seconds 5
465+
} while ((Get-Date) -lt $deviceDeadline)
466+
if (-not $deviceSeen) {
467+
Write-EmulatorDiagnostics
468+
throw "Android emulator did not appear in adb before the timeout."
469+
}
470+
$deadline = (Get-Date).AddMinutes(20)
471+
do {
472+
if ($process.HasExited) {
473+
Write-EmulatorDiagnostics
474+
throw "Android emulator exited early with code $($process.ExitCode)."
475+
}
476+
$booted = (& $adb -s $serial shell getprop sys.boot_completed 2>$null | Out-String).Trim()
477+
if ($booted -eq "1") {
478+
break
479+
}
480+
Start-Sleep -Seconds 5
481+
} while ((Get-Date) -lt $deadline)
482+
if ($booted -ne "1") {
483+
Write-EmulatorDiagnostics
484+
throw "Android emulator did not boot before the timeout."
485+
}
486+
& $adb -s $serial shell settings put global window_animation_scale 0
487+
& $adb -s $serial shell settings put global transition_animation_scale 0
488+
& $adb -s $serial shell settings put global animator_duration_scale 0
489+
$env:SIMDECK_INTEGRATION_ANDROID_AVD = "SimDeck_Pixel_CI"
490+
$env:SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID = "1"
491+
$env:SIMDECK_INTEGRATION_VERBOSE = "1"
492+
npm run test:integration:android
493+
$testExitCode = $LASTEXITCODE
494+
if ($testExitCode -ne 0) {
495+
Write-EmulatorDiagnostics
496+
throw "Android integration tests failed with exit code $testExitCode."
497+
}
498+
499+
- name: Stop Android emulator (Windows)
500+
if: always() && runner.os == 'Windows'
501+
shell: pwsh
502+
run: |
503+
$sdk = $env:ANDROID_HOME
504+
if (-not $sdk) {
505+
$sdk = $env:ANDROID_SDK_ROOT
506+
}
507+
if ($sdk) {
508+
$adb = Join-Path $sdk "platform-tools\adb.exe"
509+
if (Test-Path $adb) {
510+
& $adb emu kill
511+
if ($LASTEXITCODE -ne 0) {
512+
Write-Host "No Android emulator accepted adb emu kill."
513+
$global:LASTEXITCODE = 0
514+
}
515+
}
516+
}
517+
if (Test-Path emulator.pid) {
518+
Stop-Process -Id (Get-Content emulator.pid) -Force -ErrorAction SilentlyContinue
519+
}
520+
exit 0

.github/workflows/release.yml

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ jobs:
152152
if: ${{ steps.meta.outputs.kind == 'npm-cli' }}
153153
uses: dtolnay/rust-toolchain@stable
154154
with:
155-
targets: aarch64-apple-darwin
155+
targets: aarch64-apple-darwin,x86_64-apple-darwin
156156

157157
- name: Install native build dependencies
158158
if: ${{ steps.meta.outputs.kind == 'npm-cli' }}
@@ -265,21 +265,34 @@ jobs:
265265
266266
# ---------- Build (kind-specific) ----------
267267

268-
- name: Build root simdeck (arm64 native binary + client + simdeck-test)
268+
- name: Build root simdeck (macOS arm64+x64 binaries + client + simdeck-test)
269269
if: ${{ steps.meta.outputs.kind == 'npm-cli' }}
270-
env:
271-
SIMDECK_BUILD_TARGET: aarch64-apple-darwin
272270
run: |
273-
npm run build:cli
271+
SIMDECK_BUILD_TARGET=aarch64-apple-darwin npm run build:cli
272+
cp build/simdeck-bin build/simdeck-bin-darwin-arm64
273+
274+
SIMDECK_BUILD_TARGET=x86_64-apple-darwin npm run build:cli
275+
cp build/simdeck-bin build/simdeck-bin-darwin-x64
276+
277+
lipo -create \
278+
-output build/simdeck-bin \
279+
build/simdeck-bin-darwin-arm64 \
280+
build/simdeck-bin-darwin-x64
281+
274282
npm run build:client
275283
npm run build:simdeck-test
276284
277-
- name: Verify CLI artifact is an arm64 Mach-O
285+
- name: Verify CLI artifacts are macOS arm64+x64 binaries
278286
if: ${{ steps.meta.outputs.kind == 'npm-cli' }}
279287
run: |
280288
test -x build/simdeck-bin
289+
test -x build/simdeck-bin-darwin-arm64
290+
test -x build/simdeck-bin-darwin-x64
281291
file build/simdeck-bin
282292
file build/simdeck-bin | grep -q 'arm64'
293+
file build/simdeck-bin | grep -q 'x86_64'
294+
file build/simdeck-bin-darwin-arm64 | grep -q 'arm64'
295+
file build/simdeck-bin-darwin-x64 | grep -q 'x86_64'
283296
284297
# ---------- Apple codesign + notarize (root simdeck only) ----------
285298

@@ -474,7 +487,6 @@ jobs:
474487
NODE_AUTH_TOKEN: ""
475488
DIST_TAG: ${{ steps.tag.outputs.dist_tag }}
476489
PKG_DIR: ${{ steps.meta.outputs.dir }}
477-
SIMDECK_BUILD_TARGET: aarch64-apple-darwin
478490
run: |
479491
set -euo pipefail
480492
unset NODE_AUTH_TOKEN
@@ -493,7 +505,6 @@ jobs:
493505
NODE_AUTH_TOKEN: ""
494506
DIST_TAG: ${{ steps.tag.outputs.dist_tag }}
495507
PKG_DIR: ${{ steps.meta.outputs.dir }}
496-
SIMDECK_BUILD_TARGET: aarch64-apple-darwin
497508
run: |
498509
set -euo pipefail
499510
unset NODE_AUTH_TOKEN

package.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"scripts/postinstall.mjs",
2121
"build/simdeck-bin",
2222
"build/camera/",
23+
"build/simdeck-bin-darwin-arm64",
24+
"build/simdeck-bin-darwin-x64",
25+
"build/simdeck-bin-linux-arm64",
26+
"build/simdeck-bin-linux-x64",
27+
"build/simdeck-bin-win32-x64.exe",
2328
"packages/client/dist/",
2429
"packages/simdeck-test/dist/"
2530
],
@@ -34,16 +39,19 @@
3439
"node": ">=18"
3540
},
3641
"os": [
37-
"darwin"
42+
"darwin",
43+
"linux",
44+
"win32"
3845
],
3946
"cpu": [
40-
"arm64"
47+
"arm64",
48+
"x64"
4149
],
4250
"publishConfig": {
4351
"access": "public"
4452
},
4553
"scripts": {
46-
"build:cli": "scripts/build-cli.sh",
54+
"build:cli": "node scripts/build-cli.mjs",
4755
"build:client": "scripts/build-client.sh",
4856
"build:app": "npm run build:cli && npm run build:client",
4957
"build:inspectors": "npm run build:nativescript-inspector && npm run build:react-native-inspector",

packages/cli/bin/simdeck.mjs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ const RECOVERABLE_RESTART_EXIT_CODE = 75;
99

1010
const launcherDir = path.dirname(fileURLToPath(import.meta.url));
1111
const packageRoot = findPackageRoot(launcherDir);
12-
const binaryPath = path.join(packageRoot, "build", "simdeck-bin");
12+
const binaryPath = resolveBinaryPath(packageRoot);
1313
const childArgs = process.argv.slice(2);
1414
const isServiceRun = childArgs[0] === "service" && childArgs[1] === "run";
1515

16-
if (process.platform !== "darwin") {
17-
console.error("simdeck only supports macOS.");
16+
if (!binaryPath) {
17+
console.error(
18+
"simdeck only supports macOS, Linux, and Windows on arm64/x64.",
19+
);
1820
process.exit(1);
1921
}
2022

@@ -39,6 +41,36 @@ function findPackageRoot(startDir) {
3941
}
4042
}
4143

44+
function resolveBinaryPath(rootDir) {
45+
const platform = process.platform;
46+
const arch = process.arch;
47+
const binaryByHost = {
48+
"darwin-arm64": "simdeck-bin-darwin-arm64",
49+
"darwin-x64": "simdeck-bin-darwin-x64",
50+
"linux-arm64": "simdeck-bin-linux-arm64",
51+
"linux-x64": "simdeck-bin-linux-x64",
52+
"win32-x64": "simdeck-bin-win32-x64.exe",
53+
};
54+
55+
const binary = binaryByHost[`${platform}-${arch}`];
56+
if (!binary) {
57+
return null;
58+
}
59+
60+
const platformBinaryPath = path.join(rootDir, "build", binary);
61+
if (existsSync(platformBinaryPath)) {
62+
return platformBinaryPath;
63+
}
64+
65+
for (const fallback of ["simdeck-bin.exe", "simdeck-bin"]) {
66+
const fallbackBinaryPath = path.join(rootDir, "build", fallback);
67+
if (existsSync(fallbackBinaryPath)) {
68+
return fallbackBinaryPath;
69+
}
70+
}
71+
return platformBinaryPath;
72+
}
73+
4274
let child;
4375
let terminating = false;
4476

0 commit comments

Comments
 (0)