Skip to main content

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}