1use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
7use std::env;
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13pub struct IosBuilder {
15 project_root: PathBuf,
17 crate_name: String,
19 verbose: bool,
21}
22
23impl IosBuilder {
24 pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
31 Self {
32 project_root: project_root.into(),
33 crate_name: crate_name.into(),
34 verbose: false,
35 }
36 }
37
38 pub fn verbose(mut self, verbose: bool) -> Self {
40 self.verbose = verbose;
41 self
42 }
43
44 pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
58 let framework_name = self.crate_name.replace("-", "_");
59 println!("Building Rust libraries for iOS...");
61 self.build_rust_libraries(config)?;
62
63 println!("Generating UniFFI Swift bindings...");
65 self.generate_uniffi_bindings()?;
66
67 println!("Creating xcframework...");
69 let xcframework_path = self.create_xcframework(config)?;
70
71 println!("Code-signing xcframework...");
73 self.codesign_xcframework(&xcframework_path)?;
74
75 let header_src = self
77 .find_uniffi_header(&format!("{}FFI.h", framework_name))
78 .ok_or_else(|| {
79 BenchError::Build(format!(
80 "UniFFI header {}FFI.h not found after generation",
81 framework_name
82 ))
83 })?;
84 let include_dir = self.project_root.join("target/ios/include");
85 fs::create_dir_all(&include_dir)
86 .map_err(|e| BenchError::Build(format!("Failed to create include dir: {}", e)))?;
87 let header_dest = include_dir.join(format!("{}.h", framework_name));
88 fs::copy(&header_src, &header_dest).map_err(|e| {
89 BenchError::Build(format!(
90 "Failed to copy UniFFI header to {:?}: {}",
91 header_dest, e
92 ))
93 })?;
94
95 self.generate_xcode_project()?;
97
98 Ok(BuildResult {
99 platform: Target::Ios,
100 app_path: xcframework_path,
101 test_suite_path: None,
102 })
103 }
104
105 fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
107 let bench_mobile_dir = self.project_root.join("bench-mobile");
108
109 if !bench_mobile_dir.exists() {
110 return Err(BenchError::Build(format!(
111 "bench-mobile crate not found at {:?}",
112 bench_mobile_dir
113 )));
114 }
115
116 let targets = vec![
118 "aarch64-apple-ios", "aarch64-apple-ios-sim", ];
121
122 self.check_rust_targets(&targets)?;
124
125 for target in targets {
126 if self.verbose {
127 println!(" Building for {}", target);
128 }
129
130 let mut cmd = Command::new("cargo");
131 cmd.arg("build").arg("--target").arg(target).arg("--lib");
132
133 if matches!(config.profile, BuildProfile::Release) {
135 cmd.arg("--release");
136 }
137
138 cmd.current_dir(&bench_mobile_dir);
140
141 let output = cmd
143 .output()
144 .map_err(|e| BenchError::Build(format!("Failed to run cargo: {}", e)))?;
145
146 if !output.status.success() {
147 let stderr = String::from_utf8_lossy(&output.stderr);
148 return Err(BenchError::Build(format!(
149 "cargo build failed for {}: {}",
150 target, stderr
151 )));
152 }
153 }
154
155 Ok(())
156 }
157
158 fn check_rust_targets(&self, targets: &[&str]) -> Result<(), BenchError> {
160 let output = Command::new("rustup")
161 .arg("target")
162 .arg("list")
163 .arg("--installed")
164 .output()
165 .map_err(|e| BenchError::Build(format!("Failed to check rustup targets: {}", e)))?;
166
167 let installed = String::from_utf8_lossy(&output.stdout);
168
169 for target in targets {
170 if !installed.contains(target) {
171 return Err(BenchError::Build(format!(
172 "Rust target {} is not installed. Install it with: rustup target add {}",
173 target, target
174 )));
175 }
176 }
177
178 Ok(())
179 }
180
181 fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
183 let bench_mobile_dir = self.project_root.join("bench-mobile");
184 if !bench_mobile_dir.exists() {
185 return Err(BenchError::Build(format!(
186 "bench-mobile crate not found at {:?}",
187 bench_mobile_dir
188 )));
189 }
190
191 let mut build_cmd = Command::new("cargo");
193 build_cmd.arg("build");
194 build_cmd.current_dir(&bench_mobile_dir);
195 run_command(build_cmd, "cargo build (host)")?;
196
197 let lib_path = host_lib_path(&bench_mobile_dir, &self.crate_name)?;
198 let out_dir = self
199 .project_root
200 .join("ios")
201 .join("BenchRunner")
202 .join("BenchRunner")
203 .join("Generated");
204 fs::create_dir_all(&out_dir).map_err(|e| {
205 BenchError::Build(format!("Failed to create Swift bindings dir: {}", e))
206 })?;
207
208 let mut cmd = Command::new("uniffi-bindgen");
209 cmd.arg("generate")
210 .arg("--library")
211 .arg(&lib_path)
212 .arg("--language")
213 .arg("swift")
214 .arg("--out-dir")
215 .arg(&out_dir);
216 run_command(cmd, "uniffi-bindgen swift")?;
217
218 if self.verbose {
219 println!(" Generated UniFFI Swift bindings at {:?}", out_dir);
220 }
221
222 Ok(())
223 }
224
225 fn create_xcframework(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
227 let profile_dir = match config.profile {
228 BuildProfile::Debug => "debug",
229 BuildProfile::Release => "release",
230 };
231
232 let target_dir = self.project_root.join("target");
233 let xcframework_dir = target_dir.join("ios");
234 let framework_name = &self.crate_name.replace("-", "_");
235 let xcframework_path = xcframework_dir.join(format!("{}.xcframework", framework_name));
236
237 if xcframework_path.exists() {
239 fs::remove_dir_all(&xcframework_path).map_err(|e| {
240 BenchError::Build(format!("Failed to remove old xcframework: {}", e))
241 })?;
242 }
243
244 fs::create_dir_all(&xcframework_dir).map_err(|e| {
246 BenchError::Build(format!("Failed to create xcframework directory: {}", e))
247 })?;
248
249 self.create_framework_slice(
251 &target_dir.join("aarch64-apple-ios").join(profile_dir),
252 &xcframework_path.join("ios-arm64"),
253 framework_name,
254 "ios",
255 )?;
256
257 self.create_framework_slice(
258 &target_dir.join("aarch64-apple-ios-sim").join(profile_dir),
259 &xcframework_path.join("ios-simulator-arm64"),
260 framework_name,
261 "ios-simulator",
262 )?;
263
264 self.create_xcframework_plist(&xcframework_path, framework_name)?;
266
267 Ok(xcframework_path)
268 }
269
270 fn create_framework_slice(
272 &self,
273 lib_path: &Path,
274 output_dir: &Path,
275 framework_name: &str,
276 platform: &str,
277 ) -> Result<(), BenchError> {
278 let framework_dir = output_dir.join(format!("{}.framework", framework_name));
279 let headers_dir = framework_dir.join("Headers");
280
281 fs::create_dir_all(&headers_dir).map_err(|e| {
283 BenchError::Build(format!("Failed to create framework directories: {}", e))
284 })?;
285
286 let src_lib = lib_path.join(format!("lib{}.a", framework_name));
288 let dest_lib = framework_dir.join(framework_name);
289
290 if !src_lib.exists() {
291 return Err(BenchError::Build(format!(
292 "Static library not found: {:?}",
293 src_lib
294 )));
295 }
296
297 fs::copy(&src_lib, &dest_lib)
298 .map_err(|e| BenchError::Build(format!("Failed to copy static library: {}", e)))?;
299
300 let header_name = format!("{}FFI.h", framework_name);
302 let header_path = self.find_uniffi_header(&header_name).ok_or_else(|| {
303 BenchError::Build(format!(
304 "UniFFI header {} not found; run binding generation before building",
305 header_name
306 ))
307 })?;
308 fs::copy(&header_path, headers_dir.join(&header_name)).map_err(|e| {
309 BenchError::Build(format!(
310 "Failed to copy UniFFI header from {:?}: {}",
311 header_path, e
312 ))
313 })?;
314
315 let modulemap_content = format!(
317 "framework module {} {{\n umbrella header \"{}FFI.h\"\n export *\n module * {{ export * }}\n}}",
318 framework_name, framework_name
319 );
320 fs::write(headers_dir.join("module.modulemap"), modulemap_content)
321 .map_err(|e| BenchError::Build(format!("Failed to write module.modulemap: {}", e)))?;
322
323 self.create_framework_plist(&framework_dir, framework_name, platform)?;
325
326 Ok(())
327 }
328
329 fn create_framework_plist(
331 &self,
332 framework_dir: &Path,
333 framework_name: &str,
334 platform: &str,
335 ) -> Result<(), BenchError> {
336 let plist_content = format!(
337 r#"<?xml version="1.0" encoding="UTF-8"?>
338<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
339<plist version="1.0">
340<dict>
341 <key>CFBundleExecutable</key>
342 <string>{}</string>
343 <key>CFBundleIdentifier</key>
344 <string>dev.world.{}</string>
345 <key>CFBundleInfoDictionaryVersion</key>
346 <string>6.0</string>
347 <key>CFBundleName</key>
348 <string>{}</string>
349 <key>CFBundlePackageType</key>
350 <string>FMWK</string>
351 <key>CFBundleShortVersionString</key>
352 <string>0.1.0</string>
353 <key>CFBundleVersion</key>
354 <string>1</string>
355 <key>CFBundleSupportedPlatforms</key>
356 <array>
357 <string>{}</string>
358 </array>
359</dict>
360</plist>"#,
361 framework_name,
362 framework_name,
363 framework_name,
364 if platform == "ios" {
365 "iPhoneOS"
366 } else {
367 "iPhoneSimulator"
368 }
369 );
370
371 fs::write(framework_dir.join("Info.plist"), plist_content).map_err(|e| {
372 BenchError::Build(format!("Failed to write framework Info.plist: {}", e))
373 })?;
374
375 Ok(())
376 }
377
378 fn create_xcframework_plist(
380 &self,
381 xcframework_path: &Path,
382 framework_name: &str,
383 ) -> Result<(), BenchError> {
384 let plist_content = format!(
385 r#"<?xml version="1.0" encoding="UTF-8"?>
386<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
387<plist version="1.0">
388<dict>
389 <key>AvailableLibraries</key>
390 <array>
391 <dict>
392 <key>LibraryIdentifier</key>
393 <string>ios-arm64</string>
394 <key>LibraryPath</key>
395 <string>{}.framework</string>
396 <key>SupportedArchitectures</key>
397 <array>
398 <string>arm64</string>
399 </array>
400 <key>SupportedPlatform</key>
401 <string>ios</string>
402 </dict>
403 <dict>
404 <key>LibraryIdentifier</key>
405 <string>ios-simulator-arm64</string>
406 <key>LibraryPath</key>
407 <string>{}.framework</string>
408 <key>SupportedArchitectures</key>
409 <array>
410 <string>arm64</string>
411 </array>
412 <key>SupportedPlatform</key>
413 <string>ios</string>
414 <key>SupportedPlatformVariant</key>
415 <string>simulator</string>
416 </dict>
417 </array>
418 <key>CFBundlePackageType</key>
419 <string>XFWK</string>
420 <key>XCFrameworkFormatVersion</key>
421 <string>1.0</string>
422</dict>
423</plist>"#,
424 framework_name, framework_name
425 );
426
427 fs::write(xcframework_path.join("Info.plist"), plist_content).map_err(|e| {
428 BenchError::Build(format!("Failed to write xcframework Info.plist: {}", e))
429 })?;
430
431 Ok(())
432 }
433
434 fn codesign_xcframework(&self, xcframework_path: &Path) -> Result<(), BenchError> {
436 let output = Command::new("codesign")
437 .arg("--force")
438 .arg("--deep")
439 .arg("--sign")
440 .arg("-")
441 .arg(xcframework_path)
442 .output();
443
444 match output {
445 Ok(output) if output.status.success() => {
446 if self.verbose {
447 println!(" Successfully code-signed xcframework");
448 }
449 Ok(())
450 }
451 Ok(output) => {
452 let stderr = String::from_utf8_lossy(&output.stderr);
453 println!("Warning: Code signing failed: {}", stderr);
454 println!("You may need to manually sign the xcframework");
455 Ok(()) }
457 Err(e) => {
458 println!("Warning: Could not run codesign: {}", e);
459 println!("You may need to manually sign the xcframework");
460 Ok(()) }
462 }
463 }
464
465 fn generate_xcode_project(&self) -> Result<(), BenchError> {
467 let ios_dir = self.project_root.join("ios");
468 let project_yml = ios_dir.join("BenchRunner/project.yml");
469
470 if !project_yml.exists() {
471 if self.verbose {
472 println!(" No project.yml found, skipping xcodegen");
473 }
474 return Ok(());
475 }
476
477 if self.verbose {
478 println!(" Generating Xcode project with xcodegen");
479 }
480
481 let output = Command::new("xcodegen")
482 .arg("generate")
483 .current_dir(ios_dir.join("BenchRunner"))
484 .output();
485
486 match output {
487 Ok(output) if output.status.success() => Ok(()),
488 Ok(output) => {
489 let stderr = String::from_utf8_lossy(&output.stderr);
490 Err(BenchError::Build(format!("xcodegen failed: {}", stderr)))
491 }
492 Err(e) => {
493 println!("Warning: xcodegen not found or failed: {}", e);
494 println!("Install xcodegen with: brew install xcodegen");
495 Ok(()) }
497 }
498 }
499
500 fn find_uniffi_header(&self, header_name: &str) -> Option<PathBuf> {
502 let swift_dir = self
504 .project_root
505 .join("ios/BenchRunner/BenchRunner/Generated");
506 let candidate_swift = swift_dir.join(header_name);
507 if candidate_swift.exists() {
508 return Some(candidate_swift);
509 }
510
511 let target_dir = self.project_root.join("target");
512 let candidate = target_dir.join("uniffi").join(header_name);
514 if candidate.exists() {
515 return Some(candidate);
516 }
517
518 let mut stack = vec![target_dir];
520 while let Some(dir) = stack.pop() {
521 if let Ok(entries) = fs::read_dir(&dir) {
522 for entry in entries.flatten() {
523 let path = entry.path();
524 if path.is_dir() {
525 if let Some(name) = path.file_name().and_then(|n| n.to_str())
527 && name == "incremental"
528 {
529 continue;
530 }
531 stack.push(path);
532 } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
533 && name == header_name
534 {
535 return Some(path);
536 }
537 }
538 }
539 }
540
541 None
542 }
543}
544
545fn host_lib_path(project_dir: &PathBuf, crate_name: &str) -> Result<PathBuf, BenchError> {
547 let lib_prefix = if cfg!(target_os = "windows") {
548 ""
549 } else {
550 "lib"
551 };
552 let lib_ext = match env::consts::OS {
553 "macos" => "dylib",
554 "linux" => "so",
555 other => {
556 return Err(BenchError::Build(format!(
557 "unsupported host OS for binding generation: {}",
558 other
559 )));
560 }
561 };
562 let path = project_dir.join("target").join("debug").join(format!(
563 "{}{}.{}",
564 lib_prefix,
565 crate_name.replace('-', "_"),
566 lib_ext
567 ));
568 if !path.exists() {
569 return Err(BenchError::Build(format!(
570 "host library for UniFFI not found at {:?}",
571 path
572 )));
573 }
574 Ok(path)
575}
576
577fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> {
578 let output = cmd
579 .output()
580 .map_err(|e| BenchError::Build(format!("Failed to run {}: {}", description, e)))?;
581 if !output.status.success() {
582 let stderr = String::from_utf8_lossy(&output.stderr);
583 return Err(BenchError::Build(format!(
584 "{} failed: {}",
585 description, stderr
586 )));
587 }
588 Ok(())
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593pub enum SigningMethod {
594 AdHoc,
596 Development,
598}
599
600impl SigningMethod {
601 fn identity(&self) -> &'static str {
603 match self {
604 SigningMethod::AdHoc => "-",
605 SigningMethod::Development => "iPhone Developer",
606 }
607 }
608
609 fn export_method(&self) -> &'static str {
611 match self {
612 SigningMethod::AdHoc => "ad-hoc",
613 SigningMethod::Development => "development",
614 }
615 }
616}
617
618impl IosBuilder {
619 pub fn package_ipa(
647 &self,
648 scheme: &str,
649 method: SigningMethod,
650 ) -> Result<PathBuf, BenchError> {
651 let ios_dir = self.project_root.join("ios").join(scheme);
652 let project_path = ios_dir.join(format!("{}.xcodeproj", scheme));
653
654 if !project_path.exists() {
656 return Err(BenchError::Build(format!(
657 "Xcode project not found at {:?}. Run `cargo mobench build --target ios` first.",
658 project_path
659 )));
660 }
661
662 let archive_path = self.project_root.join("target/ios").join(format!("{}.xcarchive", scheme));
663 let export_path = self.project_root.join("target/ios");
664 let ipa_path = export_path.join(format!("{}.ipa", scheme));
665
666 fs::create_dir_all(&export_path)
668 .map_err(|e| BenchError::Build(format!("Failed to create export directory: {}", e)))?;
669
670 println!("Archiving {} for device...", scheme);
671
672 let mut cmd = Command::new("xcodebuild");
674 cmd.args(&[
675 "-project", project_path.to_str().unwrap(),
676 "-scheme", scheme,
677 "-sdk", "iphoneos",
678 "-configuration", "Release",
679 "-archivePath", archive_path.to_str().unwrap(),
680 "archive",
681 "CODE_SIGN_STYLE=Automatic",
682 &format!("CODE_SIGN_IDENTITY={}", method.identity()),
683 ]);
684
685 if self.verbose {
686 println!(" Running: {:?}", cmd);
687 }
688
689 run_command(cmd, "xcodebuild archive")?;
690
691 println!("Exporting IPA with {:?} signing...", method);
692
693 let export_options_path = export_path.join("ExportOptions.plist");
695 self.create_export_options_plist(&export_options_path, method)?;
696
697 let mut cmd = Command::new("xcodebuild");
699 cmd.args(&[
700 "-exportArchive",
701 "-archivePath", archive_path.to_str().unwrap(),
702 "-exportPath", export_path.to_str().unwrap(),
703 "-exportOptionsPlist", export_options_path.to_str().unwrap(),
704 ]);
705
706 if self.verbose {
707 println!(" Running: {:?}", cmd);
708 }
709
710 run_command(cmd, "xcodebuild -exportArchive")?;
711
712 let exported_ipa = export_path.join(format!("{}.ipa", scheme));
714 if !exported_ipa.exists() {
715 let subdir_ipa = export_path.join(scheme).join(format!("{}.ipa", scheme));
717 if subdir_ipa.exists() {
718 fs::rename(&subdir_ipa, &ipa_path).map_err(|e| {
719 BenchError::Build(format!("Failed to move IPA to final location: {}", e))
720 })?;
721 } else {
722 return Err(BenchError::Build(format!(
723 "IPA not found after export. Expected at {:?} or {:?}",
724 exported_ipa, subdir_ipa
725 )));
726 }
727 } else if exported_ipa != ipa_path {
728 fs::rename(&exported_ipa, &ipa_path).map_err(|e| {
729 BenchError::Build(format!("Failed to move IPA to final location: {}", e))
730 })?;
731 }
732
733 println!("✓ IPA created: {:?}", ipa_path);
734 Ok(ipa_path)
735 }
736
737 fn create_export_options_plist(
739 &self,
740 path: &Path,
741 method: SigningMethod,
742 ) -> Result<(), BenchError> {
743 let plist_content = format!(
744 r#"<?xml version="1.0" encoding="UTF-8"?>
745<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
746<plist version="1.0">
747<dict>
748 <key>method</key>
749 <string>{}</string>
750 <key>compileBitcode</key>
751 <false/>
752 <key>stripSwiftSymbols</key>
753 <true/>
754 <key>uploadSymbols</key>
755 <false/>
756 <key>signingStyle</key>
757 <string>automatic</string>
758</dict>
759</plist>
760"#,
761 method.export_method()
762 );
763
764 let mut file = fs::File::create(path).map_err(|e| {
765 BenchError::Build(format!("Failed to create ExportOptions.plist: {}", e))
766 })?;
767
768 file.write_all(plist_content.as_bytes()).map_err(|e| {
769 BenchError::Build(format!("Failed to write ExportOptions.plist: {}", e))
770 })?;
771
772 Ok(())
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 #[test]
781 fn test_ios_builder_creation() {
782 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile");
783 assert!(!builder.verbose);
784 }
785
786 #[test]
787 fn test_ios_builder_verbose() {
788 let builder = IosBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
789 assert!(builder.verbose);
790 }
791}