1use cargo_toml::Manifest as CargoManifest;
4use futures_util::FutureExt as _;
5use futures_util::future::{BoxFuture, Shared};
6use tracing::info;
7
8use crate::build::{BuildProgress, RustLinkage};
9use crate::framework::{
10 FrameworkChannel, ResolvedFramework, validate_local_cli, validate_resolved_cli,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum OpenMode {
15 Full,
16 PreviewBuild,
17}
18
19#[derive(Debug, Clone)]
24struct CargoLayout {
25 target_dir: PathBuf,
26 workspace_root: PathBuf,
29}
30
31enum CargoResolution {
32 Local,
33 Locked,
34 Update,
35}
36
37fn spawn_cargo_layout_resolution(
38 current_dir: &Path,
39 framework: Option<ResolvedFramework>,
40 local: bool,
41) -> Shared<BoxFuture<'static, Result<CargoLayout, String>>> {
42 let current_dir = current_dir.to_path_buf();
43 let mode = if local {
44 CargoResolution::Local
45 } else if framework.is_some() {
46 CargoResolution::Locked
47 } else {
48 CargoResolution::Update
49 };
50 smol::spawn(async move {
51 resolve_cargo_layout(¤t_dir, framework, mode)
52 .await
53 .map_err(|error| error.to_string())
54 })
55 .boxed()
56 .shared()
57}
58
59#[derive(Debug, Clone)]
61pub struct Project {
62 root: PathBuf,
63 manifest: Manifest,
64 crate_name: CrateName,
65 cargo_layout: Shared<BoxFuture<'static, Result<CargoLayout, String>>>,
66 linked_packages: Arc<async_lock::OnceCell<Result<BTreeMap<String, String>, String>>>,
67 enabled_features: Arc<async_lock::OnceCell<Result<BTreeSet<String>, String>>>,
68 managed_backends_root: PathBuf,
69}
70
71impl Project {
72 pub async fn select_channel(
77 path: impl AsRef<Path>,
78 channel: FrameworkChannel,
79 ) -> eyre::Result<Self> {
80 let path = smol::fs::canonicalize(path.as_ref()).await?;
81 let water_path = path.join("Water.toml");
82 let cargo_path = path.join("Cargo.toml");
83 let mut water: toml_edit::DocumentMut =
84 smol::fs::read_to_string(&water_path).await?.parse()?;
85 let previous: Manifest = toml::from_str(&water.to_string())?;
86 let mut cargo: toml_edit::DocumentMut =
87 smol::fs::read_to_string(&cargo_path).await?.parse()?;
88 let crate_name = CrateName::try_from(
89 cargo["package"]["name"]
90 .as_str()
91 .ok_or_else(|| eyre::eyre!("channel selection requires a project Cargo.toml"))?,
92 )
93 .map_err(|error| eyre::eyre!(error))?;
94 let (framework, lockfile) = ResolvedFramework::resolve(channel).await?;
95 for (configured, backend) in [
99 (previous.backends.gtk4().is_some(), TargetBackend::Gtk4),
100 (
101 previous.backends.hydrolysis().is_some(),
102 TargetBackend::Hydrolysis,
103 ),
104 (previous.backends.winui().is_some(), TargetBackend::WinUi),
105 (previous.backends.esp32().is_some(), TargetBackend::Dew),
106 ] {
107 if configured {
108 for package in backend.scaffold_packages() {
109 framework.require_distributable(package)?;
110 }
111 }
112 }
113 let mut next = previous.clone();
114 next.waterui_path = None;
115 next.framework = Some(framework.clone());
116 let mut updates =
117 templates::framework_updates(&path, &previous, &next, &crate_name).await?;
118 framework.update_manifest(&mut cargo, &templates::project_patches(&path, &previous)?)?;
119 water.remove("waterui_path");
120 water["framework"] =
121 toml_edit::Item::Table(toml_edit::ser::to_document(&framework)?.into_table());
122 updates.push((water_path, water.to_string().into_bytes()));
123 updates.push((cargo_path, cargo.to_string().into_bytes()));
124 if let Some(lockfile) = lockfile {
125 updates.push((
126 path.join("Cargo.lock"),
127 framework.cargo_lock(&lockfile)?.to_string().into_bytes(),
128 ));
129 updates.push((path.join("Water.lock"), lockfile));
130 }
131 let mut updates: Vec<_> = updates
132 .into_iter()
133 .map(|(file, contents)| (file, Some(contents)))
134 .collect();
135 if channel == FrameworkChannel::Stable
136 && previous
137 .framework
138 .as_ref()
139 .is_some_and(|previous| previous.channel() != Some(FrameworkChannel::Stable))
140 {
141 updates.push((path.join("Water.lock"), None));
142 }
143 apply_channel_selection(&path, framework, updates).await?;
144 Self::open_for_preview_build(path).await.map_err(Into::into)
145 }
146
147 pub async fn run<B: Backend, D: Device>(
159 &self,
160 backend: &B,
161 platform: TargetPlatform,
162 device: D,
163 ) -> Result<Running, FailToRun> {
164 self.run_with_options(backend, platform, device, RunOptions::new(), None)
165 .await
166 }
167
168 pub async fn run_with_options<B: Backend, D: Device>(
177 &self,
178 backend: &B,
179 platform: TargetPlatform,
180 device: D,
181 run_options: RunOptions,
182 progress: Option<BuildProgress>,
183 ) -> Result<Running, FailToRun> {
184 let mut build_options = BuildOptions::development(BuildProfile::Debug);
185 if let Some(progress) = &progress {
186 build_options = build_options.with_progress(progress.clone());
187 }
188 backend
190 .build(self, platform, build_options)
191 .await
192 .map_err(FailToRun::Build)?;
193
194 let mut package_options = PackageOptions::development();
195 if let Some(progress) = progress {
196 package_options = package_options.with_progress(progress);
197 }
198 let artifact = backend
200 .package(self, platform, package_options)
201 .await
202 .map_err(FailToRun::Package)?;
203
204 Self::run_packaged(device, artifact, run_options).await
205 }
206
207 pub async fn run_android_with_options<D: Device + AndroidAbiProvider>(
220 &self,
221 _backend: &AndroidBackend,
222 device: D,
223 run_options: RunOptions,
224 build_options: BuildOptions,
225 progress: Option<BuildProgress>,
226 ) -> Result<Running, FailToRun> {
227 let abi = device.android_abi();
228
229 self.browser_runtime_plan(TargetPlatform::Android, TargetBackend::Android)
230 .await
231 .map_err(FailToRun::Build)?;
232
233 AndroidPlatform::clean_jni_libs(self)
234 .await
235 .map_err(FailToRun::Build)?;
236
237 let mut build_options = build_options;
238 if let Some(progress) = &progress {
239 build_options = build_options.with_progress(progress.clone());
240 }
241 AndroidPlatform::new(abi)
242 .build(self, build_options)
243 .await
244 .map_err(FailToRun::Build)?;
245
246 let mut package_options = PackageOptions::development();
247 if let Some(progress) = progress {
248 package_options = package_options.with_progress(progress);
249 }
250 let artifact = AndroidPlatform::package_with_abis(self, package_options, &[abi])
251 .await
252 .map_err(FailToRun::Package)?;
253
254 Self::run_packaged(device, artifact, run_options).await
255 }
256
257 async fn run_packaged<D: Device>(
258 device: D,
259 artifact: Artifact,
260 run_options: RunOptions,
261 ) -> Result<Running, FailToRun> {
262 info!("Running on device");
263
264 let running = device
265 .run(&crate::toolchain::Host::current(), artifact, run_options)
266 .await?;
267 Ok(running)
268 }
269
270 #[must_use]
274 pub fn root(&self) -> &Path {
275 &self.root
276 }
277
278 pub async fn target_dir(&self) -> eyre::Result<PathBuf> {
284 Ok(self.cargo_layout().await?.target_dir)
285 }
286
287 pub async fn lockfile_path(&self) -> eyre::Result<PathBuf> {
297 Ok(self.cargo_layout().await?.workspace_root.join("Cargo.lock"))
298 }
299
300 async fn cargo_layout(&self) -> eyre::Result<CargoLayout> {
301 self.cargo_layout
302 .clone()
303 .await
304 .map_err(|error| eyre::eyre!(error))
305 }
306
307 pub async fn water_target_dir(&self, linkage: RustLinkage) -> eyre::Result<PathBuf> {
341 let variant = match linkage {
342 RustLinkage::SharedRuntime => "shared",
343 RustLinkage::Static => "static",
344 };
345 Ok(crate::water_dir::shared_target_dir().await?.join(variant))
346 }
347
348 pub async fn toolchain_target_dir(&self, toolchain: &str) -> eyre::Result<PathBuf> {
359 Ok(crate::water_dir::shared_target_dir()
360 .await?
361 .join(format!("toolchain-{toolchain}")))
362 }
363
364 pub async fn host_target_dir(&self) -> eyre::Result<PathBuf> {
376 crate::water_dir::shared_host_target_dir().await
377 }
378
379 #[must_use]
381 pub const fn backends(&self) -> &Backends {
382 &self.manifest.backends
383 }
384
385 #[must_use]
387 pub const fn crate_name(&self) -> &CrateName {
388 &self.crate_name
389 }
390
391 #[must_use]
396 pub fn ffi_crate_name(&self) -> CrateName {
397 self.app_crate_overrides()
398 .and_then(|crates| crates.ffi.clone())
399 .unwrap_or_else(|| generated_crate_name(&self.crate_name, "ffi", &self.root))
400 }
401
402 #[must_use]
404 pub fn preview_ffi_crate_name(&self) -> CrateName {
405 generated_crate_name(&self.crate_name, "preview-ffi", &self.root)
406 }
407
408 #[must_use]
410 pub fn preview_dylib_crate_path(&self, workspace_root: &Path) -> PathBuf {
411 self.preview_ffi_crate_path(workspace_root)
412 }
413
414 #[must_use]
416 pub fn preview_dylib_crate_name(&self) -> CrateName {
417 self.preview_ffi_crate_name()
418 }
419
420 #[must_use]
422 pub fn gtk_backend_crate_name(&self) -> CrateName {
423 self.app_crate_overrides()
424 .and_then(|crates| crates.gtk.clone())
425 .unwrap_or_else(|| generated_crate_name(&self.crate_name, "gtk4", &self.root))
426 }
427
428 #[must_use]
430 pub fn hydrolysis_backend_crate_name(&self) -> CrateName {
431 self.app_crate_overrides()
432 .and_then(|crates| crates.hydrolysis.clone())
433 .unwrap_or_else(|| generated_crate_name(&self.crate_name, "hydrolysis", &self.root))
434 }
435
436 #[must_use]
438 pub fn winui_backend_crate_name(&self) -> CrateName {
439 self.app_crate_overrides()
440 .and_then(|crates| crates.winui.clone())
441 .unwrap_or_else(|| generated_crate_name(&self.crate_name, "winui", &self.root))
442 }
443
444 #[must_use]
446 pub fn esp32_backend_crate_name(&self) -> CrateName {
447 generated_crate_name(&self.crate_name, "esp32", &self.root)
448 }
449
450 #[must_use]
452 pub fn tui_backend_crate_name(&self) -> CrateName {
453 generated_crate_name(&self.crate_name, "tui", &self.root)
454 }
455
456 fn shipped_backend_binary_name(
464 &self,
465 suffix: &str,
466 configured: Option<&CrateName>,
467 ) -> CrateName {
468 configured
469 .cloned()
470 .unwrap_or_else(|| self.crate_name.with_suffix(suffix))
471 }
472
473 #[must_use]
475 pub fn gtk4_binary_name(&self) -> CrateName {
476 self.shipped_backend_binary_name(
477 "gtk4",
478 self.app_crate_overrides()
479 .and_then(|crates| crates.gtk.as_ref()),
480 )
481 }
482
483 #[must_use]
485 pub fn hydrolysis_binary_name(&self) -> CrateName {
486 self.shipped_backend_binary_name(
487 "hydrolysis",
488 self.app_crate_overrides()
489 .and_then(|crates| crates.hydrolysis.as_ref()),
490 )
491 }
492
493 #[must_use]
495 pub fn winui_binary_name(&self) -> CrateName {
496 self.shipped_backend_binary_name(
497 "winui",
498 self.app_crate_overrides()
499 .and_then(|crates| crates.winui.as_ref()),
500 )
501 }
502
503 #[must_use]
505 pub fn esp32_binary_name(&self) -> CrateName {
506 self.shipped_backend_binary_name("esp32", None)
507 }
508
509 #[must_use]
511 pub const fn package_type(&self) -> PackageType {
512 self.manifest.package.package_type
513 }
514
515 #[must_use]
517 pub fn is_playground(&self) -> bool {
518 self.package_type() == PackageType::Playground
519 }
520
521 #[must_use]
523 pub const fn apple_backend(&self) -> Option<&AppleBackend> {
524 self.manifest.backends.apple()
525 }
526
527 #[must_use]
531 pub fn backend_path<B: Backend>(&self) -> PathBuf {
532 self.managed_backends_root.join(B::DEFAULT_PATH)
533 }
534
535 #[must_use]
539 pub fn backend_relative_path<B: Backend>(&self) -> PathBuf {
540 self.manifest.backends.path().join(B::DEFAULT_PATH)
541 }
542
543 #[must_use]
545 pub fn ffi_crate_path(&self) -> PathBuf {
546 self.managed_backends_root.join("ffi")
547 }
548
549 #[must_use]
551 pub fn preview_module_member_path(&self) -> PathBuf {
552 Path::new(crate::templates::PREVIEW_MODULES_DIR)
553 .join(self.preview_ffi_crate_name().to_string())
554 }
555
556 #[must_use]
563 pub fn preview_ffi_crate_path(&self, workspace_root: &Path) -> PathBuf {
564 workspace_root.join(self.preview_module_member_path())
565 }
566
567 #[must_use]
569 pub fn ffi_crate_relative_path(&self) -> PathBuf {
570 self.manifest.backends.path().join("ffi")
571 }
572
573 #[must_use]
575 pub const fn android_backend(&self) -> Option<&AndroidBackend> {
576 self.manifest.backends.android()
577 }
578
579 #[must_use]
581 pub const fn gtk4_backend(&self) -> Option<&crate::gtk4::backend::Gtk4Backend> {
582 self.manifest.backends.gtk4()
583 }
584
585 #[must_use]
587 pub const fn hydrolysis_backend(
588 &self,
589 ) -> Option<&crate::hydrolysis::backend::HydrolysisBackend> {
590 self.manifest.backends.hydrolysis()
591 }
592
593 #[must_use]
595 pub const fn winui_backend(&self) -> Option<&crate::winui::backend::WinUiBackend> {
596 self.manifest.backends.winui()
597 }
598
599 #[must_use]
601 pub const fn esp32_backend(&self) -> Option<&crate::esp32::backend::Esp32Backend> {
602 self.manifest.backends.esp32()
603 }
604
605 #[must_use]
607 pub const fn manifest(&self) -> &Manifest {
608 &self.manifest
609 }
610
611 pub async fn resolved_framework(&self) -> eyre::Result<ResolvedFramework> {
620 ResolvedFramework::for_manifest(self.manifest(), &self.root).await
621 }
622
623 async fn require_distributable_backend(
629 &self,
630 backend: TargetBackend,
631 ) -> Result<(), crate::backend::FailToInitBackend> {
632 let packages = backend.scaffold_packages();
633 if packages.is_empty() {
634 return Ok(());
635 }
636 let framework = self
637 .resolved_framework()
638 .await
639 .map_err(crate::backend::FailToInitBackend::Config)?;
640 for package in packages {
641 framework
642 .require_distributable(package)
643 .map_err(crate::backend::FailToInitBackend::Config)?;
644 }
645 Ok(())
646 }
647
648 pub async fn links_runtime_package(&self, package_name: &str) -> eyre::Result<bool> {
660 let project_root = self.root.clone();
661 let cargo_layout = self.cargo_layout.clone();
662 let packages = self
663 .linked_packages
664 .get_or_init(|| async move {
665 cargo_layout.await?;
666 resolve_linked_runtime_packages(project_root, false)
667 .await
668 .map_err(|error| error.to_string())
669 })
670 .await;
671 match packages {
672 Ok(packages) => Ok(packages.contains_key(package_name)),
673 Err(error) => Err(eyre::eyre!(error.clone())),
674 }
675 }
676
677 pub async fn uses_standard_webview(&self) -> eyre::Result<bool> {
692 let project_root = self.root.clone();
693 let cargo_layout = self.cargo_layout.clone();
694 let features = self
695 .enabled_features
696 .get_or_init(|| async move {
697 cargo_layout.await?;
698 resolve_enabled_features(project_root, false)
699 .await
700 .map_err(|error| error.to_string())
701 })
702 .await;
703 match features {
704 Ok(features) => Ok(features.contains("webview")),
705 Err(error) => Err(eyre::eyre!(error.clone())),
706 }
707 }
708
709 pub async fn resolved_webview_backend(
728 &self,
729 platform: TargetPlatform,
730 backend: TargetBackend,
731 ) -> eyre::Result<Option<ResolvedWebViewBackend>> {
732 if !self.uses_standard_webview().await? {
733 return Ok(None);
734 }
735 let engine = self.linked_browser_engine().await?;
736 engine
737 .unwrap_or(ResolvedWebViewBackend::System)
738 .validate(platform, backend)
739 .map(Some)
740 .map_err(Into::into)
741 }
742
743 pub async fn linked_browser_engine(&self) -> eyre::Result<Option<ResolvedWebViewBackend>> {
751 let cef = self.links_runtime_package("waterui-browser-cef").await?;
752 let wpe = self.links_runtime_package("waterui-browser-wpe").await?;
753 match (cef, wpe) {
754 (true, true) => eyre::bail!(
755 "the application links both waterui-browser-cef and waterui-browser-wpe; \
756 exactly one browser engine can draw a WebView"
757 ),
758 (true, false) => Ok(Some(ResolvedWebViewBackend::Cef)),
759 (false, true) => Ok(Some(ResolvedWebViewBackend::Wpe)),
760 (false, false) => Ok(None),
761 }
762 }
763
764 pub async fn declares_cef_helper(&self) -> eyre::Result<bool> {
780 Ok(crate::project_types::declares_cef_helper(
781 self.linked_browser_engine().await?,
782 ))
783 }
784
785 pub async fn browser_runtime_plan(
792 &self,
793 platform: TargetPlatform,
794 backend: TargetBackend,
795 ) -> eyre::Result<BrowserRuntimePlan> {
796 let webview = self.resolved_webview_backend(platform, backend).await?;
797 let chromium = self.links_runtime_package("waterui-chromium").await?;
798 if chromium && !cef_is_supported(platform, backend) {
799 eyre::bail!(
800 "waterui-chromium requires CEF, which is unsupported for platform {platform:?} \
801 with backend {backend:?}"
802 );
803 }
804 Ok(BrowserRuntimePlan { webview, chromium })
805 }
806
807 #[must_use]
809 pub const fn bundle_identifier(&self) -> &BundleIdentifier {
810 &self.manifest.package.bundle_identifier
811 }
812
813 #[must_use]
815 pub fn assets_path(&self) -> &str {
816 &self.manifest.package.assets_path
817 }
818
819 #[must_use]
821 pub fn assets_dir(&self) -> PathBuf {
822 self.root.join(&self.manifest.package.assets_path)
823 }
824
825 pub async fn clean<B: Backend>(
831 &self,
832 backend: &B,
833 platform: TargetPlatform,
834 ) -> Result<(), eyre::Report> {
835 backend.clean(self, platform).await
836 }
837
838 pub async fn clean_all(&self) -> Result<(), eyre::Report> {
850 use crate::{
851 android::platform::clean_android, apple::platform::clean_apple,
852 esp32::platform::clean_esp32, gtk4::platform::clean_gtk4,
853 hydrolysis::platform::clean_hydrolysis, winui::platform::clean_winui,
854 };
855
856 if self.is_playground() {
857 crate::water_dir::remove_project_build_cache(self.root()).await?;
858 let water_backends_root = self.target_dir().await?.join("water-backends");
864 if water_backends_root.exists() {
865 smol::fs::remove_dir_all(&water_backends_root).await?;
866 }
867 return Ok(());
868 }
869
870 let target_dir = self.target_dir().await?;
872 if target_dir.exists() {
873 smol::fs::remove_dir_all(&target_dir).await?;
874 }
875
876 if self.apple_backend().is_some() {
878 clean_apple(self).await?;
879 }
880
881 if self.android_backend().is_some() {
883 clean_android(self).await?;
884 }
885
886 if self.gtk4_backend().is_some() || (self.is_playground() && cfg!(target_os = "linux")) {
888 clean_gtk4(self).await?;
889 }
890
891 if self.hydrolysis_backend().is_some() || self.is_playground() {
893 clean_hydrolysis(self).await?;
894 }
895
896 if self.winui_backend().is_some() || (self.is_playground() && cfg!(target_os = "windows")) {
898 clean_winui(self).await?;
899 }
900
901 if self.esp32_backend().is_some() {
903 clean_esp32(self).await?;
904 }
905
906 let ffi_target_dir = self.ffi_crate_path().join("target");
907 if ffi_target_dir.exists() {
908 smol::fs::remove_dir_all(&ffi_target_dir).await?;
909 }
910
911 Ok(())
912 }
913
914 pub async fn package<B: Backend>(
920 &self,
921 backend: &B,
922 platform: TargetPlatform,
923 options: PackageOptions,
924 ) -> Result<Artifact, eyre::Report> {
925 backend.package(self, platform, options).await
926 }
927
928 fn app_crate_overrides(&self) -> Option<&AppCrates> {
929 self.manifest.app.as_ref()?.crates.as_ref()
930 }
931}
932
933#[derive(Debug, thiserror::Error)]
935pub enum FailToOpenProject {
936 #[error("Failed to open project manifest: {0}")]
938 Manifest(FailToOpenManifest),
939 #[error("Failed to read Cargo.toml: {0}")]
941 CargoManifest(cargo_toml::Error),
942
943 #[error("Failed to get Cargo metadata: {0}")]
945 TargetDirError(#[from] cargo_metadata::Error),
946
947 #[error("Framework compatibility check failed: {0}")]
949 Framework(eyre::Report),
950 #[error("Failed to refresh the [patch] tables from the local checkout: {0}")]
953 LocalPatches(eyre::Report),
954
955 #[error("Invalid Cargo.toml: missing crate name")]
957 MissingCrateName,
958
959 #[error("Invalid Cargo.toml crate name: {0}")]
961 InvalidCrateName(String),
962
963 #[error("Project permissions are not allowed in non-playground projects")]
965 PermissionsNotAllowedInNonPlayground,
966
967 #[error(
969 "Backend project configuration is not allowed in playground projects \
970 (device settings under [backends.esp32] are the exception)"
971 )]
972 BackendsNotAllowedInPlayground,
973
974 #[error("Failed to initialize backend: {0}")]
976 BackendInit(#[from] crate::backend::FailToInitBackend),
977
978 #[error("Failed to prepare managed build cache: {0}")]
980 BuildCache(#[from] eyre::Report),
981}
982
983#[derive(Debug, thiserror::Error)]
985pub enum FailToCreateProject {
986 #[error("Failed to resolve framework: {0}")]
988 Framework(eyre::Report),
989 #[error("Directory already exists: {0}")]
991 DirectoryExists(PathBuf),
992 #[error("{0} is already a WaterUI project (Water.toml exists)")]
994 AlreadyProject(PathBuf),
995 #[error(
998 "{0} already contains a Cargo.toml; merge the generated scaffold manually or remove it first"
999 )]
1000 CargoManifestExists(PathBuf),
1001 #[error("Failed to create directory: {0}")]
1003 CreateDir(std::io::Error),
1004 #[error("Failed to scaffold project: {0}")]
1006 Scaffold(std::io::Error),
1007 #[error("Failed to save manifest: {0}")]
1009 SaveManifest(#[from] FailToSaveManifest),
1010
1011 #[error("Failed to get Cargo metadata: {0}")]
1013 TargetDirError(#[from] cargo_metadata::Error),
1014
1015 #[error("Failed to resolve managed build cache: {0}")]
1017 BuildCache(#[from] eyre::Report),
1018
1019 #[error("Failed to initialize git repository: {0}")]
1021 GitInit(std::io::Error),
1022 #[error("Failed to check git repository status: {0}")]
1024 GitStatus(std::io::Error),
1025}
1026
1027#[derive(Debug, Clone)]
1029pub struct CreateOptions {
1030 pub name: String,
1032 pub bundle_identifier: BundleIdentifier,
1034 pub package_type: PackageType,
1036 pub waterui_path: Option<PathBuf>,
1038 pub channel: Option<FrameworkChannel>,
1041 pub framework_manifest: Option<PathBuf>,
1045 pub framework: Option<ResolvedFramework>,
1049 pub author: String,
1051 pub backends: Vec<TargetBackend>,
1056 pub web: Option<WebScaffold>,
1059}
1060
1061#[derive(Debug, Clone)]
1063pub struct WebScaffold {
1064 pub package_manager: web::PackageManager,
1066 pub include_arg: String,
1069}
1070
1071impl CreateOptions {
1072 fn crate_name(&self) -> Result<CrateName, FailToCreateProject> {
1073 let name = self
1074 .name
1075 .chars()
1076 .map(|character| {
1077 if character.is_alphanumeric() {
1078 character.to_ascii_lowercase()
1079 } else {
1080 '_'
1081 }
1082 })
1083 .collect::<String>();
1084 CrateName::try_from(name).map_err(|error| {
1085 FailToCreateProject::Scaffold(std::io::Error::new(
1086 std::io::ErrorKind::InvalidInput,
1087 error,
1088 ))
1089 })
1090 }
1091
1092 async fn resolve_framework(&mut self) -> eyre::Result<(ResolvedFramework, Option<Vec<u8>>)> {
1098 let selected = [
1099 self.waterui_path.is_some(),
1100 self.channel.is_some(),
1101 self.framework_manifest.is_some(),
1102 self.framework.is_some(),
1103 ]
1104 .into_iter()
1105 .filter(|selected| *selected)
1106 .count();
1107 if selected > 1 {
1108 eyre::bail!(
1109 "a framework channel, a local source path, a framework manifest, \
1110 and a resolved framework are mutually exclusive"
1111 );
1112 }
1113 if let Some(path) = &self.waterui_path {
1114 let path = path.clone();
1119 let root = unblock(move || dunce::canonicalize(path)).await?;
1120 self.waterui_path = Some(root.clone());
1121 return Ok((ResolvedFramework::for_local_checkout(&root).await?, None));
1122 }
1123 if let Some(path) = &self.framework_manifest {
1124 return ResolvedFramework::resolve_manifest(path).await;
1125 }
1126 if let Some(framework) = &self.framework {
1127 return Ok((framework.clone(), None));
1128 }
1129 ResolvedFramework::resolve(self.channel.unwrap_or_default()).await
1130 }
1131}
1132
1133impl Project {
1134 async fn scaffold_ffi_companion(&self) -> Result<(), crate::backend::FailToInitBackend> {
1135 let manifest = self.manifest();
1136 let app_name = manifest
1137 .package
1138 .name
1139 .chars()
1140 .filter(|c| c.is_alphanumeric())
1141 .collect::<String>();
1142 let webview_enabled = self
1143 .uses_standard_webview()
1144 .await
1145 .map_err(crate::backend::FailToInitBackend::Config)?;
1146 let chromium_enabled = self
1147 .links_runtime_package("waterui-chromium")
1148 .await
1149 .map_err(crate::backend::FailToInitBackend::Config)?;
1150 let browser_engine = self
1151 .linked_browser_engine()
1152 .await
1153 .map_err(crate::backend::FailToInitBackend::Config)?;
1154 let framework = self
1155 .resolved_framework()
1156 .await
1157 .map_err(crate::backend::FailToInitBackend::Config)?;
1158 let ctx = TemplateContext::for_project_manifest(
1159 manifest,
1160 self.crate_name().clone(),
1161 app_name,
1162 &framework,
1163 )
1164 .with_backend_project_path(self.ffi_crate_path())
1165 .with_project_root_path(self.root.clone())
1166 .with_webview_enabled(webview_enabled)
1167 .with_chromium_enabled(chromium_enabled)
1168 .with_browser_engine(browser_engine);
1169
1170 templates::ffi::scaffold(&self.ffi_crate_path(), &ctx, &self.ffi_crate_name())
1171 .await
1172 .map_err(crate::backend::FailToInitBackend::Io)?;
1173
1174 let lockfile = self
1175 .lockfile_path()
1176 .await
1177 .map_err(crate::backend::FailToInitBackend::Config)?;
1178 templates::ffi::seed_lockfile(&self.ffi_crate_path(), &lockfile)
1179 .await
1180 .map_err(crate::backend::FailToInitBackend::Io)
1181 }
1182
1183 pub async fn scaffold_preview_ffi_companion(
1189 &self,
1190 workspace_root: &Path,
1191 ) -> Result<PathBuf, crate::backend::FailToInitBackend> {
1192 let manifest = self.manifest();
1193 let app_name = manifest
1194 .package
1195 .name
1196 .chars()
1197 .filter(|c| c.is_alphanumeric())
1198 .collect::<String>();
1199 let framework = self
1200 .resolved_framework()
1201 .await
1202 .map_err(crate::backend::FailToInitBackend::Config)?;
1203 let ctx = TemplateContext::for_project_manifest(
1204 manifest,
1205 self.crate_name().clone(),
1206 app_name,
1207 &framework,
1208 )
1209 .with_backend_project_path(self.preview_ffi_crate_path(workspace_root))
1210 .with_project_root_path(self.root.clone());
1211
1212 let crate_path = self.preview_ffi_crate_path(workspace_root);
1213 templates::preview_ffi::scaffold(&crate_path, &ctx, &self.preview_ffi_crate_name())
1214 .await
1215 .map_err(crate::backend::FailToInitBackend::Io)?;
1216 Ok(crate_path)
1217 }
1218
1219 async fn remove_ffi_companion_if_unused(&self) -> eyre::Result<()> {
1220 if self.apple_backend().is_some() || self.android_backend().is_some() {
1221 return Ok(());
1222 }
1223
1224 let ffi_path = self.ffi_crate_path();
1225 if ffi_path.exists() {
1226 smol::fs::remove_dir_all(&ffi_path).await?;
1227 }
1228
1229 Ok(())
1230 }
1231
1232 pub async fn create(
1244 path: impl AsRef<Path>,
1245 options: CreateOptions,
1246 ) -> Result<Self, FailToCreateProject> {
1247 let path = path.as_ref().to_path_buf();
1248
1249 if path.exists() {
1251 return Err(FailToCreateProject::DirectoryExists(path));
1252 }
1253
1254 Self::scaffold_project(path, options).await
1255 }
1256
1257 pub async fn init(
1267 path: impl AsRef<Path>,
1268 options: CreateOptions,
1269 ) -> Result<Self, FailToCreateProject> {
1270 let path = path.as_ref().to_path_buf();
1271 if path.join("Water.toml").exists() {
1272 return Err(FailToCreateProject::AlreadyProject(path));
1273 }
1274 if path.join("Cargo.toml").exists() {
1275 return Err(FailToCreateProject::CargoManifestExists(path));
1276 }
1277 Self::scaffold_project(path, options).await
1278 }
1279
1280 async fn scaffold_project(
1281 path: PathBuf,
1282 mut options: CreateOptions,
1283 ) -> Result<Self, FailToCreateProject> {
1284 let crate_name = options.crate_name()?;
1286 let (framework, lockfile) = options
1287 .resolve_framework()
1288 .await
1289 .map_err(FailToCreateProject::Framework)?;
1290
1291 for backend in &options.backends {
1294 for package in backend.scaffold_packages() {
1295 framework
1296 .require_distributable(package)
1297 .map_err(FailToCreateProject::Framework)?;
1298 }
1299 }
1300
1301 smol::fs::create_dir_all(&path)
1305 .await
1306 .map_err(FailToCreateProject::CreateDir)?;
1307
1308 let ctx = TemplateContext::for_create_options(&options, crate_name.clone(), &framework);
1310
1311 let assets_path = default_assets_path();
1314
1315 templates::root::scaffold(&path, &ctx, &assets_path)
1317 .await
1318 .map_err(FailToCreateProject::Scaffold)?;
1319
1320 crate::mcp::ensure_mcp_json(&path)
1323 .await
1324 .map_err(FailToCreateProject::Scaffold)?;
1325 if let Some(lockfile) = lockfile {
1326 let contents = ctx
1327 .framework
1328 .cargo_lock(&lockfile)
1329 .map_err(FailToCreateProject::Framework)?
1330 .to_string();
1331 smol::fs::write(path.join("Water.lock"), lockfile)
1332 .await
1333 .map_err(FailToCreateProject::Scaffold)?;
1334 smol::fs::write(path.join("Cargo.lock"), contents)
1335 .await
1336 .map_err(FailToCreateProject::Scaffold)?;
1337 }
1338
1339 let mut backends = Backends::default();
1341 if options.package_type == PackageType::App {
1342 backends.set_path("backends");
1343 }
1344
1345 let manifest = Manifest {
1346 package: Package {
1347 package_type: options.package_type,
1348 name: options.name.clone(),
1349 bundle_identifier: options.bundle_identifier.clone(),
1350 assets_path,
1351 accessory: false,
1352 },
1353 backends,
1354 waterui_path: options
1355 .waterui_path
1356 .as_ref()
1357 .map(|p| p.display().to_string()),
1358 framework: framework.channel().is_some().then_some(framework),
1361 permissions: BTreeMap::default(),
1362 app: None,
1363 theme: None,
1364 launch: None,
1365 web: options.web.as_ref().map(|scaffold| web::WebConfig {
1366 package_manager: scaffold.package_manager,
1367 }),
1368 };
1369
1370 manifest.save(&path).await?;
1372
1373 Self::ensure_git_init(&path).await?;
1375
1376 let managed_backends_root = if options.package_type == PackageType::Playground {
1377 crate::water_dir::project_build_cache_dir(&path)
1378 .await
1379 .map_err(FailToCreateProject::BuildCache)?
1380 } else {
1381 path.join(manifest.backends.path())
1382 };
1383
1384 let cargo_layout = if let Some(framework) = &manifest.framework {
1385 let layout =
1386 resolve_cargo_layout(&path, Some(framework.clone()), CargoResolution::Update)
1387 .await
1388 .map_err(FailToCreateProject::Framework)?;
1389 futures_util::future::ready(Ok::<CargoLayout, String>(layout))
1390 .boxed()
1391 .shared()
1392 } else {
1393 spawn_cargo_layout_resolution(&path, None, true)
1394 };
1395 Ok(Self {
1396 root: path,
1397 manifest,
1398 crate_name,
1399 cargo_layout,
1400 linked_packages: Arc::new(async_lock::OnceCell::new()),
1401 enabled_features: Arc::new(async_lock::OnceCell::new()),
1402 managed_backends_root,
1403 })
1404 }
1405
1406 async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
1411 let mut cmd = Command::new("git");
1414
1415 let is_in_git = command(&mut cmd)
1416 .args(["rev-parse", "--git-dir"])
1417 .current_dir(path)
1418 .output()
1419 .await
1420 .map_err(FailToCreateProject::GitStatus)?
1421 .status
1422 .success();
1423
1424 if !is_in_git {
1425 let mut cmd = Command::new("git");
1427 command(&mut cmd)
1428 .args(["init"])
1429 .current_dir(path)
1430 .status()
1431 .await
1432 .map_err(FailToCreateProject::GitInit)?;
1433 }
1434
1435 Ok(())
1436 }
1437
1438 pub async fn init_apple_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1445 use crate::backend::Backend;
1446
1447 let backend = AppleBackend::init(self).await?;
1448 self.scaffold_ffi_companion().await?;
1449 self.manifest.backends.set_apple(backend);
1450 self.manifest
1451 .save(&self.root)
1452 .await
1453 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1454 Ok(())
1455 }
1456
1457 pub async fn init_android_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1464 use crate::backend::Backend;
1465
1466 let backend = AndroidBackend::init(self).await?;
1467 self.scaffold_ffi_companion().await?;
1468 self.manifest.backends.set_android(backend);
1469 self.manifest
1470 .save(&self.root)
1471 .await
1472 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1473 Ok(())
1474 }
1475
1476 pub async fn init_gtk4_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1483 use crate::{backend::Backend, gtk4::backend::Gtk4Backend};
1484
1485 self.require_distributable_backend(TargetBackend::Gtk4)
1486 .await?;
1487 if !cfg!(target_os = "linux") {
1488 return Err(crate::backend::FailToInitBackend::Io(
1489 std::io::Error::other("GTK4 backend is only supported on Linux hosts"),
1490 ));
1491 }
1492
1493 let backend = Gtk4Backend::init(self).await?;
1494 self.manifest.backends.set_gtk4(backend);
1495 self.manifest
1496 .save(&self.root)
1497 .await
1498 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1499 Ok(())
1500 }
1501
1502 pub async fn init_hydrolysis_backend(
1510 &mut self,
1511 ) -> Result<(), crate::backend::FailToInitBackend> {
1512 use crate::{backend::Backend, hydrolysis::backend::HydrolysisBackend};
1513
1514 self.require_distributable_backend(TargetBackend::Hydrolysis)
1515 .await?;
1516 let backend = HydrolysisBackend::init(self).await?;
1517 self.manifest.backends.set_hydrolysis(backend);
1518 self.manifest
1519 .save(&self.root)
1520 .await
1521 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1522
1523 crate::mcp::ensure_mcp_json(&self.root).await?;
1527 Ok(())
1528 }
1529
1530 pub async fn init_winui_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1537 use crate::{backend::Backend, winui::backend::WinUiBackend};
1538
1539 self.require_distributable_backend(TargetBackend::WinUi)
1540 .await?;
1541 if !cfg!(target_os = "windows") {
1542 return Err(crate::backend::FailToInitBackend::Io(
1543 std::io::Error::other("WinUI backend is only supported on Windows hosts"),
1544 ));
1545 }
1546
1547 let backend = WinUiBackend::init(self).await?;
1548 self.manifest.backends.set_winui(backend);
1549 self.manifest
1550 .save(&self.root)
1551 .await
1552 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1553 Ok(())
1554 }
1555
1556 pub async fn init_esp32_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1564 use crate::{backend::Backend, esp32::backend::Esp32Backend};
1565
1566 self.require_distributable_backend(TargetBackend::Dew)
1567 .await?;
1568 let backend = Esp32Backend::init(self).await?;
1569 self.manifest.backends.set_esp32(backend);
1570 self.manifest
1571 .save(&self.root)
1572 .await
1573 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1574 Ok(())
1575 }
1576
1577 pub async fn set_esp32_chip(
1588 &mut self,
1589 chip: crate::esp32::chip::Esp32Chip,
1590 ) -> eyre::Result<()> {
1591 let current = self.esp32_backend().cloned().unwrap_or_default();
1592 if current.chip() == chip.id() {
1593 return Ok(());
1594 }
1595 self.manifest.backends.set_esp32(current.with_chip(chip));
1596 self.save_manifest().await
1597 }
1598
1599 pub async fn remove_apple_backend(&mut self) -> eyre::Result<()> {
1604 if let Some(backend) = self.apple_backend() {
1605 let path = backend.project_path().to_path_buf();
1606 self.remove_backend_relative_dir(&path).await?;
1607 }
1608 self.manifest.backends.clear_apple();
1609 self.remove_ffi_companion_if_unused().await?;
1610 self.save_manifest().await
1611 }
1612
1613 pub async fn remove_android_backend(&mut self) -> eyre::Result<()> {
1618 if let Some(backend) = self.android_backend() {
1619 let path = backend.project_path().clone();
1620 self.remove_backend_relative_dir(&path).await?;
1621 }
1622 self.manifest.backends.clear_android();
1623 self.remove_ffi_companion_if_unused().await?;
1624 self.save_manifest().await
1625 }
1626
1627 pub async fn remove_gtk4_backend(&mut self) -> eyre::Result<()> {
1632 if let Some(backend) = self.gtk4_backend() {
1633 let path = backend.project_path().clone();
1634 self.remove_backend_relative_dir(&path).await?;
1635 }
1636 self.manifest.backends.clear_gtk4();
1637 self.save_manifest().await
1638 }
1639
1640 pub async fn remove_winui_backend(&mut self) -> eyre::Result<()> {
1645 if let Some(backend) = self.winui_backend() {
1646 let path = backend.project_path().clone();
1647 self.remove_backend_relative_dir(&path).await?;
1648 }
1649 self.manifest.backends.clear_winui();
1650 self.save_manifest().await
1651 }
1652
1653 pub async fn remove_hydrolysis_backend(&mut self) -> eyre::Result<()> {
1658 if let Some(backend) = self.hydrolysis_backend() {
1659 let path = backend.project_path().clone();
1660 self.remove_backend_relative_dir(&path).await?;
1661 }
1662 self.manifest.backends.clear_hydrolysis();
1663 self.save_manifest().await
1664 }
1665
1666 pub async fn remove_esp32_backend(&mut self) -> eyre::Result<()> {
1671 if let Some(backend) = self.esp32_backend() {
1672 let path = backend.project_path().clone();
1673 self.remove_backend_relative_dir(&path).await?;
1674 }
1675 self.manifest.backends.clear_esp32();
1676 self.save_manifest().await
1677 }
1678
1679 pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1689 Self::open_with_mode(path, OpenMode::Full).await
1690 }
1691
1692 pub async fn open_for_preview_build(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1702 Self::open_with_mode(path, OpenMode::PreviewBuild).await
1703 }
1704
1705 async fn refresh_local_patches(project_root: &Path, waterui_path: &Path) -> eyre::Result<()> {
1720 let project_root = project_root.to_path_buf();
1721 let waterui_path = waterui_path.to_path_buf();
1722 unblock(move || {
1723 let checkout = project_root.join(&waterui_path);
1724 let patch_root = templates::patch_manifest_dir(&project_root)?;
1725 if same_directory(&patch_root, &checkout)? {
1726 return Ok(());
1727 }
1728 if !same_directory(&patch_root, &project_root)? {
1729 eyre::bail!(
1734 "This project is a member of the Cargo workspace at {}, so Cargo reads \
1735 [patch] from {} and ignores any copy here. Move the WaterUI checkout's \
1736 [patch] tables — the ones in {} — into that workspace manifest, or take \
1737 the project out of that workspace.",
1738 patch_root.display(),
1739 patch_root.join("Cargo.toml").display(),
1740 checkout.join("Cargo.toml").display(),
1741 );
1742 }
1743 let cargo_path = project_root.join("Cargo.toml");
1744 let text = std::fs::read_to_string(&cargo_path)?;
1745 let current = CargoManifest::from_slice(text.as_bytes())?.patch;
1746 let next = templates::local_framework_patches(&project_root, &waterui_path)?;
1747 if current == next {
1748 return Ok(());
1749 }
1750 let mut document: toml_edit::DocumentMut = text.parse()?;
1751 crate::framework::rewrite_patch_tables(&mut document, ¤t, &next)?;
1752 std::fs::write(&cargo_path, document.to_string())?;
1753 info!(
1754 path = %cargo_path.display(),
1755 "Refreshed the [patch] tables from the local checkout"
1756 );
1757 Ok(())
1758 })
1759 .await
1760 }
1761
1762 #[allow(clippy::too_many_lines)]
1763 async fn open_with_mode(
1764 path: impl AsRef<Path>,
1765 open_mode: OpenMode,
1766 ) -> Result<Self, FailToOpenProject> {
1767 use crate::backend::Backend;
1768
1769 let total_start = std::time::Instant::now();
1770 let path = path.as_ref().to_path_buf();
1771
1772 let manifest_start = std::time::Instant::now();
1773 let manifest = Manifest::open(path.join("Water.toml"))
1774 .await
1775 .map_err(FailToOpenProject::Manifest)?;
1776 if let Some(framework) = &manifest.framework {
1777 framework
1778 .validate_cli()
1779 .map_err(FailToOpenProject::Framework)?;
1780 }
1781 if let Some(local) = &manifest.waterui_path {
1782 validate_local_cli(&path.join(local))
1783 .await
1784 .map_err(FailToOpenProject::Framework)?;
1785 Self::refresh_local_patches(&path, Path::new(local))
1786 .await
1787 .map_err(FailToOpenProject::LocalPatches)?;
1788 }
1789 info!(
1790 path = %path.display(),
1791 open_mode = ?open_mode,
1792 elapsed_ms = manifest_start.elapsed().as_millis(),
1793 "Project::open loaded Water.toml"
1794 );
1795
1796 let cargo_path = path.join("Cargo.toml");
1797
1798 let cargo_manifest_start = std::time::Instant::now();
1799 let cargo_manifest = unblock(move || CargoManifest::from_path(cargo_path))
1800 .await
1801 .map_err(FailToOpenProject::CargoManifest)?;
1802 info!(
1803 path = %path.display(),
1804 open_mode = ?open_mode,
1805 elapsed_ms = cargo_manifest_start.elapsed().as_millis(),
1806 "Project::open loaded Cargo.toml"
1807 );
1808 let crate_name = cargo_manifest
1809 .package
1810 .map(|p| p.name)
1811 .ok_or(FailToOpenProject::MissingCrateName)
1812 .and_then(|value| {
1813 CrateName::try_from(value).map_err(FailToOpenProject::InvalidCrateName)
1814 })?;
1815
1816 let is_playground = manifest.package.package_type == PackageType::Playground;
1817
1818 if !is_playground && !manifest.permissions.is_empty() {
1820 return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
1821 }
1822
1823 if is_playground && manifest.backends.configures_backend_projects() {
1829 return Err(FailToOpenProject::BackendsNotAllowedInPlayground);
1830 }
1831
1832 let cargo_layout = spawn_cargo_layout_resolution(
1833 &path,
1834 manifest.framework.clone(),
1835 manifest.waterui_path.is_some(),
1836 );
1837 cargo_layout
1838 .clone()
1839 .await
1840 .map_err(|error| FailToOpenProject::Framework(eyre::eyre!(error)))?;
1841
1842 let managed_backends_root = if is_playground {
1843 let build_cache_start = std::time::Instant::now();
1844 let root = crate::water_dir::ensure_project_build_cache(&path)
1845 .await
1846 .map_err(FailToOpenProject::BuildCache)?;
1847 info!(
1848 path = %path.display(),
1849 open_mode = ?open_mode,
1850 elapsed_ms = build_cache_start.elapsed().as_millis(),
1851 "Project::open ensured project build cache"
1852 );
1853 root
1854 } else {
1855 path.join(manifest.backends.path())
1856 };
1857
1858 let mut project = Self {
1859 root: path,
1860 manifest,
1861 crate_name,
1862 cargo_layout,
1863 linked_packages: Arc::new(async_lock::OnceCell::new()),
1864 enabled_features: Arc::new(async_lock::OnceCell::new()),
1865 managed_backends_root,
1866 };
1867
1868 let skip_backend_init = std::env::var("WATERUI_SKIP_RUST_BUILD")
1877 .is_ok_and(|value| value == "1")
1878 || std::env::var("ACTION").is_ok() || std::env::var("XCODE_PRODUCT_BUILD_VERSION").is_ok();
1880
1881 if is_playground && !skip_backend_init && open_mode == OpenMode::Full {
1882 let apple_backend_start = std::time::Instant::now();
1883 let apple_backend = AppleBackend::init(&project)
1884 .await
1885 .map_err(FailToOpenProject::BackendInit)?;
1886 info!(
1887 path = %project.root.display(),
1888 elapsed_ms = apple_backend_start.elapsed().as_millis(),
1889 "Project::open initialized Apple backend"
1890 );
1891 project.manifest.backends.set_apple(apple_backend);
1892
1893 let android_backend_start = std::time::Instant::now();
1894 let android_backend = AndroidBackend::init(&project)
1895 .await
1896 .map_err(FailToOpenProject::BackendInit)?;
1897 info!(
1898 path = %project.root.display(),
1899 elapsed_ms = android_backend_start.elapsed().as_millis(),
1900 "Project::open initialized Android backend"
1901 );
1902 project.manifest.backends.set_android(android_backend);
1903
1904 let ffi_companion_start = std::time::Instant::now();
1905 project
1906 .scaffold_ffi_companion()
1907 .await
1908 .map_err(FailToOpenProject::BackendInit)?;
1909 info!(
1910 path = %project.root.display(),
1911 elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1912 "Project::open scaffolded native ffi companion"
1913 );
1914 }
1915
1916 if !is_playground
1917 && !skip_backend_init
1918 && open_mode == OpenMode::Full
1919 && (project.apple_backend().is_some() || project.android_backend().is_some())
1920 {
1921 let ffi_companion_start = std::time::Instant::now();
1922 project
1923 .scaffold_ffi_companion()
1924 .await
1925 .map_err(FailToOpenProject::BackendInit)?;
1926 info!(
1927 path = %project.root.display(),
1928 elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1929 "Project::open refreshed native ffi companion"
1930 );
1931 }
1932
1933 info!(
1934 path = %project.root.display(),
1935 open_mode = ?open_mode,
1936 elapsed_ms = total_start.elapsed().as_millis(),
1937 "Project::open completed"
1938 );
1939
1940 Ok(project)
1941 }
1942}
1943
1944impl Project {
1945 async fn save_manifest(&self) -> eyre::Result<()> {
1946 self.manifest.save(&self.root).await.map_err(Into::into)
1947 }
1948
1949 async fn remove_backend_relative_dir(&self, relative_path: &Path) -> eyre::Result<()> {
1950 let backend_path = self.managed_backends_root.join(relative_path);
1951 if backend_path.exists() {
1952 smol::fs::remove_dir_all(&backend_path).await?;
1953 }
1954 Ok(())
1955 }
1956}
1957
1958async fn apply_channel_selection(
1959 root: &Path,
1960 framework: ResolvedFramework,
1961 updates: Vec<(PathBuf, Option<Vec<u8>>)>,
1962) -> eyre::Result<()> {
1963 let mut previous = BTreeMap::new();
1964 for file in updates
1965 .iter()
1966 .map(|(file, _)| file.clone())
1967 .chain([root.join("Cargo.lock")])
1968 {
1969 let contents = match smol::fs::read(&file).await {
1970 Ok(contents) => Some(contents),
1971 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1972 Err(error) => return Err(error.into()),
1973 };
1974 previous.insert(file, contents);
1975 }
1976 let result = async {
1977 for (file, contents) in &updates {
1978 write_channel_file(file, contents.as_deref()).await?;
1979 }
1980 resolve_cargo_layout(root, Some(framework), CargoResolution::Update).await?;
1981 Ok(())
1982 }
1983 .await;
1984 if let Err(error) = result {
1985 for (file, contents) in previous {
1986 write_channel_file(&file, contents.as_deref())
1987 .await
1988 .map_err(|restore| {
1989 eyre::eyre!("{error}; could not restore {}: {restore}", file.display())
1990 })?;
1991 }
1992 return Err(error);
1993 }
1994 Ok(())
1995}
1996
1997async fn write_channel_file(path: &Path, contents: Option<&[u8]>) -> std::io::Result<()> {
1998 match contents {
1999 Some(contents) => smol::fs::write(path, contents).await,
2000 None => match smol::fs::remove_file(path).await {
2001 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2002 result => result,
2003 },
2004 }
2005}
2006
2007async fn resolve_cargo_layout(
2008 current_dir: &Path,
2009 framework: Option<ResolvedFramework>,
2010 mode: CargoResolution,
2011) -> eyre::Result<CargoLayout> {
2012 let root = current_dir.to_path_buf();
2013 let metadata = unblock(move || {
2014 let mut command = cargo_metadata::MetadataCommand::new();
2015 command.current_dir(root);
2016 match mode {
2017 CargoResolution::Local => {
2018 command.no_deps();
2019 }
2020 CargoResolution::Locked => {
2021 command.other_options(vec!["--locked".to_string()]);
2022 }
2023 CargoResolution::Update => {}
2024 }
2025 command.exec()
2026 })
2027 .await?;
2028 validate_resolved_cli(&metadata)?;
2029 if let Some(framework) = framework
2030 && framework.channel() != Some(FrameworkChannel::Stable)
2031 {
2032 let lockfile = smol::fs::read(current_dir.join("Water.lock")).await?;
2033 framework.validate_dependencies(&metadata, &lockfile)?;
2034 }
2035
2036 Ok(CargoLayout {
2037 target_dir: metadata.target_directory.into_std_path_buf(),
2038 workspace_root: metadata.workspace_root.into_std_path_buf(),
2039 })
2040}
2041
2042async fn cargo_tree(project_root: &Path, edges: &str, locked: bool) -> eyre::Result<String> {
2049 let application_manifest = dunce::canonicalize(project_root.join("Cargo.toml"))?;
2057 let metadata_manifest = application_manifest.clone();
2058 let metadata = unblock(move || {
2059 let mut command = cargo_metadata::MetadataCommand::new();
2060 command.no_deps().manifest_path(metadata_manifest);
2061 if locked {
2062 command.other_options(vec!["--locked".to_string()]);
2063 }
2064 command.exec()
2065 })
2066 .await?;
2067 let root = metadata
2068 .packages
2069 .iter()
2070 .find(|package| package.manifest_path.as_std_path() == application_manifest)
2071 .ok_or_else(|| {
2072 eyre::eyre!(
2073 "Cargo metadata omitted the application package at {}",
2074 application_manifest.display()
2075 )
2076 })?;
2077 let package_spec = root.id.to_string();
2078 let mut tree = Command::new("cargo");
2079 tree.arg("tree")
2080 .arg("--manifest-path")
2081 .arg(&application_manifest)
2082 .arg("--package")
2083 .arg(package_spec)
2084 .arg("--edges")
2085 .arg(edges)
2086 .arg("--prefix")
2087 .arg("none")
2088 .arg("--format")
2089 .arg("{p}")
2090 .current_dir(project_root);
2091 if locked {
2092 tree.arg("--locked");
2093 }
2094 let output = tree.output().await?;
2095 if !output.status.success() {
2096 return Err(eyre::eyre!(
2097 "failed to resolve runtime dependency graph for {}: {}",
2098 application_manifest.display(),
2099 String::from_utf8_lossy(&output.stderr).trim()
2100 ));
2101 }
2102
2103 String::from_utf8(output.stdout)
2104 .map_err(|error| eyre::eyre!("Cargo runtime dependency graph is not UTF-8: {error}"))
2105}
2106
2107async fn resolve_linked_runtime_packages(
2108 project_root: PathBuf,
2109 locked: bool,
2110) -> eyre::Result<BTreeMap<String, String>> {
2111 let tree = cargo_tree(&project_root, "normal", locked).await?;
2112 let mut linked = BTreeMap::new();
2113 for package in tree.lines() {
2114 let name = package
2115 .split_ascii_whitespace()
2116 .next()
2117 .ok_or_else(|| eyre::eyre!("Cargo emitted an empty runtime dependency entry"))?;
2118 linked.insert(name.to_string(), package.to_string());
2119 }
2120
2121 Ok(linked)
2122}
2123
2124async fn resolve_enabled_features(
2129 project_root: PathBuf,
2130 locked: bool,
2131) -> eyre::Result<BTreeSet<String>> {
2132 let tree = cargo_tree(&project_root, "features", locked).await?;
2133 let mut features = BTreeSet::new();
2134 for node in tree.lines() {
2135 if let Some(feature) = node
2136 .split_once(" feature \"")
2137 .and_then(|(_, rest)| rest.strip_suffix('"'))
2138 {
2139 features.insert(feature.to_string());
2140 }
2141 }
2142 Ok(features)
2143}
2144
2145use std::{
2146 collections::{BTreeMap, BTreeSet},
2147 path::{Path, PathBuf},
2148 sync::Arc,
2149};
2150
2151use serde::{Deserialize, Serialize};
2152use smol::{fs::read_to_string, process::Command, unblock};
2153use waterui_assets_planner::{LaunchConfig, ThemeConfig};
2154
2155use crate::{
2156 android::{backend::AndroidBackend, device::AndroidAbiProvider, platform::AndroidPlatform},
2157 apple::backend::AppleBackend,
2158 backend::{Backend, Backends},
2159 build::{BuildOptions, BuildProfile},
2160 device::{Artifact, Device, FailToRun, RunOptions, Running},
2161 platform::{PackageOptions, TargetBackend, TargetPlatform},
2162 project_types::{BundleIdentifier, CrateName, PermissionKey, generated_crate_name},
2163 templates::{self, TemplateContext},
2164 utils::command,
2165 web,
2166};
2167
2168#[derive(Debug, Serialize, Deserialize, Clone)]
2170pub struct Manifest {
2171 pub package: Package,
2173 #[serde(default, skip_serializing_if = "Backends::is_empty")]
2175 pub backends: Backends,
2176 #[serde(skip_serializing_if = "Option::is_none")]
2180 pub waterui_path: Option<String>,
2181 #[serde(default, skip_serializing_if = "Option::is_none")]
2183 pub framework: Option<ResolvedFramework>,
2184 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2186 pub permissions: BTreeMap<PermissionKey, PermissionEntry>,
2187 #[serde(default, skip_serializing_if = "Option::is_none")]
2189 pub app: Option<AppConfig>,
2190 #[serde(default, skip_serializing_if = "Option::is_none")]
2192 pub theme: Option<ThemeConfig>,
2193 #[serde(default, skip_serializing_if = "Option::is_none")]
2195 pub launch: Option<LaunchConfig>,
2196 #[serde(default, skip_serializing_if = "Option::is_none")]
2198 pub web: Option<web::WebConfig>,
2199}
2200
2201#[derive(Debug, Serialize, Deserialize, Clone)]
2203pub struct PermissionEntry {
2204 enable: bool,
2205 description: String,
2207}
2208
2209impl PermissionEntry {
2210 #[must_use]
2212 pub fn enabled(description: impl Into<String>) -> Self {
2213 Self {
2214 enable: true,
2215 description: description.into(),
2216 }
2217 }
2218
2219 #[must_use]
2221 pub const fn is_enabled(&self) -> bool {
2222 self.enable
2223 }
2224
2225 #[must_use]
2227 pub fn description(&self) -> &str {
2228 &self.description
2229 }
2230}
2231
2232#[derive(Debug, thiserror::Error)]
2234pub enum FailToOpenManifest {
2235 #[error("Failed to read manifest file: {0}")]
2237 ReadError(std::io::Error),
2238 #[error("Invalid manifest file: {0}")]
2240 InvalidManifest(toml::de::Error),
2241
2242 #[error("Manifest file not found at the specified path")]
2244 NotFound,
2245}
2246
2247#[derive(Debug, thiserror::Error)]
2249pub enum FailToSaveManifest {
2250 #[error("Failed to serialize manifest: {0}")]
2252 Serialize(toml::ser::Error),
2253 #[error("Failed to write manifest file: {0}")]
2255 Write(std::io::Error),
2256}
2257impl Manifest {
2258 pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenManifest> {
2265 let path = path.as_ref();
2266 let result = read_to_string(path).await;
2267
2268 match result {
2269 Ok(c) => toml::from_str(&c).map_err(FailToOpenManifest::InvalidManifest),
2270 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(FailToOpenManifest::NotFound),
2271 Err(e) => Err(FailToOpenManifest::ReadError(e)),
2272 }
2273 }
2274
2275 pub async fn save(&self, dir: impl AsRef<Path>) -> Result<(), FailToSaveManifest> {
2281 let path = dir.as_ref().join("Water.toml");
2282 let content = toml::to_string_pretty(self).map_err(FailToSaveManifest::Serialize)?;
2283 smol::fs::write(&path, content)
2284 .await
2285 .map_err(FailToSaveManifest::Write)
2286 }
2287
2288 #[must_use]
2290 pub fn new(package: Package) -> Self {
2291 Self {
2292 package,
2293 backends: Backends::default(),
2294 waterui_path: None,
2295 framework: None,
2296 permissions: BTreeMap::default(),
2297 app: None,
2298 theme: None,
2299 launch: None,
2300 web: None,
2301 }
2302 }
2303}
2304
2305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2307pub enum ResolvedWebViewBackend {
2308 System,
2310 Wpe,
2312 Cef,
2314}
2315
2316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2318pub struct BrowserRuntimePlan {
2319 pub webview: Option<ResolvedWebViewBackend>,
2321 pub chromium: bool,
2323}
2324
2325impl BrowserRuntimePlan {
2326 #[must_use]
2329 pub const fn requires_cef(self) -> bool {
2330 self.chromium || matches!(self.webview, Some(ResolvedWebViewBackend::Cef))
2331 }
2332}
2333
2334impl ResolvedWebViewBackend {
2335 #[must_use]
2337 pub const fn supports(self, platform: TargetPlatform, backend: TargetBackend) -> bool {
2338 match self {
2339 Self::System => matches!(
2340 (platform, backend),
2341 (
2342 TargetPlatform::MacOS,
2343 TargetBackend::Apple | TargetBackend::Hydrolysis
2344 ) | (
2345 TargetPlatform::IOS
2346 | TargetPlatform::IOSSimulator
2347 | TargetPlatform::VisionOS
2348 | TargetPlatform::VisionOSSimulator,
2349 TargetBackend::Apple
2350 ) | (TargetPlatform::Android, TargetBackend::Android)
2351 | (TargetPlatform::Linux, TargetBackend::Gtk4)
2352 | (TargetPlatform::Web, TargetBackend::Hydrolysis)
2353 ),
2354 Self::Wpe => {
2355 matches!(platform, TargetPlatform::Linux)
2356 && matches!(backend, TargetBackend::Gtk4 | TargetBackend::Hydrolysis)
2357 }
2358 Self::Cef => cef_is_supported(platform, backend),
2359 }
2360 }
2361
2362 pub const fn validate(
2369 self,
2370 platform: TargetPlatform,
2371 backend: TargetBackend,
2372 ) -> Result<Self, UnsupportedWebViewBackend> {
2373 if self.supports(platform, backend) {
2374 Ok(self)
2375 } else {
2376 Err(UnsupportedWebViewBackend {
2377 resolved: self,
2378 platform,
2379 backend,
2380 })
2381 }
2382 }
2383
2384 #[must_use]
2386 pub const fn as_str(self) -> &'static str {
2387 match self {
2388 Self::System => "system",
2389 Self::Wpe => "wpe",
2390 Self::Cef => "cef",
2391 }
2392 }
2393}
2394
2395const fn cef_is_supported(platform: TargetPlatform, backend: TargetBackend) -> bool {
2396 !matches!(backend, TargetBackend::Dew)
2397 && matches!(
2398 platform,
2399 TargetPlatform::MacOS | TargetPlatform::Linux | TargetPlatform::Windows
2400 )
2401}
2402
2403#[derive(Debug, thiserror::Error)]
2405#[error(
2406 "this application's WebView engine resolves to {resolved:?}, which is unsupported for \
2407 platform {platform:?} with backend {backend:?}. The engine follows the application's \
2408 dependencies: link waterui-browser-cef or waterui-browser-wpe to select one, or \
2409 neither to use the engine this platform bridges."
2410)]
2411pub struct UnsupportedWebViewBackend {
2412 resolved: ResolvedWebViewBackend,
2413 platform: TargetPlatform,
2414 backend: TargetBackend,
2415}
2416
2417#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2419pub struct AppConfig {
2420 #[serde(default, skip_serializing_if = "Option::is_none")]
2422 pub crates: Option<AppCrates>,
2423}
2424
2425#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2427pub struct AppCrates {
2428 #[serde(default, skip_serializing_if = "Option::is_none")]
2430 pub ffi: Option<CrateName>,
2431 #[serde(default, skip_serializing_if = "Option::is_none")]
2433 pub gtk: Option<CrateName>,
2434 #[serde(default, skip_serializing_if = "Option::is_none")]
2436 pub hydrolysis: Option<CrateName>,
2437 #[serde(default, skip_serializing_if = "Option::is_none")]
2439 pub winui: Option<CrateName>,
2440}
2441
2442#[derive(Debug, Serialize, Deserialize, Clone)]
2444pub struct Package {
2445 #[serde(rename = "type")]
2447 pub package_type: PackageType,
2448 pub name: String,
2450 pub bundle_identifier: BundleIdentifier,
2452 #[serde(
2454 default = "default_assets_path",
2455 skip_serializing_if = "is_default_assets_path"
2456 )]
2457 pub assets_path: String,
2458 #[serde(default, skip_serializing_if = "is_false")]
2460 pub accessory: bool,
2461}
2462
2463pub async fn read_project_crate_name(project_path: &Path) -> eyre::Result<String> {
2473 let cargo_toml = project_path.join("Cargo.toml");
2474 let cargo_content = smol::fs::read_to_string(&cargo_toml).await?;
2475 let cargo: toml::Table = cargo_content.parse()?;
2476 cargo
2477 .get("package")
2478 .and_then(|p| p.get("name"))
2479 .and_then(|n| n.as_str())
2480 .map(ToString::to_string)
2481 .ok_or_else(|| eyre::eyre!("Could not find package name in Cargo.toml"))
2482}
2483
2484fn same_directory(left: &Path, right: &Path) -> std::io::Result<bool> {
2491 Ok(std::fs::canonicalize(left)? == std::fs::canonicalize(right)?)
2492}
2493
2494fn default_assets_path() -> String {
2495 "assets".to_string()
2496}
2497
2498fn is_default_assets_path(path: &str) -> bool {
2499 path == "assets"
2500}
2501
2502#[allow(clippy::trivially_copy_pass_by_ref)]
2503const fn is_false(value: &bool) -> bool {
2504 !*value
2505}
2506
2507#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
2509#[serde(rename_all = "lowercase")]
2510pub enum PackageType {
2511 #[default]
2513 App,
2514 Playground,
2517}
2518
2519#[cfg(test)]
2520mod channel_tests {
2521 use super::*;
2522
2523 #[test]
2524 fn local_framework_requirement_is_checked_before_project_io() {
2525 smol::block_on(async {
2526 let directory = tempfile::tempdir().unwrap();
2527 let framework_root = directory.path().join("framework");
2528 let project_root = directory.path().join("consumer");
2529 smol::fs::create_dir(&framework_root).await.unwrap();
2530 let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
2531 minimum.major += 1;
2532 let mut metadata = toml::toml! {
2533 [package.metadata.waterui]
2534 minimum-cli-version = "0.1.4"
2535 android-min-api-level = 26
2536 };
2537 metadata["package"]["metadata"]["waterui"]["minimum-cli-version"] =
2538 toml::Value::String(minimum.to_string());
2539 smol::fs::write(
2540 framework_root.join("Cargo.toml"),
2541 toml::to_string(&metadata).unwrap(),
2542 )
2543 .await
2544 .unwrap();
2545 let bundle_identifier =
2546 BundleIdentifier::try_from("dev.waterui.compatibility").unwrap();
2547 let options = CreateOptions {
2548 name: "Compatibility".into(),
2549 bundle_identifier: bundle_identifier.clone(),
2550 package_type: PackageType::Playground,
2551 waterui_path: Some(framework_root),
2552 channel: None,
2553 framework_manifest: None,
2554 framework: None,
2555 author: String::new(),
2556 backends: Vec::new(),
2557 web: None,
2558 };
2559 let error = Project::create(&project_root, options)
2560 .await
2561 .unwrap_err()
2562 .to_string();
2563 assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2564 assert!(error.contains(&format!(
2565 "cargo install waterui-cli --git {} --locked",
2566 env!("CARGO_PKG_REPOSITORY")
2567 )));
2568 assert!(!project_root.exists());
2569
2570 smol::fs::create_dir(&project_root).await.unwrap();
2571 let mut manifest = Manifest::new(Package {
2572 name: "Compatibility".into(),
2573 bundle_identifier,
2574 package_type: PackageType::Playground,
2575 assets_path: default_assets_path(),
2576 accessory: false,
2577 });
2578 manifest.waterui_path = Some("../framework".into());
2579 manifest.save(&project_root).await.unwrap();
2580 let error = Project::open_for_preview_build(&project_root)
2581 .await
2582 .unwrap_err()
2583 .to_string();
2584 assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2585 assert!(!project_root.join("Cargo.lock").exists());
2586 });
2587 }
2588
2589 #[test]
2590 fn failed_channel_selection_preserves_project_files() {
2591 smol::block_on(async {
2592 let directory = tempfile::tempdir().unwrap();
2593 let root = directory.path();
2594 let originals = [
2595 ("Cargo.toml", b"original manifest".as_slice()),
2596 ("Cargo.lock", b"original dependency lock".as_slice()),
2597 ("Water.toml", b"original project configuration".as_slice()),
2598 ];
2599 for (name, contents) in originals {
2600 smol::fs::write(root.join(name), contents).await.unwrap();
2601 }
2602 let updates = ["Cargo.toml", "Cargo.lock", "Water.toml", "Water.lock"]
2603 .into_iter()
2604 .map(|name| (root.join(name), Some(b"invalid selected manifest".to_vec())))
2605 .collect();
2606 assert!(
2607 apply_channel_selection(
2608 root,
2609 crate::framework::test_fixtures::stable_framework(),
2610 updates
2611 )
2612 .await
2613 .is_err()
2614 );
2615 for (name, contents) in originals {
2616 assert_eq!(smol::fs::read(root.join(name)).await.unwrap(), contents);
2617 }
2618 assert!(!root.join("Water.lock").exists());
2619 });
2620 }
2621}
2622
2623#[cfg(test)]
2624mod webview_backend_tests {
2625 use super::{
2626 ResolvedWebViewBackend, TargetBackend, TargetPlatform, resolve_enabled_features,
2627 resolve_linked_runtime_packages,
2628 };
2629
2630 #[test]
2635 fn the_platform_bridge_is_the_selection_without_an_engine_crate() {
2636 assert_eq!(
2637 ResolvedWebViewBackend::System
2638 .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2639 .expect("macOS Hydrolysis bridges WKWebView"),
2640 ResolvedWebViewBackend::System
2641 );
2642 assert_eq!(
2643 ResolvedWebViewBackend::System
2644 .validate(TargetPlatform::Linux, TargetBackend::Gtk4)
2645 .expect("GTK bridges WebKitGTK"),
2646 ResolvedWebViewBackend::System
2647 );
2648 assert!(
2649 ResolvedWebViewBackend::System
2650 .validate(TargetPlatform::Linux, TargetBackend::Hydrolysis)
2651 .is_err()
2652 );
2653 assert!(
2654 ResolvedWebViewBackend::System
2655 .validate(TargetPlatform::Windows, TargetBackend::Hydrolysis)
2656 .is_err()
2657 );
2658 }
2659
2660 #[test]
2661 fn unsupported_engine_combinations_fail_before_build() {
2662 assert!(
2663 ResolvedWebViewBackend::Wpe
2664 .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2665 .is_err()
2666 );
2667 assert!(
2668 ResolvedWebViewBackend::Cef
2669 .validate(TargetPlatform::Android, TargetBackend::Android)
2670 .is_err()
2671 );
2672 assert_eq!(
2673 ResolvedWebViewBackend::Cef
2674 .validate(TargetPlatform::MacOS, TargetBackend::Apple)
2675 .expect("CEF must compose with the native Apple renderer on macOS"),
2676 ResolvedWebViewBackend::Cef
2677 );
2678 }
2679
2680 #[test]
2681 fn cef_is_available_to_every_non_dew_backend_on_desktop_platforms() {
2682 for backend in [
2683 TargetBackend::Apple,
2684 TargetBackend::Android,
2685 TargetBackend::Gtk4,
2686 TargetBackend::Hydrolysis,
2687 ] {
2688 for platform in [
2689 TargetPlatform::MacOS,
2690 TargetPlatform::Linux,
2691 TargetPlatform::Windows,
2692 ] {
2693 assert_eq!(
2694 ResolvedWebViewBackend::Cef
2695 .validate(platform, backend)
2696 .expect("CEF availability must not depend on the WaterUI backend"),
2697 ResolvedWebViewBackend::Cef
2698 );
2699 }
2700 }
2701 }
2702
2703 #[test]
2704 fn cef_rejects_dew_and_platforms_without_cef_distributions() {
2705 for platform in [
2706 TargetPlatform::MacOS,
2707 TargetPlatform::Linux,
2708 TargetPlatform::Windows,
2709 ] {
2710 assert!(
2711 ResolvedWebViewBackend::Cef
2712 .validate(platform, TargetBackend::Dew)
2713 .is_err()
2714 );
2715 }
2716 for (platform, backend) in [
2717 (TargetPlatform::Android, TargetBackend::Android),
2718 (TargetPlatform::IOS, TargetBackend::Apple),
2719 (TargetPlatform::Web, TargetBackend::Hydrolysis),
2720 ] {
2721 assert!(
2722 ResolvedWebViewBackend::Cef
2723 .validate(platform, backend)
2724 .is_err()
2725 );
2726 }
2727 }
2728
2729 #[test]
2735 #[ignore = "clones the pinned framework revision"]
2736 fn runtime_graph_is_scoped_to_the_selected_application() {
2737 let repository = crate::pinned_framework::checkout();
2738 let chromium = smol::block_on(resolve_linked_runtime_packages(
2739 repository.join("examples/chromium"),
2740 true,
2741 ))
2742 .expect("Chromium example runtime graph must resolve");
2743 assert!(
2744 chromium.contains_key("waterui-chromium"),
2745 "Chromium example graph: {chromium:#?}"
2746 );
2747 assert!(
2750 chromium.contains_key("waterui-browser-cef"),
2751 "Chromium example graph: {chromium:#?}"
2752 );
2753 assert!(
2754 !chromium.contains_key("waterui-browser-wpe"),
2755 "Chromium example graph: {chromium:#?}"
2756 );
2757 assert!(
2764 chromium.contains_key("waterui-webview"),
2765 "waterui-chromium shares the webview asset-server types: {chromium:#?}"
2766 );
2767 let chromium_features = smol::block_on(resolve_enabled_features(
2768 repository.join("examples/chromium"),
2769 true,
2770 ))
2771 .expect("Chromium example feature graph must resolve");
2772 assert!(
2773 !chromium_features.contains("webview"),
2774 "a Chromium-only application must not enable the standard WebView \
2775 component: {chromium_features:#?}"
2776 );
2777
2778 let webview = smol::block_on(resolve_linked_runtime_packages(
2779 repository.join("examples/webview"),
2780 true,
2781 ))
2782 .expect("WebView example runtime graph must resolve");
2783 assert!(
2784 webview.contains_key("waterui-webview"),
2785 "WebView example graph: {webview:#?}"
2786 );
2787 let webview_features = smol::block_on(resolve_enabled_features(
2788 repository.join("examples/webview"),
2789 true,
2790 ))
2791 .expect("WebView example feature graph must resolve");
2792 assert!(
2793 webview_features.contains("webview"),
2794 "the WebView example enables the facade `webview` feature: {webview_features:#?}"
2795 );
2796 assert!(
2797 !webview.contains_key("waterui-browser-cef"),
2798 "WebView example graph: {webview:#?}"
2799 );
2800 assert!(
2801 !webview.contains_key("waterui-chromium"),
2802 "WebView example graph: {webview:#?}"
2803 );
2804
2805 let cef_webview = smol::block_on(resolve_linked_runtime_packages(
2806 repository.join("examples/webview-cef"),
2807 true,
2808 ))
2809 .expect("CEF WebView example runtime graph must resolve");
2810 assert!(
2811 cef_webview.contains_key("waterui-browser-cef"),
2812 "CEF WebView example graph: {cef_webview:#?}"
2813 );
2814 assert!(
2815 !cef_webview.contains_key("waterui-browser-wpe"),
2816 "CEF WebView example graph: {cef_webview:#?}"
2817 );
2818 }
2819
2820 #[test]
2828 #[ignore = "clones the pinned framework revision"]
2829 fn the_map_capability_is_read_from_the_application_graph() {
2830 let repository = crate::pinned_framework::checkout();
2831
2832 let map = smol::block_on(resolve_linked_runtime_packages(
2833 repository.join("examples/map"),
2834 true,
2835 ))
2836 .expect("map example runtime graph must resolve");
2837 assert!(
2838 map.contains_key("waterui-map"),
2839 "map example graph: {map:#?}"
2840 );
2841
2842 let webview = smol::block_on(resolve_linked_runtime_packages(
2843 repository.join("examples/webview"),
2844 true,
2845 ))
2846 .expect("WebView example runtime graph must resolve");
2847 assert!(
2848 !webview.contains_key("waterui-map"),
2849 "an application that shows no map must not carry the map stack: {webview:#?}"
2850 );
2851 }
2852}
2853
2854#[cfg(test)]
2855mod scaffold_tests {
2856 use std::path::Path;
2857
2858 use super::{BundleIdentifier, CreateOptions, PackageType, Project, TargetBackend};
2859
2860 #[test]
2865 fn create_scaffolds_the_assets_directory_declared_by_the_manifest() {
2866 let dir = tempfile::tempdir().expect("temp dir");
2867 let root = dir.path().join("water-example");
2868
2869 let project = smol::block_on(Project::create(
2870 &root,
2871 CreateOptions {
2872 name: "Water Example".to_string(),
2873 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2874 .expect("bundle identifier"),
2875 package_type: PackageType::Playground,
2876 waterui_path: None,
2877 channel: None,
2878 framework_manifest: None,
2879 framework: Some(crate::framework::test_fixtures::stable_framework()),
2882 author: "Lexo Liu".to_string(),
2883 backends: Vec::new(),
2884 web: None,
2885 },
2886 ))
2887 .expect("project creation must succeed");
2888
2889 let assets = project.assets_dir();
2890 assert!(
2891 assets.is_dir(),
2892 "the assets root {} must exist after `water create`",
2893 assets.display()
2894 );
2895 assert_eq!(
2896 assets,
2897 root.join(project.assets_path()),
2898 "the scaffolded directory must be the one the manifest declares"
2899 );
2900 assert!(
2901 assets.join("README.md").is_file(),
2902 "a tracked file keeps the assets directory present in git"
2903 );
2904 }
2905
2906 #[test]
2911 fn create_rejects_backends_whose_packages_stable_withholds() {
2912 for backend in [
2913 TargetBackend::Gtk4,
2914 TargetBackend::WinUi,
2915 TargetBackend::Dew,
2916 ] {
2917 let dir = tempfile::tempdir().expect("temp dir");
2918 let root = dir.path().join("water-example");
2919 let error = smol::block_on(Project::create(
2920 &root,
2921 CreateOptions {
2922 name: "Water Example".to_string(),
2923 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2924 .expect("bundle identifier"),
2925 package_type: PackageType::App,
2926 waterui_path: None,
2927 channel: None,
2928 framework_manifest: None,
2929 framework: Some(crate::framework::test_fixtures::stable_framework()),
2930 author: "Lexo Liu".to_string(),
2931 backends: vec![backend],
2932 web: None,
2933 },
2934 ))
2935 .expect_err("a withheld scaffold package must reject create");
2936 let error = error.to_string();
2937 for package in backend.scaffold_packages() {
2938 assert!(error.contains(package), "{error}");
2939 }
2940 assert!(error.contains("stable"), "{error}");
2941 assert!(error.contains("--channel dev"), "{error}");
2942 assert!(
2943 !root.exists(),
2944 "the rejection precedes any file write: {error}"
2945 );
2946 }
2947 }
2948
2949 #[test]
2954 fn shipped_binary_names_drop_the_project_root_tag() {
2955 let dir = tempfile::tempdir().expect("temp dir");
2956 let root = dir.path().join("water-example");
2957 let project = smol::block_on(Project::create(
2958 &root,
2959 CreateOptions {
2960 name: "Water Example".to_string(),
2961 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2962 .expect("bundle identifier"),
2963 package_type: PackageType::Playground,
2964 waterui_path: None,
2965 channel: None,
2966 framework_manifest: None,
2967 framework: Some(crate::framework::test_fixtures::stable_framework()),
2968 author: "Lexo Liu".to_string(),
2969 backends: Vec::new(),
2970 web: None,
2971 },
2972 ))
2973 .expect("project creation must succeed");
2974
2975 for (shipped, tagged) in [
2976 (project.gtk4_binary_name(), project.gtk_backend_crate_name()),
2977 (
2978 project.hydrolysis_binary_name(),
2979 project.hydrolysis_backend_crate_name(),
2980 ),
2981 (
2982 project.winui_binary_name(),
2983 project.winui_backend_crate_name(),
2984 ),
2985 (
2986 project.esp32_binary_name(),
2987 project.esp32_backend_crate_name(),
2988 ),
2989 ] {
2990 assert!(
2991 tagged.as_str().starts_with(&format!("{shipped}-")),
2992 "the build name must be the shipped name plus the tag: {tagged}"
2993 );
2994 assert_eq!(
2995 tagged.as_str().len() - shipped.as_str().len(),
2996 9,
2997 "the tag is a dash plus eight hex digits: {tagged}"
2998 );
2999 }
3000 }
3001
3002 #[test]
3008 fn same_named_projects_stage_packaged_binaries_under_their_own_backends() {
3009 let dir = tempfile::tempdir().expect("temp dir");
3010 let create = |root: &Path| {
3011 smol::block_on(Project::create(
3012 root,
3013 CreateOptions {
3014 name: "Water Example".to_string(),
3015 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
3016 .expect("bundle identifier"),
3017 package_type: PackageType::App,
3018 waterui_path: None,
3019 channel: None,
3020 framework_manifest: None,
3021 framework: Some(crate::framework::test_fixtures::stable_framework()),
3022 author: "Lexo Liu".to_string(),
3023 backends: Vec::new(),
3024 web: None,
3025 },
3026 ))
3027 .expect("project creation must succeed")
3028 };
3029 let first = create(&dir.path().join("one/demo"));
3030 let second = create(&dir.path().join("two/demo"));
3031
3032 let staged = |project: &Project| {
3033 crate::platforming::packaging::dist_dir(
3034 &project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>(),
3035 "linux",
3036 Some("release"),
3037 )
3038 .join(project.hydrolysis_binary_name().as_str())
3039 };
3040 let first_staged = staged(&first);
3041 let second_staged = staged(&second);
3042
3043 assert_ne!(
3044 first_staged, second_staged,
3045 "same-named projects must not stage the same shipped path"
3046 );
3047 for (project, staged) in [(&first, &first_staged), (&second, &second_staged)] {
3048 assert!(
3049 staged.starts_with(
3050 project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>()
3051 ),
3052 "{} must live under the project's own managed backend directory",
3053 staged.display()
3054 );
3055 }
3056 }
3057}
3058
3059#[cfg(test)]
3060mod local_patch_tests {
3061 use std::path::Path;
3062
3063 use super::Project;
3064
3065 #[test]
3069 fn a_local_checkout_project_follows_the_checkouts_patch_tables() {
3070 let directory = tempfile::tempdir().expect("temp dir");
3071 let checkout = directory.path().join("waterui");
3072 std::fs::create_dir_all(&checkout).expect("checkout dir");
3073 std::fs::write(
3074 checkout.join("Cargo.toml"),
3075 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3076 )
3077 .expect("checkout manifest");
3078 let app = directory.path().join("app");
3079 std::fs::create_dir_all(&app).expect("project dir");
3080 let cargo_path = app.join("Cargo.toml");
3081 std::fs::write(
3082 &cargo_path,
3083 "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../waterui\" }\n\n[patch.crates-io]\nwaterui-core = { path = \"../elsewhere/core\" }\nstale = { path = \"../elsewhere/stale\" }\n",
3084 )
3085 .expect("project manifest");
3086
3087 smol::block_on(Project::refresh_local_patches(
3088 &app,
3089 Path::new("../waterui"),
3090 ))
3091 .expect("tables refresh");
3092 let refreshed = std::fs::read_to_string(&cargo_path).expect("refreshed manifest");
3093 let manifest = cargo_toml::Manifest::from_str(&refreshed).expect("manifest parses");
3094 let crates_io = &manifest.patch["crates-io"];
3095 let cargo_toml::Dependency::Detailed(core) = &crates_io["waterui-core"] else {
3096 panic!("the core patch is a path dependency");
3097 };
3098 assert_eq!(core.path.as_deref(), Some("../waterui/core"));
3099 assert!(!crates_io.contains_key("stale"));
3100 assert!(crates_io.contains_key("vello"));
3101 assert!(refreshed.starts_with("[package]"));
3102
3103 smol::block_on(Project::refresh_local_patches(
3104 &app,
3105 Path::new("../waterui"),
3106 ))
3107 .expect("second refresh");
3108 assert_eq!(
3109 std::fs::read_to_string(&cargo_path).expect("manifest after the second refresh"),
3110 refreshed
3111 );
3112 }
3113
3114 #[test]
3119 fn a_member_of_the_checkouts_workspace_keeps_its_manifest() {
3120 let directory = tempfile::tempdir().expect("temp dir");
3121 let checkout = directory.path().join("waterui");
3122 let app = checkout.join("examples/app");
3123 std::fs::create_dir_all(&app).expect("project dir");
3124 std::fs::write(
3125 checkout.join("Cargo.toml"),
3126 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3127 )
3128 .expect("checkout manifest");
3129 let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../..\" }\n";
3130 let cargo_path = app.join("Cargo.toml");
3131 std::fs::write(&cargo_path, manifest).expect("project manifest");
3132
3133 smol::block_on(Project::refresh_local_patches(&app, Path::new("../..")))
3134 .expect("tables refresh");
3135
3136 assert_eq!(
3137 std::fs::read_to_string(&cargo_path).expect("manifest after the refresh"),
3138 manifest
3139 );
3140 }
3141
3142 #[test]
3146 fn a_member_of_a_foreign_workspace_is_told_where_the_tables_belong() {
3147 let directory = tempfile::tempdir().expect("temp dir");
3148 let checkout = directory.path().join("waterui");
3149 std::fs::create_dir_all(&checkout).expect("checkout dir");
3150 std::fs::write(
3151 checkout.join("Cargo.toml"),
3152 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3153 )
3154 .expect("checkout manifest");
3155 let workspace = directory.path().join("their-workspace");
3156 let app = workspace.join("app");
3157 std::fs::create_dir_all(&app).expect("project dir");
3158 std::fs::write(
3159 workspace.join("Cargo.toml"),
3160 "[workspace]\nmembers = [\"app\"]\n",
3161 )
3162 .expect("workspace manifest");
3163 let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../../waterui\" }\n";
3164 let cargo_path = app.join("Cargo.toml");
3165 std::fs::write(&cargo_path, manifest).expect("project manifest");
3166
3167 let error = smol::block_on(Project::refresh_local_patches(
3168 &app,
3169 Path::new("../../waterui"),
3170 ))
3171 .expect_err("a copy here would be ignored");
3172
3173 let message = error.to_string();
3174 assert!(message.contains("their-workspace"), "{message}");
3175 assert_eq!(
3176 std::fs::read_to_string(&cargo_path).expect("manifest after the refusal"),
3177 manifest
3178 );
3179 }
3180}