Skip to main content

whisker_dev_server/
installer.rs

1//! Tier 2 install + relaunch.
2//!
3//! After a successful cold-rebuild, the freshly-built artifact has to
4//! land on the target and start (re-bootstrapping the dev-runtime so
5//! it dials the dev-server back). For Android we shell out to `adb`;
6//! for iOS Simulator to `xcrun simctl`.
7//!
8//! Application identity (bundle id, applicationId, launcher activity,
9//! scheme, …) is **not** baked in here. The cli passes those as
10//! `Config::android` / `Config::ios` after reading the user's
11//! `whisker.rs::configure(&mut Config)`, so this module has zero
12//! knowledge of which example or external user crate is in play.
13
14use anyhow::{Context, Result};
15use std::path::PathBuf;
16use tokio::process::Command;
17
18use crate::{AndroidParams, IosParams, Target};
19use whisker_build::CaptureShims;
20
21pub struct Installer {
22    target: Target,
23    android: Option<AndroidParams>,
24    ios: Option<IosParams>,
25    workspace_root: PathBuf,
26    package: String,
27    /// Tier 1 capture shims for hot-reload. When `Some`, the
28    /// xcodebuild Command in [`ios_install_and_launch`] gets the
29    /// `RUSTC_WORKSPACE_WRAPPER` + `CARGO_TARGET_*_LINKER` +
30    /// `CARGO_TARGET_*_RUSTFLAGS` env vars set so the Step-7
31    /// Build Phase's cargo invocation runs as a fat capture build.
32    /// Pre-Step-7 the dev-server primed capture via a separate
33    /// `build_xcframework_with` call in `builder.rs`; that call now
34    /// produces an artifact xcodebuild's Build Phase rebuilds anyway,
35    /// so the capture wiring moves here.
36    capture: Option<CaptureShims>,
37    /// Cargo features forwarded to the iOS Build Phase via the
38    /// `WHISKER_FEATURES` env var (the pbxproj's shell script expands
39    /// it into `--features <feat>` args). `whisker run` populates this
40    /// with `["whisker/hot-reload"]` so the dev-runtime WebSocket
41    /// client gets compiled into the user dylib — without it the app
42    /// never sends its `aslr_reference` and every change falls back to
43    /// a Tier 2 cold rebuild.
44    features: Vec<String>,
45    /// Host port the dev-server's WebSocket is bound to (= the port of
46    /// `Config::bind_addr`). The device must reach this exact port:
47    /// Android bridges it with `adb reverse tcp:9876 tcp:<dev_port>`
48    /// (the device keeps dialing its default 9876), and the iOS
49    /// Simulator dials `127.0.0.1:<dev_port>` directly via
50    /// `SIMCTL_CHILD_WHISKER_DEV_ADDR`. Without this the `--bind` flag
51    /// silently breaks hot reload (server on a custom port, device
52    /// still on 9876).
53    dev_port: u16,
54    /// Shared dev-session token to deliver to the device so its
55    /// `hello` is accepted by the server's auth gate. iOS gets it via
56    /// `SIMCTL_CHILD_WHISKER_DEV_TOKEN`; Android via
57    /// `adb shell setprop debug.whisker_dev_token`. `None` = token-less.
58    dev_token: Option<String>,
59}
60
61impl Installer {
62    #[allow(clippy::too_many_arguments)]
63    pub fn new(
64        target: Target,
65        android: Option<AndroidParams>,
66        ios: Option<IosParams>,
67        workspace_root: PathBuf,
68        package: String,
69        capture: Option<CaptureShims>,
70        features: Vec<String>,
71        dev_port: u16,
72        dev_token: Option<String>,
73    ) -> Self {
74        Self {
75            target,
76            android,
77            ios,
78            workspace_root,
79            package,
80            capture,
81            features,
82            dev_port,
83            dev_token,
84        }
85    }
86
87    pub async fn install_and_launch(&self) -> Result<()> {
88        match self.target {
89            Target::Android => {
90                let p = self.android.as_ref().context(
91                    "target=Android but no AndroidParams — cli must populate Config.android",
92                )?;
93                android_install_and_launch(p, self.dev_port, self.dev_token.as_deref()).await
94            }
95            Target::IosSimulator => {
96                let p = self.ios.as_ref().context(
97                    "target=IosSimulator but no IosParams — cli must populate Config.ios",
98                )?;
99                ios_install_and_launch(
100                    p,
101                    &self.workspace_root,
102                    &self.package,
103                    self.capture.as_ref(),
104                    &self.features,
105                    self.dev_port,
106                    self.dev_token.as_deref(),
107                )
108                .await
109            }
110        }
111    }
112}
113
114/// Run a `tokio::process::Command` to completion, capture its stderr,
115/// and filter known-benign lines (`already booted`, `found nothing to
116/// terminate`, xcodebuild's IDE noise). The actual exit status is
117/// returned verbatim; the caller decides what counts as failure. Used
118/// for `xcrun simctl ...` invocations where the stderr signal is
119/// ~70 % noise.
120async fn run_filtered(mut cmd: Command, kind: SimctlNoise) -> Result<std::process::ExitStatus> {
121    use tokio::io::AsyncReadExt;
122    cmd.stdout(std::process::Stdio::piped())
123        .stderr(std::process::Stdio::piped());
124    let mut child = cmd.spawn().context("spawn child")?;
125    // Track the PID so `whisker run`'s hard-exit quit path can SIGTERM
126    // an in-flight xcodebuild / simctl instead of orphaning it. The
127    // guard unregisters when this fn returns.
128    let _child_guard = child.id().map(whisker_build::child_guard::track);
129    let mut stdout = child.stdout.take();
130    let mut stderr = child.stderr.take();
131
132    let (out_buf, err_buf) = tokio::join!(
133        async {
134            let mut s = Vec::new();
135            if let Some(mut h) = stdout.take() {
136                let _ = h.read_to_end(&mut s).await;
137            }
138            s
139        },
140        async {
141            let mut s = Vec::new();
142            if let Some(mut h) = stderr.take() {
143                let _ = h.read_to_end(&mut s).await;
144            }
145            s
146        }
147    );
148    let status = child.wait().await.context("wait for child")?;
149
150    let stderr_str = String::from_utf8_lossy(&err_buf);
151    for line in stderr_str.lines() {
152        let trimmed = line.trim();
153        if trimmed.is_empty() {
154            continue;
155        }
156        if kind.is_benign(trimmed) {
157            continue;
158        }
159        // Anything that survived the filter is real output — surface
160        // it as a warning so the user notices but the curated layout
161        // isn't drowned.
162        whisker_build::ui::warn(trimmed);
163    }
164    // Stdout from these tools is usually empty or low-noise (e.g.
165    // `simctl launch` prints `<bundle_id>: <pid>`); echo it through
166    // info() at debug-grade.
167    let stdout_str = String::from_utf8_lossy(&out_buf);
168    for line in stdout_str.lines() {
169        let trimmed = line.trim();
170        if !trimmed.is_empty() && !kind.is_benign_stdout(trimmed) {
171            whisker_build::ui::info(trimmed);
172        }
173    }
174    Ok(status)
175}
176
177/// `xcodebuild`'s `-quiet` flag silences progress chatter but the
178/// underlying compiler still emits diagnostics — which under Xcode
179/// with iOS 26 SDK + Lynx's pre-iOS-26 framework headers means a
180/// hundreds-of-lines deprecation cascade (`'mainScreen' is
181/// deprecated`, `'screens' is deprecated`, …) on every build. None
182/// of it is actionable by Whisker users (the headers ship from
183/// upstream Lynx), so we filter it as benign here.
184///
185/// Approach: drop anything that looks like a clang / xcodebuild
186/// warning chain — the `warning:` line, the `note:` follow-ups,
187/// the source-line listings (`  217 |`, `      |   ^`), the
188/// "in file included from" / "N warnings generated." summaries,
189/// and the `[MT] IDERunDestination` IDE chatter.
190///
191/// Real errors are preserved: lines containing `error:` /
192/// `fatal error:` / `** BUILD FAILED **` always fall through.
193fn is_benign_xcodebuild_line(raw: &str) -> bool {
194    // Under `--verbose` / `WHISKER_VERBOSE=1`, let every line
195    // through — that's the explicit "I want to see the full
196    // underlying tool output" mode, including the deprecation
197    // chain we'd otherwise suppress.
198    if whisker_build::ui::is_verbose() {
199        return false;
200    }
201
202    let line = raw.trim_start_matches(|c: char| c.is_ascii_whitespace() || c == '·');
203
204    // Always surface real errors. Check this first so we don't
205    // accidentally suppress a `warning:`-prefixed error message.
206    if line.starts_with("error:")
207        || line.contains(" error:")
208        || line.starts_with("fatal error:")
209        || line.starts_with("** BUILD FAILED")
210        || line.starts_with("** BUILD INTERRUPTED")
211    {
212        return false;
213    }
214
215    // `2026-05-21 18:21:52.770 xcodebuild[54160:34595363] [MT] IDERunDestination …`
216    if raw.starts_with("20") && raw.contains("xcodebuild[") && raw.contains("] [MT] ") {
217        return true;
218    }
219
220    // The xcframework command's success line.
221    if line.starts_with("xcframework successfully written out to:") {
222        return true;
223    }
224
225    // Diagnostic chain: warnings, notes, source line listings,
226    // `N warnings generated.` summary.
227    if line.starts_with("warning:")
228        || line.contains(" warning:")
229        || line.starts_with("note:")
230        || line.contains(" note:")
231        || line.starts_with("In file included from")
232        || line.ends_with(" warnings generated.")
233        || line.ends_with(" warning generated.")
234    {
235        return true;
236    }
237
238    // Source-line listings rendered alongside the warning chain:
239    //   `217 | #import "LynxBackgroundInfo.h"`
240    //   `    | ^`
241    //   `56 |`        (empty source line for context)
242    // After trimming leading whitespace + all leading digits, the
243    // remainder always starts with `|` (with or without trailing
244    // content). Multi-digit line numbers were the gap that earlier
245    // single-char `strip_prefix` filters missed.
246    let after_digits = line
247        .trim_start()
248        .trim_start_matches(|c: char| c.is_ascii_digit() || c.is_ascii_whitespace());
249    if after_digits.starts_with('|') {
250        return true;
251    }
252
253    false
254}
255
256/// Classifies which sub-command produced the stderr — we tune the
257/// "benign noise" set per tool because the false-positive shape
258/// differs (simctl emits NSPOSIX error preambles, xcodebuild emits
259/// `IDERunDestination` etc.).
260#[derive(Copy, Clone)]
261enum SimctlNoise {
262    /// `xcrun simctl boot` — "Unable to boot device in current state: Booted"
263    /// fires when the sim is already up, which is the normal case
264    /// after the first `whisker run`.
265    Boot,
266    /// `xcrun simctl install` / `launch` — generally low-noise but
267    /// can emit POSIX prefixes; treat anything matching the known
268    /// boilerplate as suppressed. `simctl launch
269    /// --terminate-running-process` is also routed through here
270    /// (the previous separate `simctl terminate` step was rolled
271    /// into the launch flag — see `ios_install_and_launch`).
272    Other,
273    /// `xcodebuild` — `[MT] IDERunDestination`, the date-time
274    /// preamble lines, and the post-build "xcframework written"
275    /// confirmation all belong here.
276    Xcodebuild,
277    /// `adb install -r` — stdout banners "Performing Streamed
278    /// Install" and "Success" duplicate the `install_step.done()`
279    /// row our UI already prints.
280    AdbInstall,
281    /// `adb shell am start` — stdout banner `Starting: Intent
282    /// { cmp=... }` duplicates what the launch step's label
283    /// already says.
284    AdbAmStart,
285}
286
287impl SimctlNoise {
288    fn is_benign(&self, line: &str) -> bool {
289        // Lines common to several Apple tools.
290        if line.contains("An error was encountered processing the command")
291            || line.contains("Underlying error (domain=")
292            || line.starts_with("    The request to terminate")
293        {
294            return true;
295        }
296        match self {
297            SimctlNoise::Boot => {
298                line.contains("Unable to boot device in current state: Booted")
299                    || line.starts_with("(code=405)")
300            }
301            SimctlNoise::Other => false,
302            SimctlNoise::Xcodebuild => is_benign_xcodebuild_line(line),
303            SimctlNoise::AdbInstall | SimctlNoise::AdbAmStart => false,
304        }
305    }
306
307    fn is_benign_stdout(&self, line: &str) -> bool {
308        // For xcodebuild, also fold stdout through the benign filter
309        // — `-quiet` doesn't fully silence it on iOS 26 SDK + Lynx
310        // pre-iOS-26 headers, so `mainScreen` deprecations etc. land
311        // on stdout depending on Xcode version.
312        if matches!(self, SimctlNoise::Xcodebuild) && is_benign_xcodebuild_line(line) {
313            return true;
314        }
315        match self {
316            // `simctl launch` always reports `<bundle_id>: <pid>` on
317            // success; it duplicates info our `step.done(...)`
318            // already covers.
319            SimctlNoise::Other => line.contains(": ") && line.chars().any(|c| c.is_ascii_digit()),
320            // `adb install -r`: two stdout lines on success — both
321            // are subsumed by the `install` step's ✓ row.
322            SimctlNoise::AdbInstall => line == "Performing Streamed Install" || line == "Success",
323            // `adb shell am start -n <component>`: the one stdout
324            // line "Starting: Intent { cmp=… }" duplicates the
325            // launch step's label.
326            SimctlNoise::AdbAmStart => line.starts_with("Starting: Intent {"),
327            _ => false,
328        }
329    }
330}
331
332async fn android_install_and_launch(
333    p: &AndroidParams,
334    dev_port: u16,
335    dev_token: Option<&str>,
336) -> Result<()> {
337    let apk = p
338        .project_dir
339        .join("app/build/outputs/apk/debug/app-debug.apk");
340    if !apk.is_file() {
341        anyhow::bail!("APK missing at {}", apk.display());
342    }
343
344    // adb reverse — bridge device `127.0.0.1:9876` → host `dev_port` so
345    // the on-device dev-runtime can reach our WebSocket without knowing
346    // the emulator-gateway IP (10.0.2.2). The device keeps dialing its
347    // default 9876 (`WHISKER_DEV_ADDR` fallback); only the host side of
348    // the mapping follows `--bind`, so a custom `--bind` port keeps hot
349    // reload working. Best-effort: it might already be set from a
350    // previous run, or the device might be a non-emulator that doesn't
351    // need it. Routed through `run_filtered` rather than `.status()` so
352    // its stdio doesn't bypass the TUI's stderr-capture pipe and overlay
353    // the live region — same reason every other adb call below now uses
354    // it.
355    let mut reverse_cmd = Command::new("adb");
356    reverse_cmd.args(["reverse", "tcp:9876", &format!("tcp:{dev_port}")]);
357    let _ = run_filtered(reverse_cmd, SimctlNoise::Other).await;
358
359    // Deliver the dev-session token. The app process doesn't inherit
360    // adb-set env vars, so we stash it in a `debug.*` system property
361    // (settable over adb) that the device-side `whisker-dev-runtime`
362    // reads via `__system_property_get`. Without a matching token the
363    // dev-server refuses to ship patches to the app.
364    if let Some(token) = dev_token {
365        let mut setprop_cmd = Command::new("adb");
366        setprop_cmd.args(["shell", "setprop", "debug.whisker_dev_token", token]);
367        let _ = run_filtered(setprop_cmd, SimctlNoise::Other).await;
368    }
369
370    let install_step = whisker_build::ui::step(
371        "install",
372        apk.file_name()
373            .map(|n| n.to_string_lossy().into_owned())
374            .unwrap_or_else(|| "app-debug.apk".into()),
375    );
376    let mut install_cmd = Command::new("adb");
377    install_cmd.args(["install", "-r"]).arg(&apk);
378    let install = run_filtered(install_cmd, SimctlNoise::AdbInstall)
379        .await
380        .context("spawn adb install")?;
381    if !install.success() {
382        install_step.fail(format!("{install}"));
383        anyhow::bail!("adb install -r {} failed ({install})", apk.display());
384    }
385    install_step.done("");
386
387    // adb shell am force-stop  (so the relaunch actually re-bootstraps).
388    // `force-stop` is silent on success; route through `run_filtered`
389    // anyway so any error preamble lands in scrollback rather than on
390    // top of the live region.
391    let mut stop_cmd = Command::new("adb");
392    stop_cmd.args(["shell", "am", "force-stop", &p.application_id]);
393    let _ = run_filtered(stop_cmd, SimctlNoise::Other).await;
394
395    let component = format!("{}/{}", p.application_id, p.launcher_activity);
396    let launch_step = whisker_build::ui::step("launch", component.clone());
397    let mut launch_cmd = Command::new("adb");
398    launch_cmd.args(["shell", "am", "start", "-n", &component]);
399    let launch = run_filtered(launch_cmd, SimctlNoise::AdbAmStart)
400        .await
401        .context("spawn adb am start")?;
402    if !launch.success() {
403        launch_step.fail(format!("{launch}"));
404        anyhow::bail!("adb am start {component} failed ({launch})");
405    }
406    launch_step.done("");
407    Ok(())
408}
409
410async fn ios_install_and_launch(
411    p: &IosParams,
412    workspace_root: &std::path::Path,
413    package: &str,
414    capture: Option<&CaptureShims>,
415    features: &[String],
416    dev_port: u16,
417    dev_token: Option<&str>,
418) -> Result<()> {
419    let xcode_project = p.project_dir.join(format!("{}.xcodeproj", p.scheme));
420    if !xcode_project.is_dir() {
421        anyhow::bail!(
422            "Xcode project missing at {} — run xcodegen first",
423            xcode_project.display()
424        );
425    }
426    let derived = workspace_root
427        .join("target/.whisker/ios-derived")
428        .join(package);
429
430    let xc_step = whisker_build::ui::step("xcodebuild", p.scheme.clone());
431    let mut xc_cmd = Command::new("xcodebuild");
432    xc_cmd
433        .arg("-project")
434        .arg(&xcode_project)
435        .args(["-scheme", &p.scheme])
436        .args(["-configuration", "Debug"])
437        .args(["-destination", "generic/platform=iOS Simulator"])
438        .arg("-derivedDataPath")
439        .arg(&derived)
440        // The WhiskerModuleCodegenPlugin is a SwiftPM build-tool plugin;
441        // Xcode gates plugins behind an interactive trust prompt a
442        // headless build can't answer, so skip validation (it ships from
443        // Whisker's own `whisker` SPM package).
444        .arg("-skipPackagePluginValidation")
445        .args(["-quiet", "build"]);
446    // NB: WhiskerRuntime + the codegen plugin now resolve from the
447    // remote `whisker` SwiftPM package (see whisker_build::ios::
448    // WHISKER_IOS_SPM_URL), so the old `WHISKER_IOS_RUNTIME` /
449    // `WHISKER_IOS_MACROS` env injection that pointed module manifests at
450    // `platforms/ios` is gone — modules no longer read it.
451    // Tier 1 capture wiring (hot-reload). Pre-Step-7 the dev-server
452    // ran a separate `build_xcframework_with` call to prime the rustc
453    // + linker capture caches before xcodebuild touched the framework.
454    // Step 7's Build Phase produces the framework during xcodebuild
455    // itself, so the capture envs need to ride along here — they
456    // propagate xcodebuild → shell Build Phase → `whisker build-ios`
457    // subprocess → cargo, where the shims actually intercept rustc +
458    // linker. Capture is opt-in (`HotPatchMode::Tier1Subsecond`); when
459    // `None`, xcodebuild runs without the shims and the loop falls
460    // back to Tier 2 cold rebuilds.
461    if let Some(c) = capture {
462        let sim_triple = "aarch64-apple-ios-sim";
463        for (k, v) in whisker_build::capture_env_vars_for_triple(c, Some(sim_triple)) {
464            xc_cmd.env(k, v);
465        }
466    }
467    // Forward cargo features through to the Build Phase's
468    // `whisker build-ios` invocation as a space-separated list. The
469    // pbxproj's shell script expands each entry into `--features <feat>`
470    // before invoking the binary. `whisker run` puts `whisker/hot-reload`
471    // here so the user dylib carries the dev-runtime WebSocket client;
472    // without that the app never reports `aslr_reference` and every
473    // patch falls through to a Tier 2 cold rebuild + relaunch.
474    if !features.is_empty() {
475        xc_cmd.env("WHISKER_FEATURES", features.join(" "));
476    }
477    let xc_status = run_filtered(xc_cmd, SimctlNoise::Xcodebuild)
478        .await
479        .context("spawn xcodebuild")?;
480    if !xc_status.success() {
481        xc_step.fail(format!("{xc_status}"));
482        anyhow::bail!("xcodebuild build failed ({xc_status})");
483    }
484    xc_step.done("");
485
486    let app_path = derived
487        .join("Build/Products/Debug-iphonesimulator")
488        .join(format!("{}.app", p.scheme));
489    if !app_path.is_dir() {
490        anyhow::bail!(
491            "expected {}.app missing under {} after build",
492            p.scheme,
493            derived.display()
494        );
495    }
496
497    // Best-effort boot of either the caller's override or the first
498    // available iPhone simctl knows about. "Already booted" stderr
499    // is filtered as a benign noise pattern.
500    let device = p
501        .device_override
502        .clone()
503        .or_else(pick_available_iphone)
504        .unwrap_or_else(|| "iPhone 17 Pro".into());
505    let boot_step = whisker_build::ui::step("boot", device.clone());
506    let mut boot_cmd = Command::new("xcrun");
507    boot_cmd.args(["simctl", "boot", &device]);
508    let _ = run_filtered(boot_cmd, SimctlNoise::Boot).await;
509    boot_step.done("");
510
511    let install_step = whisker_build::ui::step("install", format!("{}.app", p.scheme));
512    let mut install_cmd = Command::new("xcrun");
513    install_cmd
514        .args(["simctl", "install", "booted"])
515        .arg(&app_path);
516    let install = run_filtered(install_cmd, SimctlNoise::Other)
517        .await
518        .context("spawn simctl install")?;
519    if !install.success() {
520        install_step.fail(format!("{install}"));
521        anyhow::bail!("simctl install {} failed ({install})", app_path.display());
522    }
523    install_step.done("");
524
525    // `SIMCTL_CHILD_<NAME>` shows up as `<NAME>` inside the launched
526    // app's env — that's how the dev-runtime finds us.
527    //
528    // `--terminate-running-process` makes simctl atomically kill the
529    // previous instance (so the runtime re-bootstraps + reconnects
530    // the dev WebSocket) and immediately launch the fresh build. We
531    // used to do this as two steps — `simctl terminate` followed by
532    // `simctl launch` — but the terminate call emits
533    // `Simulator device failed to terminate <bundle>.` to stderr
534    // whenever the app exists on the simulator but isn't actually
535    // running (which is every cold start, and also the "user
536    // backgrounded the app between rebuilds" case). The flag bundles
537    // both operations and handles the not-running case silently.
538    let launch_step = whisker_build::ui::step("launch", p.bundle_id.clone());
539    let mut launch_cmd = Command::new("xcrun");
540    launch_cmd
541        .args([
542            "simctl",
543            "launch",
544            "--terminate-running-process",
545            "booted",
546            &p.bundle_id,
547        ])
548        // The Simulator shares the host loopback, so it dials the
549        // dev-server's bind port directly. Honors `--bind <port>`.
550        .env(
551            "SIMCTL_CHILD_WHISKER_DEV_ADDR",
552            format!("127.0.0.1:{dev_port}"),
553        );
554    // Deliver the dev-session token as an env var the launched app
555    // inherits (`SIMCTL_CHILD_<NAME>` → `<NAME>` in the child).
556    if let Some(token) = dev_token {
557        launch_cmd.env("SIMCTL_CHILD_WHISKER_DEV_TOKEN", token);
558    }
559    let launch = run_filtered(launch_cmd, SimctlNoise::Other)
560        .await
561        .context("spawn simctl launch")?;
562    if !launch.success() {
563        launch_step.fail(format!("{launch}"));
564        anyhow::bail!("simctl launch {} failed ({launch})", p.bundle_id);
565    }
566    launch_step.done("");
567    Ok(())
568}
569
570/// Best-effort pick of an iPhone simulator that's installed on this
571/// machine. `pick_available_iphone()` returns `None` if simctl isn't
572/// available or the output doesn't parse; the caller substitutes a
573/// hard-coded default.
574fn pick_available_iphone() -> Option<String> {
575    let out = std::process::Command::new("xcrun")
576        .args(["simctl", "list", "devices", "available"])
577        .output()
578        .ok()?;
579    if !out.status.success() {
580        return None;
581    }
582    let text = String::from_utf8(out.stdout).ok()?;
583    for line in text.lines() {
584        let trimmed = line.trim();
585        // Lines look like:  iPhone 17 Pro (UDID...) (Shutdown)
586        let Some((name, _rest)) = trimmed.split_once(" (") else {
587            continue;
588        };
589        if name.starts_with("iPhone ") {
590            return Some(name.to_string());
591        }
592    }
593    None
594}
595
596// ============================================================================
597// Tests
598// ============================================================================
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    fn android_params() -> AndroidParams {
605        AndroidParams {
606            project_dir: PathBuf::from("/tmp/x"),
607            application_id: "rs.whisker.examples.helloworld".into(),
608            launcher_activity: ".MainActivity".into(),
609            abi: "arm64-v8a".into(),
610        }
611    }
612
613    #[test]
614    fn installer_for_android_without_params_errors() {
615        let inst = Installer::new(
616            Target::Android,
617            None,
618            None,
619            PathBuf::new(),
620            "x".into(),
621            None,
622            Vec::new(),
623            9876,
624            None,
625        );
626        let rt = tokio::runtime::Builder::new_current_thread()
627            .build()
628            .unwrap();
629        let err = rt
630            .block_on(async { inst.install_and_launch().await })
631            .unwrap_err();
632        assert!(err.to_string().contains("AndroidParams"), "got: {err:#}");
633    }
634
635    #[test]
636    fn installer_for_ios_without_params_errors() {
637        let inst = Installer::new(
638            Target::IosSimulator,
639            None,
640            None,
641            PathBuf::new(),
642            "x".into(),
643            None,
644            Vec::new(),
645            9876,
646            None,
647        );
648        let rt = tokio::runtime::Builder::new_current_thread()
649            .build()
650            .unwrap();
651        let err = rt
652            .block_on(async { inst.install_and_launch().await })
653            .unwrap_err();
654        assert!(err.to_string().contains("IosParams"), "got: {err:#}");
655    }
656
657    #[test]
658    fn android_install_errors_when_apk_missing() {
659        let p = android_params();
660        let rt = tokio::runtime::Builder::new_current_thread()
661            .build()
662            .unwrap();
663        let err = rt
664            .block_on(async { android_install_and_launch(&p, 9876, None).await })
665            .unwrap_err();
666        assert!(err.to_string().contains("APK missing"), "got: {err:#}");
667    }
668}