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