use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
pub const WHISKER_IOS_SPM_URL: &str = "https://github.com/whiskerrs/whisker.git";
pub const WHISKER_IOS_SPM_VERSION: &str = "0.1.0";
use crate::capture::{capture_env_vars_for_triple, CaptureShims};
const FRAMEWORK_NAME: &str = "WhiskerDriver";
const BRIDGE_EXPORTS: &[&str] = &[
"_whisker_bridge_engine_attach",
"_whisker_bridge_engine_release",
"_whisker_bridge_dispatch",
"_whisker_bridge_create_element",
"_whisker_bridge_create_element_by_name",
"_whisker_bridge_release_element",
"_whisker_bridge_set_attribute",
"_whisker_bridge_set_inline_styles",
"_whisker_bridge_append_child",
"_whisker_bridge_remove_child",
"_whisker_bridge_set_event_listener",
"_whisker_bridge_set_event_listener_with_value",
"_whisker_bridge_register_event_dispatcher",
"_whisker_bridge_element_sign",
"_whisker_bridge_set_native_event_handler",
"_whisker_bridge_set_root",
"_whisker_bridge_flush",
"_whisker_bridge_invoke_module",
"_whisker_bridge_invoke_module_async",
"_whisker_bridge_value_release",
"_whisker_bridge_register_module_dispatch",
"_whisker_bridge_module_add_event_listener",
"_whisker_bridge_module_remove_event_listener",
"_whisker_bridge_module_send_event",
"_whisker_bridge_module_register_observer_hooks",
"_whisker_bridge_log_hello",
"_whisker_bridge_log_info",
];
fn cargo_build_ios_dylib(
workspace_root: &Path,
package: &str,
triple: &str,
features: &[String],
capture: Option<&CaptureShims>,
step: &crate::ui::Step,
) -> Result<()> {
let mut cmd = Command::new("cargo");
cmd.args([
"rustc",
"--release",
"-p",
package,
"--target",
triple,
"--crate-type",
"dylib",
]);
for feat in features {
cmd.args(["--features", feat]);
}
cmd.arg("--");
for sym in BRIDGE_EXPORTS {
cmd.arg(format!("-Clink-arg=-Wl,-exported_symbol,{sym}"));
}
if let Some(c) = capture {
std::fs::create_dir_all(&c.rustc_cache_dir)
.with_context(|| format!("create rustc cache dir {}", c.rustc_cache_dir.display()))?;
std::fs::create_dir_all(&c.linker_cache_dir)
.with_context(|| format!("create linker cache dir {}", c.linker_cache_dir.display()))?;
for (k, v) in capture_env_vars_for_triple(c, Some(triple)) {
cmd.env(k, v);
}
}
cmd.current_dir(workspace_root);
let status = step.pipe(&mut cmd).context("spawn cargo")?;
if !status.success() {
return Err(anyhow!("cargo rustc failed for {triple} ({status})"));
}
Ok(())
}
fn build_framework_dir(
parent: &Path,
dylib_src: &Path,
rust_headers_src: &Path,
bridge_headers_src: &Path,
) -> Result<PathBuf> {
let fw_dir = parent.join(format!("{FRAMEWORK_NAME}.framework"));
crate::ui::debug(format!("stage {}", fw_dir.display()));
if fw_dir.exists() {
std::fs::remove_dir_all(&fw_dir)?;
}
std::fs::create_dir_all(&fw_dir)?;
let binary_dst = fw_dir.join(FRAMEWORK_NAME);
std::fs::copy(dylib_src, &binary_dst)
.with_context(|| format!("copy {} → {}", dylib_src.display(), binary_dst.display()))?;
let install_name = format!("@rpath/{FRAMEWORK_NAME}.framework/{FRAMEWORK_NAME}");
let status = Command::new("install_name_tool")
.args(["-id", &install_name])
.arg(&binary_dst)
.status()
.context("spawn install_name_tool")?;
if !status.success() {
return Err(anyhow!(
"install_name_tool failed on {} ({status})",
binary_dst.display(),
));
}
let hdr_dir = fw_dir.join("Headers");
std::fs::create_dir_all(&hdr_dir)?;
std::fs::copy(
rust_headers_src.join("whisker.h"),
hdr_dir.join("whisker.h"),
)?;
std::fs::copy(
bridge_headers_src.join("whisker_bridge.h"),
hdr_dir.join("whisker_bridge.h"),
)?;
let mod_dir = fw_dir.join("Modules");
std::fs::create_dir_all(&mod_dir)?;
std::fs::write(
mod_dir.join("module.modulemap"),
format!(
"framework module {FRAMEWORK_NAME} {{\n \
header \"whisker.h\"\n \
header \"whisker_bridge.h\"\n \
export *\n\
}}\n"
),
)?;
std::fs::write(fw_dir.join("Info.plist"), framework_info_plist())?;
Ok(fw_dir)
}
pub struct XcodeRunScriptInputs<'a> {
pub workspace_root: &'a Path,
pub package: &'a str,
pub platform: &'a str,
pub archs: &'a [&'a str],
pub features: &'a [String],
}
fn resolve_bridge_header_dirs(workspace_root: &Path) -> (PathBuf, PathBuf) {
let legacy = || {
(
workspace_root.join("crates/whisker-driver/include"),
workspace_root.join("crates/whisker-driver-sys/bridge/include"),
)
};
let Ok(meta) = cargo_metadata::MetadataCommand::new()
.current_dir(workspace_root)
.exec()
else {
return legacy();
};
let crate_dir = |name: &str| -> Option<PathBuf> {
meta.packages
.iter()
.find(|p| p.name == name)
.and_then(|p| p.manifest_path.parent())
.map(|d| d.as_std_path().to_path_buf())
};
match (crate_dir("whisker-driver"), crate_dir("whisker-driver-sys")) {
(Some(driver), Some(driver_sys)) => {
(driver.join("include"), driver_sys.join("bridge/include"))
}
_ => legacy(),
}
}
pub fn build_framework_for_xcode_run_script(
inputs: &XcodeRunScriptInputs<'_>,
built_products_dir: &Path,
) -> Result<PathBuf> {
if inputs.archs.is_empty() {
return Err(anyhow!("--archs is empty; Xcode passed no ARCHS"));
}
let (rust_headers_src, bridge_headers_src) = resolve_bridge_header_dirs(inputs.workspace_root);
for required in ["whisker.h", "module.modulemap"] {
if !rust_headers_src.join(required).is_file() {
return Err(anyhow!(
"missing header {} (expected at {})",
required,
rust_headers_src.display(),
));
}
}
if !bridge_headers_src.join("whisker_bridge.h").is_file() {
return Err(anyhow!(
"missing whisker_bridge.h (expected at {})",
bridge_headers_src.display(),
));
}
let lib_stem = inputs.package.replace('-', "_");
let cargo_dylib_name = format!("lib{lib_stem}.dylib");
let mut slice_paths: Vec<PathBuf> = Vec::with_capacity(inputs.archs.len());
for arch in inputs.archs {
let triple = map_arch_to_triple(inputs.platform, arch)?;
let s = crate::ui::step("compile", format!("{} ({triple})", inputs.package));
cargo_build_ios_dylib(
inputs.workspace_root,
inputs.package,
triple,
inputs.features,
None,
&s,
)?;
s.done("");
slice_paths.push(
inputs
.workspace_root
.join("target")
.join(triple)
.join("release")
.join(&cargo_dylib_name),
);
}
let out_dir = inputs
.workspace_root
.join("target/whisker-driver/run-script");
if out_dir.exists() {
std::fs::remove_dir_all(&out_dir)
.with_context(|| format!("rm -rf {}", out_dir.display()))?;
}
std::fs::create_dir_all(&out_dir).with_context(|| format!("mkdir -p {}", out_dir.display()))?;
let combined_dylib: PathBuf = if slice_paths.len() == 1 {
slice_paths.into_iter().next().expect("checked len == 1")
} else {
let fat = out_dir.join(&cargo_dylib_name);
crate::ui::debug(format!("lipo {}", fat.display()));
let mut cmd = Command::new("lipo");
cmd.arg("-create");
for p in &slice_paths {
if !p.is_file() {
return Err(anyhow!("expected dylib not built: {}", p.display()));
}
cmd.arg(p);
}
cmd.args(["-output"]).arg(&fat);
let status = cmd.status().context("spawn lipo")?;
if !status.success() {
return Err(anyhow!("lipo failed ({status})"));
}
fat
};
let staged_fw = build_framework_dir(
&out_dir,
&combined_dylib,
&rust_headers_src,
&bridge_headers_src,
)?;
let frameworks_dst = built_products_dir.join("Frameworks");
std::fs::create_dir_all(&frameworks_dst)
.with_context(|| format!("mkdir -p {}", frameworks_dst.display()))?;
let published_fw = frameworks_dst.join(format!("{FRAMEWORK_NAME}.framework"));
if published_fw.exists() {
std::fs::remove_dir_all(&published_fw)
.with_context(|| format!("rm -rf {}", published_fw.display()))?;
}
copy_dir_recursive(&staged_fw, &published_fw)?;
crate::ui::info(format!(
"publish {}.framework → {}",
FRAMEWORK_NAME,
published_fw.display(),
));
Ok(published_fw)
}
fn map_arch_to_triple(platform: &str, arch: &str) -> Result<&'static str> {
match (platform, arch) {
("iphoneos", "arm64") => Ok("aarch64-apple-ios"),
("iphonesimulator", "arm64") => Ok("aarch64-apple-ios-sim"),
("iphonesimulator", "x86_64") => Ok("x86_64-apple-ios"),
(p, a) => Err(anyhow!(
"unsupported (PLATFORM_NAME, ARCH) pair: ({p}, {a})"
)),
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst).with_context(|| format!("mkdir -p {}", dst.display()))?;
for entry in std::fs::read_dir(src).with_context(|| format!("readdir {}", src.display()))? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&from, &to)?;
} else {
std::fs::copy(&from, &to)
.with_context(|| format!("copy {} → {}", from.display(), to.display()))?;
}
}
Ok(())
}
fn framework_info_plist() -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>{FRAMEWORK_NAME}</string>
<key>CFBundleIdentifier</key>
<string>rs.whisker.{lower}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>{FRAMEWORK_NAME}</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
"#,
lower = FRAMEWORK_NAME.to_lowercase(),
)
}
pub struct XcodebuildArgs<'a> {
pub gen_ios: &'a Path,
pub scheme: &'a str,
pub sdk: &'a str,
pub configuration: &'a str,
pub xcodeproj_name: &'a str,
pub derived_data: &'a Path,
pub whisker_runtime_path: Option<&'a Path>,
pub whisker_ios_macros_path: Option<&'a Path>,
}
pub fn stage_module_swift_sources(
gen_ios: &Path,
_whisker_runtime_path: &Path,
_whisker_ios_macros_path: &Path,
modules: &[crate::modules::ResolvedModule],
) -> Result<()> {
let root = gen_ios.join("whisker_modules");
let sources_root = root.join("Sources/WhiskerModules");
if root.exists() {
std::fs::remove_dir_all(&root).with_context(|| format!("rm -rf {}", root.display()))?;
}
std::fs::create_dir_all(&sources_root)
.with_context(|| format!("mkdir -p {}", sources_root.display()))?;
let ios_modules: Vec<&crate::modules::ResolvedModule> = modules
.iter()
.filter(|m| m.manifest_dir.join("Package.swift").is_file())
.collect();
let package_path = root.join("Package.swift");
std::fs::write(&package_path, render_modules_package_swift(&ios_modules))
.with_context(|| format!("write {}", package_path.display()))?;
let register_all_path = sources_root.join("RegisterAll.swift");
std::fs::write(®ister_all_path, render_register_all_swift(&ios_modules))
.with_context(|| format!("write {}", register_all_path.display()))?;
if !ios_modules.is_empty() {
crate::ui::info(format!(
"stage {n} module SPM package(s) under whisker_modules/",
n = ios_modules.len()
));
}
Ok(())
}
fn crate_to_spm_target(crate_name: &str) -> String {
let mut out = String::new();
let mut next_upper = true;
for ch in crate_name.chars() {
if ch == '-' || ch == '_' {
next_upper = true;
continue;
}
if next_upper {
out.extend(ch.to_uppercase());
next_upper = false;
} else {
out.push(ch);
}
}
out
}
fn render_modules_package_swift(modules: &[&crate::modules::ResolvedModule]) -> String {
let mut out = String::new();
out.push_str(
"// swift-tools-version:5.9\n\
//\n\
// AUTO-GENERATED by whisker-build. Do NOT edit — re-run\n\
// `whisker run` to refresh.\n\
//\n\
// Phase 7-Φ.G aggregator. Each Whisker module ships its\n\
// own SwiftPM package (with hand-written Package.swift),\n\
// and this file just lists them as local-path dependencies.\n\
// SwiftPM resolves the transitive build graph; the user\n\
// app's pbxproj only references THIS aggregator package\n\
// via `XCLocalSwiftPackageReference`.\n\
//\n\
// RegisterAll.swift (next to this file) imports each\n\
// module and calls its per-target register fn from a\n\
// top-level `WhiskerModuleBehaviors.registerAll()`.\n\n",
);
out.push_str("import PackageDescription\n\n");
out.push_str("let package = Package(\n");
out.push_str(" name: \"WhiskerModules\",\n");
out.push_str(" platforms: [.iOS(.v13)],\n");
out.push_str(" products: [\n");
out.push_str(" .library(name: \"WhiskerModules\", targets: [\"WhiskerModules\"]),\n");
out.push_str(" ],\n");
out.push_str(" dependencies: [\n");
out.push_str(&format!(
" .package(url: {url:?}, exact: {ver:?}),\n",
url = WHISKER_IOS_SPM_URL,
ver = WHISKER_IOS_SPM_VERSION,
));
for m in modules {
let path = m.manifest_dir.display().to_string();
out.push_str(&format!(
" .package(name: {pkg:?}, path: {path:?}),\n",
pkg = m.package
));
}
out.push_str(" ],\n");
out.push_str(" targets: [\n");
out.push_str(" .target(\n");
out.push_str(" name: \"WhiskerModules\",\n");
out.push_str(" dependencies: [\n");
out.push_str(" .product(name: \"WhiskerRuntime\", package: \"whisker\"),\n");
out.push_str(" .product(name: \"Lynx\", package: \"whisker\"),\n");
for m in modules {
let target = crate_to_spm_target(&m.package);
out.push_str(&format!(
" .product(name: {target:?}, package: {pkg:?}),\n",
pkg = m.package
));
}
out.push_str(" ],\n");
out.push_str(" path: \"Sources/WhiskerModules\"\n");
out.push_str(" ),\n");
out.push_str(" ]\n");
out.push_str(")\n");
out
}
fn render_register_all_swift(modules: &[&crate::modules::ResolvedModule]) -> String {
let mut out = String::new();
out.push_str(
"// AUTO-GENERATED by whisker-build. Do NOT edit — re-run\n\
// `whisker run` to refresh.\n\
//\n\
// Aggregates every Whisker module's per-target register fn\n\
// (emitted by the `WhiskerModuleCodegenPlugin` SwiftPM\n\
// build-tool plugin into each module's compilation) into a\n\
// single `WhiskerModuleBehaviors.registerAll()` entry point.\n\
// The user app's AppDelegate calls this once at launch —\n\
// the actual per-module registration work runs inside each\n\
// `_whiskerRegisterModules_<TargetName>()`.\n\n",
);
out.push_str("import Foundation\n");
for m in modules {
let target = crate_to_spm_target(&m.package);
out.push_str(&format!("import {target}\n"));
}
out.push('\n');
out.push_str("@objc public final class WhiskerModuleBehaviors: NSObject {\n");
out.push_str(" private static var registered = false\n");
out.push_str(" private static let lock = NSLock()\n");
out.push('\n');
out.push_str(" @objc public static func registerAll() {\n");
out.push_str(" lock.lock()\n");
out.push_str(" defer { lock.unlock() }\n");
out.push_str(" if registered { return }\n");
out.push_str(" registered = true\n");
if modules.is_empty() {
out.push_str(" // (no Whisker module dependencies)\n");
}
for m in modules {
let target = crate_to_spm_target(&m.package);
out.push_str(&format!(" _whiskerRegisterModules_{target}()\n"));
}
out.push_str(" }\n}\n");
out
}
pub fn run_xcodebuild_app(args: &XcodebuildArgs<'_>) -> Result<PathBuf> {
let project = args
.gen_ios
.join(format!("{}.xcodeproj", args.xcodeproj_name));
if !project.is_dir() {
return Err(anyhow!(
"Xcode project missing at {} — did `xcodegen generate` run?",
project.display(),
));
}
let _xc_step = crate::ui::step("xcodebuild", args.xcodeproj_name.to_string());
let destination = match args.sdk {
"iphonesimulator" => "generic/platform=iOS Simulator".to_string(),
"iphoneos" => "generic/platform=iOS".to_string(),
other => return Err(anyhow!("unknown SDK: {other}")),
};
let mut cmd = Command::new("xcodebuild");
cmd.arg("-project")
.arg(&project)
.args(["-scheme", args.scheme])
.args(["-configuration", args.configuration])
.args(["-destination", &destination])
.arg("-derivedDataPath")
.arg(args.derived_data)
.arg("-skipPackagePluginValidation")
.args(["-quiet", "build"]);
if let Some(p) = args.whisker_runtime_path {
cmd.env("WHISKER_IOS_RUNTIME", p);
}
if let Some(p) = args.whisker_ios_macros_path {
cmd.env("WHISKER_IOS_MACROS", p);
}
let status = cmd.status().context("spawn xcodebuild")?;
if !status.success() {
return Err(anyhow!("xcodebuild failed ({status})"));
}
let product_subdir = match args.sdk {
"iphonesimulator" => format!("{}-iphonesimulator", args.configuration),
"iphoneos" => format!("{}-iphoneos", args.configuration),
_ => unreachable!("checked above"),
};
let app = args
.derived_data
.join("Build/Products")
.join(product_subdir)
.join(format!("{}.app", args.scheme));
if !app.is_dir() {
return Err(anyhow!(
"xcodebuild succeeded but {} is missing",
app.display(),
));
}
Ok(app)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bridge_exports_have_leading_underscore() {
for sym in BRIDGE_EXPORTS {
assert!(
sym.starts_with('_'),
"BRIDGE_EXPORTS entry missing leading underscore: {sym}",
);
}
}
#[test]
fn framework_info_plist_contains_executable_name() {
let plist = framework_info_plist();
assert!(plist.contains("<string>WhiskerDriver</string>"));
assert!(plist.contains("FMWK"));
}
#[test]
fn missing_xcodeproj_errors() {
let tmp = std::env::temp_dir().join("whisker-cli-build_ios-test");
let _ = std::fs::create_dir_all(&tmp);
let dd = tmp.join("derived");
let args = XcodebuildArgs {
gen_ios: &tmp,
scheme: "X",
sdk: "iphonesimulator",
configuration: "Release",
xcodeproj_name: "X",
derived_data: &dd,
whisker_runtime_path: None,
whisker_ios_macros_path: None,
};
let err = run_xcodebuild_app(&args).unwrap_err();
assert!(
err.to_string().contains("Xcode project missing"),
"got: {err:#}",
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn unknown_sdk_errors() {
let tmp = std::env::temp_dir().join("whisker-cli-build_ios-sdk-test");
let _ = std::fs::create_dir_all(&tmp);
let proj = tmp.join("X.xcodeproj");
let _ = std::fs::create_dir_all(&proj);
let dd = tmp.join("derived");
let args = XcodebuildArgs {
gen_ios: &tmp,
scheme: "X",
sdk: "bogus",
configuration: "Release",
xcodeproj_name: "X",
derived_data: &dd,
whisker_runtime_path: None,
whisker_ios_macros_path: None,
};
let err = run_xcodebuild_app(&args).unwrap_err();
assert!(err.to_string().contains("unknown SDK"), "got: {err:#}");
let _ = std::fs::remove_dir_all(&tmp);
}
}