use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root};
use crate::codegen::{IosDeploymentTarget, IosProjectOptions, IosRunner, resolve_ios_runner};
use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn resolve_ios_benchmark_timeout_secs_from_env() -> u64 {
env::var("MOBENCH_IOS_BENCHMARK_TIMEOUT_SECS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.filter(|secs| *secs > 0)
.unwrap_or(crate::codegen::DEFAULT_IOS_BENCHMARK_TIMEOUT_SECS)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XcodeVersion {
pub major: u16,
pub minor: u16,
pub raw: String,
}
fn parse_xcode_version(output: &str) -> Option<XcodeVersion> {
let line = output.lines().find(|line| line.starts_with("Xcode "))?;
let raw_version = line.trim_start_matches("Xcode ").trim();
let mut parts = raw_version.split('.');
let major = parts.next()?.parse::<u16>().ok()?;
let minor = parts
.next()
.and_then(|part| part.parse::<u16>().ok())
.unwrap_or(0);
Some(XcodeVersion {
major,
minor,
raw: raw_version.to_string(),
})
}
fn selected_xcode_version() -> Result<XcodeVersion, BenchError> {
let output = Command::new("xcodebuild")
.arg("-version")
.output()
.map_err(|err| {
BenchError::Build(format!(
"Failed to run `xcodebuild -version`: {err}. Install/select Xcode before building iOS artifacts."
))
})?;
if !output.status.success() {
return Err(BenchError::Build(format!(
"`xcodebuild -version` failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_xcode_version(&stdout).ok_or_else(|| {
BenchError::Build(format!(
"Failed to parse selected Xcode version from `xcodebuild -version` output:\n{stdout}"
))
})
}
fn minimum_supported_ios_deployment_target_for_xcode(
xcode: &XcodeVersion,
) -> Result<IosDeploymentTarget, BenchError> {
let floor = if xcode.major >= 15 {
"15.0"
} else if xcode.major == 14 {
"11.0"
} else {
"10.0"
};
IosDeploymentTarget::parse(floor)
}
pub fn validate_xcode_supports_ios_deployment_target(
deployment_target: &IosDeploymentTarget,
) -> Result<(), BenchError> {
let xcode = selected_xcode_version()?;
let supported_floor = minimum_supported_ios_deployment_target_for_xcode(&xcode)?;
if deployment_target < &supported_floor {
return Err(BenchError::Build(format!(
"iOS deployment target {deployment_target} requires an older Xcode toolchain; \
selected Xcode {} supports iOS {}+ in mobench's supported lanes. \
Use a legacy CI lane with an older Xcode capable of iOS 10/11/12, or raise `[ios].deployment_target`.",
xcode.raw, supported_floor
)));
}
Ok(())
}
pub struct IosBuilder {
project_root: PathBuf,
output_dir: PathBuf,
crate_name: String,
verbose: bool,
crate_dir: Option<PathBuf>,
dry_run: bool,
deployment_target: IosDeploymentTarget,
runner: Option<IosRunner>,
}
impl IosBuilder {
pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
let root_input = project_root.into();
let root = match root_input.canonicalize() {
Ok(path) => path,
Err(err) => {
eprintln!(
"Warning: failed to canonicalize project root `{}`: {}. Using provided path.",
root_input.display(),
err
);
root_input
}
};
Self {
output_dir: root.join("target/mobench"),
project_root: root,
crate_name: crate_name.into(),
verbose: false,
crate_dir: None,
dry_run: false,
deployment_target: IosDeploymentTarget::default_target(),
runner: None,
}
}
pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.output_dir = dir.into();
self
}
pub fn crate_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.crate_dir = Some(dir.into());
self
}
pub fn verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
pub fn deployment_target(mut self, deployment_target: IosDeploymentTarget) -> Self {
self.deployment_target = deployment_target;
self
}
pub fn runner(mut self, runner: Option<IosRunner>) -> Self {
self.runner = runner;
self
}
pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
if self.crate_dir.is_none() {
validate_project_root(&self.project_root, &self.crate_name)?;
}
let runner = resolve_ios_runner(&self.deployment_target, self.runner)?;
if !self.dry_run {
validate_xcode_supports_ios_deployment_target(&self.deployment_target)?;
}
let framework_name = self.crate_name.replace("-", "_");
let ios_dir = self.output_dir.join("ios");
let xcframework_path = ios_dir.join(format!("{}.xcframework", framework_name));
if self.dry_run {
println!("\n[dry-run] iOS build plan:");
println!(
" Step 0: Check/generate iOS project scaffolding at {:?}",
ios_dir.join("BenchRunner")
);
println!(" Step 1: Build Rust libraries for iOS targets");
println!(
" Command: cargo build --target aarch64-apple-ios --lib {}",
if matches!(config.profile, BuildProfile::Release) {
"--release"
} else {
""
}
);
println!(
" Command: cargo build --target aarch64-apple-ios-sim --lib {}",
if matches!(config.profile, BuildProfile::Release) {
"--release"
} else {
""
}
);
println!(
" Command: cargo build --target x86_64-apple-ios --lib {}",
if matches!(config.profile, BuildProfile::Release) {
"--release"
} else {
""
}
);
println!(" Step 2: Generate UniFFI Swift bindings");
println!(
" Output: {:?}",
ios_dir.join("BenchRunner/BenchRunner/Generated")
);
println!(" Step 3: Create xcframework at {:?}", xcframework_path);
println!(" - ios-arm64/{}.framework (device)", framework_name);
println!(
" - ios-arm64_x86_64-simulator/{}.framework (simulator - arm64 + x86_64 lipo)",
framework_name
);
println!(" Step 4: Code-sign xcframework");
println!(
" Command: codesign --force --deep --sign - {:?}",
xcframework_path
);
println!(" Step 5: Generate Xcode project with xcodegen (if project.yml exists)");
println!(" Command: xcodegen generate");
return Ok(BuildResult {
platform: Target::Ios,
app_path: xcframework_path,
test_suite_path: None,
native_libraries: Vec::new(),
});
}
crate::codegen::ensure_ios_project_with_project_options(
&self.output_dir,
&self.crate_name,
Some(&self.project_root),
self.crate_dir.as_deref(),
IosProjectOptions {
deployment_target: self.deployment_target.clone(),
runner,
ios_benchmark_timeout_secs: resolve_ios_benchmark_timeout_secs_from_env(),
},
)?;
println!("Building Rust libraries for iOS...");
self.build_rust_libraries(config)?;
println!("Generating UniFFI Swift bindings...");
self.generate_uniffi_bindings()?;
println!("Creating xcframework...");
let xcframework_path = self.create_xcframework(config)?;
println!("Code-signing xcframework...");
self.codesign_xcframework(&xcframework_path)?;
let header_src = self
.find_uniffi_header(&format!("{}FFI.h", framework_name))
.ok_or_else(|| {
BenchError::Build(format!(
"UniFFI header {}FFI.h not found after generation",
framework_name
))
})?;
let include_dir = self.output_dir.join("ios/include");
fs::create_dir_all(&include_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to create include dir at {}: {}. Check output directory permissions.",
include_dir.display(),
e
))
})?;
let header_dest = include_dir.join(format!("{}.h", framework_name));
fs::copy(&header_src, &header_dest).map_err(|e| {
BenchError::Build(format!(
"Failed to copy UniFFI header to {:?}: {}. Check output directory permissions.",
header_dest, e
))
})?;
self.generate_xcode_project()?;
let result = BuildResult {
platform: Target::Ios,
app_path: xcframework_path,
test_suite_path: None,
native_libraries: Vec::new(),
};
self.validate_build_artifacts(&result, config)?;
Ok(result)
}
fn validate_build_artifacts(
&self,
result: &BuildResult,
config: &BuildConfig,
) -> Result<(), BenchError> {
let mut missing = Vec::new();
let framework_name = self.crate_name.replace("-", "_");
let profile_dir = match config.profile {
BuildProfile::Debug => "debug",
BuildProfile::Release => "release",
};
if !result.app_path.exists() {
missing.push(format!("XCFramework: {}", result.app_path.display()));
}
let xcframework_path = &result.app_path;
let device_slice = xcframework_path.join(format!("ios-arm64/{}.framework", framework_name));
let sim_slice = xcframework_path.join(format!(
"ios-arm64_x86_64-simulator/{}.framework",
framework_name
));
if xcframework_path.exists() {
if !device_slice.exists() {
missing.push(format!(
"Device framework slice: {}",
device_slice.display()
));
}
if !sim_slice.exists() {
missing.push(format!(
"Simulator framework slice (arm64+x86_64): {}",
sim_slice.display()
));
}
}
let crate_dir = self.find_crate_dir()?;
let target_dir = get_cargo_target_dir(&crate_dir)?;
let lib_name = format!("lib{}.a", framework_name);
let device_lib = target_dir
.join("aarch64-apple-ios")
.join(profile_dir)
.join(&lib_name);
let sim_arm64_lib = target_dir
.join("aarch64-apple-ios-sim")
.join(profile_dir)
.join(&lib_name);
let sim_x86_64_lib = target_dir
.join("x86_64-apple-ios")
.join(profile_dir)
.join(&lib_name);
if !device_lib.exists() {
missing.push(format!("Device static library: {}", device_lib.display()));
}
if !sim_arm64_lib.exists() {
missing.push(format!(
"Simulator (arm64) static library: {}",
sim_arm64_lib.display()
));
}
if !sim_x86_64_lib.exists() {
missing.push(format!(
"Simulator (x86_64) static library: {}",
sim_x86_64_lib.display()
));
}
let swift_bindings = self
.output_dir
.join("ios/BenchRunner/BenchRunner/Generated")
.join(format!("{}.swift", framework_name));
if !swift_bindings.exists() {
missing.push(format!("Swift bindings: {}", swift_bindings.display()));
}
if !missing.is_empty() {
let critical = missing
.iter()
.any(|m| m.contains("XCFramework") || m.contains("static library"));
if critical {
return Err(BenchError::Build(format!(
"Build validation failed: Critical artifacts are missing.\n\n\
Missing artifacts:\n{}\n\n\
This usually means the Rust build step failed. Check the cargo build output above.",
missing
.iter()
.map(|s| format!(" - {}", s))
.collect::<Vec<_>>()
.join("\n")
)));
} else {
eprintln!(
"Warning: Some build artifacts are missing:\n{}\n\
The build may still work but some features might be unavailable.",
missing
.iter()
.map(|s| format!(" - {}", s))
.collect::<Vec<_>>()
.join("\n")
);
}
}
Ok(())
}
fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
if let Some(ref dir) = self.crate_dir {
if dir.exists() {
return Ok(dir.clone());
}
return Err(BenchError::Build(format!(
"Specified crate path does not exist: {:?}.\n\n\
Tip: pass --crate-path pointing at a directory containing Cargo.toml.",
dir
)));
}
let root_cargo_toml = self.project_root.join("Cargo.toml");
if root_cargo_toml.exists()
&& let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml)
&& pkg_name == self.crate_name
{
return Ok(self.project_root.clone());
}
let bench_mobile_dir = self.project_root.join("bench-mobile");
if bench_mobile_dir.exists() {
return Ok(bench_mobile_dir);
}
let crates_dir = self.project_root.join("crates").join(&self.crate_name);
if crates_dir.exists() {
return Ok(crates_dir);
}
let named_dir = self.project_root.join(&self.crate_name);
if named_dir.exists() {
return Ok(named_dir);
}
let root_manifest = root_cargo_toml;
let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
let crates_manifest = crates_dir.join("Cargo.toml");
let named_manifest = named_dir.join("Cargo.toml");
Err(BenchError::Build(format!(
"Benchmark crate '{}' not found.\n\n\
Searched locations:\n\
- {} (checked [package] name)\n\
- {}\n\
- {}\n\
- {}\n\n\
To fix this:\n\
1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
2. Create a bench-mobile/ directory with your benchmark crate, or\n\
3. Use --crate-path to specify the benchmark crate location:\n\
cargo mobench build --target ios --crate-path ./my-benchmarks\n\n\
Common issues:\n\
- Typo in crate name (check Cargo.toml [package] name)\n\
- Wrong working directory (run from project root)\n\
- Missing Cargo.toml in the crate directory\n\n\
Run 'cargo mobench init --help' to generate a new benchmark project.",
self.crate_name,
root_manifest.display(),
bench_mobile_manifest.display(),
crates_manifest.display(),
named_manifest.display(),
self.crate_name,
)))
}
fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
let crate_dir = self.find_crate_dir()?;
let targets = vec![
"aarch64-apple-ios", "aarch64-apple-ios-sim", "x86_64-apple-ios", ];
self.check_rust_targets(&targets)?;
let release_flag = if matches!(config.profile, BuildProfile::Release) {
"--release"
} else {
""
};
for target in targets {
if self.verbose {
println!(" Building for {}", target);
}
let mut cmd = Command::new("cargo");
cmd.arg("build").arg("--target").arg(target).arg("--lib");
if !release_flag.is_empty() {
cmd.arg(release_flag);
}
cmd.current_dir(&crate_dir);
let command_hint = if release_flag.is_empty() {
format!("cargo build --target {} --lib", target)
} else {
format!("cargo build --target {} --lib {}", target, release_flag)
};
let output = cmd.output().map_err(|e| {
BenchError::Build(format!(
"Failed to run cargo for {}.\n\n\
Command: {}\n\
Crate directory: {}\n\
Error: {}\n\n\
Tip: ensure cargo is installed and on PATH.",
target,
command_hint,
crate_dir.display(),
e
))
})?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BenchError::Build(format!(
"cargo build failed for {}.\n\n\
Command: {}\n\
Crate directory: {}\n\
Exit status: {}\n\n\
Stdout:\n{}\n\n\
Stderr:\n{}\n\n\
Tips:\n\
- Ensure Xcode command line tools are installed (xcode-select --install)\n\
- Confirm Rust targets are installed (rustup target add {})",
target,
command_hint,
crate_dir.display(),
output.status,
stdout,
stderr,
target
)));
}
}
Ok(())
}
fn check_rust_targets(&self, targets: &[&str]) -> Result<(), BenchError> {
let sysroot = Command::new("rustc")
.args(["--print", "sysroot"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string());
for target in targets {
let installed = if let Some(ref root) = sysroot {
let lib_dir =
std::path::Path::new(root).join(format!("lib/rustlib/{}/lib", target));
lib_dir.exists()
} else {
let output = Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
.ok();
output
.map(|o| String::from_utf8_lossy(&o.stdout).contains(target))
.unwrap_or(false)
};
if !installed {
return Err(BenchError::Build(format!(
"Rust target '{}' is not installed.\n\n\
This target is required to compile for iOS.\n\n\
To install:\n\
rustup target add {}\n\n\
For a complete iOS setup, you need all three:\n\
rustup target add aarch64-apple-ios # Device\n\
rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)\n\
rustup target add x86_64-apple-ios # Simulator (Intel Macs)",
target, target
)));
}
}
Ok(())
}
fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
let crate_dir = self.find_crate_dir()?;
let crate_name_underscored = self.crate_name.replace("-", "_");
let bindings_path = self
.output_dir
.join("ios")
.join("BenchRunner")
.join("BenchRunner")
.join("Generated")
.join(format!("{}.swift", crate_name_underscored));
let had_existing_bindings = bindings_path.exists();
if had_existing_bindings && self.verbose {
println!(
" Found existing Swift bindings at {:?}; regenerating to keep the UniFFI schema current",
bindings_path
);
}
let mut build_cmd = Command::new("cargo");
build_cmd.arg("build");
build_cmd.current_dir(&crate_dir);
run_command(build_cmd, "cargo build (host)")?;
let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
let out_dir = self
.output_dir
.join("ios")
.join("BenchRunner")
.join("BenchRunner")
.join("Generated");
fs::create_dir_all(&out_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to create Swift bindings dir at {}: {}. Check output directory permissions.",
out_dir.display(),
e
))
})?;
let cargo_run_result = Command::new("cargo")
.args([
"run",
"-p",
&self.crate_name,
"--bin",
"uniffi-bindgen",
"--",
])
.arg("generate")
.arg("--library")
.arg(&lib_path)
.arg("--language")
.arg("swift")
.arg("--out-dir")
.arg(&out_dir)
.current_dir(&crate_dir)
.output();
let use_cargo_run = cargo_run_result
.as_ref()
.map(|o| o.status.success())
.unwrap_or(false);
if use_cargo_run {
if self.verbose {
println!(" Generated bindings using cargo run uniffi-bindgen");
}
} else {
let uniffi_available = Command::new("uniffi-bindgen")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !uniffi_available {
if had_existing_bindings {
if self.verbose {
println!(
" Warning: uniffi-bindgen is unavailable; keeping existing Swift bindings at {:?}",
bindings_path
);
}
return Ok(());
}
return Err(BenchError::Build(
"uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
To fix this, either:\n\
1. Add a uniffi-bindgen binary to your crate:\n\
[[bin]]\n\
name = \"uniffi-bindgen\"\n\
path = \"src/bin/uniffi-bindgen.rs\"\n\n\
2. Or install a matching uniffi-bindgen CLI globally:\n\
cargo install --git https://github.com/mozilla/uniffi-rs --tag <uniffi-tag> uniffi-bindgen-cli --bin uniffi-bindgen\n\n\
3. Or pre-generate bindings and commit them."
.to_string(),
));
}
let mut cmd = Command::new("uniffi-bindgen");
cmd.arg("generate")
.arg("--library")
.arg(&lib_path)
.arg("--language")
.arg("swift")
.arg("--out-dir")
.arg(&out_dir);
if let Err(error) = run_command(cmd, "uniffi-bindgen swift") {
if had_existing_bindings {
if self.verbose {
println!(
" Warning: failed to regenerate Swift bindings ({error}); keeping existing bindings at {:?}",
bindings_path
);
}
return Ok(());
}
return Err(error);
}
}
if self.verbose {
println!(" Generated UniFFI Swift bindings at {:?}", out_dir);
}
Ok(())
}
fn create_xcframework(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
let profile_dir = match config.profile {
BuildProfile::Debug => "debug",
BuildProfile::Release => "release",
};
let crate_dir = self.find_crate_dir()?;
let target_dir = get_cargo_target_dir(&crate_dir)?;
let xcframework_dir = self.output_dir.join("ios");
let framework_name = &self.crate_name.replace("-", "_");
let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name));
if xcframework_path.exists() {
fs::remove_dir_all(&xcframework_path).map_err(|e| {
BenchError::Build(format!(
"Failed to remove old xcframework at {}: {}. Close any tools using it and retry.",
xcframework_path.display(),
e
))
})?;
}
fs::create_dir_all(&xcframework_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to create xcframework directory at {}: {}. Check output directory permissions.",
xcframework_dir.display(),
e
))
})?;
self.create_framework_slice(
&target_dir.join("aarch64-apple-ios").join(profile_dir),
&xcframework_path.join("ios-arm64"),
framework_name,
"ios",
)?;
self.create_simulator_framework_slice(
&target_dir,
profile_dir,
&xcframework_path.join("ios-arm64_x86_64-simulator"),
framework_name,
)?;
self.create_xcframework_plist(&xcframework_path, framework_name)?;
Ok(xcframework_path)
}
fn create_framework_slice(
&self,
lib_path: &Path,
output_dir: &Path,
framework_name: &str,
platform: &str,
) -> Result<(), BenchError> {
let framework_dir = output_dir.join(format!("{}.framework", framework_name));
let headers_dir = framework_dir.join("Headers");
fs::create_dir_all(&headers_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to create framework directories at {}: {}. Check output directory permissions.",
headers_dir.display(),
e
))
})?;
let src_lib = lib_path.join(format!("lib{}.a", framework_name));
let dest_lib = framework_dir.join(framework_name);
if !src_lib.exists() {
return Err(BenchError::Build(format!(
"Static library not found at {}.\n\n\
Expected output from cargo build --target <target> --lib.\n\
Ensure your crate has [lib] crate-type = [\"staticlib\"].",
src_lib.display()
)));
}
fs::copy(&src_lib, &dest_lib).map_err(|e| {
BenchError::Build(format!(
"Failed to copy static library from {} to {}: {}. Check output directory permissions.",
src_lib.display(),
dest_lib.display(),
e
))
})?;
let header_name = format!("{}FFI.h", framework_name);
let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
BenchError::Build(format!(
"UniFFI header {} not found; run binding generation before building",
header_name
))
})?;
let dest_header = headers_dir.join(&header_name);
fs::copy(&header_path, &dest_header).map_err(|e| {
BenchError::Build(format!(
"Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.",
header_path.display(),
dest_header.display(),
e
))
})?;
let modulemap_content = format!(
"framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}",
framework_name, framework_name
);
let modulemap_path = headers_dir.join("module.modulemap");
fs::write(&modulemap_path, modulemap_content).map_err(|e| {
BenchError::Build(format!(
"Failed to write module.modulemap at {}: {}. Check output directory permissions.",
modulemap_path.display(),
e
))
})?;
self.create_framework_plist(&framework_dir, framework_name, platform)?;
Ok(())
}
fn create_simulator_framework_slice(
&self,
target_dir: &Path,
profile_dir: &str,
output_dir: &Path,
framework_name: &str,
) -> Result<(), BenchError> {
let framework_dir = output_dir.join(format!("{}.framework", framework_name));
let headers_dir = framework_dir.join("Headers");
fs::create_dir_all(&headers_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to create framework directories at {}: {}. Check output directory permissions.",
headers_dir.display(),
e
))
})?;
let arm64_lib = target_dir
.join("aarch64-apple-ios-sim")
.join(profile_dir)
.join(format!("lib{}.a", framework_name));
let x86_64_lib = target_dir
.join("x86_64-apple-ios")
.join(profile_dir)
.join(format!("lib{}.a", framework_name));
if !arm64_lib.exists() {
return Err(BenchError::Build(format!(
"Simulator library (arm64) not found at {}.\n\n\
Expected output from cargo build --target aarch64-apple-ios-sim --lib.\n\
Ensure your crate has [lib] crate-type = [\"staticlib\"].",
arm64_lib.display()
)));
}
if !x86_64_lib.exists() {
return Err(BenchError::Build(format!(
"Simulator library (x86_64) not found at {}.\n\n\
Expected output from cargo build --target x86_64-apple-ios --lib.\n\
Ensure your crate has [lib] crate-type = [\"staticlib\"].",
x86_64_lib.display()
)));
}
let dest_lib = framework_dir.join(framework_name);
let output = Command::new("lipo")
.arg("-create")
.arg(&arm64_lib)
.arg(&x86_64_lib)
.arg("-output")
.arg(&dest_lib)
.output()
.map_err(|e| {
BenchError::Build(format!(
"Failed to run lipo to create universal simulator binary.\n\n\
Command: lipo -create {} {} -output {}\n\
Error: {}\n\n\
Ensure Xcode command line tools are installed: xcode-select --install",
arm64_lib.display(),
x86_64_lib.display(),
dest_lib.display(),
e
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BenchError::Build(format!(
"lipo failed to create universal simulator binary.\n\n\
Command: lipo -create {} {} -output {}\n\
Exit status: {}\n\
Stderr: {}\n\n\
Ensure both libraries are valid static libraries.",
arm64_lib.display(),
x86_64_lib.display(),
dest_lib.display(),
output.status,
stderr
)));
}
if self.verbose {
println!(
" Created universal simulator binary (arm64 + x86_64) at {:?}",
dest_lib
);
}
let header_name = format!("{}FFI.h", framework_name);
let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
BenchError::Build(format!(
"UniFFI header {} not found; run binding generation before building",
header_name
))
})?;
let dest_header = headers_dir.join(&header_name);
fs::copy(&header_path, &dest_header).map_err(|e| {
BenchError::Build(format!(
"Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.",
header_path.display(),
dest_header.display(),
e
))
})?;
let modulemap_content = format!(
"framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}",
framework_name, framework_name
);
let modulemap_path = headers_dir.join("module.modulemap");
fs::write(&modulemap_path, modulemap_content).map_err(|e| {
BenchError::Build(format!(
"Failed to write module.modulemap at {}: {}. Check output directory permissions.",
modulemap_path.display(),
e
))
})?;
self.create_framework_plist(&framework_dir, framework_name, "ios-simulator")?;
Ok(())
}
fn create_framework_plist(
&self,
framework_dir: &Path,
framework_name: &str,
platform: &str,
) -> Result<(), BenchError> {
let bundle_id: String = framework_name
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>()
.to_lowercase();
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>{}</string>
<key>CFBundleIdentifier</key>
<string>dev.world.{}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>{}</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>{}</string>
</array>
</dict>
</plist>"#,
framework_name,
bundle_id,
framework_name,
if platform == "ios" {
"iPhoneOS"
} else {
"iPhoneSimulator"
}
);
let plist_path = framework_dir.join("Info.plist");
fs::write(&plist_path, plist_content).map_err(|e| {
BenchError::Build(format!(
"Failed to write framework Info.plist at {}: {}. Check output directory permissions.",
plist_path.display(),
e
))
})?;
Ok(())
}
fn create_xcframework_plist(
&self,
xcframework_path: &Path,
framework_name: &str,
) -> Result<(), BenchError> {
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>{}.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>{}.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>"#,
framework_name, framework_name
);
let plist_path = xcframework_path.join("Info.plist");
fs::write(&plist_path, plist_content).map_err(|e| {
BenchError::Build(format!(
"Failed to write xcframework Info.plist at {}: {}. Check output directory permissions.",
plist_path.display(),
e
))
})?;
Ok(())
}
fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> {
let output = Command::new("codesign")
.arg("--force")
.arg("--deep")
.arg("--sign")
.arg("-")
.arg(xcframework_path)
.output()
.map_err(|e| {
BenchError::Build(format!(
"Failed to run codesign.\n\n\
XCFramework: {}\n\
Error: {}\n\n\
Ensure Xcode command line tools are installed:\n\
xcode-select --install\n\n\
The xcframework must be signed for Xcode to accept it.",
xcframework_path.display(),
e
))
})?;
if output.status.success() {
if self.verbose {
println!(" Successfully code-signed xcframework");
}
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(BenchError::Build(format!(
"codesign failed to sign xcframework.\n\n\
XCFramework: {}\n\
Exit status: {}\n\
Stderr: {}\n\n\
Ensure you have valid signing credentials:\n\
security find-identity -v -p codesigning\n\n\
For ad-hoc signing (most common), the '-' identity should work.\n\
If signing continues to fail, check that the xcframework structure is valid.",
xcframework_path.display(),
output.status,
stderr
)))
}
}
fn generate_xcode_project(&self) -> Result<(), BenchError> {
let ios_dir = self.output_dir.join("ios");
let project_yml = ios_dir.join("BenchRunner/project.yml");
if !project_yml.exists() {
if self.verbose {
println!(" No project.yml found, skipping xcodegen");
}
return Ok(());
}
if self.verbose {
println!(" Generating Xcode project with xcodegen");
}
let project_dir = ios_dir.join("BenchRunner");
let output = Command::new("xcodegen")
.arg("generate")
.current_dir(&project_dir)
.output()
.map_err(|e| {
BenchError::Build(format!(
"Failed to run xcodegen.\n\n\
project.yml found at: {}\n\
Working directory: {}\n\
Error: {}\n\n\
xcodegen is required to generate the Xcode project.\n\
Install it with:\n\
brew install xcodegen\n\n\
After installation, re-run the build.",
project_yml.display(),
project_dir.display(),
e
))
})?;
if output.status.success() {
if self.verbose {
println!(" Successfully generated Xcode project");
}
Ok(())
} else {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
Err(BenchError::Build(format!(
"xcodegen failed.\n\n\
Command: xcodegen generate\n\
Working directory: {}\n\
Exit status: {}\n\n\
Stdout:\n{}\n\n\
Stderr:\n{}\n\n\
Check that project.yml is valid YAML and has correct xcodegen syntax.\n\
Try running 'xcodegen generate' manually in {} for more details.",
project_dir.display(),
output.status,
stdout,
stderr,
project_dir.display()
)))
}
}
fn find_uniffi_header(&self, header_name: &str) -> Option<PathBuf> {
let swift_dir = self
.output_dir
.join("ios/BenchRunner/BenchRunner/Generated");
let candidate_swift = swift_dir.join(header_name);
if candidate_swift.exists() {
return Some(candidate_swift);
}
let crate_dir = self.find_crate_dir().ok()?;
let target_dir = get_cargo_target_dir(&crate_dir).ok()?;
let candidate = target_dir.join("uniffi").join(header_name);
if candidate.exists() {
return Some(candidate);
}
let mut stack = vec![target_dir];
while let Some(dir) = stack.pop() {
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& name == "incremental"
{
continue;
}
stack.push(path);
} else if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& name == header_name
{
return Some(path);
}
}
}
}
None
}
}
#[allow(clippy::collapsible_if)]
fn find_codesign_identity() -> Option<String> {
let output = Command::new("security")
.args(["find-identity", "-v", "-p", "codesigning"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut identities = Vec::new();
for line in stdout.lines() {
if let Some(start) = line.find('"') {
if let Some(end) = line[start + 1..].find('"') {
identities.push(line[start + 1..start + 1 + end].to_string());
}
}
}
let preferred = [
"Apple Distribution",
"iPhone Distribution",
"Apple Development",
"iPhone Developer",
];
for label in preferred {
if let Some(identity) = identities.iter().find(|i| i.contains(label)) {
return Some(identity.clone());
}
}
identities.first().cloned()
}
#[allow(clippy::collapsible_if)]
fn find_provisioning_profile() -> Option<PathBuf> {
if let Ok(path) = env::var("MOBENCH_IOS_PROFILE") {
let profile = PathBuf::from(path);
if profile.exists() {
return Some(profile);
}
}
let home = env::var("HOME").ok()?;
let profiles_dir = PathBuf::from(home).join("Library/MobileDevice/Provisioning Profiles");
let entries = fs::read_dir(&profiles_dir).ok()?;
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("mobileprovision") {
continue;
}
if let Ok(metadata) = entry.metadata()
&& let Ok(modified) = metadata.modified()
{
match &newest {
Some((current, _)) if *current >= modified => {}
_ => newest = Some((modified, path)),
}
}
}
newest.map(|(_, path)| path)
}
fn embed_provisioning_profile(app_path: &Path, profile: &Path) -> Result<(), BenchError> {
let dest = app_path.join("embedded.mobileprovision");
fs::copy(profile, &dest).map_err(|e| {
BenchError::Build(format!(
"Failed to embed provisioning profile at {:?}: {}. Check the profile path and file permissions.",
dest, e
))
})?;
Ok(())
}
fn codesign_bundle(app_path: &Path, identity: &str) -> Result<(), BenchError> {
let output = Command::new("codesign")
.args(["--force", "--deep", "--sign", identity])
.arg(app_path)
.output()
.map_err(|e| {
BenchError::Build(format!(
"Failed to run codesign: {}. Ensure Xcode command line tools are installed.",
e
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BenchError::Build(format!(
"codesign failed: {}. Verify you have a valid signing identity.",
stderr
)));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigningMethod {
AdHoc,
Development,
}
impl IosBuilder {
pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result<PathBuf, BenchError> {
validate_xcode_supports_ios_deployment_target(&self.deployment_target)?;
let ios_dir = self.output_dir.join("ios").join(scheme);
let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
if !project_path.exists() {
return Err(BenchError::Build(format!(
"Xcode project not found at {}.\n\n\
Run `cargo mobench build --target ios` first or check --output-dir.",
project_path.display()
)));
}
let export_path = self.output_dir.join("ios");
let ipa_path = export_path.join(format!("{}.ipa", scheme));
fs::create_dir_all(&export_path).map_err(|e| {
BenchError::Build(format!(
"Failed to create export directory at {}: {}. Check output directory permissions.",
export_path.display(),
e
))
})?;
println!("Building {} for device...", scheme);
let build_dir = self.output_dir.join("ios/build");
let build_configuration = "Release";
let mut cmd = Command::new("xcodebuild");
cmd.arg("-project")
.arg(&project_path)
.arg("-scheme")
.arg(scheme)
.arg("-destination")
.arg("generic/platform=iOS")
.arg("-sdk")
.arg("iphoneos")
.arg("-configuration")
.arg(build_configuration)
.arg("-derivedDataPath")
.arg(&build_dir)
.arg("build");
match method {
SigningMethod::AdHoc => {
cmd.args([
"VALIDATE_PRODUCT=NO",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=",
"CODE_SIGNING_ALLOWED=NO",
"CODE_SIGNING_REQUIRED=NO",
"DEVELOPMENT_TEAM=",
"PROVISIONING_PROFILE_SPECIFIER=",
]);
}
SigningMethod::Development => {
cmd.args([
"CODE_SIGN_STYLE=Automatic",
"CODE_SIGN_IDENTITY=iPhone Developer",
]);
}
}
if self.verbose {
println!(" Running: {:?}", cmd);
}
let build_result = cmd.output();
let app_path = build_dir
.join(format!("Build/Products/{}-iphoneos", build_configuration))
.join(format!("{}.app", scheme));
if !app_path.exists() {
match build_result {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BenchError::Build(format!(
"xcodebuild build failed and app bundle was not created.\n\n\
Project: {}\n\
Scheme: {}\n\
Configuration: {}\n\
Derived data: {}\n\
Exit status: {}\n\n\
Stdout:\n{}\n\n\
Stderr:\n{}\n\n\
Tip: run xcodebuild manually to inspect the failure.",
project_path.display(),
scheme,
build_configuration,
build_dir.display(),
output.status,
stdout,
stderr
)));
}
Err(err) => {
return Err(BenchError::Build(format!(
"Failed to run xcodebuild: {}.\n\n\
App bundle not found at {}.\n\
Check that Xcode command line tools are installed.",
err,
app_path.display()
)));
}
}
}
if self.verbose {
println!(" App bundle created successfully at {:?}", app_path);
}
let build_log_path = export_path.join("ipa-build.log");
if let Ok(output) = &build_result
&& !output.status.success()
{
let mut log = String::new();
log.push_str("STDOUT:\n");
log.push_str(&String::from_utf8_lossy(&output.stdout));
log.push_str("\n\nSTDERR:\n");
log.push_str(&String::from_utf8_lossy(&output.stderr));
let _ = fs::write(&build_log_path, log);
println!(
"Warning: xcodebuild exited with {} but produced {}. Validating the bundle before continuing. Log: {}",
output.status,
app_path.display(),
build_log_path.display()
);
}
let source_info_plist = ios_dir.join(scheme).join("Info.plist");
if let Err(bundle_err) =
self.ensure_device_app_bundle_metadata(&app_path, &source_info_plist, scheme)
{
if let Ok(output) = &build_result
&& !output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BenchError::Build(format!(
"xcodebuild build produced an incomplete app bundle.\n\n\
Project: {}\n\
Scheme: {}\n\
Configuration: {}\n\
Derived data: {}\n\
Exit status: {}\n\
Log: {}\n\n\
Bundle validation: {}\n\n\
Stdout:\n{}\n\n\
Stderr:\n{}",
project_path.display(),
scheme,
build_configuration,
build_dir.display(),
output.status,
build_log_path.display(),
bundle_err,
stdout,
stderr
)));
}
return Err(bundle_err);
}
if matches!(method, SigningMethod::AdHoc) {
let profile = find_provisioning_profile();
let identity = find_codesign_identity();
match (profile.as_ref(), identity.as_ref()) {
(Some(profile), Some(identity)) => {
embed_provisioning_profile(&app_path, profile)?;
codesign_bundle(&app_path, identity)?;
if self.verbose {
println!(" Signed app bundle with identity {}", identity);
}
}
_ => {
let output = Command::new("codesign")
.arg("--force")
.arg("--deep")
.arg("--sign")
.arg("-")
.arg(&app_path)
.output();
match output {
Ok(output) if output.status.success() => {
println!(
"Warning: Signed app bundle without provisioning profile; BrowserStack install may fail."
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Warning: Ad-hoc signing failed: {}", stderr);
}
Err(err) => {
println!("Warning: Could not run codesign: {}", err);
}
}
}
}
}
println!("Creating IPA from app bundle...");
let payload_dir = export_path.join("Payload");
if payload_dir.exists() {
fs::remove_dir_all(&payload_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to remove old Payload dir at {}: {}. Close any tools using it and retry.",
payload_dir.display(),
e
))
})?;
}
fs::create_dir_all(&payload_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to create Payload dir at {}: {}. Check output directory permissions.",
payload_dir.display(),
e
))
})?;
let dest_app = payload_dir.join(format!("{}.app", scheme));
self.copy_bundle_with_ditto(&app_path, &dest_app)?;
if ipa_path.exists() {
fs::remove_file(&ipa_path).map_err(|e| {
BenchError::Build(format!(
"Failed to remove old IPA at {}: {}. Check file permissions.",
ipa_path.display(),
e
))
})?;
}
let mut cmd = Command::new("ditto");
cmd.arg("-c")
.arg("-k")
.arg("--sequesterRsrc")
.arg("--keepParent")
.arg("Payload")
.arg(&ipa_path)
.current_dir(&export_path);
if self.verbose {
println!(" Running: {:?}", cmd);
}
run_command(cmd, "create IPA archive with ditto")?;
self.validate_ipa_archive(&ipa_path, scheme)?;
fs::remove_dir_all(&payload_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to clean up Payload dir at {}: {}. Check file permissions.",
payload_dir.display(),
e
))
})?;
println!("✓ IPA created: {:?}", ipa_path);
Ok(ipa_path)
}
pub fn package_xcuitest(&self, scheme: &str) -> Result<PathBuf, BenchError> {
validate_xcode_supports_ios_deployment_target(&self.deployment_target)?;
let ios_dir = self.output_dir.join("ios").join(scheme);
let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
if !project_path.exists() {
return Err(BenchError::Build(format!(
"Xcode project not found at {}.\n\n\
Run `cargo mobench build --target ios` first or check --output-dir.",
project_path.display()
)));
}
let export_path = self.output_dir.join("ios");
fs::create_dir_all(&export_path).map_err(|e| {
BenchError::Build(format!(
"Failed to create export directory at {}: {}. Check output directory permissions.",
export_path.display(),
e
))
})?;
let build_dir = self.output_dir.join("ios/build");
println!("Building XCUITest runner for {}...", scheme);
let mut cmd = Command::new("xcodebuild");
cmd.arg("build-for-testing")
.arg("-project")
.arg(&project_path)
.arg("-scheme")
.arg(scheme)
.arg("-destination")
.arg("generic/platform=iOS")
.arg("-sdk")
.arg("iphoneos")
.arg("-configuration")
.arg("Release")
.arg("-derivedDataPath")
.arg(&build_dir)
.arg("VALIDATE_PRODUCT=NO")
.arg("CODE_SIGN_STYLE=Manual")
.arg("CODE_SIGN_IDENTITY=")
.arg("CODE_SIGNING_ALLOWED=NO")
.arg("CODE_SIGNING_REQUIRED=NO")
.arg("DEVELOPMENT_TEAM=")
.arg("PROVISIONING_PROFILE_SPECIFIER=")
.arg("ENABLE_BITCODE=NO")
.arg("BITCODE_GENERATION_MODE=none")
.arg("STRIP_BITCODE_FROM_COPIED_FILES=NO");
if self.verbose {
println!(" Running: {:?}", cmd);
}
let runner_name = format!("{}UITests-Runner.app", scheme);
let runner_path = build_dir
.join("Build/Products/Release-iphoneos")
.join(&runner_name);
let build_result = cmd.output();
let log_path = export_path.join("xcuitest-build.log");
if let Ok(output) = &build_result
&& !output.status.success()
{
let mut log = String::new();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
log.push_str("STDOUT:\n");
log.push_str(&stdout);
log.push_str("\n\nSTDERR:\n");
log.push_str(&stderr);
let _ = fs::write(&log_path, log);
println!("xcodebuild log written to {:?}", log_path);
if runner_path.exists() {
println!(
"Warning: xcodebuild build-for-testing failed, but runner exists: {}",
stderr
);
}
}
if !runner_path.exists() {
match build_result {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BenchError::Build(format!(
"xcodebuild build-for-testing failed and runner was not created.\n\n\
Project: {}\n\
Scheme: {}\n\
Derived data: {}\n\
Exit status: {}\n\
Log: {}\n\n\
Stdout:\n{}\n\n\
Stderr:\n{}\n\n\
Tip: open the log file above for more context.",
project_path.display(),
scheme,
build_dir.display(),
output.status,
log_path.display(),
stdout,
stderr
)));
}
Err(err) => {
return Err(BenchError::Build(format!(
"Failed to run xcodebuild: {}.\n\n\
XCUITest runner not found at {}.\n\
Check that Xcode command line tools are installed.",
err,
runner_path.display()
)));
}
}
}
let profile = find_provisioning_profile();
let identity = find_codesign_identity();
if let (Some(profile), Some(identity)) = (profile.as_ref(), identity.as_ref()) {
embed_provisioning_profile(&runner_path, profile)?;
codesign_bundle(&runner_path, identity)?;
if self.verbose {
println!(" Signed XCUITest runner with identity {}", identity);
}
} else {
println!(
"Warning: No provisioning profile/identity found; XCUITest runner may not install."
);
}
let zip_path = export_path.join(format!("{}UITests.zip", scheme));
if zip_path.exists() {
fs::remove_file(&zip_path).map_err(|e| {
BenchError::Build(format!(
"Failed to remove old zip at {}: {}. Check file permissions.",
zip_path.display(),
e
))
})?;
}
let runner_parent = runner_path.parent().ok_or_else(|| {
BenchError::Build(format!(
"Invalid XCUITest runner path with no parent directory: {}",
runner_path.display()
))
})?;
let mut zip_cmd = Command::new("zip");
zip_cmd
.arg("-qr")
.arg(&zip_path)
.arg(&runner_name)
.current_dir(runner_parent);
if self.verbose {
println!(" Running: {:?}", zip_cmd);
}
run_command(zip_cmd, "zip XCUITest runner")?;
println!("✓ XCUITest runner packaged: {:?}", zip_path);
Ok(zip_path)
}
fn copy_bundle_with_ditto(&self, src: &Path, dest: &Path) -> Result<(), BenchError> {
let mut cmd = Command::new("ditto");
cmd.arg(src).arg(dest);
if self.verbose {
println!(" Running: {:?}", cmd);
}
run_command(cmd, "copy app bundle with ditto")
}
fn ensure_device_app_bundle_metadata(
&self,
app_path: &Path,
source_info_plist: &Path,
scheme: &str,
) -> Result<(), BenchError> {
let bundled_info_plist = app_path.join("Info.plist");
if !bundled_info_plist.is_file() {
if !source_info_plist.is_file() {
return Err(BenchError::Build(format!(
"Built app bundle at {} is missing Info.plist, and the generated source plist was not found at {}.\n\n\
The device build produced an incomplete .app bundle, so packaging cannot continue.",
app_path.display(),
source_info_plist.display()
)));
}
fs::copy(source_info_plist, &bundled_info_plist).map_err(|e| {
BenchError::Build(format!(
"Built app bundle at {} is missing Info.plist, and restoring it from {} failed: {}.",
app_path.display(),
source_info_plist.display(),
e
))
})?;
println!(
"Warning: Restored missing Info.plist into built app bundle from {}.",
source_info_plist.display()
);
}
let executable = app_path.join(scheme);
if !executable.is_file() {
return Err(BenchError::Build(format!(
"Built app bundle at {} is missing the expected executable {}.\n\n\
The device build produced an incomplete .app bundle, so packaging cannot continue.",
app_path.display(),
executable.display()
)));
}
Ok(())
}
fn validate_ipa_archive(&self, ipa_path: &Path, scheme: &str) -> Result<(), BenchError> {
let extract_root = env::temp_dir().join(format!(
"mobench-ipa-validate-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
if extract_root.exists() {
fs::remove_dir_all(&extract_root).map_err(|e| {
BenchError::Build(format!(
"Failed to clear IPA validation dir at {}: {}",
extract_root.display(),
e
))
})?;
}
fs::create_dir_all(&extract_root).map_err(|e| {
BenchError::Build(format!(
"Failed to create IPA validation dir at {}: {}",
extract_root.display(),
e
))
})?;
let mut extract = Command::new("ditto");
extract.arg("-x").arg("-k").arg(ipa_path).arg(&extract_root);
let extract_result = run_command(extract, "extract IPA for validation");
if let Err(err) = extract_result {
let _ = fs::remove_dir_all(&extract_root);
return Err(err);
}
let info_plist = extract_root
.join("Payload")
.join(format!("{}.app", scheme))
.join("Info.plist");
let validation_result = if info_plist.is_file() {
Ok(())
} else {
Err(BenchError::Build(format!(
"IPA validation failed: {} is missing from {}.\n\n\
The packaged IPA does not contain a valid iOS app bundle. \
BrowserStack will reject this upload.",
info_plist
.strip_prefix(&extract_root)
.unwrap_or(&info_plist)
.display(),
ipa_path.display()
)))
};
let _ = fs::remove_dir_all(&extract_root);
validation_result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "macos")]
use std::io::Write;
#[test]
fn test_ios_builder_creation() {
let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
assert!(!builder.verbose);
assert_eq!(
builder.output_dir,
PathBuf::from("/tmp/test-project/target/mobench")
);
}
#[test]
fn test_ios_builder_verbose() {
let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
assert!(builder.verbose);
}
#[test]
fn test_ios_builder_custom_output_dir() {
let builder =
IosBuilder::new("/tmp/test-project", "test-bench-mobile").output_dir("/custom/output");
assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
}
#[test]
fn parse_xcode_version_reads_major_minor() {
let parsed = parse_xcode_version("Xcode 16.2\nBuild version 16C5032a\n").unwrap();
assert_eq!(parsed.major, 16);
assert_eq!(parsed.minor, 2);
assert_eq!(parsed.raw, "16.2");
}
#[test]
fn xcode_floor_rejects_legacy_target_on_current_lane() {
let xcode = XcodeVersion {
major: 16,
minor: 2,
raw: "16.2".to_string(),
};
let floor = minimum_supported_ios_deployment_target_for_xcode(&xcode).unwrap();
assert_eq!(floor.to_string(), "15.0");
assert!(IosDeploymentTarget::parse("10.0").unwrap() < floor);
}
#[cfg(target_os = "macos")]
#[test]
fn test_validate_ipa_archive_rejects_missing_info_plist() {
let temp_dir = env::temp_dir().join(format!(
"mobench-ios-test-bad-ipa-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let payload = temp_dir.join("Payload/BenchRunner.app");
fs::create_dir_all(&payload).expect("create payload");
let ipa = temp_dir.join("broken.ipa");
let status = Command::new("ditto")
.arg("-c")
.arg("-k")
.arg("--sequesterRsrc")
.arg("--keepParent")
.arg("Payload")
.arg(&ipa)
.current_dir(&temp_dir)
.status()
.expect("run ditto");
assert!(status.success(), "ditto should create the broken test ipa");
let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
let err = builder
.validate_ipa_archive(&ipa, "BenchRunner")
.expect_err("IPA missing Info.plist should be rejected");
assert!(
err.to_string().contains("Info.plist"),
"expected validation error mentioning Info.plist, got: {err}"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[cfg(target_os = "macos")]
#[test]
fn test_validate_ipa_archive_accepts_payload_with_info_plist() {
let temp_dir = env::temp_dir().join(format!(
"mobench-ios-test-good-ipa-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let payload = temp_dir.join("Payload/BenchRunner.app");
fs::create_dir_all(&payload).expect("create payload");
let mut info = fs::File::create(payload.join("Info.plist")).expect("create plist");
writeln!(
info,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>"
)
.expect("write plist");
let ipa = temp_dir.join("valid.ipa");
let status = Command::new("ditto")
.arg("-c")
.arg("-k")
.arg("--sequesterRsrc")
.arg("--keepParent")
.arg("Payload")
.arg(&ipa)
.current_dir(&temp_dir)
.status()
.expect("run ditto");
assert!(status.success(), "ditto should create the valid test ipa");
let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
builder
.validate_ipa_archive(&ipa, "BenchRunner")
.expect("IPA with Info.plist should validate");
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_ensure_device_app_bundle_metadata_restores_missing_info_plist() {
let temp_dir = env::temp_dir().join(format!(
"mobench-ios-test-repair-plist-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app");
fs::create_dir_all(&app_dir).expect("create app dir");
fs::write(app_dir.join("BenchRunner"), "bin").expect("create executable");
let source_dir = temp_dir.join("BenchRunner");
fs::create_dir_all(&source_dir).expect("create source dir");
fs::write(
source_dir.join("Info.plist"),
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
)
.expect("create source plist");
let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
builder
.ensure_device_app_bundle_metadata(
&app_dir,
&source_dir.join("Info.plist"),
"BenchRunner",
)
.expect("missing plist should be restored");
assert!(
app_dir.join("Info.plist").is_file(),
"restored app bundle should contain Info.plist"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_ensure_device_app_bundle_metadata_rejects_missing_executable() {
let temp_dir = env::temp_dir().join(format!(
"mobench-ios-test-missing-exec-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app");
fs::create_dir_all(&app_dir).expect("create app dir");
fs::write(
app_dir.join("Info.plist"),
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
)
.expect("create bundled plist");
let source_dir = temp_dir.join("BenchRunner");
fs::create_dir_all(&source_dir).expect("create source dir");
fs::write(
source_dir.join("Info.plist"),
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
)
.expect("create source plist");
let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
let err = builder
.ensure_device_app_bundle_metadata(
&app_dir,
&source_dir.join("Info.plist"),
"BenchRunner",
)
.expect_err("missing executable should fail validation");
assert!(
err.to_string().contains("missing the expected executable"),
"expected executable validation error, got: {err}"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_find_crate_dir_current_directory_is_crate() {
let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-current");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::write(
temp_dir.join("Cargo.toml"),
r#"[package]
name = "bench-mobile"
version = "0.1.0"
"#,
)
.unwrap();
let builder = IosBuilder::new(&temp_dir, "bench-mobile");
let result = builder.find_crate_dir();
assert!(result.is_ok(), "Should find crate in current directory");
let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone());
assert_eq!(result.unwrap(), expected);
std::fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_find_crate_dir_nested_bench_mobile() {
let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-nested");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
std::fs::write(
temp_dir.join("Cargo.toml"),
r#"[workspace]
members = ["bench-mobile"]
"#,
)
.unwrap();
std::fs::write(
temp_dir.join("bench-mobile/Cargo.toml"),
r#"[package]
name = "bench-mobile"
version = "0.1.0"
"#,
)
.unwrap();
let builder = IosBuilder::new(&temp_dir, "bench-mobile");
let result = builder.find_crate_dir();
assert!(
result.is_ok(),
"Should find crate in bench-mobile/ directory"
);
let expected = temp_dir
.canonicalize()
.unwrap_or(temp_dir.clone())
.join("bench-mobile");
assert_eq!(result.unwrap(), expected);
std::fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_find_crate_dir_crates_subdir() {
let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-crates");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
std::fs::write(
temp_dir.join("Cargo.toml"),
r#"[workspace]
members = ["crates/*"]
"#,
)
.unwrap();
std::fs::write(
temp_dir.join("crates/my-bench/Cargo.toml"),
r#"[package]
name = "my-bench"
version = "0.1.0"
"#,
)
.unwrap();
let builder = IosBuilder::new(&temp_dir, "my-bench");
let result = builder.find_crate_dir();
assert!(result.is_ok(), "Should find crate in crates/ directory");
let expected = temp_dir
.canonicalize()
.unwrap_or(temp_dir.clone())
.join("crates/my-bench");
assert_eq!(result.unwrap(), expected);
std::fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_find_crate_dir_not_found() {
let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-notfound");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::write(
temp_dir.join("Cargo.toml"),
r#"[package]
name = "some-other-crate"
version = "0.1.0"
"#,
)
.unwrap();
let builder = IosBuilder::new(&temp_dir, "nonexistent-crate");
let result = builder.find_crate_dir();
assert!(result.is_err(), "Should fail to find nonexistent crate");
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
assert!(err_msg.contains("Searched locations"));
std::fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_find_crate_dir_explicit_crate_path() {
let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-explicit");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
let builder =
IosBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location"));
let result = builder.find_crate_dir();
assert!(result.is_ok(), "Should use explicit crate_dir");
assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
std::fs::remove_dir_all(&temp_dir).unwrap();
}
}