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