From 75835943ba19996bc3b6e3526b5854deb9767546 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Sun, 31 May 2026 23:29:06 -0400 Subject: [PATCH] Add Android emulator startup args support --- README.md | 7 +- .../run-android-comment-session/action.yml | 16 +- docs/api/rest.md | 23 +- docs/cli/commands.md | 5 + docs/cli/flags.md | 33 ++- docs/guide/github-actions.md | 31 +-- docs/guide/service.md | 14 +- packages/client/src/api/controls.test.ts | 35 +++ packages/client/src/api/controls.ts | 7 +- packages/client/src/api/types.ts | 5 + packages/server/src/android.rs | 219 ++++++++++++++++-- packages/server/src/api/routes.rs | 146 ++++++++++-- packages/server/src/config.rs | 100 +++++++- packages/server/src/main.rs | 117 ++++++++-- packages/simdeck-test/dist/index.d.ts | 6 +- packages/simdeck-test/dist/index.js | 17 +- packages/simdeck-test/src/index.ts | 30 ++- scripts/github-actions.test.mjs | 15 ++ skills/simdeck/SKILL.md | 4 + 19 files changed, 737 insertions(+), 93 deletions(-) create mode 100644 packages/client/src/api/controls.test.ts diff --git a/README.md b/README.md index 4e8ab93e..79f36f1f 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,14 @@ routes with the same service token. Normal service restarts preserve that token so paired clients stay connected. Use `simdeck service reset` only when you want to rotate the service token and restart the LaunchAgent. -The service uses port 4310 unless you pass `-p` or `--port`. +The service uses port 4310 unless you pass `-p` or `--port`, or set a default +in `~/.simdeck/config.json`. SimDeck-owned Android emulator boots use host GPU rendering by default; use `simdeck service restart --android-gpu auto` or `--android-gpu swiftshader_indirect` only as a machine-specific fallback. +Managed Android boots also add `-no-audio` by default. Set +`android.disableAudio` to `false` in `~/.simdeck/config.json` when you need +emulator audio. Use `simdeck service kill` when you want to stop every SimDeck service process, including services started from another checkout or installed binary. @@ -114,6 +118,7 @@ simdeck --device describe --format agent --max-depth 2 simdeck list simdeck use simdeck boot +simdeck boot android: --android-emulator-arg=-no-snapshot simdeck shutdown simdeck erase simdeck install /path/to/App.app diff --git a/actions/run-android-comment-session/action.yml b/actions/run-android-comment-session/action.yml index e398374c..ae9a8f46 100644 --- a/actions/run-android-comment-session/action.yml +++ b/actions/run-android-comment-session/action.yml @@ -82,6 +82,10 @@ inputs: description: Android SDK build-tools version used to inspect APK metadata. required: false default: "36.0.0" + android_emulator_args: + description: Extra Android emulator startup arguments, one argument per line. + required: false + default: "" public_health_check: description: Verify the public Cloudflare Tunnel health endpoint before continuing. required: false @@ -123,6 +127,7 @@ runs: INPUT_ANDROID_TARGET_VALUE: ${{ inputs.android_target }} INPUT_ANDROID_ARCH_VALUE: ${{ inputs.android_arch }} INPUT_ANDROID_BUILD_TOOLS_VALUE: ${{ inputs.android_build_tools }} + INPUT_ANDROID_EMULATOR_ARGS_VALUE: ${{ inputs.android_emulator_args }} INPUT_PUBLIC_HEALTH_CHECK_VALUE: ${{ inputs.public_health_check }} INPUT_CI_PROXY_URL_VALUE: ${{ inputs.ci_proxy_url }} INPUT_PROXY_LINKS_VALUE: ${{ inputs.proxy_links }} @@ -163,6 +168,7 @@ runs: write_env "SIMDECK_ANDROID_TARGET" "${INPUT_ANDROID_TARGET_VALUE}" write_env "SIMDECK_ANDROID_ARCH" "${INPUT_ANDROID_ARCH_VALUE}" write_env "SIMDECK_ANDROID_BUILD_TOOLS" "${INPUT_ANDROID_BUILD_TOOLS_VALUE}" + write_env "SIMDECK_ANDROID_EMULATOR_ARGS" "${INPUT_ANDROID_EMULATOR_ARGS_VALUE}" write_env "SIMDECK_CI_PROXY_URL" "${INPUT_CI_PROXY_URL_VALUE}" write_env "SIMDECK_PROXY_LINKS" "${INPUT_PROXY_LINKS_VALUE}" write_env "SIMDECK_SESSION_PASSWORD" "${INPUT_SESSION_PASSWORD_VALUE}" @@ -656,7 +662,15 @@ runs: echo "ANDROID_UDID=${udid}" >> "${GITHUB_ENV}" echo "udid=${udid}" >> "${GITHUB_OUTPUT}" echo "Booting ${udid}" - simdeck --server-url "http://127.0.0.1:${SIMDECK_PORT}" boot "${udid}" + boot_args=() + if [[ -n "${SIMDECK_ANDROID_EMULATOR_ARGS:-}" ]]; then + while IFS= read -r arg; do + if [[ -n "${arg//[[:space:]]/}" ]]; then + boot_args+=(--android-emulator-arg="${arg}") + fi + done <<< "${SIMDECK_ANDROID_EMULATOR_ARGS}" + fi + simdeck --server-url "http://127.0.0.1:${SIMDECK_PORT}" boot "${udid}" "${boot_args[@]}" date +%s > /tmp/sim-boot-end - name: Update status comment with booted emulator URL diff --git a/docs/api/rest.md b/docs/api/rest.md index 4fdf9f5d..0dbc0b79 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -70,6 +70,25 @@ Device IDs come from `/api/simulators`. Android IDs use the `android:` prefix. Booted devices are listed first. Paired iPhone and Apple Watch entries include `pairedWatchUDID` or `pairedPhoneUDID` when CoreSimulator reports a pairing. +Android emulator boot accepts optional startup arguments: + +```json +{ + "androidEmulatorArgs": ["-no-snapshot"], + "androidDisableAudio": true +} +``` + +`androidDisableAudio` inherits the configured `android.disableAudio` value, +which defaults to `true`. SimDeck also reads global Android defaults from +`~/.simdeck/config.json` before applying request arguments. Request arguments +are appended after config arguments, so one-off boots can override safe defaults +such as `-gpu`. + +SimDeck appends its own selected AVD, emulator console/ADB ports, and +shared-video flags around those arguments; `-avd`, `@AVD`, `-ports`, and +`-share-vid` are reserved. + Create requests use identifiers from `/api/simulators/create-options`. New devices are booted before the response is returned. If an iOS simulator is created with `pairedWatch`, the watch is created, paired, and booted too. @@ -97,7 +116,9 @@ Android: "platform": "android", "name": "Pixel_8_API_36", "deviceTypeIdentifier": "pixel_8", - "runtimeIdentifier": "system-images;android-36;google_apis;arm64-v8a" + "runtimeIdentifier": "system-images;android-36;google_apis;arm64-v8a", + "androidEmulatorArgs": ["-no-snapshot"], + "androidDisableAudio": true } ``` diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 69a604d1..da10c9af 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -56,6 +56,7 @@ simdeck list simdeck list --format json simdeck use simdeck boot +simdeck boot android: --android-emulator-arg=-no-snapshot simdeck shutdown simdeck erase ``` @@ -68,6 +69,10 @@ inventory, including paths and display metadata. directory. After that, most device commands can omit ``; explicit UDIDs still override the default. +For Android emulator startup flags, repeat `--android-emulator-arg=` on +`simdeck boot`. SimDeck still owns the AVD selector, emulator ports, and +shared-video flag used for the browser stream. + ## Apps and URLs ```sh diff --git a/docs/cli/flags.md b/docs/cli/flags.md index 33e8aa09..88e92508 100644 --- a/docs/cli/flags.md +++ b/docs/cli/flags.md @@ -24,7 +24,7 @@ project default from `simdeck use `, then auto-inference from the service. Used by `simdeck`, `service start`, `service restart`, `service on`, and `service reset`. When `service restart` is run without `--port`, it preserves the installed LaunchAgent port or the current singleton service port before falling back to -`4310`. +the configured `~/.simdeck/config.json` service port and then `4310`. | Flag | Default | Notes | | ---------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------- | @@ -40,6 +40,27 @@ LaunchAgent port or the current singleton service port before falling back to | `--open` | off | Open the browser after starting the service | | `--autostart` / `-a` | off | Register the service as a macOS LaunchAgent | +## Global config + +SimDeck reads optional user defaults from `~/.simdeck/config.json`: + +```json +{ + "service": { + "port": 4311 + }, + "android": { + "emulatorArgs": ["-no-snapshot"], + "disableAudio": true + } +} +``` + +`service.port` applies when a service command omits `--port`. Command-line +flags still win. `android.emulatorArgs` are prepended to Android boot request +arguments, and `android.disableAudio` controls whether SimDeck adds +`-no-audio` to managed Android emulator boots. + ## `describe` Alias: `snapshot`. @@ -54,6 +75,16 @@ Alias: `snapshot`. | `--point ,` | Describe the element at a screen point | | `--direct` | Skip service and use native accessibility directly | +## Device lifecycle + +| Command | Useful flags | +| ------- | ----------------------------------------------------------------------------------------------------------------------- | +| `boot` | `--android-emulator-arg=` for Android emulator startup flags; repeat once per argument, for example `-no-snapshot` | + +SimDeck reserves Android emulator target and stream flags such as `-avd`, +`-ports`, and `-share-vid` so browser streaming remains attached to the +selected emulator. + ## Input | Command | Useful flags | diff --git a/docs/guide/github-actions.md b/docs/guide/github-actions.md index 46d2e372..516b02c3 100644 --- a/docs/guide/github-actions.md +++ b/docs/guide/github-actions.md @@ -188,21 +188,22 @@ Supported quality values include `tiny`, `low`, `economy`, `fast`, `smooth`, `ba ## Common inputs -| Input | Default | Purpose | -| ------------------- | ----------------------------------- | ------------------------------------------- | -| `bundle_id` | empty | Bundle ID to launch | -| `package_name` | empty | Android package name to launch | -| `build_workflow` | `build-ios-simulator.yml` | Workflow file that uploads the app artifact | -| `artifact_prefix` | `ios-simulator-app` / `android-apk` | Artifact prefix | -| `simdeck_version` | `latest` | npm version or dist-tag | -| `stream_profile` | `tiny` | Default stream quality | -| `simulator_name` | `iPhone 17 Pro` | Preferred simulator | -| `avd_name` | `SimDeck_Pixel_CI` | Preferred Android emulator | -| `keepalive_seconds` | `1800` | Session lifetime after launch | -| `simulator_cache` | `true` | Restore and save simulator cache | -| `proxy_links` | `true` | Post SimDeck CI proxy links | -| `ci_proxy_url` | `https://ci.simdeck.sh` | Optional SimDeck CI proxy URL | -| `session_password` | empty | Optional password for proxy-gated sessions | +| Input | Default | Purpose | +| ----------------------- | ----------------------------------- | ---------------------------------------------- | +| `bundle_id` | empty | Bundle ID to launch | +| `package_name` | empty | Android package name to launch | +| `build_workflow` | `build-ios-simulator.yml` | Workflow file that uploads the app artifact | +| `artifact_prefix` | `ios-simulator-app` / `android-apk` | Artifact prefix | +| `simdeck_version` | `latest` | npm version or dist-tag | +| `stream_profile` | `tiny` | Default stream quality | +| `simulator_name` | `iPhone 17 Pro` | Preferred simulator | +| `avd_name` | `SimDeck_Pixel_CI` | Preferred Android emulator | +| `android_emulator_args` | empty | Extra emulator startup arguments, one per line | +| `keepalive_seconds` | `1800` | Session lifetime after launch | +| `simulator_cache` | `true` | Restore and save simulator cache | +| `proxy_links` | `true` | Post SimDeck CI proxy links | +| `ci_proxy_url` | `https://ci.simdeck.sh` | Optional SimDeck CI proxy URL | +| `session_password` | empty | Optional password for proxy-gated sessions | ## Password-protected links diff --git a/docs/guide/service.md b/docs/guide/service.md index 4291b90c..5fd5a040 100644 --- a/docs/guide/service.md +++ b/docs/guide/service.md @@ -18,6 +18,18 @@ simdeck -p 4311 `--open` opens the local browser URL. `-p` or `--port` selects a non-default port; the default is `4310`. +You can set a user default in `~/.simdeck/config.json`: + +```json +{ + "service": { + "port": 4311 + } +} +``` + +Explicit `--port` flags still win over the config file. + When that port is already used by a SimDeck service from another binary, `simdeck` leaves it running and uses the next available port. This keeps source checkout builds fast without touching your installed service. @@ -56,7 +68,7 @@ pairing code. `service off` removes the LaunchAgent. `service kill` and services started by another SimDeck binary. When `service restart` is run without `--port`, it keeps the installed LaunchAgent port or the current singleton service port before falling back to -`4310`. +the configured service port and then `4310`. ## Options diff --git a/packages/client/src/api/controls.test.ts b/packages/client/src/api/controls.test.ts new file mode 100644 index 00000000..e47d265d --- /dev/null +++ b/packages/client/src/api/controls.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { bootSimulator } from "./controls"; + +describe("controls", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("posts Android emulator startup args when booting", async () => { + const fetchMock = vi.fn(async () => { + return new Response(JSON.stringify({ ok: true, simulator: null }), { + headers: { "content-type": "application/json" }, + status: 200, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + await bootSimulator("android:Pixel_8_API_36", { + androidEmulatorArgs: ["-no-snapshot"], + androidDisableAudio: false, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/simulators/android:Pixel_8_API_36/boot", + expect.objectContaining({ + body: JSON.stringify({ + androidEmulatorArgs: ["-no-snapshot"], + androidDisableAudio: false, + }), + method: "POST", + }), + ); + }); +}); diff --git a/packages/client/src/api/controls.ts b/packages/client/src/api/controls.ts index acd40b35..c46efdff 100644 --- a/packages/client/src/api/controls.ts +++ b/packages/client/src/api/controls.ts @@ -2,6 +2,7 @@ import { accessTokenFromLocation, apiHeaders, apiRequest } from "./client"; import { apiUrl } from "./config"; import type { ButtonPayload, + BootPayload, CrownPayload, EdgeTouchPayload, InstallUploadResponse, @@ -39,7 +40,7 @@ export interface ScreenRecordingStartResponse { async function postSimulatorAction( udid: string, action: string, - payload?: LaunchPayload | OpenUrlPayload, + payload?: BootPayload | LaunchPayload | OpenUrlPayload, ): Promise { if (action === "launch" || action === "open-url") { const response = await apiRequest<{ @@ -65,8 +66,8 @@ async function postSimulatorAction( return "simulator" in response ? response.simulator : null; } -export function bootSimulator(udid: string) { - return postSimulatorAction(udid, "boot"); +export function bootSimulator(udid: string, payload?: BootPayload) { + return postSimulatorAction(udid, "boot", payload); } export function shutdownSimulator(udid: string) { diff --git a/packages/client/src/api/types.ts b/packages/client/src/api/types.ts index 31dbde3d..0b03eaf4 100644 --- a/packages/client/src/api/types.ts +++ b/packages/client/src/api/types.ts @@ -211,6 +211,11 @@ export interface SimulatorResponse { simulator: SimulatorMetadata; } +export interface BootPayload { + androidEmulatorArgs?: string[]; + androidDisableAudio?: boolean; +} + export interface InstallUploadResponse { action: "install"; fileName: string; diff --git a/packages/server/src/android.rs b/packages/server/src/android.rs index f9af1cd7..fbe43fb1 100644 --- a/packages/server/src/android.rs +++ b/packages/server/src/android.rs @@ -1,6 +1,6 @@ use crate::error::AppError; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::env; #[cfg(unix)] use std::ffi::CString; @@ -103,6 +103,21 @@ pub struct AndroidH264StreamQuality { pub bits_per_pixel: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AndroidBootOptions { + pub emulator_args: Vec, + pub disable_audio: bool, +} + +impl Default for AndroidBootOptions { + fn default() -> Self { + Self { + emulator_args: Vec::new(), + disable_audio: true, + } + } +} + #[derive(Debug)] pub struct AndroidSharedVideoFrame { pub timestamp_us: u64, @@ -247,35 +262,18 @@ impl AndroidBridge { })) } - pub fn boot(&self, id: &str) -> Result { + pub fn boot_with_options( + &self, + id: &str, + options: &AndroidBootOptions, + ) -> Result { let avd_name = avd_from_id(id)?; if self.resolve_serial(&avd_name).is_ok() { return Ok(false); } let console_port = self.console_port_for_avd(&avd_name)?; - let adb_port = console_port.checked_add(1).ok_or_else(|| { - AppError::native("Android emulator console port overflowed while booting.") - })?; - let emulator_ports = format!("{console_port},{adb_port}"); - let gpu_mode = android_emulator_gpu_mode()?; - 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", - gpu_mode.as_str(), - ]; - if is_windows { - args.extend(["-feature", "-Vulkan"]); - } - args.extend(["-ports", &emulator_ports, "-share-vid"]); + let args = + android_emulator_launch_args(&avd_name, console_port, options, std::env::consts::OS)?; Command::new(self.emulator_path()) .args(args) .stdin(Stdio::null()) @@ -1053,6 +1051,84 @@ impl AndroidBridge { } } +fn android_emulator_launch_args( + avd_name: &str, + console_port: u16, + options: &AndroidBootOptions, + os: &str, +) -> Result, AppError> { + let adb_port = console_port.checked_add(1).ok_or_else(|| { + AppError::native("Android emulator console port overflowed while booting.") + })?; + let emulator_ports = format!("{console_port},{adb_port}"); + let gpu_mode = android_emulator_gpu_mode()?; + let user_args = sanitized_android_emulator_args(&options.emulator_args)?; + let user_option_keys = user_args + .iter() + .filter_map(|arg| android_emulator_option_key(arg)) + .collect::>(); + + let mut args = vec!["-avd".to_owned(), avd_name.to_owned()]; + let window_mode = if os == "windows" { + "-qt-hide-window" + } else { + "-no-window" + }; + let window_is_configured = + user_option_keys.contains("-no-window") || user_option_keys.contains("-qt-hide-window"); + if !window_is_configured { + args.push(window_mode.to_owned()); + } + if options.disable_audio && !user_option_keys.contains("-no-audio") { + args.push("-no-audio".to_owned()); + } + if !user_option_keys.contains("-gpu") { + args.extend(["-gpu".to_owned(), gpu_mode]); + } + if os == "windows" && !user_option_keys.contains("-feature") { + args.extend(["-feature".to_owned(), "-Vulkan".to_owned()]); + } + args.extend(user_args); + args.extend(["-ports".to_owned(), emulator_ports, "-share-vid".to_owned()]); + Ok(args) +} + +fn sanitized_android_emulator_args(args: &[String]) -> Result, AppError> { + let mut output = Vec::new(); + for arg in args { + let trimmed = arg.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request( + "Android emulator startup args must not be empty.", + )); + } + if android_emulator_arg_is_simdeck_owned(trimmed) { + return Err(AppError::bad_request(format!( + "Android emulator startup arg `{trimmed}` is managed by SimDeck." + ))); + } + output.push(trimmed.to_owned()); + } + Ok(output) +} + +fn android_emulator_arg_is_simdeck_owned(arg: &str) -> bool { + if arg.starts_with('@') { + return true; + } + matches!( + android_emulator_option_key(arg), + Some("-avd" | "-ports" | "-share-vid") + ) +} + +fn android_emulator_option_key(arg: &str) -> Option<&str> { + if !arg.starts_with('-') { + return None; + } + Some(arg.split_once('=').map(|(key, _)| key).unwrap_or(arg)) +} + impl AndroidSharedVideoFrameStream { fn open(handle: String) -> Result { let (fd, ptr, length) = open_android_shared_video_memory(&handle)?; @@ -2403,6 +2479,99 @@ fn ensure_android_clipboard_available(output: &str) -> Result<(), AppError> { mod tests { use super::*; + #[test] + fn android_emulator_launch_args_include_user_startup_flags() { + let args = android_emulator_launch_args( + "Pixel_8_API_36", + 8554, + &AndroidBootOptions { + emulator_args: vec!["-no-snapshot".to_owned()], + ..Default::default() + }, + "linux", + ) + .unwrap(); + + assert_eq!( + args, + vec![ + "-avd", + "Pixel_8_API_36", + "-no-window", + "-no-audio", + "-gpu", + "host", + "-no-snapshot", + "-ports", + "8554,8555", + "-share-vid", + ] + ); + } + + #[test] + fn android_emulator_launch_args_let_user_replace_default_gpu() { + let args = android_emulator_launch_args( + "Pixel_8_API_36", + 8554, + &AndroidBootOptions { + emulator_args: vec!["-gpu".to_owned(), "swiftshader_indirect".to_owned()], + ..Default::default() + }, + "linux", + ) + .unwrap(); + + assert_eq!(args.iter().filter(|arg| *arg == "-gpu").count(), 1); + assert!(args.contains(&"swiftshader_indirect".to_owned())); + assert!(!args + .windows(2) + .any(|pair| pair[0] == "-gpu" && pair[1] == "host")); + } + + #[test] + fn android_emulator_launch_args_can_leave_audio_enabled() { + let args = android_emulator_launch_args( + "Pixel_8_API_36", + 8554, + &AndroidBootOptions { + disable_audio: false, + ..Default::default() + }, + "linux", + ) + .unwrap(); + + assert!(!args.contains(&"-no-audio".to_owned())); + } + + #[test] + fn android_emulator_launch_args_keep_simdeck_owned_flags_protected() { + let ports_error = android_emulator_launch_args( + "Pixel_8_API_36", + 8554, + &AndroidBootOptions { + emulator_args: vec!["-ports".to_owned(), "9000,9001".to_owned()], + ..Default::default() + }, + "linux", + ) + .unwrap_err(); + assert!(ports_error.to_string().contains("managed by SimDeck")); + + let avd_error = android_emulator_launch_args( + "Pixel_8_API_36", + 8554, + &AndroidBootOptions { + emulator_args: vec!["@Other_AVD".to_owned()], + ..Default::default() + }, + "linux", + ) + .unwrap_err(); + assert!(avd_error.to_string().contains("managed by SimDeck")); + } + #[test] fn android_nodes_keep_class_type_and_semantic_role() { let document = roxmltree::Document::parse( diff --git a/packages/server/src/api/routes.rs b/packages/server/src/api/routes.rs index 156ab73e..4e4ce00b 100644 --- a/packages/server/src/api/routes.rs +++ b/packages/server/src/api/routes.rs @@ -1,9 +1,10 @@ use crate::accessibility::{interactive_accessibility_snapshot, AccessibilitySource}; -use crate::android::{self, AndroidBridge, AndroidEmulatorSpec}; +use crate::android::{self, AndroidBootOptions, AndroidBridge, AndroidEmulatorSpec}; use crate::api::json::json; use crate::auth; use crate::camera::{self, CameraStartRequest, CameraSwitchRequest}; use crate::config::Config; +use crate::config::UserConfig; use crate::devtools; use crate::error::AppError; use crate::inspector::{InspectorHub, PublishedInspector}; @@ -502,6 +503,15 @@ struct CreateSimulatorPayload { device_type_identifier: String, runtime_identifier: Option, paired_watch: Option, + android_emulator_args: Option>, + android_disable_audio: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BootSimulatorPayload { + android_emulator_args: Option>, + android_disable_audio: Option, } #[derive(Clone, Debug, Deserialize)] @@ -1803,7 +1813,9 @@ async fn create_simulator( .and_then(Value::as_str) .ok_or_else(|| AppError::internal("Android create did not return an emulator ID."))? .to_owned(); - boot_android_device(state.clone(), udid.clone()).await?; + let options = + android_boot_options(payload.android_emulator_args, payload.android_disable_audio)?; + boot_android_device(state.clone(), udid.clone(), options).await?; let devices = all_device_values(state, true).await?; let simulator = devices .iter() @@ -1819,6 +1831,16 @@ async fn create_simulator( "pairedWatchSimulator": null, }))); } + if payload + .android_emulator_args + .as_ref() + .is_some_and(|args| !args.is_empty()) + || payload.android_disable_audio.is_some() + { + return Err(AppError::bad_request( + "Android emulator boot options only apply to Android emulator creation.", + )); + } let paired_watch = payload .paired_watch @@ -2162,9 +2184,13 @@ fn performance_log_entry_matches( .any(|needle| haystack.contains(needle)) } -async fn boot_android_device(state: AppState, udid: String) -> Result<(), AppError> { +async fn boot_android_device( + state: AppState, + udid: String, + options: AndroidBootOptions, +) -> Result<(), AppError> { run_android_action(state, move |android| { - android.boot(&udid)?; + android.boot_with_options(&udid, &options)?; android.wait_until_booted(&udid, Duration::from_secs(240))?; Ok(()) }) @@ -2186,15 +2212,69 @@ async fn boot_ios_device(state: AppState, udid: String) -> Result<(), AppError> async fn boot_simulator( State(state): State, Path(udid): Path, + body: Bytes, ) -> Result, AppError> { + let payload = boot_simulator_payload_from_body(&body)?; if android::is_android_id(&udid) { - boot_android_device(state.clone(), udid.clone()).await?; + let options = + android_boot_options(payload.android_emulator_args, payload.android_disable_audio)?; + boot_android_device(state.clone(), udid.clone(), options).await?; return android_simulator_payload(state, udid).await; } + if payload + .android_emulator_args + .as_ref() + .is_some_and(|args| !args.is_empty()) + || payload.android_disable_audio.is_some() + { + return Err(AppError::bad_request( + "Android emulator boot options only apply to Android emulator IDs.", + )); + } boot_ios_device(state.clone(), udid.clone()).await?; simulator_payload(state, udid).await } +fn boot_simulator_payload_from_body(body: &Bytes) -> Result { + if body.is_empty() || body.iter().all(u8::is_ascii_whitespace) { + return Ok(BootSimulatorPayload::default()); + } + if body + .strip_prefix(b"null") + .is_some_and(|rest| rest.iter().all(u8::is_ascii_whitespace)) + { + return Ok(BootSimulatorPayload::default()); + } + serde_json::from_slice::(body) + .map_err(|error| AppError::bad_request(format!("Invalid boot request body: {error}"))) +} + +fn android_boot_options( + android_emulator_args: Option>, + android_disable_audio: Option, +) -> Result { + let config = UserConfig::load() + .map_err(|error| AppError::bad_request(format!("Invalid SimDeck config: {error}")))?; + Ok(android_boot_options_from_config( + config, + android_emulator_args, + android_disable_audio, + )) +} + +fn android_boot_options_from_config( + config: UserConfig, + android_emulator_args: Option>, + android_disable_audio: Option, +) -> AndroidBootOptions { + let mut emulator_args = config.android.emulator_args; + emulator_args.extend(android_emulator_args.unwrap_or_default()); + AndroidBootOptions { + emulator_args, + disable_audio: android_disable_audio.unwrap_or(config.android.disable_audio), + } +} + async fn shutdown_simulator( State(state): State, Path(udid): Path, @@ -5654,15 +5734,16 @@ async fn accessibility_snapshot_with_options( #[cfg(test)] mod tests { use super::{ - accessibility_point_snapshot, attach_tree_metadata, available_sources_for_snapshot, - available_sources_with_native_ax, best_inspector_session, - chrome_devtools_source_for_session, client_stats_foreground, - compact_accessibility_snapshot, element_matches_selector, first_matching_element, - inspector_available_sources, inspector_metadata, inspector_session_from_published, - inspector_session_score, is_inspector_agent_transport_path, - is_transient_native_ax_snapshot_error, logical_screen_size_from_display_pixels, - normalize_inspector_node, normalize_screen_point_from_snapshot, - normalized_gesture_coordinates, parse_lsof_tcp_listener, parse_ui_application_service_line, + accessibility_point_snapshot, android_boot_options_from_config, attach_tree_metadata, + available_sources_for_snapshot, available_sources_with_native_ax, best_inspector_session, + boot_simulator_payload_from_body, chrome_devtools_source_for_session, + client_stats_foreground, compact_accessibility_snapshot, element_matches_selector, + first_matching_element, inspector_available_sources, inspector_metadata, + inspector_session_from_published, inspector_session_score, + is_inspector_agent_transport_path, is_transient_native_ax_snapshot_error, + logical_screen_size_from_display_pixels, normalize_inspector_node, + normalize_screen_point_from_snapshot, normalized_gesture_coordinates, + parse_lsof_tcp_listener, parse_ui_application_service_line, process_identifier_from_accessibility_snapshot, resolved_stream_quality_limits, scroll_input_plan_for_udid, split_filter_values, stream_quality_profile, suppress_native_ax_translation_error, tap_point_from_snapshot, trim_tree_depth, @@ -5673,8 +5754,10 @@ mod tests { UIKitApplicationServiceDetails, SOURCE_FLUTTER, SOURCE_NATIVE_AX, SOURCE_NATIVE_SCRIPT, SOURCE_REACT_NATIVE, SOURCE_SWIFTUI, SOURCE_UIKIT, }; + use crate::config::{UserAndroidConfig, UserConfig}; use crate::inspector::PublishedInspector; use crate::metrics::counters::ClientStreamStats; + use axum::body::Bytes; use serde_json::{json, Value}; fn selector() -> ElementSelectorPayload { @@ -5693,6 +5776,41 @@ mod tests { } } + #[test] + fn boot_simulator_payload_reads_android_emulator_args() { + let payload = boot_simulator_payload_from_body(&Bytes::from_static( + br#"{"androidEmulatorArgs":["-gpu","host"],"androidDisableAudio":false}"#, + )) + .unwrap(); + let options = android_boot_options_from_config( + UserConfig { + android: UserAndroidConfig { + emulator_args: vec!["-no-snapshot".to_owned()], + disable_audio: true, + }, + ..Default::default() + }, + payload.android_emulator_args, + payload.android_disable_audio, + ); + + assert_eq!(options.emulator_args, vec!["-no-snapshot", "-gpu", "host"]); + assert!(!options.disable_audio); + } + + #[test] + fn boot_simulator_payload_allows_legacy_null_body() { + let payload = boot_simulator_payload_from_body(&Bytes::from_static(b"null")).unwrap(); + let options = android_boot_options_from_config( + UserConfig::default(), + payload.android_emulator_args, + payload.android_disable_audio, + ); + + assert!(options.emulator_args.is_empty()); + assert!(options.disable_audio); + } + fn accessibility_snapshot() -> Value { json!({ "roots": [{ diff --git a/packages/server/src/config.rs b/packages/server/src/config.rs index a85cd6b4..0bbd89b6 100644 --- a/packages/server/src/config.rs +++ b/packages/server/src/config.rs @@ -1,8 +1,9 @@ use sha2::{Digest, Sha256}; #[cfg(unix)] use std::ffi::CStr; +use std::fs; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(Clone, Debug)] pub struct Config { @@ -19,6 +20,80 @@ pub struct Config { pub low_latency: bool, } +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserConfig { + #[serde(default)] + pub service: UserServiceConfig, + #[serde(default)] + pub android: UserAndroidConfig, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserServiceConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserAndroidConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub emulator_args: Vec, + #[serde(default = "default_android_disable_audio")] + pub disable_audio: bool, +} + +impl Default for UserAndroidConfig { + fn default() -> Self { + Self { + emulator_args: Vec::new(), + disable_audio: default_android_disable_audio(), + } + } +} + +impl UserConfig { + pub fn load() -> anyhow::Result { + let path = user_config_path(); + if !path.exists() { + return Ok(Self::default()); + } + let data = fs::read_to_string(&path) + .map_err(|error| anyhow::anyhow!("read {}: {error}", path.display()))?; + let config = serde_json::from_str::(&data) + .map_err(|error| anyhow::anyhow!("parse {}: {error}", path.display()))?; + config.validate(&path)?; + Ok(config) + } + + fn validate(&self, path: &Path) -> anyhow::Result<()> { + if let Some(port) = self.service.port { + if port < 1024 { + anyhow::bail!("{} service.port must be 1024 or higher.", path.display()); + } + } + Ok(()) + } +} + +pub fn user_config_path() -> PathBuf { + simdeck_user_state_dir().join("config.json") +} + +pub fn simdeck_user_state_dir() -> PathBuf { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|home| home.join(".simdeck")) + .unwrap_or_else(|| std::env::temp_dir().join("simdeck")) +} + +fn default_android_disable_audio() -> bool { + true +} + impl Config { #[allow(clippy::too_many_arguments)] pub fn new( @@ -167,7 +242,9 @@ fn parse_io_platform_uuid(output: &str) -> Option { #[cfg(test)] mod tests { - use super::{host_identity_from_source, parse_io_platform_uuid, Config, ServerKind}; + use super::{ + host_identity_from_source, parse_io_platform_uuid, Config, ServerKind, UserConfig, + }; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::path::PathBuf; @@ -252,4 +329,23 @@ mod tests { Some("01234567-89AB-CDEF-0123-456789ABCDEF") ); } + + #[test] + fn user_config_reads_service_and_android_defaults() { + let config: UserConfig = serde_json::from_str( + r#"{ + "service": { "port": 4311 }, + "android": { + "emulatorArgs": ["-no-snapshot"], + "disableAudio": false + } + }"#, + ) + .unwrap(); + + assert_eq!(config.service.port, Some(4311)); + assert_eq!(config.android.emulator_args, vec!["-no-snapshot"]); + assert!(!config.android.disable_audio); + assert!(UserConfig::default().android.disable_audio); + } } diff --git a/packages/server/src/main.rs b/packages/server/src/main.rs index c5b8f5fa..eaeca193 100644 --- a/packages/server/src/main.rs +++ b/packages/server/src/main.rs @@ -81,7 +81,7 @@ use anyhow::Context; use api::routes::{router, AppState}; use axum::Router; use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; -use config::{Config, ServerKind}; +use config::{Config, ServerKind, UserConfig}; use inspector::{InspectorHub, InspectorRegistryAdvertisement}; use logs::LogRegistry; use metrics::counters::Metrics; @@ -182,7 +182,7 @@ enum Command { Pair { #[arg( long, - help = "Defaults to the existing service port, or 4310 when the service is not installed" + help = "Defaults to the existing service port, configured service.port, or 4310" )] port: Option, #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::UNSPECIFIED))] @@ -235,6 +235,12 @@ enum Command { }, Boot { udid: Option, + #[arg( + long = "android-emulator-arg", + value_name = "ARG", + allow_hyphen_values = true + )] + android_emulator_args: Vec, }, Shutdown { udid: Option, @@ -651,8 +657,8 @@ enum MaestroCommand { #[derive(Subcommand)] enum ServiceCommand { Start { - #[arg(long, default_value_t = SERVICE_PORT)] - port: u16, + #[arg(long)] + port: Option, #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] bind: IpAddr, #[arg(long)] @@ -671,8 +677,8 @@ enum ServiceCommand { local_stream_fps: Option, }, On { - #[arg(long, default_value_t = SERVICE_PORT)] - port: u16, + #[arg(long)] + port: Option, #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] bind: IpAddr, #[arg(long)] @@ -695,7 +701,7 @@ enum ServiceCommand { Restart { #[arg( long, - help = "Defaults to the existing service port, or 4310 when no service state exists" + help = "Defaults to the existing service port, configured service.port, or 4310" )] port: Option, #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] @@ -716,8 +722,8 @@ enum ServiceCommand { local_stream_fps: Option, }, Reset { - #[arg(long, default_value_t = SERVICE_PORT)] - port: u16, + #[arg(long)] + port: Option, #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] bind: IpAddr, #[arg(long)] @@ -2783,6 +2789,14 @@ fn removed_service_process_name() -> String { ['d', 'a', 'e', 'm', 'o', 'n'].into_iter().collect() } +fn android_boot_request_body(android_emulator_args: &[String]) -> Value { + if android_emulator_args.is_empty() { + Value::Null + } else { + serde_json::json!({ "androidEmulatorArgs": android_emulator_args }) + } +} + fn run_no_command_action(action: NoCommandAction) -> anyhow::Result<()> { match action { NoCommandAction::Service(options) => run_default_service(options), @@ -2790,9 +2804,15 @@ fn run_no_command_action(action: NoCommandAction) -> anyhow::Result<()> { } fn run_default_service(options: DefaultServiceLaunchOptions) -> anyhow::Result<()> { + let user_config = UserConfig::load()?; let selector = options.selector; + let port = if options.port_explicit { + options.port + } else { + user_config.service.port.unwrap_or(options.port) + }; let launch_options = ServiceLaunchOptions { - port: options.port, + port, bind: options.bind, advertise_host: options.advertise_host, client_root: options.client_root, @@ -2870,7 +2890,13 @@ fn service_restart_port(explicit_port: Option) -> anyhow::Result { if let Some(metadata) = read_service_metadata().ok().flatten() { return Ok(metadata.port); } - Ok(SERVICE_PORT) + Ok(UserConfig::load()?.service.port.unwrap_or(SERVICE_PORT)) +} + +fn configured_service_port(explicit_port: Option) -> anyhow::Result { + Ok(explicit_port + .or(UserConfig::load()?.service.port) + .unwrap_or(SERVICE_PORT)) } struct PairGlobalServiceOptions { @@ -2905,7 +2931,9 @@ fn pair_global_service(options: PairGlobalServiceOptions) -> anyhow::Result<()> } let requested_port = match port { Some(port) => port, - None => service::installed_port()?.unwrap_or(SERVICE_PORT), + None => service::installed_port()? + .or(UserConfig::load()?.service.port) + .unwrap_or(SERVICE_PORT), }; print_pair_progress(format!("requesting port {requested_port}")); @@ -3822,6 +3850,7 @@ fn main() -> anyhow::Result<()> { stream_quality, local_stream_fps, } => { + let port = configured_service_port(port)?; let (metadata, started) = ensure_project_service_with_status(ServiceLaunchOptions { port, @@ -3853,6 +3882,7 @@ fn main() -> anyhow::Result<()> { local_stream_fps, access_token, } => { + let port = configured_service_port(port)?; cleanup_orphaned_workspace_services_for_root(None); service::enable(ServiceOptions { port, @@ -3913,6 +3943,7 @@ fn main() -> anyhow::Result<()> { local_stream_fps, access_token, } => { + let port = configured_service_port(port)?; cleanup_orphaned_workspace_services_for_root(None); let credentials = reset_project_service_credentials(access_token)?; service::reset(ServiceOptions { @@ -4031,15 +4062,22 @@ fn main() -> anyhow::Result<()> { }))?; Ok(()) } - Command::Boot { udid } => { + Command::Boot { + udid, + android_emulator_args, + } => { let udid = resolve_device_udid(udid.as_deref())?; let service_url = command_service_url(explicit_server_url.as_deref())?; - service_post_ok(&service_url, &udid, "boot", &Value::Null)?; + let request_body = android_boot_request_body(&android_emulator_args); + service_post_ok(&service_url, &udid, "boot", &request_body)?; println!( "{}", - serde_json::to_string_pretty( - &serde_json::json!({ "ok": true, "udid": udid, "action": "boot" }) - )? + serde_json::to_string_pretty(&serde_json::json!({ + "ok": true, + "udid": udid, + "action": "boot", + "androidEmulatorArgs": android_emulator_args, + }))? ); Ok(()) } @@ -6318,9 +6356,9 @@ fn default_client_root() -> anyhow::Result { #[cfg(test)] mod tests { use super::{ - batch_line_to_json_step, http_url_for_host, interactive_accessibility_snapshot, - is_tailscale_ip, maestro_commands_from_flow, maestro_selector, - new_project_service_credentials, no_command_action_from_args_slice, + android_boot_request_body, batch_line_to_json_step, http_url_for_host, + interactive_accessibility_snapshot, is_tailscale_ip, maestro_commands_from_flow, + maestro_selector, new_project_service_credentials, no_command_action_from_args_slice, normalize_accessibility_point_for_display, parse_maestro_flow_yaml, parse_maestro_point, parse_optional_udid_f64_args, parse_optional_udid_text_args, parse_optional_udid_value_args, parse_tap_command_args, @@ -6574,7 +6612,7 @@ mod tests { else { panic!("expected service reset command"); }; - assert_eq!(port, 4315); + assert_eq!(port, Some(4315)); assert_eq!(access_token.as_deref(), Some("explicit-token")); } @@ -7242,10 +7280,15 @@ mod tests { #[test] fn device_commands_accept_omitted_udid() { let parsed = Cli::try_parse_from(["simdeck", "boot"]).unwrap(); - let Command::Boot { udid } = parsed.command else { + let Command::Boot { + udid, + android_emulator_args, + } = parsed.command + else { panic!("expected boot command"); }; assert_eq!(udid, None); + assert!(android_emulator_args.is_empty()); let parsed = Cli::try_parse_from(["simdeck", "home"]).unwrap(); let Command::Home { udid } = parsed.command else { @@ -7261,6 +7304,36 @@ mod tests { assert!(stdout); } + #[test] + fn boot_command_accepts_android_emulator_startup_args() { + let parsed = Cli::try_parse_from([ + "simdeck", + "boot", + "android:Pixel_8_API_36", + "--android-emulator-arg=-no-snapshot", + "--android-emulator-arg=-gpu", + "--android-emulator-arg=host", + ]) + .unwrap(); + + let Command::Boot { + udid, + android_emulator_args, + } = parsed.command + else { + panic!("expected boot command"); + }; + + assert_eq!(udid.as_deref(), Some("android:Pixel_8_API_36")); + assert_eq!(android_emulator_args, vec!["-no-snapshot", "-gpu", "host"]); + assert_eq!( + android_boot_request_body(&android_emulator_args), + serde_json::json!({ + "androidEmulatorArgs": ["-no-snapshot", "-gpu", "host"] + }) + ); + } + #[test] fn payload_commands_keep_positional_udid_but_allow_default_device() { let parsed = Cli::try_parse_from(["simdeck", "launch", "com.example.App"]).unwrap(); diff --git a/packages/simdeck-test/dist/index.d.ts b/packages/simdeck-test/dist/index.d.ts index 5909c627..339edb25 100644 --- a/packages/simdeck-test/dist/index.d.ts +++ b/packages/simdeck-test/dist/index.d.ts @@ -68,6 +68,10 @@ export type ScreenshotOptions = { export type ScreenRecordingOptions = { seconds?: number; }; +export type AndroidBootOptions = { + androidEmulatorArgs?: string[]; + androidDisableAudio?: boolean; +}; type DeviceMethod = { (udid: string, ...args: TArgs): TResult; (...args: TArgs): TResult; @@ -79,7 +83,7 @@ export type SimDeckSession = { udid?: string; device(udid: string): SimDeckSession; list(): Promise; - boot: DeviceMethod<[], Promise>; + boot: DeviceMethod<[options?: AndroidBootOptions], Promise>; shutdown: DeviceMethod<[], Promise>; erase: DeviceMethod<[], Promise>; install: DeviceMethod<[appPath: string], Promise>; diff --git a/packages/simdeck-test/dist/index.js b/packages/simdeck-test/dist/index.js index 6e883c53..d7991e90 100644 --- a/packages/simdeck-test/dist/index.js +++ b/packages/simdeck-test/dist/index.js @@ -61,8 +61,8 @@ export async function connect(options = {}) { device: (udid) => createSession(udid), list: () => requestJson(endpoint, "GET", "/api/simulators"), boot: (...args) => { - const { udid } = resolveNoArgDeviceCall(args); - return requestJson(endpoint, "POST", simulatorPath(udid, "/boot"), null); + const { udid, options } = resolveOptionalObjectDeviceCall(args); + return requestJson(endpoint, "POST", simulatorPath(udid, "/boot"), androidBootRequestBody(options)); }, shutdown: (...args) => { const { udid } = resolveNoArgDeviceCall(args); @@ -526,6 +526,19 @@ function runJson(command, args, options = {}) { function requestOk(endpoint, pathName, body) { return requestJson(endpoint, "POST", pathName, body).then(() => undefined); } +function androidBootRequestBody(options) { + if (!options) { + return null; + } + const body = {}; + if (options.androidEmulatorArgs?.length) { + body.androidEmulatorArgs = options.androidEmulatorArgs; + } + if (options.androidDisableAudio !== undefined) { + body.androidDisableAudio = options.androidDisableAudio; + } + return Object.keys(body).length ? body : null; +} function requestJson(endpoint, method, pathName, body) { return requestBuffer(endpoint, pathName, method, body).then((buffer) => JSON.parse(buffer.toString("utf8"))); } diff --git a/packages/simdeck-test/src/index.ts b/packages/simdeck-test/src/index.ts index 79aa8b08..399dacac 100644 --- a/packages/simdeck-test/src/index.ts +++ b/packages/simdeck-test/src/index.ts @@ -98,6 +98,11 @@ export type ScreenRecordingOptions = { seconds?: number; }; +export type AndroidBootOptions = { + androidEmulatorArgs?: string[]; + androidDisableAudio?: boolean; +}; + type DeviceMethod = { (udid: string, ...args: TArgs): TResult; (...args: TArgs): TResult; @@ -110,7 +115,7 @@ export type SimDeckSession = { udid?: string; device(udid: string): SimDeckSession; list(): Promise; - boot: DeviceMethod<[], Promise>; + boot: DeviceMethod<[options?: AndroidBootOptions], Promise>; shutdown: DeviceMethod<[], Promise>; erase: DeviceMethod<[], Promise>; install: DeviceMethod<[appPath: string], Promise>; @@ -283,13 +288,16 @@ export async function connect( udid: defaultUdid, device: (udid: string) => createSession(udid), list: () => requestJson(endpoint, "GET", "/api/simulators"), - boot: (...args: [] | [string]) => { - const { udid } = resolveNoArgDeviceCall(args); + boot: ( + ...args: [string, AndroidBootOptions?] | [AndroidBootOptions?] + ) => { + const { udid, options } = + resolveOptionalObjectDeviceCall(args); return requestJson( endpoint, "POST", simulatorPath(udid, "/boot"), - null, + androidBootRequestBody(options), ); }, shutdown: (...args: [] | [string]) => { @@ -903,6 +911,20 @@ function requestOk( return requestJson(endpoint, "POST", pathName, body).then(() => undefined); } +function androidBootRequestBody(options?: AndroidBootOptions): unknown { + if (!options) { + return null; + } + const body: Record = {}; + if (options.androidEmulatorArgs?.length) { + body.androidEmulatorArgs = options.androidEmulatorArgs; + } + if (options.androidDisableAudio !== undefined) { + body.androidDisableAudio = options.androidDisableAudio; + } + return Object.keys(body).length ? body : null; +} + function requestJson( endpoint: string, method: string, diff --git a/scripts/github-actions.test.mjs b/scripts/github-actions.test.mjs index 0696ef9e..fe10f1d7 100644 --- a/scripts/github-actions.test.mjs +++ b/scripts/github-actions.test.mjs @@ -123,6 +123,21 @@ test("Android integration runner resolves Windows executables", () => { assert.match(androidIntegration, /\.exe/); }); +test("Android PR comment forwards configured emulator startup args", () => { + assert.match(androidAction, /android_emulator_args:/); + assert.match(androidAction, /INPUT_ANDROID_EMULATOR_ARGS_VALUE/); + + const bootStep = stepSlice( + androidAction, + "Boot Android emulator", + "Update status comment with booted emulator URL", + ); + + assert.match(bootStep, /SIMDECK_ANDROID_EMULATOR_ARGS/); + assert.match(bootStep, /--android-emulator-arg=/); + assert.match(bootStep, /simdeck --server-url .* boot "\$\{udid\}"/); +}); + test("iOS PR comment waits for public simulator list access", () => { const prebootIndex = iosAction.indexOf( "- name: Select and preboot simulator", diff --git a/skills/simdeck/SKILL.md b/skills/simdeck/SKILL.md index 61573d70..f4f3072d 100644 --- a/skills/simdeck/SKILL.md +++ b/skills/simdeck/SKILL.md @@ -71,6 +71,7 @@ simdeck list simdeck list --format json simdeck use simdeck boot +simdeck boot android: --android-emulator-arg=-no-snapshot simdeck shutdown simdeck erase simdeck core-simulator restart @@ -101,6 +102,9 @@ AVDs from the Android SDK. SimDeck-owned Android boots use the emulator shared-video surface plus `--android-gpu host` by default. Use `simdeck service restart --android-gpu auto` or `--android-gpu swiftshader_indirect` only when host GPU rendering is unstable. +Optional user defaults live in `~/.simdeck/config.json`. Supported keys include +`service.port`, `android.emulatorArgs`, and `android.disableAudio`; explicit +CLI/API boot arguments still win for one-off runs. ## Fast agent inspection