1use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root};
74use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
75use std::env;
76use std::fs;
77use std::path::{Path, PathBuf};
78use std::process::Command;
79use std::time::{SystemTime, UNIX_EPOCH};
80
81pub struct IosBuilder {
110 project_root: PathBuf,
112 output_dir: PathBuf,
114 crate_name: String,
116 verbose: bool,
118 crate_dir: Option<PathBuf>,
120 dry_run: bool,
122}
123
124impl IosBuilder {
125 pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
134 let root_input = project_root.into();
135 let root = match root_input.canonicalize() {
139 Ok(path) => path,
140 Err(err) => {
141 eprintln!(
142 "Warning: failed to canonicalize project root `{}`: {}. Using provided path.",
143 root_input.display(),
144 err
145 );
146 root_input
147 }
148 };
149 Self {
150 output_dir: root.join("target/mobench"),
151 project_root: root,
152 crate_name: crate_name.into(),
153 verbose: false,
154 crate_dir: None,
155 dry_run: false,
156 }
157 }
158
159 pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
164 self.output_dir = dir.into();
165 self
166 }
167
168 pub fn crate_dir(mut self, dir: impl Into<PathBuf>) -> Self {
178 self.crate_dir = Some(dir.into());
179 self
180 }
181
182 pub fn verbose(mut self, verbose: bool) -> Self {
184 self.verbose = verbose;
185 self
186 }
187
188 pub fn dry_run(mut self, dry_run: bool) -> Self {
193 self.dry_run = dry_run;
194 self
195 }
196
197 pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
212 if self.crate_dir.is_none() {
214 validate_project_root(&self.project_root, &self.crate_name)?;
215 }
216
217 let framework_name = self.crate_name.replace("-", "_");
218 let ios_dir = self.output_dir.join("ios");
219 let xcframework_path = ios_dir.join(format!("{}.xcframework", framework_name));
220
221 if self.dry_run {
222 println!("\n[dry-run] iOS build plan:");
223 println!(
224 " Step 0: Check/generate iOS project scaffolding at {:?}",
225 ios_dir.join("BenchRunner")
226 );
227 println!(" Step 1: Build Rust libraries for iOS targets");
228 println!(
229 " Command: cargo build --target aarch64-apple-ios --lib {}",
230 if matches!(config.profile, BuildProfile::Release) {
231 "--release"
232 } else {
233 ""
234 }
235 );
236 println!(
237 " Command: cargo build --target aarch64-apple-ios-sim --lib {}",
238 if matches!(config.profile, BuildProfile::Release) {
239 "--release"
240 } else {
241 ""
242 }
243 );
244 println!(
245 " Command: cargo build --target x86_64-apple-ios --lib {}",
246 if matches!(config.profile, BuildProfile::Release) {
247 "--release"
248 } else {
249 ""
250 }
251 );
252 println!(" Step 2: Generate UniFFI Swift bindings");
253 println!(
254 " Output: {:?}",
255 ios_dir.join("BenchRunner/BenchRunner/Generated")
256 );
257 println!(" Step 3: Create xcframework at {:?}", xcframework_path);
258 println!(" - ios-arm64/{}.framework (device)", framework_name);
259 println!(
260 " - ios-arm64_x86_64-simulator/{}.framework (simulator - arm64 + x86_64 lipo)",
261 framework_name
262 );
263 println!(" Step 4: Code-sign xcframework");
264 println!(
265 " Command: codesign --force --deep --sign - {:?}",
266 xcframework_path
267 );
268 println!(" Step 5: Generate Xcode project with xcodegen (if project.yml exists)");
269 println!(" Command: xcodegen generate");
270
271 return Ok(BuildResult {
273 platform: Target::Ios,
274 app_path: xcframework_path,
275 test_suite_path: None,
276 native_libraries: Vec::new(),
277 });
278 }
279
280 crate::codegen::ensure_ios_project_with_options(
283 &self.output_dir,
284 &self.crate_name,
285 Some(&self.project_root),
286 self.crate_dir.as_deref(),
287 )?;
288
289 println!("Building Rust libraries for iOS...");
291 self.build_rust_libraries(config)?;
292
293 println!("Generating UniFFI Swift bindings...");
295 self.generate_uniffi_bindings()?;
296
297 println!("Creating xcframework...");
299 let xcframework_path = self.create_xcframework(config)?;
300
301 println!("Code-signing xcframework...");
303 self.codesign_xcframework(&xcframework_path)?;
304
305 let header_src = self
307 .find_uniffi_header(&format!("{}FFI.h", framework_name))
308 .ok_or_else(|| {
309 BenchError::Build(format!(
310 "UniFFI header {}FFI.h not found after generation",
311 framework_name
312 ))
313 })?;
314 let include_dir = self.output_dir.join("ios/include");
315 fs::create_dir_all(&include_dir).map_err(|e| {
316 BenchError::Build(format!(
317 "Failed to create include dir at {}: {}. Check output directory permissions.",
318 include_dir.display(),
319 e
320 ))
321 })?;
322 let header_dest = include_dir.join(format!("{}.h", framework_name));
323 fs::copy(&header_src, &header_dest).map_err(|e| {
324 BenchError::Build(format!(
325 "Failed to copy UniFFI header to {:?}: {}. Check output directory permissions.",
326 header_dest, e
327 ))
328 })?;
329
330 self.generate_xcode_project()?;
332
333 let result = BuildResult {
335 platform: Target::Ios,
336 app_path: xcframework_path,
337 test_suite_path: None,
338 native_libraries: Vec::new(),
339 };
340 self.validate_build_artifacts(&result, config)?;
341
342 Ok(result)
343 }
344
345 fn validate_build_artifacts(
347 &self,
348 result: &BuildResult,
349 config: &BuildConfig,
350 ) -> Result<(), BenchError> {
351 let mut missing = Vec::new();
352 let framework_name = self.crate_name.replace("-", "_");
353 let profile_dir = match config.profile {
354 BuildProfile::Debug => "debug",
355 BuildProfile::Release => "release",
356 };
357
358 if !result.app_path.exists() {
360 missing.push(format!("XCFramework: {}", result.app_path.display()));
361 }
362
363 let xcframework_path = &result.app_path;
365 let device_slice = xcframework_path.join(format!("ios-arm64/{}.framework", framework_name));
366 let sim_slice = xcframework_path.join(format!(
368 "ios-arm64_x86_64-simulator/{}.framework",
369 framework_name
370 ));
371
372 if xcframework_path.exists() {
373 if !device_slice.exists() {
374 missing.push(format!(
375 "Device framework slice: {}",
376 device_slice.display()
377 ));
378 }
379 if !sim_slice.exists() {
380 missing.push(format!(
381 "Simulator framework slice (arm64+x86_64): {}",
382 sim_slice.display()
383 ));
384 }
385 }
386
387 let crate_dir = self.find_crate_dir()?;
389 let target_dir = get_cargo_target_dir(&crate_dir)?;
390 let lib_name = format!("lib{}.a", framework_name);
391
392 let device_lib = target_dir
393 .join("aarch64-apple-ios")
394 .join(profile_dir)
395 .join(&lib_name);
396 let sim_arm64_lib = target_dir
397 .join("aarch64-apple-ios-sim")
398 .join(profile_dir)
399 .join(&lib_name);
400 let sim_x86_64_lib = target_dir
401 .join("x86_64-apple-ios")
402 .join(profile_dir)
403 .join(&lib_name);
404
405 if !device_lib.exists() {
406 missing.push(format!("Device static library: {}", device_lib.display()));
407 }
408 if !sim_arm64_lib.exists() {
409 missing.push(format!(
410 "Simulator (arm64) static library: {}",
411 sim_arm64_lib.display()
412 ));
413 }
414 if !sim_x86_64_lib.exists() {
415 missing.push(format!(
416 "Simulator (x86_64) static library: {}",
417 sim_x86_64_lib.display()
418 ));
419 }
420
421 let swift_bindings = self
423 .output_dir
424 .join("ios/BenchRunner/BenchRunner/Generated")
425 .join(format!("{}.swift", framework_name));
426 if !swift_bindings.exists() {
427 missing.push(format!("Swift bindings: {}", swift_bindings.display()));
428 }
429
430 if !missing.is_empty() {
431 let critical = missing
432 .iter()
433 .any(|m| m.contains("XCFramework") || m.contains("static library"));
434 if critical {
435 return Err(BenchError::Build(format!(
436 "Build validation failed: Critical artifacts are missing.\n\n\
437 Missing artifacts:\n{}\n\n\
438 This usually means the Rust build step failed. Check the cargo build output above.",
439 missing
440 .iter()
441 .map(|s| format!(" - {}", s))
442 .collect::<Vec<_>>()
443 .join("\n")
444 )));
445 } else {
446 eprintln!(
447 "Warning: Some build artifacts are missing:\n{}\n\
448 The build may still work but some features might be unavailable.",
449 missing
450 .iter()
451 .map(|s| format!(" - {}", s))
452 .collect::<Vec<_>>()
453 .join("\n")
454 );
455 }
456 }
457
458 Ok(())
459 }
460
461 fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
469 if let Some(ref dir) = self.crate_dir {
471 if dir.exists() {
472 return Ok(dir.clone());
473 }
474 return Err(BenchError::Build(format!(
475 "Specified crate path does not exist: {:?}.\n\n\
476 Tip: pass --crate-path pointing at a directory containing Cargo.toml.",
477 dir
478 )));
479 }
480
481 let root_cargo_toml = self.project_root.join("Cargo.toml");
484 if root_cargo_toml.exists()
485 && let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml)
486 && pkg_name == self.crate_name
487 {
488 return Ok(self.project_root.clone());
489 }
490
491 let bench_mobile_dir = self.project_root.join("bench-mobile");
493 if bench_mobile_dir.exists() {
494 return Ok(bench_mobile_dir);
495 }
496
497 let crates_dir = self.project_root.join("crates").join(&self.crate_name);
499 if crates_dir.exists() {
500 return Ok(crates_dir);
501 }
502
503 let named_dir = self.project_root.join(&self.crate_name);
505 if named_dir.exists() {
506 return Ok(named_dir);
507 }
508
509 let root_manifest = root_cargo_toml;
510 let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
511 let crates_manifest = crates_dir.join("Cargo.toml");
512 let named_manifest = named_dir.join("Cargo.toml");
513 Err(BenchError::Build(format!(
514 "Benchmark crate '{}' not found.\n\n\
515 Searched locations:\n\
516 - {} (checked [package] name)\n\
517 - {}\n\
518 - {}\n\
519 - {}\n\n\
520 To fix this:\n\
521 1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
522 2. Create a bench-mobile/ directory with your benchmark crate, or\n\
523 3. Use --crate-path to specify the benchmark crate location:\n\
524 cargo mobench build --target ios --crate-path ./my-benchmarks\n\n\
525 Common issues:\n\
526 - Typo in crate name (check Cargo.toml [package] name)\n\
527 - Wrong working directory (run from project root)\n\
528 - Missing Cargo.toml in the crate directory\n\n\
529 Run 'cargo mobench init --help' to generate a new benchmark project.",
530 self.crate_name,
531 root_manifest.display(),
532 bench_mobile_manifest.display(),
533 crates_manifest.display(),
534 named_manifest.display(),
535 self.crate_name,
536 )))
537 }
538
539 fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
541 let crate_dir = self.find_crate_dir()?;
542
543 let targets = vec![
545 "aarch64-apple-ios", "aarch64-apple-ios-sim", "x86_64-apple-ios", ];
549
550 self.check_rust_targets(&targets)?;
552 let release_flag = if matches!(config.profile, BuildProfile::Release) {
553 "--release"
554 } else {
555 ""
556 };
557
558 for target in targets {
559 if self.verbose {
560 println!(" Building for {}", target);
561 }
562
563 let mut cmd = Command::new("cargo");
564 cmd.arg("build").arg("--target").arg(target).arg("--lib");
565
566 if !release_flag.is_empty() {
568 cmd.arg(release_flag);
569 }
570
571 cmd.current_dir(&crate_dir);
573
574 let command_hint = if release_flag.is_empty() {
576 format!("cargo build --target {} --lib", target)
577 } else {
578 format!("cargo build --target {} --lib {}", target, release_flag)
579 };
580 let output = cmd.output().map_err(|e| {
581 BenchError::Build(format!(
582 "Failed to run cargo for {}.\n\n\
583 Command: {}\n\
584 Crate directory: {}\n\
585 Error: {}\n\n\
586 Tip: ensure cargo is installed and on PATH.",
587 target,
588 command_hint,
589 crate_dir.display(),
590 e
591 ))
592 })?;
593
594 if !output.status.success() {
595 let stdout = String::from_utf8_lossy(&output.stdout);
596 let stderr = String::from_utf8_lossy(&output.stderr);
597 return Err(BenchError::Build(format!(
598 "cargo build failed for {}.\n\n\
599 Command: {}\n\
600 Crate directory: {}\n\
601 Exit status: {}\n\n\
602 Stdout:\n{}\n\n\
603 Stderr:\n{}\n\n\
604 Tips:\n\
605 - Ensure Xcode command line tools are installed (xcode-select --install)\n\
606 - Confirm Rust targets are installed (rustup target add {})",
607 target,
608 command_hint,
609 crate_dir.display(),
610 output.status,
611 stdout,
612 stderr,
613 target
614 )));
615 }
616 }
617
618 Ok(())
619 }
620
621 fn check_rust_targets(&self, targets: &[&str]) -> Result<(), BenchError> {
627 let sysroot = Command::new("rustc")
628 .args(["--print", "sysroot"])
629 .output()
630 .ok()
631 .and_then(|o| {
632 if o.status.success() {
633 String::from_utf8(o.stdout).ok()
634 } else {
635 None
636 }
637 })
638 .map(|s| s.trim().to_string());
639
640 for target in targets {
641 let installed = if let Some(ref root) = sysroot {
642 let lib_dir =
644 std::path::Path::new(root).join(format!("lib/rustlib/{}/lib", target));
645 lib_dir.exists()
646 } else {
647 let output = Command::new("rustup")
649 .args(["target", "list", "--installed"])
650 .output()
651 .ok();
652 output
653 .map(|o| String::from_utf8_lossy(&o.stdout).contains(target))
654 .unwrap_or(false)
655 };
656
657 if !installed {
658 return Err(BenchError::Build(format!(
659 "Rust target '{}' is not installed.\n\n\
660 This target is required to compile for iOS.\n\n\
661 To install:\n\
662 rustup target add {}\n\n\
663 For a complete iOS setup, you need all three:\n\
664 rustup target add aarch64-apple-ios # Device\n\
665 rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)\n\
666 rustup target add x86_64-apple-ios # Simulator (Intel Macs)",
667 target, target
668 )));
669 }
670 }
671
672 Ok(())
673 }
674
675 fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
677 let crate_dir = self.find_crate_dir()?;
678 let crate_name_underscored = self.crate_name.replace("-", "_");
679
680 let bindings_path = self
683 .output_dir
684 .join("ios")
685 .join("BenchRunner")
686 .join("BenchRunner")
687 .join("Generated")
688 .join(format!("{}.swift", crate_name_underscored));
689 let had_existing_bindings = bindings_path.exists();
690 if had_existing_bindings && self.verbose {
691 println!(
692 " Found existing Swift bindings at {:?}; regenerating to keep the UniFFI schema current",
693 bindings_path
694 );
695 }
696
697 let mut build_cmd = Command::new("cargo");
699 build_cmd.arg("build");
700 build_cmd.current_dir(&crate_dir);
701 run_command(build_cmd, "cargo build (host)")?;
702
703 let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
704 let out_dir = self
705 .output_dir
706 .join("ios")
707 .join("BenchRunner")
708 .join("BenchRunner")
709 .join("Generated");
710 fs::create_dir_all(&out_dir).map_err(|e| {
711 BenchError::Build(format!(
712 "Failed to create Swift bindings dir at {}: {}. Check output directory permissions.",
713 out_dir.display(),
714 e
715 ))
716 })?;
717
718 let cargo_run_result = Command::new("cargo")
720 .args([
721 "run",
722 "-p",
723 &self.crate_name,
724 "--bin",
725 "uniffi-bindgen",
726 "--",
727 ])
728 .arg("generate")
729 .arg("--library")
730 .arg(&lib_path)
731 .arg("--language")
732 .arg("swift")
733 .arg("--out-dir")
734 .arg(&out_dir)
735 .current_dir(&crate_dir)
736 .output();
737
738 let use_cargo_run = cargo_run_result
739 .as_ref()
740 .map(|o| o.status.success())
741 .unwrap_or(false);
742
743 if use_cargo_run {
744 if self.verbose {
745 println!(" Generated bindings using cargo run uniffi-bindgen");
746 }
747 } else {
748 let uniffi_available = Command::new("uniffi-bindgen")
750 .arg("--version")
751 .output()
752 .map(|o| o.status.success())
753 .unwrap_or(false);
754
755 if !uniffi_available {
756 if had_existing_bindings {
757 if self.verbose {
758 println!(
759 " Warning: uniffi-bindgen is unavailable; keeping existing Swift bindings at {:?}",
760 bindings_path
761 );
762 }
763 return Ok(());
764 }
765 return Err(BenchError::Build(
766 "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
767 To fix this, either:\n\
768 1. Add a uniffi-bindgen binary to your crate:\n\
769 [[bin]]\n\
770 name = \"uniffi-bindgen\"\n\
771 path = \"src/bin/uniffi-bindgen.rs\"\n\n\
772 2. Or install a matching uniffi-bindgen CLI globally:\n\
773 cargo install --git https://github.com/mozilla/uniffi-rs --tag <uniffi-tag> uniffi-bindgen-cli --bin uniffi-bindgen\n\n\
774 3. Or pre-generate bindings and commit them."
775 .to_string(),
776 ));
777 }
778
779 let mut cmd = Command::new("uniffi-bindgen");
780 cmd.arg("generate")
781 .arg("--library")
782 .arg(&lib_path)
783 .arg("--language")
784 .arg("swift")
785 .arg("--out-dir")
786 .arg(&out_dir);
787 if let Err(error) = run_command(cmd, "uniffi-bindgen swift") {
788 if had_existing_bindings {
789 if self.verbose {
790 println!(
791 " Warning: failed to regenerate Swift bindings ({error}); keeping existing bindings at {:?}",
792 bindings_path
793 );
794 }
795 return Ok(());
796 }
797 return Err(error);
798 }
799 }
800
801 if self.verbose {
802 println!(" Generated UniFFI Swift bindings at {:?}", out_dir);
803 }
804
805 Ok(())
806 }
807
808 fn create_xcframework(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
810 let profile_dir = match config.profile {
811 BuildProfile::Debug => "debug",
812 BuildProfile::Release => "release",
813 };
814
815 let crate_dir = self.find_crate_dir()?;
816 let target_dir = get_cargo_target_dir(&crate_dir)?;
817 let xcframework_dir = self.output_dir.join("ios");
818 let framework_name = &self.crate_name.replace("-", "_");
819 let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name));
820
821 if xcframework_path.exists() {
823 fs::remove_dir_all(&xcframework_path).map_err(|e| {
824 BenchError::Build(format!(
825 "Failed to remove old xcframework at {}: {}. Close any tools using it and retry.",
826 xcframework_path.display(),
827 e
828 ))
829 })?;
830 }
831
832 fs::create_dir_all(&xcframework_dir).map_err(|e| {
834 BenchError::Build(format!(
835 "Failed to create xcframework directory at {}: {}. Check output directory permissions.",
836 xcframework_dir.display(),
837 e
838 ))
839 })?;
840
841 self.create_framework_slice(
844 &target_dir.join("aarch64-apple-ios").join(profile_dir),
845 &xcframework_path.join("ios-arm64"),
846 framework_name,
847 "ios",
848 )?;
849
850 self.create_simulator_framework_slice(
852 &target_dir,
853 profile_dir,
854 &xcframework_path.join("ios-arm64_x86_64-simulator"),
855 framework_name,
856 )?;
857
858 self.create_xcframework_plist(&xcframework_path, framework_name)?;
860
861 Ok(xcframework_path)
862 }
863
864 fn create_framework_slice(
866 &self,
867 lib_path: &Path,
868 output_dir: &Path,
869 framework_name: &str,
870 platform: &str,
871 ) -> Result<(), BenchError> {
872 let framework_dir = output_dir.join(format!("{}.framework", framework_name));
873 let headers_dir = framework_dir.join("Headers");
874
875 fs::create_dir_all(&headers_dir).map_err(|e| {
877 BenchError::Build(format!(
878 "Failed to create framework directories at {}: {}. Check output directory permissions.",
879 headers_dir.display(),
880 e
881 ))
882 })?;
883
884 let src_lib = lib_path.join(format!("lib{}.a", framework_name));
886 let dest_lib = framework_dir.join(framework_name);
887
888 if !src_lib.exists() {
889 return Err(BenchError::Build(format!(
890 "Static library not found at {}.\n\n\
891 Expected output from cargo build --target <target> --lib.\n\
892 Ensure your crate has [lib] crate-type = [\"staticlib\"].",
893 src_lib.display()
894 )));
895 }
896
897 fs::copy(&src_lib, &dest_lib).map_err(|e| {
898 BenchError::Build(format!(
899 "Failed to copy static library from {} to {}: {}. Check output directory permissions.",
900 src_lib.display(),
901 dest_lib.display(),
902 e
903 ))
904 })?;
905
906 let header_name = format!("{}FFI.h", framework_name);
908 let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
909 BenchError::Build(format!(
910 "UniFFI header {} not found; run binding generation before building",
911 header_name
912 ))
913 })?;
914 let dest_header = headers_dir.join(&header_name);
915 fs::copy(&header_path, &dest_header).map_err(|e| {
916 BenchError::Build(format!(
917 "Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.",
918 header_path.display(),
919 dest_header.display(),
920 e
921 ))
922 })?;
923
924 let modulemap_content = format!(
926 "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}",
927 framework_name, framework_name
928 );
929 let modulemap_path = headers_dir.join("module.modulemap");
930 fs::write(&modulemap_path, modulemap_content).map_err(|e| {
931 BenchError::Build(format!(
932 "Failed to write module.modulemap at {}: {}. Check output directory permissions.",
933 modulemap_path.display(),
934 e
935 ))
936 })?;
937
938 self.create_framework_plist(&framework_dir, framework_name, platform)?;
940
941 Ok(())
942 }
943
944 fn create_simulator_framework_slice(
946 &self,
947 target_dir: &Path,
948 profile_dir: &str,
949 output_dir: &Path,
950 framework_name: &str,
951 ) -> Result<(), BenchError> {
952 let framework_dir = output_dir.join(format!("{}.framework", framework_name));
953 let headers_dir = framework_dir.join("Headers");
954
955 fs::create_dir_all(&headers_dir).map_err(|e| {
957 BenchError::Build(format!(
958 "Failed to create framework directories at {}: {}. Check output directory permissions.",
959 headers_dir.display(),
960 e
961 ))
962 })?;
963
964 let arm64_lib = target_dir
966 .join("aarch64-apple-ios-sim")
967 .join(profile_dir)
968 .join(format!("lib{}.a", framework_name));
969 let x86_64_lib = target_dir
970 .join("x86_64-apple-ios")
971 .join(profile_dir)
972 .join(format!("lib{}.a", framework_name));
973
974 if !arm64_lib.exists() {
976 return Err(BenchError::Build(format!(
977 "Simulator library (arm64) not found at {}.\n\n\
978 Expected output from cargo build --target aarch64-apple-ios-sim --lib.\n\
979 Ensure your crate has [lib] crate-type = [\"staticlib\"].",
980 arm64_lib.display()
981 )));
982 }
983 if !x86_64_lib.exists() {
984 return Err(BenchError::Build(format!(
985 "Simulator library (x86_64) not found at {}.\n\n\
986 Expected output from cargo build --target x86_64-apple-ios --lib.\n\
987 Ensure your crate has [lib] crate-type = [\"staticlib\"].",
988 x86_64_lib.display()
989 )));
990 }
991
992 let dest_lib = framework_dir.join(framework_name);
994 let output = Command::new("lipo")
995 .arg("-create")
996 .arg(&arm64_lib)
997 .arg(&x86_64_lib)
998 .arg("-output")
999 .arg(&dest_lib)
1000 .output()
1001 .map_err(|e| {
1002 BenchError::Build(format!(
1003 "Failed to run lipo to create universal simulator binary.\n\n\
1004 Command: lipo -create {} {} -output {}\n\
1005 Error: {}\n\n\
1006 Ensure Xcode command line tools are installed: xcode-select --install",
1007 arm64_lib.display(),
1008 x86_64_lib.display(),
1009 dest_lib.display(),
1010 e
1011 ))
1012 })?;
1013
1014 if !output.status.success() {
1015 let stderr = String::from_utf8_lossy(&output.stderr);
1016 return Err(BenchError::Build(format!(
1017 "lipo failed to create universal simulator binary.\n\n\
1018 Command: lipo -create {} {} -output {}\n\
1019 Exit status: {}\n\
1020 Stderr: {}\n\n\
1021 Ensure both libraries are valid static libraries.",
1022 arm64_lib.display(),
1023 x86_64_lib.display(),
1024 dest_lib.display(),
1025 output.status,
1026 stderr
1027 )));
1028 }
1029
1030 if self.verbose {
1031 println!(
1032 " Created universal simulator binary (arm64 + x86_64) at {:?}",
1033 dest_lib
1034 );
1035 }
1036
1037 let header_name = format!("{}FFI.h", framework_name);
1039 let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
1040 BenchError::Build(format!(
1041 "UniFFI header {} not found; run binding generation before building",
1042 header_name
1043 ))
1044 })?;
1045 let dest_header = headers_dir.join(&header_name);
1046 fs::copy(&header_path, &dest_header).map_err(|e| {
1047 BenchError::Build(format!(
1048 "Failed to copy UniFFI header from {} to {}: {}. Check output directory permissions.",
1049 header_path.display(),
1050 dest_header.display(),
1051 e
1052 ))
1053 })?;
1054
1055 let modulemap_content = format!(
1057 "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}",
1058 framework_name, framework_name
1059 );
1060 let modulemap_path = headers_dir.join("module.modulemap");
1061 fs::write(&modulemap_path, modulemap_content).map_err(|e| {
1062 BenchError::Build(format!(
1063 "Failed to write module.modulemap at {}: {}. Check output directory permissions.",
1064 modulemap_path.display(),
1065 e
1066 ))
1067 })?;
1068
1069 self.create_framework_plist(&framework_dir, framework_name, "ios-simulator")?;
1071
1072 Ok(())
1073 }
1074
1075 fn create_framework_plist(
1077 &self,
1078 framework_dir: &Path,
1079 framework_name: &str,
1080 platform: &str,
1081 ) -> Result<(), BenchError> {
1082 let bundle_id: String = framework_name
1085 .chars()
1086 .filter(|c| c.is_ascii_alphanumeric())
1087 .collect::<String>()
1088 .to_lowercase();
1089 let plist_content = format!(
1090 r#"<?xml version="1.0" encoding="UTF-8"?>
1091<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1092<plist version="1.0">
1093<dict>
1094 <key>CFBundleExecutable</key>
1095 <string>{}</string>
1096 <key>CFBundleIdentifier</key>
1097 <string>dev.world.{}</string>
1098 <key>CFBundleInfoDictionaryVersion</key>
1099 <string>6.0</string>
1100 <key>CFBundleName</key>
1101 <string>{}</string>
1102 <key>CFBundlePackageType</key>
1103 <string>FMWK</string>
1104 <key>CFBundleShortVersionString</key>
1105 <string>0.1.0</string>
1106 <key>CFBundleVersion</key>
1107 <string>1</string>
1108 <key>CFBundleSupportedPlatforms</key>
1109 <array>
1110 <string>{}</string>
1111 </array>
1112</dict>
1113</plist>"#,
1114 framework_name,
1115 bundle_id,
1116 framework_name,
1117 if platform == "ios" {
1118 "iPhoneOS"
1119 } else {
1120 "iPhoneSimulator"
1121 }
1122 );
1123
1124 let plist_path = framework_dir.join("Info.plist");
1125 fs::write(&plist_path, plist_content).map_err(|e| {
1126 BenchError::Build(format!(
1127 "Failed to write framework Info.plist at {}: {}. Check output directory permissions.",
1128 plist_path.display(),
1129 e
1130 ))
1131 })?;
1132
1133 Ok(())
1134 }
1135
1136 fn create_xcframework_plist(
1138 &self,
1139 xcframework_path: &Path,
1140 framework_name: &str,
1141 ) -> Result<(), BenchError> {
1142 let plist_content = format!(
1143 r#"<?xml version="1.0" encoding="UTF-8"?>
1144<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1145<plist version="1.0">
1146<dict>
1147 <key>AvailableLibraries</key>
1148 <array>
1149 <dict>
1150 <key>LibraryIdentifier</key>
1151 <string>ios-arm64</string>
1152 <key>LibraryPath</key>
1153 <string>{}.framework</string>
1154 <key>SupportedArchitectures</key>
1155 <array>
1156 <string>arm64</string>
1157 </array>
1158 <key>SupportedPlatform</key>
1159 <string>ios</string>
1160 </dict>
1161 <dict>
1162 <key>LibraryIdentifier</key>
1163 <string>ios-arm64_x86_64-simulator</string>
1164 <key>LibraryPath</key>
1165 <string>{}.framework</string>
1166 <key>SupportedArchitectures</key>
1167 <array>
1168 <string>arm64</string>
1169 <string>x86_64</string>
1170 </array>
1171 <key>SupportedPlatform</key>
1172 <string>ios</string>
1173 <key>SupportedPlatformVariant</key>
1174 <string>simulator</string>
1175 </dict>
1176 </array>
1177 <key>CFBundlePackageType</key>
1178 <string>XFWK</string>
1179 <key>XCFrameworkFormatVersion</key>
1180 <string>1.0</string>
1181</dict>
1182</plist>"#,
1183 framework_name, framework_name
1184 );
1185
1186 let plist_path = xcframework_path.join("Info.plist");
1187 fs::write(&plist_path, plist_content).map_err(|e| {
1188 BenchError::Build(format!(
1189 "Failed to write xcframework Info.plist at {}: {}. Check output directory permissions.",
1190 plist_path.display(),
1191 e
1192 ))
1193 })?;
1194
1195 Ok(())
1196 }
1197
1198 fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> {
1205 let output = Command::new("codesign")
1206 .arg("--force")
1207 .arg("--deep")
1208 .arg("--sign")
1209 .arg("-")
1210 .arg(xcframework_path)
1211 .output()
1212 .map_err(|e| {
1213 BenchError::Build(format!(
1214 "Failed to run codesign.\n\n\
1215 XCFramework: {}\n\
1216 Error: {}\n\n\
1217 Ensure Xcode command line tools are installed:\n\
1218 xcode-select --install\n\n\
1219 The xcframework must be signed for Xcode to accept it.",
1220 xcframework_path.display(),
1221 e
1222 ))
1223 })?;
1224
1225 if output.status.success() {
1226 if self.verbose {
1227 println!(" Successfully code-signed xcframework");
1228 }
1229 Ok(())
1230 } else {
1231 let stderr = String::from_utf8_lossy(&output.stderr);
1232 Err(BenchError::Build(format!(
1233 "codesign failed to sign xcframework.\n\n\
1234 XCFramework: {}\n\
1235 Exit status: {}\n\
1236 Stderr: {}\n\n\
1237 Ensure you have valid signing credentials:\n\
1238 security find-identity -v -p codesigning\n\n\
1239 For ad-hoc signing (most common), the '-' identity should work.\n\
1240 If signing continues to fail, check that the xcframework structure is valid.",
1241 xcframework_path.display(),
1242 output.status,
1243 stderr
1244 )))
1245 }
1246 }
1247
1248 fn generate_xcode_project(&self) -> Result<(), BenchError> {
1258 let ios_dir = self.output_dir.join("ios");
1259 let project_yml = ios_dir.join("BenchRunner/project.yml");
1260
1261 if !project_yml.exists() {
1262 if self.verbose {
1263 println!(" No project.yml found, skipping xcodegen");
1264 }
1265 return Ok(());
1266 }
1267
1268 if self.verbose {
1269 println!(" Generating Xcode project with xcodegen");
1270 }
1271
1272 let project_dir = ios_dir.join("BenchRunner");
1273 let output = Command::new("xcodegen")
1274 .arg("generate")
1275 .current_dir(&project_dir)
1276 .output()
1277 .map_err(|e| {
1278 BenchError::Build(format!(
1279 "Failed to run xcodegen.\n\n\
1280 project.yml found at: {}\n\
1281 Working directory: {}\n\
1282 Error: {}\n\n\
1283 xcodegen is required to generate the Xcode project.\n\
1284 Install it with:\n\
1285 brew install xcodegen\n\n\
1286 After installation, re-run the build.",
1287 project_yml.display(),
1288 project_dir.display(),
1289 e
1290 ))
1291 })?;
1292
1293 if output.status.success() {
1294 if self.verbose {
1295 println!(" Successfully generated Xcode project");
1296 }
1297 Ok(())
1298 } else {
1299 let stdout = String::from_utf8_lossy(&output.stdout);
1300 let stderr = String::from_utf8_lossy(&output.stderr);
1301 Err(BenchError::Build(format!(
1302 "xcodegen failed.\n\n\
1303 Command: xcodegen generate\n\
1304 Working directory: {}\n\
1305 Exit status: {}\n\n\
1306 Stdout:\n{}\n\n\
1307 Stderr:\n{}\n\n\
1308 Check that project.yml is valid YAML and has correct xcodegen syntax.\n\
1309 Try running 'xcodegen generate' manually in {} for more details.",
1310 project_dir.display(),
1311 output.status,
1312 stdout,
1313 stderr,
1314 project_dir.display()
1315 )))
1316 }
1317 }
1318
1319 fn find_uniffi_header(&self, header_name: &str) -> Option<PathBuf> {
1321 let swift_dir = self
1323 .output_dir
1324 .join("ios/BenchRunner/BenchRunner/Generated");
1325 let candidate_swift = swift_dir.join(header_name);
1326 if candidate_swift.exists() {
1327 return Some(candidate_swift);
1328 }
1329
1330 let crate_dir = self.find_crate_dir().ok()?;
1332 let target_dir = get_cargo_target_dir(&crate_dir).ok()?;
1333 let candidate = target_dir.join("uniffi").join(header_name);
1335 if candidate.exists() {
1336 return Some(candidate);
1337 }
1338
1339 let mut stack = vec![target_dir];
1341 while let Some(dir) = stack.pop() {
1342 if let Ok(entries) = fs::read_dir(&dir) {
1343 for entry in entries.flatten() {
1344 let path = entry.path();
1345 if path.is_dir() {
1346 if let Some(name) = path.file_name().and_then(|n| n.to_str())
1348 && name == "incremental"
1349 {
1350 continue;
1351 }
1352 stack.push(path);
1353 } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
1354 && name == header_name
1355 {
1356 return Some(path);
1357 }
1358 }
1359 }
1360 }
1361
1362 None
1363 }
1364}
1365
1366#[allow(clippy::collapsible_if)]
1367fn find_codesign_identity() -> Option<String> {
1368 let output = Command::new("security")
1369 .args(["find-identity", "-v", "-p", "codesigning"])
1370 .output()
1371 .ok()?;
1372 if !output.status.success() {
1373 return None;
1374 }
1375 let stdout = String::from_utf8_lossy(&output.stdout);
1376 let mut identities = Vec::new();
1377 for line in stdout.lines() {
1378 if let Some(start) = line.find('"') {
1379 if let Some(end) = line[start + 1..].find('"') {
1380 identities.push(line[start + 1..start + 1 + end].to_string());
1381 }
1382 }
1383 }
1384 let preferred = [
1385 "Apple Distribution",
1386 "iPhone Distribution",
1387 "Apple Development",
1388 "iPhone Developer",
1389 ];
1390 for label in preferred {
1391 if let Some(identity) = identities.iter().find(|i| i.contains(label)) {
1392 return Some(identity.clone());
1393 }
1394 }
1395 identities.first().cloned()
1396}
1397
1398#[allow(clippy::collapsible_if)]
1399fn find_provisioning_profile() -> Option<PathBuf> {
1400 if let Ok(path) = env::var("MOBENCH_IOS_PROFILE") {
1401 let profile = PathBuf::from(path);
1402 if profile.exists() {
1403 return Some(profile);
1404 }
1405 }
1406 let home = env::var("HOME").ok()?;
1407 let profiles_dir = PathBuf::from(home).join("Library/MobileDevice/Provisioning Profiles");
1408 let entries = fs::read_dir(&profiles_dir).ok()?;
1409 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
1410 for entry in entries.flatten() {
1411 let path = entry.path();
1412 if path.extension().and_then(|e| e.to_str()) != Some("mobileprovision") {
1413 continue;
1414 }
1415 if let Ok(metadata) = entry.metadata()
1416 && let Ok(modified) = metadata.modified()
1417 {
1418 match &newest {
1419 Some((current, _)) if *current >= modified => {}
1420 _ => newest = Some((modified, path)),
1421 }
1422 }
1423 }
1424 newest.map(|(_, path)| path)
1425}
1426
1427fn embed_provisioning_profile(app_path: &Path, profile: &Path) -> Result<(), BenchError> {
1428 let dest = app_path.join("embedded.mobileprovision");
1429 fs::copy(profile, &dest).map_err(|e| {
1430 BenchError::Build(format!(
1431 "Failed to embed provisioning profile at {:?}: {}. Check the profile path and file permissions.",
1432 dest, e
1433 ))
1434 })?;
1435 Ok(())
1436}
1437
1438fn codesign_bundle(app_path: &Path, identity: &str) -> Result<(), BenchError> {
1439 let output = Command::new("codesign")
1440 .args(["--force", "--deep", "--sign", identity])
1441 .arg(app_path)
1442 .output()
1443 .map_err(|e| {
1444 BenchError::Build(format!(
1445 "Failed to run codesign: {}. Ensure Xcode command line tools are installed.",
1446 e
1447 ))
1448 })?;
1449 if !output.status.success() {
1450 let stderr = String::from_utf8_lossy(&output.stderr);
1451 return Err(BenchError::Build(format!(
1452 "codesign failed: {}. Verify you have a valid signing identity.",
1453 stderr
1454 )));
1455 }
1456 Ok(())
1457}
1458
1459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1461pub enum SigningMethod {
1462 AdHoc,
1464 Development,
1466}
1467
1468impl IosBuilder {
1469 pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result<PathBuf, BenchError> {
1497 let ios_dir = self.output_dir.join("ios").join(scheme);
1500 let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
1501
1502 if !project_path.exists() {
1504 return Err(BenchError::Build(format!(
1505 "Xcode project not found at {}.\n\n\
1506 Run `cargo mobench build --target ios` first or check --output-dir.",
1507 project_path.display()
1508 )));
1509 }
1510
1511 let export_path = self.output_dir.join("ios");
1512 let ipa_path = export_path.join(format!("{}.ipa", scheme));
1513
1514 fs::create_dir_all(&export_path).map_err(|e| {
1516 BenchError::Build(format!(
1517 "Failed to create export directory at {}: {}. Check output directory permissions.",
1518 export_path.display(),
1519 e
1520 ))
1521 })?;
1522
1523 println!("Building {} for device...", scheme);
1524
1525 let build_dir = self.output_dir.join("ios/build");
1527 let build_configuration = "Release";
1531 let mut cmd = Command::new("xcodebuild");
1532 cmd.arg("-project")
1533 .arg(&project_path)
1534 .arg("-scheme")
1535 .arg(scheme)
1536 .arg("-destination")
1537 .arg("generic/platform=iOS")
1538 .arg("-sdk")
1539 .arg("iphoneos")
1540 .arg("-configuration")
1541 .arg(build_configuration)
1542 .arg("-derivedDataPath")
1543 .arg(&build_dir)
1544 .arg("build");
1545
1546 match method {
1548 SigningMethod::AdHoc => {
1549 cmd.args([
1553 "VALIDATE_PRODUCT=NO",
1554 "CODE_SIGN_STYLE=Manual",
1555 "CODE_SIGN_IDENTITY=",
1556 "CODE_SIGNING_ALLOWED=NO",
1557 "CODE_SIGNING_REQUIRED=NO",
1558 "DEVELOPMENT_TEAM=",
1559 "PROVISIONING_PROFILE_SPECIFIER=",
1560 ]);
1561 }
1562 SigningMethod::Development => {
1563 cmd.args([
1565 "CODE_SIGN_STYLE=Automatic",
1566 "CODE_SIGN_IDENTITY=iPhone Developer",
1567 ]);
1568 }
1569 }
1570
1571 if self.verbose {
1572 println!(" Running: {:?}", cmd);
1573 }
1574
1575 let build_result = cmd.output();
1577
1578 let app_path = build_dir
1580 .join(format!("Build/Products/{}-iphoneos", build_configuration))
1581 .join(format!("{}.app", scheme));
1582
1583 if !app_path.exists() {
1584 match build_result {
1585 Ok(output) => {
1586 let stdout = String::from_utf8_lossy(&output.stdout);
1587 let stderr = String::from_utf8_lossy(&output.stderr);
1588 return Err(BenchError::Build(format!(
1589 "xcodebuild build failed and app bundle was not created.\n\n\
1590 Project: {}\n\
1591 Scheme: {}\n\
1592 Configuration: {}\n\
1593 Derived data: {}\n\
1594 Exit status: {}\n\n\
1595 Stdout:\n{}\n\n\
1596 Stderr:\n{}\n\n\
1597 Tip: run xcodebuild manually to inspect the failure.",
1598 project_path.display(),
1599 scheme,
1600 build_configuration,
1601 build_dir.display(),
1602 output.status,
1603 stdout,
1604 stderr
1605 )));
1606 }
1607 Err(err) => {
1608 return Err(BenchError::Build(format!(
1609 "Failed to run xcodebuild: {}.\n\n\
1610 App bundle not found at {}.\n\
1611 Check that Xcode command line tools are installed.",
1612 err,
1613 app_path.display()
1614 )));
1615 }
1616 }
1617 }
1618
1619 if self.verbose {
1620 println!(" App bundle created successfully at {:?}", app_path);
1621 }
1622
1623 let build_log_path = export_path.join("ipa-build.log");
1624 if let Ok(output) = &build_result
1625 && !output.status.success()
1626 {
1627 let mut log = String::new();
1628 log.push_str("STDOUT:\n");
1629 log.push_str(&String::from_utf8_lossy(&output.stdout));
1630 log.push_str("\n\nSTDERR:\n");
1631 log.push_str(&String::from_utf8_lossy(&output.stderr));
1632 let _ = fs::write(&build_log_path, log);
1633 println!(
1634 "Warning: xcodebuild exited with {} but produced {}. Validating the bundle before continuing. Log: {}",
1635 output.status,
1636 app_path.display(),
1637 build_log_path.display()
1638 );
1639 }
1640
1641 let source_info_plist = ios_dir.join(scheme).join("Info.plist");
1642 if let Err(bundle_err) =
1643 self.ensure_device_app_bundle_metadata(&app_path, &source_info_plist, scheme)
1644 {
1645 if let Ok(output) = &build_result
1646 && !output.status.success()
1647 {
1648 let stdout = String::from_utf8_lossy(&output.stdout);
1649 let stderr = String::from_utf8_lossy(&output.stderr);
1650 return Err(BenchError::Build(format!(
1651 "xcodebuild build produced an incomplete app bundle.\n\n\
1652 Project: {}\n\
1653 Scheme: {}\n\
1654 Configuration: {}\n\
1655 Derived data: {}\n\
1656 Exit status: {}\n\
1657 Log: {}\n\n\
1658 Bundle validation: {}\n\n\
1659 Stdout:\n{}\n\n\
1660 Stderr:\n{}",
1661 project_path.display(),
1662 scheme,
1663 build_configuration,
1664 build_dir.display(),
1665 output.status,
1666 build_log_path.display(),
1667 bundle_err,
1668 stdout,
1669 stderr
1670 )));
1671 }
1672 return Err(bundle_err);
1673 }
1674
1675 if matches!(method, SigningMethod::AdHoc) {
1676 let profile = find_provisioning_profile();
1677 let identity = find_codesign_identity();
1678 match (profile.as_ref(), identity.as_ref()) {
1679 (Some(profile), Some(identity)) => {
1680 embed_provisioning_profile(&app_path, profile)?;
1681 codesign_bundle(&app_path, identity)?;
1682 if self.verbose {
1683 println!(" Signed app bundle with identity {}", identity);
1684 }
1685 }
1686 _ => {
1687 let output = Command::new("codesign")
1688 .arg("--force")
1689 .arg("--deep")
1690 .arg("--sign")
1691 .arg("-")
1692 .arg(&app_path)
1693 .output();
1694 match output {
1695 Ok(output) if output.status.success() => {
1696 println!(
1697 "Warning: Signed app bundle without provisioning profile; BrowserStack install may fail."
1698 );
1699 }
1700 Ok(output) => {
1701 let stderr = String::from_utf8_lossy(&output.stderr);
1702 println!("Warning: Ad-hoc signing failed: {}", stderr);
1703 }
1704 Err(err) => {
1705 println!("Warning: Could not run codesign: {}", err);
1706 }
1707 }
1708 }
1709 }
1710 }
1711
1712 println!("Creating IPA from app bundle...");
1713
1714 let payload_dir = export_path.join("Payload");
1719 if payload_dir.exists() {
1720 fs::remove_dir_all(&payload_dir).map_err(|e| {
1721 BenchError::Build(format!(
1722 "Failed to remove old Payload dir at {}: {}. Close any tools using it and retry.",
1723 payload_dir.display(),
1724 e
1725 ))
1726 })?;
1727 }
1728 fs::create_dir_all(&payload_dir).map_err(|e| {
1729 BenchError::Build(format!(
1730 "Failed to create Payload dir at {}: {}. Check output directory permissions.",
1731 payload_dir.display(),
1732 e
1733 ))
1734 })?;
1735
1736 let dest_app = payload_dir.join(format!("{}.app", scheme));
1738 self.copy_bundle_with_ditto(&app_path, &dest_app)?;
1739
1740 if ipa_path.exists() {
1742 fs::remove_file(&ipa_path).map_err(|e| {
1743 BenchError::Build(format!(
1744 "Failed to remove old IPA at {}: {}. Check file permissions.",
1745 ipa_path.display(),
1746 e
1747 ))
1748 })?;
1749 }
1750
1751 let mut cmd = Command::new("ditto");
1752 cmd.arg("-c")
1753 .arg("-k")
1754 .arg("--sequesterRsrc")
1755 .arg("--keepParent")
1756 .arg("Payload")
1757 .arg(&ipa_path)
1758 .current_dir(&export_path);
1759
1760 if self.verbose {
1761 println!(" Running: {:?}", cmd);
1762 }
1763
1764 run_command(cmd, "create IPA archive with ditto")?;
1765 self.validate_ipa_archive(&ipa_path, scheme)?;
1766
1767 fs::remove_dir_all(&payload_dir).map_err(|e| {
1769 BenchError::Build(format!(
1770 "Failed to clean up Payload dir at {}: {}. Check file permissions.",
1771 payload_dir.display(),
1772 e
1773 ))
1774 })?;
1775
1776 println!("✓ IPA created: {:?}", ipa_path);
1777 Ok(ipa_path)
1778 }
1779
1780 pub fn package_xcuitest(&self, scheme: &str) -> Result<PathBuf, BenchError> {
1785 let ios_dir = self.output_dir.join("ios").join(scheme);
1786 let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
1787
1788 if !project_path.exists() {
1789 return Err(BenchError::Build(format!(
1790 "Xcode project not found at {}.\n\n\
1791 Run `cargo mobench build --target ios` first or check --output-dir.",
1792 project_path.display()
1793 )));
1794 }
1795
1796 let export_path = self.output_dir.join("ios");
1797 fs::create_dir_all(&export_path).map_err(|e| {
1798 BenchError::Build(format!(
1799 "Failed to create export directory at {}: {}. Check output directory permissions.",
1800 export_path.display(),
1801 e
1802 ))
1803 })?;
1804
1805 let build_dir = self.output_dir.join("ios/build");
1806 println!("Building XCUITest runner for {}...", scheme);
1807
1808 let mut cmd = Command::new("xcodebuild");
1809 cmd.arg("build-for-testing")
1810 .arg("-project")
1811 .arg(&project_path)
1812 .arg("-scheme")
1813 .arg(scheme)
1814 .arg("-destination")
1815 .arg("generic/platform=iOS")
1816 .arg("-sdk")
1817 .arg("iphoneos")
1818 .arg("-configuration")
1819 .arg("Release")
1820 .arg("-derivedDataPath")
1821 .arg(&build_dir)
1822 .arg("VALIDATE_PRODUCT=NO")
1823 .arg("CODE_SIGN_STYLE=Manual")
1824 .arg("CODE_SIGN_IDENTITY=")
1825 .arg("CODE_SIGNING_ALLOWED=NO")
1826 .arg("CODE_SIGNING_REQUIRED=NO")
1827 .arg("DEVELOPMENT_TEAM=")
1828 .arg("PROVISIONING_PROFILE_SPECIFIER=")
1829 .arg("ENABLE_BITCODE=NO")
1830 .arg("BITCODE_GENERATION_MODE=none")
1831 .arg("STRIP_BITCODE_FROM_COPIED_FILES=NO");
1832
1833 if self.verbose {
1834 println!(" Running: {:?}", cmd);
1835 }
1836
1837 let runner_name = format!("{}UITests-Runner.app", scheme);
1838 let runner_path = build_dir
1839 .join("Build/Products/Release-iphoneos")
1840 .join(&runner_name);
1841
1842 let build_result = cmd.output();
1843 let log_path = export_path.join("xcuitest-build.log");
1844 if let Ok(output) = &build_result
1845 && !output.status.success()
1846 {
1847 let mut log = String::new();
1848 let stdout = String::from_utf8_lossy(&output.stdout);
1849 let stderr = String::from_utf8_lossy(&output.stderr);
1850 log.push_str("STDOUT:\n");
1851 log.push_str(&stdout);
1852 log.push_str("\n\nSTDERR:\n");
1853 log.push_str(&stderr);
1854 let _ = fs::write(&log_path, log);
1855 println!("xcodebuild log written to {:?}", log_path);
1856 if runner_path.exists() {
1857 println!(
1858 "Warning: xcodebuild build-for-testing failed, but runner exists: {}",
1859 stderr
1860 );
1861 }
1862 }
1863
1864 if !runner_path.exists() {
1865 match build_result {
1866 Ok(output) => {
1867 let stdout = String::from_utf8_lossy(&output.stdout);
1868 let stderr = String::from_utf8_lossy(&output.stderr);
1869 return Err(BenchError::Build(format!(
1870 "xcodebuild build-for-testing failed and runner was not created.\n\n\
1871 Project: {}\n\
1872 Scheme: {}\n\
1873 Derived data: {}\n\
1874 Exit status: {}\n\
1875 Log: {}\n\n\
1876 Stdout:\n{}\n\n\
1877 Stderr:\n{}\n\n\
1878 Tip: open the log file above for more context.",
1879 project_path.display(),
1880 scheme,
1881 build_dir.display(),
1882 output.status,
1883 log_path.display(),
1884 stdout,
1885 stderr
1886 )));
1887 }
1888 Err(err) => {
1889 return Err(BenchError::Build(format!(
1890 "Failed to run xcodebuild: {}.\n\n\
1891 XCUITest runner not found at {}.\n\
1892 Check that Xcode command line tools are installed.",
1893 err,
1894 runner_path.display()
1895 )));
1896 }
1897 }
1898 }
1899
1900 let profile = find_provisioning_profile();
1901 let identity = find_codesign_identity();
1902 if let (Some(profile), Some(identity)) = (profile.as_ref(), identity.as_ref()) {
1903 embed_provisioning_profile(&runner_path, profile)?;
1904 codesign_bundle(&runner_path, identity)?;
1905 if self.verbose {
1906 println!(" Signed XCUITest runner with identity {}", identity);
1907 }
1908 } else {
1909 println!(
1910 "Warning: No provisioning profile/identity found; XCUITest runner may not install."
1911 );
1912 }
1913
1914 let zip_path = export_path.join(format!("{}UITests.zip", scheme));
1915 if zip_path.exists() {
1916 fs::remove_file(&zip_path).map_err(|e| {
1917 BenchError::Build(format!(
1918 "Failed to remove old zip at {}: {}. Check file permissions.",
1919 zip_path.display(),
1920 e
1921 ))
1922 })?;
1923 }
1924
1925 let runner_parent = runner_path.parent().ok_or_else(|| {
1926 BenchError::Build(format!(
1927 "Invalid XCUITest runner path with no parent directory: {}",
1928 runner_path.display()
1929 ))
1930 })?;
1931
1932 let mut zip_cmd = Command::new("zip");
1933 zip_cmd
1934 .arg("-qr")
1935 .arg(&zip_path)
1936 .arg(&runner_name)
1937 .current_dir(runner_parent);
1938
1939 if self.verbose {
1940 println!(" Running: {:?}", zip_cmd);
1941 }
1942
1943 run_command(zip_cmd, "zip XCUITest runner")?;
1944 println!("✓ XCUITest runner packaged: {:?}", zip_path);
1945
1946 Ok(zip_path)
1947 }
1948
1949 fn copy_bundle_with_ditto(&self, src: &Path, dest: &Path) -> Result<(), BenchError> {
1950 let mut cmd = Command::new("ditto");
1951 cmd.arg(src).arg(dest);
1952
1953 if self.verbose {
1954 println!(" Running: {:?}", cmd);
1955 }
1956
1957 run_command(cmd, "copy app bundle with ditto")
1958 }
1959
1960 fn ensure_device_app_bundle_metadata(
1961 &self,
1962 app_path: &Path,
1963 source_info_plist: &Path,
1964 scheme: &str,
1965 ) -> Result<(), BenchError> {
1966 let bundled_info_plist = app_path.join("Info.plist");
1967 if !bundled_info_plist.is_file() {
1968 if !source_info_plist.is_file() {
1969 return Err(BenchError::Build(format!(
1970 "Built app bundle at {} is missing Info.plist, and the generated source plist was not found at {}.\n\n\
1971 The device build produced an incomplete .app bundle, so packaging cannot continue.",
1972 app_path.display(),
1973 source_info_plist.display()
1974 )));
1975 }
1976
1977 fs::copy(source_info_plist, &bundled_info_plist).map_err(|e| {
1978 BenchError::Build(format!(
1979 "Built app bundle at {} is missing Info.plist, and restoring it from {} failed: {}.",
1980 app_path.display(),
1981 source_info_plist.display(),
1982 e
1983 ))
1984 })?;
1985 println!(
1986 "Warning: Restored missing Info.plist into built app bundle from {}.",
1987 source_info_plist.display()
1988 );
1989 }
1990
1991 let executable = app_path.join(scheme);
1992 if !executable.is_file() {
1993 return Err(BenchError::Build(format!(
1994 "Built app bundle at {} is missing the expected executable {}.\n\n\
1995 The device build produced an incomplete .app bundle, so packaging cannot continue.",
1996 app_path.display(),
1997 executable.display()
1998 )));
1999 }
2000
2001 Ok(())
2002 }
2003
2004 fn validate_ipa_archive(&self, ipa_path: &Path, scheme: &str) -> Result<(), BenchError> {
2005 let extract_root = env::temp_dir().join(format!(
2006 "mobench-ipa-validate-{}-{}",
2007 std::process::id(),
2008 SystemTime::now()
2009 .duration_since(UNIX_EPOCH)
2010 .map(|d| d.as_nanos())
2011 .unwrap_or(0)
2012 ));
2013
2014 if extract_root.exists() {
2015 fs::remove_dir_all(&extract_root).map_err(|e| {
2016 BenchError::Build(format!(
2017 "Failed to clear IPA validation dir at {}: {}",
2018 extract_root.display(),
2019 e
2020 ))
2021 })?;
2022 }
2023 fs::create_dir_all(&extract_root).map_err(|e| {
2024 BenchError::Build(format!(
2025 "Failed to create IPA validation dir at {}: {}",
2026 extract_root.display(),
2027 e
2028 ))
2029 })?;
2030
2031 let mut extract = Command::new("ditto");
2032 extract.arg("-x").arg("-k").arg(ipa_path).arg(&extract_root);
2033
2034 let extract_result = run_command(extract, "extract IPA for validation");
2035 if let Err(err) = extract_result {
2036 let _ = fs::remove_dir_all(&extract_root);
2037 return Err(err);
2038 }
2039
2040 let info_plist = extract_root
2041 .join("Payload")
2042 .join(format!("{}.app", scheme))
2043 .join("Info.plist");
2044 let validation_result = if info_plist.is_file() {
2045 Ok(())
2046 } else {
2047 Err(BenchError::Build(format!(
2048 "IPA validation failed: {} is missing from {}.\n\n\
2049 The packaged IPA does not contain a valid iOS app bundle. \
2050 BrowserStack will reject this upload.",
2051 info_plist
2052 .strip_prefix(&extract_root)
2053 .unwrap_or(&info_plist)
2054 .display(),
2055 ipa_path.display()
2056 )))
2057 };
2058
2059 let _ = fs::remove_dir_all(&extract_root);
2060 validation_result
2061 }
2062}
2063
2064#[cfg(test)]
2065mod tests {
2066 use super::*;
2067 use std::io::Write;
2068
2069 #[test]
2070 fn test_ios_builder_creation() {
2071 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2072 assert!(!builder.verbose);
2073 assert_eq!(
2074 builder.output_dir,
2075 PathBuf::from("/tmp/test-project/target/mobench")
2076 );
2077 }
2078
2079 #[test]
2080 fn test_ios_builder_verbose() {
2081 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
2082 assert!(builder.verbose);
2083 }
2084
2085 #[test]
2086 fn test_ios_builder_custom_output_dir() {
2087 let builder =
2088 IosBuilder::new("/tmp/test-project", "test-bench-mobile").output_dir("/custom/output");
2089 assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
2090 }
2091
2092 #[cfg(target_os = "macos")]
2093 #[test]
2094 fn test_validate_ipa_archive_rejects_missing_info_plist() {
2095 let temp_dir = env::temp_dir().join(format!(
2096 "mobench-ios-test-bad-ipa-{}-{}",
2097 std::process::id(),
2098 SystemTime::now()
2099 .duration_since(UNIX_EPOCH)
2100 .map(|d| d.as_nanos())
2101 .unwrap_or(0)
2102 ));
2103 let payload = temp_dir.join("Payload/BenchRunner.app");
2104 fs::create_dir_all(&payload).expect("create payload");
2105 let ipa = temp_dir.join("broken.ipa");
2106
2107 let status = Command::new("ditto")
2108 .arg("-c")
2109 .arg("-k")
2110 .arg("--sequesterRsrc")
2111 .arg("--keepParent")
2112 .arg("Payload")
2113 .arg(&ipa)
2114 .current_dir(&temp_dir)
2115 .status()
2116 .expect("run ditto");
2117 assert!(status.success(), "ditto should create the broken test ipa");
2118
2119 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2120 let err = builder
2121 .validate_ipa_archive(&ipa, "BenchRunner")
2122 .expect_err("IPA missing Info.plist should be rejected");
2123 assert!(
2124 err.to_string().contains("Info.plist"),
2125 "expected validation error mentioning Info.plist, got: {err}"
2126 );
2127
2128 let _ = fs::remove_dir_all(&temp_dir);
2129 }
2130
2131 #[cfg(target_os = "macos")]
2132 #[test]
2133 fn test_validate_ipa_archive_accepts_payload_with_info_plist() {
2134 let temp_dir = env::temp_dir().join(format!(
2135 "mobench-ios-test-good-ipa-{}-{}",
2136 std::process::id(),
2137 SystemTime::now()
2138 .duration_since(UNIX_EPOCH)
2139 .map(|d| d.as_nanos())
2140 .unwrap_or(0)
2141 ));
2142 let payload = temp_dir.join("Payload/BenchRunner.app");
2143 fs::create_dir_all(&payload).expect("create payload");
2144 let mut info = fs::File::create(payload.join("Info.plist")).expect("create plist");
2145 writeln!(
2146 info,
2147 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>"
2148 )
2149 .expect("write plist");
2150 let ipa = temp_dir.join("valid.ipa");
2151
2152 let status = Command::new("ditto")
2153 .arg("-c")
2154 .arg("-k")
2155 .arg("--sequesterRsrc")
2156 .arg("--keepParent")
2157 .arg("Payload")
2158 .arg(&ipa)
2159 .current_dir(&temp_dir)
2160 .status()
2161 .expect("run ditto");
2162 assert!(status.success(), "ditto should create the valid test ipa");
2163
2164 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2165 builder
2166 .validate_ipa_archive(&ipa, "BenchRunner")
2167 .expect("IPA with Info.plist should validate");
2168
2169 let _ = fs::remove_dir_all(&temp_dir);
2170 }
2171
2172 #[test]
2173 fn test_ensure_device_app_bundle_metadata_restores_missing_info_plist() {
2174 let temp_dir = env::temp_dir().join(format!(
2175 "mobench-ios-test-repair-plist-{}-{}",
2176 std::process::id(),
2177 SystemTime::now()
2178 .duration_since(UNIX_EPOCH)
2179 .map(|d| d.as_nanos())
2180 .unwrap_or(0)
2181 ));
2182 let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app");
2183 fs::create_dir_all(&app_dir).expect("create app dir");
2184 fs::write(app_dir.join("BenchRunner"), "bin").expect("create executable");
2185
2186 let source_dir = temp_dir.join("BenchRunner");
2187 fs::create_dir_all(&source_dir).expect("create source dir");
2188 fs::write(
2189 source_dir.join("Info.plist"),
2190 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
2191 )
2192 .expect("create source plist");
2193
2194 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2195 builder
2196 .ensure_device_app_bundle_metadata(
2197 &app_dir,
2198 &source_dir.join("Info.plist"),
2199 "BenchRunner",
2200 )
2201 .expect("missing plist should be restored");
2202
2203 assert!(
2204 app_dir.join("Info.plist").is_file(),
2205 "restored app bundle should contain Info.plist"
2206 );
2207
2208 let _ = fs::remove_dir_all(&temp_dir);
2209 }
2210
2211 #[test]
2212 fn test_ensure_device_app_bundle_metadata_rejects_missing_executable() {
2213 let temp_dir = env::temp_dir().join(format!(
2214 "mobench-ios-test-missing-exec-{}-{}",
2215 std::process::id(),
2216 SystemTime::now()
2217 .duration_since(UNIX_EPOCH)
2218 .map(|d| d.as_nanos())
2219 .unwrap_or(0)
2220 ));
2221 let app_dir = temp_dir.join("Build/Products/Release-iphoneos/BenchRunner.app");
2222 fs::create_dir_all(&app_dir).expect("create app dir");
2223 fs::write(
2224 app_dir.join("Info.plist"),
2225 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
2226 )
2227 .expect("create bundled plist");
2228 let source_dir = temp_dir.join("BenchRunner");
2229 fs::create_dir_all(&source_dir).expect("create source dir");
2230 fs::write(
2231 source_dir.join("Info.plist"),
2232 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"></plist>",
2233 )
2234 .expect("create source plist");
2235
2236 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
2237 let err = builder
2238 .ensure_device_app_bundle_metadata(
2239 &app_dir,
2240 &source_dir.join("Info.plist"),
2241 "BenchRunner",
2242 )
2243 .expect_err("missing executable should fail validation");
2244 assert!(
2245 err.to_string().contains("missing the expected executable"),
2246 "expected executable validation error, got: {err}"
2247 );
2248
2249 let _ = fs::remove_dir_all(&temp_dir);
2250 }
2251
2252 #[test]
2253 fn test_find_crate_dir_current_directory_is_crate() {
2254 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-current");
2256 let _ = std::fs::remove_dir_all(&temp_dir);
2257 std::fs::create_dir_all(&temp_dir).unwrap();
2258
2259 std::fs::write(
2261 temp_dir.join("Cargo.toml"),
2262 r#"[package]
2263name = "bench-mobile"
2264version = "0.1.0"
2265"#,
2266 )
2267 .unwrap();
2268
2269 let builder = IosBuilder::new(&temp_dir, "bench-mobile");
2270 let result = builder.find_crate_dir();
2271 assert!(result.is_ok(), "Should find crate in current directory");
2272 let expected = temp_dir.canonicalize().unwrap_or(temp_dir.clone());
2274 assert_eq!(result.unwrap(), expected);
2275
2276 std::fs::remove_dir_all(&temp_dir).unwrap();
2277 }
2278
2279 #[test]
2280 fn test_find_crate_dir_nested_bench_mobile() {
2281 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-nested");
2283 let _ = std::fs::remove_dir_all(&temp_dir);
2284 std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
2285
2286 std::fs::write(
2288 temp_dir.join("Cargo.toml"),
2289 r#"[workspace]
2290members = ["bench-mobile"]
2291"#,
2292 )
2293 .unwrap();
2294
2295 std::fs::write(
2297 temp_dir.join("bench-mobile/Cargo.toml"),
2298 r#"[package]
2299name = "bench-mobile"
2300version = "0.1.0"
2301"#,
2302 )
2303 .unwrap();
2304
2305 let builder = IosBuilder::new(&temp_dir, "bench-mobile");
2306 let result = builder.find_crate_dir();
2307 assert!(
2308 result.is_ok(),
2309 "Should find crate in bench-mobile/ directory"
2310 );
2311 let expected = temp_dir
2312 .canonicalize()
2313 .unwrap_or(temp_dir.clone())
2314 .join("bench-mobile");
2315 assert_eq!(result.unwrap(), expected);
2316
2317 std::fs::remove_dir_all(&temp_dir).unwrap();
2318 }
2319
2320 #[test]
2321 fn test_find_crate_dir_crates_subdir() {
2322 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-crates");
2324 let _ = std::fs::remove_dir_all(&temp_dir);
2325 std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
2326
2327 std::fs::write(
2329 temp_dir.join("Cargo.toml"),
2330 r#"[workspace]
2331members = ["crates/*"]
2332"#,
2333 )
2334 .unwrap();
2335
2336 std::fs::write(
2338 temp_dir.join("crates/my-bench/Cargo.toml"),
2339 r#"[package]
2340name = "my-bench"
2341version = "0.1.0"
2342"#,
2343 )
2344 .unwrap();
2345
2346 let builder = IosBuilder::new(&temp_dir, "my-bench");
2347 let result = builder.find_crate_dir();
2348 assert!(result.is_ok(), "Should find crate in crates/ directory");
2349 let expected = temp_dir
2350 .canonicalize()
2351 .unwrap_or(temp_dir.clone())
2352 .join("crates/my-bench");
2353 assert_eq!(result.unwrap(), expected);
2354
2355 std::fs::remove_dir_all(&temp_dir).unwrap();
2356 }
2357
2358 #[test]
2359 fn test_find_crate_dir_not_found() {
2360 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-notfound");
2362 let _ = std::fs::remove_dir_all(&temp_dir);
2363 std::fs::create_dir_all(&temp_dir).unwrap();
2364
2365 std::fs::write(
2367 temp_dir.join("Cargo.toml"),
2368 r#"[package]
2369name = "some-other-crate"
2370version = "0.1.0"
2371"#,
2372 )
2373 .unwrap();
2374
2375 let builder = IosBuilder::new(&temp_dir, "nonexistent-crate");
2376 let result = builder.find_crate_dir();
2377 assert!(result.is_err(), "Should fail to find nonexistent crate");
2378 let err_msg = result.unwrap_err().to_string();
2379 assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
2380 assert!(err_msg.contains("Searched locations"));
2381
2382 std::fs::remove_dir_all(&temp_dir).unwrap();
2383 }
2384
2385 #[test]
2386 fn test_find_crate_dir_explicit_crate_path() {
2387 let temp_dir = std::env::temp_dir().join("mobench-ios-test-find-crate-explicit");
2389 let _ = std::fs::remove_dir_all(&temp_dir);
2390 std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
2391
2392 let builder =
2393 IosBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location"));
2394 let result = builder.find_crate_dir();
2395 assert!(result.is_ok(), "Should use explicit crate_dir");
2396 assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
2397
2398 std::fs::remove_dir_all(&temp_dir).unwrap();
2399 }
2400}