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 ([backends.esp32] device settings and `backend_path` source \
971 selections are the exceptions)"
972 )]
973 BackendsNotAllowedInPlayground,
974
975 #[error("Failed to initialize backend: {0}")]
977 BackendInit(#[from] crate::backend::FailToInitBackend),
978
979 #[error("Failed to prepare managed build cache: {0}")]
981 BuildCache(#[from] eyre::Report),
982}
983
984#[derive(Debug, thiserror::Error)]
986pub enum FailToCreateProject {
987 #[error("Failed to resolve framework: {0}")]
989 Framework(eyre::Report),
990 #[error("Directory already exists: {0}")]
992 DirectoryExists(PathBuf),
993 #[error("{0} is already a WaterUI project (Water.toml exists)")]
995 AlreadyProject(PathBuf),
996 #[error(
999 "{0} already contains a Cargo.toml; merge the generated scaffold manually or remove it first"
1000 )]
1001 CargoManifestExists(PathBuf),
1002 #[error("Failed to create directory: {0}")]
1004 CreateDir(std::io::Error),
1005 #[error("Failed to scaffold project: {0}")]
1007 Scaffold(std::io::Error),
1008 #[error("Failed to save manifest: {0}")]
1010 SaveManifest(#[from] FailToSaveManifest),
1011
1012 #[error("Failed to get Cargo metadata: {0}")]
1014 TargetDirError(#[from] cargo_metadata::Error),
1015
1016 #[error("Failed to resolve managed build cache: {0}")]
1018 BuildCache(#[from] eyre::Report),
1019
1020 #[error("Failed to initialize git repository: {0}")]
1022 GitInit(std::io::Error),
1023 #[error("Failed to check git repository status: {0}")]
1025 GitStatus(std::io::Error),
1026}
1027
1028#[derive(Debug, Clone)]
1030pub struct CreateOptions {
1031 pub name: String,
1033 pub bundle_identifier: BundleIdentifier,
1035 pub package_type: PackageType,
1037 pub waterui_path: Option<PathBuf>,
1039 pub channel: Option<FrameworkChannel>,
1042 pub framework_manifest: Option<PathBuf>,
1046 pub framework: Option<ResolvedFramework>,
1050 pub author: String,
1052 pub backends: Vec<TargetBackend>,
1057 pub web: Option<WebScaffold>,
1060}
1061
1062#[derive(Debug, Clone)]
1064pub struct WebScaffold {
1065 pub package_manager: web::PackageManager,
1067 pub include_arg: String,
1070}
1071
1072impl CreateOptions {
1073 fn crate_name(&self) -> Result<CrateName, FailToCreateProject> {
1074 let name = self
1075 .name
1076 .chars()
1077 .map(|character| {
1078 if character.is_alphanumeric() {
1079 character.to_ascii_lowercase()
1080 } else {
1081 '_'
1082 }
1083 })
1084 .collect::<String>();
1085 CrateName::try_from(name).map_err(|error| {
1086 FailToCreateProject::Scaffold(std::io::Error::new(
1087 std::io::ErrorKind::InvalidInput,
1088 error,
1089 ))
1090 })
1091 }
1092
1093 async fn resolve_framework(&mut self) -> eyre::Result<(ResolvedFramework, Option<Vec<u8>>)> {
1099 let selected = [
1100 self.waterui_path.is_some(),
1101 self.channel.is_some(),
1102 self.framework_manifest.is_some(),
1103 self.framework.is_some(),
1104 ]
1105 .into_iter()
1106 .filter(|selected| *selected)
1107 .count();
1108 if selected > 1 {
1109 eyre::bail!(
1110 "a framework channel, a local source path, a framework manifest, \
1111 and a resolved framework are mutually exclusive"
1112 );
1113 }
1114 if let Some(path) = &self.waterui_path {
1115 let path = path.clone();
1120 let root = unblock(move || dunce::canonicalize(path)).await?;
1121 self.waterui_path = Some(root.clone());
1122 return Ok((ResolvedFramework::for_local_checkout(&root).await?, None));
1123 }
1124 if let Some(path) = &self.framework_manifest {
1125 return ResolvedFramework::resolve_manifest(path).await;
1126 }
1127 if let Some(framework) = &self.framework {
1128 return Ok((framework.clone(), None));
1129 }
1130 ResolvedFramework::resolve(self.channel.unwrap_or_default()).await
1131 }
1132}
1133
1134impl Project {
1135 async fn scaffold_ffi_companion(&self) -> Result<(), crate::backend::FailToInitBackend> {
1136 let manifest = self.manifest();
1137 let app_name = manifest
1138 .package
1139 .name
1140 .chars()
1141 .filter(|c| c.is_alphanumeric())
1142 .collect::<String>();
1143 let webview_enabled = self
1144 .uses_standard_webview()
1145 .await
1146 .map_err(crate::backend::FailToInitBackend::Config)?;
1147 let chromium_enabled = self
1148 .links_runtime_package("waterui-chromium")
1149 .await
1150 .map_err(crate::backend::FailToInitBackend::Config)?;
1151 let browser_engine = self
1152 .linked_browser_engine()
1153 .await
1154 .map_err(crate::backend::FailToInitBackend::Config)?;
1155 let framework = self
1156 .resolved_framework()
1157 .await
1158 .map_err(crate::backend::FailToInitBackend::Config)?;
1159 let ctx = TemplateContext::for_project_manifest(
1160 manifest,
1161 self.crate_name().clone(),
1162 app_name,
1163 &framework,
1164 )
1165 .with_backend_project_path(self.ffi_crate_path())
1166 .with_project_root_path(self.root.clone())
1167 .with_webview_enabled(webview_enabled)
1168 .with_chromium_enabled(chromium_enabled)
1169 .with_browser_engine(browser_engine);
1170
1171 templates::ffi::scaffold(&self.ffi_crate_path(), &ctx, &self.ffi_crate_name())
1172 .await
1173 .map_err(crate::backend::FailToInitBackend::Io)?;
1174
1175 let lockfile = self
1176 .lockfile_path()
1177 .await
1178 .map_err(crate::backend::FailToInitBackend::Config)?;
1179 templates::ffi::seed_lockfile(&self.ffi_crate_path(), &lockfile)
1180 .await
1181 .map_err(crate::backend::FailToInitBackend::Io)
1182 }
1183
1184 pub async fn scaffold_preview_ffi_companion(
1190 &self,
1191 workspace_root: &Path,
1192 ) -> Result<PathBuf, crate::backend::FailToInitBackend> {
1193 let manifest = self.manifest();
1194 let app_name = manifest
1195 .package
1196 .name
1197 .chars()
1198 .filter(|c| c.is_alphanumeric())
1199 .collect::<String>();
1200 let framework = self
1201 .resolved_framework()
1202 .await
1203 .map_err(crate::backend::FailToInitBackend::Config)?;
1204 let ctx = TemplateContext::for_project_manifest(
1205 manifest,
1206 self.crate_name().clone(),
1207 app_name,
1208 &framework,
1209 )
1210 .with_backend_project_path(self.preview_ffi_crate_path(workspace_root))
1211 .with_project_root_path(self.root.clone());
1212
1213 let crate_path = self.preview_ffi_crate_path(workspace_root);
1214 templates::preview_ffi::scaffold(&crate_path, &ctx, &self.preview_ffi_crate_name())
1215 .await
1216 .map_err(crate::backend::FailToInitBackend::Io)?;
1217 Ok(crate_path)
1218 }
1219
1220 async fn remove_ffi_companion_if_unused(&self) -> eyre::Result<()> {
1221 if self.apple_backend().is_some() || self.android_backend().is_some() {
1222 return Ok(());
1223 }
1224
1225 let ffi_path = self.ffi_crate_path();
1226 if ffi_path.exists() {
1227 smol::fs::remove_dir_all(&ffi_path).await?;
1228 }
1229
1230 Ok(())
1231 }
1232
1233 pub async fn create(
1245 path: impl AsRef<Path>,
1246 options: CreateOptions,
1247 ) -> Result<Self, FailToCreateProject> {
1248 let path = path.as_ref().to_path_buf();
1249
1250 if path.exists() {
1252 return Err(FailToCreateProject::DirectoryExists(path));
1253 }
1254
1255 Self::scaffold_project(path, options).await
1256 }
1257
1258 pub async fn init(
1268 path: impl AsRef<Path>,
1269 options: CreateOptions,
1270 ) -> Result<Self, FailToCreateProject> {
1271 let path = path.as_ref().to_path_buf();
1272 if path.join("Water.toml").exists() {
1273 return Err(FailToCreateProject::AlreadyProject(path));
1274 }
1275 if path.join("Cargo.toml").exists() {
1276 return Err(FailToCreateProject::CargoManifestExists(path));
1277 }
1278 Self::scaffold_project(path, options).await
1279 }
1280
1281 async fn scaffold_project(
1282 path: PathBuf,
1283 mut options: CreateOptions,
1284 ) -> Result<Self, FailToCreateProject> {
1285 let crate_name = options.crate_name()?;
1287 let (framework, lockfile) = options
1288 .resolve_framework()
1289 .await
1290 .map_err(FailToCreateProject::Framework)?;
1291
1292 for backend in &options.backends {
1295 for package in backend.scaffold_packages() {
1296 framework
1297 .require_distributable(package)
1298 .map_err(FailToCreateProject::Framework)?;
1299 }
1300 }
1301
1302 smol::fs::create_dir_all(&path)
1306 .await
1307 .map_err(FailToCreateProject::CreateDir)?;
1308
1309 let ctx = TemplateContext::for_create_options(&options, crate_name.clone(), &framework);
1311
1312 let assets_path = default_assets_path();
1315
1316 templates::root::scaffold(&path, &ctx, &assets_path)
1318 .await
1319 .map_err(FailToCreateProject::Scaffold)?;
1320
1321 crate::mcp::ensure_mcp_json(&path)
1324 .await
1325 .map_err(FailToCreateProject::Scaffold)?;
1326 if let Some(lockfile) = lockfile {
1327 let contents = ctx
1328 .framework
1329 .cargo_lock(&lockfile)
1330 .map_err(FailToCreateProject::Framework)?
1331 .to_string();
1332 smol::fs::write(path.join("Water.lock"), lockfile)
1333 .await
1334 .map_err(FailToCreateProject::Scaffold)?;
1335 smol::fs::write(path.join("Cargo.lock"), contents)
1336 .await
1337 .map_err(FailToCreateProject::Scaffold)?;
1338 }
1339
1340 let mut backends = Backends::default();
1342 if options.package_type == PackageType::App {
1343 backends.set_path("backends");
1344 }
1345
1346 let manifest = Manifest {
1347 package: Package {
1348 package_type: options.package_type,
1349 name: options.name.clone(),
1350 bundle_identifier: options.bundle_identifier.clone(),
1351 assets_path,
1352 accessory: false,
1353 },
1354 backends,
1355 waterui_path: options
1356 .waterui_path
1357 .as_ref()
1358 .map(|p| p.display().to_string()),
1359 framework: framework.channel().is_some().then_some(framework),
1362 permissions: BTreeMap::default(),
1363 app: None,
1364 theme: None,
1365 launch: None,
1366 web: options.web.as_ref().map(|scaffold| web::WebConfig {
1367 package_manager: scaffold.package_manager,
1368 }),
1369 };
1370
1371 manifest.save(&path).await?;
1373
1374 Self::ensure_git_init(&path).await?;
1376
1377 let managed_backends_root = if options.package_type == PackageType::Playground {
1378 crate::water_dir::project_build_cache_dir(&path)
1379 .await
1380 .map_err(FailToCreateProject::BuildCache)?
1381 } else {
1382 path.join(manifest.backends.path())
1383 };
1384
1385 let cargo_layout = if let Some(framework) = &manifest.framework {
1386 let layout =
1387 resolve_cargo_layout(&path, Some(framework.clone()), CargoResolution::Update)
1388 .await
1389 .map_err(FailToCreateProject::Framework)?;
1390 futures_util::future::ready(Ok::<CargoLayout, String>(layout))
1391 .boxed()
1392 .shared()
1393 } else {
1394 spawn_cargo_layout_resolution(&path, None, true)
1395 };
1396 Ok(Self {
1397 root: path,
1398 manifest,
1399 crate_name,
1400 cargo_layout,
1401 linked_packages: Arc::new(async_lock::OnceCell::new()),
1402 enabled_features: Arc::new(async_lock::OnceCell::new()),
1403 managed_backends_root,
1404 })
1405 }
1406
1407 async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
1412 let mut cmd = Command::new("git");
1415
1416 let is_in_git = command(&mut cmd)
1417 .args(["rev-parse", "--git-dir"])
1418 .current_dir(path)
1419 .output()
1420 .await
1421 .map_err(FailToCreateProject::GitStatus)?
1422 .status
1423 .success();
1424
1425 if !is_in_git {
1426 let mut cmd = Command::new("git");
1428 command(&mut cmd)
1429 .args(["init"])
1430 .current_dir(path)
1431 .status()
1432 .await
1433 .map_err(FailToCreateProject::GitInit)?;
1434 }
1435
1436 Ok(())
1437 }
1438
1439 pub async fn init_apple_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1446 use crate::backend::Backend;
1447
1448 let backend = AppleBackend::init(self).await?;
1449 self.scaffold_ffi_companion().await?;
1450 self.manifest.backends.set_apple(backend);
1451 self.manifest
1452 .save(&self.root)
1453 .await
1454 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1455 Ok(())
1456 }
1457
1458 pub async fn init_android_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1465 use crate::backend::Backend;
1466
1467 let backend = AndroidBackend::init(self).await?;
1468 self.scaffold_ffi_companion().await?;
1469 self.manifest.backends.set_android(backend);
1470 self.manifest
1471 .save(&self.root)
1472 .await
1473 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1474 Ok(())
1475 }
1476
1477 pub async fn init_gtk4_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1484 use crate::{backend::Backend, gtk4::backend::Gtk4Backend};
1485
1486 self.require_distributable_backend(TargetBackend::Gtk4)
1487 .await?;
1488 if !cfg!(target_os = "linux") {
1489 return Err(crate::backend::FailToInitBackend::Io(
1490 std::io::Error::other("GTK4 backend is only supported on Linux hosts"),
1491 ));
1492 }
1493
1494 let backend = Gtk4Backend::init(self).await?;
1495 self.manifest.backends.set_gtk4(backend);
1496 self.manifest
1497 .save(&self.root)
1498 .await
1499 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1500 Ok(())
1501 }
1502
1503 pub async fn init_hydrolysis_backend(
1511 &mut self,
1512 ) -> Result<(), crate::backend::FailToInitBackend> {
1513 use crate::{backend::Backend, hydrolysis::backend::HydrolysisBackend};
1514
1515 self.require_distributable_backend(TargetBackend::Hydrolysis)
1516 .await?;
1517 let backend = HydrolysisBackend::init(self).await?;
1518 self.manifest.backends.set_hydrolysis(backend);
1519 self.manifest
1520 .save(&self.root)
1521 .await
1522 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1523
1524 crate::mcp::ensure_mcp_json(&self.root).await?;
1528 Ok(())
1529 }
1530
1531 pub async fn init_winui_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1538 use crate::{backend::Backend, winui::backend::WinUiBackend};
1539
1540 self.require_distributable_backend(TargetBackend::WinUi)
1541 .await?;
1542 if !cfg!(target_os = "windows") {
1543 return Err(crate::backend::FailToInitBackend::Io(
1544 std::io::Error::other("WinUI backend is only supported on Windows hosts"),
1545 ));
1546 }
1547
1548 let backend = WinUiBackend::init(self).await?;
1549 self.manifest.backends.set_winui(backend);
1550 self.manifest
1551 .save(&self.root)
1552 .await
1553 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1554 Ok(())
1555 }
1556
1557 pub async fn init_esp32_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1565 use crate::{backend::Backend, esp32::backend::Esp32Backend};
1566
1567 self.require_distributable_backend(TargetBackend::Dew)
1568 .await?;
1569 let backend = Esp32Backend::init(self).await?;
1570 self.manifest.backends.set_esp32(backend);
1571 self.manifest
1572 .save(&self.root)
1573 .await
1574 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1575 Ok(())
1576 }
1577
1578 pub async fn set_esp32_chip(
1589 &mut self,
1590 chip: crate::esp32::chip::Esp32Chip,
1591 ) -> eyre::Result<()> {
1592 let current = self.esp32_backend().cloned().unwrap_or_default();
1593 if current.chip() == chip.id() {
1594 return Ok(());
1595 }
1596 self.manifest.backends.set_esp32(current.with_chip(chip));
1597 self.save_manifest().await
1598 }
1599
1600 pub async fn remove_apple_backend(&mut self) -> eyre::Result<()> {
1605 if let Some(backend) = self.apple_backend() {
1606 let path = backend.project_path().to_path_buf();
1607 self.remove_backend_relative_dir(&path).await?;
1608 }
1609 self.manifest.backends.clear_apple();
1610 self.remove_ffi_companion_if_unused().await?;
1611 self.save_manifest().await
1612 }
1613
1614 pub async fn remove_android_backend(&mut self) -> eyre::Result<()> {
1619 if let Some(backend) = self.android_backend() {
1620 let path = backend.project_path().clone();
1621 self.remove_backend_relative_dir(&path).await?;
1622 }
1623 self.manifest.backends.clear_android();
1624 self.remove_ffi_companion_if_unused().await?;
1625 self.save_manifest().await
1626 }
1627
1628 pub async fn remove_gtk4_backend(&mut self) -> eyre::Result<()> {
1633 if let Some(backend) = self.gtk4_backend() {
1634 let path = backend.project_path().clone();
1635 self.remove_backend_relative_dir(&path).await?;
1636 }
1637 self.manifest.backends.clear_gtk4();
1638 self.save_manifest().await
1639 }
1640
1641 pub async fn remove_winui_backend(&mut self) -> eyre::Result<()> {
1646 if let Some(backend) = self.winui_backend() {
1647 let path = backend.project_path().clone();
1648 self.remove_backend_relative_dir(&path).await?;
1649 }
1650 self.manifest.backends.clear_winui();
1651 self.save_manifest().await
1652 }
1653
1654 pub async fn remove_hydrolysis_backend(&mut self) -> eyre::Result<()> {
1659 if let Some(backend) = self.hydrolysis_backend() {
1660 let path = backend.project_path().clone();
1661 self.remove_backend_relative_dir(&path).await?;
1662 }
1663 self.manifest.backends.clear_hydrolysis();
1664 self.save_manifest().await
1665 }
1666
1667 pub async fn remove_esp32_backend(&mut self) -> eyre::Result<()> {
1672 if let Some(backend) = self.esp32_backend() {
1673 let path = backend.project_path().clone();
1674 self.remove_backend_relative_dir(&path).await?;
1675 }
1676 self.manifest.backends.clear_esp32();
1677 self.save_manifest().await
1678 }
1679
1680 pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1690 Self::open_with_mode(path, OpenMode::Full).await
1691 }
1692
1693 pub async fn open_for_preview_build(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1703 Self::open_with_mode(path, OpenMode::PreviewBuild).await
1704 }
1705
1706 async fn refresh_local_patches(project_root: &Path, waterui_path: &Path) -> eyre::Result<()> {
1721 let project_root = project_root.to_path_buf();
1722 let waterui_path = waterui_path.to_path_buf();
1723 unblock(move || {
1724 let checkout = project_root.join(&waterui_path);
1725 let patch_root = templates::patch_manifest_dir(&project_root)?;
1726 if same_directory(&patch_root, &checkout)? {
1727 return Ok(());
1728 }
1729 if !same_directory(&patch_root, &project_root)? {
1730 eyre::bail!(
1735 "This project is a member of the Cargo workspace at {}, so Cargo reads \
1736 [patch] from {} and ignores any copy here. Move the WaterUI checkout's \
1737 [patch] tables — the ones in {} — into that workspace manifest, or take \
1738 the project out of that workspace.",
1739 patch_root.display(),
1740 patch_root.join("Cargo.toml").display(),
1741 checkout.join("Cargo.toml").display(),
1742 );
1743 }
1744 let cargo_path = project_root.join("Cargo.toml");
1745 let text = std::fs::read_to_string(&cargo_path)?;
1746 let current = CargoManifest::from_slice(text.as_bytes())?.patch;
1747 let next = templates::local_framework_patches(&project_root, &waterui_path)?;
1748 if current == next {
1749 return Ok(());
1750 }
1751 let mut document: toml_edit::DocumentMut = text.parse()?;
1752 crate::framework::rewrite_patch_tables(&mut document, ¤t, &next)?;
1753 std::fs::write(&cargo_path, document.to_string())?;
1754 info!(
1755 path = %cargo_path.display(),
1756 "Refreshed the [patch] tables from the local checkout"
1757 );
1758 Ok(())
1759 })
1760 .await
1761 }
1762
1763 #[allow(clippy::too_many_lines)]
1764 async fn open_with_mode(
1765 path: impl AsRef<Path>,
1766 open_mode: OpenMode,
1767 ) -> Result<Self, FailToOpenProject> {
1768 use crate::backend::Backend;
1769
1770 let total_start = std::time::Instant::now();
1771 let path = path.as_ref().to_path_buf();
1772
1773 let manifest_start = std::time::Instant::now();
1774 let manifest = Manifest::open(path.join("Water.toml"))
1775 .await
1776 .map_err(FailToOpenProject::Manifest)?;
1777 if let Some(framework) = &manifest.framework {
1778 framework
1779 .validate_cli()
1780 .map_err(FailToOpenProject::Framework)?;
1781 }
1782 if let Some(local) = &manifest.waterui_path {
1783 validate_local_cli(&path.join(local))
1784 .await
1785 .map_err(FailToOpenProject::Framework)?;
1786 Self::refresh_local_patches(&path, Path::new(local))
1787 .await
1788 .map_err(FailToOpenProject::LocalPatches)?;
1789 }
1790 info!(
1791 path = %path.display(),
1792 open_mode = ?open_mode,
1793 elapsed_ms = manifest_start.elapsed().as_millis(),
1794 "Project::open loaded Water.toml"
1795 );
1796
1797 let cargo_path = path.join("Cargo.toml");
1798
1799 let cargo_manifest_start = std::time::Instant::now();
1800 let cargo_manifest = unblock(move || CargoManifest::from_path(cargo_path))
1801 .await
1802 .map_err(FailToOpenProject::CargoManifest)?;
1803 info!(
1804 path = %path.display(),
1805 open_mode = ?open_mode,
1806 elapsed_ms = cargo_manifest_start.elapsed().as_millis(),
1807 "Project::open loaded Cargo.toml"
1808 );
1809 let crate_name = cargo_manifest
1810 .package
1811 .map(|p| p.name)
1812 .ok_or(FailToOpenProject::MissingCrateName)
1813 .and_then(|value| {
1814 CrateName::try_from(value).map_err(FailToOpenProject::InvalidCrateName)
1815 })?;
1816
1817 let is_playground = manifest.package.package_type == PackageType::Playground;
1818
1819 if !is_playground && !manifest.permissions.is_empty() {
1821 return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
1822 }
1823
1824 if is_playground && manifest.backends.configures_backend_projects() {
1832 return Err(FailToOpenProject::BackendsNotAllowedInPlayground);
1833 }
1834
1835 let cargo_layout = spawn_cargo_layout_resolution(
1836 &path,
1837 manifest.framework.clone(),
1838 manifest.waterui_path.is_some(),
1839 );
1840 cargo_layout
1841 .clone()
1842 .await
1843 .map_err(|error| FailToOpenProject::Framework(eyre::eyre!(error)))?;
1844
1845 let managed_backends_root = if is_playground {
1846 let build_cache_start = std::time::Instant::now();
1847 let root = crate::water_dir::ensure_project_build_cache(&path)
1848 .await
1849 .map_err(FailToOpenProject::BuildCache)?;
1850 info!(
1851 path = %path.display(),
1852 open_mode = ?open_mode,
1853 elapsed_ms = build_cache_start.elapsed().as_millis(),
1854 "Project::open ensured project build cache"
1855 );
1856 root
1857 } else {
1858 path.join(manifest.backends.path())
1859 };
1860
1861 let mut project = Self {
1862 root: path,
1863 manifest,
1864 crate_name,
1865 cargo_layout,
1866 linked_packages: Arc::new(async_lock::OnceCell::new()),
1867 enabled_features: Arc::new(async_lock::OnceCell::new()),
1868 managed_backends_root,
1869 };
1870
1871 let skip_backend_init = std::env::var("WATERUI_SKIP_RUST_BUILD")
1880 .is_ok_and(|value| value == "1")
1881 || std::env::var("ACTION").is_ok() || std::env::var("XCODE_PRODUCT_BUILD_VERSION").is_ok();
1883
1884 if is_playground && !skip_backend_init && open_mode == OpenMode::Full {
1885 let apple_backend_start = std::time::Instant::now();
1886 let apple_backend = AppleBackend::init(&project)
1887 .await
1888 .map_err(FailToOpenProject::BackendInit)?;
1889 info!(
1890 path = %project.root.display(),
1891 elapsed_ms = apple_backend_start.elapsed().as_millis(),
1892 "Project::open initialized Apple backend"
1893 );
1894 project.manifest.backends.set_apple(apple_backend);
1895
1896 let android_backend_start = std::time::Instant::now();
1897 let android_backend = AndroidBackend::init(&project)
1898 .await
1899 .map_err(FailToOpenProject::BackendInit)?;
1900 info!(
1901 path = %project.root.display(),
1902 elapsed_ms = android_backend_start.elapsed().as_millis(),
1903 "Project::open initialized Android backend"
1904 );
1905 project.manifest.backends.set_android(android_backend);
1906
1907 let ffi_companion_start = std::time::Instant::now();
1908 project
1909 .scaffold_ffi_companion()
1910 .await
1911 .map_err(FailToOpenProject::BackendInit)?;
1912 info!(
1913 path = %project.root.display(),
1914 elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1915 "Project::open scaffolded native ffi companion"
1916 );
1917 }
1918
1919 if !is_playground
1920 && !skip_backend_init
1921 && open_mode == OpenMode::Full
1922 && (project.apple_backend().is_some() || project.android_backend().is_some())
1923 {
1924 let ffi_companion_start = std::time::Instant::now();
1925 project
1926 .scaffold_ffi_companion()
1927 .await
1928 .map_err(FailToOpenProject::BackendInit)?;
1929 info!(
1930 path = %project.root.display(),
1931 elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1932 "Project::open refreshed native ffi companion"
1933 );
1934 }
1935
1936 info!(
1937 path = %project.root.display(),
1938 open_mode = ?open_mode,
1939 elapsed_ms = total_start.elapsed().as_millis(),
1940 "Project::open completed"
1941 );
1942
1943 Ok(project)
1944 }
1945}
1946
1947impl Project {
1948 async fn save_manifest(&self) -> eyre::Result<()> {
1949 self.manifest.save(&self.root).await.map_err(Into::into)
1950 }
1951
1952 async fn remove_backend_relative_dir(&self, relative_path: &Path) -> eyre::Result<()> {
1953 let backend_path = self.managed_backends_root.join(relative_path);
1954 if backend_path.exists() {
1955 smol::fs::remove_dir_all(&backend_path).await?;
1956 }
1957 Ok(())
1958 }
1959}
1960
1961async fn apply_channel_selection(
1962 root: &Path,
1963 framework: ResolvedFramework,
1964 updates: Vec<(PathBuf, Option<Vec<u8>>)>,
1965) -> eyre::Result<()> {
1966 let mut previous = BTreeMap::new();
1967 for file in updates
1968 .iter()
1969 .map(|(file, _)| file.clone())
1970 .chain([root.join("Cargo.lock")])
1971 {
1972 let contents = match smol::fs::read(&file).await {
1973 Ok(contents) => Some(contents),
1974 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1975 Err(error) => return Err(error.into()),
1976 };
1977 previous.insert(file, contents);
1978 }
1979 let result = async {
1980 for (file, contents) in &updates {
1981 write_channel_file(file, contents.as_deref()).await?;
1982 }
1983 resolve_cargo_layout(root, Some(framework), CargoResolution::Update).await?;
1984 Ok(())
1985 }
1986 .await;
1987 if let Err(error) = result {
1988 for (file, contents) in previous {
1989 write_channel_file(&file, contents.as_deref())
1990 .await
1991 .map_err(|restore| {
1992 eyre::eyre!("{error}; could not restore {}: {restore}", file.display())
1993 })?;
1994 }
1995 return Err(error);
1996 }
1997 Ok(())
1998}
1999
2000async fn write_channel_file(path: &Path, contents: Option<&[u8]>) -> std::io::Result<()> {
2001 match contents {
2002 Some(contents) => smol::fs::write(path, contents).await,
2003 None => match smol::fs::remove_file(path).await {
2004 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2005 result => result,
2006 },
2007 }
2008}
2009
2010async fn resolve_cargo_layout(
2011 current_dir: &Path,
2012 framework: Option<ResolvedFramework>,
2013 mode: CargoResolution,
2014) -> eyre::Result<CargoLayout> {
2015 let root = current_dir.to_path_buf();
2016 let metadata = unblock(move || {
2017 let mut command = cargo_metadata::MetadataCommand::new();
2018 command.current_dir(root);
2019 match mode {
2020 CargoResolution::Local => {
2021 command.no_deps();
2022 }
2023 CargoResolution::Locked => {
2024 command.other_options(vec!["--locked".to_string()]);
2025 }
2026 CargoResolution::Update => {}
2027 }
2028 command.exec()
2029 })
2030 .await?;
2031 validate_resolved_cli(&metadata)?;
2032 if let Some(framework) = framework
2033 && framework.channel() != Some(FrameworkChannel::Stable)
2034 {
2035 let lockfile = smol::fs::read(current_dir.join("Water.lock")).await?;
2036 framework.validate_dependencies(&metadata, &lockfile)?;
2037 }
2038
2039 Ok(CargoLayout {
2040 target_dir: metadata.target_directory.into_std_path_buf(),
2041 workspace_root: metadata.workspace_root.into_std_path_buf(),
2042 })
2043}
2044
2045async fn cargo_tree(project_root: &Path, edges: &str, locked: bool) -> eyre::Result<String> {
2052 let application_manifest = dunce::canonicalize(project_root.join("Cargo.toml"))?;
2060 let metadata_manifest = application_manifest.clone();
2061 let metadata = unblock(move || {
2062 let mut command = cargo_metadata::MetadataCommand::new();
2063 command.no_deps().manifest_path(metadata_manifest);
2064 if locked {
2065 command.other_options(vec!["--locked".to_string()]);
2066 }
2067 command.exec()
2068 })
2069 .await?;
2070 let root = metadata
2071 .packages
2072 .iter()
2073 .find(|package| package.manifest_path.as_std_path() == application_manifest)
2074 .ok_or_else(|| {
2075 eyre::eyre!(
2076 "Cargo metadata omitted the application package at {}",
2077 application_manifest.display()
2078 )
2079 })?;
2080 let package_spec = root.id.to_string();
2081 let mut tree = Command::new("cargo");
2082 tree.arg("tree")
2083 .arg("--manifest-path")
2084 .arg(&application_manifest)
2085 .arg("--package")
2086 .arg(package_spec)
2087 .arg("--edges")
2088 .arg(edges)
2089 .arg("--prefix")
2090 .arg("none")
2091 .arg("--format")
2092 .arg("{p}")
2093 .current_dir(project_root);
2094 if locked {
2095 tree.arg("--locked");
2096 }
2097 let output = tree.output().await?;
2098 if !output.status.success() {
2099 return Err(eyre::eyre!(
2100 "failed to resolve runtime dependency graph for {}: {}",
2101 application_manifest.display(),
2102 String::from_utf8_lossy(&output.stderr).trim()
2103 ));
2104 }
2105
2106 String::from_utf8(output.stdout)
2107 .map_err(|error| eyre::eyre!("Cargo runtime dependency graph is not UTF-8: {error}"))
2108}
2109
2110async fn resolve_linked_runtime_packages(
2111 project_root: PathBuf,
2112 locked: bool,
2113) -> eyre::Result<BTreeMap<String, String>> {
2114 let tree = cargo_tree(&project_root, "normal", locked).await?;
2115 let mut linked = BTreeMap::new();
2116 for package in tree.lines() {
2117 let name = package
2118 .split_ascii_whitespace()
2119 .next()
2120 .ok_or_else(|| eyre::eyre!("Cargo emitted an empty runtime dependency entry"))?;
2121 linked.insert(name.to_string(), package.to_string());
2122 }
2123
2124 Ok(linked)
2125}
2126
2127async fn resolve_enabled_features(
2132 project_root: PathBuf,
2133 locked: bool,
2134) -> eyre::Result<BTreeSet<String>> {
2135 let tree = cargo_tree(&project_root, "features", locked).await?;
2136 let mut features = BTreeSet::new();
2137 for node in tree.lines() {
2138 if let Some(feature) = node
2139 .split_once(" feature \"")
2140 .and_then(|(_, rest)| rest.strip_suffix('"'))
2141 {
2142 features.insert(feature.to_string());
2143 }
2144 }
2145 Ok(features)
2146}
2147
2148use std::{
2149 collections::{BTreeMap, BTreeSet},
2150 path::{Path, PathBuf},
2151 sync::Arc,
2152};
2153
2154use serde::{Deserialize, Serialize};
2155use smol::{fs::read_to_string, process::Command, unblock};
2156use waterui_assets_planner::{LaunchConfig, ThemeConfig};
2157
2158use crate::{
2159 android::{backend::AndroidBackend, device::AndroidAbiProvider, platform::AndroidPlatform},
2160 apple::backend::AppleBackend,
2161 backend::{Backend, Backends},
2162 build::{BuildOptions, BuildProfile},
2163 device::{Artifact, Device, FailToRun, RunOptions, Running},
2164 platform::{PackageOptions, TargetBackend, TargetPlatform},
2165 project_types::{BundleIdentifier, CrateName, PermissionKey, generated_crate_name},
2166 templates::{self, TemplateContext},
2167 utils::command,
2168 web,
2169};
2170
2171#[derive(Debug, Serialize, Deserialize, Clone)]
2173pub struct Manifest {
2174 pub package: Package,
2176 #[serde(default, skip_serializing_if = "Backends::is_empty")]
2178 pub backends: Backends,
2179 #[serde(skip_serializing_if = "Option::is_none")]
2183 pub waterui_path: Option<String>,
2184 #[serde(default, skip_serializing_if = "Option::is_none")]
2186 pub framework: Option<ResolvedFramework>,
2187 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2189 pub permissions: BTreeMap<PermissionKey, PermissionEntry>,
2190 #[serde(default, skip_serializing_if = "Option::is_none")]
2192 pub app: Option<AppConfig>,
2193 #[serde(default, skip_serializing_if = "Option::is_none")]
2195 pub theme: Option<ThemeConfig>,
2196 #[serde(default, skip_serializing_if = "Option::is_none")]
2198 pub launch: Option<LaunchConfig>,
2199 #[serde(default, skip_serializing_if = "Option::is_none")]
2201 pub web: Option<web::WebConfig>,
2202}
2203
2204#[derive(Debug, Serialize, Deserialize, Clone)]
2206pub struct PermissionEntry {
2207 enable: bool,
2208 description: String,
2210}
2211
2212impl PermissionEntry {
2213 #[must_use]
2215 pub fn enabled(description: impl Into<String>) -> Self {
2216 Self {
2217 enable: true,
2218 description: description.into(),
2219 }
2220 }
2221
2222 #[must_use]
2224 pub const fn is_enabled(&self) -> bool {
2225 self.enable
2226 }
2227
2228 #[must_use]
2230 pub fn description(&self) -> &str {
2231 &self.description
2232 }
2233}
2234
2235#[derive(Debug, thiserror::Error)]
2237pub enum FailToOpenManifest {
2238 #[error("Failed to read manifest file: {0}")]
2240 ReadError(std::io::Error),
2241 #[error("Invalid manifest file: {0}")]
2243 InvalidManifest(toml::de::Error),
2244
2245 #[error("Manifest file not found at the specified path")]
2247 NotFound,
2248}
2249
2250#[derive(Debug, thiserror::Error)]
2252pub enum FailToSaveManifest {
2253 #[error("Failed to serialize manifest: {0}")]
2255 Serialize(toml::ser::Error),
2256 #[error("Failed to write manifest file: {0}")]
2258 Write(std::io::Error),
2259}
2260impl Manifest {
2261 pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenManifest> {
2268 let path = path.as_ref();
2269 let result = read_to_string(path).await;
2270
2271 match result {
2272 Ok(c) => toml::from_str(&c).map_err(FailToOpenManifest::InvalidManifest),
2273 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(FailToOpenManifest::NotFound),
2274 Err(e) => Err(FailToOpenManifest::ReadError(e)),
2275 }
2276 }
2277
2278 pub async fn save(&self, dir: impl AsRef<Path>) -> Result<(), FailToSaveManifest> {
2284 let path = dir.as_ref().join("Water.toml");
2285 let content = toml::to_string_pretty(self).map_err(FailToSaveManifest::Serialize)?;
2286 smol::fs::write(&path, content)
2287 .await
2288 .map_err(FailToSaveManifest::Write)
2289 }
2290
2291 #[must_use]
2293 pub fn new(package: Package) -> Self {
2294 Self {
2295 package,
2296 backends: Backends::default(),
2297 waterui_path: None,
2298 framework: None,
2299 permissions: BTreeMap::default(),
2300 app: None,
2301 theme: None,
2302 launch: None,
2303 web: None,
2304 }
2305 }
2306}
2307
2308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2310pub enum ResolvedWebViewBackend {
2311 System,
2313 Wpe,
2315 Cef,
2317}
2318
2319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2321pub struct BrowserRuntimePlan {
2322 pub webview: Option<ResolvedWebViewBackend>,
2324 pub chromium: bool,
2326}
2327
2328impl BrowserRuntimePlan {
2329 #[must_use]
2332 pub const fn requires_cef(self) -> bool {
2333 self.chromium || matches!(self.webview, Some(ResolvedWebViewBackend::Cef))
2334 }
2335}
2336
2337impl ResolvedWebViewBackend {
2338 #[must_use]
2340 pub const fn supports(self, platform: TargetPlatform, backend: TargetBackend) -> bool {
2341 match self {
2342 Self::System => matches!(
2343 (platform, backend),
2344 (
2345 TargetPlatform::MacOS,
2346 TargetBackend::Apple | TargetBackend::Hydrolysis
2347 ) | (
2348 TargetPlatform::IOS
2349 | TargetPlatform::IOSSimulator
2350 | TargetPlatform::VisionOS
2351 | TargetPlatform::VisionOSSimulator,
2352 TargetBackend::Apple
2353 ) | (TargetPlatform::Android, TargetBackend::Android)
2354 | (TargetPlatform::Linux, TargetBackend::Gtk4)
2355 | (TargetPlatform::Web, TargetBackend::Hydrolysis)
2356 ),
2357 Self::Wpe => {
2358 matches!(platform, TargetPlatform::Linux)
2359 && matches!(backend, TargetBackend::Gtk4 | TargetBackend::Hydrolysis)
2360 }
2361 Self::Cef => cef_is_supported(platform, backend),
2362 }
2363 }
2364
2365 pub const fn validate(
2372 self,
2373 platform: TargetPlatform,
2374 backend: TargetBackend,
2375 ) -> Result<Self, UnsupportedWebViewBackend> {
2376 if self.supports(platform, backend) {
2377 Ok(self)
2378 } else {
2379 Err(UnsupportedWebViewBackend {
2380 resolved: self,
2381 platform,
2382 backend,
2383 })
2384 }
2385 }
2386
2387 #[must_use]
2389 pub const fn as_str(self) -> &'static str {
2390 match self {
2391 Self::System => "system",
2392 Self::Wpe => "wpe",
2393 Self::Cef => "cef",
2394 }
2395 }
2396}
2397
2398const fn cef_is_supported(platform: TargetPlatform, backend: TargetBackend) -> bool {
2399 !matches!(backend, TargetBackend::Dew)
2400 && matches!(
2401 platform,
2402 TargetPlatform::MacOS | TargetPlatform::Linux | TargetPlatform::Windows
2403 )
2404}
2405
2406#[derive(Debug, thiserror::Error)]
2408#[error(
2409 "this application's WebView engine resolves to {resolved:?}, which is unsupported for \
2410 platform {platform:?} with backend {backend:?}. The engine follows the application's \
2411 dependencies: link waterui-browser-cef or waterui-browser-wpe to select one, or \
2412 neither to use the engine this platform bridges."
2413)]
2414pub struct UnsupportedWebViewBackend {
2415 resolved: ResolvedWebViewBackend,
2416 platform: TargetPlatform,
2417 backend: TargetBackend,
2418}
2419
2420#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2422pub struct AppConfig {
2423 #[serde(default, skip_serializing_if = "Option::is_none")]
2425 pub crates: Option<AppCrates>,
2426}
2427
2428#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2430pub struct AppCrates {
2431 #[serde(default, skip_serializing_if = "Option::is_none")]
2433 pub ffi: Option<CrateName>,
2434 #[serde(default, skip_serializing_if = "Option::is_none")]
2436 pub gtk: Option<CrateName>,
2437 #[serde(default, skip_serializing_if = "Option::is_none")]
2439 pub hydrolysis: Option<CrateName>,
2440 #[serde(default, skip_serializing_if = "Option::is_none")]
2442 pub winui: Option<CrateName>,
2443}
2444
2445#[derive(Debug, Serialize, Deserialize, Clone)]
2447pub struct Package {
2448 #[serde(rename = "type")]
2450 pub package_type: PackageType,
2451 pub name: String,
2453 pub bundle_identifier: BundleIdentifier,
2455 #[serde(
2457 default = "default_assets_path",
2458 skip_serializing_if = "is_default_assets_path"
2459 )]
2460 pub assets_path: String,
2461 #[serde(default, skip_serializing_if = "is_false")]
2463 pub accessory: bool,
2464}
2465
2466pub async fn read_project_crate_name(project_path: &Path) -> eyre::Result<String> {
2476 let cargo_toml = project_path.join("Cargo.toml");
2477 let cargo_content = smol::fs::read_to_string(&cargo_toml).await?;
2478 let cargo: toml::Table = cargo_content.parse()?;
2479 cargo
2480 .get("package")
2481 .and_then(|p| p.get("name"))
2482 .and_then(|n| n.as_str())
2483 .map(ToString::to_string)
2484 .ok_or_else(|| eyre::eyre!("Could not find package name in Cargo.toml"))
2485}
2486
2487fn same_directory(left: &Path, right: &Path) -> std::io::Result<bool> {
2494 Ok(std::fs::canonicalize(left)? == std::fs::canonicalize(right)?)
2495}
2496
2497fn default_assets_path() -> String {
2498 "assets".to_string()
2499}
2500
2501fn is_default_assets_path(path: &str) -> bool {
2502 path == "assets"
2503}
2504
2505#[allow(clippy::trivially_copy_pass_by_ref)]
2506const fn is_false(value: &bool) -> bool {
2507 !*value
2508}
2509
2510#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
2512#[serde(rename_all = "lowercase")]
2513pub enum PackageType {
2514 #[default]
2516 App,
2517 Playground,
2520}
2521
2522#[cfg(test)]
2523mod channel_tests {
2524 use super::*;
2525
2526 #[test]
2527 fn local_framework_requirement_is_checked_before_project_io() {
2528 smol::block_on(async {
2529 let directory = tempfile::tempdir().unwrap();
2530 let framework_root = directory.path().join("framework");
2531 let project_root = directory.path().join("consumer");
2532 smol::fs::create_dir(&framework_root).await.unwrap();
2533 let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
2534 minimum.major += 1;
2535 let mut metadata = toml::toml! {
2536 [package.metadata.waterui]
2537 minimum-cli-version = "0.1.4"
2538 android-min-api-level = 26
2539 };
2540 metadata["package"]["metadata"]["waterui"]["minimum-cli-version"] =
2541 toml::Value::String(minimum.to_string());
2542 smol::fs::write(
2543 framework_root.join("Cargo.toml"),
2544 toml::to_string(&metadata).unwrap(),
2545 )
2546 .await
2547 .unwrap();
2548 let bundle_identifier =
2549 BundleIdentifier::try_from("dev.waterui.compatibility").unwrap();
2550 let options = CreateOptions {
2551 name: "Compatibility".into(),
2552 bundle_identifier: bundle_identifier.clone(),
2553 package_type: PackageType::Playground,
2554 waterui_path: Some(framework_root),
2555 channel: None,
2556 framework_manifest: None,
2557 framework: None,
2558 author: String::new(),
2559 backends: Vec::new(),
2560 web: None,
2561 };
2562 let error = Project::create(&project_root, options)
2563 .await
2564 .unwrap_err()
2565 .to_string();
2566 assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2567 assert!(error.contains(&format!(
2568 "cargo install waterui-cli --git {} --locked",
2569 env!("CARGO_PKG_REPOSITORY")
2570 )));
2571 assert!(!project_root.exists());
2572
2573 smol::fs::create_dir(&project_root).await.unwrap();
2574 let mut manifest = Manifest::new(Package {
2575 name: "Compatibility".into(),
2576 bundle_identifier,
2577 package_type: PackageType::Playground,
2578 assets_path: default_assets_path(),
2579 accessory: false,
2580 });
2581 manifest.waterui_path = Some("../framework".into());
2582 manifest.save(&project_root).await.unwrap();
2583 let error = Project::open_for_preview_build(&project_root)
2584 .await
2585 .unwrap_err()
2586 .to_string();
2587 assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2588 assert!(!project_root.join("Cargo.lock").exists());
2589 });
2590 }
2591
2592 #[test]
2593 fn failed_channel_selection_preserves_project_files() {
2594 smol::block_on(async {
2595 let directory = tempfile::tempdir().unwrap();
2596 let root = directory.path();
2597 let originals = [
2598 ("Cargo.toml", b"original manifest".as_slice()),
2599 ("Cargo.lock", b"original dependency lock".as_slice()),
2600 ("Water.toml", b"original project configuration".as_slice()),
2601 ];
2602 for (name, contents) in originals {
2603 smol::fs::write(root.join(name), contents).await.unwrap();
2604 }
2605 let updates = ["Cargo.toml", "Cargo.lock", "Water.toml", "Water.lock"]
2606 .into_iter()
2607 .map(|name| (root.join(name), Some(b"invalid selected manifest".to_vec())))
2608 .collect();
2609 assert!(
2610 apply_channel_selection(
2611 root,
2612 crate::framework::test_fixtures::stable_framework(),
2613 updates
2614 )
2615 .await
2616 .is_err()
2617 );
2618 for (name, contents) in originals {
2619 assert_eq!(smol::fs::read(root.join(name)).await.unwrap(), contents);
2620 }
2621 assert!(!root.join("Water.lock").exists());
2622 });
2623 }
2624}
2625
2626#[cfg(test)]
2627mod webview_backend_tests {
2628 use super::{
2629 ResolvedWebViewBackend, TargetBackend, TargetPlatform, resolve_enabled_features,
2630 resolve_linked_runtime_packages,
2631 };
2632
2633 #[test]
2638 fn the_platform_bridge_is_the_selection_without_an_engine_crate() {
2639 assert_eq!(
2640 ResolvedWebViewBackend::System
2641 .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2642 .expect("macOS Hydrolysis bridges WKWebView"),
2643 ResolvedWebViewBackend::System
2644 );
2645 assert_eq!(
2646 ResolvedWebViewBackend::System
2647 .validate(TargetPlatform::Linux, TargetBackend::Gtk4)
2648 .expect("GTK bridges WebKitGTK"),
2649 ResolvedWebViewBackend::System
2650 );
2651 assert!(
2652 ResolvedWebViewBackend::System
2653 .validate(TargetPlatform::Linux, TargetBackend::Hydrolysis)
2654 .is_err()
2655 );
2656 assert!(
2657 ResolvedWebViewBackend::System
2658 .validate(TargetPlatform::Windows, TargetBackend::Hydrolysis)
2659 .is_err()
2660 );
2661 }
2662
2663 #[test]
2664 fn unsupported_engine_combinations_fail_before_build() {
2665 assert!(
2666 ResolvedWebViewBackend::Wpe
2667 .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2668 .is_err()
2669 );
2670 assert!(
2671 ResolvedWebViewBackend::Cef
2672 .validate(TargetPlatform::Android, TargetBackend::Android)
2673 .is_err()
2674 );
2675 assert_eq!(
2676 ResolvedWebViewBackend::Cef
2677 .validate(TargetPlatform::MacOS, TargetBackend::Apple)
2678 .expect("CEF must compose with the native Apple renderer on macOS"),
2679 ResolvedWebViewBackend::Cef
2680 );
2681 }
2682
2683 #[test]
2684 fn cef_is_available_to_every_non_dew_backend_on_desktop_platforms() {
2685 for backend in [
2686 TargetBackend::Apple,
2687 TargetBackend::Android,
2688 TargetBackend::Gtk4,
2689 TargetBackend::Hydrolysis,
2690 ] {
2691 for platform in [
2692 TargetPlatform::MacOS,
2693 TargetPlatform::Linux,
2694 TargetPlatform::Windows,
2695 ] {
2696 assert_eq!(
2697 ResolvedWebViewBackend::Cef
2698 .validate(platform, backend)
2699 .expect("CEF availability must not depend on the WaterUI backend"),
2700 ResolvedWebViewBackend::Cef
2701 );
2702 }
2703 }
2704 }
2705
2706 #[test]
2707 fn cef_rejects_dew_and_platforms_without_cef_distributions() {
2708 for platform in [
2709 TargetPlatform::MacOS,
2710 TargetPlatform::Linux,
2711 TargetPlatform::Windows,
2712 ] {
2713 assert!(
2714 ResolvedWebViewBackend::Cef
2715 .validate(platform, TargetBackend::Dew)
2716 .is_err()
2717 );
2718 }
2719 for (platform, backend) in [
2720 (TargetPlatform::Android, TargetBackend::Android),
2721 (TargetPlatform::IOS, TargetBackend::Apple),
2722 (TargetPlatform::Web, TargetBackend::Hydrolysis),
2723 ] {
2724 assert!(
2725 ResolvedWebViewBackend::Cef
2726 .validate(platform, backend)
2727 .is_err()
2728 );
2729 }
2730 }
2731
2732 #[test]
2738 #[ignore = "clones the pinned framework revision"]
2739 fn runtime_graph_is_scoped_to_the_selected_application() {
2740 let repository = crate::pinned_framework::checkout();
2741 let chromium = smol::block_on(resolve_linked_runtime_packages(
2742 repository.join("examples/chromium"),
2743 true,
2744 ))
2745 .expect("Chromium example runtime graph must resolve");
2746 assert!(
2747 chromium.contains_key("waterui-chromium"),
2748 "Chromium example graph: {chromium:#?}"
2749 );
2750 assert!(
2753 chromium.contains_key("waterui-browser-cef"),
2754 "Chromium example graph: {chromium:#?}"
2755 );
2756 assert!(
2757 !chromium.contains_key("waterui-browser-wpe"),
2758 "Chromium example graph: {chromium:#?}"
2759 );
2760 assert!(
2767 chromium.contains_key("waterui-webview"),
2768 "waterui-chromium shares the webview asset-server types: {chromium:#?}"
2769 );
2770 let chromium_features = smol::block_on(resolve_enabled_features(
2771 repository.join("examples/chromium"),
2772 true,
2773 ))
2774 .expect("Chromium example feature graph must resolve");
2775 assert!(
2776 !chromium_features.contains("webview"),
2777 "a Chromium-only application must not enable the standard WebView \
2778 component: {chromium_features:#?}"
2779 );
2780
2781 let webview = smol::block_on(resolve_linked_runtime_packages(
2782 repository.join("examples/webview"),
2783 true,
2784 ))
2785 .expect("WebView example runtime graph must resolve");
2786 assert!(
2787 webview.contains_key("waterui-webview"),
2788 "WebView example graph: {webview:#?}"
2789 );
2790 let webview_features = smol::block_on(resolve_enabled_features(
2791 repository.join("examples/webview"),
2792 true,
2793 ))
2794 .expect("WebView example feature graph must resolve");
2795 assert!(
2796 webview_features.contains("webview"),
2797 "the WebView example enables the facade `webview` feature: {webview_features:#?}"
2798 );
2799 assert!(
2800 !webview.contains_key("waterui-browser-cef"),
2801 "WebView example graph: {webview:#?}"
2802 );
2803 assert!(
2804 !webview.contains_key("waterui-chromium"),
2805 "WebView example graph: {webview:#?}"
2806 );
2807
2808 let cef_webview = smol::block_on(resolve_linked_runtime_packages(
2809 repository.join("examples/webview-cef"),
2810 true,
2811 ))
2812 .expect("CEF WebView example runtime graph must resolve");
2813 assert!(
2814 cef_webview.contains_key("waterui-browser-cef"),
2815 "CEF WebView example graph: {cef_webview:#?}"
2816 );
2817 assert!(
2818 !cef_webview.contains_key("waterui-browser-wpe"),
2819 "CEF WebView example graph: {cef_webview:#?}"
2820 );
2821 }
2822
2823 #[test]
2831 #[ignore = "clones the pinned framework revision"]
2832 fn the_map_capability_is_read_from_the_application_graph() {
2833 let repository = crate::pinned_framework::checkout();
2834
2835 let map = smol::block_on(resolve_linked_runtime_packages(
2836 repository.join("examples/map"),
2837 true,
2838 ))
2839 .expect("map example runtime graph must resolve");
2840 assert!(
2841 map.contains_key("waterui-map"),
2842 "map example graph: {map:#?}"
2843 );
2844
2845 let webview = smol::block_on(resolve_linked_runtime_packages(
2846 repository.join("examples/webview"),
2847 true,
2848 ))
2849 .expect("WebView example runtime graph must resolve");
2850 assert!(
2851 !webview.contains_key("waterui-map"),
2852 "an application that shows no map must not carry the map stack: {webview:#?}"
2853 );
2854 }
2855}
2856
2857#[cfg(test)]
2858mod scaffold_tests {
2859 use std::path::Path;
2860
2861 use super::{BundleIdentifier, CreateOptions, PackageType, Project, TargetBackend};
2862
2863 #[test]
2868 fn create_scaffolds_the_assets_directory_declared_by_the_manifest() {
2869 let dir = tempfile::tempdir().expect("temp dir");
2870 let root = dir.path().join("water-example");
2871
2872 let project = smol::block_on(Project::create(
2873 &root,
2874 CreateOptions {
2875 name: "Water Example".to_string(),
2876 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2877 .expect("bundle identifier"),
2878 package_type: PackageType::Playground,
2879 waterui_path: None,
2880 channel: None,
2881 framework_manifest: None,
2882 framework: Some(crate::framework::test_fixtures::stable_framework()),
2885 author: "Lexo Liu".to_string(),
2886 backends: Vec::new(),
2887 web: None,
2888 },
2889 ))
2890 .expect("project creation must succeed");
2891
2892 let assets = project.assets_dir();
2893 assert!(
2894 assets.is_dir(),
2895 "the assets root {} must exist after `water create`",
2896 assets.display()
2897 );
2898 assert_eq!(
2899 assets,
2900 root.join(project.assets_path()),
2901 "the scaffolded directory must be the one the manifest declares"
2902 );
2903 assert!(
2904 assets.join("README.md").is_file(),
2905 "a tracked file keeps the assets directory present in git"
2906 );
2907 }
2908
2909 #[test]
2914 fn create_rejects_backends_whose_packages_stable_withholds() {
2915 for backend in [
2916 TargetBackend::Gtk4,
2917 TargetBackend::WinUi,
2918 TargetBackend::Dew,
2919 ] {
2920 let dir = tempfile::tempdir().expect("temp dir");
2921 let root = dir.path().join("water-example");
2922 let error = smol::block_on(Project::create(
2923 &root,
2924 CreateOptions {
2925 name: "Water Example".to_string(),
2926 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2927 .expect("bundle identifier"),
2928 package_type: PackageType::App,
2929 waterui_path: None,
2930 channel: None,
2931 framework_manifest: None,
2932 framework: Some(crate::framework::test_fixtures::stable_framework()),
2933 author: "Lexo Liu".to_string(),
2934 backends: vec![backend],
2935 web: None,
2936 },
2937 ))
2938 .expect_err("a withheld scaffold package must reject create");
2939 let error = error.to_string();
2940 for package in backend.scaffold_packages() {
2941 assert!(error.contains(package), "{error}");
2942 }
2943 assert!(error.contains("stable"), "{error}");
2944 assert!(error.contains("--channel dev"), "{error}");
2945 assert!(
2946 !root.exists(),
2947 "the rejection precedes any file write: {error}"
2948 );
2949 }
2950 }
2951
2952 #[test]
2957 fn shipped_binary_names_drop_the_project_root_tag() {
2958 let dir = tempfile::tempdir().expect("temp dir");
2959 let root = dir.path().join("water-example");
2960 let project = smol::block_on(Project::create(
2961 &root,
2962 CreateOptions {
2963 name: "Water Example".to_string(),
2964 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2965 .expect("bundle identifier"),
2966 package_type: PackageType::Playground,
2967 waterui_path: None,
2968 channel: None,
2969 framework_manifest: None,
2970 framework: Some(crate::framework::test_fixtures::stable_framework()),
2971 author: "Lexo Liu".to_string(),
2972 backends: Vec::new(),
2973 web: None,
2974 },
2975 ))
2976 .expect("project creation must succeed");
2977
2978 for (shipped, tagged) in [
2979 (project.gtk4_binary_name(), project.gtk_backend_crate_name()),
2980 (
2981 project.hydrolysis_binary_name(),
2982 project.hydrolysis_backend_crate_name(),
2983 ),
2984 (
2985 project.winui_binary_name(),
2986 project.winui_backend_crate_name(),
2987 ),
2988 (
2989 project.esp32_binary_name(),
2990 project.esp32_backend_crate_name(),
2991 ),
2992 ] {
2993 assert!(
2994 tagged.as_str().starts_with(&format!("{shipped}-")),
2995 "the build name must be the shipped name plus the tag: {tagged}"
2996 );
2997 assert_eq!(
2998 tagged.as_str().len() - shipped.as_str().len(),
2999 9,
3000 "the tag is a dash plus eight hex digits: {tagged}"
3001 );
3002 }
3003 }
3004
3005 #[test]
3011 fn same_named_projects_stage_packaged_binaries_under_their_own_backends() {
3012 let dir = tempfile::tempdir().expect("temp dir");
3013 let create = |root: &Path| {
3014 smol::block_on(Project::create(
3015 root,
3016 CreateOptions {
3017 name: "Water Example".to_string(),
3018 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
3019 .expect("bundle identifier"),
3020 package_type: PackageType::App,
3021 waterui_path: None,
3022 channel: None,
3023 framework_manifest: None,
3024 framework: Some(crate::framework::test_fixtures::stable_framework()),
3025 author: "Lexo Liu".to_string(),
3026 backends: Vec::new(),
3027 web: None,
3028 },
3029 ))
3030 .expect("project creation must succeed")
3031 };
3032 let first = create(&dir.path().join("one/demo"));
3033 let second = create(&dir.path().join("two/demo"));
3034
3035 let staged = |project: &Project| {
3036 crate::platforming::packaging::dist_dir(
3037 &project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>(),
3038 "linux",
3039 Some("release"),
3040 )
3041 .join(project.hydrolysis_binary_name().as_str())
3042 };
3043 let first_staged = staged(&first);
3044 let second_staged = staged(&second);
3045
3046 assert_ne!(
3047 first_staged, second_staged,
3048 "same-named projects must not stage the same shipped path"
3049 );
3050 for (project, staged) in [(&first, &first_staged), (&second, &second_staged)] {
3051 assert!(
3052 staged.starts_with(
3053 project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>()
3054 ),
3055 "{} must live under the project's own managed backend directory",
3056 staged.display()
3057 );
3058 }
3059 }
3060}
3061
3062#[cfg(test)]
3063mod local_patch_tests {
3064 use std::path::Path;
3065
3066 use super::Project;
3067
3068 #[test]
3072 fn a_local_checkout_project_follows_the_checkouts_patch_tables() {
3073 let directory = tempfile::tempdir().expect("temp dir");
3074 let checkout = directory.path().join("waterui");
3075 std::fs::create_dir_all(&checkout).expect("checkout dir");
3076 std::fs::write(
3077 checkout.join("Cargo.toml"),
3078 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3079 )
3080 .expect("checkout manifest");
3081 let app = directory.path().join("app");
3082 std::fs::create_dir_all(&app).expect("project dir");
3083 let cargo_path = app.join("Cargo.toml");
3084 std::fs::write(
3085 &cargo_path,
3086 "[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",
3087 )
3088 .expect("project manifest");
3089
3090 smol::block_on(Project::refresh_local_patches(
3091 &app,
3092 Path::new("../waterui"),
3093 ))
3094 .expect("tables refresh");
3095 let refreshed = std::fs::read_to_string(&cargo_path).expect("refreshed manifest");
3096 let manifest = cargo_toml::Manifest::from_str(&refreshed).expect("manifest parses");
3097 let crates_io = &manifest.patch["crates-io"];
3098 let cargo_toml::Dependency::Detailed(core) = &crates_io["waterui-core"] else {
3099 panic!("the core patch is a path dependency");
3100 };
3101 assert_eq!(core.path.as_deref(), Some("../waterui/core"));
3102 assert!(!crates_io.contains_key("stale"));
3103 assert!(crates_io.contains_key("vello"));
3104 assert!(refreshed.starts_with("[package]"));
3105
3106 smol::block_on(Project::refresh_local_patches(
3107 &app,
3108 Path::new("../waterui"),
3109 ))
3110 .expect("second refresh");
3111 assert_eq!(
3112 std::fs::read_to_string(&cargo_path).expect("manifest after the second refresh"),
3113 refreshed
3114 );
3115 }
3116
3117 #[test]
3122 fn a_member_of_the_checkouts_workspace_keeps_its_manifest() {
3123 let directory = tempfile::tempdir().expect("temp dir");
3124 let checkout = directory.path().join("waterui");
3125 let app = checkout.join("examples/app");
3126 std::fs::create_dir_all(&app).expect("project dir");
3127 std::fs::write(
3128 checkout.join("Cargo.toml"),
3129 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3130 )
3131 .expect("checkout manifest");
3132 let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../..\" }\n";
3133 let cargo_path = app.join("Cargo.toml");
3134 std::fs::write(&cargo_path, manifest).expect("project manifest");
3135
3136 smol::block_on(Project::refresh_local_patches(&app, Path::new("../..")))
3137 .expect("tables refresh");
3138
3139 assert_eq!(
3140 std::fs::read_to_string(&cargo_path).expect("manifest after the refresh"),
3141 manifest
3142 );
3143 }
3144
3145 #[test]
3149 fn a_member_of_a_foreign_workspace_is_told_where_the_tables_belong() {
3150 let directory = tempfile::tempdir().expect("temp dir");
3151 let checkout = directory.path().join("waterui");
3152 std::fs::create_dir_all(&checkout).expect("checkout dir");
3153 std::fs::write(
3154 checkout.join("Cargo.toml"),
3155 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3156 )
3157 .expect("checkout manifest");
3158 let workspace = directory.path().join("their-workspace");
3159 let app = workspace.join("app");
3160 std::fs::create_dir_all(&app).expect("project dir");
3161 std::fs::write(
3162 workspace.join("Cargo.toml"),
3163 "[workspace]\nmembers = [\"app\"]\n",
3164 )
3165 .expect("workspace manifest");
3166 let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../../waterui\" }\n";
3167 let cargo_path = app.join("Cargo.toml");
3168 std::fs::write(&cargo_path, manifest).expect("project manifest");
3169
3170 let error = smol::block_on(Project::refresh_local_patches(
3171 &app,
3172 Path::new("../../waterui"),
3173 ))
3174 .expect_err("a copy here would be ignored");
3175
3176 let message = error.to_string();
3177 assert!(message.contains("their-workspace"), "{message}");
3178 assert_eq!(
3179 std::fs::read_to_string(&cargo_path).expect("manifest after the refusal"),
3180 manifest
3181 );
3182 }
3183}