diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 908d9fdd..1cf1305a 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -380,7 +380,7 @@ jobs: matrix: adapter: [circom, halo2, noir, gnark] runs-on: macos-latest - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo_full_name + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name steps: - uses: actions/checkout@v6 @@ -390,6 +390,14 @@ jobs: with: go-version: "1.24" + # The barretenberg prebuilt is Zig-built instead of NDK + # link libc++ with Zig so it resolves. + - name: Setup Zig (for noir/barretenberg libc++ ABI) + if: matrix.adapter == 'noir' + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + # Build mopro example project for iOS + Android first, and create RN frameworks - name: Prepare template project uses: ./.github/actions/mopro-init-project @@ -488,7 +496,7 @@ jobs: matrix: adapter: [halo2] # web build only for halo2 runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo_full_name + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index fdf1f848..03026471 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ mopro_flutter_bindings MoproAndroidBindings MoproiOSBindings MoproReactNativeBindings -build/ \ No newline at end of file +build/ +!cli/src/build/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index da7d1272..aeddbb27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,7 +1723,7 @@ dependencies = [ [[package]] name = "mopro-cli" -version = "0.3.7-alpha.0" +version = "0.3.7" dependencies = [ "anyhow", "clap", @@ -1742,7 +1742,7 @@ dependencies = [ [[package]] name = "mopro-ffi" -version = "0.3.7-alpha.0" +version = "0.3.7" dependencies = [ "anyhow", "camino", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ca4df637..076820ac 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mopro-cli" -version = "0.3.7-alpha.0" +version = "0.3.7" edition = "2021" description = "A CLI tool for creating a mobile native app with Mopro FFI" license = "MIT OR Apache-2.0" @@ -14,7 +14,7 @@ name = "mopro" path = "src/main.rs" [dependencies] -mopro-ffi = { path = "../mopro-ffi", version = "=0.3.7-alpha.0", features = [ +mopro-ffi = { path = "../mopro-ffi", version = "=0.3.7", features = [ "build", "uniffi", ], default-features = false } diff --git a/cli/src/build.rs b/cli/src/build.rs index cc661814..f36d62d0 100644 --- a/cli/src/build.rs +++ b/cli/src/build.rs @@ -24,6 +24,7 @@ use target_resolver::TargetSelection; mod android_noir; mod mode_resolver; +mod react_native_noir; mod target_resolver; pub fn build_project( @@ -192,7 +193,65 @@ pub fn build_project( Platform::ReactNative => { let arch_strings = selection.architecture_strings(); let arch_refs: Vec<&String> = arch_strings.iter().collect(); - build_from_str_arch::(mode, ¤t_dir, arch_refs, ())?; + + let ios_arch_strings: Vec = arch_strings + .iter() + .filter(|a| a.contains("ios")) + .cloned() + .collect(); + let android_arch_strings: Vec = arch_strings + .iter() + .filter(|a| a.contains("android")) + .cloned() + .collect(); + + let params = + if android_arch_strings.is_empty() || !config.adapter_contains(Adapter::Noir) { + AndroidBindingsParams::default() + } else { + let android_arch_refs: Vec<&String> = android_arch_strings.iter().collect(); + android_noir::android_bindings_params(¤t_dir, &android_arch_refs)? + }; + + if params.arch_overrides.is_empty() { + // Noir doesn't need any special handling here: mopro-ffi builds + // iOS and Android independently, so both run in one pass. + build_from_str_arch::(mode, ¤t_dir, arch_refs, ())?; + } else { + // Android needs the Zig-linked Noir build; iOS (if requested) + // still goes through the normal path since Noir only affects + // Android linking. + let bindings_dir = + mopro_ffi::app_config::react_native::setup_bindings_dir(¤t_dir)?; + mopro_ffi::app_config::react_native::generate_jsi_bindings(&bindings_dir)?; + if !ios_arch_strings.is_empty() { + let ios_target_string = ios_arch_strings.join(","); + mopro_ffi::app_config::react_native::build_for_arch( + "ios", + mode, + &ios_target_string, + &bindings_dir, + )?; + } + react_native_noir::build( + ¤t_dir, + &bindings_dir, + &android_arch_strings, + mode, + ¶ms, + )?; + mopro_ffi::app_config::react_native::patch_android_cmake_lists_uniffi_bindgen_resolve(&bindings_dir)?; + mopro_ffi::app_config::react_native::set_xcframework_package_files( + &bindings_dir, + )?; + } + + if !android_arch_strings.is_empty() { + mopro_ffi::app_config::react_native::patch_gradle_properties_architectures( + ¤t_dir, + &android_arch_strings, + )?; + } } Platform::Web => { build_from_str_arch::( diff --git a/cli/src/build/android_noir.rs b/cli/src/build/android_noir.rs index efc36da6..91e8a253 100644 --- a/cli/src/build/android_noir.rs +++ b/cli/src/build/android_noir.rs @@ -7,6 +7,13 @@ //! glibc prebuilt Android can't `dlopen`. We pre-fetch the right prebuilt, point //! `BB_LIB_DIR` at it, and link with Zig so the prebuilt's libc++ (`std::__1`) //! resolves (the NDK's `std::__ndk1` is ABI-incompatible). +//! +//! Lives in the CLI (not `mopro-ffi`) and is only invoked when the project's +//! `Config.toml` declares the Noir adapter: computing these params touches +//! `Cargo.lock` (see `barretenberg_rs_version`), and forcing that on every +//! Android/React Native build — Noir or not — would force a full dependency +//! resolution (including unrelated optional deps like `flutter_rust_bridge`) +//! on projects that never asked for it. use anyhow::Context; use mopro_ffi::app_config::android::{AndroidBindingsParams, ArchBuildConfig}; @@ -41,7 +48,7 @@ pub fn android_bindings_params( for (triple, bb_arch) in &bb_targets { let bb_lib_dir = download_barretenberg_android_lib(bb_arch, &version, &build_dir)?; - let linker_flag = zig_linker_flag(triple, &build_dir)?; + let linker = zig_linker_path(triple, &build_dir)?; params.arch_overrides.insert( triple.clone(), ArchBuildConfig { @@ -49,7 +56,7 @@ pub fn android_bindings_params( "BB_LIB_DIR".to_string(), bb_lib_dir.to_string_lossy().into_owned(), )], - extra_rustflags: vec![linker_flag], + linker: Some(linker), }, ); } @@ -145,10 +152,10 @@ fn ensure_zig_available() -> anyhow::Result<()> { Ok(()) } -/// Write a Zig-cc linker wrapper for `triple` and return the `-Clinker=` -/// flag. Zig provides the barretenberg prebuilt's `std::__1` libc++ symbols; only -/// the linker is overridden, so the NDK still compiles everything else. -fn zig_linker_flag(triple: &str, build_dir: &Path) -> anyhow::Result { +/// Write a Zig-cc linker wrapper for `triple` and return its path. Zig provides +/// the barretenberg prebuilt's `std::__1` libc++ symbols; only the linker is +/// overridden, so the NDK still compiles everything else. +fn zig_linker_path(triple: &str, build_dir: &Path) -> anyhow::Result { let ndk = android_ndk_home()?; let host = ndk_host_tag(); let sysroot = ndk @@ -204,7 +211,7 @@ fn zig_linker_flag(triple: &str, build_dir: &Path) -> anyhow::Result { fs::set_permissions(&wrapper, perms)?; } - Ok(format!("-Clinker={}", wrapper.display())) + Ok(wrapper.to_string_lossy().into_owned()) } /// Locate the Android NDK from the usual environment variables. diff --git a/cli/src/build/react_native_noir.rs b/cli/src/build/react_native_noir.rs new file mode 100644 index 00000000..ef465287 --- /dev/null +++ b/cli/src/build/react_native_noir.rs @@ -0,0 +1,219 @@ +//! Noir/barretenberg-specific React Native Android build. +//! +//! Mirrors `android_noir`'s handling for the plain Android builder (see +//! `super::android_noir`), but `uniffi-bindgen-react-native` needs a different build +//! strategy than a single `cargo ndk` invocation: +//! +//! - It can't take per-target env in one multi-target invocation, and each +//! arch may need a different `BB_LIB_DIR` (barretenberg's Android prebuilt +//! is arch-specific), so each arch is built in its own invocation. +//! - Its `--and-generate` reads UniFFI metadata straight from the compiled +//! Android `.so`, and the Zig-linked build (needed to link barretenberg's +//! prebuilt) has stripped the `.symtab` that needs. So metadata is instead +//! extracted from a separate NDK-linked (non-Zig) scratch build, mirroring +//! the plain Android builder's `relink_with_ndk_for_bindgen`, via +//! `uniffi-bindgen-react-native generate all `. + +use anyhow::Context; +use mopro_ffi::app_config::android::AndroidBindingsParams; +use mopro_ffi::app_config::constants::Mode; +use mopro_ffi::app_config::project_name_from_toml; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Build every requested Android arch for React Native with `params`' overrides +/// applied, then generate JSI/turbo-module bindings from a separate NDK-linked +/// scratch build. Assumes `bindings_dir` is already set up (template copied, +/// `ubrn.config.yaml` pointed at `project_dir`, `generate jsi turbo-module` run). +pub fn build( + project_dir: &Path, + bindings_dir: &Path, + android_arch_strings: &[String], + mode: Mode, + params: &AndroidBindingsParams, +) -> anyhow::Result<()> { + patch_ubrn_config_for_noir(bindings_dir, params)?; + + for arch in android_arch_strings { + let env = params + .arch_overrides + .get(arch) + .map(|config| config.extra_env.clone()) + .unwrap_or_default(); + build_android_once(mode, arch, bindings_dir, &env)?; + } + + let bindgen_arch = android_arch_strings + .first() + .context("No Android architectures provided for binding generation")?; + let bindgen_env = params + .arch_overrides + .get(bindgen_arch) + .map(|config| config.extra_env.clone()) + .unwrap_or_default(); + let bindgen_lib = build_ndk_linked_lib_for_bindgen( + project_dir, + bindgen_arch, + mode, + params.min_sdk_version, + &bindgen_env, + )?; + + let status = Command::new("uniffi-bindgen-react-native") + .args([ + "generate", + "all", + &bindgen_lib.to_string_lossy(), + "--flavor", + "jsi", + ]) + .current_dir(bindings_dir) + .status() + .expect("failed to generate react native bindings"); + if !status.success() { + anyhow::bail!( + "Failed to generate react native bindings from {}", + bindgen_lib.display() + ); + } + Ok(()) +} + +fn build_android_once( + mode: Mode, + target_string: &str, + bindings_dir: &Path, + env: &[(String, String)], +) -> anyhow::Result<()> { + let mut args = vec![ + "build".to_string(), + "android".to_string(), + "--targets".to_string(), + target_string.to_string(), + ]; + if mode == Mode::Release { + args.push("--release".to_string()); + } + + let mut cmd = Command::new("uniffi-bindgen-react-native"); + cmd.args(&args).current_dir(bindings_dir); + for (key, value) in env { + cmd.env(key, value); + } + let status = cmd.status().expect("failed to build react native bindings"); + if !status.success() { + anyhow::bail!("Failed to build react native bindings for {target_string}"); + } + Ok(()) +} + +/// Build `arch` with the plain NDK linker (no Zig override) purely to extract +/// UniFFI metadata via `uniffi-bindgen-react-native generate all `. +/// This lib is never shipped or run; a separate target dir keeps the +/// already-built (Zig-linked) jniLibs untouched. +fn build_ndk_linked_lib_for_bindgen( + project_dir: &Path, + arch: &str, + mode: Mode, + min_sdk_version: Option, + extra_env: &[(String, String)], +) -> anyhow::Result { + let lib_name = format!( + "lib{}.so", + project_name_from_toml(project_dir) + .context("Failed to get project name from Cargo.toml")? + ); + let bindgen_target = project_dir.join("build").join("rn-bindgen"); + + let mut cmd = Command::new("cargo"); + cmd.arg("ndk").arg("-t").arg(arch); + if let Some(min_sdk) = min_sdk_version { + cmd.arg("--platform").arg(min_sdk.to_string()); + } + cmd.arg("build").arg("--link-libcxx-shared").arg("--lib"); + if mode == Mode::Release { + cmd.arg("--release"); + } + for (key, value) in extra_env { + cmd.env(key, value); + } + cmd.env("CARGO_BUILD_TARGET_DIR", &bindgen_target) + .env("CARGO_BUILD_TARGET", arch) + .env("CARGO_NDK_OUTPUT_PATH", bindgen_target.join("jniLibs")); + + let status = cmd + .status() + .expect("failed to build NDK-linked lib for bindgen"); + if !status.success() { + anyhow::bail!("cargo ndk build (bindgen lib) failed for {arch}"); + } + + let profile_dir = if mode == Mode::Release { + "release" + } else { + "debug" + }; + let out_lib_path = bindgen_target.join(arch).join(profile_dir).join(&lib_name); + if !out_lib_path.exists() { + anyhow::bail!( + "NDK bindgen lib missing at {} (needed for uniffi metadata)", + out_lib_path.display() + ); + } + Ok(out_lib_path) +} + +/// Bump `ubrn.config.yaml`'s Android `apiLevel` and add `cargoExtras` so +/// `uniffi-bindgen-react-native`'s own `cargo ndk` invocation picks up the +/// per-arch linker override. This can't go through `RUSTFLAGS`/ +/// `CARGO_TARGET__RUSTFLAGS`: ubrn already sets the latter itself (for +/// a 16KB-page-size flag) on every target build, which would clobber ours. +fn patch_ubrn_config_for_noir( + bindings_dir: &Path, + params: &AndroidBindingsParams, +) -> anyhow::Result<()> { + let config_path = bindings_dir.join("ubrn.config.yaml"); + let mut contents = + fs::read_to_string(&config_path).context("Failed to read ubrn.config.yaml")?; + + if let Some(min_sdk) = params.min_sdk_version { + if let Some(pos) = contents.find("apiLevel: ") { + let line_end = contents[pos..] + .find('\n') + .map(|i| pos + i) + .unwrap_or(contents.len()); + let current: u32 = contents[pos + "apiLevel: ".len()..line_end] + .trim() + .parse() + .unwrap_or(0); + if min_sdk > current { + contents.replace_range(pos..line_end, &format!("apiLevel: {min_sdk}")); + } + } + } + + let cargo_extras: Vec = params + .arch_overrides + .iter() + .filter_map(|(triple, config)| { + config + .linker + .as_ref() + .map(|linker| format!("target.{triple}.linker='{linker}'")) + }) + .flat_map(|entry| ["--config".to_string(), entry]) + .collect(); + + if !cargo_extras.is_empty() { + let yaml_list = cargo_extras + .iter() + .map(|entry| format!(" - \"{}\"", entry.replace('"', "\\\""))) + .collect::>() + .join("\n"); + contents.push_str(&format!("\n cargoExtras:\n{yaml_list}\n")); + } + + fs::write(&config_path, contents).context("Failed to write ubrn.config.yaml")?; + Ok(()) +} diff --git a/cli/src/create/react_native.rs b/cli/src/create/react_native.rs index c462d0ba..d53d112a 100644 --- a/cli/src/create/react_native.rs +++ b/cli/src/create/react_native.rs @@ -1,17 +1,19 @@ use super::Create; +use crate::config::read_config; use crate::constants::Platform; use crate::create::utils::{check_bindings, copy_dir, copy_keys, download_and_extract_template}; use crate::print::print_footer_message; use crate::style::print_green_bold; -use anyhow::{Error, Result}; -use mopro_ffi::app_config::constants::REACT_NATIVE_BINDINGS_DIR; +use anyhow::{Context, Error, Result}; +use mopro_ffi::app_config::constants::{REACT_NATIVE_APP_DIR, REACT_NATIVE_BINDINGS_DIR}; +use mopro_ffi::app_config::react_native::patch_gradle_properties_architectures; use std::{fs, path::PathBuf}; pub struct ReactNative; impl Create for ReactNative { - const NAME: &'static str = "react-native"; + const NAME: &'static str = REACT_NATIVE_APP_DIR; fn create(project_dir: PathBuf) -> Result<()> { let react_native_bindings_dir = check_bindings(&project_dir, Platform::ReactNative)?; @@ -37,6 +39,7 @@ impl Create for ReactNative { react_native_bindings_dir.as_ref().unwrap(), &mopro_module_dir, )?; + remove_stale_web_entrypoint(&mopro_module_dir)?; let assets_dir = target_dir.join("assets/keys"); fs::remove_dir_all(&assets_dir)?; @@ -44,6 +47,31 @@ impl Create for ReactNative { copy_keys(assets_dir)?; + // The downloaded scaffold's `android/gradle.properties` defaults + // `reactNativeArchitectures` to all four ABIs, but the bindings dir + // we just copied in only has `.so`s for the architectures this + // project was built for. Narrow it now so a fresh `create` doesn't + // reintroduce the "missing .so, no known rule to make it" ninja + // failure for ABIs that were never built. + let config_path = project_dir.join("Config.toml"); + match read_config(&config_path) { + Ok(config) => { + if let Some(react_native_archs) = config.react_native { + let android_archs: Vec = react_native_archs + .into_iter() + .filter(|a| a.contains("android")) + .collect(); + if !android_archs.is_empty() { + patch_gradle_properties_architectures(&project_dir, &android_archs)?; + } + } + } + Err(e) + if e.downcast_ref::() + .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound) => {} + Err(e) => return Err(e).with_context(|| format!("Failed to read {:?}", config_path)), + } + Self::print_message(); Ok(()) } @@ -59,3 +87,19 @@ impl Create for ReactNative { print_footer_message(); } } + +/// The downloaded `zkmopro/react-native-app` scaffold ships a static +/// `src/index.web.ts` (for optional web/wasm support) that imports from +/// `./generated/wasm-bindgen/index.js` and `index_bg.wasm`. `mopro build` never +/// generates that `generated/wasm-bindgen` directory — React Native builds only +/// target iOS/Android — so the file is always a dangling reference. Since +/// `copy_dir` only overwrites files present in the built bindings dir, it can't +/// remove this pre-existing one; left in place, it breaks `npm install`'s +/// `prepare: bob build` step (`tsc` fails to resolve the missing module). +fn remove_stale_web_entrypoint(mopro_module_dir: &std::path::Path) -> Result<()> { + let index_web_ts = mopro_module_dir.join("src").join("index.web.ts"); + if index_web_ts.exists() { + fs::remove_file(&index_web_ts)?; + } + Ok(()) +} diff --git a/cli/src/init/write_toml.rs b/cli/src/init/write_toml.rs index 32371547..1a32d866 100644 --- a/cli/src/init/write_toml.rs +++ b/cli/src/init/write_toml.rs @@ -16,7 +16,7 @@ flutter = ["mopro-ffi/flutter"] wasm = ["mopro-ffi/wasm"] [dependencies] -mopro-ffi = { version = "=0.3.7-alpha.0" } +mopro-ffi = { version = "=0.3.7" } thiserror = "2.0.12" anyhow = "1.0.99" @@ -32,7 +32,7 @@ anyhow = "1.0.99" # GNARK_BUILD_DEPENDENCIES [dev-dependencies] -mopro-ffi = { version = "=0.3.7-alpha.0", features = ["uniffi-tests"] } +mopro-ffi = { version = "=0.3.7", features = ["uniffi-tests"] } # CIRCOM_DEV_DEPENDENCIES # HALO2_DEV_DEPENDENCIES @@ -40,7 +40,7 @@ mopro-ffi = { version = "=0.3.7-alpha.0", features = ["uniffi-tests"] } # GNARK_DEV_DEPENDENCIES [target.wasm32-unknown-unknown.dependencies] -mopro-ffi = { version = "=0.3.7-alpha.0", features = ["wasm"] } +mopro-ffi = { version = "=0.3.7", features = ["wasm"] } wasm-bindgen = "0.2" serde-wasm-bindgen = "0.6" diff --git a/cli/src/print.rs b/cli/src/print.rs index c7d648d6..2e7e79cd 100644 --- a/cli/src/print.rs +++ b/cli/src/print.rs @@ -18,7 +18,7 @@ pub(crate) fn print_init_instructions(project_name: String) { println!("To get started, follow these steps:"); println!(); print_green_bold("1. Navigate to your project directory:".to_string()); - print_bold(format!(" cd {}", project_name)); + print_bold(format!(" cd {project_name}")); println!(); print_green_bold("2. Run the following commands to build and run the project:".to_string()); print_bold(" mopro build".to_string()); diff --git a/mopro-ffi/Cargo.toml b/mopro-ffi/Cargo.toml index 9617ec07..aa5f499a 100644 --- a/mopro-ffi/Cargo.toml +++ b/mopro-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mopro-ffi" -version = "0.3.7-alpha.0" +version = "0.3.7" edition = "2021" description = "Mopro is a toolkit for ZK app development on mobile. Mopro makes client-side proving on mobile simple." license = "MIT OR Apache-2.0" diff --git a/mopro-ffi/src/app_config/android.rs b/mopro-ffi/src/app_config/android.rs index 2eb91dc9..04bb8acc 100644 --- a/mopro-ffi/src/app_config/android.rs +++ b/mopro-ffi/src/app_config/android.rs @@ -31,8 +31,13 @@ pub fn build() { pub struct ArchBuildConfig { /// Extra environment variables for this arch's `cargo ndk` invocation. pub extra_env: Vec<(String, String)>, - /// Flags appended to `RUSTFLAGS` for this arch (e.g. `-Clinker=`). - pub extra_rustflags: Vec, + /// Path to a linker (or linker wrapper script) to use for this arch, if + /// overridden. Kept separate from raw `RUSTFLAGS` so callers that can't use + /// the `RUSTFLAGS`/`CARGO_TARGET__RUSTFLAGS` env channel (e.g. + /// uniffi-bindgen-react-native, which sets its own target-specific + /// `CARGO_TARGET__RUSTFLAGS` and would clobber anything set there) + /// can instead pass it as a `--config target..linker=` arg. + pub linker: Option, } /// Generic knobs that let a caller tailor the Android build without `mopro-ffi` @@ -69,9 +74,9 @@ impl PlatformBuilder for AndroidPlatform { let out_android_kt_file_name = ANDROID_KT_FILE; // Names for the generated files by uniffi - let lib_name = format!("lib{}.so", uniffi_style_identifier); + let lib_name = format!("lib{uniffi_style_identifier}.so"); let gen_android_module_name = &uniffi_style_identifier; - let gen_android_kt_file_name = format!("{}.kt", uniffi_style_identifier); + let gen_android_kt_file_name = format!("{uniffi_style_identifier}.kt"); #[cfg(feature = "witnesscalc")] let _ = std::env::var("ANDROID_NDK").context("ANDROID_NDK is not set")?; @@ -183,7 +188,7 @@ fn build_for_arch( Ok(out_lib_path) } -/// Apply caller-supplied env vars and `RUSTFLAGS` additions to a `cargo ndk` command. +/// Apply caller-supplied env vars and a linker override to a `cargo ndk` command. fn apply_arch_config(build_cmd: &mut Command, config: Option<&ArchBuildConfig>) { let Some(config) = config else { return; @@ -191,8 +196,8 @@ fn apply_arch_config(build_cmd: &mut Command, config: Option<&ArchBuildConfig>) for (key, value) in &config.extra_env { build_cmd.env(key, value); } - if !config.extra_rustflags.is_empty() { - let extra = config.extra_rustflags.join(" "); + if let Some(linker) = &config.linker { + let extra = format!("-Clinker={linker}"); // Preserve any RUSTFLAGS the caller already set in the environment. let rustflags = match std::env::var("RUSTFLAGS") { Ok(existing) if !existing.trim().is_empty() => format!("{existing} {extra}"), @@ -206,8 +211,7 @@ fn apply_arch_config(build_cmd: &mut Command, config: Option<&ArchBuildConfig>) /// custom linker (e.g. Zig) can drop the `.symtab` uniffi-bindgen reads, which the /// NDK linker keeps. This lib may not run, but bindgen never runs it; a separate /// target dir keeps the shipped jniLibs untouched. Caller env (e.g. an overriding -/// lib dir) is applied, but `extra_rustflags` is not — this build must use the NDK -/// linker. +/// lib dir) is applied, but `linker` is not — this build must use the NDK linker. fn build_bindgen_lib( arch: AndroidArch, lib_name: &str, diff --git a/mopro-ffi/src/app_config/constants.rs b/mopro-ffi/src/app_config/constants.rs index 17df2083..200a2a32 100644 --- a/mopro-ffi/src/app_config/constants.rs +++ b/mopro-ffi/src/app_config/constants.rs @@ -27,6 +27,7 @@ pub const ARCH_ARM_64_V8: &str = "arm64-v8a"; pub const FLUTTER_BINDINGS_DIR: &str = "mopro_flutter_bindings"; pub const REACT_NATIVE_BINDINGS_DIR: &str = "MoproReactNativeBindings"; +pub const REACT_NATIVE_APP_DIR: &str = "react-native"; #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Mode { diff --git a/mopro-ffi/src/app_config/react_native.rs b/mopro-ffi/src/app_config/react_native.rs index d5f0c487..2ff51f14 100644 --- a/mopro-ffi/src/app_config/react_native.rs +++ b/mopro-ffi/src/app_config/react_native.rs @@ -4,7 +4,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; use crate::app_config::constants::{ - Arch, Mode, ReactNativeArch, ReactNativePlatform, REACT_NATIVE_BINDINGS_DIR, + Arch, Mode, ReactNativeArch, ReactNativePlatform, ARCH_ARM_64_V8, ARCH_ARM_V7_ABI, ARCH_I686, + ARCH_X86_64, REACT_NATIVE_APP_DIR, REACT_NATIVE_BINDINGS_DIR, }; use super::PlatformBuilder; @@ -25,43 +26,14 @@ impl PlatformBuilder for ReactNativePlatform { target_archs: Vec, _params: Self::Params, ) -> anyhow::Result { - install_uniffi_bindgen_react_native()?; - - fs::create_dir_all(project_dir.join(REACT_NATIVE_BINDINGS_DIR)) - .expect("failed to create bindings directory"); - - // Copy the react_native template to the project directory - // Get the path to the template directory relative to this source file - let template_dir = - Path::new(env!("CARGO_MANIFEST_DIR")).join("src/app_config/template/react_native"); - let mut copy_options = fs_extra::dir::CopyOptions::new(); - copy_options.overwrite = true; - copy_options.content_only = true; - fs_extra::dir::copy( - &template_dir, - project_dir.join(REACT_NATIVE_BINDINGS_DIR), - ©_options, - ) - .with_context(|| format!("Failed to copy react_native folder from {:?}", template_dir))?; - - // Replace the <%PATH_TO_PROJECT%> in the ubrn.config.yaml template with the project directory - let target_file = project_dir - .join(REACT_NATIVE_BINDINGS_DIR) - .join("ubrn.config.yaml"); - - let contents = fs::read_to_string(&target_file) - .with_context(|| format!("Failed to read ubrn.config.yaml from {:?}", target_file))? - .replace("<%PATH_TO_PROJECT%>", &project_dir.to_string_lossy()); - - fs::write(&target_file, contents) - .with_context(|| format!("Failed to write ubrn.config.yaml to {:?}", target_file))?; - - generate_react_native_bindings(project_dir, target_archs, mode)?; + let bindings_dir = setup_bindings_dir(project_dir)?; + generate_react_native_bindings(project_dir, target_archs, mode, &bindings_dir)?; Ok(PathBuf::from(REACT_NATIVE_BINDINGS_DIR)) } } -fn install_uniffi_bindgen_react_native() -> anyhow::Result<()> { +/// Install `uniffi-bindgen-react-native` if it isn't already on `PATH`. +pub fn install_uniffi_bindgen_react_native() -> anyhow::Result<()> { let output = Command::new("uniffi-bindgen-react-native").output(); match output { Ok(_) => { @@ -76,7 +48,7 @@ fn install_uniffi_bindgen_react_native() -> anyhow::Result<()> { let status = Command::new("git") .args([ "clone", - "https://github.com/jhugman/uniffi-bindgen-react-native.git", + "https://github.com/zkmopro/uniffi-bindgen-react-native.git", ]) .current_dir(current_path.clone()) .status() @@ -112,20 +84,58 @@ fn install_uniffi_bindgen_react_native() -> anyhow::Result<()> { Ok(()) } -fn generate_react_native_bindings( - project_dir: &Path, - target_archs: Vec, - mode: Mode, -) -> anyhow::Result<()> { +/// Install `uniffi-bindgen-react-native`, and set up the `MoproReactNativeBindings` +/// directory (copy the template, point `ubrn.config.yaml` at `project_dir`). +/// Returns the bindings directory. Idempotent: safe to call before every build. +pub fn setup_bindings_dir(project_dir: &Path) -> anyhow::Result { + install_uniffi_bindgen_react_native()?; + let bindings_dir = project_dir.join(REACT_NATIVE_BINDINGS_DIR); + fs::create_dir_all(&bindings_dir).expect("failed to create bindings directory"); + + // Copy the react_native template to the project directory + // Get the path to the template directory relative to this source file + let template_dir = + Path::new(env!("CARGO_MANIFEST_DIR")).join("src/app_config/template/react_native"); + let mut copy_options = fs_extra::dir::CopyOptions::new(); + copy_options.overwrite = true; + copy_options.content_only = true; + fs_extra::dir::copy(&template_dir, &bindings_dir, ©_options) + .with_context(|| format!("Failed to copy react_native folder from {:?}", template_dir))?; + + // Replace the <%PATH_TO_PROJECT%> in the ubrn.config.yaml template with the project directory + let target_file = bindings_dir.join("ubrn.config.yaml"); + + let contents = fs::read_to_string(&target_file) + .with_context(|| format!("Failed to read ubrn.config.yaml from {:?}", target_file))? + .replace("<%PATH_TO_PROJECT%>", &project_dir.to_string_lossy()); + + fs::write(&target_file, contents) + .with_context(|| format!("Failed to write ubrn.config.yaml to {:?}", target_file))?; + + Ok(bindings_dir) +} + +/// Run `uniffi-bindgen-react-native generate jsi turbo-module` in `bindings_dir`. +pub fn generate_jsi_bindings(bindings_dir: &Path) -> anyhow::Result<()> { let status = Command::new("uniffi-bindgen-react-native") .args(["generate", "jsi", "turbo-module"]) - .current_dir(bindings_dir.clone()) + .current_dir(bindings_dir) .status() .expect("failed to generate react native bindings"); if !status.success() { return Err(anyhow::anyhow!("Failed to generate react native bindings")); } + Ok(()) +} + +fn generate_react_native_bindings( + _project_dir: &Path, + target_archs: Vec, + mode: Mode, + bindings_dir: &Path, +) -> anyhow::Result<()> { + generate_jsi_bindings(bindings_dir)?; let ios_target_string = target_archs .iter() @@ -141,14 +151,20 @@ fn generate_react_native_bindings( .join(","); if !ios_target_string.is_empty() { - let platform = "ios"; - build_for_arch(platform, mode, &ios_target_string, &bindings_dir)?; - } else if !android_target_string.is_empty() { - let platform = "android"; - build_for_arch(platform, mode, &android_target_string, &bindings_dir)?; + build_for_arch("ios", mode, &ios_target_string, bindings_dir)?; + } + if !android_target_string.is_empty() { + build_for_arch("android", mode, &android_target_string, bindings_dir)?; + patch_android_cmake_lists_uniffi_bindgen_resolve(bindings_dir)?; } - // Include the xcframework in the package.json for mopro-react-native-package + set_xcframework_package_files(bindings_dir)?; + + Ok(()) +} + +/// Include the xcframework in `package.json` for `mopro-react-native-package`. +pub fn set_xcframework_package_files(bindings_dir: &Path) -> anyhow::Result<()> { let npm_status = Command::new("npm") .args(["pkg", "set", "files[]=*.xcframework/**"]) .current_dir(bindings_dir) @@ -157,11 +173,12 @@ fn generate_react_native_bindings( if !npm_status.success() { return Err(anyhow::anyhow!("Failed to set files in package.json")); } - Ok(()) } -fn build_for_arch( +/// Run `uniffi-bindgen-react-native build --and-generate --targets ` +/// in `bindings_dir`. `platform` is `"ios"` or `"android"`. +pub fn build_for_arch( platform: &str, mode: Mode, target_string: &str, @@ -190,3 +207,114 @@ fn build_for_arch( } Ok(()) } + +// uniffi-bindgen-react-native's generated android/CMakeLists.txt resolves its +// own package root via `require.resolve('uniffi-bindgen-react-native/package.json')`, +// which throws ERR_PACKAGE_PATH_NOT_EXPORTED on package versions whose +// "exports" field omits that subpath (0.31.0-3+, see +// zkmopro/uniffi-bindgen-react-native#399), breaking the Android C++ build +// (missing headers like UniffiCallInvoker.h). Rewrite the generated file to +// resolve the package's public "." export instead and walk up to the +// package root, which works regardless of the "exports" field. This is a +// no-op once the upstream fix ships in a published release. +pub fn patch_android_cmake_lists_uniffi_bindgen_resolve(bindings_dir: &Path) -> anyhow::Result<()> { + let cmake_lists_path = bindings_dir.join("android").join("CMakeLists.txt"); + let contents = match fs::read_to_string(&cmake_lists_path) { + Ok(contents) => contents, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e).with_context(|| format!("Failed to read {:?}", cmake_lists_path)), + }; + + const BROKEN: &str = "require.resolve('uniffi-bindgen-react-native/package.json')"; + if !contents.contains(BROKEN) { + return Ok(()); + } + + const FIXED: &str = "require('path').dirname(require('path').dirname(require('path').dirname(require('path').dirname(require.resolve('uniffi-bindgen-react-native')))))"; + let patched = contents.replace(BROKEN, FIXED).replace( + "\n# Get the directory; get_filename_component and cmake_path will normalize\n\ +# paths with Windows path separators.\n\ +get_filename_component(UNIFFI_BINDGEN_PATH \"${UNIFFI_BINDGEN_PATH}\" DIRECTORY)\n", + "\n", + ); + + fs::write(&cmake_lists_path, patched) + .with_context(|| format!("Failed to patch {:?}", cmake_lists_path))?; + Ok(()) +} + +fn android_abi_for_triple(triple: &str) -> Option<&'static str> { + match triple { + "aarch64-linux-android" => Some(ARCH_ARM_64_V8), + "armv7-linux-androideabi" => Some(ARCH_ARM_V7_ABI), + "i686-linux-android" => Some(ARCH_I686), + "x86_64-linux-android" => Some(ARCH_X86_64), + _ => None, + } +} + +/// The `react-native/android/gradle.properties` template (from the +/// `zkmopro/react-native-app` scaffold) defaults `reactNativeArchitectures` to +/// all four Android ABIs, but the native library is only built for the +/// architectures configured for this project. Gradle's CMake build fails with +/// a missing `.so` for any listed ABI that wasn't actually built, so narrow +/// the property to match what was built (appending the line if it's missing +/// entirely). `project_dir` is the mopro project root (the parent of the +/// `react-native/` app directory); a no-op if that app directory hasn't been +/// created yet (i.e. before `mopro create react-native` has run). +pub fn patch_gradle_properties_architectures( + project_dir: &Path, + android_target_strings: &[String], +) -> anyhow::Result<()> { + let abis: Vec<&str> = android_target_strings + .iter() + .filter_map(|triple| android_abi_for_triple(triple)) + .collect(); + if abis.is_empty() { + return Ok(()); + } + + let gradle_properties_path = project_dir + .join(REACT_NATIVE_APP_DIR) + .join("android") + .join("gradle.properties"); + let contents = match fs::read_to_string(&gradle_properties_path) { + Ok(contents) => contents, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(e).with_context(|| format!("Failed to read {:?}", gradle_properties_path)) + } + }; + + const PREFIX: &str = "reactNativeArchitectures="; + let new_line = format!("{PREFIX}{}", abis.join(",")); + + // Match only a real assignment line (trimmed line starting with the + // property name), not any occurrence of the substring — the template's + // own comment block (`# ./gradlew -PreactNativeArchitectures=x86_64`) + // also contains "reactNativeArchitectures=" and would otherwise be + // matched first, silently leaving the real property untouched. + let existing_line = contents + .lines() + .find(|line| line.trim_start().starts_with(PREFIX)); + + let patched = match existing_line { + Some(line) => contents.replacen(line, &new_line, 1), + // Property was removed from the template (e.g. by hand); append it + // rather than silently leaving Gradle to fall back to building all + // four ABIs again. + None => { + let mut patched = contents.clone(); + if !patched.ends_with('\n') && !patched.is_empty() { + patched.push('\n'); + } + patched.push_str(&new_line); + patched.push('\n'); + patched + } + }; + + fs::write(&gradle_properties_path, patched) + .with_context(|| format!("Failed to patch {:?}", gradle_properties_path))?; + Ok(()) +} diff --git a/mopro-ffi/src/app_config/template/react_native/package.json b/mopro-ffi/src/app_config/template/react_native/package.json index a3f87e92..e113b574 100644 --- a/mopro-ffi/src/app_config/template/react_native/package.json +++ b/mopro-ffi/src/app_config/template/react_native/package.json @@ -1,18 +1,18 @@ { - "name": "mopro-ffi", - "version": "0.1.0", - "description": "Mopro FFI bindings for React Native", - "main": "./lib/module/index.js", - "types": "./lib/typescript/src/index.d.ts", - "exports": { + "name": "mopro-ffi", + "version": "0.1.0", + "description": "Mopro FFI bindings for React Native", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { ".": { - "source": "./src/index.tsx", - "types": "./lib/typescript/src/index.d.ts", - "default": "./lib/module/index.js" + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" }, "./package.json": "./package.json" - }, - "files": [ + }, + "files": [ "src", "lib", "android", @@ -29,9 +29,10 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" - ], - "scripts": { + "!**/.*", + "*.xcframework/**" + ], + "scripts": { "ubrn:ios": "ubrn build ios --and-generate --release && (cd example/ios && pod install)", "ubrn:android": "ubrn build android --and-generate --release --targets aarch64-linux-android", "test": "jest", @@ -40,26 +41,26 @@ "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", "prepare": "bob build", "release": "release-it --only-version" - }, - "keywords": [ + }, + "keywords": [ "react-native", "ios", "android" - ], - "repository": { + ], + "repository": { "type": "git", "url": "git+https://github.com/zkmopro/react-native-app.git" - }, - "author": "ZK Mopro Team (https://zkmopro.com)", - "license": "MIT", - "bugs": { + }, + "author": "ZK Mopro Team (https://zkmopro.com)", + "license": "MIT", + "bugs": { "url": "https://github.com/zkmopro/react-native-app/issues" - }, - "homepage": "https://github.com/zkmopro/react-native-app#readme", - "publishConfig": { + }, + "homepage": "https://github.com/zkmopro/react-native-app#readme", + "publishConfig": { "registry": "https://registry.npmjs.org/" - }, - "devDependencies": { + }, + "devDependencies": { "@commitlint/config-conventional": "^19.8.1", "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", @@ -84,86 +85,86 @@ "release-it": "^19.0.4", "turbo": "^2.5.6", "typescript": "^5.9.2" - }, - "peerDependencies": { + }, + "peerDependencies": { "react": "*", "react-native": "*" - }, - "workspaces": [ + }, + "workspaces": [ "example" - ], - "packageManager": "yarn@3.6.1", - "jest": { + ], + "packageManager": "yarn@3.6.1", + "jest": { "preset": "react-native", "modulePathIgnorePatterns": [ - "/example/node_modules", - "/lib/" + "/example/node_modules", + "/lib/" ] - }, - "commitlint": { + }, + "commitlint": { "extends": [ - "@commitlint/config-conventional" + "@commitlint/config-conventional" ] - }, - "release-it": { + }, + "release-it": { "git": { - "commitMessage": "chore: release ${version}", - "tagName": "v${version}" + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" }, "npm": { - "publish": true + "publish": true }, "github": { - "release": true + "release": true }, "plugins": { - "@release-it/conventional-changelog": { - "preset": { - "name": "angular" + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } } - } } - }, - "prettier": { + }, + "prettier": { "quoteProps": "consistent", "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", "useTabs": false - }, - "react-native-builder-bob": { + }, + "react-native-builder-bob": { "source": "src", "output": "lib", "targets": [ - [ - "module", - { - "esm": true - } - ], - [ - "typescript", - { - "project": "tsconfig.build.json" - } - ] + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] ] - }, - "codegenConfig": { + }, + "codegenConfig": { "name": "MoproFFISpec", "type": "modules", "jsSrcsDir": "src", "android": { - "javaPackageName": "com.moproffi" + "javaPackageName": "com.moproffi" } - }, - "create-react-native-library": { + }, + "create-react-native-library": { "languages": "kotlin-objc", "type": "turbo-module", "version": "0.54.5" - }, - "dependencies": { - "uniffi-bindgen-react-native": "^0.29.3-1" - } + }, + "dependencies": { + "@ubjs/core": "^0.31.0-3", + "uniffi-bindgen-react-native": "^0.31.0-3" } - \ No newline at end of file +}