Skip to main content

whisker_cli/
platforms.rs

1//! Glue between `whisker-cng` and the CLI.
2//!
3//! Responsibilities split:
4//!
5//! - `whisker-cng` owns the *pure* renderer: Config + paths → files
6//!   on disk. No shelling out, no environment assumptions. Pure logic
7//!   so it stays unit-testable against tempdirs.
8//! - This module decides *where* the gen dirs live (always
9//!   `<crate_dir>/gen/{android,ios}`), resolves the Whisker native
10//!   runtime paths (today: `<workspace>/native/{android,ios}`), and
11//!   handles the side-effect bits that follow a sync — running
12//!   `xcodegen generate` after iOS regeneration so the
13//!   `<scheme>.xcodeproj` is fresh before `xcodebuild` runs.
14//!
15//! Public entry point: [`sync_for_target`]. The cli's `run` and
16//! `build` subcommands call this before kicking off the rest of the
17//! build pipeline.
18
19use anyhow::{anyhow, Context, Result};
20use std::path::{Path, PathBuf};
21use std::process::Command;
22use whisker_cng::{discover_plugins, DiscoveredPlugin, Engine, SubprocessPlugin};
23use whisker_config::Config;
24use whisker_dev_server::Target;
25
26/// Run the platform-appropriate sync for `target`. Returns the gen
27/// directory the caller should hand to gradle / xcodebuild — useful
28/// even for the fast-path (`regenerated == false`) case.
29pub fn sync_for_target(
30    target: Target,
31    app_config: &Config,
32    crate_dir: &Path,
33    workspace_root: &Path,
34    package: &str,
35) -> Result<PlatformSync> {
36    match target {
37        Target::Android => sync_android(app_config, crate_dir, workspace_root, package),
38        Target::IosSimulator => sync_ios(app_config, crate_dir, workspace_root, package),
39    }
40}
41
42/// Outcome of one sync_native pass.
43#[derive(Debug, Clone)]
44pub struct PlatformSync {
45    /// Where the generated project tree lives — `gen/android/` or
46    /// `gen/ios/` under `crate_dir`.
47    pub gen_dir: PathBuf,
48    /// `true` if the renderer rewrote files this pass, `false` if the
49    /// fingerprint matched and the existing tree was reused.
50    pub regenerated: bool,
51}
52
53/// SDK version pinned into the cng-generated
54/// `app/build.gradle.kts` (`rs.whisker:whisker-runtime-android:<this>`).
55/// Bumped alongside the `sdk-v*` release tag.
56///
57/// 0.1.1 rolls forward the transitive Lynx pin baked into the SDK's
58/// POM from `v3.8.0-whisker.4` (initial SDK release) to
59/// `v3.8.0-whisker.6`. The newer Lynx exposes `lynx_capi_abi_version()`
60/// which the Step-6 dlopen-based bridge requires; without this bump,
61/// downstream apps that pull `whisker-runtime-android:0.1.0`
62/// transitively get the older Lynx and the bridge loader aborts on
63/// "undefined symbol: lynx_capi_abi_version" at engine_attach time.
64const WHISKER_SDK_VERSION: &str = "0.1.1";
65/// Gradle plugin version pinned into the generated
66/// `settings.gradle.kts` `pluginManagement.plugins` + `plugins`
67/// blocks. Bumped independently from the SDK via the
68/// `gradle-plugin-v*` release tag.
69///
70/// 0.3.0 was the first version with the two-JAR split (Settings
71/// plugin / Project plugin in separate Maven artifacts). 0.4.0
72/// adds two fixes that surfaced during the first Step-5 e2e:
73///   - `WhiskerBuildTask.workspace` switched from `@InputDirectory`
74///     to `@Internal` so Gradle stops walking the cargo workspace
75///     tree (which contains other subprojects' `build/` dirs)
76///     and refusing the build for implicit dependencies.
77///   - `WhiskerProjectPlugin` now wires the aggregator Kotlin
78///     generator into `variant.sources.java` (which AGP 8.6's
79///     Kotlin compile actually depends on) rather than `.kotlin`
80///     alone, plus places the staged `.so` into a nested
81///     `<jniLibsDir>/<abi>/` subdir so AGP's `mergeJniLibFolders`
82///     recognises the layout.
83const WHISKER_GRADLE_PLUGIN_VERSION: &str = "0.4.0";
84const WHISKER_MAVEN_URL: &str = "https://whiskerrs.github.io/whisker/maven";
85const LYNX_MAVEN_URL: &str = "https://whiskerrs.github.io/lynx/maven";
86
87fn sync_android(
88    app_config: &Config,
89    crate_dir: &Path,
90    workspace_root: &Path,
91    package: &str,
92) -> Result<PlatformSync> {
93    // Settings plugin reads `workspace` as a `file(...)` — Gradle
94    // resolves that relative to the settings.gradle.kts directory
95    // (= `gen/android/`). Hand the renderer the absolute path; the
96    // template embeds it verbatim. Absolute keeps the generated
97    // tree independent of `gen/android`'s on-disk depth, at the cost
98    // of looking less portable in diffs (acceptable — these files
99    // are AUTO-GENERATED and not meant to be committed).
100    let workspace_path = workspace_root.to_path_buf();
101    let engine = build_engine_with_discovered_plugins(workspace_root, package)?;
102    let inputs = whisker_cng::android::inputs_from_with_engine(
103        &engine,
104        app_config,
105        package.replace('-', "_"),
106        workspace_path,
107        package.to_string(),
108        WHISKER_SDK_VERSION.to_string(),
109        WHISKER_GRADLE_PLUGIN_VERSION.to_string(),
110        WHISKER_MAVEN_URL.to_string(),
111        LYNX_MAVEN_URL.to_string(),
112    )?;
113    let gen_dir = crate_dir.join("gen/android");
114    let regenerated = whisker_cng::sync_android(&gen_dir, &inputs).context("render gen/android")?;
115    Ok(PlatformSync {
116        gen_dir,
117        regenerated,
118    })
119}
120
121fn sync_ios(
122    app_config: &Config,
123    crate_dir: &Path,
124    workspace_root: &Path,
125    package: &str,
126) -> Result<PlatformSync> {
127    let gen_dir = crate_dir.join("gen/ios");
128    let whisker_runtime = workspace_root.join("platforms/ios");
129    // `gen/ios/whisker_modules/` is populated lazily by
130    // `whisker-build::ios::stage_module_swift_sources` later in the
131    // pipeline (between cargo build and xcodebuild). The pbxproj
132    // template's `XCLocalSwiftPackageReference` for WhiskerModules
133    // needs an *absolute* path to that directory at sync time, so we
134    // pre-compute it here even though the contents will land later.
135    let whisker_modules = gen_dir.join("whisker_modules");
136    let engine = build_engine_with_discovered_plugins(workspace_root, package)?;
137    let inputs = whisker_cng::ios::inputs_from_with_engine(
138        &engine,
139        app_config,
140        whisker_runtime,
141        whisker_modules,
142        workspace_root.to_path_buf(),
143        package.to_string(),
144    )?;
145    // whisker-cng renders the full Xcode project directly (pbxproj +
146    // xcworkspacedata + sources). No xcodegen subprocess needed —
147    // see crates/whisker-cng/src/ios.rs for the rationale.
148    let regenerated = whisker_cng::sync_ios(&gen_dir, &inputs).context("render gen/ios")?;
149    Ok(PlatformSync {
150        gen_dir,
151        regenerated,
152    })
153}
154
155/// Build a [`whisker_cng::Engine`] populated with built-ins plus
156/// every 3rd-party plugin discovered via `[package.metadata.whisker.plugins]`
157/// in the user app's dep graph. Each discovered plugin's `[[bin]]`
158/// target gets `cargo build`d (debug profile, workspace target dir)
159/// and registered as a [`SubprocessPlugin`] pointing at the
160/// resulting binary.
161fn build_engine_with_discovered_plugins(
162    workspace_root: &Path,
163    user_package: &str,
164) -> Result<Engine> {
165    let manifest_path = workspace_root.join("Cargo.toml");
166    let discovered = discover_plugins(&manifest_path, user_package)
167        .with_context(|| format!("discover Whisker CNG plugins for `{user_package}`"))?;
168
169    let mut engine = Engine::with_builtins();
170    if discovered.is_empty() {
171        return Ok(engine);
172    }
173
174    // Single `cargo build` invocation listing every plugin's
175    // `--bin` + `--package` pair. Cheaper than spawning cargo once
176    // per plugin and shares the build graph / unit cache.
177    build_discovered_plugins(workspace_root, &discovered)?;
178
179    let target_dir = workspace_root.join("target/debug");
180    for plugin in discovered {
181        let binary_path = target_dir.join(&plugin.bin_target_name);
182        if !binary_path.exists() {
183            return Err(anyhow!(
184                "discovered plugin `{}` (from crate `{}`) declared bin = `{}` \
185                 but `cargo build` did not produce `{}`. Check that the bin \
186                 target is declared correctly in `{}/Cargo.toml`.",
187                plugin.name,
188                plugin.source_crate,
189                plugin.bin_target_name,
190                binary_path.display(),
191                plugin.source_manifest_dir.display(),
192            ));
193        }
194        engine.register_subprocess(
195            SubprocessPlugin::new(plugin.name.clone(), binary_path)
196                .after(plugin.after.clone())
197                .before(plugin.before.clone()),
198        );
199    }
200    Ok(engine)
201}
202
203/// Run a single `cargo build` that builds every discovered
204/// plugin's `[[bin]]` target. We use the workspace's existing
205/// `target/debug` so subsequent runs are no-op when the plugin
206/// crates haven't changed (cargo's own incremental cache).
207///
208/// Output streams through the curated `Step::pipe` machinery so the
209/// cargo progress (`    Compiling …` / `    Finished …` lines) folds
210/// into a single spinner row instead of leaking unfiltered ahead of
211/// the dev loop's `── whisker run ──` section header — and, with
212/// the TUI on, ahead of the inline status bar where stray
213/// `eprintln!`s race the viewport redraw.
214fn build_discovered_plugins(workspace_root: &Path, discovered: &[DiscoveredPlugin]) -> Result<()> {
215    let bins: Vec<&str> = discovered
216        .iter()
217        .map(|p| p.bin_target_name.as_str())
218        .collect();
219    let step = whisker_build::ui::step("compile", format!("plugins ({})", bins.join(", ")));
220    let mut cmd = Command::new("cargo");
221    cmd.arg("build").current_dir(workspace_root);
222    for plugin in discovered {
223        cmd.arg("--bin")
224            .arg(&plugin.bin_target_name)
225            .arg("--package")
226            .arg(&plugin.source_crate);
227    }
228    let status = step
229        .pipe(&mut cmd)
230        .with_context(|| "spawn `cargo build` for discovered Whisker CNG plugin binaries")?;
231    if !status.success() {
232        step.fail(format!("{status}"));
233        return Err(anyhow!(
234            "`cargo build` for discovered Whisker CNG plugin binaries exited with {status}. \
235             Re-run with `RUST_BACKTRACE=1 cargo build --bin <bin> --package <crate>` to see \
236             the underlying compile error."
237        ));
238    }
239    step.done("");
240    Ok(())
241}