use crate::platform::Platform;
use anyhow::{Context, Result};
use colored::*;
use std::fs;
use std::path::Path;
use std::process::Command;
fn validate_project_structure() -> Result<()> {
validate_project_structure_at(Path::new("."))
}
fn validate_project_structure_at(root: &Path) -> Result<()> {
if !root.join("jffi.toml").exists() {
anyhow::bail!(
"{}\n\nThis command must be run from a project created with:\n {} {}\n\nOr navigate to an existing JFFI project directory.",
"Error: Not in a JFFI project directory.".red().bold(),
"jffi new".bright_cyan(),
"<project-name>".bright_yellow()
);
}
if !root.join("core").exists() {
anyhow::bail!(
"{}\n\n{}",
"Error: Missing 'core' directory.".red().bold(),
"Your project structure appears incomplete. Expected directories: core/, platforms/"
);
}
Ok(())
}
pub fn build_project(
platform: Option<String>,
all: bool,
release: bool,
device: bool,
deploy: bool,
) -> Result<()> {
validate_project_structure()?;
if all && (device || deploy) {
anyhow::bail!("--device and --deploy require an explicit --platform");
}
if device && platform.as_deref() != Some("ios") {
anyhow::bail!(
"build --device applies only to iOS; Android libraries serve both emulator and device"
);
}
if deploy && platform.as_deref() != Some("windows") {
anyhow::bail!("--deploy applies only to Windows multi-architecture builds");
}
let platform = platform.map(|platform| {
if matches!(platform.as_str(), "macos-arm64" | "macos-x64") {
eprintln!("{} is a compatibility alias: JFFI builds a universal macOS library. Use bundle.macos.targets for distribution architectures.", platform);
}
Platform::from_str(&platform).map(|selected| selected.as_str().to_string()).context("Unknown build platform")
}).transpose()?;
let config = crate::config::load_config()?;
if let Some(platform) = platform.as_deref() {
let selected = Platform::from_str(platform).context("Unknown build platform")?;
if !selected.is_host_supported() {
anyhow::bail!("{} cannot be built on this host", selected.as_str());
}
if !config
.platforms
.enabled
.iter()
.any(|p| p == selected.as_str())
{
anyhow::bail!("Platform '{}' is not enabled in jffi.toml", platform);
}
}
sync_configs_to_platforms(&config)?;
if all {
for p in &config.platforms.enabled {
crate::commands::icons::generate_icons(&config, p)?;
}
} else if let Some(ref p) = platform {
crate::commands::icons::generate_icons(&config, p)?;
}
if all {
build_all_platforms(release)?;
} else if let Some(platform) = platform {
build_platform_with_options(&platform, release, device, deploy)?;
} else {
anyhow::bail!("Specify --platform <platform> or --all");
}
Ok(())
}
pub fn build_platform_with_options(
platform: &str,
release: bool,
device: bool,
deploy: bool,
) -> Result<()> {
validate_project_structure()?;
let selected = Platform::from_str(platform).context("Unknown build platform")?;
let platform = selected.as_str();
if !selected.is_host_supported() {
anyhow::bail!("{} cannot be built on this host", selected.as_str());
}
if let Some(p_enum) = Platform::from_str(platform) {
p_enum.check_requirements()?;
}
println!(
"{}",
format!("🔨 Building for {}...", platform)
.bright_green()
.bold()
);
match platform {
"ios" => {
let target_type = if device { "device" } else { "simulator" };
build_ios_target(release, target_type)
}
"android" => build_android(release),
"macos" => {
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x86_64"
};
build_macos(arch, release)
}
"macos-arm64" => build_macos("aarch64", release),
"macos-x64" => build_macos("x86_64", release),
"windows" => {
if deploy {
build_windows_all_archs(release)
} else {
build_windows_host_arch(release)
}
}
"linux" => build_linux(release),
"web" => build_web(release),
_ => anyhow::bail!("Unknown platform: {}", platform),
}
}
fn build_all_platforms(release: bool) -> Result<()> {
println!("{}", "🔨 Building all platforms...".bright_green().bold());
println!();
let config = crate::config::load_config()?;
let mut failures = Vec::new();
for platform in &config.platforms.enabled {
println!(
"{} Building {}...",
"→".bright_blue(),
platform.bright_cyan()
);
if let Err(e) = build_platform(platform, release) {
println!("{} Failed to build {}: {}", "✗".red(), platform, e);
failures.push(format!("{}: {:#}", platform, e));
} else {
println!("{} {} built successfully", "✓".green(), platform);
}
println!();
}
if failures.is_empty() {
Ok(())
} else {
anyhow::bail!(
"{} platform build(s) failed:\n - {}",
failures.len(),
failures.join("\n - ")
)
}
}
pub fn build_platform(platform: &str, release: bool) -> Result<()> {
validate_project_structure()?;
let selected = Platform::from_str(platform).context("Unknown build platform")?;
let platform = selected.as_str();
if !selected.is_host_supported() {
anyhow::bail!("{} cannot be built on this host", selected.as_str());
}
if let Some(p_enum) = Platform::from_str(platform) {
p_enum.check_requirements()?;
}
println!(
"{}",
format!("🔨 Building for {}...", platform)
.bright_green()
.bold()
);
match platform {
"ios" => build_ios(release),
"android" => build_android(release),
"macos" => {
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x86_64"
};
build_macos(arch, release)
}
"macos-arm64" => build_macos("aarch64", release),
"macos-x64" => build_macos("x86_64", release),
"windows" => build_windows_host_arch(release),
"linux" => build_linux(release),
"web" => build_web(release),
_ => anyhow::bail!("Unknown platform: {}", platform),
}
}
fn build_ios(release: bool) -> Result<()> {
build_ios_xcframework(release)?;
Ok(())
}
fn build_ios_target(release: bool, _target_type: &str) -> Result<()> {
build_ios_xcframework(release)
}
fn build_ios_xcframework(release: bool) -> Result<()> {
let config = crate::config::load_config()?;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
let profile = if release { "release" } else { "debug" };
let verbose = std::env::var("JFFI_VERBOSE").is_ok();
let targets = vec![
("aarch64-apple-ios-sim", "iOS Simulator (ARM64)"),
("x86_64-apple-ios", "iOS Simulator (x86_64)"),
("aarch64-apple-ios", "iOS Device"),
];
if verbose {
for (target, target_name) in &targets {
println!(
" {} Building Rust library for {}...",
"→".bright_blue(),
target_name
);
let mut args = vec!["build"];
if release {
args.push("--release");
}
args.extend(&["--manifest-path", "core/Cargo.toml", "--target", target]);
let status = Command::new("cargo")
.env("CARGO_TARGET_DIR", "target")
.env(
"IPHONEOS_DEPLOYMENT_TARGET",
&config.platforms.ios.deployment_target,
)
.args(&args)
.status()
.context(format!("Failed to build Rust library for {}", target))?;
if !status.success() {
anyhow::bail!("Rust build failed for {}", target);
}
}
} else {
let multi = MultiProgress::new();
let spinner_style = ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
.unwrap()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
for (target, target_name) in &targets {
let pb = multi.add(ProgressBar::new_spinner());
pb.set_style(spinner_style.clone());
pb.set_prefix(" →");
pb.set_message(format!("Building {}", target_name));
pb.enable_steady_tick(std::time::Duration::from_millis(100));
let mut args = vec!["build"];
if release {
args.push("--release");
}
args.extend(&["--manifest-path", "core/Cargo.toml", "--target", target]);
let status = Command::new("cargo")
.env("CARGO_TARGET_DIR", "target")
.env(
"IPHONEOS_DEPLOYMENT_TARGET",
&config.platforms.ios.deployment_target,
)
.args(&args)
.status()
.context(format!("Failed to build Rust library for {}", target))?;
if !status.success() {
pb.finish_with_message(format!("{} {}", "✗".red(), target_name));
anyhow::bail!("Rust build failed for {}", target);
}
pb.finish_with_message(format!("{} {}", "✓".green(), target_name));
}
}
println!(" {} Generating Swift bindings...", "→".bright_blue());
let lib_dir = format!("target/aarch64-apple-ios-sim/{}", profile);
let lib_path = std::fs::read_dir(&lib_dir)
.context("Failed to read target directory")?
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let name_str = name.to_string_lossy();
name_str.starts_with("lib") && name_str.ends_with("core.a")
})
.map(|e| e.path())
.context("Could not find FFI library")?;
let status = Command::new("uniffi-bindgen")
.args([
"generate",
"--library",
lib_path.to_str().unwrap(),
"--language",
"swift",
"--out-dir",
"platforms/ios",
])
.status()
.context("Failed to generate Swift bindings")?;
if !status.success() {
anyhow::bail!("Binding generation failed");
}
patch_swift_concurrency("platforms/ios")?;
println!(" {} Creating XCFramework...", "→".bright_blue());
let lib_name = lib_path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_prefix("lib"))
.context("Could not determine library name")?;
let xcframework_path = format!("platforms/ios/{}.xcframework", lib_name);
let sim_arm64_lib = format!("target/aarch64-apple-ios-sim/{}/lib{}.a", profile, lib_name);
let sim_x86_lib = format!("target/x86_64-apple-ios/{}/lib{}.a", profile, lib_name);
let sim_universal_lib = format!("target/ios-simulator-universal/lib{}.a", lib_name);
let universal_dir = std::path::Path::new("target/ios-simulator-universal");
for attempt in 0..3 {
if universal_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(universal_dir) {
if attempt < 2 {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
} else {
eprintln!(
" {} Warning: Could not clean ios-simulator-universal directory: {}",
"⚠".yellow(),
e
);
}
}
}
break;
}
std::fs::create_dir_all(universal_dir)?;
let lipo_status = Command::new("lipo")
.args([
"-create",
&sim_arm64_lib,
&sim_x86_lib,
"-output",
&sim_universal_lib,
])
.status()
.context("Failed to create universal simulator library")?;
if !lipo_status.success() {
anyhow::bail!("lipo failed to create universal simulator library");
}
let device_lib = format!("target/aarch64-apple-ios/{}/lib{}.a", profile, lib_name);
if std::path::Path::new(&xcframework_path).exists() {
let mut sim_dir = None;
let mut device_dir = None;
if let Ok(entries) = std::fs::read_dir(&xcframework_path) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name.contains("..") || dir_name.contains('/') || dir_name.contains('\\')
{
continue;
}
if dir_name.contains("simulator") {
sim_dir = Some(dir_name.to_string());
} else if dir_name.starts_with("ios-arm64") && !dir_name.contains("simulator") {
device_dir = Some(dir_name.to_string());
}
}
}
}
if let (Some(sim), Some(dev)) = (sim_dir, device_dir) {
let sim_dest = format!("{}/{}/lib{}.a", xcframework_path, sim, lib_name);
let device_dest = format!("{}/{}/lib{}.a", xcframework_path, dev, lib_name);
match (
std::fs::copy(&sim_universal_lib, &sim_dest),
std::fs::copy(&device_lib, &device_dest),
) {
(Ok(_), Ok(_)) => {
let info_plist = format!("{}/Info.plist", xcframework_path);
if Command::new("touch").arg(&info_plist).status().is_err() {
if let Ok(content) = std::fs::read(&info_plist) {
let _ = std::fs::write(&info_plist, content);
}
}
println!("{}", " ✅ iOS XCFramework updated in-place".green());
return Ok(());
}
_ => {
println!(
" {} XCFramework update failed, recreating...",
"⚠".yellow()
);
let _ = std::fs::remove_dir_all(&xcframework_path);
}
}
} else {
println!(
" {} XCFramework structure incomplete, recreating...",
"⚠".yellow()
);
let _ = std::fs::remove_dir_all(&xcframework_path);
}
}
let xcframework_status = Command::new("xcodebuild")
.args([
"-create-xcframework",
"-library",
&sim_universal_lib,
"-library",
&device_lib,
"-output",
&xcframework_path,
])
.status()
.context("Failed to create XCFramework")?;
if !xcframework_status.success() {
anyhow::bail!("xcodebuild failed to create XCFramework");
}
println!("{}", " ✅ iOS XCFramework created successfully".green());
Ok(())
}
fn generate_android_cargo_config() -> Result<()> {
let cargo_dir = ".cargo";
std::fs::create_dir_all(cargo_dir).context("Failed to create .cargo directory")?;
let config_content = r#"# Auto-generated by JFFI for Android 15+ compatibility
# Android 15+ requires native libraries to be 16 KB page-aligned
# See: https://developer.android.com/ndk/guides/16kb-page-size
[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
[target.armv7-linux-androideabi]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
[target.x86_64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
[target.i686-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
"#;
let config_path = format!("{}/config.toml", cargo_dir);
let existing = if Path::new(&config_path).exists() {
fs::read_to_string(&config_path).context("Failed to read .cargo/config.toml")?
} else {
config_content.to_string()
};
let updated = merge_android_cargo_config(&existing)?;
if updated != existing || !Path::new(&config_path).exists() {
fs::write(&config_path, updated).context("Failed to write .cargo/config.toml")?;
}
Ok(())
}
fn merge_android_cargo_config(content: &str) -> Result<String> {
use toml_edit::{value, Array, DocumentMut};
let mut doc = content
.parse::<DocumentMut>()
.context("Invalid .cargo/config.toml; left unchanged")?;
if let Some(targets) = doc.get("target") {
anyhow::ensure!(
targets.is_table_like(),
"target must be a table; config left unchanged"
);
for (_, settings) in targets.as_table_like().unwrap().iter() {
anyhow::ensure!(
settings.is_table_like(),
"target settings must be a table; config left unchanged"
);
}
}
for target in [
"aarch64-linux-android",
"armv7-linux-androideabi",
"x86_64-linux-android",
"i686-linux-android",
] {
let flags = &mut doc["target"][target]["rustflags"];
if flags.is_none() {
*flags = value(Array::new());
}
let flags = flags.as_array_mut().with_context(|| {
format!("target.{target}.rustflags must be an array; config left unchanged")
})?;
if !flags
.iter()
.any(|flag| flag.as_str() == Some("link-arg=-Wl,-z,max-page-size=16384"))
{
flags.push("-C");
flags.push("link-arg=-Wl,-z,max-page-size=16384");
}
}
Ok(doc.to_string())
}
fn check_ndk_context_needed() -> bool {
let output = Command::new("cargo")
.args([
"tree",
"-i",
"ndk-context",
"--target",
"aarch64-linux-android",
"--depth",
"10",
])
.output();
if let Ok(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout);
!stdout.contains("warning: nothing to print")
} else {
false
}
}
fn build_android(release: bool) -> Result<()> {
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
let verbose = std::env::var("JFFI_VERBOSE").is_ok();
generate_android_cargo_config()?;
let no_bridge = std::env::var("JFFI_NO_ANDROID_BRIDGE").is_ok();
let needs_ndk_context = check_ndk_context_needed();
if needs_ndk_context && no_bridge {
println!(
" {} Skipping ndk-context JNI bridge (--no-android-bridge)",
"ℹ".bright_blue()
);
} else if needs_ndk_context {
println!(
" {} Detected ndk-context dependency - generating JNI bridge...",
"ℹ".bright_blue()
);
generate_android_ndk_bridge()?;
}
let config = crate::config::load_config()?;
let cargo_manifest =
std::fs::read_to_string("core/Cargo.toml").context("Failed to read core/Cargo.toml")?;
let cargo_manifest: toml::Value =
toml::from_str(&cargo_manifest).context("Failed to parse core/Cargo.toml")?;
let lib_name = cargo_manifest
.get("lib")
.and_then(|lib| lib.get("name"))
.and_then(toml::Value::as_str)
.or_else(|| {
cargo_manifest
.get("package")
.and_then(|package| package.get("name"))
.and_then(toml::Value::as_str)
})
.context("core/Cargo.toml must define a package or library name")?
.replace('-', "_");
let mut architectures = vec![
("aarch64-linux-android", "arm64-v8a"),
("armv7-linux-androideabi", "armeabi-v7a"),
("x86_64-linux-android", "x86_64"),
];
if let Some(abis) = &config.platforms.android.abis {
architectures.retain(|(_, abi)| abis.contains(&abi.to_string()));
if architectures.is_empty() {
anyhow::bail!(
"No valid Android ABIs configured in jffi.toml. Enabled ABIs: {:?}",
abis
);
}
}
let profile = if release { "release" } else { "debug" };
for (_, abi) in &architectures {
let output_dir = Path::new("platforms/android/app/src/main/jniLibs").join(abi);
if output_dir.exists() {
std::fs::remove_dir_all(&output_dir).with_context(|| {
format!(
"Failed to clear stale Android JNI output at {}",
output_dir.display()
)
})?;
}
std::fs::create_dir_all(&output_dir)?;
}
if verbose {
for (target, abi) in &architectures {
println!(" {} Building Android {}...", "→".bright_blue(), abi);
let mut args = vec![
"ndk",
"-t",
target,
"--manifest-path",
"core/Cargo.toml",
"build",
];
if release {
args.push("--release");
}
let ndk_platform = std::env::var("CARGO_NDK_PLATFORM")
.unwrap_or_else(|_| config.platforms.android.min_sdk.to_string());
let mut cmd = Command::new("cargo");
cmd.env("CARGO_TARGET_DIR", "target")
.env("CARGO_NDK_PLATFORM", ndk_platform)
.args(&args);
if let Some(rustflags) = &config.platforms.android.rustflags {
let env_var_name = format!(
"CARGO_TARGET_{}_RUSTFLAGS",
target.to_uppercase().replace("-", "_")
);
cmd.env(env_var_name, rustflags);
}
let status = cmd
.status()
.context(format!("Failed to build for {}", target))?;
if !status.success() {
anyhow::bail!("Rust build failed for {}", target);
}
copy_android_primary_library(target, abi, profile, &lib_name)?;
}
} else {
let multi = MultiProgress::new();
let spinner_style = ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
.unwrap()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
for (target, abi) in &architectures {
let pb = multi.add(ProgressBar::new_spinner());
pb.set_style(spinner_style.clone());
pb.set_prefix(" →");
pb.set_message(format!("Building Android {}", abi));
pb.enable_steady_tick(std::time::Duration::from_millis(100));
let mut args = vec![
"ndk",
"-t",
target,
"--manifest-path",
"core/Cargo.toml",
"build",
];
if release {
args.push("--release");
}
let ndk_platform = std::env::var("CARGO_NDK_PLATFORM")
.unwrap_or_else(|_| config.platforms.android.min_sdk.to_string());
let mut cmd = Command::new("cargo");
cmd.env("CARGO_TARGET_DIR", "target")
.env("CARGO_NDK_PLATFORM", ndk_platform)
.args(&args);
if let Some(rustflags) = &config.platforms.android.rustflags {
let env_var_name = format!(
"CARGO_TARGET_{}_RUSTFLAGS",
target.to_uppercase().replace("-", "_")
);
cmd.env(env_var_name, rustflags);
}
let status = cmd
.status()
.context(format!("Failed to build for {}", target))?;
if !status.success() {
pb.finish_with_message(format!("{} Android {}", "✗".red(), abi));
anyhow::bail!("Rust build failed for {}", target);
}
copy_android_primary_library(target, abi, profile, &lib_name)?;
pb.finish_with_message(format!("{} Android {}", "✓".green(), abi));
}
}
if let Some(ndk_dir) = find_ndk_dir() {
for (target, abi) in &architectures {
if let Some(libcxx_path) = find_libcxx_shared(&ndk_dir, target) {
let dest_dir = format!("platforms/android/app/src/main/jniLibs/{}", abi);
let dest_path = Path::new(&dest_dir).join("libc++_shared.so");
if let Err(e) = std::fs::create_dir_all(&dest_dir) {
println!(
" {} Warning: Failed to create jniLibs directory: {}",
"⚠".yellow(),
e
);
} else if let Err(e) = std::fs::copy(&libcxx_path, &dest_path) {
println!(
" {} Warning: Failed to copy libc++_shared.so for {}: {}",
"⚠".yellow(),
abi,
e
);
} else {
if verbose {
println!(
" {} Copied libc++_shared.so to {}",
"✓".green(),
dest_path.display()
);
}
}
}
}
} else {
println!(
" {} Warning: Could not find Android NDK directory to bundle libc++_shared.so",
"⚠".yellow()
);
}
let mut lib_path = None;
for (target, _) in &architectures {
let lib_dir = format!("target/{}/{}", target, profile);
if let Ok(entries) = std::fs::read_dir(&lib_dir) {
if let Some(path) = entries
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let name_str = name.to_string_lossy();
name_str.starts_with("lib") && name_str.ends_with("core.so")
})
.map(|e| e.path())
{
lib_path = Some(path);
break;
}
}
}
let lib_path = lib_path.context("Could not find FFI library in any target directory")?;
let status = Command::new("uniffi-bindgen")
.args([
"generate",
"--library",
lib_path.to_str().unwrap(),
"--language",
"kotlin",
"--out-dir",
"platforms/android/app/src/main/java",
])
.status()
.context("Failed to generate Kotlin bindings")?;
if !status.success() {
anyhow::bail!("Binding generation failed");
}
println!("{}", " ✅ Android build complete".green());
Ok(())
}
fn copy_android_primary_library(
target: &str,
abi: &str,
profile: &str,
lib_name: &str,
) -> Result<()> {
let filename = format!("lib{}.so", lib_name);
let source = Path::new("target")
.join(target)
.join(profile)
.join(&filename);
let destination = Path::new("platforms/android/app/src/main/jniLibs")
.join(abi)
.join(&filename);
std::fs::copy(&source, &destination).with_context(|| {
format!(
"Failed to copy Android JNI library from {} to {}",
source.display(),
destination.display()
)
})?;
let ndk_dir = find_ndk_dir().context("Android NDK not found while preparing JNI library")?;
let prebuilt_dir = ndk_dir.join("toolchains/llvm/prebuilt");
let strip = std::fs::read_dir(&prebuilt_dir)
.with_context(|| format!("Failed to inspect {}", prebuilt_dir.display()))?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path().join("bin/llvm-strip"))
.find(|candidate| candidate.exists())
.context("llvm-strip not found in the Android NDK")?;
let strip_status = Command::new(&strip)
.args(["--strip-debug", destination.to_str().unwrap()])
.status()
.with_context(|| format!("Failed to run {}", strip.display()))?;
if !strip_status.success() {
anyhow::bail!("Failed to strip packaged Android JNI library for {abi}");
}
Ok(())
}
fn generate_android_ndk_bridge() -> Result<()> {
let config = crate::config::load_config()?;
let package_name = &config.platforms.android.package;
let cargo_toml =
std::fs::read_to_string("core/Cargo.toml").context("Failed to read core/Cargo.toml")?;
let lib_name = cargo_toml
.lines()
.find(|line| line.starts_with("name"))
.and_then(|line| line.split('"').nth(1))
.context("Could not find project name in Cargo.toml")?
.replace("-", "_");
let jni_package = package_name.replace(".", "_");
let android_rs = format!(
r#"//! Android-specific JNI bridge for ndk-context initialization
//!
//! This module is auto-generated by JFFI to handle the UniFFI + JNA + ndk-context
//! incompatibility. UniFFI uses JNA which doesn't call JNI_OnLoad, so we need
//! a separate JNI function that Kotlin calls explicitly to initialize ndk-context.
//!
//! Background: Some Rust crates (like hickory-resolver used by iroh) need Android
//! context to access system DNS APIs. Since JNA doesn't provide JNI_OnLoad, we
//! expose this init function that Kotlin calls with the Application context.
use jni::JNIEnv;
use jni::objects::{{JClass, JObject}};
use std::ffi::c_void;
/// Initialize ndk-context with the Android application context.
///
/// This MUST be called from Kotlin before any Rust code that uses ndk-context
/// (e.g., DNS resolution via hickory-resolver).
///
/// # Safety
/// This function is called from JNI and must follow JNI safety rules.
/// The pointers passed to ndk-context must remain valid for the lifetime of the app.
#[no_mangle]
pub unsafe extern "C" fn Java_{jni_package}_JffiAndroidInit_initNdkContext(
env: JNIEnv,
_class: JClass,
context: JObject,
) {{
// Get the JavaVM pointer (required by ndk-context)
let vm = match env.get_java_vm() {{
Ok(vm) => vm,
Err(e) => {{
eprintln!("JFFI: Failed to get JavaVM: {{}}", e);
return;
}}
}};
// Create a global reference to the context so it persists
let global_context = match env.new_global_ref(context) {{
Ok(ctx) => ctx,
Err(e) => {{
eprintln!("JFFI: Failed to create global ref for Android context: {{}}", e);
return;
}}
}};
// Get raw pointers for ndk-context
// ndk-context expects: (java_vm: *mut c_void, context_jobject: *mut c_void)
let vm_ptr = vm.get_java_vm_pointer() as *mut c_void;
let ctx_ptr = global_context.as_obj().as_raw() as *mut c_void;
// Initialize ndk-context with raw pointers
ndk_context::initialize_android_context(vm_ptr, ctx_ptr);
// Keep the global reference alive (ndk-context stores the raw pointer)
std::mem::forget(global_context);
println!("JFFI: Android ndk-context initialized successfully");
}}
"#
);
let android_bridge_path = Path::new("core/src/android.rs");
if !android_bridge_path.exists() {
std::fs::write(android_bridge_path, android_rs)
.context("Failed to create core/src/android.rs")?;
}
let lib_rs_path = "core/src/lib.rs";
let mut lib_rs =
std::fs::read_to_string(lib_rs_path).context("Failed to read core/src/lib.rs")?;
if !lib_rs.contains("mod android;") {
if let Some(pos) = lib_rs.find("uniffi::include_scaffolding!") {
let insert_pos = lib_rs[pos..]
.find('\n')
.map(|p| pos + p + 1)
.unwrap_or(lib_rs.len());
lib_rs.insert_str(
insert_pos,
"\n#[cfg(target_os = \"android\")]\nmod android;\n",
);
std::fs::write(lib_rs_path, lib_rs).context("Failed to update core/src/lib.rs")?;
}
}
let cargo_toml_path = "core/Cargo.toml";
let mut cargo_toml =
std::fs::read_to_string(cargo_toml_path).context("Failed to read core/Cargo.toml")?;
if !cargo_toml.contains("ndk-context") {
cargo_toml.push_str("\n# Android ndk-context support (auto-added by JFFI)\n");
cargo_toml.push_str("[target.'cfg(target_os = \"android\")'.dependencies]\n");
cargo_toml.push_str("ndk-context = \"0.1\"\n");
cargo_toml.push_str("jni = \"0.21\"\n");
std::fs::write(cargo_toml_path, cargo_toml).context("Failed to update core/Cargo.toml")?;
}
let package_path = package_name.replace(".", "/");
let kotlin_dir = format!("platforms/android/app/src/main/java/{}", package_path);
std::fs::create_dir_all(&kotlin_dir).context("Failed to create Kotlin package directory")?;
let kotlin_init = format!(
r#"package {package_name}
import android.content.Context
/**
* JFFI Android initialization helper.
*
* Auto-generated to handle ndk-context initialization for Rust dependencies
* that need Android context (e.g., hickory-resolver for DNS).
*
* This is necessary because UniFFI uses JNA which doesn't call JNI_OnLoad,
* so we need explicit initialization before any Rust code runs.
*/
object JffiAndroidInit {{
init {{
System.loadLibrary("{lib_name}")
}}
/**
* Initialize Android context for Rust code.
*
* MUST be called before creating any Rust objects that use ndk-context.
* Typically called in Application.onCreate() or Activity.onCreate().
*
* @param context The Android application or activity context
*/
@JvmStatic
external fun initNdkContext(context: Context)
}}
"#
);
let kotlin_path = format!("{}/JffiAndroidInit.kt", kotlin_dir);
std::fs::write(&kotlin_path, kotlin_init).context("Failed to write JffiAndroidInit.kt")?;
println!(" {} Generated JNI bridge and Kotlin helper", "✓".green());
println!(
" {} Add to MainActivity: JffiAndroidInit.initNdkContext(applicationContext)",
"ℹ".bright_yellow()
);
Ok(())
}
fn build_macos(_arch: &str, release: bool) -> Result<()> {
build_macos_xcframework(release)
}
fn build_macos_xcframework(release: bool) -> Result<()> {
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
let profile = if release { "release" } else { "debug" };
let verbose = std::env::var("JFFI_VERBOSE").is_ok();
let config = crate::config::load_config()?;
let cargo_toml =
std::fs::read_to_string("core/Cargo.toml").context("Failed to read core/Cargo.toml")?;
let lib_name = cargo_toml
.lines()
.find(|line| line.starts_with("name"))
.and_then(|line| line.split('"').nth(1))
.context("Could not find project name in Cargo.toml")?
.replace("-", "_");
let targets = vec![
("aarch64-apple-darwin", "macOS Apple Silicon"),
("x86_64-apple-darwin", "macOS Intel"),
];
if verbose {
for (target, target_name) in &targets {
println!(
" {} Building Rust library for {}...",
"→".bright_blue(),
target_name
);
let mut args = vec!["build"];
if release {
args.push("--release");
}
args.extend(&["--manifest-path", "core/Cargo.toml", "--target", target]);
let mut cmd = Command::new("cargo");
cmd.env("CARGO_TARGET_DIR", "target")
.env(
"MACOSX_DEPLOYMENT_TARGET",
&config.platforms.macos.deployment_target,
)
.args(&args);
let mut rustflags_val = config.platforms.macos.rustflags.clone().unwrap_or_default();
if !rustflags_val.contains("-install_name") {
if !rustflags_val.is_empty() {
rustflags_val.push(' ');
}
rustflags_val.push_str(&format!(
"-C link-arg=-Wl,-install_name,@rpath/lib{}.dylib",
lib_name
));
}
cmd.env("RUSTFLAGS", rustflags_val);
let status = cmd
.status()
.context(format!("Failed to build Rust library for {}", target))?;
if !status.success() {
anyhow::bail!("Rust build failed for {}", target);
}
}
} else {
let multi = MultiProgress::new();
let spinner_style = ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
.unwrap()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
for (target, target_name) in &targets {
let pb = multi.add(ProgressBar::new_spinner());
pb.set_style(spinner_style.clone());
pb.set_prefix(" →");
pb.set_message(format!("Building {}", target_name));
pb.enable_steady_tick(std::time::Duration::from_millis(100));
let mut args = vec!["build"];
if release {
args.push("--release");
}
args.extend(&["--manifest-path", "core/Cargo.toml", "--target", target]);
let mut cmd = Command::new("cargo");
cmd.env("CARGO_TARGET_DIR", "target")
.env(
"MACOSX_DEPLOYMENT_TARGET",
&config.platforms.macos.deployment_target,
)
.args(&args);
let mut rustflags_val = config.platforms.macos.rustflags.clone().unwrap_or_default();
if !rustflags_val.contains("-install_name") {
if !rustflags_val.is_empty() {
rustflags_val.push(' ');
}
rustflags_val.push_str(&format!(
"-C link-arg=-Wl,-install_name,@rpath/lib{}.dylib",
lib_name
));
}
cmd.env("RUSTFLAGS", rustflags_val);
let status = cmd
.status()
.context(format!("Failed to build Rust library for {}", target))?;
if !status.success() {
pb.finish_with_message(format!("{} {}", "✗".red(), target_name));
anyhow::bail!("Rust build failed for {}", target);
}
pb.finish_with_message(format!("{} {}", "✓".green(), target_name));
}
}
println!(" {} Generating Swift bindings...", "→".bright_blue());
let lib_dir = format!("target/aarch64-apple-darwin/{}", profile);
let lib_path = std::fs::read_dir(&lib_dir)
.context("Failed to read target directory")?
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let name_str = name.to_string_lossy();
name_str.starts_with("lib") && name_str.ends_with("core.dylib")
})
.map(|e| e.path())
.context("Could not find FFI library")?;
let status = Command::new("uniffi-bindgen")
.args([
"generate",
"--library",
lib_path.to_str().unwrap(),
"--language",
"swift",
"--out-dir",
"platforms/macos",
])
.status()
.context("Failed to generate Swift bindings")?;
if !status.success() {
anyhow::bail!("Binding generation failed");
}
patch_swift_concurrency("platforms/macos")?;
println!(" {} Creating XCFramework...", "→".bright_blue());
let lib_name = lib_path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_prefix("lib"))
.context("Could not determine library name")?;
let xcframework_path = format!("platforms/macos/{}.xcframework", lib_name);
let arm64_lib = format!(
"target/aarch64-apple-darwin/{}/lib{}.dylib",
profile, lib_name
);
let x86_lib = format!(
"target/x86_64-apple-darwin/{}/lib{}.dylib",
profile, lib_name
);
let universal_lib = format!("target/macos-universal/lib{}.dylib", lib_name);
std::fs::create_dir_all("target/macos-universal")?;
let lipo_status = Command::new("lipo")
.args(["-create", &arm64_lib, &x86_lib, "-output", &universal_lib])
.status()
.context("Failed to create universal macOS library")?;
if !lipo_status.success() {
anyhow::bail!("lipo failed to create universal macOS library");
}
if std::path::Path::new(&xcframework_path).exists() {
let mut macos_dir = None;
if let Ok(entries) = std::fs::read_dir(&xcframework_path) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name.contains("..") || dir_name.contains('/') || dir_name.contains('\\')
{
continue;
}
if dir_name.starts_with("macos-") {
macos_dir = Some(dir_name.to_string());
break;
}
}
}
}
if let Some(dir) = macos_dir {
let dest = format!("{}/{}/lib{}.dylib", xcframework_path, dir, lib_name);
match std::fs::copy(&universal_lib, &dest) {
Ok(_) => {
let info_plist = format!("{}/Info.plist", xcframework_path);
if Command::new("touch").arg(&info_plist).status().is_err() {
if let Ok(content) = std::fs::read(&info_plist) {
let _ = std::fs::write(&info_plist, content);
}
}
println!("{}", " ✅ macOS XCFramework updated in-place".green());
return Ok(());
}
Err(_) => {
println!(
" {} XCFramework update failed, recreating...",
"⚠".yellow()
);
let _ = std::fs::remove_dir_all(&xcframework_path);
}
}
} else {
println!(
" {} XCFramework structure not found, recreating...",
"⚠".yellow()
);
let _ = std::fs::remove_dir_all(&xcframework_path);
}
}
let xcframework_status = Command::new("xcodebuild")
.args([
"-create-xcframework",
"-library",
&universal_lib,
"-output",
&xcframework_path,
])
.status()
.context("Failed to create XCFramework")?;
if !xcframework_status.success() {
anyhow::bail!("xcodebuild failed to create XCFramework");
}
println!("{}", " ✅ macOS XCFramework created successfully".green());
Ok(())
}
fn build_windows_host_arch(release: bool) -> Result<()> {
ensure_uniffi_bindgen_cs()?;
let host_arch = if let Ok(arch) = std::env::var("PROCESSOR_ARCHITECTURE") {
match arch.as_str() {
"AMD64" => "x86_64",
"x86" => "i686",
"ARM64" => "aarch64",
_ => "x86_64",
}
} else {
"x86_64"
};
let host_platform = match host_arch {
"x86_64" => "x64",
"i686" => "x86",
"aarch64" => "ARM64",
_ => "x64",
};
println!(
" {} Detected host architecture: {}",
"→".bright_blue(),
host_platform
);
let archs = [host_arch];
let platforms = [host_platform];
let profile = if release { "release" } else { "debug" };
build_windows_archs(&archs, &platforms, release, profile)?;
Ok(())
}
fn build_windows_all_archs(release: bool) -> Result<()> {
ensure_uniffi_bindgen_cs()?;
let config = crate::config::load_config()?;
let targets = config
.bundle
.as_ref()
.and_then(|b| b.windows.as_ref())
.map(|w| w.targets.clone())
.unwrap_or_else(|| {
vec![
"x86_64-pc-windows-msvc".into(),
"aarch64-pc-windows-msvc".into(),
]
});
let pairs = windows_architectures(&targets)?;
let archs: Vec<&str> = pairs.iter().map(|pair| pair.0).collect();
let platforms: Vec<&str> = pairs.iter().map(|pair| pair.1).collect();
let profile = if release { "release" } else { "debug" };
build_windows_archs(&archs, &platforms, release, profile)?;
Ok(())
}
fn build_windows_archs(
archs: &[&str],
platforms: &[&str],
release: bool,
profile: &str,
) -> Result<()> {
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
let verbose = std::env::var("JFFI_VERBOSE").is_ok();
if archs.is_empty() || archs.len() != platforms.len() {
anyhow::bail!("Windows build requires matching nonempty architecture and platform lists");
}
let available_archs = archs;
let targets: Vec<String> = archs
.iter()
.map(|arch| format!("{}-pc-windows-msvc", arch))
.collect();
let target_refs: Vec<&str> = targets.iter().map(String::as_str).collect();
crate::setup::ensure_rust_targets(&target_refs)?;
let lib_name = core_library_name(Path::new("core/Cargo.toml"))?;
if verbose {
for arch in available_archs.iter() {
let target = format!("{}-pc-windows-msvc", arch);
println!(" {} Building Windows {}...", "→".bright_blue(), arch);
let mut args = vec!["build"];
if release {
args.push("--release");
}
args.push("--jobs");
args.push("1");
args.extend(&["--target", &target, "--manifest-path", "core/Cargo.toml"]);
let status = Command::new("cargo")
.env("CARGO_TARGET_DIR", "target")
.args(&args)
.status()
.context("Failed to build Rust library")?;
if !status.success() {
anyhow::bail!("Rust build failed for {}", arch);
}
}
} else {
let multi = MultiProgress::new();
let spinner_style = ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
.unwrap()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
for arch in available_archs.iter() {
let target = format!("{}-pc-windows-msvc", arch);
let pb = multi.add(ProgressBar::new_spinner());
pb.set_style(spinner_style.clone());
pb.set_prefix(" →");
pb.set_message(format!("Building Windows {}", arch));
pb.enable_steady_tick(std::time::Duration::from_millis(100));
let mut args = vec!["build"];
if release {
args.push("--release");
}
args.push("--jobs");
args.push("1");
args.extend(&["--target", &target, "--manifest-path", "core/Cargo.toml"]);
let status = Command::new("cargo")
.env("CARGO_TARGET_DIR", "target")
.args(&args)
.status()
.context("Failed to build Rust library")?;
if !status.success() {
pb.finish_with_message(format!("{} Windows {}", "✗".red(), arch));
anyhow::bail!("Rust build failed for {}", arch);
}
pb.finish_with_message(format!("{} Windows {}", "✓".green(), arch));
}
}
let lib_path = windows_library_path(archs[0], profile, &lib_name);
println!(
" {} Generating C# bindings with uniffi-bindgen-cs...",
"→".bright_blue()
);
std::fs::create_dir_all("platforms/windows/Generated")?;
let status = Command::new("uniffi-bindgen-cs")
.arg("--library")
.arg(&lib_path)
.arg("--config")
.arg("core/uniffi.toml")
.arg("--out-dir")
.arg("platforms/windows/Generated")
.status()
.context("Failed to run uniffi-bindgen-cs")?;
if !status.success() {
anyhow::bail!("uniffi-bindgen-cs failed to generate C# bindings");
}
let cs_file_path = format!("platforms/windows/Generated/{}.cs", lib_name);
if let Ok(content) = std::fs::read_to_string(&cs_file_path) {
let mut fixed_content = content.clone();
let marker = "class UniffiCallbackInterface";
let mut start_idx = 0;
let mut patched = false;
while let Some(idx) = fixed_content[start_idx..].find(marker) {
let actual_idx = start_idx + idx;
let name_start = actual_idx + marker.len();
let mut name_end = name_start;
for (i, c) in fixed_content[name_start..].char_indices() {
if !c.is_alphanumeric() && c != '_' {
name_end = name_start + i;
break;
}
}
let callback_name = fixed_content[name_start..name_end].to_string();
if !callback_name.is_empty() {
let bad_internal = format!("internal interface I{}", callback_name);
let good_internal = format!("internal interface {}", callback_name);
if fixed_content.contains(&bad_internal) {
fixed_content = fixed_content.replace(&bad_internal, &good_internal);
patched = true;
}
let bad_public = format!("public interface I{}", callback_name);
let good_public = format!("public interface {}", callback_name);
if fixed_content.contains(&bad_public) {
fixed_content = fixed_content.replace(&bad_public, &good_public);
patched = true;
}
}
start_idx = name_end;
}
if patched {
let _ = std::fs::write(&cs_file_path, fixed_content);
println!(
" {} Auto-fixed uniffi-bindgen-cs interface prefix bug",
"ℹ".bright_blue()
);
}
}
println!(
" {} Building C# project with MSBuild...",
"→".bright_blue()
);
let csproj_file = std::fs::read_dir("platforms/windows")
.context("Failed to read platforms/windows directory")?
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let name_str = name.to_string_lossy();
name_str.ends_with(".csproj")
})
.map(|e| e.path())
.context("Could not find .csproj file")?;
let dotnet_cmd = find_dotnet();
let msbuild_cmd = find_msbuild();
let build_cmd: &str = if dotnet_cmd.is_some() {
"dotnet"
} else {
"msbuild"
};
for (arch, platform) in archs.iter().zip(platforms) {
println!(" {} Building for {}...", "→".bright_blue(), platform);
let source = windows_library_path(arch, profile, &lib_name);
fs::copy(
&source,
Path::new("platforms/windows").join(format!("{}.dll", lib_name)),
)
.with_context(|| format!("Failed to stage {}", source.display()))?;
let output = crate::platform::windows_output_dir(platform, release);
if output.exists() {
fs::remove_dir_all(&output)?;
}
fs::create_dir_all(&output)?;
let output = std::env::current_dir()?.join(output);
let runtime = match *platform {
"x86" => "win-x86",
"x64" => "win-x64",
"ARM64" => "win-arm64",
_ => anyhow::bail!("Unknown Windows platform {}", platform),
};
let mut build_args: Vec<String> = Vec::new();
if build_cmd == "dotnet" {
build_args.push("build".to_string());
build_args.push(csproj_file.to_string_lossy().into_owned());
build_args.push(format!("-p:Platform={}", platform));
build_args.push(format!("-p:RuntimeIdentifier={}", runtime));
build_args.push(format!(
"-p:OutputPath={}{}",
output.display(),
std::path::MAIN_SEPARATOR
));
build_args.push("-p:AppendTargetFrameworkToOutputPath=false".into());
build_args.push("-p:AppendRuntimeIdentifierToOutputPath=false".into());
build_args.push("-p:PublishReadyToRun=false".to_string());
build_args.push("-p:PublishTrimmed=false".to_string());
if release {
build_args.extend(["-c".to_string(), "Release".to_string()]);
}
} else {
build_args.push(csproj_file.to_string_lossy().into_owned());
build_args.push(format!("/p:Platform={}", platform));
build_args.push(format!("/p:RuntimeIdentifier={}", runtime));
build_args.push(format!(
"/p:OutputPath={}{}",
output.display(),
std::path::MAIN_SEPARATOR
));
build_args.push("/p:AppendTargetFrameworkToOutputPath=false".into());
build_args.push("/p:AppendRuntimeIdentifierToOutputPath=false".into());
build_args.push("/p:PublishReadyToRun=false".to_string());
build_args.push("/p:PublishTrimmed=false".to_string());
if release {
build_args.extend(["/p:Configuration=Release".to_string()]);
}
}
let mut cmd = if build_cmd == "dotnet" {
Command::new(dotnet_cmd.as_deref().unwrap_or("dotnet"))
} else {
Command::new(msbuild_cmd.as_deref().unwrap_or("msbuild"))
};
let status = cmd
.args(&build_args)
.status()
.with_context(|| {
let dotnet_hint = if dotnet_cmd.is_some() {
"dotnet was found but failed to run."
} else {
"dotnet was not found (checked PATH and `C:\\Program Files\\dotnet\\dotnet.exe`). Install .NET 8 SDK from https://dotnet.microsoft.com/download"
};
let msbuild_hint = if msbuild_cmd.is_some() {
"msbuild was found but failed to run. Note: MSBuild requires the .NET SDK to resolve Microsoft.NET.Sdk-style projects."
} else {
"msbuild was not found (checked PATH and Visual Studio via vswhere)."
};
format!(
"Failed to build Windows app. {dotnet_hint} {msbuild_hint} Install the .NET SDK (recommended) or Visual Studio Build Tools."
)
})?;
if !status.success() {
anyhow::bail!("C# build failed for platform {}", platform);
}
}
println!("{}", " ✅ Windows build complete".green());
Ok(())
}
fn find_dotnet() -> Option<String> {
if Command::new("dotnet").arg("--version").output().is_ok() {
return Some("dotnet".to_string());
}
let candidates = [
r"C:\Program Files\dotnet\dotnet.exe",
r"C:\Program Files (x86)\dotnet\dotnet.exe",
];
for c in candidates {
if std::path::Path::new(c).exists() {
return Some(c.to_string());
}
}
None
}
fn find_msbuild() -> Option<String> {
if Command::new("msbuild").arg("-version").output().is_ok() {
return Some("msbuild".to_string());
}
let vswhere = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe";
if !std::path::Path::new(vswhere).exists() {
return None;
}
let out = Command::new(vswhere)
.args([
"-latest",
"-products",
"*",
"-requires",
"Microsoft.Component.MSBuild",
"-find",
r"MSBuild\**\Bin\MSBuild.exe",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout);
let first = s.lines().find(|l| !l.trim().is_empty())?.trim().to_string();
if std::path::Path::new(&first).exists() {
Some(first)
} else {
None
}
}
fn ensure_uniffi_bindgen() -> Result<()> {
crate::setup::ensure_uniffi_bindgen()
}
pub(crate) fn ensure_uniffi_bindgen_cs() -> Result<()> {
println!(" {} Checking uniffi-bindgen-cs...", "→".bright_blue());
let check = Command::new("uniffi-bindgen-cs").arg("--version").output();
if check.is_err() || !check.unwrap().status.success() {
if !crate::setup::installation_allowed() {
anyhow::bail!("uniffi-bindgen-cs is required. Run `jffi setup --platform windows`");
}
let repo = std::env::var("JFFI_UNIFFI_BINDGEN_CS_GIT").unwrap_or_else(|_| {
"https://github.com/NordSecurity/uniffi-bindgen-cs".to_string()
});
let tag = std::env::var("JFFI_UNIFFI_BINDGEN_CS_TAG")
.unwrap_or_else(|_| "v0.10.0+v0.29.4".to_string());
let branch = std::env::var("JFFI_UNIFFI_BINDGEN_CS_BRANCH").ok();
println!(
" Installing uniffi-bindgen-cs ({} @ {})...",
repo,
branch.as_deref().unwrap_or(&tag)
);
println!(" {} If you use UniFFI v0.31+, you may need a newer uniffi-bindgen-cs fork; set JFFI_UNIFFI_BINDGEN_CS_GIT/JFFI_UNIFFI_BINDGEN_CS_TAG.", "⚠".yellow());
let mut cmd = Command::new("cargo");
cmd.env("CARGO_NET_GIT_FETCH_WITH_CLI", "true").args([
"install",
"uniffi-bindgen-cs",
"--git",
&repo,
]);
if let Some(branch) = branch.as_deref() {
cmd.args(["--branch", branch]);
} else {
cmd.args(["--tag", &tag]);
}
let status = cmd
.status()
.context("Failed to install uniffi-bindgen-cs")?;
if !status.success() {
anyhow::bail!("Failed to install uniffi-bindgen-cs");
}
println!(
" {} uniffi-bindgen-cs installed successfully",
"✓".green()
);
} else {
println!(" {} uniffi-bindgen-cs is already installed", "✓".green());
}
Ok(())
}
fn build_linux(release: bool) -> Result<()> {
println!("{} Building Linux project...", "→".bright_blue());
let profile = if release { "release" } else { "debug" };
println!(" {} Checking dependencies...", "→".bright_blue());
let python_check = Command::new("python3")
.args(["-c", "import gi; gi.require_version('Gtk', '4.0')"])
.output();
if python_check.is_err() || !python_check.unwrap().status.success() {
anyhow::bail!(
"Python GTK4 dependencies are not available. Run `jffi setup --platform linux`"
);
}
let verbose = std::env::var("JFFI_VERBOSE").is_ok();
let mut args = vec!["build"];
if release {
args.push("--release");
}
args.extend(&["--manifest-path", "core/Cargo.toml"]);
if verbose {
println!(" {} Building Rust library...", "→".bright_blue());
let status = Command::new("cargo")
.args(&args)
.status()
.context("Failed to build Rust library")?;
if !status.success() {
anyhow::bail!("Rust build failed");
}
} else {
use indicatif::{ProgressBar, ProgressStyle};
let pb = ProgressBar::new_spinner();
let spinner_style = ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
.unwrap()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
pb.set_style(spinner_style);
pb.set_prefix(" →");
pb.set_message("Building Rust library");
pb.enable_steady_tick(std::time::Duration::from_millis(100));
let status = Command::new("cargo")
.args(&args)
.status()
.context("Failed to build Rust library")?;
if !status.success() {
pb.finish_with_message(format!("{} Build failed", "✗".red()));
anyhow::bail!("Rust build failed");
}
pb.finish_with_message(format!("{} Rust library", "✓".green()));
}
println!(" {} Generating Python bindings...", "→".bright_blue());
let lib_dir = format!("target/{}", profile);
let lib_path = std::fs::read_dir(&lib_dir)
.context("Failed to read target directory")?
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let name_str = name.to_string_lossy();
name_str.starts_with("lib") && name_str.ends_with("core.so")
})
.map(|e| e.path())
.context("Could not find FFI library")?;
ensure_uniffi_bindgen()?;
let status = Command::new("uniffi-bindgen")
.args([
"generate",
"--library",
lib_path.to_str().unwrap(),
"--language",
"python",
"--out-dir",
"platforms/linux",
])
.status()
.context("Failed to generate Python bindings")?;
if !status.success() {
anyhow::bail!("Binding generation failed");
}
println!("{}", " ✅ Linux build complete".green());
Ok(())
}
pub(crate) fn configure_wasm_compiler(cmd: &mut Command) {
if std::env::consts::OS == "macos" && std::env::var("CC_wasm32_unknown_unknown").is_err() {
if let Ok(output) = Command::new("brew").args(["--prefix", "llvm"]).output() {
if output.status.success() {
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
let clang = format!("{}/bin/clang", prefix);
let ar = format!("{}/bin/llvm-ar", prefix);
if std::path::Path::new(&clang).exists() {
cmd.env("CC_wasm32_unknown_unknown", clang);
cmd.env("AR_wasm32_unknown_unknown", ar);
}
}
}
}
}
fn build_web(release: bool) -> Result<()> {
use indicatif::{ProgressBar, ProgressStyle};
let verbose = std::env::var("JFFI_VERBOSE").is_ok();
ensure_wasm_target()?;
let profile = if release { "release" } else { "debug" };
crate::web_bindings::sync_for_build(std::path::Path::new("."), release)?;
let mut args = vec!["build", "--target", "wasm32-unknown-unknown"];
if release {
args.push("--release");
}
args.extend(&["--manifest-path", "ffi-web/Cargo.toml"]);
if verbose {
println!(" {} Building WASM...", "→".bright_blue());
let mut cmd = Command::new("cargo");
configure_wasm_compiler(&mut cmd);
let status = cmd
.args(&args)
.status()
.context("Failed to build Rust library for WASM")?;
if !status.success() {
anyhow::bail!("Rust WASM build failed");
}
} else {
let pb = ProgressBar::new_spinner();
let spinner_style = ProgressStyle::with_template("{prefix:.bold.dim} {spinner} {wide_msg}")
.unwrap()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ");
pb.set_style(spinner_style);
pb.set_prefix(" →");
pb.set_message("Building WASM");
pb.enable_steady_tick(std::time::Duration::from_millis(100));
let mut cmd = Command::new("cargo");
configure_wasm_compiler(&mut cmd);
let status = cmd
.args(&args)
.status()
.context("Failed to build Rust library for WASM")?;
if !status.success() {
pb.finish_with_message(format!("{} WASM", "✗".red()));
anyhow::bail!("Rust WASM build failed");
}
pb.finish_with_message(format!("{} WASM", "✓".green()));
}
println!(
" {} Generating JavaScript bindings with wasm-bindgen...",
"→".bright_blue()
);
let wasm_dir = format!("target/wasm32-unknown-unknown/{}", profile);
let wasm_file = std::fs::read_dir(&wasm_dir)
.context("Failed to read wasm target directory")?
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let name_str = name.to_string_lossy();
name_str.ends_with("_ffi_web.wasm")
})
.map(|e| e.path())
.context("Could not find WASM file")?;
let out_name = wasm_file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("wasm")
.to_string();
ensure_wasm_bindgen_cli()?;
let status = Command::new("wasm-bindgen")
.arg(wasm_file.to_str().unwrap())
.arg("--out-dir")
.arg("platforms/web/pkg")
.arg("--target")
.arg("web")
.arg("--out-name")
.arg(&out_name)
.status()
.context("Failed to run wasm-bindgen")?;
if !status.success() {
anyhow::bail!("wasm-bindgen failed");
}
println!("{}", " ✅ Web build complete".green());
Ok(())
}
fn ensure_wasm_target() -> Result<()> {
println!(
" {} Checking wasm32-unknown-unknown target...",
"→".bright_blue()
);
let output = Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
.context("Failed to check installed targets")?;
let installed = String::from_utf8_lossy(&output.stdout);
if !installed.contains("wasm32-unknown-unknown") {
anyhow::bail!("wasm32-unknown-unknown is not installed. Run `jffi setup --platform web`");
}
Ok(())
}
pub(crate) fn ensure_wasm_bindgen_cli() -> Result<()> {
println!(" {} Checking wasm-bindgen-cli...", "→".bright_blue());
let lock_content = std::fs::read_to_string("Cargo.lock")
.context("Failed to read Cargo.lock. Run cargo build first.")?;
let mut in_wasm_bindgen = false;
let mut required_version = None;
for line in lock_content.lines() {
let trimmed = line.trim();
if trimmed == "[[package]]" {
in_wasm_bindgen = false;
} else if trimmed == r#"name = "wasm-bindgen""# {
in_wasm_bindgen = true;
} else if in_wasm_bindgen && trimmed.starts_with(r#"version = ""#) {
required_version = trimmed.split('"').nth(1).map(|s| s.to_string());
break;
}
}
let required_version =
required_version.context("Could not find wasm-bindgen version in Cargo.lock")?;
let check = Command::new("wasm-bindgen").arg("--version").output();
let needs_install = if let Ok(output) = check {
if output.status.success() {
let installed_version = String::from_utf8_lossy(&output.stdout);
let installed_version = installed_version.split_whitespace().last().unwrap_or("");
installed_version != required_version
} else {
true
}
} else {
true
};
if needs_install {
if !crate::setup::installation_allowed() {
anyhow::bail!(
"wasm-bindgen-cli {} is required. Run `jffi setup --platform web`",
required_version
);
}
println!(
" Installing wasm-bindgen-cli {} (this may take a few minutes)...",
required_version
);
let status = Command::new("cargo")
.args([
"install",
"-f",
"wasm-bindgen-cli",
"--version",
&required_version,
])
.status()
.context("Failed to install wasm-bindgen-cli")?;
if !status.success() {
anyhow::bail!("Failed to install wasm-bindgen-cli");
}
println!(
" {} wasm-bindgen-cli {} installed",
"✓".green(),
required_version
);
} else {
println!(
" {} wasm-bindgen-cli {} already installed",
"✓".green(),
required_version
);
}
Ok(())
}
fn patch_swift_concurrency(platform_dir: &str) -> Result<()> {
let dir = Path::new(platform_dir);
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if let Some(ext) = path.extension() {
if ext == "swift" {
let filename = path.file_name().unwrap().to_string_lossy();
if filename.ends_with("_ffi.swift") || filename.ends_with("_core.swift") {
patch_swift_file(&path)?;
}
}
}
}
Ok(())
}
fn patch_swift_file(path: &Path) -> Result<()> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read Swift file: {}", path.display()))?;
let patched = content.replace(
"static let vtablePtr: UnsafePointer<",
"nonisolated(unsafe) static let vtablePtr: UnsafePointer<",
);
if patched != content {
fs::write(path, patched)
.with_context(|| format!("Failed to write patched Swift file: {}", path.display()))?;
println!(
" {} Patched Swift 6 concurrency in {}",
"✓".green(),
path.file_name().unwrap().to_string_lossy()
);
}
Ok(())
}
fn resolve_version_code(
configured_build_number: Option<u32>,
package_version_code: Option<u32>,
ci_run_number: Option<&str>,
) -> (u32, bool) {
if let Some(number) = configured_build_number {
return (number, false);
}
if let Some(number) = ci_run_number.and_then(|value| value.parse::<u32>().ok()) {
return (number, true);
}
(package_version_code.unwrap_or(1), false)
}
fn resolve_android_target_sdk(
configured_target_sdk: Option<u32>,
bundle_compile_sdk: Option<u32>,
) -> u32 {
configured_target_sdk.or(bundle_compile_sdk).unwrap_or(36)
}
fn sync_android_gradle_properties(content: &str, shrink_resources: bool) -> String {
let mut found = false;
let mut lines: Vec<String> = content
.lines()
.map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("android.enableResourceOptimizations") {
found = true;
let indent = line.len() - line.trim_start().len();
format!(
"{:width$}android.enableResourceOptimizations={}",
"",
shrink_resources,
width = indent
)
} else {
line.to_string()
}
})
.collect();
if !found && shrink_resources {
lines.push("android.enableResourceOptimizations=true".to_string());
}
let mut result = lines.join("\n");
if content.ends_with("\n") && !result.ends_with("\n") {
result.push('\n');
}
result
}
fn sync_windows_csproj_content(content: &str, version_name: &str) -> String {
let assembly_version = if version_name.split('.').count() == 3 {
format!("{}.0", version_name)
} else {
version_name.to_string()
};
let replacements = [
("Version", version_name),
("AssemblyVersion", assembly_version.as_str()),
("FileVersion", assembly_version.as_str()),
];
let mut result = content.to_string();
let mut missing = Vec::new();
for (tag, value) in replacements {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
if let Some(start) = result.find(&open) {
if let Some(relative_end) = result[start + open.len()..].find(&close) {
let value_start = start + open.len();
let value_end = value_start + relative_end;
result.replace_range(value_start..value_end, value);
continue;
}
}
missing.push((tag, value.to_string()));
}
if !missing.is_empty() {
if let Some(group_start) = result.find("<PropertyGroup>") {
let insert_at = group_start + "<PropertyGroup>".len();
let block = missing
.into_iter()
.map(|(tag, value)| format!("\n <{}>{}</{}>", tag, value, tag))
.collect::<String>();
result.insert_str(insert_at, &block);
}
}
result
}
fn sync_linux_metainfo_content(
content: &str,
version_name: &str,
release_date: Option<&str>,
) -> String {
let release_prefix = format!("<release version=\"{}\"", version_name);
let date_attribute = release_date
.map(|date| format!(" date=\"{}\"", date))
.unwrap_or_default();
if let Some(release_start) = content.find(&release_prefix) {
let tag_end = content[release_start..]
.find('>')
.map(|offset| release_start + offset);
if let (Some(date), Some(tag_end)) = (release_date, tag_end) {
let tag = &content[release_start..=tag_end];
if !tag.contains(" date=") {
let insert_at = if content.as_bytes().get(tag_end.wrapping_sub(1)) == Some(&b'/') {
if content.as_bytes().get(tag_end.wrapping_sub(2)) == Some(&b' ') {
tag_end - 2
} else {
tag_end - 1
}
} else {
tag_end
};
let mut result = content.to_string();
result.insert_str(insert_at, &format!(" date=\"{}\"", date));
return result;
}
}
return content.to_string();
}
if let Some(releases_start) = content.find("<releases>") {
let insert_at = releases_start + "<releases>".len();
let indent = content[..releases_start]
.rsplit_once('\n')
.map(|(_, line)| {
line.chars()
.take_while(|c| c.is_whitespace())
.collect::<String>()
})
.unwrap_or_default();
let entry = format!(
"\n{} <release version=\"{}\"{} />",
indent, version_name, date_attribute
);
let mut result = content.to_string();
result.insert_str(insert_at, &entry);
return result;
}
if let Some(component_end) = content.rfind("</component>") {
let mut result = content.to_string();
result.insert_str(
component_end,
&format!(
" <releases>\n <release version=\"{}\"{} />\n </releases>\n",
version_name, date_attribute
),
);
return result;
}
content.to_string()
}
pub fn sync_configs_to_platforms(config: &crate::config::Config) -> Result<()> {
println!(
" {} Syncing jffi.toml configurations to native platform builds...",
"→".bright_blue()
);
let version_name = config
.bundle
.as_ref()
.and_then(|b| b.version.as_ref())
.unwrap_or(&config.package.version);
let configured_build_number = config.bundle.as_ref().and_then(|b| b.build_number);
let ci_run_number = std::env::var("GITHUB_RUN_NUMBER").ok();
let (version_code, used_ci_run_number) = resolve_version_code(
configured_build_number,
config.package.version_code,
ci_run_number.as_deref(),
);
if used_ci_run_number {
println!(
" {} No build_number configured. Using CI run number: {}",
"★".bright_magenta(),
version_code
);
}
let gradle_path = Path::new("platforms/android/app/build.gradle.kts");
if gradle_path.exists() {
let content = fs::read_to_string(gradle_path)?;
let package = &config.platforms.android.package;
let old_package = content
.lines()
.find(|line| line.trim().starts_with("namespace ="))
.and_then(|line| {
let parts: Vec<&str> = line.split('=').collect();
if parts.len() == 2 {
let val = parts[1].trim();
let trimmed_val = val.trim_matches(|c| {
c == '"' || c == '\'' || c == ' ' || c == ';' || c == ','
});
Some(trimmed_val.to_string())
} else {
None
}
});
if let Some(ref old_pkg) = old_package {
if old_pkg != package {
rename_android_package(old_pkg, package)?;
}
}
let min_sdk = config.platforms.android.min_sdk;
let bundle_compile_sdk = config
.bundle
.as_ref()
.and_then(|bundle| bundle.android.as_ref())
.map(|android| android.compile_sdk);
let target_sdk =
resolve_android_target_sdk(config.platforms.android.target_sdk, bundle_compile_sdk);
let obfuscate = config.platforms.android.obfuscate;
let shrink_resources = config.platforms.android.shrink_resources;
let lines: Vec<String> = content
.lines()
.map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("applicationId =") {
let indent = line.len() - line.trim_start().len();
format!(
"{:width$}applicationId = \"{}\"",
"",
package,
width = indent
)
} else if trimmed.starts_with("namespace =") {
let indent = line.len() - line.trim_start().len();
format!("{:width$}namespace = \"{}\"", "", package, width = indent)
} else if trimmed.starts_with("minSdk =") {
let indent = line.len() - line.trim_start().len();
format!("{:width$}minSdk = {}", "", min_sdk, width = indent)
} else if trimmed.starts_with("targetSdk =") {
let indent = line.len() - line.trim_start().len();
format!("{:width$}targetSdk = {}", "", target_sdk, width = indent)
} else if trimmed.starts_with("versionName =") {
let indent = line.len() - line.trim_start().len();
format!(
"{:width$}versionName = \"{}\"",
"",
version_name,
width = indent
)
} else if trimmed.starts_with("versionCode =") {
let indent = line.len() - line.trim_start().len();
format!(
"{:width$}versionCode = {}",
"",
version_code,
width = indent
)
} else if trimmed.starts_with("isMinifyEnabled =") {
let indent = line.len() - line.trim_start().len();
format!(
"{:width$}isMinifyEnabled = {}",
"",
obfuscate,
width = indent
)
} else if trimmed.starts_with("isShrinkResources =") {
let indent = line.len() - line.trim_start().len();
format!(
"{:width$}isShrinkResources = {}",
"",
shrink_resources,
width = indent
)
} else {
line.to_string()
}
})
.collect();
let new_content = lines.join("\n");
if new_content != content {
fs::write(gradle_path, new_content)?;
println!(
" {} Synced Android gradle configurations (package: {})",
"✓".green(),
package
);
}
let gradle_props_path = Path::new("platforms/android/gradle.properties");
if gradle_props_path.exists() {
let props_content = fs::read_to_string(gradle_props_path)?;
let new_props = sync_android_gradle_properties(&props_content, shrink_resources);
if new_props != props_content {
fs::write(gradle_props_path, new_props)?;
println!(
" {} Synced Android gradle.properties (enableResourceOptimizations: {})",
"✓".green(),
shrink_resources
);
}
}
}
let bundle_id = config
.bundle
.as_ref()
.and_then(|b| b.identifier.clone())
.unwrap_or_else(|| config.platforms.ios.bundle_id.clone());
let platforms = vec!["ios", "macos"];
for platform_str in platforms {
let platform_dir = format!("platforms/{}", platform_str);
if !Path::new(&platform_dir).exists() {
continue;
}
if let Ok(entries) = fs::read_dir(&platform_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("xcodeproj") {
let pbxproj_path = path.join("project.pbxproj");
if pbxproj_path.exists() {
let content = fs::read_to_string(&pbxproj_path)?;
let mut all_ids = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("PRODUCT_BUNDLE_IDENTIFIER = ") {
if let Some(val) = trimmed.split('=').nth(1) {
let id = val
.trim()
.trim_end_matches(';')
.trim_matches('"')
.to_string();
if !id.is_empty() {
all_ids.push(id);
}
}
}
}
all_ids.sort_by_key(|id| id.len());
let base_id = all_ids.first().cloned();
let lines: Vec<String> = content
.lines()
.map(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with("PRODUCT_BUNDLE_IDENTIFIER = ") {
let parts: Vec<&str> = line.split('=').collect();
if parts.len() == 2 {
let indent = line.len() - trimmed.len();
let existing_id =
parts[1].trim().trim_end_matches(';').trim_matches('"');
let final_id = if let Some(ref base) = base_id {
if existing_id.starts_with(base)
&& existing_id.len() > base.len()
{
let suffix = &existing_id[base.len()..];
format!("{}{}", bundle_id, suffix)
} else {
bundle_id.clone()
}
} else {
bundle_id.clone()
};
format!(
"{:width$}PRODUCT_BUNDLE_IDENTIFIER = {};",
"",
final_id,
width = indent
)
} else {
line.to_string()
}
} else if trimmed.starts_with("IPHONEOS_DEPLOYMENT_TARGET = ")
|| trimmed.starts_with("MACOSX_DEPLOYMENT_TARGET = ")
{
let (key, value) = if platform_str == "ios" {
(
"IPHONEOS_DEPLOYMENT_TARGET",
&config.platforms.ios.deployment_target,
)
} else {
(
"MACOSX_DEPLOYMENT_TARGET",
&config.platforms.macos.deployment_target,
)
};
format!(
"{}{} = {};",
" ".repeat(line.len() - trimmed.len()),
key,
value
)
} else if trimmed.starts_with("MARKETING_VERSION = ") {
let parts: Vec<&str> = line.split('=').collect();
if parts.len() == 2 {
let indent = line.len() - trimmed.len();
format!(
"{:width$}MARKETING_VERSION = {};",
"",
version_name,
width = indent
)
} else {
line.to_string()
}
} else if trimmed.starts_with("CURRENT_PROJECT_VERSION = ") {
let parts: Vec<&str> = line.split('=').collect();
if parts.len() == 2 {
let indent = line.len() - trimmed.len();
format!(
"{:width$}CURRENT_PROJECT_VERSION = {};",
"",
version_code,
width = indent
)
} else {
line.to_string()
}
} else {
line.to_string()
}
})
.collect();
let new_content = lines.join("\n");
if new_content != content {
fs::write(&pbxproj_path, new_content)?;
println!(" {} Synced {} Xcode bundle settings (id: {}, version: {}, build: {})", "✓".green(), platform_str, bundle_id, version_name, version_code);
}
}
}
}
}
if platform_str == "ios" {
if let Some(ref groups) = config.platforms.ios.app_groups {
sync_entitlements(Path::new("platforms/ios"), groups)?;
}
}
if platform_str == "macos" {
if let Some(ref groups) = config.platforms.macos.app_groups {
sync_entitlements(Path::new("platforms/macos"), groups)?;
}
}
}
let index_path = Path::new("platforms/web/index.html");
if index_path.exists() {
let web = &config.platforms.web;
let name_pascal = config
.package
.name
.split('-')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<String>();
let title = web.title.as_deref().unwrap_or(&name_pascal);
let lang = &web.lang;
let content = fs::read_to_string(index_path)?;
let mut new_content = content.clone();
if new_content.contains("<html lang=\"") {
let re_lang = format!("<html lang=\"{}\"", lang);
if let Some(start) = new_content.find("<html lang=\"") {
if let Some(end) = new_content[start + 12..].find('"') {
let old = &new_content[start..start + 12 + end + 1];
new_content = new_content.replace(old, &re_lang);
}
}
}
if let (Some(start), Some(end)) =
(new_content.find("<title>"), new_content.find("</title>"))
{
let old_title = &new_content[start..end + 8];
new_content = new_content.replace(old_title, &format!("<title>{}</title>", title));
}
let mut meta_tags = Vec::new();
if let Some(desc) = &web.description {
meta_tags.push(format!(
" <meta name=\"description\" content=\"{}\">",
desc
));
}
if let Some(keywords) = &web.keywords {
meta_tags.push(format!(
" <meta name=\"keywords\" content=\"{}\">",
keywords
));
}
if let Some(author) = &web.author {
meta_tags.push(format!(" <meta name=\"author\" content=\"{}\">", author));
}
if let Some(theme_color) = &web.theme_color {
meta_tags.push(format!(
" <meta name=\"theme-color\" content=\"{}\">",
theme_color
));
}
if let Some(favicon) = &web.favicon {
meta_tags.push(format!(" <link rel=\"icon\" href=\"{}\">", favicon));
}
meta_tags.push(format!(
" <meta property=\"og:title\" content=\"{}\">",
title
));
if let Some(desc) = &web.description {
meta_tags.push(format!(
" <meta property=\"og:description\" content=\"{}\">",
desc
));
}
if let Some(og_type) = &web.og_type {
meta_tags.push(format!(
" <meta property=\"og:type\" content=\"{}\">",
og_type
));
}
if let Some(og_url) = &web.og_url {
meta_tags.push(format!(
" <meta property=\"og:url\" content=\"{}\">",
og_url
));
}
if let Some(og_image) = &web.og_image {
meta_tags.push(format!(
" <meta property=\"og:image\" content=\"{}\">",
og_image
));
}
if let Some(twitter_card) = &web.twitter_card {
meta_tags.push(format!(
" <meta name=\"twitter:card\" content=\"{}\">",
twitter_card
));
meta_tags.push(format!(
" <meta name=\"twitter:title\" content=\"{}\">",
title
));
if let Some(desc) = &web.description {
meta_tags.push(format!(
" <meta name=\"twitter:description\" content=\"{}\">",
desc
));
}
if let Some(og_image) = &web.og_image {
meta_tags.push(format!(
" <meta name=\"twitter:image\" content=\"{}\">",
og_image
));
}
}
let marker_start = " <!-- jffi:meta:start -->";
let marker_end = " <!-- jffi:meta:end -->";
if let (Some(ms), Some(me)) = (new_content.find(marker_start), new_content.find(marker_end))
{
let old_block = &new_content[ms..me + marker_end.len()];
let new_block = format!("{}\n{}\n{}", marker_start, meta_tags.join("\n"), marker_end);
new_content = new_content.replace(old_block, &new_block);
} else if !meta_tags.is_empty() {
if let Some(head_end) = new_content.find("</head>") {
let new_block = format!(
"{}\n{}\n{}\n",
marker_start,
meta_tags.join("\n"),
marker_end
);
new_content.insert_str(head_end, &new_block);
}
}
if new_content != content {
fs::write(index_path, new_content)?;
println!(
" {} Synced web SEO & meta tags (title: {})",
"✓".green(),
title
);
}
}
let cargo_path = Path::new("core/Cargo.toml");
if cargo_path.exists() {
let content = fs::read_to_string(cargo_path)?;
let lines: Vec<String> = content
.lines()
.map(|line| {
if line.starts_with("version = \"") {
format!("version = \"{}\"", version_name)
} else {
line.to_string()
}
})
.collect();
let new_content = lines.join("\n");
if new_content != content {
fs::write(cargo_path, new_content)?;
println!(" {} Synced Rust core Cargo.toml version", "✓".green());
}
}
let windows_path = Path::new("platforms/windows/Package.appxmanifest");
if windows_path.exists() {
let content = fs::read_to_string(windows_path)?;
let parts: Vec<&str> = version_name.split('.').collect();
let windows_version = if parts.len() == 3 {
format!("{}.{}.{}.0", parts[0], parts[1], version_code)
} else {
version_name.to_string()
};
let windows_app_id = get_windows_app_id(config);
let mut inside_identity = false;
let lines: Vec<String> = content
.lines()
.map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("<Identity") {
inside_identity = true;
}
let mut result = line.to_string();
if inside_identity {
if trimmed.starts_with("Name=\"") {
if let Some(start) = line.find("Name=\"") {
if let Some(end) = line[start + 6..].find('"') {
let prefix = &line[..start + 6];
let suffix = &line[start + 6 + end + 1..];
result = format!("{}{}\"{}", prefix, windows_app_id, suffix);
}
}
}
if trimmed.ends_with("/>") || trimmed.contains("</Identity>") {
inside_identity = false;
}
}
if result.trim_start().starts_with("Version=\"") {
if let Some(start) = result.find("Version=\"") {
if let Some(end) = result[start + 9..].find('"') {
let prefix = &result[..start + 9];
let suffix = &result[start + 9 + end + 1..];
return format!("{}{}\"{}", prefix, windows_version, suffix);
}
}
}
result
})
.collect();
let new_content = lines.join("\n");
let new_content = sync_windows_minimum(&new_content, &config.platforms.windows.min_version);
let new_content = if let Some(publisher) = config
.bundle
.as_ref()
.and_then(|bundle| bundle.windows.as_ref())
.and_then(|windows| windows.publisher_id.as_deref())
{
sync_windows_publisher(&new_content, publisher)
} else {
new_content
};
if new_content != content {
fs::write(windows_path, new_content)?;
println!(
" {} Synced Windows Package.appxmanifest identity & version (id: {})",
"✓".green(),
windows_app_id
);
}
}
let windows_dir = Path::new("platforms/windows");
if windows_dir.is_dir() {
for entry in fs::read_dir(windows_dir)?.filter_map(|entry| entry.ok()) {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("csproj") {
continue;
}
let content = fs::read_to_string(&path)?;
let new_content = sync_windows_csproj_content(&content, version_name);
let new_content =
sync_windows_minimum(&new_content, &config.platforms.windows.min_version);
if new_content != content {
fs::write(&path, new_content)?;
println!(
" {} Synced Windows project version ({})",
"✓".green(),
path.file_name().unwrap_or_default().to_string_lossy()
);
}
}
}
let linux_app_py = Path::new("platforms/linux/app.py");
if linux_app_py.exists() {
let content = fs::read_to_string(linux_app_py)?;
let app_id = get_linux_app_id(config);
let lines: Vec<String> = content
.lines()
.map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("application_id=") || trimmed.starts_with("application_id =")
{
if let Some(start) = line.find("application_id") {
if let Some(_eq_pos) = line[start..].find('=') {
let indent = line.len() - line.trim_start().len();
return format!(
"{:width$}application_id='{}',",
"",
app_id,
width = indent
);
}
}
}
line.to_string()
})
.collect();
let new_content = lines.join("\n");
if new_content != content {
fs::write(linux_app_py, new_content)?;
println!(
" {} Synced Linux application ID (id: {})",
"✓".green(),
app_id
);
}
}
let linux_dir = Path::new("platforms/linux");
if linux_dir.is_dir() {
for entry in fs::read_dir(linux_dir)?.filter_map(|entry| entry.ok()) {
let path = entry.path();
let is_metainfo = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".metainfo.xml"));
if !is_metainfo {
continue;
}
let content = fs::read_to_string(&path)?;
let new_content = sync_linux_metainfo_content(
&content,
version_name,
config.package.release_date.as_deref(),
);
if new_content != content {
fs::write(&path, new_content)?;
println!(
" {} Synced Linux AppStream version ({})",
"✓".green(),
path.file_name().unwrap_or_default().to_string_lossy()
);
}
}
}
Ok(())
}
fn find_ndk_dir() -> Option<std::path::PathBuf> {
if let Ok(ndk_home) = std::env::var("ANDROID_NDK_HOME") {
let path = std::path::PathBuf::from(ndk_home);
if path.exists() {
return Some(path);
}
}
let sdk_roots = vec![
std::env::var("ANDROID_HOME").ok(),
std::env::var("ANDROID_SDK_ROOT").ok(),
std::env::var("HOME")
.ok()
.map(|h| format!("{}/Library/Android/sdk", h)),
std::env::var("HOME")
.ok()
.map(|h| format!("{}/Android/Sdk", h)),
];
for sdk_root in sdk_roots.into_iter().flatten() {
let ndk_base = std::path::PathBuf::from(&sdk_root).join("ndk");
if ndk_base.exists() {
if let Ok(entries) = std::fs::read_dir(&ndk_base) {
let mut versions: Vec<std::path::PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
versions.sort();
if let Some(latest) = versions.last() {
return Some(latest.clone());
}
}
}
let ndk_bundle = std::path::PathBuf::from(&sdk_root).join("ndk-bundle");
if ndk_bundle.exists() {
return Some(ndk_bundle);
}
}
None
}
fn find_libcxx_shared(ndk_dir: &Path, target: &str) -> Option<std::path::PathBuf> {
let target_sub = match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "arm-linux-androideabi",
"x86_64-linux-android" => "x86_64-linux-android",
"i686-linux-android" => "i686-linux-android",
_ => target,
};
let hosts = vec!["darwin-x86_64", "linux-x86_64", "windows-x86_64", "windows"];
for host in hosts {
let path = ndk_dir
.join("toolchains/llvm/prebuilt")
.join(host)
.join("sysroot/usr/lib")
.join(target_sub)
.join("libc++_shared.so");
if path.exists() {
return Some(path);
}
}
None
}
pub(crate) fn get_linux_app_id(config: &crate::config::Config) -> String {
if let Some(ref bundle) = config.bundle {
if let Some(ref linux) = bundle.linux {
if let Some(ref app_id) = linux.app_id {
return app_id.clone();
}
}
if let Some(ref id) = bundle.identifier {
return id.clone();
}
}
if !config.platforms.android.package.is_empty()
&& config.platforms.android.package != "com.example.app"
{
return config.platforms.android.package.clone();
}
if !config.platforms.ios.bundle_id.is_empty()
&& config.platforms.ios.bundle_id != "com.example.app"
{
return config.platforms.ios.bundle_id.clone();
}
format!("com.example.{}", config.package.name.replace("-", ""))
}
fn get_windows_app_id(config: &crate::config::Config) -> String {
if let Some(ref bundle) = config.bundle {
if let Some(ref windows) = bundle.windows {
if let Some(ref identity_name) = windows.identity_name {
return identity_name.clone();
}
}
if let Some(ref id) = bundle.identifier {
return id.clone();
}
}
if !config.platforms.android.package.is_empty()
&& config.platforms.android.package != "com.example.app"
{
return config.platforms.android.package.clone();
}
if !config.platforms.ios.bundle_id.is_empty()
&& config.platforms.ios.bundle_id != "com.example.app"
{
return config.platforms.ios.bundle_id.clone();
}
format!("com.example.{}", config.package.name.replace("-", ""))
}
fn rename_android_package(old_pkg: &str, new_pkg: &str) -> Result<()> {
use std::path::PathBuf;
let src_dir = Path::new("platforms/android/app/src");
if !src_dir.exists() {
return Ok(());
}
println!(
" {} Android package name change detected: {} → {}",
"→".bright_blue(),
old_pkg,
new_pkg
);
let walk_dir = |dir: &Path| -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut queue = vec![dir.to_path_buf()];
while let Some(path) = queue.pop() {
if path.is_dir() {
for entry in fs::read_dir(path)? {
let entry = entry?;
queue.push(entry.path());
}
} else {
let ext = path.extension().and_then(|s| s.to_str());
if ext == Some("kt") || ext == Some("java") || ext == Some("xml") {
files.push(path);
}
}
}
Ok(files)
};
let files = walk_dir(src_dir)?;
for file in &files {
let content = fs::read_to_string(file)?;
if content.contains(old_pkg) {
let new_content = content.replace(old_pkg, new_pkg);
fs::write(file, new_content)?;
}
}
if let Ok(entries) = fs::read_dir(src_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
for lang_dir_name in &["java", "kotlin"] {
let lang_dir = path.join(lang_dir_name);
if lang_dir.is_dir() {
let old_pkg_dir = lang_dir.join(old_pkg.replace('.', "/"));
let new_pkg_dir = lang_dir.join(new_pkg.replace('.', "/"));
if old_pkg_dir.exists() && old_pkg_dir != new_pkg_dir {
fs::create_dir_all(&new_pkg_dir)?;
move_dir_contents(&old_pkg_dir, &new_pkg_dir)?;
clean_empty_parent_directories(&old_pkg_dir, &lang_dir)?;
}
}
}
}
}
}
Ok(())
}
fn move_dir_contents(src: &Path, dst: &Path) -> Result<()> {
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let target = dst.join(name);
if path.is_dir() {
fs::create_dir_all(&target)?;
move_dir_contents(&path, &target)?;
fs::remove_dir(&path)?;
} else {
if target.exists() {
fs::remove_file(&target)?;
}
fs::rename(&path, &target)?;
}
}
Ok(())
}
fn clean_empty_parent_directories(leaf: &Path, stop_at: &Path) -> Result<()> {
let mut current = leaf.to_path_buf();
while current != stop_at {
if current.exists() && current.is_dir() {
if fs::read_dir(¤t)?.next().is_none() {
fs::remove_dir(¤t)?;
} else {
break;
}
}
if let Some(parent) = current.parent() {
current = parent.to_path_buf();
} else {
break;
}
}
Ok(())
}
fn sync_entitlements(dir: &Path, groups: &[String]) -> Result<()> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
sync_entitlements(&path, groups)?;
} else if path.extension().and_then(|s| s.to_str()) == Some("entitlements") {
update_entitlements_file(&path, groups)?;
}
}
}
Ok(())
}
fn update_entitlements_file(path: &Path, app_groups: &[String]) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut groups_xml = String::new();
groups_xml.push_str("\t<key>com.apple.security.application-groups</key>\n\t<array>\n");
for group in app_groups {
groups_xml.push_str(&format!("\t\t<string>{}</string>\n", group));
}
groups_xml.push_str("\t</array>\n");
let new_content = if content.contains("com.apple.security.application-groups") {
if let Some(key_idx) = content.find("<key>com.apple.security.application-groups</key>") {
if let Some(array_start) = content[key_idx..].find("<array>") {
if let Some(array_end) = content[key_idx + array_start..].find("</array>") {
let line_start = content[..key_idx].rfind('\n').map_or(0, |idx| idx + 1);
let array_end_pos = key_idx + array_start + array_end + "</array>".len();
let suffix_start = content[array_end_pos..]
.find(|ch: char| !ch.is_whitespace())
.map_or(content.len(), |idx| array_end_pos + idx);
let mut res = content[..line_start].to_string();
res.push_str(&groups_xml);
if suffix_start < content.len()
&& !content[suffix_start..].starts_with("</dict>")
{
res.push('\t');
}
res.push_str(&content[suffix_start..]);
res
} else {
content.clone()
}
} else {
content.clone()
}
} else {
content.clone()
}
} else {
if let Some(dict_end) = content.rfind("</dict>") {
let mut res = content[..dict_end].to_string();
res.push_str(&groups_xml);
res.push_str(&content[dict_end..]);
res
} else {
content.clone()
}
};
if new_content != content {
fs::write(path, new_content)?;
}
Ok(())
}
fn sync_windows_publisher(content: &str, publisher: &str) -> String {
let mut result = content.to_string();
let Some(start) = result.find("<Identity") else {
return result;
};
let Some(tag_end) = result[start..].find('>') else {
return result;
};
let end = start + tag_end;
let prefix = "Publisher=\"";
if let Some(attribute) = result[start..end].find(prefix) {
let value_start = start + attribute + prefix.len();
if let Some(value_end) = result[value_start..end].find('"') {
result.replace_range(
value_start..value_start + value_end,
&xml_attribute(publisher),
);
}
} else {
result.insert_str(
start + "<Identity".len(),
&format!(" Publisher=\"{}\"", xml_attribute(publisher)),
);
}
result
}
fn xml_attribute(value: &str) -> String {
value
.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
.replace('\'', "'")
}
pub(crate) fn core_library_name(manifest: &Path) -> Result<String> {
let content = fs::read_to_string(manifest)
.with_context(|| format!("Could not read {}", manifest.display()))?;
let manifest: toml::Value = toml::from_str(&content)?;
let name = manifest
.get("lib")
.and_then(|lib| lib.get("name"))
.and_then(toml::Value::as_str)
.or_else(|| {
manifest
.get("package")
.and_then(|package| package.get("name"))
.and_then(toml::Value::as_str)
})
.context("core/Cargo.toml must define a library or package name")?;
Ok(name.replace('-', "_"))
}
fn windows_library_path(arch: &str, profile: &str, library: &str) -> std::path::PathBuf {
Path::new("target")
.join(format!("{}-pc-windows-msvc", arch))
.join(profile)
.join(format!("{}.dll", library))
}
fn sync_windows_minimum(content: &str, minimum: &str) -> String {
let mut result = content.to_string();
let open = "<TargetPlatformMinVersion>";
if let Some(start) = result.find(open) {
let value_start = start + open.len();
if let Some(end) = result[value_start..].find("</TargetPlatformMinVersion>") {
result.replace_range(value_start..value_start + end, minimum);
}
}
let mut cursor = 0;
while let Some(start) = result[cursor..].find("<TargetDeviceFamily") {
let start = cursor + start;
let Some(end) = result[start..].find('>') else {
break;
};
let end = start + end;
if let Some(attribute) = result[start..end].find("MinVersion=\"") {
let value_start = start + attribute + "MinVersion=\"".len();
if let Some(value_end) = result[value_start..end].find('"') {
result.replace_range(value_start..value_start + value_end, minimum);
}
}
cursor = start + result[start..].find('>').unwrap() + 1;
}
result
}
fn windows_architectures(targets: &[String]) -> Result<Vec<(&'static str, &'static str)>> {
if targets.is_empty() {
anyhow::bail!("bundle.windows.targets cannot be empty");
}
targets
.iter()
.map(|target| match target.as_str() {
"i686-pc-windows-msvc" => Ok(("i686", "x86")),
"x86_64-pc-windows-msvc" => Ok(("x86_64", "x64")),
"aarch64-pc-windows-msvc" => Ok(("aarch64", "ARM64")),
_ => anyhow::bail!("Unsupported Windows target: {}", target),
})
.collect()
}
#[cfg(test)]
mod config_sync_tests {
#[test]
fn msix_publisher_sync_preserves_the_identity_tag_and_escapes_xml() {
let source = "<Package><Identity Name=\"Sample\" Publisher=\"CN=dev\" Version=\"1.0.0.0\" /></Package>";
let updated = super::sync_windows_publisher(source, "CN=Company & Friends");
assert_eq!(updated, "<Package><Identity Name=\"Sample\" Publisher=\"CN=Company & Friends\" Version=\"1.0.0.0\" /></Package>");
assert_eq!(
super::sync_windows_publisher(&updated, "CN=Company & Friends"),
updated
);
}
#[cfg(unix)]
#[test]
fn windows_multi_arch_build_stages_matching_dll_and_ignores_stale_outputs() {
use std::os::unix::fs::PermissionsExt;
let root =
std::env::temp_dir().join(format!("jffi-windows-review-{}", uuid::Uuid::new_v4()));
let tools = root.join("tools");
fs::create_dir_all(&tools).unwrap();
fs::create_dir_all(root.join("core")).unwrap();
fs::create_dir_all(root.join("platforms/windows/bin/ARM64/Release/jffi")).unwrap();
fs::write(
root.join("platforms/windows/bin/ARM64/Release/jffi/stale.dll"),
"stale",
)
.unwrap();
fs::write(root.join("core/Cargo.toml"), "[package]\nname='different-project-name'\nversion='0.1.0'\n[lib]\nname='custom_native'\n").unwrap();
fs::write(root.join("core/uniffi.toml"), "").unwrap();
fs::write(root.join("platforms/windows/app.csproj"), "<Project />").unwrap();
let script = r#"#!/bin/sh
set -eu
tool=${0##*/}
printf '%s|%s\n' "$tool" "$*" >> commands.log
case "$tool" in
rustup) printf '%s\n' aarch64-pc-windows-msvc i686-pc-windows-msvc ;;
cargo)
previous=''
for arg in "$@"; do
if [ "$previous" = --target ]; then target=$arg; fi
previous=$arg
done
mkdir -p "target/$target/release"
printf '%s' "$target" > "target/$target/release/custom_native.dll" ;;
uniffi-bindgen-cs)
test "$2" = target/aarch64-pc-windows-msvc/release/custom_native.dll
test -f "$2" ;;
dotnet)
if [ "${1:-}" = --version ]; then echo 8.0; exit 0; fi
for arg in "$@"; do
case "$arg" in
-p:Platform=*) platform=${arg#-p:Platform=} ;;
-p:RuntimeIdentifier=*) runtime=${arg#-p:RuntimeIdentifier=} ;;
-p:OutputPath=*) output=${arg#-p:OutputPath=} ;;
esac
done
case "$platform" in
ARM64) expected=aarch64-pc-windows-msvc; test "$runtime" = win-arm64 ;;
x86) expected=i686-pc-windows-msvc; test "$runtime" = win-x86 ;;
*) exit 90 ;;
esac
test "$(cat platforms/windows/custom_native.dll)" = "$expected"
test ! -f "$output/stale.dll"
mkdir -p "$output"
cp platforms/windows/custom_native.dll "$output/custom_native.dll"
printf '<Package />' > "$output/AppxManifest.xml" ;;
esac
"#;
for name in ["cargo", "rustup", "uniffi-bindgen-cs", "dotnet"] {
let path = tools.join(name);
fs::write(&path, script).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
}
let path = std::env::join_paths(std::iter::once(tools).chain(std::env::split_paths(
&std::env::var_os("PATH").unwrap_or_default(),
)))
.unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--ignored",
"--exact",
"commands::build::config_sync_tests::windows_build_review_worker",
"--nocapture",
])
.current_dir(&root)
.env("PATH", path)
.env("JFFI_WINDOWS_REVIEW_WORKER", "1")
.env("JFFI_NO_SETUP", "1")
.env("JFFI_VERBOSE", "1")
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(
root.join("platforms/windows/bin/ARM64/Release/jffi/custom_native.dll")
)
.unwrap(),
"aarch64-pc-windows-msvc"
);
assert_eq!(
fs::read_to_string(
root.join("platforms/windows/bin/x86/Release/jffi/custom_native.dll")
)
.unwrap(),
"i686-pc-windows-msvc"
);
assert!(!root.join("target/x86_64-pc-windows-msvc").exists());
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
#[ignore = "subprocess worker for isolated environment and command shim tests"]
fn windows_build_review_worker() {
if std::env::var("JFFI_WINDOWS_REVIEW_WORKER").as_deref() != Ok("1") {
return;
}
super::build_windows_archs(&["aarch64", "i686"], &["ARM64", "x86"], true, "release")
.unwrap();
}
#[test]
fn windows_minimum_reaches_both_compiler_and_package_without_changing_sdk_ceiling() {
let source = "<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>\n<TargetDeviceFamily Name=\"Windows.Desktop\" MinVersion=\"10.0.17763.0\" MaxVersionTested=\"10.0.26226.0\" />";
let changed = super::sync_windows_minimum(source, "10.0.19041.0");
assert!(!changed.contains("10.0.17763.0"));
assert!(changed.contains("MaxVersionTested=\"10.0.26226.0\""));
assert_eq!(
super::sync_windows_minimum(&changed, "10.0.19041.0"),
changed
);
}
#[test]
fn configured_windows_architectures_are_exact_and_strict() {
let selected = super::windows_architectures(&[
"aarch64-pc-windows-msvc".into(),
"i686-pc-windows-msvc".into(),
])
.unwrap();
assert_eq!(selected, [("aarch64", "ARM64"), ("i686", "x86")]);
assert!(super::windows_architectures(&[]).is_err());
assert!(super::windows_architectures(&["x86_64-pc-windows-gnu".into()]).is_err());
}
#[test]
fn private_helpers_are_allowed_and_source_is_unchanged() {
let root =
std::env::temp_dir().join(format!("jffi-private-modules-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(root.join("core/src")).unwrap();
std::fs::write(root.join("jffi.toml"), "").unwrap();
let source = "mod device_info;\nmod offer_summary;\npub struct Core;\n";
let path = root.join("core/src/lib.rs");
std::fs::write(&path, source).unwrap();
let result = super::validate_project_structure_at(&root);
let unchanged = std::fs::read_to_string(&path).unwrap();
std::fs::remove_dir_all(&root).unwrap();
assert!(result.is_ok());
assert_eq!(source, unchanged);
}
#[test]
fn android_config_preserves_other_platforms_comments_and_flags() {
let source = "# Keep this comment\n[env]\nIPHONEOS_DEPLOYMENT_TARGET = { value = \"16.0\", force = false }\n[target.aarch64-linux-android]\nlinker = \"custom-linker\"\nrustflags = [\"-C\", \"debuginfo=2\"]\n[build]\njobs = 2\n";
let merged = super::merge_android_cargo_config(source).unwrap();
for expected in [
"# Keep this comment",
"IPHONEOS_DEPLOYMENT_TARGET",
"custom-linker",
"debuginfo=2",
"jobs = 2",
] {
assert!(merged.contains(expected));
}
assert_eq!(merged, super::merge_android_cargo_config(&merged).unwrap());
}
#[test]
fn android_config_rejects_invalid_config_and_string_flags() {
assert!(super::merge_android_cargo_config("[broken").is_err());
assert!(super::merge_android_cargo_config(
"[target.aarch64-linux-android]\nrustflags = \"custom\"\n"
)
.is_err());
}
use super::{
resolve_android_target_sdk, resolve_version_code, sync_android_gradle_properties,
sync_linux_metainfo_content, sync_windows_csproj_content, update_entitlements_file,
};
use std::fs;
#[test]
fn android_gradle_properties_synced_idempotently() {
let original = "org.gradle.jvmargs=-Xmx2048m\nandroid.useAndroidX=true\n";
let synced = sync_android_gradle_properties(original, true);
assert!(synced.contains("android.enableResourceOptimizations=true"));
let synced_again = sync_android_gradle_properties(&synced, true);
assert_eq!(synced, synced_again);
let disabled = sync_android_gradle_properties(&synced, false);
assert!(disabled.contains("android.enableResourceOptimizations=false"));
}
#[test]
fn explicit_build_number_wins_over_ci_run_number() {
assert_eq!(
resolve_version_code(Some(83), Some(7), Some("91")),
(83, false)
);
}
#[test]
fn ci_run_number_is_a_fallback_when_build_number_is_absent() {
assert_eq!(resolve_version_code(None, Some(7), Some("91")), (91, true));
}
#[test]
fn invalid_ci_run_number_falls_back_to_package_version_code() {
assert_eq!(
resolve_version_code(None, Some(7), Some("not-a-number")),
(7, false)
);
}
#[test]
fn entitlement_sync_is_idempotent_and_normalizes_whitespace() {
let path =
std::env::temp_dir().join(format!("jffi-entitlements-{}.plist", uuid::Uuid::new_v4()));
fs::write(
&path,
"<plist>\n<dict>\n \t\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>old</string>\n\t</array>\n\n\n</dict>\n</plist>\n",
)
.unwrap();
let groups = vec!["group.example.app".to_string()];
update_entitlements_file(&path, &groups).unwrap();
let once = fs::read_to_string(&path).unwrap();
update_entitlements_file(&path, &groups).unwrap();
let twice = fs::read_to_string(&path).unwrap();
assert_eq!(once, twice);
assert!(once.contains("\n\t<key>com.apple.security.application-groups</key>\n"));
assert!(!once.contains("\n\n\n"));
let _ = fs::remove_file(path);
}
#[test]
fn android_target_prefers_explicit_target_then_compile_sdk() {
assert_eq!(resolve_android_target_sdk(Some(34), Some(36)), 34);
assert_eq!(resolve_android_target_sdk(None, Some(36)), 36);
assert_eq!(resolve_android_target_sdk(None, None), 36);
}
#[test]
fn windows_csproj_versions_are_inserted_and_updated_idempotently() {
let original = "<Project>\n <PropertyGroup>\n <Version>1.2.3</Version>\n </PropertyGroup>\n</Project>\n";
let once = sync_windows_csproj_content(original, "2.0.0");
let twice = sync_windows_csproj_content(&once, "2.0.0");
assert_eq!(once, twice);
assert!(once.contains("<Version>2.0.0</Version>"));
assert!(once.contains("<AssemblyVersion>2.0.0.0</AssemblyVersion>"));
assert!(once.contains("<FileVersion>2.0.0.0</FileVersion>"));
}
#[test]
fn linux_metainfo_prepends_version_and_preserves_release_history() {
let original = "<component>\n <releases>\n <release version=\"1.6.3\" date=\"2026-08-27\" />\n </releases>\n</component>\n";
let once = sync_linux_metainfo_content(original, "2.0.0", Some("2026-08-28"));
let twice = sync_linux_metainfo_content(&once, "2.0.0", Some("2026-08-28"));
assert_eq!(once, twice);
assert!(once.contains("<release version=\"2.0.0\" date=\"2026-08-28\" />"));
assert!(once.contains("<release version=\"1.6.3\" date=\"2026-08-27\" />"));
assert!(once.find("2.0.0").unwrap() < once.find("1.6.3").unwrap());
}
#[test]
fn linux_metainfo_repairs_an_existing_undated_release() {
let original = "<component>\n <releases>\n <release version=\"2.0.0\" />\n </releases>\n</component>\n";
let synced = sync_linux_metainfo_content(original, "2.0.0", Some("2026-08-28"));
assert!(synced.contains("<release version=\"2.0.0\" date=\"2026-08-28\" />"));
}
}