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