use anyhow::{Context, Result};
use std::path::PathBuf;
use tokio::process::Command;
use crate::{AndroidParams, IosParams, Target};
use whisker_build::CaptureShims;
pub struct Installer {
target: Target,
android: Option<AndroidParams>,
ios: Option<IosParams>,
workspace_root: PathBuf,
package: String,
capture: Option<CaptureShims>,
features: Vec<String>,
}
impl Installer {
pub fn new(
target: Target,
android: Option<AndroidParams>,
ios: Option<IosParams>,
workspace_root: PathBuf,
package: String,
capture: Option<CaptureShims>,
features: Vec<String>,
) -> Self {
Self {
target,
android,
ios,
workspace_root,
package,
capture,
features,
}
}
pub async fn install_and_launch(&self) -> Result<()> {
match self.target {
Target::Android => {
let p = self.android.as_ref().context(
"target=Android but no AndroidParams — cli must populate Config.android",
)?;
android_install_and_launch(p).await
}
Target::IosSimulator => {
let p = self.ios.as_ref().context(
"target=IosSimulator but no IosParams — cli must populate Config.ios",
)?;
ios_install_and_launch(
p,
&self.workspace_root,
&self.package,
self.capture.as_ref(),
&self.features,
)
.await
}
}
}
}
async fn run_filtered(mut cmd: Command, kind: SimctlNoise) -> Result<std::process::ExitStatus> {
use tokio::io::AsyncReadExt;
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().context("spawn child")?;
let mut stdout = child.stdout.take();
let mut stderr = child.stderr.take();
let (out_buf, err_buf) = tokio::join!(
async {
let mut s = Vec::new();
if let Some(mut h) = stdout.take() {
let _ = h.read_to_end(&mut s).await;
}
s
},
async {
let mut s = Vec::new();
if let Some(mut h) = stderr.take() {
let _ = h.read_to_end(&mut s).await;
}
s
}
);
let status = child.wait().await.context("wait for child")?;
let stderr_str = String::from_utf8_lossy(&err_buf);
for line in stderr_str.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if kind.is_benign(trimmed) {
continue;
}
whisker_build::ui::warn(trimmed);
}
let stdout_str = String::from_utf8_lossy(&out_buf);
for line in stdout_str.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !kind.is_benign_stdout(trimmed) {
whisker_build::ui::info(trimmed);
}
}
Ok(status)
}
fn is_benign_xcodebuild_line(raw: &str) -> bool {
if whisker_build::ui::is_verbose() {
return false;
}
let line = raw.trim_start_matches(|c: char| c.is_ascii_whitespace() || c == '·');
if line.starts_with("error:")
|| line.contains(" error:")
|| line.starts_with("fatal error:")
|| line.starts_with("** BUILD FAILED")
|| line.starts_with("** BUILD INTERRUPTED")
{
return false;
}
if raw.starts_with("20") && raw.contains("xcodebuild[") && raw.contains("] [MT] ") {
return true;
}
if line.starts_with("xcframework successfully written out to:") {
return true;
}
if line.starts_with("warning:")
|| line.contains(" warning:")
|| line.starts_with("note:")
|| line.contains(" note:")
|| line.starts_with("In file included from")
|| line.ends_with(" warnings generated.")
|| line.ends_with(" warning generated.")
{
return true;
}
let after_digits = line
.trim_start()
.trim_start_matches(|c: char| c.is_ascii_digit() || c.is_ascii_whitespace());
if after_digits.starts_with('|') {
return true;
}
false
}
#[derive(Copy, Clone)]
enum SimctlNoise {
Boot,
Other,
Xcodebuild,
AdbInstall,
AdbAmStart,
}
impl SimctlNoise {
fn is_benign(&self, line: &str) -> bool {
if line.contains("An error was encountered processing the command")
|| line.contains("Underlying error (domain=")
|| line.starts_with(" The request to terminate")
{
return true;
}
match self {
SimctlNoise::Boot => {
line.contains("Unable to boot device in current state: Booted")
|| line.starts_with("(code=405)")
}
SimctlNoise::Other => false,
SimctlNoise::Xcodebuild => is_benign_xcodebuild_line(line),
SimctlNoise::AdbInstall | SimctlNoise::AdbAmStart => false,
}
}
fn is_benign_stdout(&self, line: &str) -> bool {
if matches!(self, SimctlNoise::Xcodebuild) && is_benign_xcodebuild_line(line) {
return true;
}
match self {
SimctlNoise::Other => line.contains(": ") && line.chars().any(|c| c.is_ascii_digit()),
SimctlNoise::AdbInstall => line == "Performing Streamed Install" || line == "Success",
SimctlNoise::AdbAmStart => line.starts_with("Starting: Intent {"),
_ => false,
}
}
}
async fn android_install_and_launch(p: &AndroidParams) -> Result<()> {
let apk = p
.project_dir
.join("app/build/outputs/apk/debug/app-debug.apk");
if !apk.is_file() {
anyhow::bail!("APK missing at {}", apk.display());
}
let mut reverse_cmd = Command::new("adb");
reverse_cmd.args(["reverse", "tcp:9876", "tcp:9876"]);
let _ = run_filtered(reverse_cmd, SimctlNoise::Other).await;
let install_step = whisker_build::ui::step(
"install",
apk.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "app-debug.apk".into()),
);
let mut install_cmd = Command::new("adb");
install_cmd.args(["install", "-r"]).arg(&apk);
let install = run_filtered(install_cmd, SimctlNoise::AdbInstall)
.await
.context("spawn adb install")?;
if !install.success() {
install_step.fail(format!("{install}"));
anyhow::bail!("adb install -r {} failed ({install})", apk.display());
}
install_step.done("");
let mut stop_cmd = Command::new("adb");
stop_cmd.args(["shell", "am", "force-stop", &p.application_id]);
let _ = run_filtered(stop_cmd, SimctlNoise::Other).await;
let component = format!("{}/{}", p.application_id, p.launcher_activity);
let launch_step = whisker_build::ui::step("launch", component.clone());
let mut launch_cmd = Command::new("adb");
launch_cmd.args(["shell", "am", "start", "-n", &component]);
let launch = run_filtered(launch_cmd, SimctlNoise::AdbAmStart)
.await
.context("spawn adb am start")?;
if !launch.success() {
launch_step.fail(format!("{launch}"));
anyhow::bail!("adb am start {component} failed ({launch})");
}
launch_step.done("");
Ok(())
}
async fn ios_install_and_launch(
p: &IosParams,
workspace_root: &std::path::Path,
package: &str,
capture: Option<&CaptureShims>,
features: &[String],
) -> Result<()> {
let xcode_project = p.project_dir.join(format!("{}.xcodeproj", p.scheme));
if !xcode_project.is_dir() {
anyhow::bail!(
"Xcode project missing at {} — run xcodegen first",
xcode_project.display()
);
}
let derived = workspace_root
.join("target/.whisker/ios-derived")
.join(package);
let xc_step = whisker_build::ui::step("xcodebuild", p.scheme.clone());
let mut xc_cmd = Command::new("xcodebuild");
xc_cmd
.arg("-project")
.arg(&xcode_project)
.args(["-scheme", &p.scheme])
.args(["-configuration", "Debug"])
.args(["-destination", "generic/platform=iOS Simulator"])
.arg("-derivedDataPath")
.arg(&derived)
.args(["-quiet", "build"])
.env("WHISKER_IOS_RUNTIME", workspace_root.join("platforms/ios"))
.env(
"WHISKER_IOS_MACROS",
workspace_root.join("platforms/ios/macros"),
);
if let Some(c) = capture {
let sim_triple = "aarch64-apple-ios-sim";
for (k, v) in whisker_build::capture_env_vars_for_triple(c, Some(sim_triple)) {
xc_cmd.env(k, v);
}
}
if !features.is_empty() {
xc_cmd.env("WHISKER_FEATURES", features.join(" "));
}
let xc_status = run_filtered(xc_cmd, SimctlNoise::Xcodebuild)
.await
.context("spawn xcodebuild")?;
if !xc_status.success() {
xc_step.fail(format!("{xc_status}"));
anyhow::bail!("xcodebuild build failed ({xc_status})");
}
xc_step.done("");
let app_path = derived
.join("Build/Products/Debug-iphonesimulator")
.join(format!("{}.app", p.scheme));
if !app_path.is_dir() {
anyhow::bail!(
"expected {}.app missing under {} after build",
p.scheme,
derived.display()
);
}
let device = p
.device_override
.clone()
.or_else(pick_available_iphone)
.unwrap_or_else(|| "iPhone 17 Pro".into());
let boot_step = whisker_build::ui::step("boot", device.clone());
let mut boot_cmd = Command::new("xcrun");
boot_cmd.args(["simctl", "boot", &device]);
let _ = run_filtered(boot_cmd, SimctlNoise::Boot).await;
boot_step.done("");
let install_step = whisker_build::ui::step("install", format!("{}.app", p.scheme));
let mut install_cmd = Command::new("xcrun");
install_cmd
.args(["simctl", "install", "booted"])
.arg(&app_path);
let install = run_filtered(install_cmd, SimctlNoise::Other)
.await
.context("spawn simctl install")?;
if !install.success() {
install_step.fail(format!("{install}"));
anyhow::bail!("simctl install {} failed ({install})", app_path.display());
}
install_step.done("");
let launch_step = whisker_build::ui::step("launch", p.bundle_id.clone());
let mut launch_cmd = Command::new("xcrun");
launch_cmd
.args([
"simctl",
"launch",
"--terminate-running-process",
"booted",
&p.bundle_id,
])
.env("SIMCTL_CHILD_WHISKER_DEV_ADDR", "127.0.0.1:9876");
let launch = run_filtered(launch_cmd, SimctlNoise::Other)
.await
.context("spawn simctl launch")?;
if !launch.success() {
launch_step.fail(format!("{launch}"));
anyhow::bail!("simctl launch {} failed ({launch})", p.bundle_id);
}
launch_step.done("");
Ok(())
}
fn pick_available_iphone() -> Option<String> {
let out = std::process::Command::new("xcrun")
.args(["simctl", "list", "devices", "available"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8(out.stdout).ok()?;
for line in text.lines() {
let trimmed = line.trim();
let Some((name, _rest)) = trimmed.split_once(" (") else {
continue;
};
if name.starts_with("iPhone ") {
return Some(name.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn android_params() -> AndroidParams {
AndroidParams {
project_dir: PathBuf::from("/tmp/x"),
application_id: "rs.whisker.examples.helloworld".into(),
launcher_activity: ".MainActivity".into(),
abi: "arm64-v8a".into(),
}
}
#[test]
fn installer_for_android_without_params_errors() {
let inst = Installer::new(
Target::Android,
None,
None,
PathBuf::new(),
"x".into(),
None,
Vec::new(),
);
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let err = rt
.block_on(async { inst.install_and_launch().await })
.unwrap_err();
assert!(err.to_string().contains("AndroidParams"), "got: {err:#}");
}
#[test]
fn installer_for_ios_without_params_errors() {
let inst = Installer::new(
Target::IosSimulator,
None,
None,
PathBuf::new(),
"x".into(),
None,
Vec::new(),
);
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let err = rt
.block_on(async { inst.install_and_launch().await })
.unwrap_err();
assert!(err.to_string().contains("IosParams"), "got: {err:#}");
}
#[test]
fn android_install_errors_when_apk_missing() {
let p = android_params();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let err = rt
.block_on(async { android_install_and_launch(&p).await })
.unwrap_err();
assert!(err.to_string().contains("APK missing"), "got: {err:#}");
}
}