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.7`. 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(crate_dir, 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(crate_dir, 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    crate_dir: &Path,
163    workspace_root: &Path,
164    user_package: &str,
165) -> Result<Engine> {
166    let manifest_path = workspace_root.join("Cargo.toml");
167    let discovered = discover_plugins(&manifest_path, user_package)
168        .with_context(|| format!("discover Whisker CNG plugins for `{user_package}`"))?;
169
170    // Stamp the app crate dir onto the engine so subprocess plugins
171    // (e.g. `whisker-asset`) can resolve paths the user spelled
172    // relative to their crate — they don't inherit a reliable cwd.
173    let mut engine = Engine::with_builtins().with_app_crate_dir(crate_dir);
174    if discovered.is_empty() {
175        return Ok(engine);
176    }
177
178    // Single `cargo build` invocation listing every plugin's
179    // `--bin` + `--package` pair. Cheaper than spawning cargo once
180    // per plugin and shares the build graph / unit cache.
181    build_discovered_plugins(workspace_root, &discovered)?;
182
183    let target_dir = workspace_root.join("target/debug");
184    for plugin in discovered {
185        let binary_path = target_dir.join(&plugin.bin_target_name);
186        if !binary_path.exists() {
187            return Err(anyhow!(
188                "discovered plugin `{}` (from crate `{}`) declared bin = `{}` \
189                 but `cargo build` did not produce `{}`. Check that the bin \
190                 target is declared correctly in `{}/Cargo.toml`.",
191                plugin.name,
192                plugin.source_crate,
193                plugin.bin_target_name,
194                binary_path.display(),
195                plugin.source_manifest_dir.display(),
196            ));
197        }
198        engine.register_subprocess(
199            SubprocessPlugin::new(plugin.name.clone(), binary_path)
200                .after(plugin.after.clone())
201                .before(plugin.before.clone()),
202        );
203    }
204    Ok(engine)
205}
206
207/// Run a single `cargo build` that builds every discovered
208/// plugin's `[[bin]]` target. We use the workspace's existing
209/// `target/debug` so subsequent runs are no-op when the plugin
210/// crates haven't changed (cargo's own incremental cache).
211///
212/// Output streams through the curated `Step::pipe` machinery so the
213/// cargo progress (`    Compiling …` / `    Finished …` lines) folds
214/// into a single spinner row instead of leaking unfiltered ahead of
215/// the dev loop's `── whisker run ──` section header — and, with
216/// the TUI on, ahead of the inline status bar where stray
217/// `eprintln!`s race the viewport redraw.
218fn build_discovered_plugins(workspace_root: &Path, discovered: &[DiscoveredPlugin]) -> Result<()> {
219    let bins: Vec<&str> = discovered
220        .iter()
221        .map(|p| p.bin_target_name.as_str())
222        .collect();
223    let step = whisker_build::ui::step("compile", format!("plugins ({})", bins.join(", ")));
224    let mut cmd = Command::new("cargo");
225    cmd.arg("build").current_dir(workspace_root);
226    for plugin in discovered {
227        cmd.arg("--bin")
228            .arg(&plugin.bin_target_name)
229            .arg("--package")
230            .arg(&plugin.source_crate);
231    }
232    let status = step
233        .pipe(&mut cmd)
234        .with_context(|| "spawn `cargo build` for discovered Whisker CNG plugin binaries")?;
235    if !status.success() {
236        step.fail(format!("{status}"));
237        return Err(anyhow!(
238            "`cargo build` for discovered Whisker CNG plugin binaries exited with {status}. \
239             Re-run with `RUST_BACKTRACE=1 cargo build --bin <bin> --package <crate>` to see \
240             the underlying compile error."
241        ));
242    }
243    step.done("");
244    Ok(())
245}