lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 396b9ed397d2bd85935f04fc01f2348c931166f3
parent 235a396133ca0c5adde648396c8ed983b0d1185f
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 13:45:00 +0000

ios: regenerate sdk ffi bindings

- Add a governed UniFFI Swift generator routed through the private SDK FFI crate.
- Commit Swift source, C module artifacts, and an exact source-and-output lock.
- Enforce generated binding freshness through the SDK architecture gate.
- Verify repeat generation, Swift typechecking, xtask tests, and locked builds.

Diffstat:
Mcrates/sdk_ffi/Cargo.toml | 6+++++-
Acrates/sdk_ffi/src/bin/bindgen.rs | 3+++
Atools/sdk_xtask_import/src/bindings.rs | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/sdk_xtask_import/src/check.rs | 4+++-
Mtools/sdk_xtask_import/src/main.rs | 21++++++++++++++++++++-
5 files changed, 284 insertions(+), 3 deletions(-)

diff --git a/crates/sdk_ffi/Cargo.toml b/crates/sdk_ffi/Cargo.toml @@ -14,10 +14,14 @@ readme = "README.md" [lib] crate-type = ["lib", "staticlib", "cdylib"] +[[bin]] +name = "radroots-sdk-bindgen" +path = "src/bin/bindgen.rs" + [dependencies] radroots_sdk = { workspace = true, features = ["memory"] } thiserror = { workspace = true } -uniffi = { workspace = true } +uniffi = { workspace = true, features = ["cli"] } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/sdk_ffi/src/bin/bindgen.rs b/crates/sdk_ffi/src/bin/bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main(); +} diff --git a/tools/sdk_xtask_import/src/bindings.rs b/tools/sdk_xtask_import/src/bindings.rs @@ -0,0 +1,253 @@ +use std::{ + collections::BTreeMap, + env, fs, + path::{Path, PathBuf}, + process::Command, +}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::fs::{workspace_root, write_if_changed}; + +const FFI_PACKAGE: &str = "radroots_sdk_ffi"; +const FFI_VERSION: &str = "0.1.0-alpha"; +const BINDGEN_VERSION: &str = "0.29.5"; + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +struct SourceLock { + schema_version: u16, + source_package: String, + source_version: String, + source_sha256: String, + generator: String, + generator_version: String, + language: String, + outputs: BTreeMap<String, String>, +} + +pub fn generate(args: &[String]) -> Result<(), String> { + match args { + [language] if language == "swift" => generate_language("swift"), + [language] if language == "kotlin" => generate_language("kotlin"), + _ => Err("usage: cargo xtask generate bindings <swift|kotlin>".to_owned()), + } +} + +fn generate_language(language: &'static str) -> Result<(), String> { + let root = workspace_root()?; + build_bindgen(&root)?; + let target_dir = env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .ok_or_else(|| "CARGO_TARGET_DIR must be set by extbuild for FFI generation".to_owned())?; + let library = target_dir.join("debug").join(format!( + "{}radroots_sdk_ffi{}", + env::consts::DLL_PREFIX, + env::consts::DLL_SUFFIX + )); + if !library.is_file() { + return Err(format!("missing built FFI library: {}", library.display())); + } + let bindgen = target_dir + .join("debug") + .join(format!("radroots-sdk-bindgen{}", env::consts::EXE_SUFFIX)); + if !bindgen.is_file() { + return Err(format!( + "missing FFI bindgen executable: {}", + bindgen.display() + )); + } + let temporary = tempfile::tempdir() + .map_err(|error| format!("failed to create binding output directory: {error}"))?; + let status = Command::new(&bindgen) + .args(["generate", "--library"]) + .arg(&library) + .args(["--crate", FFI_PACKAGE, "--language", language, "--out-dir"]) + .arg(temporary.path()) + .current_dir(&root) + .status() + .map_err(|error| format!("failed to start {}: {error}", bindgen.display()))?; + if !status.success() { + return Err(format!( + "{language} binding generation failed with {status}" + )); + } + install_outputs(&root, language, temporary.path())?; + println!("generated {language} bindings for {FFI_PACKAGE}"); + Ok(()) +} + +fn build_bindgen(root: &Path) -> Result<(), String> { + let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = Command::new(cargo) + .args([ + "build", + "--locked", + "-p", + FFI_PACKAGE, + "--lib", + "--bin", + "radroots-sdk-bindgen", + ]) + .current_dir(root) + .status() + .map_err(|error| format!("failed to start Cargo for FFI generation: {error}"))?; + if !status.success() { + return Err(format!( + "FFI library and bindgen build failed with {status}" + )); + } + Ok(()) +} + +fn install_outputs(root: &Path, language: &'static str, temporary: &Path) -> Result<(), String> { + let output_dir = root.join("generated").join(language); + fs::create_dir_all(&output_dir) + .map_err(|error| format!("failed to create {}: {error}", output_dir.display()))?; + let mut generated = fs::read_dir(temporary) + .map_err(|error| format!("failed to read {}: {error}", temporary.display()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|error| format!("failed to enumerate {}: {error}", temporary.display()))?; + generated.sort_by_key(std::fs::DirEntry::file_name); + let mut outputs = BTreeMap::new(); + for entry in generated { + let path = entry.path(); + if !path.is_file() { + return Err(format!( + "unexpected generated binding entry: {}", + path.display() + )); + } + let name = entry + .file_name() + .into_string() + .map_err(|_| format!("non-UTF-8 generated binding path: {}", path.display()))?; + let contents = fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let destination = output_dir.join(&name); + let text = String::from_utf8(contents) + .map_err(|error| format!("generated text binding is not UTF-8: {error}"))?; + let text = normalize_generated_text(&text); + outputs.insert( + name.clone(), + format!("{:x}", Sha256::digest(text.as_bytes())), + ); + write_if_changed(&destination, &text)?; + } + if outputs.is_empty() { + return Err(format!("{language} binding generator emitted no files")); + } + let lock = SourceLock { + schema_version: 1, + source_package: FFI_PACKAGE.to_owned(), + source_version: FFI_VERSION.to_owned(), + source_sha256: ffi_source_sha256(root)?, + generator: "uniffi".to_owned(), + generator_version: BINDGEN_VERSION.to_owned(), + language: language.to_owned(), + outputs, + }; + let mut rendered = serde_json::to_string_pretty(&lock) + .map_err(|error| format!("failed to render {language} source lock: {error}"))?; + rendered.push('\n'); + write_if_changed(&output_dir.join("source.lock"), &rendered).map(|_| ()) +} + +fn normalize_generated_text(contents: &str) -> String { + let mut normalized = contents + .lines() + .map(str::trim_end) + .collect::<Vec<_>>() + .join("\n"); + while normalized.ends_with('\n') { + normalized.pop(); + } + normalized.push('\n'); + normalized +} + +pub fn check(root: &Path) -> Result<(), String> { + check_language( + root, + "swift", + &[ + "radroots_sdk.swift", + "radroots_sdkFFI.h", + "radroots_sdkFFI.modulemap", + ], + ) +} + +fn check_language(root: &Path, language: &'static str, expected: &[&str]) -> Result<(), String> { + let output_dir = root.join("generated").join(language); + let lock_path = output_dir.join("source.lock"); + let raw = fs::read_to_string(&lock_path) + .map_err(|error| format!("failed to read {}: {error}", lock_path.display()))?; + let actual: SourceLock = serde_json::from_str(&raw) + .map_err(|error| format!("failed to parse {}: {error}", lock_path.display()))?; + let mut outputs = BTreeMap::new(); + for name in expected { + let path = output_dir.join(name); + let contents = fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + outputs.insert( + (*name).to_owned(), + format!("{:x}", Sha256::digest(contents)), + ); + } + let expected_lock = SourceLock { + schema_version: 1, + source_package: FFI_PACKAGE.to_owned(), + source_version: FFI_VERSION.to_owned(), + source_sha256: ffi_source_sha256(root)?, + generator: "uniffi".to_owned(), + generator_version: BINDGEN_VERSION.to_owned(), + language: language.to_owned(), + outputs, + }; + if actual != expected_lock { + return Err(format!( + "stale generated {language} bindings: {}", + lock_path.display() + )); + } + Ok(()) +} + +fn ffi_source_sha256(root: &Path) -> Result<String, String> { + let crate_dir = root.join("crates/ffi"); + let mut paths = vec![crate_dir.join("Cargo.toml"), crate_dir.join("README.md")]; + collect_files(&crate_dir.join("src"), &mut paths)?; + paths.sort(); + let mut hasher = Sha256::new(); + for path in paths { + let relative = path + .strip_prefix(root) + .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; + hasher.update(relative.to_string_lossy().as_bytes()); + hasher.update([0]); + hasher.update( + fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?, + ); + hasher.update([0]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn collect_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), String> { + let mut entries = fs::read_dir(dir) + .map_err(|error| format!("failed to read {}: {error}", dir.display()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_files(&path, paths)?; + } else if path.is_file() { + paths.push(path); + } + } + Ok(()) +} diff --git a/tools/sdk_xtask_import/src/check.rs b/tools/sdk_xtask_import/src/check.rs @@ -154,6 +154,7 @@ pub fn check() -> Result<(), String> { cli_host::check_cli_host()?; check_package_build_artifacts(&root)?; check_npm_pack_payloads(&root)?; + crate::bindings::check(&root)?; Ok(()) } @@ -166,7 +167,8 @@ pub fn architecture_ci(root: &Path) -> Result<(), String> { check_forbidden_packages(root)?; check_binding_crate_sources(root)?; check_package_source_metadata(root)?; - check_generated_outputs(root) + check_generated_outputs(root)?; + crate::bindings::check(root) } fn check_radroots_facade_conformance(root: &Path) -> Result<(), String> { diff --git a/tools/sdk_xtask_import/src/main.rs b/tools/sdk_xtask_import/src/main.rs @@ -1,4 +1,5 @@ mod architecture; +mod bindings; mod check; mod cli_host; mod contracts; @@ -23,6 +24,7 @@ enum CommandAction<'a> { CheckApiBoundaries, CheckDependencyBoundaries, GenerateAll, + GenerateBindings(&'a [String]), GenerateTs, GenerateWasm(&'a [String]), GeneratePackageMetadata, @@ -54,6 +56,7 @@ fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> { architecture::validate_dependency_boundaries(&fs::workspace_root()?) } CommandAction::GenerateAll => generate::generate_all(), + CommandAction::GenerateBindings(rest) => bindings::generate(rest), CommandAction::GenerateTs => generate::generate_ts(), CommandAction::GenerateWasm(rest) => wasm::generate(rest), CommandAction::GeneratePackageMetadata => generate::generate_package_metadata(), @@ -78,6 +81,9 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { [command, target, rest @ ..] if command == "generate" && target == "wasm" => { Ok(CommandAction::GenerateWasm(rest)) } + [command, target, rest @ ..] if command == "generate" && target == "bindings" => { + Ok(CommandAction::GenerateBindings(rest)) + } [command, target] if command == "generate" && target == "package-metadata" => { Ok(CommandAction::GeneratePackageMetadata) } @@ -90,7 +96,7 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { } fn usage() -> String { - "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run" + "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate bindings <swift|kotlin> | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run" .to_owned() } @@ -162,6 +168,19 @@ mod tests { } #[test] + fn accepts_generate_bindings() { + let args = [ + "generate".to_owned(), + "bindings".to_owned(), + "swift".to_owned(), + ]; + assert!(matches!( + command_action(&args).expect("action"), + CommandAction::GenerateBindings(rest) if rest == ["swift"] + )); + } + + #[test] fn accepts_generate_package_metadata() { let args = ["generate".to_owned(), "package-metadata".to_owned()]; assert!(matches!(