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