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