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