use cargo_toml::Manifest as CargoManifest;
use color_eyre::eyre;
use futures::FutureExt as _;
use futures::future::{BoxFuture, Shared};
use tracing::info;
use crate::build::RustLinkage;
use crate::framework::{
FrameworkChannel, ResolvedFramework, validate_local_cli, validate_resolved_cli,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OpenMode {
Full,
PreviewBuild,
}
#[derive(Debug, Clone)]
struct CargoLayout {
target_dir: PathBuf,
workspace_root: PathBuf,
}
enum CargoResolution {
Local,
Locked,
Update,
}
fn spawn_cargo_layout_resolution(
current_dir: &Path,
framework: Option<ResolvedFramework>,
local: bool,
) -> Shared<BoxFuture<'static, Result<CargoLayout, String>>> {
let current_dir = current_dir.to_path_buf();
let mode = if local {
CargoResolution::Local
} else if framework.is_some() {
CargoResolution::Locked
} else {
CargoResolution::Update
};
smol::spawn(async move {
resolve_cargo_layout(¤t_dir, framework, mode)
.await
.map_err(|error| error.to_string())
})
.boxed()
.shared()
}
#[derive(Debug, Clone)]
pub struct Project {
root: PathBuf,
manifest: Manifest,
crate_name: CrateName,
cargo_layout: Shared<BoxFuture<'static, Result<CargoLayout, String>>>,
linked_packages: Arc<async_lock::OnceCell<Result<BTreeMap<String, String>, String>>>,
managed_backends_root: PathBuf,
}
impl Project {
pub async fn select_channel(
path: impl AsRef<Path>,
channel: FrameworkChannel,
) -> eyre::Result<Self> {
let path = smol::fs::canonicalize(path.as_ref()).await?;
let water_path = path.join("Water.toml");
let cargo_path = path.join("Cargo.toml");
let mut water: toml_edit::DocumentMut =
smol::fs::read_to_string(&water_path).await?.parse()?;
let previous: Manifest = toml::from_str(&water.to_string())?;
let mut cargo: toml_edit::DocumentMut =
smol::fs::read_to_string(&cargo_path).await?.parse()?;
let crate_name = CrateName::try_from(
cargo["package"]["name"]
.as_str()
.ok_or_else(|| eyre::eyre!("channel selection requires a project Cargo.toml"))?,
)
.map_err(|error| eyre::eyre!(error))?;
let (framework, lockfile) = ResolvedFramework::resolve(channel).await?;
let mut next = previous.clone();
next.waterui_path = None;
next.framework = Some(framework.clone());
let mut updates =
templates::framework_updates(&path, &previous, &next, &crate_name).await?;
framework.update_manifest(&mut cargo, &templates::project_patches(&path, &previous)?)?;
water.remove("waterui_path");
water["framework"] =
toml_edit::Item::Table(toml_edit::ser::to_document(&framework)?.into_table());
updates.push((water_path, water.to_string().into_bytes()));
updates.push((cargo_path, cargo.to_string().into_bytes()));
if let Some(lockfile) = lockfile {
updates.push((
path.join("Cargo.lock"),
framework.cargo_lock(&lockfile)?.to_string().into_bytes(),
));
updates.push((path.join("Water.lock"), lockfile));
}
let mut updates: Vec<_> = updates
.into_iter()
.map(|(file, contents)| (file, Some(contents)))
.collect();
if channel == FrameworkChannel::Stable
&& previous
.framework
.as_ref()
.is_some_and(|previous| previous.channel() != FrameworkChannel::Stable)
{
updates.push((path.join("Water.lock"), None));
}
apply_channel_selection(&path, framework, updates).await?;
Self::open_for_preview_build(path).await.map_err(Into::into)
}
pub async fn run<B: Backend, D: Device>(
&self,
backend: &B,
platform: TargetPlatform,
device: D,
) -> Result<Running, FailToRun> {
self.run_with_options(backend, platform, device, RunOptions::new())
.await
}
pub async fn run_with_options<B: Backend, D: Device>(
&self,
backend: &B,
platform: TargetPlatform,
device: D,
run_options: RunOptions,
) -> Result<Running, FailToRun> {
backend
.build(self, platform, BuildOptions::development(false))
.await
.map_err(FailToRun::Build)?;
let artifact = backend
.package(self, platform, PackageOptions::development())
.await
.map_err(FailToRun::Package)?;
Self::run_packaged(device, artifact, run_options).await
}
pub async fn run_android_with_options<D: Device + AndroidAbiProvider>(
&self,
_backend: &AndroidBackend,
device: D,
run_options: RunOptions,
) -> Result<Running, FailToRun> {
let abi = device.android_abi();
self.browser_runtime_plan(TargetPlatform::Android, TargetBackend::Android)
.await
.map_err(FailToRun::Build)?;
AndroidPlatform::clean_jni_libs(self)
.await
.map_err(FailToRun::Build)?;
AndroidPlatform::new(abi)
.build(self, BuildOptions::development(false))
.await
.map_err(FailToRun::Build)?;
let artifact =
AndroidPlatform::package_with_abis(self, PackageOptions::development(), &[abi])
.await
.map_err(FailToRun::Package)?;
Self::run_packaged(device, artifact, run_options).await
}
async fn run_packaged<D: Device>(
device: D,
artifact: Artifact,
run_options: RunOptions,
) -> Result<Running, FailToRun> {
info!("Running on device");
let running = device.run(artifact, run_options).await?;
Ok(running)
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub async fn target_dir(&self) -> eyre::Result<PathBuf> {
Ok(self.cargo_layout().await?.target_dir)
}
pub async fn lockfile_path(&self) -> eyre::Result<PathBuf> {
Ok(self.cargo_layout().await?.workspace_root.join("Cargo.lock"))
}
async fn cargo_layout(&self) -> eyre::Result<CargoLayout> {
self.cargo_layout
.clone()
.await
.map_err(|error| eyre::eyre!(error))
}
pub async fn water_target_dir(&self, linkage: RustLinkage) -> eyre::Result<PathBuf> {
let variant = match linkage {
RustLinkage::SharedRuntime => "shared",
RustLinkage::Static => "static",
};
Ok(self
.target_dir()
.await?
.join("water-backends")
.join(variant))
}
pub async fn toolchain_target_dir(&self, toolchain: &str) -> eyre::Result<PathBuf> {
Ok(self
.target_dir()
.await?
.join("water-backends")
.join(toolchain))
}
#[must_use]
pub const fn backends(&self) -> &Backends {
&self.manifest.backends
}
#[must_use]
pub const fn crate_name(&self) -> &CrateName {
&self.crate_name
}
#[must_use]
pub fn ffi_crate_name(&self) -> CrateName {
self.app_crate_overrides()
.and_then(|crates| crates.ffi.clone())
.unwrap_or_else(|| self.crate_name.with_suffix("ffi"))
}
#[must_use]
pub fn preview_ffi_crate_name(&self) -> CrateName {
self.crate_name.with_suffix("preview-ffi")
}
#[must_use]
pub fn preview_dylib_crate_path(&self, workspace_root: &Path) -> PathBuf {
self.preview_ffi_crate_path(workspace_root)
}
#[must_use]
pub fn preview_dylib_crate_name(&self) -> CrateName {
self.preview_ffi_crate_name()
}
#[must_use]
pub fn gtk_backend_crate_name(&self) -> CrateName {
self.app_crate_overrides()
.and_then(|crates| crates.gtk.clone())
.unwrap_or_else(|| self.crate_name.with_suffix("gtk4"))
}
#[must_use]
pub fn hydrolysis_backend_crate_name(&self) -> CrateName {
self.app_crate_overrides()
.and_then(|crates| crates.hydrolysis.clone())
.unwrap_or_else(|| self.crate_name.with_suffix("hydrolysis"))
}
#[must_use]
pub fn esp32_backend_crate_name(&self) -> CrateName {
self.crate_name.with_suffix("esp32")
}
#[must_use]
pub const fn package_type(&self) -> PackageType {
self.manifest.package.package_type
}
#[must_use]
pub fn is_playground(&self) -> bool {
self.package_type() == PackageType::Playground
}
#[must_use]
pub const fn apple_backend(&self) -> Option<&AppleBackend> {
self.manifest.backends.apple()
}
#[must_use]
pub fn backend_path<B: Backend>(&self) -> PathBuf {
self.managed_backends_root.join(B::DEFAULT_PATH)
}
#[must_use]
pub fn backend_relative_path<B: Backend>(&self) -> PathBuf {
self.manifest.backends.path().join(B::DEFAULT_PATH)
}
#[must_use]
pub fn ffi_crate_path(&self) -> PathBuf {
self.managed_backends_root.join("ffi")
}
#[must_use]
pub fn preview_module_member_path(&self) -> PathBuf {
Path::new(crate::templates::PREVIEW_MODULES_DIR)
.join(self.preview_ffi_crate_name().to_string())
}
#[must_use]
pub fn preview_ffi_crate_path(&self, workspace_root: &Path) -> PathBuf {
workspace_root.join(self.preview_module_member_path())
}
#[must_use]
pub fn ffi_crate_relative_path(&self) -> PathBuf {
self.manifest.backends.path().join("ffi")
}
#[must_use]
pub const fn android_backend(&self) -> Option<&AndroidBackend> {
self.manifest.backends.android()
}
#[must_use]
pub const fn gtk4_backend(&self) -> Option<&crate::gtk4::backend::Gtk4Backend> {
self.manifest.backends.gtk4()
}
#[must_use]
pub const fn hydrolysis_backend(
&self,
) -> Option<&crate::hydrolysis::backend::HydrolysisBackend> {
self.manifest.backends.hydrolysis()
}
#[must_use]
pub const fn esp32_backend(&self) -> Option<&crate::esp32::backend::Esp32Backend> {
self.manifest.backends.esp32()
}
#[must_use]
pub const fn manifest(&self) -> &Manifest {
&self.manifest
}
pub async fn links_runtime_package(&self, package_name: &str) -> eyre::Result<bool> {
let project_root = self.root.clone();
let cargo_layout = self.cargo_layout.clone();
let packages = self
.linked_packages
.get_or_init(|| async move {
cargo_layout.await?;
resolve_linked_runtime_packages(project_root)
.await
.map_err(|error| error.to_string())
})
.await;
match packages {
Ok(packages) => Ok(packages.contains_key(package_name)),
Err(error) => Err(eyre::eyre!(error.clone())),
}
}
pub async fn resolved_webview_backend(
&self,
platform: TargetPlatform,
backend: TargetBackend,
) -> eyre::Result<Option<ResolvedWebViewBackend>> {
if !self.links_runtime_package("waterui-webview").await? {
return Ok(None);
}
let engine = self.linked_browser_engine().await?;
engine
.unwrap_or(ResolvedWebViewBackend::System)
.validate(platform, backend)
.map(Some)
.map_err(Into::into)
}
pub async fn linked_browser_engine(&self) -> eyre::Result<Option<ResolvedWebViewBackend>> {
let cef = self.links_runtime_package("waterui-browser-cef").await?;
let wpe = self.links_runtime_package("waterui-browser-wpe").await?;
match (cef, wpe) {
(true, true) => eyre::bail!(
"the application links both waterui-browser-cef and waterui-browser-wpe; \
exactly one browser engine can draw a WebView"
),
(true, false) => Ok(Some(ResolvedWebViewBackend::Cef)),
(false, true) => Ok(Some(ResolvedWebViewBackend::Wpe)),
(false, false) => Ok(None),
}
}
pub async fn browser_runtime_plan(
&self,
platform: TargetPlatform,
backend: TargetBackend,
) -> eyre::Result<BrowserRuntimePlan> {
let webview = self.resolved_webview_backend(platform, backend).await?;
let chromium = self.links_runtime_package("waterui-chromium").await?;
if chromium && !cef_is_supported(platform, backend) {
eyre::bail!(
"waterui-chromium requires CEF, which is unsupported for platform {platform:?} \
with backend {backend:?}"
);
}
Ok(BrowserRuntimePlan { webview, chromium })
}
#[must_use]
pub const fn bundle_identifier(&self) -> &BundleIdentifier {
&self.manifest.package.bundle_identifier
}
#[must_use]
pub fn assets_path(&self) -> &str {
&self.manifest.package.assets_path
}
#[must_use]
pub fn assets_dir(&self) -> PathBuf {
self.root.join(&self.manifest.package.assets_path)
}
pub async fn clean<B: Backend>(
&self,
backend: &B,
platform: TargetPlatform,
) -> Result<(), eyre::Report> {
backend.clean(self, platform).await
}
pub async fn clean_all(&self) -> Result<(), eyre::Report> {
use crate::{
android::platform::clean_android, apple::platform::clean_apple,
esp32::platform::clean_esp32, gtk4::platform::clean_gtk4,
hydrolysis::platform::clean_hydrolysis,
};
if self.is_playground() {
crate::water_dir::remove_project_build_cache(self.root()).await?;
let water_backends_root = self.target_dir().await?.join("water-backends");
if water_backends_root.exists() {
smol::fs::remove_dir_all(&water_backends_root).await?;
}
return Ok(());
}
let target_dir = self.target_dir().await?;
if target_dir.exists() {
smol::fs::remove_dir_all(&target_dir).await?;
}
if self.apple_backend().is_some() {
clean_apple(self).await?;
}
if self.android_backend().is_some() {
clean_android(self).await?;
}
if self.gtk4_backend().is_some() || (self.is_playground() && cfg!(target_os = "linux")) {
clean_gtk4(self).await?;
}
if self.hydrolysis_backend().is_some() || self.is_playground() {
clean_hydrolysis(self).await?;
}
if self.esp32_backend().is_some() {
clean_esp32(self).await?;
}
let ffi_target_dir = self.ffi_crate_path().join("target");
if ffi_target_dir.exists() {
smol::fs::remove_dir_all(&ffi_target_dir).await?;
}
Ok(())
}
pub async fn package<B: Backend>(
&self,
backend: &B,
platform: TargetPlatform,
options: PackageOptions,
) -> Result<Artifact, eyre::Report> {
backend.package(self, platform, options).await
}
fn app_crate_overrides(&self) -> Option<&AppCrates> {
self.manifest.app.as_ref()?.crates.as_ref()
}
}
#[derive(Debug, thiserror::Error)]
pub enum FailToOpenProject {
#[error("Failed to open project manifest: {0}")]
Manifest(FailToOpenManifest),
#[error("Failed to read Cargo.toml: {0}")]
CargoManifest(cargo_toml::Error),
#[error("Failed to get Cargo metadata: {0}")]
TargetDirError(#[from] cargo_metadata::Error),
#[error("Framework compatibility check failed: {0}")]
Framework(eyre::Report),
#[error("Failed to refresh the [patch] tables from the local checkout: {0}")]
LocalPatches(eyre::Report),
#[error("Invalid Cargo.toml: missing crate name")]
MissingCrateName,
#[error("Invalid Cargo.toml crate name: {0}")]
InvalidCrateName(String),
#[error("Project permissions are not allowed in non-playground projects")]
PermissionsNotAllowedInNonPlayground,
#[error(
"Backend project configuration is not allowed in playground projects \
(device settings under [backends.esp32] are the exception)"
)]
BackendsNotAllowedInPlayground,
#[error("Failed to initialize backend: {0}")]
BackendInit(#[from] crate::backend::FailToInitBackend),
#[error("Failed to prepare managed build cache: {0}")]
BuildCache(#[from] eyre::Report),
}
#[derive(Debug, thiserror::Error)]
pub enum FailToCreateProject {
#[error("Failed to resolve framework: {0}")]
Framework(eyre::Report),
#[error("Directory already exists: {0}")]
DirectoryExists(PathBuf),
#[error("Failed to create directory: {0}")]
CreateDir(std::io::Error),
#[error("Failed to scaffold project: {0}")]
Scaffold(std::io::Error),
#[error("Failed to save manifest: {0}")]
SaveManifest(#[from] FailToSaveManifest),
#[error("Failed to get Cargo metadata: {0}")]
TargetDirError(#[from] cargo_metadata::Error),
#[error("Failed to resolve managed build cache: {0}")]
BuildCache(#[from] eyre::Report),
#[error("Failed to initialize git repository: {0}")]
GitInit(std::io::Error),
#[error("Failed to check git repository status: {0}")]
GitStatus(std::io::Error),
}
#[derive(Debug, Clone)]
pub struct CreateOptions {
pub name: String,
pub bundle_identifier: BundleIdentifier,
pub package_type: PackageType,
pub waterui_path: Option<PathBuf>,
pub channel: Option<FrameworkChannel>,
pub author: String,
}
impl CreateOptions {
fn crate_name(&self) -> Result<CrateName, FailToCreateProject> {
let name = self
.name
.chars()
.map(|character| {
if character.is_alphanumeric() {
character.to_ascii_lowercase()
} else {
'_'
}
})
.collect::<String>();
CrateName::try_from(name).map_err(|error| {
FailToCreateProject::Scaffold(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
error,
))
})
}
async fn resolve_framework(
&mut self,
) -> eyre::Result<(Option<ResolvedFramework>, Option<Vec<u8>>)> {
if let Some(path) = &self.waterui_path {
if self.channel.is_some() {
eyre::bail!("a framework channel and a local source path are mutually exclusive");
}
let root = smol::fs::canonicalize(path).await?;
validate_local_cli(&root).await?;
self.waterui_path = Some(root);
return Ok((None, None));
}
let (framework, lockfile) =
ResolvedFramework::resolve(self.channel.unwrap_or_default()).await?;
Ok((Some(framework), lockfile))
}
}
impl Project {
async fn scaffold_ffi_companion(&self) -> Result<(), crate::backend::FailToInitBackend> {
let manifest = self.manifest();
let app_name = manifest
.package
.name
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>();
let webview_enabled = self
.links_runtime_package("waterui-webview")
.await
.map_err(crate::backend::FailToInitBackend::Config)?;
let chromium_enabled = self
.links_runtime_package("waterui-chromium")
.await
.map_err(crate::backend::FailToInitBackend::Config)?;
let browser_engine = self
.linked_browser_engine()
.await
.map_err(crate::backend::FailToInitBackend::Config)?;
let ctx =
TemplateContext::for_project_manifest(manifest, self.crate_name().clone(), app_name)
.with_backend_project_path(self.ffi_crate_path())
.with_project_root_path(self.root.clone())
.with_webview_enabled(webview_enabled)
.with_chromium_enabled(chromium_enabled)
.with_browser_engine(browser_engine);
templates::ffi::scaffold(&self.ffi_crate_path(), &ctx, &self.ffi_crate_name())
.await
.map_err(crate::backend::FailToInitBackend::Io)?;
let lockfile = self
.lockfile_path()
.await
.map_err(crate::backend::FailToInitBackend::Config)?;
templates::ffi::seed_lockfile(&self.ffi_crate_path(), &lockfile)
.await
.map_err(crate::backend::FailToInitBackend::Io)
}
pub async fn scaffold_preview_ffi_companion(
&self,
workspace_root: &Path,
) -> Result<PathBuf, crate::backend::FailToInitBackend> {
let manifest = self.manifest();
let app_name = manifest
.package
.name
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>();
let ctx =
TemplateContext::for_project_manifest(manifest, self.crate_name().clone(), app_name)
.with_backend_project_path(self.preview_ffi_crate_path(workspace_root))
.with_project_root_path(self.root.clone());
let crate_path = self.preview_ffi_crate_path(workspace_root);
templates::preview_ffi::scaffold(&crate_path, &ctx, &self.preview_ffi_crate_name())
.await
.map_err(crate::backend::FailToInitBackend::Io)?;
Ok(crate_path)
}
async fn remove_ffi_companion_if_unused(&self) -> eyre::Result<()> {
if self.apple_backend().is_some() || self.android_backend().is_some() {
return Ok(());
}
let ffi_path = self.ffi_crate_path();
if ffi_path.exists() {
smol::fs::remove_dir_all(&ffi_path).await?;
}
Ok(())
}
pub async fn create(
path: impl AsRef<Path>,
mut options: CreateOptions,
) -> Result<Self, FailToCreateProject> {
let path = path.as_ref().to_path_buf();
if path.exists() {
return Err(FailToCreateProject::DirectoryExists(path));
}
let crate_name = options.crate_name()?;
let (framework, lockfile) = options
.resolve_framework()
.await
.map_err(FailToCreateProject::Framework)?;
smol::fs::create_dir_all(&path)
.await
.map_err(FailToCreateProject::CreateDir)?;
let mut ctx = TemplateContext::for_create_options(&options, crate_name.clone());
if let Some(framework) = &framework {
ctx.framework = framework.clone();
}
let assets_path = default_assets_path();
templates::root::scaffold(&path, &ctx, &assets_path)
.await
.map_err(FailToCreateProject::Scaffold)?;
if let Some(lockfile) = lockfile {
let contents = ctx
.framework
.cargo_lock(&lockfile)
.map_err(FailToCreateProject::Framework)?
.to_string();
smol::fs::write(path.join("Water.lock"), lockfile)
.await
.map_err(FailToCreateProject::Scaffold)?;
smol::fs::write(path.join("Cargo.lock"), contents)
.await
.map_err(FailToCreateProject::Scaffold)?;
}
let mut backends = Backends::default();
if options.package_type == PackageType::App {
backends.set_path("backends");
}
let manifest = Manifest {
package: Package {
package_type: options.package_type,
name: options.name.clone(),
bundle_identifier: options.bundle_identifier.clone(),
assets_path,
accessory: false,
},
backends,
waterui_path: options
.waterui_path
.as_ref()
.map(|p| p.display().to_string()),
framework,
permissions: BTreeMap::default(),
app: None,
theme: None,
};
manifest.save(&path).await?;
Self::ensure_git_init(&path).await?;
let managed_backends_root = if options.package_type == PackageType::Playground {
crate::water_dir::project_build_cache_dir(&path)
.await
.map_err(FailToCreateProject::BuildCache)?
} else {
path.join(manifest.backends.path())
};
let cargo_layout = if let Some(framework) = &manifest.framework {
let layout =
resolve_cargo_layout(&path, Some(framework.clone()), CargoResolution::Update)
.await
.map_err(FailToCreateProject::Framework)?;
futures::future::ready(Ok::<CargoLayout, String>(layout))
.boxed()
.shared()
} else {
spawn_cargo_layout_resolution(&path, None, true)
};
Ok(Self {
root: path,
manifest,
crate_name,
cargo_layout,
linked_packages: Arc::new(async_lock::OnceCell::new()),
managed_backends_root,
})
}
async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
let mut cmd = Command::new("git");
let is_in_git = command(&mut cmd)
.args(["rev-parse", "--git-dir"])
.current_dir(path)
.output()
.await
.map_err(FailToCreateProject::GitStatus)?
.status
.success();
if !is_in_git {
let mut cmd = Command::new("git");
command(&mut cmd)
.args(["init"])
.current_dir(path)
.status()
.await
.map_err(FailToCreateProject::GitInit)?;
}
Ok(())
}
pub async fn init_apple_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
use crate::backend::Backend;
let backend = AppleBackend::init(self).await?;
self.scaffold_ffi_companion().await?;
self.manifest.backends.set_apple(backend);
self.manifest
.save(&self.root)
.await
.map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
Ok(())
}
pub async fn init_android_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
use crate::backend::Backend;
let backend = AndroidBackend::init(self).await?;
self.scaffold_ffi_companion().await?;
self.manifest.backends.set_android(backend);
self.manifest
.save(&self.root)
.await
.map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
Ok(())
}
pub async fn init_gtk4_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
use crate::{backend::Backend, gtk4::backend::Gtk4Backend};
if !cfg!(target_os = "linux") {
return Err(crate::backend::FailToInitBackend::Io(
std::io::Error::other("GTK4 backend is only supported on Linux hosts"),
));
}
let backend = Gtk4Backend::init(self).await?;
self.manifest.backends.set_gtk4(backend);
self.manifest
.save(&self.root)
.await
.map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
Ok(())
}
pub async fn init_hydrolysis_backend(
&mut self,
) -> Result<(), crate::backend::FailToInitBackend> {
use crate::{backend::Backend, hydrolysis::backend::HydrolysisBackend};
let backend = HydrolysisBackend::init(self).await?;
self.manifest.backends.set_hydrolysis(backend);
self.manifest
.save(&self.root)
.await
.map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
Ok(())
}
pub async fn init_esp32_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
use crate::{backend::Backend, esp32::backend::Esp32Backend};
let backend = Esp32Backend::init(self).await?;
self.manifest.backends.set_esp32(backend);
self.manifest
.save(&self.root)
.await
.map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
Ok(())
}
pub async fn set_esp32_chip(
&mut self,
chip: crate::esp32::chip::Esp32Chip,
) -> eyre::Result<()> {
let current = self.esp32_backend().cloned().unwrap_or_default();
if current.chip() == chip.id() {
return Ok(());
}
self.manifest.backends.set_esp32(current.with_chip(chip));
self.save_manifest().await
}
pub async fn remove_apple_backend(&mut self) -> eyre::Result<()> {
if let Some(backend) = self.apple_backend() {
let path = backend.project_path().to_path_buf();
self.remove_backend_relative_dir(&path).await?;
}
self.manifest.backends.clear_apple();
self.remove_ffi_companion_if_unused().await?;
self.save_manifest().await
}
pub async fn remove_android_backend(&mut self) -> eyre::Result<()> {
if let Some(backend) = self.android_backend() {
let path = backend.project_path().clone();
self.remove_backend_relative_dir(&path).await?;
}
self.manifest.backends.clear_android();
self.remove_ffi_companion_if_unused().await?;
self.save_manifest().await
}
pub async fn remove_gtk4_backend(&mut self) -> eyre::Result<()> {
if let Some(backend) = self.gtk4_backend() {
let path = backend.project_path().clone();
self.remove_backend_relative_dir(&path).await?;
}
self.manifest.backends.clear_gtk4();
self.save_manifest().await
}
pub async fn remove_hydrolysis_backend(&mut self) -> eyre::Result<()> {
if let Some(backend) = self.hydrolysis_backend() {
let path = backend.project_path().clone();
self.remove_backend_relative_dir(&path).await?;
}
self.manifest.backends.clear_hydrolysis();
self.save_manifest().await
}
pub async fn remove_esp32_backend(&mut self) -> eyre::Result<()> {
if let Some(backend) = self.esp32_backend() {
let path = backend.project_path().clone();
self.remove_backend_relative_dir(&path).await?;
}
self.manifest.backends.clear_esp32();
self.save_manifest().await
}
pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
Self::open_with_mode(path, OpenMode::Full).await
}
pub async fn open_for_preview_build(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
Self::open_with_mode(path, OpenMode::PreviewBuild).await
}
async fn refresh_local_patches(project_root: &Path, waterui_path: &Path) -> eyre::Result<()> {
let project_root = project_root.to_path_buf();
let waterui_path = waterui_path.to_path_buf();
unblock(move || {
let cargo_path = project_root.join("Cargo.toml");
let text = std::fs::read_to_string(&cargo_path)?;
let current = CargoManifest::from_slice(text.as_bytes())?.patch;
let next = templates::local_framework_patches(&project_root, &waterui_path)?;
if current == next {
return Ok(());
}
let mut document: toml_edit::DocumentMut = text.parse()?;
crate::framework::rewrite_patch_tables(&mut document, ¤t, &next)?;
std::fs::write(&cargo_path, document.to_string())?;
info!(
path = %cargo_path.display(),
"Refreshed the [patch] tables from the local checkout"
);
Ok(())
})
.await
}
#[allow(clippy::too_many_lines)]
async fn open_with_mode(
path: impl AsRef<Path>,
open_mode: OpenMode,
) -> Result<Self, FailToOpenProject> {
use crate::backend::Backend;
let total_start = std::time::Instant::now();
let path = path.as_ref().to_path_buf();
let manifest_start = std::time::Instant::now();
let manifest = Manifest::open(path.join("Water.toml"))
.await
.map_err(FailToOpenProject::Manifest)?;
if let Some(framework) = &manifest.framework {
framework
.validate_cli()
.map_err(FailToOpenProject::Framework)?;
}
if let Some(local) = &manifest.waterui_path {
validate_local_cli(&path.join(local))
.await
.map_err(FailToOpenProject::Framework)?;
Self::refresh_local_patches(&path, Path::new(local))
.await
.map_err(FailToOpenProject::LocalPatches)?;
}
info!(
path = %path.display(),
open_mode = ?open_mode,
elapsed_ms = manifest_start.elapsed().as_millis(),
"Project::open loaded Water.toml"
);
let cargo_path = path.join("Cargo.toml");
let cargo_manifest_start = std::time::Instant::now();
let cargo_manifest = unblock(move || CargoManifest::from_path(cargo_path))
.await
.map_err(FailToOpenProject::CargoManifest)?;
info!(
path = %path.display(),
open_mode = ?open_mode,
elapsed_ms = cargo_manifest_start.elapsed().as_millis(),
"Project::open loaded Cargo.toml"
);
let crate_name = cargo_manifest
.package
.map(|p| p.name)
.ok_or(FailToOpenProject::MissingCrateName)
.and_then(|value| {
CrateName::try_from(value).map_err(FailToOpenProject::InvalidCrateName)
})?;
let is_playground = manifest.package.package_type == PackageType::Playground;
if !is_playground && !manifest.permissions.is_empty() {
return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
}
if is_playground && manifest.backends.configures_backend_projects() {
return Err(FailToOpenProject::BackendsNotAllowedInPlayground);
}
let cargo_layout = spawn_cargo_layout_resolution(
&path,
manifest.framework.clone(),
manifest.waterui_path.is_some(),
);
cargo_layout
.clone()
.await
.map_err(|error| FailToOpenProject::Framework(eyre::eyre!(error)))?;
let managed_backends_root = if is_playground {
let build_cache_start = std::time::Instant::now();
let root = crate::water_dir::ensure_project_build_cache(&path)
.await
.map_err(FailToOpenProject::BuildCache)?;
info!(
path = %path.display(),
open_mode = ?open_mode,
elapsed_ms = build_cache_start.elapsed().as_millis(),
"Project::open ensured project build cache"
);
root
} else {
path.join(manifest.backends.path())
};
let mut project = Self {
root: path,
manifest,
crate_name,
cargo_layout,
linked_packages: Arc::new(async_lock::OnceCell::new()),
managed_backends_root,
};
let skip_backend_init = std::env::var("WATERUI_SKIP_RUST_BUILD")
.is_ok_and(|value| value == "1")
|| std::env::var("ACTION").is_ok() || std::env::var("XCODE_PRODUCT_BUILD_VERSION").is_ok();
if is_playground && !skip_backend_init && open_mode == OpenMode::Full {
let apple_backend_start = std::time::Instant::now();
let apple_backend = AppleBackend::init(&project)
.await
.map_err(FailToOpenProject::BackendInit)?;
info!(
path = %project.root.display(),
elapsed_ms = apple_backend_start.elapsed().as_millis(),
"Project::open initialized Apple backend"
);
project.manifest.backends.set_apple(apple_backend);
let android_backend_start = std::time::Instant::now();
let android_backend = AndroidBackend::init(&project)
.await
.map_err(FailToOpenProject::BackendInit)?;
info!(
path = %project.root.display(),
elapsed_ms = android_backend_start.elapsed().as_millis(),
"Project::open initialized Android backend"
);
project.manifest.backends.set_android(android_backend);
let ffi_companion_start = std::time::Instant::now();
project
.scaffold_ffi_companion()
.await
.map_err(FailToOpenProject::BackendInit)?;
info!(
path = %project.root.display(),
elapsed_ms = ffi_companion_start.elapsed().as_millis(),
"Project::open scaffolded native ffi companion"
);
}
if !is_playground
&& !skip_backend_init
&& open_mode == OpenMode::Full
&& (project.apple_backend().is_some() || project.android_backend().is_some())
{
let ffi_companion_start = std::time::Instant::now();
project
.scaffold_ffi_companion()
.await
.map_err(FailToOpenProject::BackendInit)?;
info!(
path = %project.root.display(),
elapsed_ms = ffi_companion_start.elapsed().as_millis(),
"Project::open refreshed native ffi companion"
);
}
info!(
path = %project.root.display(),
open_mode = ?open_mode,
elapsed_ms = total_start.elapsed().as_millis(),
"Project::open completed"
);
Ok(project)
}
}
impl Project {
async fn save_manifest(&self) -> eyre::Result<()> {
self.manifest.save(&self.root).await.map_err(Into::into)
}
async fn remove_backend_relative_dir(&self, relative_path: &Path) -> eyre::Result<()> {
let backend_path = self.managed_backends_root.join(relative_path);
if backend_path.exists() {
smol::fs::remove_dir_all(&backend_path).await?;
}
Ok(())
}
}
async fn apply_channel_selection(
root: &Path,
framework: ResolvedFramework,
updates: Vec<(PathBuf, Option<Vec<u8>>)>,
) -> eyre::Result<()> {
let mut previous = BTreeMap::new();
for file in updates
.iter()
.map(|(file, _)| file.clone())
.chain([root.join("Cargo.lock")])
{
let contents = match smol::fs::read(&file).await {
Ok(contents) => Some(contents),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
previous.insert(file, contents);
}
let result = async {
for (file, contents) in &updates {
write_channel_file(file, contents.as_deref()).await?;
}
resolve_cargo_layout(root, Some(framework), CargoResolution::Update).await?;
Ok(())
}
.await;
if let Err(error) = result {
for (file, contents) in previous {
write_channel_file(&file, contents.as_deref())
.await
.map_err(|restore| {
eyre::eyre!("{error}; could not restore {}: {restore}", file.display())
})?;
}
return Err(error);
}
Ok(())
}
async fn write_channel_file(path: &Path, contents: Option<&[u8]>) -> std::io::Result<()> {
match contents {
Some(contents) => smol::fs::write(path, contents).await,
None => match smol::fs::remove_file(path).await {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
result => result,
},
}
}
async fn resolve_cargo_layout(
current_dir: &Path,
framework: Option<ResolvedFramework>,
mode: CargoResolution,
) -> eyre::Result<CargoLayout> {
let root = current_dir.to_path_buf();
let metadata = unblock(move || {
let mut command = cargo_metadata::MetadataCommand::new();
command.current_dir(root);
match mode {
CargoResolution::Local => {
command.no_deps();
}
CargoResolution::Locked => {
command.other_options(vec!["--locked".to_string()]);
}
CargoResolution::Update => {}
}
command.exec()
})
.await?;
validate_resolved_cli(&metadata)?;
if let Some(framework) = framework
&& framework.channel() != FrameworkChannel::Stable
{
let lockfile = smol::fs::read(current_dir.join("Water.lock")).await?;
framework.validate_dependencies(&metadata, &lockfile)?;
}
Ok(CargoLayout {
target_dir: metadata.target_directory.into_std_path_buf(),
workspace_root: metadata.workspace_root.into_std_path_buf(),
})
}
async fn resolve_linked_runtime_packages(
project_root: PathBuf,
) -> eyre::Result<BTreeMap<String, String>> {
let manifest_path = project_root.join("Cargo.toml");
let metadata_manifest = manifest_path.clone();
let metadata = unblock(move || {
cargo_metadata::MetadataCommand::new()
.no_deps()
.manifest_path(metadata_manifest)
.exec()
})
.await?;
let application_manifest = dunce::canonicalize(&manifest_path)?;
let root = metadata
.packages
.iter()
.find(|package| package.manifest_path.as_std_path() == application_manifest)
.ok_or_else(|| {
eyre::eyre!(
"Cargo metadata omitted the application package at {}",
application_manifest.display()
)
})?;
let package_spec = root.id.to_string();
let output = Command::new("cargo")
.arg("tree")
.arg("--manifest-path")
.arg(&manifest_path)
.arg("--package")
.arg(package_spec)
.arg("--edges")
.arg("normal")
.arg("--prefix")
.arg("none")
.arg("--format")
.arg("{p}")
.current_dir(&project_root)
.output()
.await?;
if !output.status.success() {
return Err(eyre::eyre!(
"failed to resolve runtime dependency graph for {}: {}",
manifest_path.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
let mut linked = BTreeMap::new();
let tree = String::from_utf8(output.stdout)
.map_err(|error| eyre::eyre!("Cargo runtime dependency graph is not UTF-8: {error}"))?;
for package in tree.lines() {
let name = package
.split_ascii_whitespace()
.next()
.ok_or_else(|| eyre::eyre!("Cargo emitted an empty runtime dependency entry"))?;
linked.insert(name.to_string(), package.to_string());
}
Ok(linked)
}
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
};
use serde::{Deserialize, Serialize};
use smol::{fs::read_to_string, process::Command, unblock};
use waterui_assets_planner::ThemeConfig;
use crate::{
android::{backend::AndroidBackend, device::AndroidAbiProvider, platform::AndroidPlatform},
apple::backend::AppleBackend,
backend::{Backend, Backends},
build::BuildOptions,
device::{Artifact, Device, FailToRun, RunOptions, Running},
platform::{PackageOptions, TargetBackend, TargetPlatform},
project_types::{BundleIdentifier, CrateName, PermissionKey},
templates::{self, TemplateContext},
utils::command,
};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Manifest {
pub package: Package,
#[serde(default, skip_serializing_if = "Backends::is_empty")]
pub backends: Backends,
#[serde(skip_serializing_if = "Option::is_none")]
pub waterui_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub framework: Option<ResolvedFramework>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub permissions: BTreeMap<PermissionKey, PermissionEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app: Option<AppConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<ThemeConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PermissionEntry {
enable: bool,
description: String,
}
impl PermissionEntry {
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.enable
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
}
#[derive(Debug, thiserror::Error)]
pub enum FailToOpenManifest {
#[error("Failed to read manifest file: {0}")]
ReadError(std::io::Error),
#[error("Invalid manifest file: {0}")]
InvalidManifest(toml::de::Error),
#[error("Manifest file not found at the specified path")]
NotFound,
}
#[derive(Debug, thiserror::Error)]
pub enum FailToSaveManifest {
#[error("Failed to serialize manifest: {0}")]
Serialize(toml::ser::Error),
#[error("Failed to write manifest file: {0}")]
Write(std::io::Error),
}
impl Manifest {
pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenManifest> {
let path = path.as_ref();
let result = read_to_string(path).await;
match result {
Ok(c) => toml::from_str(&c).map_err(FailToOpenManifest::InvalidManifest),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(FailToOpenManifest::NotFound),
Err(e) => Err(FailToOpenManifest::ReadError(e)),
}
}
pub async fn save(&self, dir: impl AsRef<Path>) -> Result<(), FailToSaveManifest> {
let path = dir.as_ref().join("Water.toml");
let content = toml::to_string_pretty(self).map_err(FailToSaveManifest::Serialize)?;
smol::fs::write(&path, content)
.await
.map_err(FailToSaveManifest::Write)
}
#[must_use]
pub fn new(package: Package) -> Self {
Self {
package,
backends: Backends::default(),
waterui_path: None,
framework: None,
permissions: BTreeMap::default(),
app: None,
theme: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedWebViewBackend {
System,
Wpe,
Cef,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BrowserRuntimePlan {
pub webview: Option<ResolvedWebViewBackend>,
pub chromium: bool,
}
impl BrowserRuntimePlan {
#[must_use]
pub const fn requires_cef(self) -> bool {
self.chromium || matches!(self.webview, Some(ResolvedWebViewBackend::Cef))
}
}
impl ResolvedWebViewBackend {
#[must_use]
pub const fn supports(self, platform: TargetPlatform, backend: TargetBackend) -> bool {
match self {
Self::System => matches!(
(platform, backend),
(
TargetPlatform::MacOS,
TargetBackend::Apple | TargetBackend::Hydrolysis
) | (
TargetPlatform::IOS
| TargetPlatform::IOSSimulator
| TargetPlatform::VisionOS
| TargetPlatform::VisionOSSimulator,
TargetBackend::Apple
) | (TargetPlatform::Android, TargetBackend::Android)
| (TargetPlatform::Linux, TargetBackend::Gtk4)
| (TargetPlatform::Web, TargetBackend::Hydrolysis)
),
Self::Wpe => {
matches!(platform, TargetPlatform::Linux)
&& matches!(backend, TargetBackend::Gtk4 | TargetBackend::Hydrolysis)
}
Self::Cef => cef_is_supported(platform, backend),
}
}
pub const fn validate(
self,
platform: TargetPlatform,
backend: TargetBackend,
) -> Result<Self, UnsupportedWebViewBackend> {
if self.supports(platform, backend) {
Ok(self)
} else {
Err(UnsupportedWebViewBackend {
resolved: self,
platform,
backend,
})
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::System => "system",
Self::Wpe => "wpe",
Self::Cef => "cef",
}
}
}
const fn cef_is_supported(platform: TargetPlatform, backend: TargetBackend) -> bool {
!matches!(backend, TargetBackend::Dew)
&& matches!(
platform,
TargetPlatform::MacOS | TargetPlatform::Linux | TargetPlatform::Windows
)
}
#[derive(Debug, thiserror::Error)]
#[error(
"this application's WebView engine resolves to {resolved:?}, which is unsupported for \
platform {platform:?} with backend {backend:?}. The engine follows the application's \
dependencies: link waterui-browser-cef or waterui-browser-wpe to select one, or \
neither to use the engine this platform bridges."
)]
pub struct UnsupportedWebViewBackend {
resolved: ResolvedWebViewBackend,
platform: TargetPlatform,
backend: TargetBackend,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct AppConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crates: Option<AppCrates>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct AppCrates {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ffi: Option<CrateName>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gtk: Option<CrateName>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hydrolysis: Option<CrateName>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Package {
#[serde(rename = "type")]
pub package_type: PackageType,
pub name: String,
pub bundle_identifier: BundleIdentifier,
#[serde(
default = "default_assets_path",
skip_serializing_if = "is_default_assets_path"
)]
pub assets_path: String,
#[serde(default, skip_serializing_if = "is_false")]
pub accessory: bool,
}
fn default_assets_path() -> String {
"assets".to_string()
}
fn is_default_assets_path(path: &str) -> bool {
path == "assets"
}
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PackageType {
#[default]
App,
Playground,
}
#[cfg(test)]
mod channel_tests {
use super::*;
#[test]
fn local_framework_requirement_is_checked_before_project_io() {
smol::block_on(async {
let directory = tempfile::tempdir().unwrap();
let framework_root = directory.path().join("framework");
let project_root = directory.path().join("consumer");
smol::fs::create_dir(&framework_root).await.unwrap();
let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
minimum.major += 1;
let mut metadata = toml::toml! {
[package.metadata.waterui]
minimum-cli-version = "0.1.4"
};
metadata["package"]["metadata"]["waterui"]["minimum-cli-version"] =
toml::Value::String(minimum.to_string());
smol::fs::write(
framework_root.join("Cargo.toml"),
toml::to_string(&metadata).unwrap(),
)
.await
.unwrap();
let bundle_identifier =
BundleIdentifier::try_from("dev.waterui.compatibility").unwrap();
let options = CreateOptions {
name: "Compatibility".into(),
bundle_identifier: bundle_identifier.clone(),
package_type: PackageType::Playground,
waterui_path: Some(framework_root),
channel: None,
author: String::new(),
};
let error = Project::create(&project_root, options)
.await
.unwrap_err()
.to_string();
assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
assert!(error.contains("cargo install --path cli --locked"));
assert!(!project_root.exists());
smol::fs::create_dir(&project_root).await.unwrap();
let mut manifest = Manifest::new(Package {
name: "Compatibility".into(),
bundle_identifier,
package_type: PackageType::Playground,
assets_path: default_assets_path(),
accessory: false,
});
manifest.waterui_path = Some("../framework".into());
manifest.save(&project_root).await.unwrap();
let error = Project::open_for_preview_build(&project_root)
.await
.unwrap_err()
.to_string();
assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
assert!(!project_root.join("Cargo.lock").exists());
});
}
#[test]
fn failed_channel_selection_preserves_project_files() {
smol::block_on(async {
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
let originals = [
("Cargo.toml", b"original manifest".as_slice()),
("Cargo.lock", b"original dependency lock".as_slice()),
("Water.toml", b"original project configuration".as_slice()),
];
for (name, contents) in originals {
smol::fs::write(root.join(name), contents).await.unwrap();
}
let updates = ["Cargo.toml", "Cargo.lock", "Water.toml", "Water.lock"]
.into_iter()
.map(|name| (root.join(name), Some(b"invalid selected manifest".to_vec())))
.collect();
assert!(
apply_channel_selection(root, ResolvedFramework::stable(), updates)
.await
.is_err()
);
for (name, contents) in originals {
assert_eq!(smol::fs::read(root.join(name)).await.unwrap(), contents);
}
assert!(!root.join("Water.lock").exists());
});
}
}
#[cfg(test)]
mod webview_backend_tests {
use std::path::Path;
use super::{
ResolvedWebViewBackend, TargetBackend, TargetPlatform, resolve_linked_runtime_packages,
};
#[test]
fn the_platform_bridge_is_the_selection_without_an_engine_crate() {
assert_eq!(
ResolvedWebViewBackend::System
.validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
.expect("macOS Hydrolysis bridges WKWebView"),
ResolvedWebViewBackend::System
);
assert_eq!(
ResolvedWebViewBackend::System
.validate(TargetPlatform::Linux, TargetBackend::Gtk4)
.expect("GTK bridges WebKitGTK"),
ResolvedWebViewBackend::System
);
assert!(
ResolvedWebViewBackend::System
.validate(TargetPlatform::Linux, TargetBackend::Hydrolysis)
.is_err()
);
assert!(
ResolvedWebViewBackend::System
.validate(TargetPlatform::Windows, TargetBackend::Hydrolysis)
.is_err()
);
}
#[test]
fn unsupported_engine_combinations_fail_before_build() {
assert!(
ResolvedWebViewBackend::Wpe
.validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
.is_err()
);
assert!(
ResolvedWebViewBackend::Cef
.validate(TargetPlatform::Android, TargetBackend::Android)
.is_err()
);
assert_eq!(
ResolvedWebViewBackend::Cef
.validate(TargetPlatform::MacOS, TargetBackend::Apple)
.expect("CEF must compose with the native Apple renderer on macOS"),
ResolvedWebViewBackend::Cef
);
}
#[test]
fn cef_is_available_to_every_non_dew_backend_on_desktop_platforms() {
for backend in [
TargetBackend::Apple,
TargetBackend::Android,
TargetBackend::Gtk4,
TargetBackend::Hydrolysis,
] {
for platform in [
TargetPlatform::MacOS,
TargetPlatform::Linux,
TargetPlatform::Windows,
] {
assert_eq!(
ResolvedWebViewBackend::Cef
.validate(platform, backend)
.expect("CEF availability must not depend on the WaterUI backend"),
ResolvedWebViewBackend::Cef
);
}
}
}
#[test]
fn cef_rejects_dew_and_platforms_without_cef_distributions() {
for platform in [
TargetPlatform::MacOS,
TargetPlatform::Linux,
TargetPlatform::Windows,
] {
assert!(
ResolvedWebViewBackend::Cef
.validate(platform, TargetBackend::Dew)
.is_err()
);
}
for (platform, backend) in [
(TargetPlatform::Android, TargetBackend::Android),
(TargetPlatform::IOS, TargetBackend::Apple),
(TargetPlatform::Web, TargetBackend::Hydrolysis),
] {
assert!(
ResolvedWebViewBackend::Cef
.validate(platform, backend)
.is_err()
);
}
}
#[test]
fn runtime_graph_is_scoped_to_the_selected_application() {
let repository = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("CLI crate must be inside the WaterUI repository");
let chromium = smol::block_on(resolve_linked_runtime_packages(
repository.join("examples/chromium"),
))
.expect("Chromium example runtime graph must resolve");
assert!(
chromium.contains_key("waterui-chromium"),
"Chromium example graph: {chromium:#?}"
);
assert!(
chromium.contains_key("waterui-browser-cef"),
"Chromium example graph: {chromium:#?}"
);
assert!(
!chromium.contains_key("waterui-browser-wpe"),
"Chromium example graph: {chromium:#?}"
);
assert!(
!chromium.contains_key("waterui-webview"),
"a Chromium-only application must not link the standard WebView \
component: {chromium:#?}"
);
let webview = smol::block_on(resolve_linked_runtime_packages(
repository.join("examples/webview"),
))
.expect("WebView example runtime graph must resolve");
assert!(
webview.contains_key("waterui-webview"),
"WebView example graph: {webview:#?}"
);
assert!(
!webview.contains_key("waterui-browser-cef"),
"WebView example graph: {webview:#?}"
);
assert!(
!webview.contains_key("waterui-chromium"),
"WebView example graph: {webview:#?}"
);
let cef_webview = smol::block_on(resolve_linked_runtime_packages(
repository.join("examples/webview-cef"),
))
.expect("CEF WebView example runtime graph must resolve");
assert!(
cef_webview.contains_key("waterui-browser-cef"),
"CEF WebView example graph: {cef_webview:#?}"
);
assert!(
!cef_webview.contains_key("waterui-browser-wpe"),
"CEF WebView example graph: {cef_webview:#?}"
);
}
#[test]
fn the_map_capability_is_read_from_the_application_graph() {
let repository = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("CLI crate must be inside the WaterUI repository");
let map = smol::block_on(resolve_linked_runtime_packages(
repository.join("examples/map"),
))
.expect("map example runtime graph must resolve");
assert!(
map.contains_key("waterui-map"),
"map example graph: {map:#?}"
);
let webview = smol::block_on(resolve_linked_runtime_packages(
repository.join("examples/webview"),
))
.expect("WebView example runtime graph must resolve");
assert!(
!webview.contains_key("waterui-map"),
"an application that shows no map must not carry the map stack: {webview:#?}"
);
}
}
#[cfg(test)]
mod scaffold_tests {
use super::{BundleIdentifier, CreateOptions, PackageType, Project};
#[test]
fn create_scaffolds_the_assets_directory_declared_by_the_manifest() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().join("water-example");
let project = smol::block_on(Project::create(
&root,
CreateOptions {
name: "Water Example".to_string(),
bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
.expect("bundle identifier"),
package_type: PackageType::Playground,
waterui_path: None,
channel: None,
author: "Lexo Liu".to_string(),
},
))
.expect("project creation must succeed");
let assets = project.assets_dir();
assert!(
assets.is_dir(),
"the assets root {} must exist after `water create`",
assets.display()
);
assert_eq!(
assets,
root.join(project.assets_path()),
"the scaffolded directory must be the one the manifest declares"
);
assert!(
assets.join("README.md").is_file(),
"a tracked file keeps the assets directory present in git"
);
}
}
#[cfg(test)]
mod local_patch_tests {
use std::path::Path;
use super::Project;
#[test]
fn a_local_checkout_project_follows_the_checkouts_patch_tables() {
let directory = tempfile::tempdir().expect("temp dir");
let checkout = directory.path().join("waterui");
std::fs::create_dir_all(&checkout).expect("checkout dir");
std::fs::write(
checkout.join("Cargo.toml"),
include_str!("../../tests/fixtures/local_checkout_patches.toml"),
)
.expect("checkout manifest");
let app = directory.path().join("app");
std::fs::create_dir_all(&app).expect("project dir");
let cargo_path = app.join("Cargo.toml");
std::fs::write(
&cargo_path,
"[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",
)
.expect("project manifest");
smol::block_on(Project::refresh_local_patches(
&app,
Path::new("../waterui"),
))
.expect("tables refresh");
let refreshed = std::fs::read_to_string(&cargo_path).expect("refreshed manifest");
let manifest = cargo_toml::Manifest::from_str(&refreshed).expect("manifest parses");
let crates_io = &manifest.patch["crates-io"];
let cargo_toml::Dependency::Detailed(core) = &crates_io["waterui-core"] else {
panic!("the core patch is a path dependency");
};
assert_eq!(core.path.as_deref(), Some("../waterui/core"));
assert!(!crates_io.contains_key("stale"));
assert!(crates_io.contains_key("vello"));
assert!(refreshed.starts_with("[package]"));
smol::block_on(Project::refresh_local_patches(
&app,
Path::new("../waterui"),
))
.expect("second refresh");
assert_eq!(
std::fs::read_to_string(&cargo_path).expect("manifest after the second refresh"),
refreshed
);
}
}