1use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
7use std::env;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12pub struct IosBuilder {
14 project_root: PathBuf,
16 output_dir: PathBuf,
18 crate_name: String,
20 verbose: bool,
22}
23
24impl IosBuilder {
25 pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
32 let root = project_root.into();
33 Self {
34 output_dir: root.join("target/mobench"),
35 project_root: root,
36 crate_name: crate_name.into(),
37 verbose: false,
38 }
39 }
40
41 pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
46 self.output_dir = dir.into();
47 self
48 }
49
50 pub fn verbose(mut self, verbose: bool) -> Self {
52 self.verbose = verbose;
53 self
54 }
55
56 pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
70 let framework_name = self.crate_name.replace("-", "_");
71 println!("Building Rust libraries for iOS...");
73 self.build_rust_libraries(config)?;
74
75 println!("Generating UniFFI Swift bindings...");
77 self.generate_uniffi_bindings()?;
78
79 println!("Creating xcframework...");
81 let xcframework_path = self.create_xcframework(config)?;
82
83 println!("Code-signing xcframework...");
85 self.codesign_xcframework(&xcframework_path)?;
86
87 let header_src = self
89 .find_uniffi_header(&format!("{}FFI.h", framework_name))
90 .ok_or_else(|| {
91 BenchError::Build(format!(
92 "UniFFI header {}FFI.h not found after generation",
93 framework_name
94 ))
95 })?;
96 let include_dir = self.output_dir.join("ios/include");
97 fs::create_dir_all(&include_dir)
98 .map_err(|e| BenchError::Build(format!("Failed to create include dir: {}", e)))?;
99 let header_dest = include_dir.join(format!("{}.h", framework_name));
100 fs::copy(&header_src, &header_dest).map_err(|e| {
101 BenchError::Build(format!(
102 "Failed to copy UniFFI header to {:?}: {}",
103 header_dest, e
104 ))
105 })?;
106
107 self.generate_xcode_project()?;
109
110 Ok(BuildResult {
111 platform: Target::Ios,
112 app_path: xcframework_path,
113 test_suite_path: None,
114 })
115 }
116
117 fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
119 let bench_mobile_dir = self.project_root.join("bench-mobile");
121 if bench_mobile_dir.exists() {
122 return Ok(bench_mobile_dir);
123 }
124
125 let crates_dir = self.project_root.join("crates").join(&self.crate_name);
127 if crates_dir.exists() {
128 return Ok(crates_dir);
129 }
130
131 Err(BenchError::Build(format!(
132 "Benchmark crate '{}' not found. Tried:\n - {:?}\n - {:?}",
133 self.crate_name, bench_mobile_dir, crates_dir
134 )))
135 }
136
137 fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
139 let crate_dir = self.find_crate_dir()?;
140
141 let targets = vec![
143 "aarch64-apple-ios", "aarch64-apple-ios-sim", ];
146
147 self.check_rust_targets(&targets)?;
149
150 for target in targets {
151 if self.verbose {
152 println!(" Building for {}", target);
153 }
154
155 let mut cmd = Command::new("cargo");
156 cmd.arg("build").arg("--target").arg(target).arg("--lib");
157
158 if matches!(config.profile, BuildProfile::Release) {
160 cmd.arg("--release");
161 }
162
163 cmd.current_dir(&crate_dir);
165
166 let output = cmd
168 .output()
169 .map_err(|e| BenchError::Build(format!("Failed to run cargo: {}", e)))?;
170
171 if !output.status.success() {
172 let stderr = String::from_utf8_lossy(&output.stderr);
173 return Err(BenchError::Build(format!(
174 "cargo build failed for {}: {}",
175 target, stderr
176 )));
177 }
178 }
179
180 Ok(())
181 }
182
183 fn check_rust_targets(&self, targets: &[&str]) -> Result<(), BenchError> {
185 let output = Command::new("rustup")
186 .arg("target")
187 .arg("list")
188 .arg("--installed")
189 .output()
190 .map_err(|e| BenchError::Build(format!("Failed to check rustup targets: {}", e)))?;
191
192 let installed = String::from_utf8_lossy(&output.stdout);
193
194 for target in targets {
195 if !installed.contains(target) {
196 return Err(BenchError::Build(format!(
197 "Rust target {} is not installed. Install it with: rustup target add {}",
198 target, target
199 )));
200 }
201 }
202
203 Ok(())
204 }
205
206 fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
208 let crate_dir = self.find_crate_dir()?;
209 let crate_name_underscored = self.crate_name.replace("-", "_");
210
211 let bindings_path = self
213 .output_dir
214 .join("ios")
215 .join("BenchRunner")
216 .join("BenchRunner")
217 .join("Generated")
218 .join(format!("{}.swift", crate_name_underscored));
219
220 if bindings_path.exists() {
221 if self.verbose {
222 println!(" Using existing Swift bindings at {:?}", bindings_path);
223 }
224 return Ok(());
225 }
226
227 let uniffi_available = Command::new("uniffi-bindgen")
229 .arg("--version")
230 .output()
231 .map(|o| o.status.success())
232 .unwrap_or(false);
233
234 if !uniffi_available {
235 return Err(BenchError::Build(
236 "uniffi-bindgen not found and no pre-generated bindings exist.\n\
237 Install it with: cargo install uniffi-bindgen\n\
238 Or use pre-generated bindings by copying them to the expected location."
239 .to_string(),
240 ));
241 }
242
243 let mut build_cmd = Command::new("cargo");
245 build_cmd.arg("build");
246 build_cmd.current_dir(&crate_dir);
247 run_command(build_cmd, "cargo build (host)")?;
248
249 let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
250 let out_dir = self
251 .output_dir
252 .join("ios")
253 .join("BenchRunner")
254 .join("BenchRunner")
255 .join("Generated");
256 fs::create_dir_all(&out_dir).map_err(|e| {
257 BenchError::Build(format!("Failed to create Swift bindings dir: {}", e))
258 })?;
259
260 let mut cmd = Command::new("uniffi-bindgen");
261 cmd.arg("generate")
262 .arg("--library")
263 .arg(&lib_path)
264 .arg("--language")
265 .arg("swift")
266 .arg("--out-dir")
267 .arg(&out_dir);
268 run_command(cmd, "uniffi-bindgen swift")?;
269
270 if self.verbose {
271 println!(" Generated UniFFI Swift bindings at {:?}", out_dir);
272 }
273
274 Ok(())
275 }
276
277 fn create_xcframework(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
279 let profile_dir = match config.profile {
280 BuildProfile::Debug => "debug",
281 BuildProfile::Release => "release",
282 };
283
284 let target_dir = self.project_root.join("target");
285 let xcframework_dir = self.output_dir.join("ios");
286 let framework_name = &self.crate_name.replace("-", "_");
287 let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name));
288
289 if xcframework_path.exists() {
291 fs::remove_dir_all(&xcframework_path).map_err(|e| {
292 BenchError::Build(format!("Failed to remove old xcframework: {}", e))
293 })?;
294 }
295
296 fs::create_dir_all(&xcframework_dir).map_err(|e| {
298 BenchError::Build(format!("Failed to create xcframework directory: {}", e))
299 })?;
300
301 self.create_framework_slice(
303 &target_dir.join("aarch64-apple-ios").join(profile_dir),
304 &xcframework_path.join("ios-arm64"),
305 framework_name,
306 "ios",
307 )?;
308
309 self.create_framework_slice(
310 &target_dir.join("aarch64-apple-ios-sim").join(profile_dir),
311 &xcframework_path.join("ios-simulator-arm64"),
312 framework_name,
313 "ios-simulator",
314 )?;
315
316 self.create_xcframework_plist(&xcframework_path, framework_name)?;
318
319 Ok(xcframework_path)
320 }
321
322 fn create_framework_slice(
324 &self,
325 lib_path: &Path,
326 output_dir: &Path,
327 framework_name: &str,
328 platform: &str,
329 ) -> Result<(), BenchError> {
330 let framework_dir = output_dir.join(format!("{}.framework", framework_name));
331 let headers_dir = framework_dir.join("Headers");
332
333 fs::create_dir_all(&headers_dir).map_err(|e| {
335 BenchError::Build(format!("Failed to create framework directories: {}", e))
336 })?;
337
338 let src_lib = lib_path.join(format!("lib{}.a", framework_name));
340 let dest_lib = framework_dir.join(framework_name);
341
342 if !src_lib.exists() {
343 return Err(BenchError::Build(format!(
344 "Static library not found: {:?}",
345 src_lib
346 )));
347 }
348
349 fs::copy(&src_lib, &dest_lib)
350 .map_err(|e| BenchError::Build(format!("Failed to copy static library: {}", e)))?;
351
352 let header_name = format!("{}FFI.h", framework_name);
354 let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
355 BenchError::Build(format!(
356 "UniFFI header {} not found; run binding generation before building",
357 header_name
358 ))
359 })?;
360 fs::copy(&header_path, headers_dir.join(&header_name)).map_err(|e| {
361 BenchError::Build(format!(
362 "Failed to copy UniFFI header from {:?}: {}",
363 header_path, e
364 ))
365 })?;
366
367 let modulemap_content = format!(
369 "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}",
370 framework_name, framework_name
371 );
372 fs::write(headers_dir.join("module.modulemap"), modulemap_content)
373 .map_err(|e| BenchError::Build(format!("Failed to write module.modulemap: {}", e)))?;
374
375 self.create_framework_plist(&framework_dir, framework_name, platform)?;
377
378 Ok(())
379 }
380
381 fn create_framework_plist(
383 &self,
384 framework_dir: &Path,
385 framework_name: &str,
386 platform: &str,
387 ) -> Result<(), BenchError> {
388 let bundle_id = framework_name.replace('_', "-");
389 let plist_content = format!(
390 r#"<?xml version="1.0" encoding="UTF-8"?>
391<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
392<plist version="1.0">
393<dict>
394 <key>CFBundleExecutable</key>
395 <string>{}</string>
396 <key>CFBundleIdentifier</key>
397 <string>dev.world.{}</string>
398 <key>CFBundleInfoDictionaryVersion</key>
399 <string>6.0</string>
400 <key>CFBundleName</key>
401 <string>{}</string>
402 <key>CFBundlePackageType</key>
403 <string>FMWK</string>
404 <key>CFBundleShortVersionString</key>
405 <string>0.1.0</string>
406 <key>CFBundleVersion</key>
407 <string>1</string>
408 <key>CFBundleSupportedPlatforms</key>
409 <array>
410 <string>{}</string>
411 </array>
412</dict>
413</plist>"#,
414 framework_name,
415 bundle_id,
416 framework_name,
417 if platform == "ios" {
418 "iPhoneOS"
419 } else {
420 "iPhoneSimulator"
421 }
422 );
423
424 fs::write(framework_dir.join("Info.plist"), plist_content).map_err(|e| {
425 BenchError::Build(format!("Failed to write framework Info.plist: {}", e))
426 })?;
427
428 Ok(())
429 }
430
431 fn create_xcframework_plist(
433 &self,
434 xcframework_path: &Path,
435 framework_name: &str,
436 ) -> Result<(), BenchError> {
437 let plist_content = format!(
438 r#"<?xml version="1.0" encoding="UTF-8"?>
439<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
440<plist version="1.0">
441<dict>
442 <key>AvailableLibraries</key>
443 <array>
444 <dict>
445 <key>LibraryIdentifier</key>
446 <string>ios-arm64</string>
447 <key>LibraryPath</key>
448 <string>{}.framework</string>
449 <key>SupportedArchitectures</key>
450 <array>
451 <string>arm64</string>
452 </array>
453 <key>SupportedPlatform</key>
454 <string>ios</string>
455 </dict>
456 <dict>
457 <key>LibraryIdentifier</key>
458 <string>ios-simulator-arm64</string>
459 <key>LibraryPath</key>
460 <string>{}.framework</string>
461 <key>SupportedArchitectures</key>
462 <array>
463 <string>arm64</string>
464 </array>
465 <key>SupportedPlatform</key>
466 <string>ios</string>
467 <key>SupportedPlatformVariant</key>
468 <string>simulator</string>
469 </dict>
470 </array>
471 <key>CFBundlePackageType</key>
472 <string>XFWK</string>
473 <key>XCFrameworkFormatVersion</key>
474 <string>1.0</string>
475</dict>
476</plist>"#,
477 framework_name, framework_name
478 );
479
480 fs::write(xcframework_path.join("Info.plist"), plist_content).map_err(|e| {
481 BenchError::Build(format!("Failed to write xcframework Info.plist: {}", e))
482 })?;
483
484 Ok(())
485 }
486
487 fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> {
489 let output = Command::new("codesign")
490 .arg("--force")
491 .arg("--deep")
492 .arg("--sign")
493 .arg("-")
494 .arg(xcframework_path)
495 .output();
496
497 match output {
498 Ok(output) if output.status.success() => {
499 if self.verbose {
500 println!(" Successfully code-signed xcframework");
501 }
502 Ok(())
503 }
504 Ok(output) => {
505 let stderr = String::from_utf8_lossy(&output.stderr);
506 println!("Warning: Code signing failed: {}", stderr);
507 println!("You may need to manually sign the xcframework");
508 Ok(()) }
510 Err(e) => {
511 println!("Warning: Could not run codesign: {}", e);
512 println!("You may need to manually sign the xcframework");
513 Ok(()) }
515 }
516 }
517
518 fn generate_xcode_project(&self) -> Result<(), BenchError> {
520 let ios_dir = self.output_dir.join("ios");
521 let project_yml = ios_dir.join("BenchRunner/project.yml");
522
523 if !project_yml.exists() {
524 if self.verbose {
525 println!(" No project.yml found, skipping xcodegen");
526 }
527 return Ok(());
528 }
529
530 if self.verbose {
531 println!(" Generating Xcode project with xcodegen");
532 }
533
534 let output = Command::new("xcodegen")
535 .arg("generate")
536 .current_dir(ios_dir.join("BenchRunner"))
537 .output();
538
539 match output {
540 Ok(output) if output.status.success() => Ok(()),
541 Ok(output) => {
542 let stderr = String::from_utf8_lossy(&output.stderr);
543 Err(BenchError::Build(format!("xcodegen failed: {}", stderr)))
544 }
545 Err(e) => {
546 println!("Warning: xcodegen not found or failed: {}", e);
547 println!("Install xcodegen with: brew install xcodegen");
548 Ok(()) }
550 }
551 }
552
553 fn find_uniffi_header(&self, header_name: &str) -> Option<PathBuf> {
555 let swift_dir = self
557 .output_dir
558 .join("ios/BenchRunner/BenchRunner/Generated");
559 let candidate_swift = swift_dir.join(header_name);
560 if candidate_swift.exists() {
561 return Some(candidate_swift);
562 }
563
564 let target_dir = self.project_root.join("target");
565 let candidate = target_dir.join("uniffi").join(header_name);
567 if candidate.exists() {
568 return Some(candidate);
569 }
570
571 let mut stack = vec![target_dir];
573 while let Some(dir) = stack.pop() {
574 if let Ok(entries) = fs::read_dir(&dir) {
575 for entry in entries.flatten() {
576 let path = entry.path();
577 if path.is_dir() {
578 if let Some(name) = path.file_name().and_then(|n| n.to_str())
580 && name == "incremental"
581 {
582 continue;
583 }
584 stack.push(path);
585 } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
586 && name == header_name
587 {
588 return Some(path);
589 }
590 }
591 }
592 }
593
594 None
595 }
596}
597
598fn host_lib_path(project_dir: &Path, crate_name: &str) -> Result<PathBuf, BenchError> {
600 let lib_prefix = if cfg!(target_os = "windows") {
601 ""
602 } else {
603 "lib"
604 };
605 let lib_ext = match env::consts::OS {
606 "macos" => "dylib",
607 "linux" => "so",
608 other => {
609 return Err(BenchError::Build(format!(
610 "unsupported host OS for binding generation: {}",
611 other
612 )));
613 }
614 };
615 let path = project_dir.join("target").join("debug").join(format!(
616 "{}{}.{}",
617 lib_prefix,
618 crate_name.replace('-', "_"),
619 lib_ext
620 ));
621 if !path.exists() {
622 return Err(BenchError::Build(format!(
623 "host library for UniFFI not found at {:?}",
624 path
625 )));
626 }
627 Ok(path)
628}
629
630fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> {
631 let output = cmd
632 .output()
633 .map_err(|e| BenchError::Build(format!("Failed to run {}: {}", description, e)))?;
634 if !output.status.success() {
635 let stderr = String::from_utf8_lossy(&output.stderr);
636 return Err(BenchError::Build(format!(
637 "{} failed: {}",
638 description, stderr
639 )));
640 }
641 Ok(())
642}
643
644#[allow(clippy::collapsible_if)]
645fn find_codesign_identity() -> Option<String> {
646 let output = Command::new("security")
647 .args(["find-identity", "-v", "-p", "codesigning"])
648 .output()
649 .ok()?;
650 if !output.status.success() {
651 return None;
652 }
653 let stdout = String::from_utf8_lossy(&output.stdout);
654 let mut identities = Vec::new();
655 for line in stdout.lines() {
656 if let Some(start) = line.find('"') {
657 if let Some(end) = line[start + 1..].find('"') {
658 identities.push(line[start + 1..start + 1 + end].to_string());
659 }
660 }
661 }
662 let preferred = [
663 "Apple Distribution",
664 "iPhone Distribution",
665 "Apple Development",
666 "iPhone Developer",
667 ];
668 for label in preferred {
669 if let Some(identity) = identities.iter().find(|i| i.contains(label)) {
670 return Some(identity.clone());
671 }
672 }
673 identities.first().cloned()
674}
675
676#[allow(clippy::collapsible_if)]
677fn find_provisioning_profile() -> Option<PathBuf> {
678 if let Ok(path) = env::var("MOBENCH_IOS_PROFILE") {
679 let profile = PathBuf::from(path);
680 if profile.exists() {
681 return Some(profile);
682 }
683 }
684 let home = env::var("HOME").ok()?;
685 let profiles_dir = PathBuf::from(home).join("Library/MobileDevice/Provisioning Profiles");
686 let entries = fs::read_dir(&profiles_dir).ok()?;
687 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
688 for entry in entries.flatten() {
689 let path = entry.path();
690 if path.extension().and_then(|e| e.to_str()) != Some("mobileprovision") {
691 continue;
692 }
693 if let Ok(metadata) = entry.metadata()
694 && let Ok(modified) = metadata.modified()
695 {
696 match &newest {
697 Some((current, _)) if *current >= modified => {}
698 _ => newest = Some((modified, path)),
699 }
700 }
701 }
702 newest.map(|(_, path)| path)
703}
704
705fn embed_provisioning_profile(app_path: &Path, profile: &Path) -> Result<(), BenchError> {
706 let dest = app_path.join("embedded.mobileprovision");
707 fs::copy(profile, &dest).map_err(|e| {
708 BenchError::Build(format!(
709 "Failed to embed provisioning profile {:?}: {}",
710 dest, e
711 ))
712 })?;
713 Ok(())
714}
715
716fn codesign_bundle(app_path: &Path, identity: &str) -> Result<(), BenchError> {
717 let output = Command::new("codesign")
718 .args(["--force", "--deep", "--sign", identity])
719 .arg(app_path)
720 .output()
721 .map_err(|e| BenchError::Build(format!("Failed to run codesign: {}", e)))?;
722 if !output.status.success() {
723 let stderr = String::from_utf8_lossy(&output.stderr);
724 return Err(BenchError::Build(format!("codesign failed: {}", stderr)));
725 }
726 Ok(())
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731pub enum SigningMethod {
732 AdHoc,
734 Development,
736}
737
738impl IosBuilder {
739 pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result<PathBuf, BenchError> {
767 let ios_dir = self.output_dir.join("ios").join(scheme);
770 let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
771
772 if !project_path.exists() {
774 return Err(BenchError::Build(format!(
775 "Xcode project not found at {:?}. Run `cargo mobench build --target ios` first.",
776 project_path
777 )));
778 }
779
780 let export_path = self.output_dir.join("ios");
781 let ipa_path = export_path.join(format!("{}.ipa", scheme));
782
783 fs::create_dir_all(&export_path)
785 .map_err(|e| BenchError::Build(format!("Failed to create export directory: {}", e)))?;
786
787 println!("Building {} for device...", scheme);
788
789 let build_dir = self.output_dir.join("ios/build");
791 let build_configuration = "Debug";
792 let mut cmd = Command::new("xcodebuild");
793 cmd.args([
794 "-project",
795 project_path.to_str().unwrap(),
796 "-scheme",
797 scheme,
798 "-destination",
799 "generic/platform=iOS",
800 "-configuration",
801 build_configuration,
802 "-derivedDataPath",
803 build_dir.to_str().unwrap(),
804 "build",
805 ]);
806
807 match method {
809 SigningMethod::AdHoc => {
810 cmd.args(["CODE_SIGNING_REQUIRED=NO", "CODE_SIGNING_ALLOWED=NO"]);
813 }
814 SigningMethod::Development => {
815 cmd.args([
817 "CODE_SIGN_STYLE=Automatic",
818 "CODE_SIGN_IDENTITY=iPhone Developer",
819 ]);
820 }
821 }
822
823 if self.verbose {
824 println!(" Running: {:?}", cmd);
825 }
826
827 let build_result = cmd.output();
829
830 let app_path = build_dir
832 .join(format!("Build/Products/{}-iphoneos", build_configuration))
833 .join(format!("{}.app", scheme));
834
835 if !app_path.exists() {
836 if let Ok(output) = build_result {
838 let stderr = String::from_utf8_lossy(&output.stderr);
839 return Err(BenchError::Build(format!(
840 "xcodebuild build failed and app bundle not found: {}",
841 stderr
842 )));
843 }
844 return Err(BenchError::Build(format!(
845 "App bundle not found at {:?}. Build may have failed.",
846 app_path
847 )));
848 }
849
850 if self.verbose {
851 println!(" App bundle created successfully at {:?}", app_path);
852 }
853
854 if matches!(method, SigningMethod::AdHoc) {
855 let profile = find_provisioning_profile();
856 let identity = find_codesign_identity();
857 match (profile.as_ref(), identity.as_ref()) {
858 (Some(profile), Some(identity)) => {
859 embed_provisioning_profile(&app_path, profile)?;
860 codesign_bundle(&app_path, identity)?;
861 if self.verbose {
862 println!(" Signed app bundle with identity {}", identity);
863 }
864 }
865 _ => {
866 let output = Command::new("codesign")
867 .arg("--force")
868 .arg("--deep")
869 .arg("--sign")
870 .arg("-")
871 .arg(&app_path)
872 .output();
873 match output {
874 Ok(output) if output.status.success() => {
875 println!(
876 "Warning: Signed app bundle without provisioning profile; BrowserStack install may fail."
877 );
878 }
879 Ok(output) => {
880 let stderr = String::from_utf8_lossy(&output.stderr);
881 println!("Warning: Ad-hoc signing failed: {}", stderr);
882 }
883 Err(err) => {
884 println!("Warning: Could not run codesign: {}", err);
885 }
886 }
887 }
888 }
889 }
890
891 println!("Creating IPA from app bundle...");
892
893 let payload_dir = export_path.join("Payload");
895 if payload_dir.exists() {
896 fs::remove_dir_all(&payload_dir).map_err(|e| {
897 BenchError::Build(format!("Failed to remove old Payload dir: {}", e))
898 })?;
899 }
900 fs::create_dir_all(&payload_dir)
901 .map_err(|e| BenchError::Build(format!("Failed to create Payload dir: {}", e)))?;
902
903 let dest_app = payload_dir.join(format!("{}.app", scheme));
905 self.copy_dir_recursive(&app_path, &dest_app)?;
906
907 if ipa_path.exists() {
909 fs::remove_file(&ipa_path)
910 .map_err(|e| BenchError::Build(format!("Failed to remove old IPA: {}", e)))?;
911 }
912
913 let mut cmd = Command::new("zip");
914 cmd.args(["-qr", ipa_path.to_str().unwrap(), "Payload"])
915 .current_dir(&export_path);
916
917 if self.verbose {
918 println!(" Running: {:?}", cmd);
919 }
920
921 run_command(cmd, "zip IPA")?;
922
923 fs::remove_dir_all(&payload_dir)
925 .map_err(|e| BenchError::Build(format!("Failed to clean up Payload dir: {}", e)))?;
926
927 println!("✓ IPA created: {:?}", ipa_path);
928 Ok(ipa_path)
929 }
930
931 pub fn package_xcuitest(&self, scheme: &str) -> Result<PathBuf, BenchError> {
936 let ios_dir = self.output_dir.join("ios").join(scheme);
937 let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
938
939 if !project_path.exists() {
940 return Err(BenchError::Build(format!(
941 "Xcode project not found at {:?}. Run `cargo mobench build --target ios` first.",
942 project_path
943 )));
944 }
945
946 let export_path = self.output_dir.join("ios");
947 fs::create_dir_all(&export_path)
948 .map_err(|e| BenchError::Build(format!("Failed to create export directory: {}", e)))?;
949
950 let build_dir = self.output_dir.join("ios/build");
951 println!("Building XCUITest runner for {}...", scheme);
952
953 let mut cmd = Command::new("xcodebuild");
954 cmd.args([
955 "build-for-testing",
956 "-project",
957 project_path.to_str().unwrap(),
958 "-scheme",
959 scheme,
960 "-destination",
961 "generic/platform=iOS",
962 "-sdk",
963 "iphoneos",
964 "-configuration",
965 "Release",
966 "-derivedDataPath",
967 build_dir.to_str().unwrap(),
968 "VALIDATE_PRODUCT=NO",
969 "CODE_SIGN_STYLE=Manual",
970 "CODE_SIGN_IDENTITY=",
971 "CODE_SIGNING_ALLOWED=NO",
972 "CODE_SIGNING_REQUIRED=NO",
973 "DEVELOPMENT_TEAM=",
974 "PROVISIONING_PROFILE_SPECIFIER=",
975 "ENABLE_BITCODE=NO",
976 "BITCODE_GENERATION_MODE=none",
977 "STRIP_BITCODE_FROM_COPIED_FILES=NO",
978 ]);
979
980 if self.verbose {
981 println!(" Running: {:?}", cmd);
982 }
983
984 let runner_name = format!("{}UITests-Runner.app", scheme);
985 let runner_path = build_dir
986 .join("Build/Products/Release-iphoneos")
987 .join(&runner_name);
988
989 let build_result = cmd.output();
990 let log_path = export_path.join("xcuitest-build.log");
991 if let Ok(output) = &build_result
992 && !output.status.success()
993 {
994 let mut log = String::new();
995 let stdout = String::from_utf8_lossy(&output.stdout);
996 let stderr = String::from_utf8_lossy(&output.stderr);
997 log.push_str("STDOUT:\n");
998 log.push_str(&stdout);
999 log.push_str("\n\nSTDERR:\n");
1000 log.push_str(&stderr);
1001 let _ = fs::write(&log_path, log);
1002 println!("xcodebuild log written to {:?}", log_path);
1003 if runner_path.exists() {
1004 println!(
1005 "Warning: xcodebuild build-for-testing failed, but runner exists: {}",
1006 stderr
1007 );
1008 }
1009 }
1010
1011 if !runner_path.exists() {
1012 if let Ok(output) = build_result {
1013 let stderr = String::from_utf8_lossy(&output.stderr);
1014 return Err(BenchError::Build(format!(
1015 "xcodebuild build-for-testing failed and runner not found: {}",
1016 stderr
1017 )));
1018 }
1019 return Err(BenchError::Build(format!(
1020 "XCUITest runner not found at {:?}. Build may have failed.",
1021 runner_path
1022 )));
1023 }
1024
1025 let profile = find_provisioning_profile();
1026 let identity = find_codesign_identity();
1027 if let (Some(profile), Some(identity)) = (profile.as_ref(), identity.as_ref()) {
1028 embed_provisioning_profile(&runner_path, profile)?;
1029 codesign_bundle(&runner_path, identity)?;
1030 if self.verbose {
1031 println!(" Signed XCUITest runner with identity {}", identity);
1032 }
1033 } else {
1034 println!(
1035 "Warning: No provisioning profile/identity found; XCUITest runner may not install."
1036 );
1037 }
1038
1039 let zip_path = export_path.join(format!("{}UITests.zip", scheme));
1040 if zip_path.exists() {
1041 fs::remove_file(&zip_path)
1042 .map_err(|e| BenchError::Build(format!("Failed to remove old zip: {}", e)))?;
1043 }
1044
1045 let mut zip_cmd = Command::new("zip");
1046 zip_cmd
1047 .args(["-qr", zip_path.to_str().unwrap(), runner_name.as_str()])
1048 .current_dir(runner_path.parent().unwrap());
1049
1050 if self.verbose {
1051 println!(" Running: {:?}", zip_cmd);
1052 }
1053
1054 run_command(zip_cmd, "zip XCUITest runner")?;
1055 println!("✓ XCUITest runner packaged: {:?}", zip_path);
1056
1057 Ok(zip_path)
1058 }
1059
1060 fn copy_dir_recursive(&self, src: &Path, dest: &Path) -> Result<(), BenchError> {
1062 fs::create_dir_all(dest).map_err(|e| {
1063 BenchError::Build(format!("Failed to create directory {:?}: {}", dest, e))
1064 })?;
1065
1066 for entry in fs::read_dir(src)
1067 .map_err(|e| BenchError::Build(format!("Failed to read directory {:?}: {}", src, e)))?
1068 {
1069 let entry =
1070 entry.map_err(|e| BenchError::Build(format!("Failed to read entry: {}", e)))?;
1071 let path = entry.path();
1072 let file_name = path
1073 .file_name()
1074 .ok_or_else(|| BenchError::Build(format!("Invalid file name in {:?}", path)))?;
1075 let dest_path = dest.join(file_name);
1076
1077 if path.is_dir() {
1078 self.copy_dir_recursive(&path, &dest_path)?;
1079 } else {
1080 fs::copy(&path, &dest_path).map_err(|e| {
1081 BenchError::Build(format!(
1082 "Failed to copy {:?} to {:?}: {}",
1083 path, dest_path, e
1084 ))
1085 })?;
1086 }
1087 }
1088
1089 Ok(())
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096
1097 #[test]
1098 fn test_ios_builder_creation() {
1099 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
1100 assert!(!builder.verbose);
1101 assert_eq!(
1102 builder.output_dir,
1103 PathBuf::from("/tmp/test-project/target/mobench")
1104 );
1105 }
1106
1107 #[test]
1108 fn test_ios_builder_verbose() {
1109 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
1110 assert!(builder.verbose);
1111 }
1112
1113 #[test]
1114 fn test_ios_builder_custom_output_dir() {
1115 let builder =
1116 IosBuilder::new("/tmp/test-project", "test-bench-mobile").output_dir("/custom/output");
1117 assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
1118 }
1119}