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}