1use cargo_toml::Manifest as CargoManifest;
4use color_eyre::eyre;
5use tracing::info;
6
7use crate::backend::Backend;
8
9#[derive(Debug, Clone)]
11pub struct Project {
12 root: PathBuf,
13 manifest: Manifest,
14 crate_name: String,
15 target_dir: PathBuf,
16}
17
18impl Project {
19 pub async fn run(&self, device: impl Device, hot_reload: bool) -> Result<Running, FailToRun> {
26 use crate::debug::hot_reload::{DEFAULT_PORT, HotReloadServer};
27
28 let platform = device.platform();
29
30 platform
32 .build(self, BuildOptions::new(false, hot_reload))
33 .await
34 .map_err(FailToRun::Build)?;
35
36 let artifact = platform
38 .package(self, PackageOptions::new(false, true))
39 .await
40 .map_err(FailToRun::Package)?;
41
42 let mut run_options = RunOptions::new();
44
45 let server = if hot_reload {
46 let server = HotReloadServer::launch(DEFAULT_PORT)
48 .await
49 .map_err(FailToRun::HotReload)?;
50
51 run_options.insert_env_var("WATERUI_HOT_RELOAD_HOST".to_string(), server.host());
53 run_options.insert_env_var(
54 "WATERUI_HOT_RELOAD_PORT".to_string(),
55 server.port().to_string(),
56 );
57
58 info!(
59 "Hot reload server started on {}:{}",
60 server.host(),
61 server.port()
62 );
63
64 Some(server)
65 } else {
66 None
67 };
68
69 info!("Running on device");
70
71 let mut running = device.run(artifact, run_options).await?;
72
73 if let Some(server) = server {
74 running.retain(server);
75 }
76
77 Ok(running)
78 }
79
80 #[must_use]
84 pub fn root(&self) -> &Path {
85 &self.root
86 }
87
88 #[must_use]
90 pub fn target_dir(&self) -> &Path {
91 &self.target_dir
92 }
93
94 #[must_use]
96 pub const fn backends(&self) -> &Backends {
97 &self.manifest.backends
98 }
99
100 #[must_use]
102 pub fn crate_name(&self) -> &str {
103 &self.crate_name
104 }
105
106 #[must_use]
108 pub const fn apple_backend(&self) -> Option<&AppleBackend> {
109 self.manifest.backends.apple()
110 }
111
112 #[must_use]
116 pub fn backend_path<B: Backend>(&self) -> PathBuf {
117 self.root
118 .join(self.manifest.backends.path())
119 .join(B::DEFAULT_PATH)
120 }
121
122 #[must_use]
126 pub fn backend_relative_path<B: Backend>(&self) -> PathBuf {
127 self.manifest.backends.path().join(B::DEFAULT_PATH)
128 }
129
130 #[must_use]
132 pub const fn android_backend(&self) -> Option<&AndroidBackend> {
133 self.manifest.backends.android()
134 }
135
136 #[must_use]
138 pub const fn manifest(&self) -> &Manifest {
139 &self.manifest
140 }
141
142 #[must_use]
144 pub const fn bundle_identifier(&self) -> &str {
145 self.manifest.package.bundle_identifier.as_str()
146 }
147
148 pub async fn clean(&self, platform: impl Platform) -> Result<(), eyre::Report> {
154 platform.clean(self).await
156 }
157
158 pub async fn clean_all(&self) -> Result<(), eyre::Report> {
169 use crate::{
170 android::platform::AndroidPlatform, apple::platform::ApplePlatform, platform::Platform,
171 };
172
173 let target_dir = self.root.join("target");
175 if target_dir.exists() {
176 smol::fs::remove_dir_all(&target_dir).await?;
177 }
178
179 if self.apple_backend().is_some() {
181 ApplePlatform::macos().clean(self).await?;
184 }
185
186 if self.android_backend().is_some() {
188 AndroidPlatform::arm64().clean(self).await?;
189 }
190
191 Ok(())
192 }
193
194 pub async fn package(
200 &self,
201 platform: impl Platform,
202 options: PackageOptions,
203 ) -> Result<Artifact, eyre::Report> {
204 platform.package(self, options).await
205 }
206}
207
208#[derive(Debug, thiserror::Error)]
210pub enum FailToOpenProject {
211 #[error("Failed to open project manifest: {0}")]
213 Manifest(FailToOpenManifest),
214 #[error("Failed to read Cargo.toml: {0}")]
216 CargoManifest(cargo_toml::Error),
217
218 #[error("Failed to get Cargo metadata: {0}")]
220 TargetDirError(#[from] cargo_metadata::Error),
221
222 #[error("Invalid Cargo.toml: missing crate name")]
224 MissingCrateName,
225
226 #[error("Project permissions are not allowed in non-playground projects")]
228 PermissionsNotAllowedInNonPlayground,
229
230 #[error("Backends configuration is not allowed in playground projects")]
232 BackendsNotAllowedInPlayground,
233
234 #[error("Failed to initialize backend: {0}")]
236 BackendInit(#[from] crate::backend::FailToInitBackend),
237}
238
239#[derive(Debug, thiserror::Error)]
241pub enum FailToCreateProject {
242 #[error("Directory already exists: {0}")]
244 DirectoryExists(PathBuf),
245 #[error("Failed to create directory: {0}")]
247 CreateDir(std::io::Error),
248 #[error("Failed to scaffold project: {0}")]
250 Scaffold(std::io::Error),
251 #[error("Failed to save manifest: {0}")]
253 SaveManifest(#[from] FailToSaveManifest),
254
255 #[error("Failed to get Cargo metadata: {0}")]
257 TargetDirError(#[from] cargo_metadata::Error),
258
259 #[error("Failed to initialize git repository: {0}")]
261 GitInit(std::io::Error),
262}
263
264#[derive(Debug, Clone)]
266pub struct CreateOptions {
267 pub name: String,
269 pub bundle_identifier: String,
271 pub playground: bool,
273 pub waterui_path: Option<PathBuf>,
275 pub author: String,
277}
278
279impl Project {
280 pub async fn create(
292 path: impl AsRef<Path>,
293 options: CreateOptions,
294 ) -> Result<Self, FailToCreateProject> {
295 let path = path.as_ref().to_path_buf();
296
297 if path.exists() {
299 return Err(FailToCreateProject::DirectoryExists(path));
300 }
301
302 smol::fs::create_dir_all(&path)
304 .await
305 .map_err(FailToCreateProject::CreateDir)?;
306
307 let crate_name = options
309 .name
310 .chars()
311 .map(|c| {
312 if c.is_alphanumeric() {
313 c.to_ascii_lowercase()
314 } else {
315 '_'
316 }
317 })
318 .collect::<String>();
319
320 let ctx = TemplateContext {
322 app_display_name: options.name.clone(),
323 app_name: options.name.replace(' ', ""),
324 crate_name: crate_name.clone(),
325 bundle_identifier: options.bundle_identifier.clone(),
326 author: options.author.clone(),
327 android_backend_path: options
328 .waterui_path
329 .as_ref()
330 .map(|p| p.join("backends/android")),
331 use_remote_dev_backend: options.waterui_path.is_none(),
332 waterui_path: options.waterui_path.clone(),
333 backend_project_path: None, android_permissions: Vec::new(),
335 };
336
337 templates::root::scaffold(&path, &ctx)
339 .await
340 .map_err(FailToCreateProject::Scaffold)?;
341
342 let package_type = if options.playground {
344 PackageType::Playground
345 } else {
346 PackageType::App
347 };
348
349 let manifest = Manifest {
350 package: Package {
351 package_type,
352 name: options.name.clone(),
353 bundle_identifier: options.bundle_identifier.clone(),
354 },
355 backends: Backends::default(),
356 waterui_path: options
357 .waterui_path
358 .as_ref()
359 .map(|p| p.display().to_string()),
360 permissions: HashMap::default(),
361 };
362
363 manifest.save(&path).await?;
365
366 Self::ensure_git_init(&path).await?;
368
369 let target_dir = get_target_dir(&path)
370 .await
371 .map_err(FailToCreateProject::TargetDirError)?;
372
373 Ok(Self {
374 root: path,
375 manifest,
376 crate_name,
377 target_dir,
378 })
379 }
380
381 async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
386 let mut cmd = Command::new("git");
389
390 let is_in_git = command(&mut cmd)
391 .args(["rev-parse", "--git-dir"])
392 .current_dir(path)
393 .output()
394 .await
395 .map(|output| output.status.success())
396 .unwrap_or(false);
397
398 if !is_in_git {
399 let mut cmd = Command::new("git");
401 command(&mut cmd)
402 .args(["init"])
403 .current_dir(path)
404 .status()
405 .await
406 .map_err(FailToCreateProject::GitInit)?;
407 }
408
409 Ok(())
410 }
411
412 pub async fn init_apple_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
419 use crate::backend::Backend;
420
421 let backend = AppleBackend::init(self).await?;
422 self.manifest.backends.set_apple(backend);
423 self.manifest
424 .save(&self.root)
425 .await
426 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
427 Ok(())
428 }
429
430 pub async fn init_android_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
437 use crate::backend::Backend;
438
439 let backend = AndroidBackend::init(self).await?;
440 self.manifest.backends.set_android(backend);
441 self.manifest
442 .save(&self.root)
443 .await
444 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
445 Ok(())
446 }
447
448 pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
458 use crate::backend::Backend;
459
460 let path = path.as_ref().to_path_buf();
461 let mut manifest = Manifest::open(path.join("Water.toml"))
462 .await
463 .map_err(FailToOpenProject::Manifest)?;
464
465 let cargo_path = path.join("Cargo.toml");
466
467 let cargo_manifest = unblock(move || CargoManifest::from_path(cargo_path))
468 .await
469 .map_err(FailToOpenProject::CargoManifest)?;
470 let crate_name = cargo_manifest
471 .package
472 .map(|p| p.name)
473 .ok_or(FailToOpenProject::MissingCrateName)?;
474
475 let is_playground = manifest.package.package_type == PackageType::Playground;
476
477 if !is_playground && !manifest.permissions.is_empty() {
479 return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
480 }
481
482 if is_playground && !manifest.backends.is_empty() {
484 return Err(FailToOpenProject::BackendsNotAllowedInPlayground);
485 }
486
487 if is_playground {
489 manifest.backends.set_path(".water");
490 }
491
492 let target_dir = get_target_dir(&path)
493 .await
494 .map_err(FailToOpenProject::TargetDirError)?;
495
496 let mut project = Self {
497 root: path,
498 manifest,
499 crate_name,
500 target_dir,
501 };
502
503 let skip_backend_init = std::env::var("WATERUI_SKIP_RUST_BUILD")
512 .map(|v| v == "1")
513 .unwrap_or(false)
514 || std::env::var("ACTION").is_ok() || std::env::var("XCODE_PRODUCT_BUILD_VERSION").is_ok();
516
517 if is_playground && !skip_backend_init {
518 let apple_backend = AppleBackend::init(&project)
520 .await
521 .map_err(FailToOpenProject::BackendInit)?;
522 project.manifest.backends.set_apple(apple_backend);
523
524 let android_backend = AndroidBackend::init(&project)
526 .await
527 .map_err(FailToOpenProject::BackendInit)?;
528 project.manifest.backends.set_android(android_backend);
529 }
530
531 Ok(project)
532 }
533}
534
535async fn get_target_dir(current_dir: &Path) -> Result<PathBuf, cargo_metadata::Error> {
536 let current_dir = current_dir.to_path_buf();
537 let metadata = unblock(|| {
538 cargo_metadata::MetadataCommand::new()
539 .no_deps()
540 .current_dir(current_dir)
541 .exec()
542 })
543 .await?;
544
545 let target_dir = metadata.target_directory.as_std_path();
546
547 Ok(target_dir.to_path_buf())
548}
549
550use std::{
551 collections::HashMap,
552 path::{Path, PathBuf},
553};
554
555use serde::{Deserialize, Serialize};
556use smol::{fs::read_to_string, process::Command, unblock};
557
558use crate::{
559 android::backend::AndroidBackend,
560 apple::backend::AppleBackend,
561 backend::Backends,
562 build::BuildOptions,
563 device::{Artifact, Device, FailToRun, RunOptions, Running},
564 platform::{PackageOptions, Platform},
565 templates::{self, TemplateContext},
566 utils::command,
567};
568
569#[derive(Debug, Serialize, Deserialize, Clone)]
571pub struct Manifest {
572 pub package: Package,
574 #[serde(default, skip_serializing_if = "Backends::is_empty")]
576 pub backends: Backends,
577 #[serde(skip_serializing_if = "Option::is_none")]
580 pub waterui_path: Option<String>,
581 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
583 pub permissions: HashMap<String, PermissionEntry>,
584}
585
586#[derive(Debug, Serialize, Deserialize, Clone)]
588pub struct PermissionEntry {
589 enable: bool,
590 description: String,
592}
593
594impl PermissionEntry {
595 #[must_use]
597 pub const fn is_enabled(&self) -> bool {
598 self.enable
599 }
600}
601
602#[derive(Debug, thiserror::Error)]
604pub enum FailToOpenManifest {
605 #[error("Failed to read manifest file: {0}")]
607 ReadError(std::io::Error),
608 #[error("Invalid manifest file: {0}")]
610 InvalidManifest(toml::de::Error),
611
612 #[error("Manifest file not found at the specified path")]
614 NotFound,
615}
616
617#[derive(Debug, thiserror::Error)]
619pub enum FailToSaveManifest {
620 #[error("Failed to serialize manifest: {0}")]
622 Serialize(toml::ser::Error),
623 #[error("Failed to write manifest file: {0}")]
625 Write(std::io::Error),
626}
627impl Manifest {
628 pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenManifest> {
635 let path = path.as_ref();
636 let result = read_to_string(path).await;
637
638 match result {
639 Ok(c) => toml::from_str(&c).map_err(FailToOpenManifest::InvalidManifest),
640 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(FailToOpenManifest::NotFound),
641 Err(e) => Err(FailToOpenManifest::ReadError(e)),
642 }
643 }
644
645 pub async fn save(&self, dir: impl AsRef<Path>) -> Result<(), FailToSaveManifest> {
651 let path = dir.as_ref().join("Water.toml");
652 let content = toml::to_string_pretty(self).map_err(FailToSaveManifest::Serialize)?;
653 smol::fs::write(&path, content)
654 .await
655 .map_err(FailToSaveManifest::Write)
656 }
657
658 #[must_use]
660 pub fn new(package: Package) -> Self {
661 Self {
662 package,
663 backends: Backends::default(),
664 waterui_path: None,
665 permissions: HashMap::default(),
666 }
667 }
668}
669
670#[derive(Debug, Serialize, Deserialize, Clone)]
672pub struct Package {
673 #[serde(rename = "type")]
675 pub package_type: PackageType,
676 pub name: String,
678 pub bundle_identifier: String,
680}
681
682#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
684#[serde(rename_all = "lowercase")]
685pub enum PackageType {
686 #[default]
688 App,
689 Playground,
692}