Skip to main content

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}