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