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