mobench_sdk/builders/
ios.rs

1//! iOS build automation
2//!
3//! This module provides functionality to build Rust libraries for iOS and
4//! create an xcframework that can be used in Xcode projects.
5
6use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
7use std::env;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12/// iOS builder that handles the complete build pipeline
13pub struct IosBuilder {
14    /// Root directory of the project
15    project_root: PathBuf,
16    /// Name of the bench-mobile crate
17    crate_name: String,
18    /// Whether to use verbose output
19    verbose: bool,
20}
21
22impl IosBuilder {
23    /// Creates a new iOS builder
24    ///
25    /// # Arguments
26    ///
27    /// * `project_root` - Root directory containing the bench-mobile crate
28    /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile")
29    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    /// Enables verbose output
38    pub fn verbose(mut self, verbose: bool) -> Self {
39        self.verbose = verbose;
40        self
41    }
42
43    /// Builds the iOS app with the given configuration
44    ///
45    /// This performs the following steps:
46    /// 1. Build Rust libraries for iOS targets (device + simulator)
47    /// 2. Generate UniFFI Swift bindings and C headers
48    /// 3. Create xcframework with proper structure
49    /// 4. Code-sign the xcframework
50    /// 5. Generate Xcode project with xcodegen (if project.yml exists)
51    ///
52    /// # Returns
53    ///
54    /// * `Ok(BuildResult)` containing the path to the xcframework
55    /// * `Err(BenchError)` if the build fails
56    pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
57        let framework_name = self.crate_name.replace("-", "_");
58        // Step 1: Build Rust libraries
59        println!("Building Rust libraries for iOS...");
60        self.build_rust_libraries(config)?;
61
62        // Step 2: Generate UniFFI bindings
63        println!("Generating UniFFI Swift bindings...");
64        self.generate_uniffi_bindings()?;
65
66        // Step 3: Create xcframework
67        println!("Creating xcframework...");
68        let xcframework_path = self.create_xcframework(config)?;
69
70        // Step 4: Code-sign xcframework
71        println!("Code-signing xcframework...");
72        self.codesign_xcframework(&xcframework_path)?;
73
74        // Copy header to include/ for consumers (handy for CLI uploads)
75        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        // Step 5: Generate Xcode project if needed
95        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    /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/)
105    fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
106        // Try bench-mobile/ first (SDK projects)
107        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        // Try crates/{crate_name}/ (repository structure)
113        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    /// Builds Rust libraries for iOS targets
125    fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
126        let crate_dir = self.find_crate_dir()?;
127
128        // iOS targets: device and simulator
129        let targets = vec![
130            "aarch64-apple-ios",     // Device (ARM64)
131            "aarch64-apple-ios-sim", // Simulator (M1+ Macs)
132        ];
133
134        // Check if targets are installed
135        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            // Add release flag if needed
146            if matches!(config.profile, BuildProfile::Release) {
147                cmd.arg("--release");
148            }
149
150            // Set working directory
151            cmd.current_dir(&crate_dir);
152
153            // Execute build
154            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    /// Checks if required Rust targets are installed
171    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    /// Generates UniFFI Swift bindings
194    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        // Check if bindings already exist (for repository testing with pre-generated bindings)
199        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        // Check if uniffi-bindgen is available
215        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        // Build host library to feed uniffi-bindgen
231        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    /// Creates an xcframework from the built libraries
265    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        // Remove existing xcframework if it exists
277        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        // Create xcframework directory
284        fs::create_dir_all(&xcframework_dir).map_err(|e| {
285            BenchError::Build(format!("Failed to create xcframework directory: {}", e))
286        })?;
287
288        // Build framework structure for each platform
289        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        // Create xcframework Info.plist
304        self.create_xcframework_plist(&xcframework_path, framework_name)?;
305
306        Ok(xcframework_path)
307    }
308
309    /// Creates a framework slice for a specific platform
310    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        // Create directories
321        fs::create_dir_all(&headers_dir).map_err(|e| {
322            BenchError::Build(format!("Failed to create framework directories: {}", e))
323        })?;
324
325        // Copy static library
326        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        // Copy UniFFI-generated header into the framework
340        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        // Create module.modulemap
355        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        // Create framework Info.plist
363        self.create_framework_plist(&framework_dir, framework_name, platform)?;
364
365        Ok(())
366    }
367
368    /// Creates Info.plist for a framework slice
369    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    /// Creates xcframework Info.plist
419    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    /// Code-signs the xcframework
475    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(()) // Don't fail the build for signing issues
496            }
497            Err(e) => {
498                println!("Warning: Could not run codesign: {}", e);
499                println!("You may need to manually sign the xcframework");
500                Ok(()) // Don't fail the build if codesign is not available
501            }
502        }
503    }
504
505    /// Generates Xcode project using xcodegen if project.yml exists
506    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(()) // Don't fail if xcodegen is not available
536            }
537        }
538    }
539
540    /// Locate the generated UniFFI header for the crate
541    fn find_uniffi_header(&self, header_name: &str) -> Option<PathBuf> {
542        // Check generated Swift bindings directory first
543        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        // Common UniFFI output location when using uniffi::generate_scaffolding
553        let candidate = target_dir.join("uniffi").join(header_name);
554        if candidate.exists() {
555            return Some(candidate);
556        }
557
558        // Fallback: walk the target directory for the header
559        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                        // Limit depth by skipping non-target subtrees such as incremental caches
566                        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
585// Shared helpers (duplicated with android builder)
586fn 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/// iOS code signing methods for IPA packaging
717#[derive(Debug, Clone, Copy, PartialEq, Eq)]
718pub enum SigningMethod {
719    /// Ad-hoc signing (no Apple ID required, works for BrowserStack testing)
720    AdHoc,
721    /// Development signing (requires Apple Developer account and provisioning profile)
722    Development,
723}
724
725impl IosBuilder {
726    /// Packages the iOS app as an IPA file for distribution or testing
727    ///
728    /// This requires the app to have been built first with `build()`.
729    /// The IPA can be used for:
730    /// - BrowserStack device testing (ad-hoc signing)
731    /// - Physical device testing (development signing)
732    ///
733    /// # Arguments
734    ///
735    /// * `scheme` - The Xcode scheme to build (e.g., "BenchRunner")
736    /// * `method` - The signing method (AdHoc or Development)
737    ///
738    /// # Returns
739    ///
740    /// * `Ok(PathBuf)` - Path to the generated IPA file
741    /// * `Err(BenchError)` - If the build or packaging fails
742    ///
743    /// # Example
744    ///
745    /// ```no_run
746    /// use mobench_sdk::builders::{IosBuilder, SigningMethod};
747    ///
748    /// let builder = IosBuilder::new(".", "bench-mobile");
749    /// let ipa_path = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?;
750    /// println!("IPA created at: {:?}", ipa_path);
751    /// # Ok::<(), mobench_sdk::BenchError>(())
752    /// ```
753    pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result<PathBuf, BenchError> {
754        // For repository structure: ios/BenchRunner/BenchRunner.xcodeproj
755        // The directory and scheme happen to have the same name
756        let ios_dir = self.project_root.join("ios").join(scheme);
757        let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
758
759        // Verify Xcode project exists
760        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        // Create target/ios directory if it doesn't exist
771        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        // Step 1: Build the app for device (simpler than archiving)
777        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        // Add signing parameters based on method
795        match method {
796            SigningMethod::AdHoc => {
797                // Ad-hoc signing (works for BrowserStack, no Apple ID needed)
798                // For ad-hoc, we disable signing during build and sign manually after
799                cmd.args(["CODE_SIGNING_REQUIRED=NO", "CODE_SIGNING_ALLOWED=NO"]);
800            }
801            SigningMethod::Development => {
802                // Development signing (requires Apple Developer account)
803                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        // Run the build - may fail on validation but still produce the .app
815        let build_result = cmd.output();
816
817        // Step 2: Check if the .app bundle was created (even if validation failed)
818        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            // Only fail if the .app wasn't created
824            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        // Step 3: Create IPA (which is just a zip of Payload/{app})
881        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        // Copy app bundle into Payload/
891        let dest_app = payload_dir.join(format!("{}.app", scheme));
892        self.copy_dir_recursive(&app_path, &dest_app)?;
893
894        // Create zip archive
895        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        // Clean up Payload directory
911        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    /// Packages the XCUITest runner app into a zip for BrowserStack.
919    ///
920    /// This requires the app project to be generated first with `build()`.
921    /// The resulting zip can be supplied to BrowserStack as the test suite.
922    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    /// Recursively copies a directory
1048    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}