whisker_dev_server/builder.rs
1//! Tier 2 cold rebuild: produce a fresh artifact + (re)install it on
2//! the active [`Target`].
3//!
4//! Delegates the cargo + gradle / xcodebuild orchestration to
5//! `whisker-build`, which is shared with `whisker-cli`'s `whisker
6//! build` subcommand. When `with_capture` is set, the cargo step
7//! doubles as a **fat build** that fills the rustc + linker capture
8//! caches the Tier 1 hot-patch pipeline replays later.
9
10use anyhow::{Context, Result};
11use std::path::PathBuf;
12
13use crate::Target;
14use whisker_build::CaptureShims;
15
16/// Builder for cold (Tier 2) rebuilds. Tier 1 hot-patches live in
17/// [`crate::hotpatch::Patcher`] — Builder is only invoked for
18/// dependency-shaped changes (Cargo.toml edits) and as a fallback
19/// when Tier 1 errors.
20pub struct Builder {
21 workspace_root: PathBuf,
22 /// User crate dir (= `Cargo.toml` parent). Needed to find
23 /// `gen/android/` for gradle invocation.
24 crate_dir: PathBuf,
25 package: String,
26 target: Target,
27 /// Cargo features forwarded to whichever step compiles the user
28 /// crate. The dev loop turns on `whisker/hot-reload` here.
29 features: Vec<String>,
30 /// `Some` → fat build (Tier 1 capture caches get populated).
31 /// `None` → plain Tier 2.
32 capture: Option<CaptureShims>,
33}
34
35impl Builder {
36 pub fn new(
37 workspace_root: PathBuf,
38 crate_dir: PathBuf,
39 package: String,
40 target: Target,
41 ) -> Self {
42 Self {
43 workspace_root,
44 crate_dir,
45 package,
46 target,
47 features: Vec::new(),
48 capture: None,
49 }
50 }
51
52 pub fn with_features(mut self, features: Vec<String>) -> Self {
53 self.features = features;
54 self
55 }
56
57 /// Read-only view of the features currently configured. The dev
58 /// loop reads this when constructing the [`Installer`] so the iOS
59 /// xcodebuild env var (`WHISKER_FEATURES`) stays in sync with what
60 /// the Builder would have passed to a direct cargo invocation.
61 pub fn features(&self) -> &[String] {
62 &self.features
63 }
64
65 /// Elevate the next build into a fat build. The cache dirs and
66 /// shim binaries from `capture` get folded into the cargo
67 /// invocation via env vars — see
68 /// [`whisker_build::capture_env_vars`] for the exact set.
69 pub fn with_capture(mut self, capture: CaptureShims) -> Self {
70 self.capture = Some(capture);
71 self
72 }
73
74 /// Run the build for the current target. Inherits stdout/stderr.
75 pub async fn build(&self) -> Result<()> {
76 match self.target {
77 Target::Android => self.build_android().await,
78 Target::IosSimulator => self.build_ios_simulator().await,
79 }
80 }
81
82 /// Whether this builder is configured for a fat build.
83 pub fn captures_shims(&self) -> bool {
84 self.capture.is_some()
85 }
86
87 // ----- per-target build paths ------------------------------------------
88
89 async fn build_android(&self) -> Result<()> {
90 // Dev loop only stages module Kotlin sources, then drives
91 // gradle. Gradle's own `whiskerBuildDebugArm64V8a` task runs
92 // `whisker build-android` (which runs cargo + stages the .so +
93 // libc++_shared.so into the generated jniLibs source dir AGP
94 // mergeJniLibFolders picks up), so a *second* pre-cargo build
95 // here would just produce the same `.so` twice and leak its
96 // output across the curated dev-loop UI.
97 //
98 // Mirrors what iOS already does: cargo runs only inside
99 // xcodebuild's Build Phase; the dev-server's `build_ios_simulator`
100 // is module-source-staging only. Aligning Android to the same
101 // shape halves the wall-clock of every Tier 2 rebuild on a
102 // cache-warm cargo and removes one race against the TUI viewport.
103 let ws = self.workspace_root.clone();
104 let crate_dir = self.crate_dir.clone();
105 let pkg = self.package.clone();
106 let features = self.features.clone();
107 let capture = self.capture.clone();
108
109 tokio::task::spawn_blocking(move || -> Result<()> {
110 let gen_android = crate_dir.join("gen/android");
111 // Stage discovered Whisker modules' Android Kotlin
112 // sources before gradle runs. Empty when no module
113 // declares android.kotlin_sources.
114 let modules = whisker_build::modules::discover(&ws.join("Cargo.toml"), &pkg)?;
115 whisker_build::android::stage_module_kotlin_sources(&gen_android, &modules)?;
116 whisker_build::android::run_gradle_assemble(
117 &gen_android,
118 whisker_build::Profile::Debug,
119 &features,
120 capture.as_ref(),
121 )?;
122 Ok(())
123 })
124 .await
125 .context("spawn_blocking Android build")?
126 }
127
128 async fn build_ios_simulator(&self) -> Result<()> {
129 // Step 7: this method's only remaining job is to stage the
130 // module Swift sources for SwiftPM. The actual `.app` build —
131 // and the cargo cross-compile that produces
132 // `WhiskerDriver.framework` — happens during xcodebuild in
133 // `installer.rs::ios_install_and_launch`, via the cng-generated
134 // pbxproj's "Whisker Generate" Run Script Build Phase.
135 //
136 // Pre-Step-7 this method also ran `build_xcframework_with` to
137 // produce `target/whisker-driver/WhiskerDriver.xcframework`
138 // and to prime the Tier 1 capture shims. The xcframework is
139 // no longer referenced by anything (Step 7 dropped the SPM
140 // binaryTarget) so its output was wasted; the capture wiring
141 // moved to `installer.rs` where it gets applied as env vars
142 // on the xcodebuild Command.
143 let ws = self.workspace_root.clone();
144 let crate_dir = self.crate_dir.clone();
145 let pkg = self.package.clone();
146
147 tokio::task::spawn_blocking(move || -> Result<()> {
148 // Stage Whisker modules' iOS Swift sources before
149 // xcodebuild runs so the pbxproj's WhiskerModules SwiftPM
150 // ref resolves cleanly. Empty when no module declares
151 // `[ios].swift_sources` — the staging step still writes a
152 // no-op Package.swift + WhiskerModuleBehaviors.swift so
153 // AppDelegate's `import WhiskerModules` doesn't fail to
154 // resolve.
155 let modules = whisker_build::modules::discover(&ws.join("Cargo.toml"), &pkg)?;
156 let gen_ios = crate_dir.join("gen/ios");
157 let whisker_runtime_path = ws.join("platforms/ios");
158 let whisker_ios_macros_path = ws.join("platforms/ios/macros");
159 whisker_build::ios::stage_module_swift_sources(
160 &gen_ios,
161 &whisker_runtime_path,
162 &whisker_ios_macros_path,
163 &modules,
164 )?;
165 Ok(())
166 })
167 .await
168 .context("spawn_blocking iOS module-source stage")?
169 }
170}
171
172// ============================================================================
173// Tests
174// ============================================================================
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn builder_can_be_constructed_for_each_target() {
182 for t in [Target::Android, Target::IosSimulator] {
183 let b = Builder::new(
184 PathBuf::from("/tmp/ws"),
185 PathBuf::from("/tmp/ws/examples/x"),
186 "x".into(),
187 t,
188 );
189 assert!(!b.captures_shims());
190 assert!(b.features.is_empty());
191 }
192 }
193
194 #[test]
195 fn with_features_replaces_the_feature_list() {
196 let b = Builder::new(
197 PathBuf::from("/tmp/ws"),
198 PathBuf::from("/tmp/ws/examples/x"),
199 "x".into(),
200 Target::Android,
201 )
202 .with_features(vec!["whisker/hot-reload".into(), "extra".into()]);
203 assert_eq!(b.features, vec!["whisker/hot-reload", "extra"]);
204 }
205
206 #[test]
207 fn with_capture_flips_captures_shims() {
208 let shims = CaptureShims {
209 rustc_shim: PathBuf::from("/tmp/rs"),
210 linker_shim: PathBuf::from("/tmp/ls"),
211 rustc_cache_dir: PathBuf::from("/tmp/rc"),
212 linker_cache_dir: PathBuf::from("/tmp/lc"),
213 real_linker: PathBuf::from("/usr/bin/cc"),
214 target_triple: Some("aarch64-linux-android".into()),
215 };
216 let b = Builder::new(
217 PathBuf::from("/tmp/ws"),
218 PathBuf::from("/tmp/ws/examples/x"),
219 "x".into(),
220 Target::Android,
221 )
222 .with_capture(shims);
223 assert!(b.captures_shims());
224 }
225}