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    /// Output directory for mobile artifacts (defaults to target/mobench)
17    output_dir: PathBuf,
18    /// Name of the bench-mobile crate
19    crate_name: String,
20    /// Whether to use verbose output
21    verbose: bool,
22}
23
24impl IosBuilder {
25    /// Creates a new iOS builder
26    ///
27    /// # Arguments
28    ///
29    /// * `project_root` - Root directory containing the bench-mobile crate
30    /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile")
31    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    /// Sets the output directory for mobile artifacts
42    ///
43    /// By default, artifacts are written to `{project_root}/target/mobench/`.
44    /// Use this to customize the output location.
45    pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
46        self.output_dir = dir.into();
47        self
48    }
49
50    /// Enables verbose output
51    pub fn verbose(mut self, verbose: bool) -> Self {
52        self.verbose = verbose;
53        self
54    }
55
56    /// Builds the iOS app with the given configuration
57    ///
58    /// This performs the following steps:
59    /// 1. Build Rust libraries for iOS targets (device + simulator)
60    /// 2. Generate UniFFI Swift bindings and C headers
61    /// 3. Create xcframework with proper structure
62    /// 4. Code-sign the xcframework
63    /// 5. Generate Xcode project with xcodegen (if project.yml exists)
64    ///
65    /// # Returns
66    ///
67    /// * `Ok(BuildResult)` containing the path to the xcframework
68    /// * `Err(BenchError)` if the build fails
69    pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
70        let framework_name = self.crate_name.replace("-", "_");
71        // Step 1: Build Rust libraries
72        println!("Building Rust libraries for iOS...");
73        self.build_rust_libraries(config)?;
74
75        // Step 2: Generate UniFFI bindings
76        println!("Generating UniFFI Swift bindings...");
77        self.generate_uniffi_bindings()?;
78
79        // Step 3: Create xcframework
80        println!("Creating xcframework...");
81        let xcframework_path = self.create_xcframework(config)?;
82
83        // Step 4: Code-sign xcframework
84        println!("Code-signing xcframework...");
85        self.codesign_xcframework(&xcframework_path)?;
86
87        // Copy header to include/ for consumers (handy for CLI uploads)
88        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        // Step 5: Generate Xcode project if needed
108        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    /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/)
118    fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
119        // Try bench-mobile/ first (SDK projects)
120        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        // Try crates/{crate_name}/ (repository structure)
126        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    /// Builds Rust libraries for iOS targets
138    fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
139        let crate_dir = self.find_crate_dir()?;
140
141        // iOS targets: device and simulator
142        let targets = vec![
143            "aarch64-apple-ios",     // Device (ARM64)
144            "aarch64-apple-ios-sim", // Simulator (M1+ Macs)
145        ];
146
147        // Check if targets are installed
148        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            // Add release flag if needed
159            if matches!(config.profile, BuildProfile::Release) {
160                cmd.arg("--release");
161            }
162
163            // Set working directory
164            cmd.current_dir(&crate_dir);
165
166            // Execute build
167            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    /// Checks if required Rust targets are installed
184    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    /// Generates UniFFI Swift bindings
207    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        // Check if bindings already exist (for repository testing with pre-generated bindings)
212        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        // Check if uniffi-bindgen is available
228        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        // Build host library to feed uniffi-bindgen
244        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    /// Creates an xcframework from the built libraries
278    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        // Remove existing xcframework if it exists
290        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        // Create xcframework directory
297        fs::create_dir_all(&xcframework_dir).map_err(|e| {
298            BenchError::Build(format!("Failed to create xcframework directory: {}", e))
299        })?;
300
301        // Build framework structure for each platform
302        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        // Create xcframework Info.plist
317        self.create_xcframework_plist(&xcframework_path, framework_name)?;
318
319        Ok(xcframework_path)
320    }
321
322    /// Creates a framework slice for a specific platform
323    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        // Create directories
334        fs::create_dir_all(&headers_dir).map_err(|e| {
335            BenchError::Build(format!("Failed to create framework directories: {}", e))
336        })?;
337
338        // Copy static library
339        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        // Copy UniFFI-generated header into the framework
353        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        // Create module.modulemap
368        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        // Create framework Info.plist
376        self.create_framework_plist(&framework_dir, framework_name, platform)?;
377
378        Ok(())
379    }
380
381    /// Creates Info.plist for a framework slice
382    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    /// Creates xcframework Info.plist
432    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    /// Code-signs the xcframework
488    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(()) // Don't fail the build for signing issues
509            }
510            Err(e) => {
511                println!("Warning: Could not run codesign: {}", e);
512                println!("You may need to manually sign the xcframework");
513                Ok(()) // Don't fail the build if codesign is not available
514            }
515        }
516    }
517
518    /// Generates Xcode project using xcodegen if project.yml exists
519    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(()) // Don't fail if xcodegen is not available
549            }
550        }
551    }
552
553    /// Locate the generated UniFFI header for the crate
554    fn find_uniffi_header(&self, header_name: &str) -> Option<PathBuf> {
555        // Check generated Swift bindings directory first
556        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        // Common UniFFI output location when using uniffi::generate_scaffolding
566        let candidate = target_dir.join("uniffi").join(header_name);
567        if candidate.exists() {
568            return Some(candidate);
569        }
570
571        // Fallback: walk the target directory for the header
572        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                        // Limit depth by skipping non-target subtrees such as incremental caches
579                        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
598// Shared helpers (duplicated with android builder)
599fn 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/// iOS code signing methods for IPA packaging
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731pub enum SigningMethod {
732    /// Ad-hoc signing (no Apple ID required, works for BrowserStack testing)
733    AdHoc,
734    /// Development signing (requires Apple Developer account and provisioning profile)
735    Development,
736}
737
738impl IosBuilder {
739    /// Packages the iOS app as an IPA file for distribution or testing
740    ///
741    /// This requires the app to have been built first with `build()`.
742    /// The IPA can be used for:
743    /// - BrowserStack device testing (ad-hoc signing)
744    /// - Physical device testing (development signing)
745    ///
746    /// # Arguments
747    ///
748    /// * `scheme` - The Xcode scheme to build (e.g., "BenchRunner")
749    /// * `method` - The signing method (AdHoc or Development)
750    ///
751    /// # Returns
752    ///
753    /// * `Ok(PathBuf)` - Path to the generated IPA file
754    /// * `Err(BenchError)` - If the build or packaging fails
755    ///
756    /// # Example
757    ///
758    /// ```no_run
759    /// use mobench_sdk::builders::{IosBuilder, SigningMethod};
760    ///
761    /// let builder = IosBuilder::new(".", "bench-mobile");
762    /// let ipa_path = builder.package_ipa("BenchRunner", SigningMethod::AdHoc)?;
763    /// println!("IPA created at: {:?}", ipa_path);
764    /// # Ok::<(), mobench_sdk::BenchError>(())
765    /// ```
766    pub fn package_ipa(&self, scheme: &str, method: SigningMethod) -> Result<PathBuf, BenchError> {
767        // For repository structure: ios/BenchRunner/BenchRunner.xcodeproj
768        // The directory and scheme happen to have the same name
769        let ios_dir = self.output_dir.join("ios").join(scheme);
770        let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
771
772        // Verify Xcode project exists
773        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        // Create target/ios directory if it doesn't exist
784        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        // Step 1: Build the app for device (simpler than archiving)
790        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        // Add signing parameters based on method
808        match method {
809            SigningMethod::AdHoc => {
810                // Ad-hoc signing (works for BrowserStack, no Apple ID needed)
811                // For ad-hoc, we disable signing during build and sign manually after
812                cmd.args(["CODE_SIGNING_REQUIRED=NO", "CODE_SIGNING_ALLOWED=NO"]);
813            }
814            SigningMethod::Development => {
815                // Development signing (requires Apple Developer account)
816                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        // Run the build - may fail on validation but still produce the .app
828        let build_result = cmd.output();
829
830        // Step 2: Check if the .app bundle was created (even if validation failed)
831        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            // Only fail if the .app wasn't created
837            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        // Step 3: Create IPA (which is just a zip of Payload/{app})
894        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        // Copy app bundle into Payload/
904        let dest_app = payload_dir.join(format!("{}.app", scheme));
905        self.copy_dir_recursive(&app_path, &dest_app)?;
906
907        // Create zip archive
908        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        // Clean up Payload directory
924        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    /// Packages the XCUITest runner app into a zip for BrowserStack.
932    ///
933    /// This requires the app project to be generated first with `build()`.
934    /// The resulting zip can be supplied to BrowserStack as the test suite.
935    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    /// Recursively copies a directory
1061    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}