use std::env; use std::fs; use std::process::Command; const IOS_DEPLOYMENT_TARGET_DEFAULT: &str = "26.0"; fn main() { let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let host = env::var("HOST").unwrap_or_default(); let is_apple_host = host.contains("apple-darwin"); let mut force_whisper = env::var("MAKEPAD") .ok() .is_some_and(|configs| configs.split(['+', ',']).any(|config| config == "whisper")); println!("cargo:rustc-check-cfg=cfg(force_whisper)"); println!("cargo:rerun-if-env-changed=MAKEPAD"); println!("cargo:rerun-if-env-changed=MAKEPAD_VOICE_METAL_PRECOMPILE"); println!("cargo:rerun-if-env-changed=IPHONEOS_DEPLOYMENT_TARGET"); println!("cargo:rerun-if-env-changed=IPHONESIMULATOR_DEPLOYMENT_TARGET"); if target_os == "macos" { build_whisper_metallib(); } if !force_whisper && is_apple_host && (target_os == "macos" || target_os == "ios") { if !build_speech_bridge(&target_os) { force_whisper = true; println!( "cargo:warning=swift speech bridge unavailable; building makepad-voice2 with Whisper fallback" ); } } if force_whisper { println!("cargo:rustc-cfg=force_whisper"); } } fn build_whisper_metallib() { let precompile_default = env::var_os("CARGO_FEATURE_METAL_PRECOMPILE").is_some(); let precompile_enabled = env::var("MAKEPAD_VOICE_METAL_PRECOMPILE") .ok() .map(|v| { let v = v.trim().to_ascii_lowercase(); !(v.is_empty() || v == "0" || v == "false" || v == "no" || v == "off") }) .unwrap_or(precompile_default); let out_dir = env::var("OUT_DIR").unwrap(); let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let ggml_src_dir = format!("{}/src/metal/ggml", manifest_dir); let ggml_metal_dir = format!("{}/src/metal/ggml", manifest_dir); let metal_src = format!("{}/ggml-metal.metal", ggml_metal_dir); let common_h = format!("{}/ggml-common.h", ggml_src_dir); let impl_h = format!("{}/ggml-metal-impl.h", ggml_metal_dir); println!("cargo:rerun-if-changed={}", metal_src); println!("cargo:rerun-if-changed={}", common_h); println!("cargo:rerun-if-changed={}", impl_h); let _ = fs::create_dir_all(&out_dir); let air_path = format!("{}/ggml-metal.air", out_dir); let metallib_path = format!("{}/ggml-default.metallib", out_dir); if !precompile_enabled { let _ = fs::write(&metallib_path, []); println!( "cargo:rustc-env=MAKEPAD_VOICE2_GGML_METALLIB={}", metallib_path ); return; } let metal_status = Command::new("xcrun") .args([ "--sdk", "macosx", "metal", "-O3", "-c", &metal_src, "-I", &ggml_src_dir, "-I", &ggml_metal_dir, "-o", &air_path, ]) .status(); let ok = metal_status.as_ref().is_ok_and(|s| s.success()); if !ok { println!("cargo:warning=failed to compile ggml-metal.metal to AIR; runtime source compile will be used"); let _ = fs::write(&metallib_path, []); println!( "cargo:rustc-env=MAKEPAD_VOICE2_GGML_METALLIB={}", metallib_path ); return; } let metallib_status = Command::new("xcrun") .args([ "--sdk", "macosx", "metallib", &air_path, "-o", &metallib_path, ]) .status(); let ok = metallib_status.as_ref().is_ok_and(|s| s.success()); if !ok { println!("cargo:warning=failed to build ggml default metallib; runtime source compile will be used"); let _ = fs::write(&metallib_path, []); } println!( "cargo:rustc-env=MAKEPAD_VOICE2_GGML_METALLIB={}", metallib_path ); } fn build_speech_bridge(target_os: &str) -> bool { let out_dir = env::var("OUT_DIR").unwrap(); let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let swift_src = format!("{}/swift/speech_bridge.swift", manifest_dir); let swift_module_cache = format!("{}/swift_module_cache", out_dir); println!("cargo:rerun-if-changed=swift/speech_bridge.swift"); let _ = fs::create_dir_all(&swift_module_cache); // Compile Swift -> static library let output_lib = format!("{}/libspeech_bridge.a", out_dir); let mut swift_args = vec![ "-emit-library".to_string(), "-static".to_string(), "-parse-as-library".to_string(), "-module-name".to_string(), "speech_bridge".to_string(), "-module-cache-path".to_string(), swift_module_cache.clone(), ]; if target_os == "ios" { if let Some((swift_target, sdk_path)) = ios_swift_target_and_sdk() { swift_args.push("-target".to_string()); swift_args.push(swift_target); swift_args.push("-sdk".to_string()); swift_args.push(sdk_path); } } swift_args.push("-o".to_string()); swift_args.push(output_lib.clone()); swift_args.push(swift_src); let status = match Command::new("swiftc").args(swift_args).status() { Ok(status) => status, Err(err) => { println!("cargo:warning=failed to run swiftc for speech bridge: {}", err); return false; } }; if !status.success() { println!("cargo:warning=swiftc compilation failed for speech bridge"); return false; } // Fix the @rpath issue with libswift_Concurrency.dylib BEFORE emitting any // link-search paths, so our override directory appears first in -L order. // // Background: The macOS SDK's libswift_Concurrency.tbd contains $ld$previous // entries that tell the linker to record "@rpath/libswift_Concurrency.dylib" // as the install name when MACOSX_DEPLOYMENT_TARGET < 15.0. Rust defaults to // MACOSX_DEPLOYMENT_TARGET=11.0, which triggers this behavior. The resulting // binary then fails at runtime with "dyld: Library not loaded: @rpath/...". // // The fix: create modified copies of the .tbd files with $ld$previous entries // stripped, and add them to the linker search path before the SDK paths. // Since cargo:rustc-link-search propagates from library crates to dependent // binaries, the final binary's link step will find our clean .tbd first and // use the absolute install name "/usr/lib/swift/libswift_Concurrency.dylib". if target_os == "macos" { fix_swift_rpath_tbds(&out_dir); } // Link the static library println!("cargo:rustc-link-search=native={}", out_dir); println!("cargo:rustc-link-lib=static=speech_bridge"); // On iOS, ensure the linker uses the same deployment target as the Swift objects. // Without this, Rust's default target triple (arm64-apple-ios10.0.0) causes a // mismatch and missing symbols like ___chkstk_darwin from the Swift async runtime. if target_os == "ios" { let deployment_key = { let abi = env::var("CARGO_CFG_TARGET_ABI").unwrap_or_default(); let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); if abi == "sim" || arch == "x86_64" { "IPHONESIMULATOR_DEPLOYMENT_TARGET" } else { "IPHONEOS_DEPLOYMENT_TARGET" } }; let deployment = env::var(deployment_key).unwrap_or_else(|_| IOS_DEPLOYMENT_TARGET_DEFAULT.to_string()); println!("cargo:rustc-link-arg=-miphoneos-version-min={}", deployment); } // Link Apple frameworks println!("cargo:rustc-link-lib=framework=Speech"); println!("cargo:rustc-link-lib=framework=Foundation"); println!("cargo:rustc-link-lib=framework=AVFoundation"); println!("cargo:rustc-link-lib=framework=CoreMedia"); // Add Swift runtime library search paths so the linker can resolve symbols. let target_info = match Command::new("swiftc").args(["-print-target-info"]).output() { Ok(output) => output, Err(err) => { println!("cargo:warning=failed to get swift target info: {}", err); return true; } }; if target_info.status.success() { let info_str = String::from_utf8_lossy(&target_info.stdout); for line in info_str.lines() { let trimmed = line.trim().trim_matches('"').trim_end_matches(','); if trimmed.starts_with("/") && trimmed.contains("lib/swift") { println!("cargo:rustc-link-search=native={}", trimmed); } } } true } fn ios_swift_target_and_sdk() -> Option<(String, String)> { let arch = env::var("CARGO_CFG_TARGET_ARCH").ok()?; let abi = env::var("CARGO_CFG_TARGET_ABI").unwrap_or_default(); let is_simulator = abi == "sim" || arch == "x86_64"; let swift_arch = match arch.as_str() { "aarch64" => "arm64", "x86_64" => "x86_64", _ => return None, }; let deployment_key = if is_simulator { "IPHONESIMULATOR_DEPLOYMENT_TARGET" } else { "IPHONEOS_DEPLOYMENT_TARGET" }; let deployment = env::var(deployment_key).unwrap_or_else(|_| IOS_DEPLOYMENT_TARGET_DEFAULT.to_string()); let swift_target = if is_simulator { format!("{swift_arch}-apple-ios{deployment}-simulator") } else { format!("{swift_arch}-apple-ios{deployment}") }; let sdk_name = if is_simulator { "iphonesimulator" } else { "iphoneos" }; let sdk_path = Command::new("xcrun") .args(["--sdk", sdk_name, "--show-sdk-path"]) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?; Some((swift_target, sdk_path)) } /// Create modified copies of Swift runtime .tbd files without $ld$previous entries. /// /// The $ld$previous entries in the SDK's .tbd files cause the linker to use /// @rpath/ as the install name for deployment targets below certain thresholds. /// By stripping these entries and placing our modified .tbd in a search directory /// that comes before the SDK, the linker will use the actual absolute install /// names (e.g. "/usr/lib/swift/libswift_Concurrency.dylib") regardless of the /// deployment target. fn fix_swift_rpath_tbds(out_dir: &str) { let sdk_path = Command::new("xcrun") .args(&["--show-sdk-path"]) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); let sdk_path = match sdk_path { Some(p) => p, None => return, }; let override_dir = format!("{}/swift_tbd_override", out_dir); if fs::create_dir_all(&override_dir).is_err() { return; } // Only patch the specific Swift runtime .tbd files our static library depends on. let swift_tbd_dir = format!("{}/usr/lib/swift", sdk_path); let tbds_to_fix = [ "libswift_Concurrency.tbd", "libswiftCore.tbd", "libswiftFoundation.tbd", "libswift_StringProcessing.tbd", "libswift_RegexParser.tbd", ]; for name in &tbds_to_fix { let tbd_path = format!("{}/{}", swift_tbd_dir, name); let content = match fs::read_to_string(&tbd_path) { Ok(c) => c, Err(_) => continue, }; if !content.contains("$ld$previous$@rpath/") { continue; } let fixed = strip_ld_previous_rpath(&content); let _ = fs::write(format!("{}/{}", override_dir, name), &fixed); } // Emit this search path BEFORE any other Swift library search paths. // cargo:rustc-link-search propagates from library crates to dependent binaries, // so the final binary's linker will find our modified .tbd files first. println!("cargo:rustc-link-search=native={}", override_dir); } /// Strip $ld$previous entries that reference @rpath/ from a .tbd file's content. fn strip_ld_previous_rpath(content: &str) -> String { let mut result = content.to_string(); // $ld$previous entries appear as quoted strings in YAML: // '$ld$previous$@rpath/libswift_Concurrency.dylib$$1$10.9$12.0$$' // They may be followed by comma + whitespace in a YAML sequence. while let Some(start) = result.find("'$ld$previous$@rpath/") { if let Some(end_quote_offset) = result[start + 1..].find('\'') { let end = start + 1 + end_quote_offset + 1; // past closing quote // Skip trailing comma and whitespace/newlines let rest = &result[end..]; let trimmed = rest.trim_start_matches(|c: char| c == ',' || c == ' ' || c == '\n' || c == '\r'); let skip = rest.len() - trimmed.len(); result = format!("{}{}", &result[..start], &result[end + skip..]); } else { break; } } result }