whisker_cli/build_dispatch.rs
1//! Internal build-tool dispatch — the entry points the **generated
2//! native projects** invoke to cross-compile the user's Rust crate
3//! into the platform artifact (iOS `WhiskerDriver.framework` /
4//! Android `lib*.so`) and to discover Whisker modules.
5//!
6//! These used to be a separate `whisker-build` binary. Folding them
7//! into the `whisker` CLI (which already links `whisker-build` as a
8//! library) means **`cargo install whisker-cli` is the only install
9//! needed** — there is no second binary to put on `PATH`.
10//!
11//! Invocation shape (hidden subcommands, not for humans):
12//!
13//! ```sh
14//! # Xcode Run Script Phase (gen/ios/.../project.pbxproj):
15//! whisker build-ios \
16//! --workspace="$WORKSPACE" --package="$PKG" \
17//! --configuration="$CONFIGURATION" --platform="$PLATFORM_NAME" \
18//! --archs="$ARCHS" --built-products-dir="$BUILT_PRODUCTS_DIR"
19//!
20//! # Gradle cargoBuild task (whisker-gradle-plugin):
21//! whisker build-android \
22//! --workspace="$WS" --package="$PKG" --profile=debug \
23//! --abi=arm64-v8a --jni-libs-dir="$DIR" --min-sdk=24
24//!
25//! # Gradle Settings plugin module discovery → JSON on stdout:
26//! whisker modules --workspace="$WS" --package="$PKG"
27//! ```
28
29use anyhow::{anyhow, Context, Result};
30use clap::Args;
31use std::path::PathBuf;
32use whisker_build::Profile;
33
34/// Inputs the Xcode Run Script Phase passes through. Mirrors the
35/// Xcode environment variables verbatim so the script glue stays one
36/// shell line.
37#[derive(Args, Debug)]
38pub struct IosArgs {
39 /// Workspace root containing the user app's top-level `Cargo.toml`.
40 #[arg(long)]
41 workspace: PathBuf,
42
43 /// Cargo package name (the user app crate). Passed rather than
44 /// re-discovered to stay deterministic with multiple workspace
45 /// members.
46 #[arg(long)]
47 package: String,
48
49 /// Xcode `CONFIGURATION` (`Debug` or `Release`).
50 #[arg(long)]
51 configuration: String,
52
53 /// Xcode `PLATFORM_NAME` (`iphoneos` or `iphonesimulator`).
54 #[arg(long)]
55 platform: String,
56
57 /// Xcode `ARCHS` — one or more space-separated architectures.
58 #[arg(long)]
59 archs: String,
60
61 /// Xcode `BUILT_PRODUCTS_DIR`. The framework lands under
62 /// `<dir>/Frameworks/` so Xcode's embed phase picks it up.
63 #[arg(long)]
64 built_products_dir: PathBuf,
65
66 /// Cargo `--features` to forward to the cross-compile. Repeatable.
67 /// `whisker run` passes `whisker/hot-reload` here so the user
68 /// dylib carries the dev-runtime WebSocket client.
69 #[arg(long)]
70 features: Vec<String>,
71}
72
73/// Inputs the Gradle `cargoBuild*` task passes through.
74#[derive(Args, Debug)]
75pub struct AndroidArgs {
76 /// Workspace root.
77 #[arg(long)]
78 workspace: PathBuf,
79
80 /// Cargo package name (the user app crate).
81 #[arg(long)]
82 package: String,
83
84 /// Gradle build type (`debug` or `release`).
85 #[arg(long)]
86 profile: String,
87
88 /// Target ABI (`arm64-v8a` / `armeabi-v7a` / `x86_64` / `x86`).
89 #[arg(long)]
90 abi: String,
91
92 /// Where to place the resulting `.so` (`<...>/jniLibs/<abi>/`).
93 #[arg(long)]
94 jni_libs_dir: PathBuf,
95
96 /// Android `minSdkVersion` — selects the NDK sysroot.
97 #[arg(long, default_value = "24")]
98 min_sdk: u32,
99
100 /// Cargo `--features` to forward to the cross-compile. Repeatable.
101 #[arg(long)]
102 features: Vec<String>,
103}
104
105/// Inputs for the `modules` discovery subcommand. Workspace + app
106/// crate name are enough — the JSON carries per-platform availability
107/// flags inline.
108#[derive(Args, Debug)]
109pub struct ModulesArgs {
110 /// Workspace root containing the user app's top-level `Cargo.toml`.
111 #[arg(long)]
112 workspace: PathBuf,
113
114 /// User app crate name. Discovery walks the cargo dep graph rooted
115 /// at this package.
116 #[arg(long)]
117 package: String,
118}
119
120/// Resolve the workspace path to its canonical form (`..` collapsed,
121/// symlinks resolved) before anything downstream consumes it.
122///
123/// Without this, two invocations with logically-equivalent but
124/// textually-different workspace paths bake different absolute paths
125/// into the cng-rendered files — and SPM's `.package(path:)` does a
126/// byte-for-byte string compare for identity, so a mismatch splits the
127/// same package into two identities and breaks the SwiftPM build-tool
128/// plugin dependency chain.
129fn canonicalize_workspace(p: &PathBuf) -> Result<PathBuf> {
130 std::fs::canonicalize(p).with_context(|| format!("canonicalize workspace {}", p.display()))
131}
132
133pub fn run_modules(args: ModulesArgs) -> Result<()> {
134 let workspace = canonicalize_workspace(&args.workspace)?;
135 let report = whisker_build::modules::build_modules_report(&workspace, &args.package)
136 .with_context(|| {
137 format!(
138 "build modules report for `{}` (workspace={})",
139 args.package,
140 workspace.display(),
141 )
142 })?;
143 // Pretty-print so a human inspecting the cache file can read it;
144 // the Gradle plugin parses either form fine.
145 let json = serde_json::to_string_pretty(&report).context("serialize modules report")?;
146 println!("{json}");
147 Ok(())
148}
149
150pub fn run_ios(args: IosArgs) -> Result<()> {
151 let workspace = canonicalize_workspace(&args.workspace)?;
152 // No Lynx pre-fetch here. The bridge cc build no longer touches
153 // any Lynx header path, and the host xcodebuild invocation
154 // resolves Lynx xcframeworks via SPM's `binaryTarget(url:checksum:)`.
155 let archs: Vec<&str> = args.archs.split_whitespace().collect();
156 let fw = whisker_build::ios::build_framework_for_xcode_run_script(
157 &whisker_build::ios::XcodeRunScriptInputs {
158 workspace_root: &workspace,
159 package: &args.package,
160 platform: &args.platform,
161 archs: &archs,
162 features: &args.features,
163 },
164 &args.built_products_dir,
165 )
166 .with_context(|| {
167 format!(
168 "build framework for ({}/{}) → {}",
169 args.platform,
170 args.archs,
171 args.built_products_dir.display(),
172 )
173 })?;
174
175 // `configuration` is currently informational — the iOS cargo build
176 // is always release-tier (subsecond's Tier 1 capture wants the same
177 // optimised codegen prod ships). Logged so a Debug-mode Xcode build
178 // surprised by release optimisation has the mismatch visible.
179 eprintln!(
180 "[whisker build-ios] published {} (configuration={}, archs=[{}])",
181 fw.display(),
182 args.configuration,
183 args.archs,
184 );
185 Ok(())
186}
187
188pub fn run_android(args: AndroidArgs) -> Result<()> {
189 let workspace = canonicalize_workspace(&args.workspace)?;
190 let cargo_toml = workspace.join("Cargo.toml");
191 let modules = whisker_build::modules::discover(&cargo_toml, &args.package)
192 .with_context(|| format!("discover whisker modules in {}", cargo_toml.display()))?;
193
194 let profile = parse_profile(&args.profile)?;
195
196 // No Lynx pre-fetch on Android either — the bridge calls into Lynx
197 // via `dlopen("liblynx.so")` + `dlsym` at engine-attach time, and
198 // gradle pulls `lynx-android.aar` transitively from Maven.
199 let toolchain = whisker_build::android::resolve_toolchain(&args.abi, args.min_sdk)
200 .with_context(|| {
201 format!(
202 "resolve NDK toolchain for {} (api {})",
203 args.abi, args.min_sdk
204 )
205 })?;
206
207 let so_path = whisker_build::android::cargo_build_dylib(&whisker_build::android::CargoBuild {
208 workspace_root: &workspace,
209 package: &args.package,
210 toolchain: &toolchain,
211 profile,
212 features: &args.features,
213 capture: None,
214 })
215 .context("cargo cross-compile for Android")?;
216
217 whisker_build::android::stage_so_files(&args.jni_libs_dir, &so_path, &toolchain, &args.abi)
218 .with_context(|| {
219 format!(
220 "stage .so + libc++_shared.so into {}",
221 args.jni_libs_dir.display()
222 )
223 })?;
224
225 eprintln!(
226 "[whisker build-android] {} module(s) discovered (gradle-subproject wiring is the Gradle plugin's job)",
227 modules.len(),
228 );
229 Ok(())
230}
231
232/// Translate the `--profile` string the Gradle plugin passes into the
233/// typed [`Profile`] the library API expects.
234fn parse_profile(s: &str) -> Result<Profile> {
235 match s {
236 "debug" => Ok(Profile::Debug),
237 "release" => Ok(Profile::Release),
238 other => Err(anyhow!(
239 "--profile must be 'debug' or 'release' (got `{other}`)"
240 )),
241 }
242}