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}