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}