Skip to main content

waterui_cli/preview/
launcher.rs

1//! Preview app launcher and session management.
2//!
3//! Handles launching the preview app on the target platform and
4//! establishing TCP connection.
5
6use std::net::{IpAddr, Ipv4Addr, SocketAddr};
7use std::path::{Path, PathBuf};
8use std::pin::Pin;
9use std::time::{Duration, Instant, UNIX_EPOCH};
10
11use cargo_toml::Manifest as CargoManifest;
12use eyre::{Context, Result, bail};
13use futures_util::{FutureExt as _, pin_mut, select};
14#[cfg(feature = "preview")]
15use notify::{RecursiveMode, Watcher as _};
16use sha2::Digest as _;
17use smol::stream::StreamExt;
18use tracing::{error, info};
19
20use super::app_client::{PreviewAppClient, PreviewProbe};
21use super::inputs::{ProjectInputsFingerprint, project_inputs_fingerprint};
22use super::protocol::DylibId;
23use super::protocol::PreviewPlatform;
24use super::protocol::PreviewRuntimePlatform;
25use super::protocol::PreviewTcpConfig;
26use crate::build::BuildProgress;
27
28use crate::apple::dynamic_runtime;
29use crate::build::{BuildOptions, BuildProfile, BuiltTarget, RustBuild, RustLinkage};
30use crate::device::{Device, DeviceEvent, Local, LogLevel, RunOptions, Running};
31use crate::platform::TargetPlatform;
32use crate::project::{ManagedBackends, Project};
33use crate::runtime_compat::{PREVIEW_RUNTIME_ENV_VARS, runtime_profile_tag};
34use crate::runtime_fingerprint::{compute_runtime_fingerprint, runtime_package_identity};
35use crate::support_app;
36use waterui_preview_protocol::registry::preview_instance_registry_dir;
37
38const PREVIEW_TEMPLATE_COMMIT: &str = env!("WATERUI_CLI_COMMIT");
39const PREVIEW_METADATA_FILE: &str = ".waterui-preview-signature";
40/// Bumped whenever `scaffold_preview_app` changes what it generates beyond the
41/// templated files (manifest edits, permissions), which the template fingerprint
42/// does not cover.
43const PREVIEW_SCAFFOLD_GENERATION: u32 = 1;
44const PREVIEW_DYLIB_METADATA_SUFFIX: &str = ".waterui-preview-dylib-signature";
45
46#[derive(Debug, Clone)]
47struct PreviewRequirements {
48    waterui_path: Option<PathBuf>,
49    runtime_fingerprint: String,
50    runtime_features: Vec<String>,
51    app_crate_name: crate::project_types::CrateName,
52    app_path: PathBuf,
53}
54
55#[derive(Debug)]
56struct ResolvedPreviewMetadata {
57    metadata: cargo_metadata::Metadata,
58    app_crate_name: crate::project_types::CrateName,
59    app_path: PathBuf,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63struct PreviewLinkMode {
64    crate_type_override: Option<&'static str>,
65    prefer_dynamic: bool,
66    abi_feature: &'static str,
67    /// Invalidates the module cache when the link strategy for this mode
68    /// changes — a module built under an older scheme resolves its symbols
69    /// against a runtime the support app no longer ships.
70    signature_tag: &'static str,
71}
72
73impl PreviewLinkMode {
74    const MACOS_DYNAMIC: Self = Self {
75        crate_type_override: None,
76        prefer_dynamic: true,
77        abi_feature: crate::templates::preview_ffi::APPLE_ABI_FEATURE,
78        signature_tag: "preview-dylib+shared-waterui-dylib+prefer-dynamic",
79    };
80    const PORTABLE_DYNAMIC: Self = Self {
81        crate_type_override: Some("cdylib"),
82        prefer_dynamic: true,
83        abi_feature: crate::templates::preview_ffi::APPLE_ABI_FEATURE,
84        signature_tag: "preview-cdylib+shared-waterui-dylib+prefer-dynamic",
85    };
86    const ANDROID_DYNAMIC: Self = Self {
87        crate_type_override: Some("cdylib"),
88        prefer_dynamic: true,
89        abi_feature: crate::templates::preview_ffi::ANDROID_ABI_FEATURE,
90        // `std` comes from a `-Zbuild-std` dylib linked 16 KB-aligned, not
91        // from rustup's 4 KB-aligned prebuilt.
92        signature_tag: "preview-cdylib+shared-waterui-dylib+prefer-dynamic+build-std-16k",
93    };
94
95    const fn for_platform(platform: PreviewPlatform) -> Self {
96        match platform {
97            PreviewPlatform::Macos => Self::MACOS_DYNAMIC,
98            PreviewPlatform::Ios | PreviewPlatform::IosSimulator => Self::PORTABLE_DYNAMIC,
99            PreviewPlatform::Android => Self::ANDROID_DYNAMIC,
100        }
101    }
102
103    const fn signature_tag(self) -> &'static str {
104        self.signature_tag
105    }
106
107    fn configure_build(self, build: RustBuild) -> RustBuild {
108        let build = match self.crate_type_override {
109            Some(crate_type) => build.with_crate_type_override(crate_type),
110            None => build,
111        };
112        build.with_feature(self.abi_feature)
113    }
114}
115
116/// A preview session that manages the preview app and TCP connection.
117#[derive(Debug)]
118pub struct PreviewSession {
119    /// TCP client to the preview app.
120    pub client: PreviewAppClient,
121    /// Current platform.
122    pub platform: PreviewPlatform,
123    /// Path to the built dylib (if any).
124    dylib_path: Option<PathBuf>,
125    /// Running instance for apps launched by this session.
126    running: Option<Pin<Box<Running>>>,
127    /// Whether this session owns the app lifecycle.
128    owns_app: bool,
129    /// Optional path to sccache for compilation caching.
130    sccache_path: Option<PathBuf>,
131    /// Runtime fingerprint used for ABI-safe dylib invalidation.
132    runtime_fingerprint: String,
133}
134
135#[derive(Debug, Clone)]
136/// A built dylib payload (stable id + on-disk path).
137pub struct BuiltDylib {
138    /// Stable preview cache id for the dylib payload.
139    pub id: DylibId,
140    /// Path to dylib on disk.
141    pub path: PathBuf,
142}
143
144impl PreviewSession {
145    /// Build the user's project as a dylib.
146    ///
147    /// # Errors
148    /// Returns an error if the project cannot be opened, rebuilt, or fingerprinted.
149    pub async fn build_dylib(&mut self, project_path: &std::path::Path) -> Result<BuiltDylib> {
150        // The build's state machine spans the opened project, the configured
151        // module build and every path they produce, and on Windows it crosses
152        // clippy's `large_futures` threshold (16 KiB). Pinning it on the heap
153        // keeps that off the stack of whoever awaits a preview build.
154        Box::pin(build_preview_dylib(
155            project_path,
156            self.platform,
157            self.sccache_path.as_ref(),
158            &self.runtime_fingerprint,
159            &mut self.dylib_path,
160        ))
161        .await
162    }
163
164    /// Render a preview and return PNG bytes.
165    ///
166    /// # Errors
167    /// Returns an error if the preview app rejects the render or the transport fails.
168    pub async fn render(
169        &mut self,
170        dylib: &BuiltDylib,
171        symbol: &str,
172        width: f32,
173        height: f32,
174    ) -> Result<Vec<u8>> {
175        let prefer_local_path = self.platform == PreviewPlatform::Macos;
176        self.client
177            .render_with_dylib_file(
178                dylib.id,
179                &dylib.path,
180                symbol,
181                width,
182                height,
183                prefer_local_path,
184            )
185            .await
186            .map_err(|e| eyre::eyre!("Preview app error: {e}"))
187    }
188
189    /// Shutdown the preview app if this session launched it.
190    ///
191    /// # Errors
192    /// Returns an error if the support app does not acknowledge the shutdown request.
193    pub async fn shutdown(&mut self) -> Result<()> {
194        if self.owns_app {
195            let result = self.client.shutdown().await;
196            // Dropping `running` will terminate the app if still alive.
197            self.running.take();
198            self.owns_app = false;
199            result?;
200        }
201        Ok(())
202    }
203
204    /// Detach the preview app so it keeps running after this session is dropped.
205    ///
206    /// The app continues running and can be reused by future preview sessions.
207    pub fn detach(&mut self) {
208        if let Some(mut running) = self.running.take() {
209            running.as_mut().detach();
210            self.owns_app = false;
211        }
212    }
213}
214
215/// Configures the module build to compile exactly as the runtime it will be
216/// loaded into was compiled.
217///
218/// The preview wrapper crate lives in the managed build cache, whose generated
219/// sources are regenerated whenever the CLI's scaffold templates move, so its
220/// dependency graph must not be compiled into that regenerated tree.
221///
222/// It goes into the *support app's* shared target directory rather than the
223/// previewed project's. A preview module is loaded into the support app and
224/// resolves its framework symbols against the runtime that app already has open,
225/// so the two have to be the same build of that runtime — not merely the same
226/// source at the same version. Two target directories mean two independent
227/// compilations, each with its own `-C metadata` and therefore its own hash in
228/// every mangled symbol; the module then fails to `dlopen` against a runtime
229/// whose symbols no longer match, even though every input to both builds was
230/// identical.
231///
232/// Cargo folds both the deployment target and the unified feature set into that
233/// same `-C metadata` hash, so a module that disagrees with its host on either
234/// one links against symbols the host does not have.
235/// Returns the configured build and, when the module compiles under a
236/// `-Zbuild-std` toolchain, that toolchain's `rustc -vV` identity so the
237/// module signature can pin the exact compiler — a `rustup update` changes
238/// what `nightly` resolves to without changing its name, and a cached module
239/// built against the previous `libstd-<hash>.so` would fail `dlopen`.
240async fn configure_preview_module_build(
241    preview_crate_path: &Path,
242    platform: PreviewPlatform,
243    target: TargetPlatform,
244    link_mode: PreviewLinkMode,
245) -> Result<(RustBuild, Option<String>)> {
246    let support_project = Project::open(
247        &preview_support_path()?,
248        ManagedBackends::for_platform(target),
249    )
250    .await
251    .wrap_err("Failed to open the preview support project")?;
252    let support_target_dir = support_project
253        .water_target_dir(RustLinkage::SharedRuntime)
254        .await?;
255    let rust_build = link_mode
256        .configure_build(
257            RustBuild::new(preview_crate_path, target.triple()).with_project(&support_project),
258        )
259        .with_target_dir(support_target_dir);
260    if matches!(platform, PreviewPlatform::Android) {
261        let host = crate::toolchain::Host::current();
262        let triple = target.triple();
263        let abi = crate::android::platform::AndroidAbi::from_triple(&triple).ok_or_else(|| {
264            eyre::eyre!("the Android preview module needs a supported ABI; `{triple}` has none")
265        })?;
266        let rust_envs = crate::android::platform::android_rust_build_envs(
267            &host,
268            &support_project,
269            abi,
270            &triple,
271            true,
272        )
273        .await?;
274        // The module dlopens into a process whose `libstd` was built from
275        // source — it has to resolve against that exact dylib, so it builds
276        // under the same nightly, the same `-Zbuild-std` wrapper, and the
277        // same 16 KB page-size link flag as the support app.
278        let nightly = crate::toolchain::rust::nightly_toolchain_with_rust_src(&host).await?;
279        let toolchain_identity =
280            crate::toolchain::rust::rustc_verbose_version(&host, &nightly).await?;
281        Ok((
282            rust_build
283                .with_envs(rust_envs)
284                .with_rustc_flag(crate::android::platform::ANDROID_MAX_PAGE_SIZE_LINK_ARG)
285                .with_build_std(nightly)
286                .with_features(
287                    crate::android::platform::android_ffi_dependency_features(&support_project)
288                        .await?,
289                ),
290            Some(toolchain_identity),
291        ))
292    } else {
293        let browser_runtime = support_project
294            .browser_runtime_plan(target, crate::platform::TargetBackend::Apple)
295            .await?;
296        let (key, value) =
297            crate::apple::platform::apple_deployment_target(&support_project, target)
298                .await
299                .wrap_err("Failed to resolve the preview support deployment target")?;
300        Ok((
301            rust_build.with_env(key, value).with_features(
302                crate::apple::platform::apple_ffi_dependency_features(
303                    &support_project,
304                    browser_runtime,
305                )
306                .await?,
307            ),
308            None,
309        ))
310    }
311}
312
313async fn build_preview_dylib(
314    project_path: &Path,
315    platform: PreviewPlatform,
316    sccache_path: Option<&PathBuf>,
317    runtime_fingerprint: &str,
318    dylib_path: &mut Option<PathBuf>,
319) -> Result<BuiltDylib> {
320    let total_start = Instant::now();
321    let fingerprint_start = Instant::now();
322    let project_inputs = project_inputs_fingerprint(project_path).await?;
323    info!(
324        project_path = %project_path.display(),
325        fingerprint = %project_inputs,
326        elapsed_ms = fingerprint_start.elapsed().as_millis(),
327        "Preview fingerprinted project inputs"
328    );
329
330    let project_open_start = Instant::now();
331    let project = Project::open_for_preview_build(project_path).await?;
332    info!(
333        project_path = %project_path.display(),
334        elapsed_ms = project_open_start.elapsed().as_millis(),
335        "Preview opened project"
336    );
337    // Scaffold rather than assume: this build used to derive the module's path
338    // and trust that some earlier flow had written it, which held only while a
339    // previous preview's module survived in the build cache. The support-app
340    // discard that runs when the runtime checkout changes deletes that cache,
341    // and the next dylib build then spawned cargo in a directory that did not
342    // exist — the "Failed to execute cargo build: No such file or directory"
343    // that hit every first preview after switching workspaces.
344    let scaffold_start = Instant::now();
345    let preview_crate_path = scaffold_preview_module(&project, platform).await?;
346    info!(
347        path = %preview_crate_path.display(),
348        elapsed_ms = scaffold_start.elapsed().as_millis(),
349        "Preview module scaffold is up to date"
350    );
351    let preview_crate_name = project.preview_dylib_crate_name();
352    let target = preview_target_platform(platform);
353    let target_triple = target.triple().to_string();
354    let link_mode = PreviewLinkMode::for_platform(platform);
355
356    ensure_project_dev_feature_for_preview(&project).await?;
357
358    let (rust_build, toolchain_identity) =
359        configure_preview_module_build(&preview_crate_path, platform, target, link_mode).await?;
360    let dylib_path_start = Instant::now();
361    let expected_path = rust_build
362        .dylib_path(preview_crate_name.as_str(), false)
363        .await?;
364    info!(
365        build_crate_path = %preview_crate_path.display(),
366        build_crate_name = %preview_crate_name,
367        path = %expected_path.display(),
368        elapsed_ms = dylib_path_start.elapsed().as_millis(),
369        "Preview resolved dylib path"
370    );
371    let candidate_path = dylib_path.clone().unwrap_or_else(|| expected_path.clone());
372
373    let dylib_signature = dylib_build_signature(
374        project_inputs,
375        runtime_fingerprint,
376        &target_triple,
377        preview_crate_name.as_str(),
378        link_mode,
379        toolchain_identity.as_deref(),
380    );
381    let built_path = if dylib_is_up_to_date(&candidate_path, &dylib_signature).await? {
382        candidate_path
383    } else {
384        build_preview_module_dylib(
385            rust_build,
386            sccache_path,
387            link_mode,
388            platform,
389            &dylib_signature,
390            &preview_crate_path,
391            &preview_crate_name,
392        )
393        .await?
394    };
395
396    *dylib_path = Some(built_path.clone());
397
398    let dylib_id_start = Instant::now();
399    let id = compute_dylib_id(&built_path, &dylib_signature).await?;
400    info!(
401        path = %built_path.display(),
402        elapsed_ms = dylib_id_start.elapsed().as_millis(),
403        total_elapsed_ms = total_start.elapsed().as_millis(),
404        "Preview prepared dylib payload"
405    );
406    Ok(BuiltDylib {
407        id,
408        path: built_path,
409    })
410}
411
412async fn build_preview_module_dylib(
413    mut rust_build: RustBuild,
414    sccache_path: Option<&PathBuf>,
415    link_mode: PreviewLinkMode,
416    platform: PreviewPlatform,
417    dylib_signature: &str,
418    preview_crate_path: &Path,
419    preview_crate_name: &str,
420) -> Result<PathBuf> {
421    info!("Building dylib...");
422    if let Some(sccache) = sccache_path {
423        rust_build = rust_build.with_sccache(sccache.clone());
424    }
425    if link_mode.prefer_dynamic {
426        rust_build = rust_build.with_preferred_dynamic_linking();
427    }
428    let build_start = Instant::now();
429    let built = rust_build
430        .build_dylib(false)
431        .await
432        .wrap_err("Failed to build dylib")?;
433    prepare_preview_module_linkage(&built, link_mode, platform).await?;
434    write_dylib_signature(&built.artifact, dylib_signature).await?;
435    info!(
436        build_crate_path = %preview_crate_path.display(),
437        build_crate_name = %preview_crate_name,
438        path = %built.artifact.display(),
439        elapsed_ms = build_start.elapsed().as_millis(),
440        "Preview built dylib"
441    );
442    Ok(built.artifact)
443}
444
445async fn prepare_preview_module_linkage(
446    built: &BuiltTarget,
447    link_mode: PreviewLinkMode,
448    platform: PreviewPlatform,
449) -> Result<()> {
450    if platform == PreviewPlatform::Android {
451        // The module is pushed to a device that may run 16 KB pages; a
452        // 4 KB-aligned LOAD segment would fail `dlopen` there.
453        return smol::unblock({
454            let built_path = built.artifact.clone();
455            move || crate::elf::require_aligned_load_segments(&built_path)
456        })
457        .await;
458    }
459    if !link_mode.prefer_dynamic {
460        return Ok(());
461    }
462    dynamic_runtime::retarget_module(&built.artifact, built.shared_runtime()?).await
463}
464
465async fn ensure_project_dev_feature_for_preview(project: &Project) -> Result<()> {
466    let manifest_path = project.root().join("Cargo.toml");
467    let manifest = smol::unblock(move || CargoManifest::from_path(&manifest_path)).await?;
468    let Some(dev_features) = manifest.features.get("dev") else {
469        bail!(
470            "Preview requires `{}/dev` feature. Add `[features] dev = [\"waterui/dynamic_linking\"]` to {}",
471            project.crate_name().as_str(),
472            project.root().join("Cargo.toml").display()
473        );
474    };
475    if !dev_features
476        .iter()
477        .any(|feature| feature == "waterui/dynamic_linking")
478    {
479        bail!(
480            "Preview requires `{}/dev` to include `waterui/dynamic_linking`. Update {}",
481            project.crate_name().as_str(),
482            project.root().join("Cargo.toml").display()
483        );
484    }
485    Ok(())
486}
487
488fn dylib_signature_path(path: &Path) -> PathBuf {
489    let mut raw = path.as_os_str().to_os_string();
490    raw.push(PREVIEW_DYLIB_METADATA_SUFFIX);
491    PathBuf::from(raw)
492}
493
494fn dylib_build_signature(
495    project_inputs: ProjectInputsFingerprint,
496    runtime_fingerprint: &str,
497    target_triple: &str,
498    crate_name: &str,
499    link_mode: PreviewLinkMode,
500    toolchain_identity: Option<&str>,
501) -> String {
502    let link_mode = link_mode.signature_tag();
503    let toolchain = toolchain_identity.unwrap_or("ambient");
504    format!(
505        "inputs={project_inputs}\nruntime={runtime_fingerprint}\ntarget={target_triple}\ncrate={crate_name}\nlink_mode={link_mode}\ntoolchain={toolchain}"
506    )
507}
508
509fn preview_run_options(platform: PreviewPlatform) -> RunOptions {
510    let mut run_options = RunOptions::new();
511    run_options.set_replace_existing_macos_app_instances(false);
512    run_options.set_log_level(LogLevel::Info);
513    if platform != PreviewPlatform::Android {
514        // Point the support app's registry at the cache directory the CLI
515        // watches. This is a host path: on Android it would resolve inside the
516        // app's sandbox to a location it cannot create, and the server task
517        // would exit right after binding. The Android template installs its
518        // own on-device default instead.
519        let preview_cache_root = waterui_preview_protocol::registry::preview_cache_root_dir();
520        let water_cache_dir = preview_cache_root.parent().unwrap_or_else(|| {
521            panic!(
522                "preview cache root must have a parent directory: {}",
523                preview_cache_root.display()
524            )
525        });
526        run_options.insert_env_var(
527            "WATER_CACHE_DIR".to_string(),
528            water_cache_dir.display().to_string(),
529        );
530    }
531    for (key, value) in PREVIEW_RUNTIME_ENV_VARS {
532        run_options.insert_env_var(key.to_string(), value.to_string());
533    }
534    if let Some(rust_log) = std::env::var_os("RUST_LOG") {
535        run_options.insert_env_var(
536            "RUST_LOG".to_string(),
537            rust_log.to_string_lossy().into_owned(),
538        );
539    }
540    run_options
541}
542
543async fn write_dylib_signature(path: &Path, signature: &str) -> Result<()> {
544    let signature_path = dylib_signature_path(path);
545    smol::fs::write(signature_path, signature.as_bytes()).await?;
546    Ok(())
547}
548
549async fn dylib_is_up_to_date(path: &std::path::Path, expected_signature: &str) -> Result<bool> {
550    match smol::fs::metadata(path).await {
551        Ok(_) => {}
552        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
553        Err(e) => return Err(e.into()),
554    }
555
556    let signature_path = dylib_signature_path(path);
557    let stored_signature = match smol::fs::read_to_string(&signature_path).await {
558        Ok(text) => text,
559        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
560        Err(e) => return Err(e.into()),
561    };
562
563    Ok(stored_signature.trim() == expected_signature)
564}
565
566async fn compute_dylib_id(path: &Path, build_signature: &str) -> Result<DylibId> {
567    let path = path.to_path_buf();
568    let build_signature = build_signature.to_string();
569    smol::unblock(move || {
570        let metadata = std::fs::metadata(&path)?;
571        let modified = metadata.modified()?;
572        let mut hasher = sha2::Sha256::new();
573        hasher.update(build_signature.as_bytes());
574        hasher.update([0]);
575        hasher.update(path.to_string_lossy().as_bytes());
576        hasher.update([0]);
577        hasher.update(metadata.len().to_le_bytes());
578
579        match modified.duration_since(UNIX_EPOCH) {
580            Ok(duration) => {
581                hasher.update([0]);
582                hasher.update(duration.as_secs().to_le_bytes());
583                hasher.update(duration.subsec_nanos().to_le_bytes());
584            }
585            Err(err) => {
586                hasher.update([1]);
587                hasher.update(err.duration().as_secs().to_le_bytes());
588                hasher.update(err.duration().subsec_nanos().to_le_bytes());
589            }
590        }
591
592        let hash: [u8; 32] = hasher.finalize().into();
593        Ok(DylibId::from_bytes(hash))
594    })
595    .await
596}
597
598/// Launch a preview session for the given platform.
599///
600/// This will:
601/// 1. Try to connect to an existing preview app via TCP
602/// 2. If not found, scaffold and launch the preview app
603/// 3. Wait for TCP connection
604///
605/// # Arguments
606/// * `platform` - Target platform for preview
607/// * `sccache_path` - Optional path to sccache for compilation caching
608///
609/// # Errors
610/// Returns an error if the preview app cannot be launched or connected.
611pub async fn launch_preview_session(
612    project_path: &Path,
613    platform: PreviewPlatform,
614    sccache_path: Option<PathBuf>,
615    progress: Option<BuildProgress>,
616) -> Result<PreviewSession> {
617    let requirements_start = Instant::now();
618    let requirements = resolve_preview_requirements(project_path, platform).await?;
619    info!(
620        project_path = %project_path.display(),
621        elapsed_ms = requirements_start.elapsed().as_millis(),
622        "Preview resolved runtime requirements"
623    );
624    let expected_fingerprint = requirements.runtime_fingerprint.clone();
625    let tcp_config = PreviewTcpConfig::from_env()
626        .map_err(|e| eyre::eyre!(e))
627        .wrap_err("Invalid preview TCP config")?;
628
629    let connect_start = Instant::now();
630    if let Some(session) = try_connect_existing_preview_app(
631        tcp_config,
632        &expected_fingerprint,
633        platform,
634        sccache_path.clone(),
635    )
636    .await?
637    {
638        info!(
639            elapsed_ms = connect_start.elapsed().as_millis(),
640            "Preview reused existing support app"
641        );
642        return Ok(session);
643    }
644
645    let project = open_preview_support_project(&requirements, platform).await?;
646    let running =
647        launch_preview_app_for_platform(&project, platform, tcp_config, progress.as_ref()).await?;
648    build_preview_session_from_launch(
649        running,
650        platform,
651        tcp_config,
652        expected_fingerprint,
653        sccache_path,
654    )
655    .await
656}
657
658async fn try_connect_existing_preview_app(
659    tcp_config: PreviewTcpConfig,
660    expected_fingerprint: &str,
661    platform: PreviewPlatform,
662    sccache_path: Option<PathBuf>,
663) -> Result<Option<PreviewSession>> {
664    let probe = match platform {
665        PreviewPlatform::Macos => {
666            PreviewAppClient::probe_registered(expected_fingerprint, PreviewRuntimePlatform::Macos)
667                .await?
668        }
669        PreviewPlatform::IosSimulator | PreviewPlatform::Ios | PreviewPlatform::Android => {
670            PreviewAppClient::probe_ports(
671                tcp_config,
672                expected_fingerprint,
673                preview_runtime_platform(platform),
674            )
675            .await
676        }
677    };
678    let client = match probe {
679        PreviewProbe::Connected(client) => client,
680        // Not an error here: a support app from another checkout is exactly the
681        // case this function exists to decline, and the caller goes on to launch
682        // one that matches. Saying so keeps the launch from looking unexplained.
683        PreviewProbe::Rejected(reason) => {
684            info!("Not reusing the running preview app: {reason}");
685            return Ok(None);
686        }
687        PreviewProbe::Silent => return Ok(None),
688    };
689
690    info!("Connected to existing preview app");
691    Ok(Some(PreviewSession {
692        client,
693        platform,
694        dylib_path: None,
695        running: None,
696        owns_app: false,
697        sccache_path,
698        runtime_fingerprint: expected_fingerprint.to_string(),
699    }))
700}
701
702const fn preview_runtime_platform(platform: PreviewPlatform) -> PreviewRuntimePlatform {
703    match platform {
704        PreviewPlatform::Macos => PreviewRuntimePlatform::Macos,
705        PreviewPlatform::IosSimulator => PreviewRuntimePlatform::IosSimulator,
706        PreviewPlatform::Ios => PreviewRuntimePlatform::Ios,
707        PreviewPlatform::Android => PreviewRuntimePlatform::Android,
708    }
709}
710
711/// The build target a preview on `platform` links its module for.
712const fn preview_target_platform(platform: PreviewPlatform) -> TargetPlatform {
713    match platform {
714        PreviewPlatform::Macos => TargetPlatform::MacOS,
715        PreviewPlatform::IosSimulator => TargetPlatform::IOSSimulator,
716        PreviewPlatform::Ios => TargetPlatform::IOS,
717        PreviewPlatform::Android => TargetPlatform::Android,
718    }
719}
720
721async fn open_preview_support_project(
722    requirements: &PreviewRequirements,
723    platform: PreviewPlatform,
724) -> Result<Project> {
725    info!("No preview app running, launching...");
726    let preview_app_path = preview_support_path()?;
727    let ensure_start = Instant::now();
728    ensure_preview_support_app(&preview_app_path, requirements).await?;
729    info!(
730        path = %preview_app_path.display(),
731        elapsed_ms = ensure_start.elapsed().as_millis(),
732        "Preview support app scaffold is up to date"
733    );
734    let open_start = Instant::now();
735    let project = Project::open(
736        &preview_app_path,
737        ManagedBackends::for_platform(preview_target_platform(platform)),
738    )
739    .await
740    .wrap_err("Failed to open preview app project")?;
741    info!(
742        path = %preview_app_path.display(),
743        elapsed_ms = open_start.elapsed().as_millis(),
744        "Preview support project opened"
745    );
746    Ok(project)
747}
748
749async fn launch_preview_app_for_platform(
750    project: &Project,
751    platform: PreviewPlatform,
752    tcp_config: PreviewTcpConfig,
753    progress: Option<&BuildProgress>,
754) -> Result<Running> {
755    match platform {
756        PreviewPlatform::Macos => launch_preview_on_macos(project, progress).await,
757        PreviewPlatform::IosSimulator => launch_preview_on_ios_simulator(project, progress).await,
758        PreviewPlatform::Ios => {
759            bail!("Physical iOS devices are not yet supported for preview");
760        }
761        PreviewPlatform::Android => launch_preview_on_android(project, tcp_config, progress).await,
762    }
763}
764
765async fn launch_preview_on_macos(
766    project: &Project,
767    progress: Option<&BuildProgress>,
768) -> Result<Running> {
769    let backend = project
770        .apple_backend()
771        .ok_or_else(|| eyre::eyre!("Apple backend not configured"))?;
772    let host = crate::toolchain::Host::current();
773    let device = Local;
774    device.launch(&host).await?;
775    info!("Building and running preview app on macOS...");
776    project
777        .run_with_options(
778            backend,
779            TargetPlatform::MacOS,
780            device,
781            preview_run_options(PreviewPlatform::Macos),
782            progress.cloned(),
783        )
784        .await
785        .map_err(|e| eyre::eyre!("Failed to run preview app: {e}"))
786}
787
788async fn launch_preview_on_ios_simulator(
789    project: &Project,
790    progress: Option<&BuildProgress>,
791) -> Result<Running> {
792    let backend = project
793        .apple_backend()
794        .ok_or_else(|| eyre::eyre!("Apple backend not configured"))?;
795    let host = crate::toolchain::Host::current();
796    let simulator = crate::apple::device::AppleSimulator::select_ios(&host, project, None).await?;
797    simulator.launch(&host).await?;
798    info!("Building and running preview app on iOS Simulator...");
799    project
800        .run_with_options(
801            backend,
802            TargetPlatform::IOSSimulator,
803            simulator,
804            preview_run_options(PreviewPlatform::IosSimulator),
805            progress.cloned(),
806        )
807        .await
808        .map_err(|e| eyre::eyre!("Failed to run preview app: {e}"))
809}
810
811async fn launch_preview_on_android(
812    project: &Project,
813    tcp_config: PreviewTcpConfig,
814    progress: Option<&BuildProgress>,
815) -> Result<Running> {
816    let backend = project
817        .android_backend()
818        .ok_or_else(|| eyre::eyre!("Android backend not configured"))?;
819    let host = crate::toolchain::Host::current();
820
821    let mut run_options = preview_run_options(PreviewPlatform::Android);
822    // The support app's TCP server binds the device's loopback; forward every
823    // candidate port so the CLI's probe reaches it.
824    run_options.set_forward_tcp_ports(tcp_config.ports());
825
826    if let Some(device) = crate::android::device::AndroidDevice::scan(&host)
827        .await?
828        .into_iter()
829        .next()
830    {
831        device.launch(&host).await?;
832        info!("Building and running preview app on Android device...");
833        return project
834            .run_android_with_options(
835                backend,
836                device,
837                run_options,
838                // The preview support app dlopens the pushed module, so the
839                // shared Rust runtime must be built and packaged.
840                BuildOptions::development(BuildProfile::Debug).with_dynamic_module_loading(),
841                progress.cloned(),
842            )
843            .await
844            .map_err(|e| eyre::eyre!("Failed to run preview app: {e}"));
845    }
846
847    let avd_name = crate::android::platform::AndroidPlatform::list_avds(&host)
848        .await?
849        .into_iter()
850        .next()
851        .ok_or_else(|| eyre::eyre!("No Android devices or emulators available."))?;
852    let emulator = crate::android::device::AndroidEmulator::open(&host, avd_name).await?;
853    emulator.launch(&host).await?;
854    info!("Building and running preview app on Android emulator...");
855    project
856        .run_android_with_options(
857            backend,
858            emulator,
859            run_options,
860            BuildOptions::development(BuildProfile::Debug).with_dynamic_module_loading(),
861            progress.cloned(),
862        )
863        .await
864        .map_err(|e| eyre::eyre!("Failed to run preview app: {e}"))
865}
866
867async fn build_preview_session_from_launch(
868    running: Running,
869    platform: PreviewPlatform,
870    tcp_config: PreviewTcpConfig,
871    expected_fingerprint: String,
872    sccache_path: Option<PathBuf>,
873) -> Result<PreviewSession> {
874    info!("Preview app launched, waiting for TCP connection...");
875    let mut running = Box::pin(running);
876    match wait_for_connection_or_crash(&mut running, platform, tcp_config, &expected_fingerprint)
877        .await
878    {
879        ConnectionWaitResult::Ready(client) => Ok(PreviewSession {
880            client,
881            platform,
882            dylib_path: None,
883            running: Some(running),
884            owns_app: true,
885            sccache_path,
886            runtime_fingerprint: expected_fingerprint,
887        }),
888        ConnectionWaitResult::Crashed(message) => {
889            bail!(
890                "Preview app crashed:
891{message}"
892            );
893        }
894        ConnectionWaitResult::Exited => {
895            bail!(
896                "Preview app exited unexpectedly.
897Check the app logs for more information."
898            );
899        }
900        // An app that answered and was turned away is not a connection problem,
901        // and listing connection problems in front of it is how this timeout
902        // once sent two debugging sessions at the network.
903        ConnectionWaitResult::Timeout(Some(rejection)) => {
904            bail!(
905                "Preview app started but no compatible app ever answered within {} seconds.
906{rejection}",
907                STARTUP_DEADLINE.as_secs()
908            );
909        }
910        ConnectionWaitResult::Timeout(None) => {
911            bail!(
912                "Preview app is still running after {} seconds but never accepted a connection.
913Possible causes:
914- The TCP server failed to start
915- Port range {}..={} may be blocked
916- The app is stuck during initialization
917
918Try running with WATERUI_CRASH_DEBUG=1 for more details.",
919                STARTUP_DEADLINE.as_secs(),
920                tcp_config.port_start,
921                tcp_config.ports().end()
922            );
923        }
924    }
925}
926
927/// Result of waiting for preview-app readiness.
928enum ConnectionWaitResult {
929    /// Preview app accepted a connection and completed the protocol handshake.
930    Ready(PreviewAppClient),
931    /// App crashed with error message.
932    Crashed(String),
933    /// App exited without crash.
934    Exited,
935    /// The app stayed alive but never became reachable before the hang backstop.
936    ///
937    /// Carries the explanation of an app that answered and was turned away, when
938    /// one did: that is a different failure from silence and has to be reported
939    /// as itself.
940    Timeout(Option<String>),
941}
942
943/// How long a launched preview app may stay alive without ever becoming reachable.
944///
945/// This is a backstop against a wedged process, not a judgement about how fast a
946/// preview app "should" start. Readiness is decided by real signals — the registry
947/// entry the app publishes, its listening-address log line, and its crash/exit
948/// events — so a slow but healthy launch is waited out rather than failed. An
949/// earlier 10s budget sat right on top of the ~10.2s cold start of a debug support
950/// app and lost the race by milliseconds, killing an app that was about to work.
951const STARTUP_DEADLINE: Duration = Duration::from_mins(3);
952
953/// Wait for TCP connection while monitoring for app crashes.
954///
955/// macOS support apps publish a registry entry once the TCP server is ready, so wait on that
956/// concrete readiness signal instead of sleeping between blind connection retries.
957async fn wait_for_connection_or_crash(
958    running: &mut Pin<Box<Running>>,
959    platform: PreviewPlatform,
960    tcp_config: PreviewTcpConfig,
961    expected_fingerprint: &str,
962) -> ConnectionWaitResult {
963    const NON_MACOS_POLL_INTERVAL: Duration = Duration::from_millis(100);
964
965    let start = Instant::now();
966
967    let ready = match platform {
968        PreviewPlatform::Macos => {
969            wait_for_registered_preview_ready(
970                running,
971                expected_fingerprint,
972                start,
973                STARTUP_DEADLINE,
974            )
975            .await
976        }
977        PreviewPlatform::IosSimulator | PreviewPlatform::Ios | PreviewPlatform::Android => {
978            wait_for_polled_preview_ready(
979                running,
980                tcp_config,
981                expected_fingerprint,
982                preview_runtime_platform(platform),
983                start,
984                STARTUP_DEADLINE,
985                NON_MACOS_POLL_INTERVAL,
986            )
987            .await
988        }
989    };
990
991    match ready {
992        ConnectionWaitResult::Timeout(rejection) => {
993            drain_terminal_preview_event(running, rejection).await
994        }
995        other => other,
996    }
997}
998
999async fn wait_for_registered_preview_ready(
1000    running: &mut Pin<Box<Running>>,
1001    expected_fingerprint: &str,
1002    start: Instant,
1003    timeout: Duration,
1004) -> ConnectionWaitResult {
1005    const POLL_INTERVAL: Duration = Duration::from_millis(100);
1006
1007    // The one app that answered and was turned away outlives every silent poll:
1008    // on timeout it is the only thing here that explains anything.
1009    let mut rejection = None;
1010
1011    match probe_registered_preview(expected_fingerprint, PreviewRuntimePlatform::Macos, start).await
1012    {
1013        PreviewProbe::Connected(client) => return ConnectionWaitResult::Ready(client),
1014        PreviewProbe::Rejected(reason) => rejection = Some(reason),
1015        PreviewProbe::Silent => {}
1016    }
1017
1018    let registry_dir = preview_instance_registry_dir();
1019    if let Err(error) = smol::fs::create_dir_all(&registry_dir).await {
1020        error!(path = %registry_dir.display(), "Failed to create preview registry dir: {error}");
1021        return ConnectionWaitResult::Timeout(rejection);
1022    }
1023
1024    #[cfg(feature = "preview")]
1025    let (event_rx, _watcher) = {
1026        let (event_tx, event_rx) = async_channel::unbounded();
1027        let mut watcher = match notify::recommended_watcher(move |result| {
1028            let _ = event_tx.try_send(result);
1029        }) {
1030            Ok(watcher) => watcher,
1031            Err(error) => {
1032                error!(path = %registry_dir.display(), "Failed to create preview registry watcher: {error}");
1033                return ConnectionWaitResult::Timeout(rejection);
1034            }
1035        };
1036        if let Err(error) = watcher.watch(&registry_dir, RecursiveMode::NonRecursive) {
1037            error!(path = %registry_dir.display(), "Failed to watch preview registry dir: {error}");
1038            return ConnectionWaitResult::Timeout(rejection);
1039        }
1040        (event_rx, watcher)
1041    };
1042
1043    loop {
1044        match probe_registered_preview(expected_fingerprint, PreviewRuntimePlatform::Macos, start)
1045            .await
1046        {
1047            PreviewProbe::Connected(client) => return ConnectionWaitResult::Ready(client),
1048            PreviewProbe::Rejected(reason) => rejection = Some(reason),
1049            PreviewProbe::Silent => {}
1050        }
1051
1052        let remaining = timeout.saturating_sub(start.elapsed());
1053        if remaining.is_zero() {
1054            return ConnectionWaitResult::Timeout(rejection);
1055        }
1056
1057        let sleep = futures_util::FutureExt::fuse(smol::Timer::after(POLL_INTERVAL.min(remaining)));
1058        let running_event = running.next().fuse();
1059        #[cfg(feature = "preview")]
1060        let registry_event = futures_util::FutureExt::fuse(event_rx.recv());
1061        #[cfg(not(feature = "preview"))]
1062        let registry_event = futures_util::FutureExt::fuse(futures_util::future::pending::<()>());
1063        pin_mut!(sleep);
1064        pin_mut!(running_event);
1065        pin_mut!(registry_event);
1066
1067        select! {
1068            event = running_event => {
1069                if let Some(result) = preview_connection_result_from_device_event(
1070                    event,
1071                    expected_fingerprint,
1072                    PreviewRuntimePlatform::Macos,
1073                    start,
1074                    &mut rejection,
1075                )
1076                .await
1077                {
1078                    return result;
1079                }
1080            },
1081            event = registry_event => {
1082                #[cfg(feature = "preview")]
1083                match event {
1084                    Ok(Ok(_notification)) => {}
1085                    Ok(Err(error)) => {
1086                        error!(path = %registry_dir.display(), "Preview registry watcher error: {error}");
1087                    }
1088                    Err(_) => return ConnectionWaitResult::Timeout(rejection),
1089                }
1090                #[cfg(not(feature = "preview"))]
1091                let () = event;
1092            },
1093            _ = sleep => {}
1094        }
1095    }
1096}
1097
1098async fn wait_for_polled_preview_ready(
1099    running: &mut Pin<Box<Running>>,
1100    tcp_config: PreviewTcpConfig,
1101    expected_fingerprint: &str,
1102    expected_platform: PreviewRuntimePlatform,
1103    start: Instant,
1104    timeout: Duration,
1105    poll_interval: Duration,
1106) -> ConnectionWaitResult {
1107    let mut rejection = None;
1108
1109    loop {
1110        match probe_polled_preview(tcp_config, expected_fingerprint, expected_platform, start).await
1111        {
1112            PreviewProbe::Connected(client) => return ConnectionWaitResult::Ready(client),
1113            PreviewProbe::Rejected(reason) => rejection = Some(reason),
1114            PreviewProbe::Silent => {}
1115        }
1116
1117        let remaining = timeout.saturating_sub(start.elapsed());
1118        if remaining.is_zero() {
1119            return ConnectionWaitResult::Timeout(rejection);
1120        }
1121
1122        let sleep = futures_util::FutureExt::fuse(smol::Timer::after(poll_interval.min(remaining)));
1123        let running_event = running.next().fuse();
1124        pin_mut!(sleep);
1125        pin_mut!(running_event);
1126
1127        select! {
1128            event = running_event => {
1129                if let Some(result) = preview_connection_result_from_device_event(
1130                    event,
1131                    expected_fingerprint,
1132                    expected_platform,
1133                    start,
1134                    &mut rejection,
1135                )
1136                .await
1137                {
1138                    return result;
1139                }
1140            },
1141            _ = sleep => {}
1142        }
1143    }
1144}
1145
1146/// Probe the registry for a ready preview app, keeping the connection it establishes.
1147///
1148/// The probe completes a full protocol handshake, so discarding the client and
1149/// reconnecting afterwards would pay for that handshake twice and reopen the window
1150/// for the app to go away in between.
1151async fn probe_registered_preview(
1152    expected_fingerprint: &str,
1153    expected_platform: PreviewRuntimePlatform,
1154    start: Instant,
1155) -> PreviewProbe {
1156    match PreviewAppClient::probe_registered(expected_fingerprint, expected_platform).await {
1157        Ok(PreviewProbe::Connected(client)) => {
1158            info!(
1159                "Connected to preview app after {}ms",
1160                start.elapsed().as_millis()
1161            );
1162            PreviewProbe::Connected(client)
1163        }
1164        Ok(other) => other,
1165        Err(error) => {
1166            error!("Failed to read the preview instance registry: {error}");
1167            PreviewProbe::Silent
1168        }
1169    }
1170}
1171
1172/// Probe the configured port range for a ready preview app, keeping the connection.
1173async fn probe_polled_preview(
1174    tcp_config: PreviewTcpConfig,
1175    expected_fingerprint: &str,
1176    expected_platform: PreviewRuntimePlatform,
1177    start: Instant,
1178) -> PreviewProbe {
1179    let probe =
1180        PreviewAppClient::probe_ports(tcp_config, expected_fingerprint, expected_platform).await;
1181    if matches!(probe, PreviewProbe::Connected(_)) {
1182        info!(
1183            "Connected to preview app after {}ms",
1184            start.elapsed().as_millis()
1185        );
1186    }
1187    probe
1188}
1189
1190async fn preview_connection_result_from_device_event(
1191    event: Option<DeviceEvent>,
1192    expected_fingerprint: &str,
1193    expected_platform: PreviewRuntimePlatform,
1194    start: Instant,
1195    rejection: &mut Option<String>,
1196) -> Option<ConnectionWaitResult> {
1197    match event? {
1198        DeviceEvent::Crashed(message) => {
1199            info!("App crashed after {}ms", start.elapsed().as_millis());
1200            Some(ConnectionWaitResult::Crashed(message))
1201        }
1202        DeviceEvent::Exited(_) => {
1203            info!("App exited after {}ms", start.elapsed().as_millis());
1204            Some(ConnectionWaitResult::Exited)
1205        }
1206        DeviceEvent::Log { level, message } => {
1207            info!("Preview app log event: {message}");
1208            if level == tracing::Level::ERROR {
1209                error!("{message}");
1210            }
1211            if let Some(addr) = parse_preview_listening_addr(&message) {
1212                match PreviewAppClient::probe_addr(addr, expected_fingerprint, expected_platform)
1213                    .await
1214                {
1215                    PreviewProbe::Connected(client) => {
1216                        info!(
1217                            "Connected to preview app after {}ms",
1218                            start.elapsed().as_millis()
1219                        );
1220                        return Some(ConnectionWaitResult::Ready(client));
1221                    }
1222                    // The app this launch just started announced its own port and
1223                    // is the wrong build: that is the finding, and the wait keeps
1224                    // it so the deadline can report it instead of guessing.
1225                    PreviewProbe::Rejected(reason) => *rejection = Some(reason),
1226                    PreviewProbe::Silent => {}
1227                }
1228            }
1229            None
1230        }
1231        _ => None,
1232    }
1233}
1234
1235fn parse_preview_listening_addr(message: &str) -> Option<SocketAddr> {
1236    const PREFIX: &str = "Preview support app listening on ";
1237    let suffix = message.split(PREFIX).nth(1)?;
1238    let port = suffix.rsplit(':').next()?.trim().parse::<u16>().ok()?;
1239    Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port))
1240}
1241
1242async fn drain_terminal_preview_event(
1243    running: &mut Pin<Box<Running>>,
1244    rejection: Option<String>,
1245) -> ConnectionWaitResult {
1246    while let Some(event) = futures_lite::future::poll_once(running.as_mut().next())
1247        .await
1248        .flatten()
1249    {
1250        match event {
1251            DeviceEvent::Crashed(message) => return ConnectionWaitResult::Crashed(message),
1252            DeviceEvent::Exited(_) => return ConnectionWaitResult::Exited,
1253            _ => {}
1254        }
1255    }
1256
1257    ConnectionWaitResult::Timeout(rejection)
1258}
1259
1260/// Get the path to the preview support app.
1261fn preview_support_path() -> Result<PathBuf> {
1262    support_app::support_app_path("preview_support")
1263}
1264
1265/// Root of the workspace a preview module joins.
1266///
1267/// This is the support runtime's generated FFI crate. The path is derived rather
1268/// than read from an opened [`Project`] because the module has to exist before the
1269/// support application is scaffolded: resolving the runtime's requirements reads
1270/// the module's own Cargo metadata.
1271async fn preview_support_ffi_crate_path() -> Result<PathBuf> {
1272    // The support application's root has to exist before its build-cache path can
1273    // be derived, because deriving it canonicalizes the root. On the very first
1274    // preview nothing has scaffolded it yet, and an empty directory is exactly
1275    // what the scaffolder expects to find.
1276    let support_path = preview_support_path()?;
1277    smol::fs::create_dir_all(&support_path)
1278        .await
1279        .wrap_err("Failed to create the preview support application directory")?;
1280    // The cache is brought into shape here, where the path into it is first
1281    // handed out, and not left to whoever opens the support project later. A
1282    // managed cache built by a different CLI is emptied when its shape is
1283    // checked, and the check used to land *after* the preview module had been
1284    // written into it: the module was deleted out from under the `cargo
1285    // metadata` that reads it, and the first preview after any change to the
1286    // CLI failed with a manifest path that does not exist.
1287    Ok(crate::water_dir::ensure_project_build_cache(&support_path)
1288        .await?
1289        .join("ffi"))
1290}
1291
1292/// Write the project's preview module into the support runtime's workspace.
1293///
1294/// Only one module lives there at a time. A module left behind by a previously
1295/// previewed project would still be a workspace member, and Cargo resolves every
1296/// member of a workspace, so a stale one whose project has since moved or been
1297/// deleted breaks the build of an unrelated preview.
1298async fn scaffold_preview_module(project: &Project, platform: PreviewPlatform) -> Result<PathBuf> {
1299    let support_path = preview_support_path()?;
1300    // Before anything reads the support runtime's workspace: one left over from
1301    // a different `WaterUI` checkout points its manifests at a path that may no
1302    // longer exist, and reading it fails before the scaffolder gets a chance to
1303    // notice and rebuild.
1304    // The project's recorded runtime path is written relative to the project,
1305    // so it is resolved against the project rather than against wherever the
1306    // CLI happens to have been invoked from.
1307    let runtime_path = project
1308        .manifest()
1309        .waterui_path
1310        .as_deref()
1311        .map(|path| project.root().join(path));
1312    support_app::discard_support_app_for_other_runtime(&support_path, runtime_path.as_deref())
1313        .await?;
1314    let workspace_root = preview_support_ffi_crate_path().await?;
1315    let modules_root = workspace_root.join(crate::templates::PREVIEW_MODULES_DIR);
1316    let crate_path = project.preview_dylib_crate_path(&workspace_root);
1317    if let Ok(mut entries) = smol::fs::read_dir(&modules_root).await {
1318        use smol::stream::StreamExt as _;
1319        while let Some(entry) = entries.next().await {
1320            let entry = entry.wrap_err("Failed to read preview modules directory")?;
1321            if entry.path() != crate_path {
1322                smol::fs::remove_dir_all(entry.path())
1323                    .await
1324                    .wrap_err("Failed to remove a stale preview module")?;
1325            }
1326        }
1327    }
1328    let crate_path = project
1329        .scaffold_preview_ffi_companion(&workspace_root)
1330        .await
1331        .wrap_err("Failed to scaffold the preview module")?;
1332
1333    // Then refresh the support runtime, so the manifest that roots this workspace
1334    // is rewritten with the module now on disk. A module under a root that does
1335    // not declare it is rejected outright by Cargo, and the root lists whichever
1336    // modules it finds — so it has to be written after, never before.
1337    if support_path.join("Water.toml").is_file() {
1338        Project::open(
1339            &support_path,
1340            ManagedBackends::for_platform(preview_target_platform(platform)),
1341        )
1342        .await
1343        .wrap_err("Failed to open the preview support project")?;
1344    }
1345    Ok(crate_path)
1346}
1347
1348/// Ensure the preview support app exists and matches the current project requirements.
1349async fn ensure_preview_support_app(path: &Path, requirements: &PreviewRequirements) -> Result<()> {
1350    let desired_signature = preview_signature(requirements);
1351    let scaffold_path = path.to_path_buf();
1352    let scaffold_requirements = requirements.clone();
1353    support_app::ensure_support_app(
1354        path,
1355        PREVIEW_METADATA_FILE,
1356        &desired_signature,
1357        "preview support",
1358        move || async move { scaffold_preview_app(&scaffold_path, &scaffold_requirements).await },
1359    )
1360    .await
1361}
1362
1363/// Scaffold the preview support app as a normal playground project.
1364async fn scaffold_preview_app(path: &Path, requirements: &PreviewRequirements) -> Result<()> {
1365    use crate::project::{CreateOptions, Manifest as WaterManifest, PackageType};
1366    use crate::templates::TemplateContext;
1367
1368    let waterui_path = requirements.waterui_path.clone();
1369
1370    let options = CreateOptions {
1371        name: "WaterUI Preview".to_string(),
1372        bundle_identifier: crate::project_types::BundleIdentifier::try_from("dev.waterui.preview")
1373            .expect("preview support bundle identifier must be valid"),
1374        package_type: PackageType::Playground,
1375        waterui_path: waterui_path.clone(),
1376        channel: None,
1377        framework_manifest: None,
1378        framework: None,
1379        author: String::new(),
1380        backends: Vec::new(),
1381        web: None,
1382    };
1383
1384    // Create as normal playground project
1385    let project = Project::create(path, options)
1386        .await
1387        .map_err(|e| eyre::eyre!("Failed to create preview app: {e}"))?;
1388
1389    // Mark the preview app as accessory/headless.
1390    let mut manifest = WaterManifest::open(project.root().join("Water.toml")).await?;
1391    manifest.package.accessory = true;
1392    // The support app hosts the preview TCP server on-device; binding a socket
1393    // requires INTERNET in its manifest regardless of what the previewed app
1394    // declares.
1395    manifest.permissions.insert(
1396        crate::project_types::PermissionKey::Internet,
1397        crate::project::PermissionEntry::enabled(
1398            "Hosts the preview TCP server that the CLI connects to",
1399        ),
1400    );
1401    manifest.save(project.root()).await?;
1402
1403    let ctx = TemplateContext::for_support_playground(
1404        "WaterUI Preview",
1405        project.crate_name().clone(),
1406        crate::project_types::BundleIdentifier::try_from("dev.waterui.preview")
1407            .expect("preview support bundle identifier must be valid"),
1408        waterui_path,
1409        &project.resolved_framework().await?,
1410        true,
1411        Some(requirements.runtime_fingerprint.clone()),
1412    )
1413    .with_preview_runtime_features(requirements.runtime_features.clone())
1414    .with_preview_app_dependency(
1415        requirements.app_crate_name.clone(),
1416        requirements.app_path.clone(),
1417    );
1418
1419    crate::templates::preview::scaffold(project.root(), &ctx)
1420        .await
1421        .wrap_err("Failed to scaffold embedded preview app template")?;
1422
1423    info!("Preview app scaffolded at {}", path.display());
1424    Ok(())
1425}
1426
1427fn preview_signature(requirements: &PreviewRequirements) -> String {
1428    format!(
1429        "template_commit={PREVIEW_TEMPLATE_COMMIT}\nscaffold_generation={PREVIEW_SCAFFOLD_GENERATION}\nwaterui_dependency={}\nruntime_fingerprint={}\ntemplate_fingerprint={}",
1430        requirements.waterui_path.as_ref().map_or_else(
1431            || String::from("registry"),
1432            |path| path.display().to_string()
1433        ),
1434        requirements.runtime_fingerprint,
1435        crate::templates::preview::template_fingerprint(),
1436    )
1437}
1438
1439async fn resolve_preview_requirements(
1440    project_path: &Path,
1441    platform: PreviewPlatform,
1442) -> Result<PreviewRequirements> {
1443    let resolved = resolve_preview_metadata(project_path, platform).await?;
1444    let metadata = &resolved.metadata;
1445    let waterui = select_unique_package(metadata, "waterui")?;
1446    let runtime_features = resolved_package_features(metadata, waterui)?;
1447    let graph_fingerprint = resolved_graph_fingerprint(metadata)?;
1448
1449    if let Some(requirements) = resolve_preview_requirements_from_manifest(
1450        project_path,
1451        &runtime_features,
1452        &graph_fingerprint,
1453        &resolved.app_crate_name,
1454        &resolved.app_path,
1455    )
1456    .await?
1457    {
1458        return Ok(requirements);
1459    }
1460    let waterui_core = select_unique_package(metadata, "waterui-core")?;
1461    let runtime_identity = runtime_package_identity(waterui_core);
1462
1463    let runtime_fingerprint_start = Instant::now();
1464    let runtime_fingerprint_base = if waterui.source.is_none() {
1465        let waterui_root = waterui
1466            .manifest_path
1467            .as_std_path()
1468            .parent()
1469            .map(Path::to_path_buf)
1470            .ok_or_else(|| eyre::eyre!("Failed to derive waterui package root path"))?;
1471        let fingerprint = compute_runtime_fingerprint(&waterui_root, &runtime_identity).await?;
1472        info!(
1473            waterui_root = %waterui_root.display(),
1474            elapsed_ms = runtime_fingerprint_start.elapsed().as_millis(),
1475            "Preview computed dev-mode runtime fingerprint"
1476        );
1477        return Ok(PreviewRequirements {
1478            waterui_path: Some(waterui_root),
1479            runtime_fingerprint: runtime_fingerprint(
1480                &fingerprint,
1481                &runtime_features,
1482                &graph_fingerprint,
1483            ),
1484            runtime_features,
1485            app_crate_name: resolved.app_crate_name,
1486            app_path: resolved.app_path,
1487        });
1488    } else {
1489        let source = waterui
1490            .source
1491            .as_ref()
1492            .map(ToString::to_string)
1493            .expect("registry dependency must have a source");
1494        info!(
1495            package = %runtime_identity,
1496            source = %source,
1497            elapsed_ms = runtime_fingerprint_start.elapsed().as_millis(),
1498            "Preview resolved release-mode runtime fingerprint"
1499        );
1500        format!("{runtime_identity}:source:{source}")
1501    };
1502
1503    Ok(PreviewRequirements {
1504        waterui_path: None,
1505        runtime_fingerprint: runtime_fingerprint(
1506            &runtime_fingerprint_base,
1507            &runtime_features,
1508            &graph_fingerprint,
1509        ),
1510        runtime_features,
1511        app_crate_name: resolved.app_crate_name,
1512        app_path: resolved.app_path,
1513    })
1514}
1515
1516async fn resolve_preview_requirements_from_manifest(
1517    project_path: &Path,
1518    runtime_features: &[String],
1519    graph_fingerprint: &str,
1520    app_crate_name: &crate::project_types::CrateName,
1521    app_path: &Path,
1522) -> Result<Option<PreviewRequirements>> {
1523    let manifest_open_start = Instant::now();
1524    let manifest = crate::project::Manifest::open(project_path.join("Water.toml"))
1525        .await
1526        .map_err(|error| {
1527            eyre::eyre!(
1528                "Failed to read Water.toml for preview requirements at {}: {error}",
1529                project_path.display()
1530            )
1531        })?;
1532    info!(
1533        project_path = %project_path.display(),
1534        elapsed_ms = manifest_open_start.elapsed().as_millis(),
1535        "Preview opened Water.toml for runtime requirements"
1536    );
1537    let Some(waterui_path) = manifest.waterui_path else {
1538        return Ok(None);
1539    };
1540
1541    let resolve_root_start = Instant::now();
1542    let waterui_root = resolve_waterui_root_from_manifest(project_path, &waterui_path).await?;
1543    info!(
1544        project_path = %project_path.display(),
1545        waterui_root = %waterui_root.display(),
1546        elapsed_ms = resolve_root_start.elapsed().as_millis(),
1547        "Preview resolved waterui root from manifest"
1548    );
1549
1550    let runtime_identity_start = Instant::now();
1551    let runtime_identity = runtime_identity_from_waterui_root(&waterui_root).await?;
1552    info!(
1553        waterui_root = %waterui_root.display(),
1554        elapsed_ms = runtime_identity_start.elapsed().as_millis(),
1555        "Preview resolved runtime identity"
1556    );
1557
1558    let runtime_fingerprint_start = Instant::now();
1559    let runtime_fingerprint = runtime_fingerprint(
1560        &compute_runtime_fingerprint(&waterui_root, &runtime_identity).await?,
1561        runtime_features,
1562        graph_fingerprint,
1563    );
1564    info!(
1565        project_path = %project_path.display(),
1566        waterui_root = %waterui_root.display(),
1567        elapsed_ms = runtime_fingerprint_start.elapsed().as_millis(),
1568        "Preview resolved runtime requirements from Water.toml"
1569    );
1570
1571    Ok(Some(PreviewRequirements {
1572        waterui_path: Some(waterui_root),
1573        runtime_fingerprint,
1574        runtime_features: runtime_features.to_vec(),
1575        app_crate_name: app_crate_name.clone(),
1576        app_path: app_path.to_path_buf(),
1577    }))
1578}
1579
1580async fn resolve_preview_metadata(
1581    project_path: &Path,
1582    platform: PreviewPlatform,
1583) -> Result<ResolvedPreviewMetadata> {
1584    let project = Project::open_for_preview_build(project_path).await?;
1585    ensure_project_dev_feature_for_preview(&project).await?;
1586    let manifest_path = scaffold_preview_module(&project, platform)
1587        .await?
1588        .join("Cargo.toml");
1589    let app_crate_name = project.crate_name().clone();
1590    let app_path = project.root().to_path_buf();
1591    let metadata_start = Instant::now();
1592    let metadata_manifest_path = manifest_path.clone();
1593    let abi_feature = PreviewLinkMode::for_platform(platform)
1594        .abi_feature
1595        .to_string();
1596    let metadata = smol::unblock(move || {
1597        let mut command = cargo_metadata::MetadataCommand::new();
1598        command
1599            .manifest_path(metadata_manifest_path)
1600            .features(cargo_metadata::CargoOpt::SomeFeatures(vec![abi_feature]));
1601        command.exec()
1602    })
1603    .await
1604    .wrap_err("Failed to resolve user project Cargo metadata with its dev feature")?;
1605    info!(
1606        project_path = %project_path.display(),
1607        elapsed_ms = metadata_start.elapsed().as_millis(),
1608        "Preview resolved user project cargo metadata"
1609    );
1610    Ok(ResolvedPreviewMetadata {
1611        metadata,
1612        app_crate_name,
1613        app_path,
1614    })
1615}
1616
1617fn resolved_package_features(
1618    metadata: &cargo_metadata::Metadata,
1619    package: &cargo_metadata::Package,
1620) -> Result<Vec<String>> {
1621    let resolve = metadata
1622        .resolve
1623        .as_ref()
1624        .ok_or_else(|| eyre::eyre!("Cargo metadata omitted its dependency resolution graph"))?;
1625    let node = resolve
1626        .nodes
1627        .iter()
1628        .find(|node| node.id == package.id)
1629        .ok_or_else(|| {
1630            eyre::eyre!(
1631                "Cargo metadata omitted the resolution node for package `{}`",
1632                package.name
1633            )
1634        })?;
1635    let mut features = node
1636        .features
1637        .iter()
1638        .map(ToString::to_string)
1639        .collect::<Vec<_>>();
1640    features.sort_unstable();
1641    features.dedup();
1642    if !features.iter().any(|feature| feature == "dynamic_linking") {
1643        bail!("Preview requires the project dev feature to enable waterui/dynamic_linking");
1644    }
1645    Ok(features)
1646}
1647
1648fn resolved_graph_fingerprint(metadata: &cargo_metadata::Metadata) -> Result<String> {
1649    let resolve = metadata
1650        .resolve
1651        .as_ref()
1652        .ok_or_else(|| eyre::eyre!("Cargo metadata omitted its dependency resolution graph"))?;
1653    let mut units = resolve
1654        .nodes
1655        .iter()
1656        .map(|node| {
1657            let mut features = node
1658                .features
1659                .iter()
1660                .map(ToString::to_string)
1661                .collect::<Vec<_>>();
1662            features.sort_unstable();
1663            format!("{}|{}", node.id, features.join(","))
1664        })
1665        .collect::<Vec<_>>();
1666    units.sort_unstable();
1667    let mut hasher = sha2::Sha256::new();
1668    for unit in units {
1669        hasher.update(unit.as_bytes());
1670        hasher.update(b"\n");
1671    }
1672    Ok(hex::encode(hasher.finalize()))
1673}
1674
1675fn runtime_fingerprint(base: &str, features: &[String], graph_fingerprint: &str) -> String {
1676    format!(
1677        "{base}|features={}|graph={}|profile={}",
1678        features.join(","),
1679        graph_fingerprint,
1680        runtime_profile_tag()
1681    )
1682}
1683
1684async fn resolve_waterui_root_from_manifest(
1685    project_path: &Path,
1686    waterui_path: &str,
1687) -> Result<PathBuf> {
1688    let candidate = PathBuf::from(waterui_path);
1689    let resolved = if candidate.is_absolute() {
1690        candidate
1691    } else {
1692        project_path.join(candidate)
1693    };
1694    smol::fs::canonicalize(&resolved).await.wrap_err_with(|| {
1695        format!(
1696            "Failed to resolve `waterui_path = {waterui_path}` from {}",
1697            project_path.display()
1698        )
1699    })
1700}
1701
1702async fn runtime_identity_from_waterui_root(waterui_root: &Path) -> Result<String> {
1703    let core_manifest_path = waterui_root.join("core").join("Cargo.toml");
1704    let manifest_text = smol::fs::read_to_string(&core_manifest_path)
1705        .await
1706        .wrap_err("Failed to read waterui-core Cargo.toml for preview requirements")?;
1707    let manifest: toml::Table = manifest_text
1708        .parse()
1709        .wrap_err("Failed to parse waterui-core Cargo.toml for preview requirements")?;
1710    let package = manifest
1711        .get("package")
1712        .and_then(toml::Value::as_table)
1713        .ok_or_else(|| {
1714            eyre::eyre!(
1715                "Invalid waterui-core manifest at {}: missing package section",
1716                core_manifest_path.display()
1717            )
1718        })?;
1719    let package_name = package
1720        .get("name")
1721        .and_then(toml::Value::as_str)
1722        .ok_or_else(|| {
1723            eyre::eyre!(
1724                "Invalid waterui-core manifest at {}: missing package.name",
1725                core_manifest_path.display()
1726            )
1727        })?;
1728    if package_name != "waterui-core" {
1729        bail!(
1730            "Invalid preview runtime root {}: expected core/Cargo.toml package `waterui-core`, found `{}`",
1731            waterui_root.display(),
1732            package_name
1733        );
1734    }
1735    let package_version = package
1736        .get("version")
1737        .and_then(toml::Value::as_str)
1738        .ok_or_else(|| {
1739            eyre::eyre!(
1740                "Invalid waterui-core manifest at {}: missing package.version",
1741                core_manifest_path.display()
1742            )
1743        })?;
1744
1745    Ok(format!("{package_name}@{package_version}"))
1746}
1747
1748fn select_unique_package<'a>(
1749    metadata: &'a cargo_metadata::Metadata,
1750    name: &str,
1751) -> Result<&'a cargo_metadata::Package> {
1752    let mut matches = metadata.packages.iter().filter(|p| p.name == name);
1753    let first = matches
1754        .next()
1755        .ok_or_else(|| eyre::eyre!("Could not resolve package `{name}` from metadata"))?;
1756    if matches.next().is_some() {
1757        bail!(
1758            "Multiple `{name}` packages were resolved. Preview requires a single resolved `{name}` package to guarantee compatibility."
1759        );
1760    }
1761    Ok(first)
1762}
1763
1764#[cfg(test)]
1765mod tests {
1766    use super::{PreviewLinkMode, PreviewPlatform};
1767
1768    #[test]
1769    fn macos_preview_uses_shared_waterui_runtime() {
1770        let link_mode = PreviewLinkMode::for_platform(PreviewPlatform::Macos);
1771
1772        assert_eq!(link_mode, PreviewLinkMode::MACOS_DYNAMIC);
1773        assert_eq!(link_mode.crate_type_override, None);
1774        assert!(link_mode.prefer_dynamic);
1775        assert_eq!(
1776            link_mode.abi_feature,
1777            crate::templates::preview_ffi::APPLE_ABI_FEATURE
1778        );
1779        assert_eq!(
1780            link_mode.signature_tag(),
1781            "preview-dylib+shared-waterui-dylib+prefer-dynamic"
1782        );
1783    }
1784
1785    #[test]
1786    fn remote_preview_platforms_use_shared_runtime_cdylibs() {
1787        for platform in [PreviewPlatform::Ios, PreviewPlatform::IosSimulator] {
1788            let link_mode = PreviewLinkMode::for_platform(platform);
1789
1790            assert_eq!(link_mode, PreviewLinkMode::PORTABLE_DYNAMIC);
1791            assert_eq!(link_mode.crate_type_override, Some("cdylib"));
1792            assert!(link_mode.prefer_dynamic);
1793            assert_eq!(
1794                link_mode.abi_feature,
1795                crate::templates::preview_ffi::APPLE_ABI_FEATURE
1796            );
1797            assert_eq!(
1798                link_mode.signature_tag(),
1799                "preview-cdylib+shared-waterui-dylib+prefer-dynamic"
1800            );
1801        }
1802    }
1803
1804    #[test]
1805    fn android_preview_uses_the_jni_shared_runtime_abi() {
1806        let link_mode = PreviewLinkMode::for_platform(PreviewPlatform::Android);
1807        assert_eq!(link_mode, PreviewLinkMode::ANDROID_DYNAMIC);
1808        assert_eq!(link_mode.crate_type_override, Some("cdylib"));
1809        assert!(link_mode.prefer_dynamic);
1810        assert_eq!(
1811            link_mode.abi_feature,
1812            crate::templates::preview_ffi::ANDROID_ABI_FEATURE
1813        );
1814    }
1815
1816    #[test]
1817    fn dylib_signature_pins_the_build_std_toolchain() {
1818        let dir = tempfile::tempdir().unwrap();
1819        std::fs::create_dir_all(dir.path().join("src")).unwrap();
1820        std::fs::write(dir.path().join("src/lib.rs"), "fn main() {}").unwrap();
1821        let inputs = smol::block_on(super::project_inputs_fingerprint(dir.path())).unwrap();
1822
1823        let signature = |toolchain| {
1824            super::dylib_build_signature(
1825                inputs,
1826                "runtime",
1827                "aarch64-linux-android",
1828                "preview_ffi",
1829                PreviewLinkMode::ANDROID_DYNAMIC,
1830                toolchain,
1831            )
1832        };
1833
1834        // A `rustup update` keeps the channel name but changes `rustc -vV` —
1835        // the cached module then names a libstd soname that no longer exists,
1836        // so the identity, not the name, is what must land in the signature.
1837        assert_ne!(
1838            signature(Some("rustc 1.100.0-nightly (aaa 2026-08-30)")),
1839            signature(Some("rustc 1.101.0-nightly (bbb 2026-10-04)")),
1840        );
1841        assert_ne!(signature(None), signature(Some("rustc 1.100.0-nightly")));
1842    }
1843}