use std::{
io,
path::{Path, PathBuf},
};
use askama::Template;
use crate::framework::{FrameworkChannel, ResolvedFramework};
use crate::project::ResolvedWebViewBackend;
use include_dir::{Dir, include_dir};
use smol::fs;
use crate::project_types::{BundleIdentifier, CrateName, RustIdent};
fn normalize_path_for_config(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn cargo_semver(version: &str) -> cargo_toml::SemVer {
version
.parse()
.unwrap_or_else(|error| panic!("Invalid Cargo semantic version `{version}`: {error}"))
}
fn cargo_version_req(version: &str) -> cargo_toml::VersionReq {
version
.parse()
.unwrap_or_else(|error| panic!("Invalid Cargo version requirement `{version}`: {error}"))
}
pub fn scaffold_template_digest() -> String {
use sha2::Digest as _;
fn hash_dir(hasher: &mut sha2::Sha256, dir: &Dir<'_>) {
let mut files: Vec<_> = dir.files().collect();
files.sort_by_key(|file| file.path());
for file in files {
hasher.update(file.path().to_string_lossy().as_bytes());
hasher.update(file.contents());
}
let mut dirs: Vec<_> = dir.dirs().collect();
dirs.sort_by_key(|entry| entry.path());
for entry in dirs {
hash_dir(hasher, entry);
}
}
let mut hasher = sha2::Sha256::new();
for dir in [
&embedded::ROOT,
&embedded::HYDROLYSIS,
&embedded::PREVIEW,
&embedded::PREVIEW_FFI,
&embedded::INSPECTOR,
&embedded::FFI,
&embedded::TUI,
] {
hash_dir(&mut hasher, dir);
}
let digest = hasher.finalize();
digest.iter().take(8).fold(String::new(), |mut out, byte| {
use core::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
out
})
}
pub mod embedded {
use super::{Dir, include_dir};
pub static APPLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/apple");
pub static ANDROID: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/android");
pub static FFI: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/ffi");
pub static GTK4: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/gtk4");
pub static HYDROLYSIS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/hydrolysis");
pub static ESP32: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/esp32");
pub static PREVIEW: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/preview");
pub static PREVIEW_FFI: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/preview_ffi");
pub static INSPECTOR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/inspector");
pub static TUI: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/tui");
pub static WINUI: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/winui");
pub static ROOT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates");
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AndroidPermissionTemplateEntry {
pub name: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IosPermissionTemplateEntry {
pub plist_key: &'static str,
pub description: String,
}
impl IosPermissionTemplateEntry {
#[must_use]
pub fn escaped_description(&self) -> String {
self.description.replace('"', "\\\"")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FontRegistrationTemplateEntry {
pub family_name: String,
pub file_name: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LaunchTemplateEntry {
pub has_background: bool,
pub has_image: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Esp32TemplateEntry {
pub chip: String,
pub panel_width: u32,
pub panel_height: u32,
pub band_height: u32,
pub fonts: Vec<String>,
pub console_uart_default: bool,
pub flash_size_mb: u32,
pub main_task_stack_bytes: u32,
pub app_partition_offset: String,
pub app_partition_size: String,
pub opt_level: String,
}
impl Esp32TemplateEntry {
#[must_use]
pub fn new(
chip: crate::esp32::chip::Esp32Chip,
panel_width: u32,
panel_height: u32,
band_height: u32,
) -> Self {
let params = chip.firmware_params();
Self {
chip: chip.id().to_string(),
panel_width,
panel_height,
band_height,
fonts: Vec::new(),
console_uart_default: params.console_uart_default,
flash_size_mb: params.flash_size_mb,
main_task_stack_bytes: params.main_task_stack_bytes,
app_partition_offset: params.app_partition_offset.to_string(),
app_partition_size: params.app_partition_size.to_string(),
opt_level: params.opt_level.to_string(),
}
}
#[must_use]
pub fn with_fonts(mut self, fonts: Vec<String>) -> Self {
self.fonts = fonts;
self
}
#[must_use]
pub fn resolved_target_triple(&self) -> &'static str {
self.chip
.parse::<crate::esp32::chip::Esp32Chip>()
.unwrap_or_else(|error| panic!("Esp32TemplateEntry holds an invalid chip: {error}"))
.target_triple()
}
}
impl Default for Esp32TemplateEntry {
fn default() -> Self {
Self::new(crate::esp32::chip::Esp32Chip::Esp32S3, 410, 502, 16)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct BrowserTemplateContext {
pub webview_enabled: bool,
pub chromium_enabled: bool,
pub engine: Option<ResolvedWebViewBackend>,
}
#[derive(Debug, Clone)]
pub struct TemplateContext {
pub app_display_name: String,
pub app_name: String,
pub crate_name: CrateName,
pub bundle_identifier: BundleIdentifier,
pub author: String,
pub android_backend_path: Option<PathBuf>,
pub apple_backend_path: Option<PathBuf>,
pub apple_backend_branch: Option<String>,
pub apple_backend_revision: Option<String>,
pub waterui_path: Option<PathBuf>,
pub framework: ResolvedFramework,
pub browser: BrowserTemplateContext,
pub backend_project_path: Option<PathBuf>,
pub project_root_path: Option<PathBuf>,
pub android_permissions: Vec<AndroidPermissionTemplateEntry>,
pub ios_permissions: Vec<IosPermissionTemplateEntry>,
pub accessory: bool,
pub preview_runtime_fingerprint: Option<String>,
pub preview_runtime_features: Vec<String>,
pub preview_app_dependency: Option<(CrateName, PathBuf)>,
pub package_type: crate::project::PackageType,
pub web_frontend_arg: Option<String>,
pub esp32: Esp32TemplateEntry,
pub launch: LaunchTemplateEntry,
}
impl TemplateContext {
#[must_use]
pub fn web_arg(&self) -> &str {
self.web_frontend_arg.as_deref().unwrap_or("web")
}
#[must_use]
pub fn for_create_options(
options: &crate::project::CreateOptions,
crate_name: CrateName,
framework: &ResolvedFramework,
) -> Self {
let waterui_path = options.waterui_path.clone();
Self {
app_display_name: options.name.clone(),
app_name: options.name.replace(' ', ""),
crate_name,
bundle_identifier: options.bundle_identifier.clone(),
author: options.author.clone(),
android_backend_path: None,
apple_backend_path: None,
apple_backend_branch: None,
apple_backend_revision: None,
waterui_path,
framework: framework.clone(),
browser: BrowserTemplateContext::default(),
backend_project_path: None,
project_root_path: None,
android_permissions: Vec::new(),
ios_permissions: Vec::new(),
accessory: false,
preview_runtime_fingerprint: None,
preview_runtime_features: Vec::new(),
preview_app_dependency: None,
package_type: options.package_type,
web_frontend_arg: options.web.as_ref().map(|web| web.include_arg.clone()),
esp32: Esp32TemplateEntry::default(),
launch: LaunchTemplateEntry::default(),
}
}
#[must_use]
pub fn for_project_manifest(
manifest: &crate::project::Manifest,
crate_name: CrateName,
app_name: impl Into<String>,
framework: &ResolvedFramework,
) -> Self {
let apple = manifest.backends.apple();
Self {
app_display_name: manifest.package.name.clone(),
app_name: app_name.into(),
crate_name,
bundle_identifier: manifest.package.bundle_identifier.clone(),
author: String::new(),
android_backend_path: manifest
.backends
.android()
.and_then(|backend| backend.backend_path().map(PathBuf::from)),
apple_backend_path: apple
.and_then(|backend| backend.backend_path.as_deref())
.map(PathBuf::from),
apple_backend_branch: apple.and_then(|backend| backend.branch.clone()),
apple_backend_revision: apple.and_then(|backend| backend.revision.clone()),
waterui_path: manifest.waterui_path.as_ref().map(PathBuf::from),
framework: framework.clone(),
browser: BrowserTemplateContext::default(),
backend_project_path: None,
project_root_path: None,
android_permissions: Vec::new(),
ios_permissions: Vec::new(),
accessory: manifest.package.accessory,
preview_runtime_fingerprint: None,
preview_runtime_features: Vec::new(),
preview_app_dependency: None,
package_type: manifest.package.package_type,
web_frontend_arg: manifest.web.as_ref().map(|_| "web".to_string()),
esp32: Esp32TemplateEntry::default(),
launch: LaunchTemplateEntry::default(),
}
}
#[must_use]
pub fn for_support_playground(
app_display_name: impl Into<String>,
crate_name: CrateName,
bundle_identifier: BundleIdentifier,
waterui_path: Option<PathBuf>,
framework: &ResolvedFramework,
accessory: bool,
preview_runtime_fingerprint: Option<String>,
) -> Self {
let project_root_path = waterui_path.clone();
let app_display_name = app_display_name.into();
Self {
app_name: app_display_name.replace(' ', ""),
app_display_name,
crate_name,
bundle_identifier,
author: String::new(),
android_backend_path: None,
apple_backend_path: None,
apple_backend_branch: None,
apple_backend_revision: None,
waterui_path,
framework: framework.clone(),
browser: BrowserTemplateContext::default(),
backend_project_path: None,
project_root_path,
android_permissions: Vec::new(),
ios_permissions: Vec::new(),
accessory,
preview_runtime_fingerprint,
preview_runtime_features: Vec::new(),
preview_app_dependency: None,
package_type: crate::project::PackageType::Playground,
web_frontend_arg: None,
esp32: Esp32TemplateEntry::default(),
launch: LaunchTemplateEntry::default(),
}
}
#[must_use]
pub fn with_backend_project_path(mut self, path: PathBuf) -> Self {
self.backend_project_path = Some(path);
self
}
#[must_use]
pub fn with_project_root_path(mut self, path: PathBuf) -> Self {
self.project_root_path = Some(path);
self
}
#[must_use]
pub const fn with_webview_enabled(mut self, enabled: bool) -> Self {
self.browser.webview_enabled = enabled;
self
}
#[must_use]
pub const fn with_chromium_enabled(mut self, enabled: bool) -> Self {
self.browser.chromium_enabled = enabled;
self
}
#[must_use]
pub const fn with_browser_engine(mut self, engine: Option<ResolvedWebViewBackend>) -> Self {
self.browser.engine = engine;
self
}
const fn webview_backend_feature(&self) -> Option<&'static str> {
if self.browser.webview_enabled && self.browser.engine.is_none() {
Some("webview-system")
} else {
None
}
}
const fn chromium_enabled(&self) -> bool {
self.browser.chromium_enabled
}
const fn cef_webview_enabled(&self) -> bool {
self.browser.webview_enabled && self.cef_runtime_enabled()
}
const fn cef_runtime_enabled(&self) -> bool {
crate::project_model::project_types::declares_cef_helper(self.browser.engine)
}
#[must_use]
pub fn with_preview_runtime_features(mut self, features: Vec<String>) -> Self {
self.preview_runtime_features = features;
self
}
#[must_use]
pub fn with_preview_app_dependency(mut self, crate_name: CrateName, path: PathBuf) -> Self {
self.preview_app_dependency = Some((crate_name, path));
self
}
#[must_use]
pub fn with_android_permissions(
mut self,
permissions: Vec<AndroidPermissionTemplateEntry>,
) -> Self {
self.android_permissions = permissions;
self
}
#[must_use]
pub fn with_ios_permissions(mut self, permissions: Vec<IosPermissionTemplateEntry>) -> Self {
self.ios_permissions = permissions;
self
}
#[must_use]
pub const fn with_launch(mut self, launch: LaunchTemplateEntry) -> Self {
self.launch = launch;
self
}
#[must_use]
pub fn with_esp32(mut self, esp32: Esp32TemplateEntry) -> Self {
self.esp32 = esp32;
self
}
#[must_use]
pub fn crate_name_ident(&self) -> RustIdent {
self.crate_name.rust_ident()
}
#[must_use]
pub fn android_package_name(&self) -> String {
self.bundle_identifier.android_package_name().to_string()
}
#[must_use]
pub fn android_min_api_level(&self) -> u32 {
self.framework
.android_min_api_level()
.unwrap_or_else(|error| panic!("{error:#}"))
}
#[must_use]
pub fn use_remote_dev_backend(&self) -> bool {
self.compute_android_backend_path().is_none()
}
#[must_use]
pub fn android_backend_path(&self) -> String {
self.compute_android_backend_path().unwrap_or_default()
}
#[must_use]
pub fn android_remote_backend_dependency(&self) -> String {
jitpack_dependency_coordinate(
self.framework.scaffold_value("android-backend-url"),
self.framework.scaffold_value("android-backend-revision"),
)
}
#[must_use]
pub fn is_playground(&self) -> bool {
self.package_type == crate::project::PackageType::Playground
}
#[must_use]
pub const fn macos_lsuielement(&self) -> &'static str {
if self.accessory { "YES" } else { "NO" }
}
#[must_use]
pub fn preview_runtime_fingerprint(&self) -> &str {
self.preview_runtime_fingerprint
.as_deref()
.unwrap_or_default()
}
#[must_use]
pub fn transform_path(&self, path: &Path) -> PathBuf {
let path_str = path.to_string_lossy();
PathBuf::from(path_str.replace("AppName", &self.app_name))
}
fn backend_relative_path(&self, target: &Path) -> String {
if target.is_absolute() {
return normalize_path_for_config(target);
}
if let Some(backend_project_path) = self
.backend_project_path
.as_ref()
.filter(|path| path.is_absolute())
{
let project_root = self.project_root_path.as_ref().unwrap_or_else(|| {
panic!(
"TemplateContext missing project_root_path for absolute backend project {}",
backend_project_path.display()
)
});
let absolute_backend_path = project_root.join(target);
let relative_path = pathdiff::diff_paths(&absolute_backend_path, backend_project_path)
.unwrap_or_else(|| {
panic!(
"Failed to compute backend dependency path from {} to {}",
backend_project_path.display(),
absolute_backend_path.display()
)
});
return normalize_path_for_config(&relative_path);
}
let project_depth = self
.backend_project_path
.as_ref()
.map_or(1, |p| p.components().count());
let mut backend_path = PathBuf::new();
for _ in 0..project_depth {
backend_path.push("..");
}
backend_path.push(target);
normalize_path_for_config(&backend_path)
}
fn compute_apple_backend_path(&self) -> Option<String> {
self.apple_backend_path
.as_ref()
.map(|path| self.backend_relative_path(path))
.or_else(|| {
let local = self.waterui_workspace_root()?.join("backends/apple");
local
.join("Package.swift")
.is_file()
.then(|| self.backend_relative_path(&local))
})
}
fn compute_android_backend_path(&self) -> Option<String> {
self.android_backend_path
.as_ref()
.map(|path| self.backend_relative_path(path))
.or_else(|| {
let local = self.waterui_workspace_root()?.join("backends/android");
local
.join("settings.gradle.kts")
.is_file()
.then(|| self.backend_relative_path(&local))
})
}
fn waterui_workspace_root(&self) -> Option<PathBuf> {
let waterui_path = self.waterui_path.as_ref()?;
if waterui_path.is_absolute() {
return Some(waterui_path.clone());
}
self.project_root_path
.as_ref()
.map(|project_root| project_root.join(waterui_path))
}
fn project_root_relative_path(&self) -> String {
if let Some(backend_project_path) = self
.backend_project_path
.as_ref()
.filter(|path| path.is_absolute())
{
let project_root = self.project_root_path.as_ref().unwrap_or_else(|| {
panic!(
"TemplateContext missing project_root_path for absolute backend project {}",
backend_project_path.display()
)
});
let relative_path = pathdiff::diff_paths(project_root, backend_project_path)
.unwrap_or_else(|| {
panic!(
"Failed to compute project root path from {} to {}",
backend_project_path.display(),
project_root.display()
)
});
return normalize_path_for_config(&relative_path);
}
let depth = self
.backend_project_path
.as_ref()
.map_or(1, |p| p.components().count());
(0..depth).map(|_| "..").collect::<Vec<_>>().join("/")
}
fn swift_package_reference_entry(&self) -> String {
const PACKAGE_ID: &str = "D01867782E6C82CA00802E96";
const INDENT: &str = "\t\t\t\t";
let repository_name =
github_repository_name(self.framework.scaffold_value("apple-backend-url"));
self.compute_apple_backend_path().map_or_else(
|| {
format!(
"{INDENT}{PACKAGE_ID} /* XCRemoteSwiftPackageReference \"{repository_name}\" */,"
)
},
|backend_path| {
format!(
"{INDENT}{PACKAGE_ID} /* XCLocalSwiftPackageReference \"{backend_path}\" */,"
)
},
)
}
fn apple_backend_requirement(&self) -> String {
if let (Some(_), Some(_)) = (&self.apple_backend_branch, &self.apple_backend_revision) {
panic!("`[backends.apple]` sets both `branch` and `revision`; pick one");
}
let revision =
|revision: &str| format!("kind = revision;\n\t\t\t\trevision = \"{revision}\";");
let version =
|version: &str| format!("kind = exactVersion;\n\t\t\t\tversion = \"{version}\";");
self.apple_backend_branch
.as_deref()
.map(|branch| format!("kind = branch;\n\t\t\t\tbranch = \"{branch}\";"))
.or_else(|| self.apple_backend_revision.as_deref().map(revision))
.or_else(|| match self.framework.channel() {
Some(FrameworkChannel::Dev | FrameworkChannel::Nightly) => self
.framework
.apple_backend_revision()
.map(revision)
.or_else(|| self.framework.apple_backend_version().map(version)),
Some(FrameworkChannel::Stable) | None => self
.framework
.apple_backend_version()
.map(version)
.or_else(|| self.framework.apple_backend_revision().map(revision)),
})
.unwrap_or_else(|| panic!("resolved framework carries no Apple backend pin"))
}
fn swift_package_reference_section(&self) -> String {
const PACKAGE_ID: &str = "D01867782E6C82CA00802E96";
let repository_name =
github_repository_name(self.framework.scaffold_value("apple-backend-url"));
self.compute_apple_backend_path().map_or_else(
|| {
format!(
"/* Begin XCRemoteSwiftPackageReference section */\n\
\t\t{PACKAGE_ID} /* XCRemoteSwiftPackageReference \"{repository_name}\" */ = {{\n\
\t\t\tisa = XCRemoteSwiftPackageReference;\n\
\t\t\trepositoryURL = \"{}\";\n\
\t\t\trequirement = {{\n\
\t\t\t\t{}\n\
\t\t\t}};\n\
\t\t}};\n\
/* End XCRemoteSwiftPackageReference section */",
self.framework.scaffold_value("apple-backend-url"),
self.apple_backend_requirement(),
)
},
|backend_path| {
format!(
"/* Begin XCLocalSwiftPackageReference section */\n\
\t\t{PACKAGE_ID} /* XCLocalSwiftPackageReference \"{backend_path}\" */ = {{\n\
\t\t\tisa = XCLocalSwiftPackageReference;\n\
\t\t\trelativePath = \"{backend_path}\";\n\
\t\t}};\n\
/* End XCLocalSwiftPackageReference section */"
)
},
)
}
}
pub fn apple_app_name(crate_name: &CrateName) -> String {
crate_name
.as_str()
.split('-')
.map(|part| {
let mut chars = part.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TemplateNamespace {
Apple,
Android,
Ffi,
Gtk4,
Hydrolysis,
Esp32,
Inspector,
Preview,
PreviewFfi,
Tui,
WinUi,
Root,
}
impl TemplateNamespace {
const fn scaffold_template_prefix(self) -> &'static str {
match self {
Self::Apple => "src/templates/apple",
Self::Android => "src/templates/android",
Self::Ffi => "src/templates/ffi",
Self::Gtk4 => "src/templates/gtk4",
Self::Hydrolysis => "src/templates/hydrolysis",
Self::Esp32 => "src/templates/esp32",
Self::Inspector => "src/templates/inspector",
Self::Preview => "src/templates/preview",
Self::PreviewFfi => "src/templates/preview_ffi",
Self::Tui => "src/templates/tui",
Self::WinUi => "src/templates/winui",
Self::Root => "src/templates",
}
}
}
fn scaffold_template_dispatch_path(namespace: TemplateNamespace, relative_path: &Path) -> String {
let relative_path = normalize_path_for_config(relative_path);
if relative_path.starts_with("src/templates/") {
return relative_path;
}
format!("{}/{relative_path}", namespace.scaffold_template_prefix())
}
fn github_repository_owner_and_name(repository_url: &str) -> (&str, &str) {
let path = repository_url
.strip_prefix("https://github.com/")
.or_else(|| repository_url.strip_prefix("git@github.com:"))
.unwrap_or_else(|| panic!("unsupported GitHub repository URL: {repository_url}"));
let path = path.strip_suffix(".git").unwrap_or(path);
let mut segments = path.split('/');
let owner = segments
.next()
.filter(|segment| !segment.is_empty())
.unwrap_or_else(|| panic!("missing GitHub owner in repository URL: {repository_url}"));
let repo = segments
.next()
.filter(|segment| !segment.is_empty())
.unwrap_or_else(|| {
panic!("missing GitHub repository name in repository URL: {repository_url}")
});
assert!(
segments.next().is_none(),
"unsupported GitHub repository URL path: {repository_url}"
);
(owner, repo)
}
fn github_repository_name(repository_url: &str) -> &str {
let (_, repo) = github_repository_owner_and_name(repository_url);
repo
}
fn jitpack_dependency_coordinate(repository_url: &str, revision: &str) -> String {
let (owner, repo) = github_repository_owner_and_name(repository_url);
format!("com.github.{owner}:{repo}:{revision}")
}
macro_rules! define_scaffold_templates {
($($name:ident => ($namespace:ident, $path:literal)),* $(,)?) => {
$(
#[derive(Template)]
#[template(path = $path, escape = "none")]
struct $name<'a> {
ctx: &'a TemplateContext,
}
)*
fn render_scaffold_template(
namespace: TemplateNamespace,
relative_path: &Path,
content: &str,
ctx: &TemplateContext,
) -> io::Result<String> {
let display_path = relative_path.to_string_lossy();
let dispatch_path = scaffold_template_dispatch_path(namespace, relative_path);
match dispatch_path.as_str() {
"src/templates/apple/AppName/WaterUIFonts.swift.tpl" => {
let empty_font_entries: &[FontRegistrationTemplateEntry] = &[];
ScaffoldAppleFontTemplate {
font_entries: empty_font_entries,
}
.render()
.map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to render template {display_path}: {error}"),
)
})
}
"src/templates/esp32/Cargo.toml.tpl" => Esp32CargoTomlTemplate::from_ctx(ctx)
.and_then(|template| {
template.render().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to render template {display_path}: {error}"),
)
})
})
.and_then(|rendered| {
let mut document = rendered
.parse::<toml_edit::DocumentMut>()
.map_err(io::Error::other)?;
crate::framework::rewrite_patch_tables(
&mut document,
&cargo_toml::PatchSet::default(),
&generated_crate_patches(ctx)?,
)
.map_err(|error| io::Error::other(error.to_string()))?;
Ok(document.to_string())
}),
$(
$path => $name { ctx }
.render()
.map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to render template {display_path}: {error}"),
)
}),
)*
_ => Ok(content.to_string()),
}
}
};
}
#[derive(Template)]
#[template(
path = "src/templates/apple/AppName/WaterUIFonts.swift.tpl",
escape = "none"
)]
struct ScaffoldAppleFontTemplate<'a> {
font_entries: &'a [FontRegistrationTemplateEntry],
}
#[derive(Template)]
#[template(path = "src/templates/esp32/Cargo.toml.tpl", escape = "none")]
struct Esp32CargoTomlTemplate {
package_name: String,
app_crate_name: String,
app_crate_path: String,
dew_dependency: String,
core_dependency: String,
locale_dependency: String,
opt_level_literal: String,
}
impl Esp32CargoTomlTemplate {
fn from_ctx(ctx: &TemplateContext) -> io::Result<Self> {
let dew_dependency = generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-dew",
&["espidf", "progress"],
NativeBackendDependencySource::WorkspaceDependency,
),
)?
.with_default_features(false)
.inline_toml();
let core_dependency = generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-core",
&[],
NativeBackendDependencySource::WorkspaceSubdir("core"),
),
)?
.inline_toml();
let locale_dependency = generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-locale",
&[],
NativeBackendDependencySource::WorkspaceSubdir("utils/locale"),
),
)?
.inline_toml();
Ok(Self {
package_name: crate::project_model::project_types::generated_crate_name(
&ctx.crate_name,
"esp32",
ctx.project_root_path
.as_deref()
.expect("ESP32 manifests are rendered for a project"),
)
.to_string(),
app_crate_name: ctx.crate_name.to_string(),
app_crate_path: ctx.project_root_relative_path(),
dew_dependency,
core_dependency,
locale_dependency,
opt_level_literal: match ctx.esp32.opt_level.as_str() {
symbolic @ ("s" | "z") => format!("\"{symbolic}\""),
numeric => numeric
.parse::<u8>()
.unwrap_or_else(|error| {
panic!(
"ESP32 opt-level {numeric:?} is neither symbolic nor numeric: {error}"
)
})
.to_string(),
},
})
}
}
define_scaffold_templates! {
AssetsReadmeTemplate => (Root, "src/templates/assets_readme.md.tpl"),
AppleProjectTemplate => (Apple, "src/templates/apple/AppName.xcodeproj/project.pbxproj.tpl"),
AppleAppTemplate => (Apple, "src/templates/apple/AppName/AppNameApp.swift.tpl"),
AppleInfoPlistTemplate => (Apple, "src/templates/apple/AppName/Info.plist.tpl"),
AppleBuildScriptTemplate => (Apple, "src/templates/apple/build-rust.sh.tpl"),
AndroidGradleAppTemplate => (Android, "src/templates/android/app/build.gradle.kts.tpl"),
AndroidManifestTemplate => (Android, "src/templates/android/app/src/main/AndroidManifest.xml.tpl"),
AndroidMainActivityTemplate => (Android, "src/templates/android/app/src/main/java/MainActivity.kt.tpl"),
AndroidApplicationTemplate => (Android, "src/templates/android/app/src/main/java/WaterUiApplication.kt.tpl"),
AndroidStringsTemplate => (Android, "src/templates/android/app/src/main/res/values/strings.xml.tpl"),
AndroidSettingsTemplate => (Android, "src/templates/android/settings.gradle.kts.tpl"),
FfiBuildScriptTemplate => (Ffi, "src/templates/ffi/build.rs.tpl"),
FfiLibTemplate => (Ffi, "src/templates/ffi/src/lib.rs.tpl"),
Gtk4BuildScriptTemplate => (Gtk4, "src/templates/gtk4/build.rs.tpl"),
Gtk4MainTemplate => (Gtk4, "src/templates/gtk4/src/main.rs.tpl"),
HydrolysisBuildScriptTemplate => (Hydrolysis, "src/templates/hydrolysis/build.rs.tpl"),
RootWebLibTemplate => (Root, "src/templates/web_lib.rs.tpl"),
HydrolysisLibTemplate => (Hydrolysis, "src/templates/hydrolysis/src/lib.rs.tpl"),
HydrolysisMainTemplate => (Hydrolysis, "src/templates/hydrolysis/src/main.rs.tpl"),
HydrolysisPreviewRuntimeTemplate => (Hydrolysis, "src/templates/hydrolysis/src/preview_runtime.rs.tpl"),
HydrolysisPreviewTestRuntimeTemplate => (Hydrolysis, "src/templates/hydrolysis/src/preview_test_runtime.rs.tpl"),
HydrolysisMcpRuntimeTemplate => (Hydrolysis, "src/templates/hydrolysis/src/mcp_runtime.rs.tpl"),
Esp32BuildScriptTemplate => (Esp32, "src/templates/esp32/build.rs.tpl"),
Esp32MainTemplate => (Esp32, "src/templates/esp32/src/main.rs.tpl"),
Esp32CargoConfigTemplate => (Esp32, "src/templates/esp32/.cargo/config.toml.tpl"),
Esp32SdkconfigTemplate => (Esp32, "src/templates/esp32/sdkconfig.defaults.tpl"),
Esp32PartitionsTemplate => (Esp32, "src/templates/esp32/partitions.csv.tpl"),
PreviewLibTemplate => (Preview, "src/templates/preview/src/lib.rs.tpl"),
PreviewFfiLibTemplate => (PreviewFfi, "src/templates/preview_ffi/src/lib.rs.tpl"),
TuiBuildScriptTemplate => (Tui, "src/templates/tui/build.rs.tpl"),
TuiMainTemplate => (Tui, "src/templates/tui/src/main.rs.tpl"),
WinUiBuildScriptTemplate => (WinUi, "src/templates/winui/build.rs.tpl"),
WinUiMainTemplate => (WinUi, "src/templates/winui/src/main.rs.tpl"),
}
#[cfg(test)]
mod tests {
use super::{
BrowserTemplateContext, Esp32TemplateEntry, LaunchTemplateEntry, ResolvedFramework,
ResolvedWebViewBackend, TemplateContext, TemplateNamespace, embedded, gtk4,
jitpack_dependency_coordinate, normalize_path_for_config, preview_ffi,
render_scaffold_template,
};
use crate::framework::test_fixtures::{
dev_framework, nightly_framework, stable_framework, write_apple_revision_checkout,
};
use crate::project_types::{BundleIdentifier, CrateName};
use include_dir::Dir;
use std::path::PathBuf;
use tempfile::tempdir;
fn ctx(
waterui_path: Option<PathBuf>,
backend_project_path: Option<PathBuf>,
project_root_path: Option<PathBuf>,
package_type: crate::project::PackageType,
) -> TemplateContext {
TemplateContext {
app_display_name: String::new(),
app_name: String::new(),
crate_name: CrateName::try_from("waterui_test").expect("test crate name must be valid"),
bundle_identifier: BundleIdentifier::try_from("com.example.test")
.expect("test bundle identifier must be valid"),
author: String::new(),
android_backend_path: None,
apple_backend_path: None,
apple_backend_branch: None,
apple_backend_revision: None,
waterui_path,
framework: stable_framework(),
browser: BrowserTemplateContext::default(),
backend_project_path,
project_root_path,
android_permissions: Vec::new(),
ios_permissions: Vec::new(),
accessory: false,
preview_runtime_fingerprint: None,
preview_runtime_features: Vec::new(),
preview_app_dependency: None,
package_type,
web_frontend_arg: None,
esp32: Esp32TemplateEntry::default(),
launch: LaunchTemplateEntry::default(),
}
}
fn app_ctx() -> TemplateContext {
ctx(
None,
None,
Some(PathBuf::from("/tmp/test-app")),
crate::project::PackageType::App,
)
}
#[test]
fn android_uses_the_local_runtime_only_when_the_checkout_carries_one() {
let workspace = tempdir().expect("tempdir");
let waterui = workspace.path().join("waterui");
let project = workspace.path().join("app");
std::fs::create_dir_all(&waterui).expect("checkout");
std::fs::create_dir_all(project.join("android")).expect("project");
let context = || {
ctx(
Some(waterui.clone()),
Some(project.join("android")),
Some(project.clone()),
crate::project::PackageType::App,
)
};
let bare = context();
assert!(bare.use_remote_dev_backend());
assert_eq!(bare.android_backend_path(), "");
assert!(
bare.android_remote_backend_dependency()
.contains(&"c".repeat(40)),
"the remote coordinate names the declared android-backend-revision"
);
std::fs::create_dir_all(waterui.join("backends/android")).expect("runtime");
std::fs::write(waterui.join("backends/android/settings.gradle.kts"), "").expect("gradle");
let overlaid = context();
assert!(!overlaid.use_remote_dev_backend());
assert!(
overlaid
.android_backend_path()
.ends_with("backends/android"),
"{}",
overlaid.android_backend_path()
);
}
#[test]
fn android_backend_path_renders_a_composite_build() {
let manifest: crate::project::Manifest = toml::from_str(
r#"
[package]
type = "playground"
name = "Demo"
bundle_identifier = "dev.waterui.demo"
[backends.android]
backend_path = "/opt/android-backend"
"#,
)
.expect("manifest parses");
let context = |manifest: &crate::project::Manifest| {
TemplateContext::for_project_manifest(
manifest,
CrateName::try_from("demo").expect("crate name"),
"Demo",
&stable_framework(),
)
.with_backend_project_path(PathBuf::from("/proj/android"))
.with_project_root_path(PathBuf::from("/proj"))
};
let template = embedded::ANDROID
.get_file("settings.gradle.kts.tpl")
.expect("settings.gradle.kts template must exist")
.contents_utf8()
.expect("settings.gradle.kts template must be utf-8");
let render = |ctx: &TemplateContext| {
render_scaffold_template(
TemplateNamespace::Android,
std::path::Path::new("settings.gradle.kts.tpl"),
template,
ctx,
)
.expect("settings.gradle.kts render")
};
let local = render(&context(&manifest));
assert!(
local.contains("includeBuild(\"/opt/android-backend\")"),
"{local}"
);
assert!(local.contains("if (false) {"), "{local}");
assert!(local.contains("if (!false) {"), "{local}");
let mut remote_manifest = manifest;
remote_manifest.backends.clear_android();
let remote = render(&context(&remote_manifest));
assert!(remote.contains("if (true) {"), "{remote}");
assert!(remote.contains("if (!true) {"), "{remote}");
}
fn playground_ctx() -> TemplateContext {
TemplateContext::for_support_playground(
"WaterUIApp",
CrateName::try_from("waterui_app").expect("test crate name must be valid"),
BundleIdentifier::try_from("dev.waterui.playground")
.expect("test bundle identifier must be valid"),
Some(PathBuf::from("../..")),
&stable_framework(),
false,
None,
)
.with_backend_project_path(PathBuf::from("managed_backends/apple"))
}
fn pinned(key: &str) -> String {
format!("={}", stable_framework().scaffold_value(key))
}
fn render_esp32(relative: &str, ctx: &TemplateContext) -> String {
let template = embedded::ESP32
.get_file(relative)
.unwrap_or_else(|| panic!("esp32 template {relative} must exist"))
.contents_utf8()
.expect("esp32 template must be utf-8");
render_scaffold_template(
TemplateNamespace::Esp32,
std::path::Path::new(relative),
template,
ctx,
)
.unwrap_or_else(|error| panic!("esp32 template {relative} render: {error}"))
}
#[test]
fn esp32_templates_are_chip_architecture_aware() {
use crate::esp32::chip::Esp32Chip;
let mut s3 = app_ctx();
s3.framework = dev_framework();
s3.esp32 = Esp32TemplateEntry::new(Esp32Chip::Esp32S3, 410, 502, 16);
let mut c3 = app_ctx();
c3.framework = dev_framework();
c3.esp32 = Esp32TemplateEntry::new(Esp32Chip::Esp32C3, 200, 240, 16);
let s3_cargo = render_esp32(".cargo/config.toml.tpl", &s3);
assert!(s3_cargo.contains("target = \"xtensa-esp32s3-espidf\""));
assert!(s3_cargo.contains("MCU = \"esp32s3\""));
let c3_cargo = render_esp32(".cargo/config.toml.tpl", &c3);
assert!(c3_cargo.contains("target = \"riscv32imc-esp-espidf\""));
assert!(c3_cargo.contains("MCU = \"esp32c3\""));
let s3_sdk = render_esp32("sdkconfig.defaults.tpl", &s3);
assert!(s3_sdk.contains("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y"));
assert!(s3_sdk.contains("CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y"));
assert!(s3_sdk.contains("CONFIG_ESP_MAIN_TASK_STACK_SIZE=163840"));
let c3_sdk = render_esp32("sdkconfig.defaults.tpl", &c3);
assert!(c3_sdk.contains("CONFIG_ESP_CONSOLE_UART_DEFAULT=y"));
assert!(c3_sdk.contains("CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y"));
assert!(c3_sdk.contains("CONFIG_ESP_MAIN_TASK_STACK_SIZE=49152"));
assert!(render_esp32("partitions.csv.tpl", &s3).contains("0x10000, 0x600000,"));
assert!(render_esp32("partitions.csv.tpl", &c3).contains("0x10000, 0x300000,"));
let s3_manifest = render_esp32("Cargo.toml.tpl", &s3);
assert!(s3_manifest.contains("opt-level = \"s\""));
assert!(s3_manifest.contains("features = [\"espidf\", \"progress\"]"));
let c3_manifest = render_esp32("Cargo.toml.tpl", &c3);
assert!(c3_manifest.contains("opt-level = 2"));
assert!(c3_manifest.contains("lto = \"fat\""));
assert!(c3_manifest.contains("codegen-units = 1"));
assert!(c3_manifest.contains("strip = \"symbols\""));
assert!(c3_manifest.contains("features = [\"espidf\", \"progress\"]"));
assert!(render_esp32("src/main.rs.tpl", &c3).contains("PanelConfig::new(200, 240, 16)"));
let mut with_fonts = app_ctx();
with_fonts.esp32 = Esp32TemplateEntry::new(Esp32Chip::Esp32C3, 200, 240, 16)
.with_fonts(vec!["/tmp/fonts/Demo.ttf".to_string()]);
let main_rs = render_esp32("src/main.rs.tpl", &with_fonts);
assert!(main_rs.contains("include_bytes!(\"/tmp/fonts/Demo.ttf\")"));
assert!(main_rs.contains("FONTS"));
assert!(!render_esp32("src/main.rs.tpl", &c3).contains("include_bytes!"));
}
fn render_embedded(
namespace: TemplateNamespace,
embedded_dir: &Dir<'_>,
relative: &str,
ctx: &TemplateContext,
) -> String {
let template = embedded_dir
.get_file(relative)
.unwrap_or_else(|| panic!("template {relative} must exist"))
.contents_utf8()
.expect("template must be utf-8");
render_scaffold_template(namespace, std::path::Path::new(relative), template, ctx)
.unwrap_or_else(|error| panic!("template {relative} render: {error}"))
}
#[test]
fn generated_runtime_templates_install_app_catalog() {
let mut ctx =
app_ctx().with_backend_project_path(PathBuf::from("managed_backends/hydrolysis"));
ctx.framework = dev_framework();
for relative in [
"src/main.rs.tpl",
"src/lib.rs.tpl",
"src/mcp_runtime.rs.tpl",
] {
let rendered = render_embedded(
TemplateNamespace::Hydrolysis,
&embedded::HYDROLYSIS,
relative,
&ctx,
);
assert!(
rendered.contains("waterui::configure_environment!"),
"hydrolysis {relative} must create its environment through `configure_environment!`"
);
}
for relative in [
"src/preview_runtime.rs.tpl",
"src/preview_test_runtime.rs.tpl",
] {
let rendered = render_embedded(
TemplateNamespace::Hydrolysis,
&embedded::HYDROLYSIS,
relative,
&ctx,
);
assert!(
rendered.contains("app_environment()"),
"hydrolysis {relative} must take its environment from the generated `app_environment()` binding"
);
}
let preview_bindings = include_str!("../preview/hydrolysis_preview_bindings.rs.tpl");
assert!(
preview_bindings.contains("waterui::configure_environment!")
&& preview_bindings.contains("::app(env)"),
"hydrolysis preview bindings must build the preview environment \
through `configure_environment!` and the application's `app(env)`"
);
assert!(
render_esp32("src/main.rs.tpl", &ctx).contains("waterui_core::configure_environment!"),
"esp32 firmware must configure the environment through waterui-core, \
which it already depends on"
);
for (namespace, embedded_dir, relative) in [
(TemplateNamespace::Gtk4, &embedded::GTK4, "src/main.rs.tpl"),
(TemplateNamespace::Tui, &embedded::TUI, "src/main.rs.tpl"),
] {
let rendered = render_embedded(namespace, embedded_dir, relative, &ctx);
assert!(
rendered.contains("configure_environment!"),
"{relative} must create its environment through `configure_environment!`"
);
}
for (namespace, embedded_dir) in [
(TemplateNamespace::Hydrolysis, &embedded::HYDROLYSIS),
(TemplateNamespace::Esp32, &embedded::ESP32),
(TemplateNamespace::Ffi, &embedded::FFI),
(TemplateNamespace::Gtk4, &embedded::GTK4),
(TemplateNamespace::Tui, &embedded::TUI),
] {
let rendered = render_embedded(namespace, embedded_dir, "build.rs.tpl", &ctx);
assert!(
rendered.contains("cargo:rustc-env=WATERUI_I18N_DIR="),
"generated build.rs must export WATERUI_I18N_DIR for `catalog!`"
);
assert!(
rendered.contains(".join(\"../..\")") && rendered.contains(".join(\"i18n\")"),
"generated build.rs must resolve i18n/ relative to the project root: {rendered}"
);
assert!(
rendered.contains("cargo:rerun-if-changed={}"),
"generated build.rs must watch i18n/ so locale changes rebuild the crate"
);
}
let esp32_manifest = render_esp32("Cargo.toml.tpl", &ctx);
assert!(esp32_manifest.contains("waterui-locale"));
let gtk4_manifest = gtk4::rendered_outputs(&ctx, "test-gtk4")
.expect("gtk4 manifest should render")
.into_iter()
.find(|(path, _)| path == std::path::Path::new("Cargo.toml"))
.map(|(_, content)| String::from_utf8(content).expect("manifest is utf-8"))
.expect("gtk4 rendered outputs must include Cargo.toml");
let gtk4_manifest = gtk4_manifest
.parse::<toml::Table>()
.expect("gtk4 Cargo.toml should parse");
assert!(
gtk4_manifest["dependencies"].get("waterui").is_some(),
"gtk4 manifest must depend on waterui for `configure_environment!`"
);
}
#[test]
fn local_checkout_patches_are_rebased_onto_the_waterui_path() {
let tempdir = tempdir().expect("temporary checkout dir");
let checkout = tempdir.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 project_root = tempdir.path().join("app");
std::fs::create_dir_all(&project_root).expect("project dir");
let patches =
super::local_framework_patches(&project_root, std::path::Path::new("../waterui"))
.expect("patches from the checkout");
let crates_io = &patches["crates-io"];
let cargo_toml::Dependency::Detailed(core) = &crates_io["waterui-core"] else {
panic!("a path patch stays a detailed dependency");
};
assert_eq!(core.path.as_deref(), Some("../waterui/core"));
let cargo_toml::Dependency::Detailed(vello) = &crates_io["vello"] else {
panic!("a git patch stays a detailed dependency");
};
assert_eq!(
vello.git.as_deref(),
Some("https://github.com/lexoliu/vello")
);
assert!(vello.path.is_none());
let repository = &patches["https://github.com/water-rs/waterui"];
let cargo_toml::Dependency::Detailed(core) = &repository["waterui-core"] else {
panic!("a repository-source patch stays a detailed dependency");
};
assert_eq!(core.path.as_deref(), Some("../waterui/core"));
assert!(!repository.contains_key("vello"));
}
#[test]
fn native_backend_manifest_carries_the_checkout_patch_tables() {
let tempdir = tempdir().expect("temporary checkout dir");
let checkout = tempdir.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 ctx = ctx(
Some(checkout.clone()),
None,
Some(tempdir.path().join("app")),
crate::project::PackageType::Playground,
);
let manifest = super::render_native_backend_bin_cargo_toml(&ctx, "waterui-test-gtk4", &[])
.expect("generated manifest renders");
let core_path = checkout.join("core");
let manifest: toml::Value = toml::from_str(&manifest).expect("generated manifest parses");
let patched_path = |source: &str| {
manifest["patch"][source]["waterui-core"]["path"]
.as_str()
.map_or_else(
|| panic!("no waterui-core path patch under [patch.{source:?}]:\n{manifest}"),
std::path::PathBuf::from,
)
};
assert_eq!(patched_path("crates-io"), core_path);
assert_eq!(
patched_path("https://github.com/water-rs/waterui"),
core_path
);
}
#[test]
fn esp32_manifest_carries_the_checkout_patch_tables() {
let tempdir = tempdir().expect("temporary checkout dir");
let checkout = tempdir.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 ctx = ctx(
Some(checkout.clone()),
None,
Some(tempdir.path().join("app")),
crate::project::PackageType::App,
);
let manifest = render_esp32("Cargo.toml.tpl", &ctx);
let manifest: toml::Value = toml::from_str(&manifest).expect("esp32 manifest parses");
let core_path = checkout.join("core");
let patched_path = |source: &str| {
manifest["patch"][source]["waterui-core"]["path"]
.as_str()
.map_or_else(
|| panic!("no waterui-core path patch under [patch.{source:?}]:\n{manifest}"),
std::path::PathBuf::from,
)
};
assert_eq!(patched_path("crates-io"), core_path);
assert_eq!(
patched_path("https://github.com/water-rs/waterui"),
core_path
);
}
#[test]
fn relative_apple_backend_path_produces_clean_relative_backend_path() {
let mut ctx = ctx(
None,
Some(PathBuf::from("managed_backends/apple")),
None,
crate::project::PackageType::App,
);
ctx.apple_backend_path = Some(PathBuf::from("../apple-backend"));
let path = ctx
.compute_apple_backend_path()
.expect("expected relative backend path");
assert_eq!(path, "../../../apple-backend");
assert!(!path.contains("//"));
}
#[test]
fn absolute_apple_backend_path_is_used_directly() {
let abs = if cfg!(windows) {
PathBuf::from(r"C:\waterui\backends\apple")
} else {
PathBuf::from("/waterui/backends/apple")
};
let mut ctx = ctx(
None,
Some(PathBuf::from("apple")),
None,
crate::project::PackageType::App,
);
ctx.apple_backend_path = Some(abs);
let path = ctx
.compute_apple_backend_path()
.expect("expected backend path");
let expected = if cfg!(windows) {
"C:/waterui/backends/apple"
} else {
"/waterui/backends/apple"
};
assert_eq!(path, expected);
}
#[test]
fn waterui_path_backends_apple_is_used_when_no_manifest_override() {
let waterui_root = tempdir().expect("tempdir");
let backend_dir = waterui_root.path().join("backends/apple");
std::fs::create_dir_all(&backend_dir).expect("backend dir");
std::fs::write(
backend_dir.join("Package.swift"),
"// swift-tools-version:5.9\n",
)
.expect("Package.swift");
let project_root = tempdir().expect("tempdir");
let ctx = ctx(
Some(waterui_root.path().to_path_buf()),
Some(project_root.path().join("managed_backends/apple")),
Some(project_root.path().to_path_buf()),
crate::project::PackageType::Playground,
);
let path = ctx
.compute_apple_backend_path()
.expect("waterui_path/backends/apple must resolve");
assert!(
path.ends_with("backends/apple"),
"expected the staged backend path, got {path}"
);
}
#[test]
fn missing_local_apple_backend_falls_back_to_remote_package() {
let waterui_root = tempdir().expect("tempdir");
std::fs::create_dir_all(waterui_root.path().join("backends")).expect("backends dir");
let ctx = ctx(
Some(waterui_root.path().to_path_buf()),
Some(PathBuf::from("managed_backends/apple")),
None,
crate::project::PackageType::Playground,
);
assert!(ctx.compute_apple_backend_path().is_none());
}
#[test]
fn absolute_backend_project_path_uses_real_project_root() {
let project_root = if cfg!(windows) {
PathBuf::from(r"C:\Users\lexo\demo")
} else {
PathBuf::from("/Users/lexo/demo")
};
let backend_project_path = if cfg!(windows) {
PathBuf::from(
r"C:\Users\lexo\.water\build_cache\drive-C\Users\lexo\demo\managed_backends\apple",
)
} else {
PathBuf::from("/Users/lexo/.water/build_cache/Users/lexo/demo/managed_backends/apple")
};
let mut ctx = ctx(
None,
Some(backend_project_path.clone()),
Some(project_root.clone()),
crate::project::PackageType::Playground,
);
ctx.apple_backend_path = Some(PathBuf::from("../waterui/backends/apple"));
let path = ctx
.compute_apple_backend_path()
.expect("expected backend path");
let expected_backend_path = pathdiff::diff_paths(
project_root.join("../waterui/backends/apple"),
&backend_project_path,
)
.expect("backend diff path");
assert_eq!(path, normalize_path_for_config(&expected_backend_path));
let expected_project_root =
pathdiff::diff_paths(&project_root, &backend_project_path).expect("project root diff");
assert_eq!(
ctx.project_root_relative_path(),
normalize_path_for_config(&expected_project_root)
);
}
#[test]
fn android_manifest_enables_picture_in_picture_by_default() {
let ctx = app_ctx();
let template = embedded::ANDROID
.get_file("app/src/main/AndroidManifest.xml.tpl")
.expect("android manifest template must exist")
.contents_utf8()
.expect("android manifest template must be utf-8");
let rendered = render_scaffold_template(
TemplateNamespace::Android,
std::path::Path::new("app/src/main/AndroidManifest.xml.tpl"),
template,
&ctx,
)
.expect("android manifest render");
assert!(rendered.contains("android:resizeableActivity=\"true\""));
assert!(rendered.contains("android:supportsPictureInPicture=\"true\""));
assert!(rendered.contains(
"android:configChanges=\"screenSize|smallestScreenSize|screenLayout|orientation\""
));
}
#[test]
fn apple_project_enables_picture_in_picture_background_mode_by_default() {
let ctx = app_ctx();
let template = embedded::APPLE
.get_file("AppName.xcodeproj/project.pbxproj.tpl")
.expect("apple project template must exist")
.contents_utf8()
.expect("apple project template must be utf-8");
let rendered = render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new("AppName.xcodeproj/project.pbxproj.tpl"),
template,
&ctx,
)
.expect("apple project render");
assert!(
rendered.contains("\"INFOPLIST_KEY_UIBackgroundModes[sdk=iphoneos*][0]\" = audio;")
);
assert!(
rendered
.contains("\"INFOPLIST_KEY_UIBackgroundModes[sdk=iphonesimulator*][0]\" = audio;")
);
assert!(!rendered.contains("libwaterui_app"));
assert!(rendered.contains("LIBRARY_SEARCH_PATHS = \"$(BUILT_PRODUCTS_DIR)\";"));
assert!(rendered.contains(ctx.framework.scaffold_value("apple-backend-url")));
assert!(rendered.contains(ctx.framework.apple_backend_version().unwrap()));
assert!(rendered.contains("kind = exactVersion;"));
}
#[test]
fn apple_project_pins_the_channel_backend_on_dev_and_nightly() {
let project = |framework: ResolvedFramework| {
let mut context = app_ctx();
context.framework = framework;
let template = embedded::APPLE
.get_file("AppName.xcodeproj/project.pbxproj.tpl")
.expect("apple project template must exist")
.contents_utf8()
.expect("apple project template must be utf-8");
render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new("AppName.xcodeproj/project.pbxproj.tpl"),
template,
&context,
)
.expect("apple project render")
};
let revision = 'd'.to_string().repeat(40);
for (channel, rendered) in [
("dev", project(dev_framework())),
("nightly", project(nightly_framework(true))),
] {
assert!(
rendered.contains(&format!("revision = \"{revision}\";")),
"{channel} must pin the backend revision:\n{rendered}"
);
assert!(!rendered.contains("kind = exactVersion;"), "{channel}");
}
let rendered = project(nightly_framework(false));
assert!(rendered.contains("kind = exactVersion;"));
assert!(rendered.contains("version = \"0.3.0-dev.2\";"));
let mut context = app_ctx();
context.framework = dev_framework();
context.apple_backend_branch = Some("dev".to_owned());
let template = embedded::APPLE
.get_file("AppName.xcodeproj/project.pbxproj.tpl")
.expect("apple project template must exist")
.contents_utf8()
.expect("apple project template must be utf-8");
let rendered = render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new("AppName.xcodeproj/project.pbxproj.tpl"),
template,
&context,
)
.expect("apple project render");
assert!(rendered.contains("kind = branch;\n\t\t\t\tbranch = \"dev\";"));
}
#[test]
fn declared_apple_revision_becomes_an_exact_package_requirement() {
let directory = tempdir().unwrap();
let root = directory.path().join("waterui");
let revision = "dddddddddddddddddddddddddddddddddddddddd";
write_apple_revision_checkout(&root, revision);
let mut context = app_ctx();
context.framework = smol::block_on(ResolvedFramework::for_local_checkout(&root)).unwrap();
assert_eq!(
context.apple_backend_requirement(),
"kind = revision;\n\t\t\t\trevision = \"dddddddddddddddddddddddddddddddddddddddd\";"
);
}
#[test]
fn apple_project_names_only_the_launch_assets_that_were_staged() {
let render = |ctx: &TemplateContext, file: &str| {
let template = embedded::APPLE
.get_file(file)
.expect("apple template must exist")
.contents_utf8()
.expect("apple template must be utf-8");
render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new(file),
template,
ctx,
)
.expect("apple template render")
};
let project = "AppName.xcodeproj/project.pbxproj.tpl";
let info_plist = "AppName/Info.plist.tpl";
let bare = app_ctx();
let rendered = render(&bare, project);
assert!(rendered.contains(&format!(
"INFOPLIST_FILE = \"{}/Info.plist\";",
bare.app_name
)));
assert!(rendered.contains("GENERATE_INFOPLIST_FILE = YES;"));
assert!(!rendered.contains("INFOPLIST_KEY_UILaunchScreen"));
assert!(
rendered.contains("membershipExceptions = (\n\t\t\t\tInfo.plist,\n\t\t\t);"),
"{rendered}"
);
let plist = render(&bare, info_plist);
assert!(
plist.contains("<key>UILaunchScreen</key>\n\t<dict>\n\t</dict>"),
"{plist}"
);
assert!(
plist.contains(
"<key>UISceneDelegateClassName</key>\n\t\t\t\t\t<string>SceneDelegate</string>"
),
"{plist}"
);
let configured = app_ctx().with_launch(LaunchTemplateEntry {
has_background: true,
has_image: true,
});
let plist = render(&configured, info_plist);
assert!(plist.contains("<key>UIColorName</key>\n\t\t<string>LaunchBackground</string>"));
assert!(plist.contains("<key>UIImageName</key>\n\t\t<string>LaunchImage</string>"));
assert!(plist.contains("<key>UIImageRespectsSafeAreaInsets</key>\n\t\t<true/>"));
let color_only = app_ctx().with_launch(LaunchTemplateEntry {
has_background: true,
has_image: false,
});
let plist = render(&color_only, info_plist);
assert!(plist.contains("LaunchBackground"));
assert!(!plist.contains("LaunchImage"));
}
#[test]
fn apple_chromium_template_links_and_initializes_cef_before_appkit() {
let ctx = app_ctx()
.with_chromium_enabled(true)
.with_browser_engine(Some(ResolvedWebViewBackend::Cef));
let project_template = embedded::APPLE
.get_file("AppName.xcodeproj/project.pbxproj.tpl")
.expect("apple project template must exist")
.contents_utf8()
.expect("apple project template must be utf-8");
let project = render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new("AppName.xcodeproj/project.pbxproj.tpl"),
project_template,
&ctx,
)
.expect("apple Chromium project render");
assert!(project.contains("WaterUICEF in Frameworks"));
assert!(project.contains("WaterUIChromium in Frameworks"));
assert!(!project.contains("WaterUICefWebView in Frameworks"));
let app_template = embedded::APPLE
.get_file("AppName/AppNameApp.swift.tpl")
.expect("apple app template must exist")
.contents_utf8()
.expect("apple app template must be utf-8");
let app = render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new("AppName/AppNameApp.swift.tpl"),
app_template,
&ctx,
)
.expect("apple Chromium app render");
assert!(app.contains("import WaterUICEF"));
assert!(app.contains("import WaterUIChromium"));
assert!(app.contains("installWaterUIChromium()"));
assert!(
app.find("prepareWaterUICEFApplication()") < app.find("let app = NSApplication.shared")
);
assert!(!app.contains("runWaterUICEFSubprocessIfNeeded()"));
}
#[test]
fn apple_cef_webview_template_links_only_the_standard_cef_component() {
let ctx = app_ctx()
.with_webview_enabled(true)
.with_browser_engine(Some(ResolvedWebViewBackend::Cef));
let project_template = embedded::APPLE
.get_file("AppName.xcodeproj/project.pbxproj.tpl")
.expect("apple project template must exist")
.contents_utf8()
.expect("apple project template must be utf-8");
let project = render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new("AppName.xcodeproj/project.pbxproj.tpl"),
project_template,
&ctx,
)
.expect("apple CEF WebView project render");
assert!(project.contains("WaterUICEF in Frameworks"));
assert!(project.contains("WaterUICefWebView in Frameworks"));
assert!(!project.contains("WaterUIChromium in Frameworks"));
let app_template = embedded::APPLE
.get_file("AppName/AppNameApp.swift.tpl")
.expect("apple app template must exist")
.contents_utf8()
.expect("apple app template must be utf-8");
let app = render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new("AppName/AppNameApp.swift.tpl"),
app_template,
&ctx,
)
.expect("apple CEF WebView app render");
assert!(app.contains("import WaterUICefWebView"));
assert!(app.contains("installWaterUICefWebView()"));
assert!(!app.contains("installWaterUIChromium()"));
}
#[test]
fn android_build_gradle_uses_embedded_remote_backend_revision() {
let mut ctx = app_ctx();
let mut persisted: toml::Value =
toml::from_str(&toml::to_string(&ctx.framework).unwrap()).unwrap();
persisted["metadata"]["android-min-api-level"] = toml::Value::Integer(30);
ctx.framework = persisted.try_into().unwrap();
let template = embedded::ANDROID
.get_file("app/build.gradle.kts.tpl")
.expect("android build.gradle template must exist")
.contents_utf8()
.expect("android build.gradle template must be utf-8");
let rendered = render_scaffold_template(
TemplateNamespace::Android,
std::path::Path::new("app/build.gradle.kts.tpl"),
template,
&ctx,
)
.expect("android build.gradle render");
assert!(rendered.contains("minSdk = 30"));
assert!(rendered.contains(&jitpack_dependency_coordinate(
ctx.framework.scaffold_value("android-backend-url"),
ctx.framework.scaffold_value("android-backend-revision"),
)));
}
#[test]
fn android_activity_installs_edge_to_edge_and_leases_activity_context() {
let ctx = app_ctx();
let activity_template = embedded::ANDROID
.get_file("app/src/main/java/MainActivity.kt.tpl")
.expect("android MainActivity template must exist")
.contents_utf8()
.expect("android MainActivity template must be utf-8");
let activity = render_scaffold_template(
TemplateNamespace::Android,
std::path::Path::new("app/src/main/java/MainActivity.kt.tpl"),
activity_template,
&ctx,
)
.expect("android MainActivity render");
assert!(activity.contains("enableEdgeToEdge()"));
assert!(activity.contains(r#"envVar == "WATERUI_DEV_URL" && !BuildConfig.DEBUG"#));
assert!(activity.contains("waterUiApplication.acquireRuntime(this)"));
assert!(activity.contains("androidRuntimeLease.close()"));
assert!(activity.contains("val reportActivityFinished = !isChangingConfigurations"));
assert!(activity.contains("reportActivityFinished && releasedActiveRuntime"));
assert!(activity.contains("WATERUI_ACTIVITY_FINISHED"));
let application_template = embedded::ANDROID
.get_file("app/src/main/java/WaterUiApplication.kt.tpl")
.expect("android WaterUiApplication template must exist")
.contents_utf8()
.expect("android WaterUiApplication template must be utf-8");
let application = render_scaffold_template(
TemplateNamespace::Android,
std::path::Path::new("app/src/main/java/WaterUiApplication.kt.tpl"),
application_template,
&ctx,
)
.expect("android WaterUiApplication render");
assert!(application.starts_with("package com.example.test"));
assert!(application.contains("activeRuntime?.let { previous ->"));
assert!(application.contains("releaseWaterUiRuntime(previous.owner)"));
assert!(application.contains("bootstrapWaterUiRuntime(activity)"));
assert!(application.contains("if (runtime.generation != generation) return false"));
assert!(application.contains("return application.releaseRuntime(generation)"));
assert!(application.contains("Application(), WaterUiRuntimeOwner"));
assert!(application.contains("processEnvironment = WuiEnvironment.create()"));
assert!(application.contains("createWaterUiEnvironment(): WuiEnvironment"));
assert!(application.contains("}.clone()"));
let manifest_template = embedded::ANDROID
.get_file("app/src/main/AndroidManifest.xml.tpl")
.expect("android manifest template must exist")
.contents_utf8()
.expect("android manifest template must be utf-8");
let manifest = render_scaffold_template(
TemplateNamespace::Android,
std::path::Path::new("app/src/main/AndroidManifest.xml.tpl"),
manifest_template,
&ctx,
)
.expect("android manifest render");
assert!(manifest.contains("android:name=\".WaterUiApplication\""));
assert!(manifest.contains("android:launchMode=\"singleTask\""));
}
#[test]
fn gtk4_scaffold_pins_the_declared_git_source() {
let mut ctx = app_ctx();
ctx.framework = dev_framework();
let tempdir = tempdir().expect("temporary gtk scaffold dir");
smol::block_on(crate::templates::gtk4::scaffold(
tempdir.path(),
&ctx,
"waterui-test-gtk",
))
.expect("gtk4 scaffold should succeed");
let cargo_toml = std::fs::read_to_string(tempdir.path().join("Cargo.toml"))
.expect("gtk4 Cargo.toml should be written");
let manifest: toml::Value = toml::from_str(&cargo_toml).unwrap();
let gtk = &manifest["dependencies"]["waterui-gtk"];
assert_eq!(
gtk["git"].as_str(),
Some(ctx.framework.scaffold_value("waterui-gtk-git"))
);
assert_eq!(
gtk["rev"].as_str(),
Some(ctx.framework.scaffold_value("waterui-gtk-rev"))
);
assert!(!cargo_toml.contains("webview-default"));
}
#[test]
fn winui_scaffold_pins_the_backend_and_its_vendored_patch_to_one_source() {
let mut ctx = app_ctx();
ctx.framework = dev_framework();
let manifest = crate::templates::winui::rendered_outputs(&ctx, "waterui-test-winui")
.expect("winui outputs should render")
.into_iter()
.find_map(|(path, content)| {
(path == std::path::Path::new("Cargo.toml"))
.then(|| String::from_utf8(content).expect("Cargo.toml must be UTF-8"))
})
.expect("winui Cargo.toml output should exist");
let manifest: toml::Value = toml::from_str(&manifest).expect("winui manifest must parse");
let framework = dev_framework();
let backend = &manifest["dependencies"]["waterui-winui"];
assert_eq!(
backend["git"].as_str(),
Some(framework.scaffold_value("waterui-winui-git")),
"the backend pins the repository the framework declares"
);
assert_eq!(
backend["rev"].as_str(),
Some(framework.scaffold_value("waterui-winui-rev")),
"the backend pins the revision the framework declares"
);
let gpu_allocator = &manifest["patch"]["crates-io"]["gpu-allocator"];
assert_eq!(gpu_allocator["git"].as_str(), backend["git"].as_str());
assert_eq!(gpu_allocator["rev"].as_str(), backend["rev"].as_str());
assert!(manifest["workspace"].is_table());
assert_eq!(
manifest["build-dependencies"]["windows-reactor-setup"].as_str(),
Some("^0.100")
);
assert_eq!(
manifest["build-dependencies"]["winresource"].as_str(),
Some("^0.1")
);
}
#[test]
fn generated_native_backends_only_bridge_the_platform_engine_when_no_engine_is_linked() {
let mut gtk_ctx = app_ctx().with_webview_enabled(true);
gtk_ctx.framework = dev_framework();
let tempdir = tempdir().expect("temporary gtk webview scaffold dir");
smol::block_on(crate::templates::gtk4::scaffold(
tempdir.path(),
>k_ctx,
"waterui-test-gtk",
))
.expect("gtk4 webview scaffold should succeed");
let gtk_manifest = std::fs::read_to_string(tempdir.path().join("Cargo.toml"))
.expect("gtk4 Cargo.toml should be written");
assert!(gtk_manifest.contains("features = [\"webview-system\"]"));
let mut gtk_wpe_ctx = app_ctx()
.with_webview_enabled(true)
.with_browser_engine(Some(ResolvedWebViewBackend::Wpe));
gtk_wpe_ctx.framework = dev_framework();
let gtk_wpe_manifest =
crate::templates::gtk4::rendered_outputs(>k_wpe_ctx, "waterui-test-gtk-wpe")
.expect("GTK WPE outputs should render")
.into_iter()
.find_map(|(path, content)| {
(path == std::path::Path::new("Cargo.toml"))
.then(|| String::from_utf8(content).expect("Cargo.toml must be UTF-8"))
})
.expect("GTK WPE Cargo.toml output should exist");
assert!(!gtk_wpe_manifest.contains("webview-system"));
let hydrolysis_ctx = app_ctx()
.with_webview_enabled(true)
.with_browser_engine(Some(ResolvedWebViewBackend::Cef));
let cargo_toml = crate::templates::hydrolysis::rendered_outputs(
&hydrolysis_ctx,
"waterui-test-hydrolysis",
)
.expect("hydrolysis outputs should render")
.into_iter()
.find_map(|(path, content)| {
(path == std::path::Path::new("Cargo.toml"))
.then(|| String::from_utf8(content).expect("Cargo.toml must be UTF-8"))
})
.expect("hydrolysis Cargo.toml output should exist");
let manifest = cargo_toml
.parse::<toml::Table>()
.expect("hydrolysis Cargo.toml should parse");
let native_dependencies =
&manifest["target"]["cfg(not(target_arch = \"wasm32\"))"]["dependencies"];
assert_eq!(
native_dependencies["waterui-preview"]["version"].as_str(),
Some(pinned("waterui-preview-version").as_str()),
);
assert_eq!(
native_dependencies["waterui-preview-protocol"]["version"].as_str(),
Some(pinned("waterui-preview-protocol-version").as_str()),
);
assert_eq!(
native_dependencies["waterui-core"]["version"].as_str(),
Some(pinned("waterui-core-version").as_str()),
);
assert_eq!(
native_dependencies["hydrolysis-m3"]["version"].as_str(),
Some(pinned("hydrolysis-m3-version").as_str()),
);
assert_eq!(
native_dependencies["waterui-browser-cef"]["version"].as_str(),
Some(pinned("waterui-browser-cef-version").as_str()),
);
let features = native_dependencies["hydrolysis"]["features"]
.as_array()
.expect("hydrolysis dependency features should be an array")
.iter()
.map(|feature| feature.as_str().expect("feature should be a string"))
.collect::<Vec<_>>();
assert_eq!(features, ["winit"]);
assert_eq!(manifest["package"]["autobins"].as_bool(), Some(false));
let bins = manifest["bin"]
.as_array()
.expect("CEF Hydrolysis manifest should declare binaries");
assert!(bins.iter().any(|bin| {
bin["name"].as_str() == Some("waterui-test-hydrolysis-cef-helper")
&& bin["path"].as_str() == Some("src/bin/waterui-cef-helper.rs")
}));
}
#[test]
fn hydrolysis_manifest_enables_wasm_opt_for_the_features_rustc_emits() {
let cargo_toml =
crate::templates::hydrolysis::rendered_outputs(&app_ctx(), "waterui-test-hydrolysis")
.expect("hydrolysis outputs should render")
.into_iter()
.find_map(|(path, content)| {
(path == std::path::Path::new("Cargo.toml"))
.then(|| String::from_utf8(content).expect("Cargo.toml must be UTF-8"))
})
.expect("hydrolysis Cargo.toml output should exist");
let manifest = cargo_toml
.parse::<toml::Table>()
.expect("hydrolysis Cargo.toml should parse");
let wasm_opt =
manifest["package"]["metadata"]["wasm-pack"]["profile"]["release"]["wasm-opt"]
.as_array()
.expect("wasm-pack release profile should carry wasm-opt flags")
.iter()
.map(|flag| flag.as_str().expect("wasm-opt flag should be a string"))
.collect::<Vec<_>>();
for required in [
"--enable-bulk-memory",
"--enable-mutable-globals",
"--enable-sign-ext",
"--enable-nontrapping-float-to-int",
"--enable-reference-types",
] {
assert!(
wasm_opt.contains(&required),
"wasm-opt flags should include {required}, got {wasm_opt:?}"
);
}
}
#[test]
fn path_pinned_hydrolysis_manifest_uses_the_checkouts_own_sources() {
let tempdir = tempdir().expect("temporary checkout dir");
let checkout = tempdir.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 hydrolysis_ctx = ctx(
Some(checkout.clone()),
Some(PathBuf::from("managed_backends/hydrolysis")),
None,
crate::project::PackageType::Playground,
);
let cargo_toml = crate::templates::hydrolysis::rendered_outputs(
&hydrolysis_ctx,
"waterui-test-hydrolysis",
)
.expect("hydrolysis outputs should render")
.into_iter()
.find_map(|(path, content)| {
(path == std::path::Path::new("Cargo.toml"))
.then(|| String::from_utf8(content).expect("Cargo.toml must be UTF-8"))
})
.expect("hydrolysis Cargo.toml output should exist");
let manifest = cargo_toml
.parse::<toml::Table>()
.expect("hydrolysis Cargo.toml should parse");
for cfg in [
"cfg(not(target_arch = \"wasm32\"))",
"cfg(target_arch = \"wasm32\")",
] {
let dependencies = &manifest["target"][cfg]["dependencies"];
let hydrolysis = &dependencies["hydrolysis"];
assert_eq!(
hydrolysis["git"].as_str(),
Some("https://github.com/water-rs/hydrolysis"),
"{cfg} hydrolysis must name the checkout's patched git source"
);
assert_eq!(
hydrolysis["rev"].as_str(),
Some("e0cab32d0302877bbc9852cf504538fcf1534cb7"),
);
assert!(hydrolysis.get("path").is_none());
assert!(hydrolysis.get("version").is_none());
let m3 = &dependencies["hydrolysis-m3"];
assert_eq!(
m3["git"].as_str(),
Some("https://github.com/water-rs/hydrolysis-m3"),
);
assert_eq!(m3["rev"].as_str(), Some("d8872e5"));
assert!(m3.get("path").is_none());
}
let native_dependencies =
&manifest["target"]["cfg(not(target_arch = \"wasm32\"))"]["dependencies"];
assert_eq!(
native_dependencies["waterui-core"]["path"].as_str(),
Some(normalize_path_for_config(&checkout.join("core")).as_str()),
);
let gtk_ctx = ctx(
Some(checkout),
Some(PathBuf::from("gtk4")),
None,
crate::project::PackageType::App,
);
let gtk_manifest = crate::templates::gtk4::rendered_outputs(>k_ctx, "waterui-test-gtk")
.expect("GTK outputs should render")
.into_iter()
.find_map(|(path, content)| {
(path == std::path::Path::new("Cargo.toml"))
.then(|| String::from_utf8(content).expect("Cargo.toml must be UTF-8"))
})
.expect("GTK Cargo.toml output should exist")
.parse::<toml::Table>()
.expect("GTK Cargo.toml should parse");
let gtk = >k_manifest["dependencies"]["waterui-gtk"];
assert_eq!(gtk["version"].as_str(), Some("^0.1.2"));
assert!(gtk.get("path").is_none());
assert!(gtk.get("git").is_none());
}
#[test]
fn preview_scaffold_uses_embedded_workspace_version() {
let tempdir = tempdir().expect("temporary preview scaffold dir");
let ctx = app_ctx()
.with_preview_runtime_features(vec!["dynamic_linking".to_string(), "gpu".to_string()])
.with_preview_app_dependency(
CrateName::try_from("preview_test_app").expect("test crate name must be valid"),
tempdir.path().join("app"),
);
smol::block_on(crate::templates::preview::scaffold(tempdir.path(), &ctx))
.expect("preview scaffold should succeed");
let cargo_toml = std::fs::read_to_string(tempdir.path().join("Cargo.toml"))
.expect("preview Cargo.toml should be written");
assert!(cargo_toml.contains("default-features = false"));
let manifest = cargo_toml
.parse::<toml::Table>()
.expect("preview Cargo.toml should parse");
assert_eq!(
manifest["dependencies"]["waterui-preview"]["version"].as_str(),
Some(pinned("waterui-preview-version").as_str())
);
let dev_features = manifest["features"]["dev"]
.as_array()
.expect("preview dev feature should be an array")
.iter()
.map(|feature| feature.as_str().expect("feature should be a string"))
.collect::<Vec<_>>();
assert_eq!(dev_features, ["waterui/dynamic_linking", "waterui/gpu"]);
assert!(cargo_toml.contains("package = \"preview_test_app\""));
assert!(cargo_toml.contains("features = [\"dev\"]"));
let lib_rs = std::fs::read_to_string(tempdir.path().join("src/lib.rs"))
.expect("preview lib.rs should be written");
assert!(!lib_rs.contains("waterui_ffi::export!()"));
}
#[test]
fn ffi_scaffold_resolves_waterui_ffi_from_playground_cache_path() {
let tempdir = tempdir().expect("temporary ffi scaffold dir");
let project_root = tempdir.path().join("playground");
let ffi_dir = tempdir
.path()
.join("cache")
.join("managed_backends")
.join("ffi");
let ctx = ctx(
Some(PathBuf::from("../waterui")),
Some(ffi_dir.clone()),
Some(project_root.clone()),
crate::project::PackageType::Playground,
);
smol::block_on(crate::templates::ffi::scaffold(
&ffi_dir,
&ctx,
"playground-ffi",
))
.expect("ffi scaffold should succeed");
let cargo_toml = std::fs::read_to_string(ffi_dir.join("Cargo.toml"))
.expect("ffi Cargo.toml should be written");
let expected_ffi_path = pathdiff::diff_paths(project_root.join("../waterui/ffi"), &ffi_dir)
.expect("expected waterui ffi dependency diff path");
let expected_ffi_path = normalize_path_for_config(&expected_ffi_path);
assert!(cargo_toml.contains(&format!("path = \"{expected_ffi_path}\"")));
assert!(cargo_toml.contains("dev = [\"waterui_test/dev\"]"));
assert!(
cargo_toml.contains("[workspace]"),
"generated FFI crate must root the preview module workspace"
);
assert!(
!cargo_toml.contains(crate::templates::PREVIEW_MODULES_DIR),
"an FFI crate with no preview module on disk must declare no members"
);
let manifest = cargo_toml
.parse::<toml::Table>()
.expect("ffi Cargo.toml should parse");
assert_eq!(
manifest["dependencies"]["waterui-ffi"]["default-features"].as_bool(),
Some(false)
);
assert_eq!(manifest["package"]["autobins"].as_bool(), Some(false));
assert!(manifest.get("bin").is_none());
}
#[test]
fn ffi_scaffold_declares_minimal_cef_helper_for_chromium() {
let tempdir = tempdir().expect("temporary ffi scaffold dir");
let ffi_dir = tempdir.path().join("managed_backends/ffi");
let ctx = app_ctx()
.with_backend_project_path(ffi_dir.clone())
.with_project_root_path(tempdir.path().to_path_buf())
.with_chromium_enabled(true)
.with_browser_engine(Some(ResolvedWebViewBackend::Cef));
smol::block_on(crate::templates::ffi::scaffold(
&ffi_dir,
&ctx,
"chromium-ffi",
))
.expect("Chromium ffi scaffold should succeed");
let manifest = std::fs::read_to_string(ffi_dir.join("Cargo.toml"))
.expect("ffi Cargo.toml should be written")
.parse::<toml::Table>()
.expect("ffi Cargo.toml should parse");
let bins = manifest["bin"]
.as_array()
.expect("CEF FFI companion should declare a helper binary");
assert_eq!(bins.len(), 1);
assert_eq!(bins[0]["name"].as_str(), Some("chromium-ffi-cef-helper"));
assert_eq!(
bins[0]["path"].as_str(),
Some("src/bin/waterui-cef-helper.rs")
);
let helper = std::fs::read_to_string(ffi_dir.join("src/bin/waterui-cef-helper.rs"))
.expect("CEF helper source should be written");
assert!(helper.contains("waterui_cef_run_packaged_subprocess"));
}
#[test]
fn ffi_lockfile_seed_follows_the_project_lockfile() {
let tempdir = tempdir().expect("temporary ffi seed dir");
let project_lock = tempdir.path().join("Cargo.lock");
let ffi_dir = tempdir.path().join("managed_backends/ffi");
std::fs::create_dir_all(&ffi_dir).expect("ffi dir");
let managed_lock = ffi_dir.join("Cargo.lock");
let seed = || {
smol::block_on(crate::templates::ffi::seed_lockfile(
&ffi_dir,
&project_lock,
))
.expect("seeding the managed lockfile should succeed");
};
let managed = || std::fs::read_to_string(&managed_lock).expect("managed Cargo.lock");
seed();
assert!(!managed_lock.exists());
std::fs::write(&project_lock, "pins v1").expect("project lock");
seed();
assert_eq!(managed(), "pins v1");
std::fs::write(&managed_lock, "pins v1 + ffi entries").expect("managed lock");
seed();
assert_eq!(managed(), "pins v1 + ffi entries");
std::fs::write(&project_lock, "pins v2").expect("project lock");
seed();
assert_eq!(managed(), "pins v2");
std::fs::remove_file(&managed_lock).expect("remove managed lock");
seed();
assert_eq!(managed(), "pins v2");
}
#[test]
fn preview_ffi_scaffold_emits_dylib_only_wrapper() {
let tempdir = tempdir().expect("temporary preview ffi scaffold dir");
let project_root = tempdir.path().join("playground");
let preview_ffi_dir = tempdir
.path()
.join("cache")
.join("managed_backends")
.join("preview_ffi");
let workspace_root = tempdir.path().join("waterui");
std::fs::create_dir_all(workspace_root.join("preview/src"))
.expect("fixture workspace member dir");
std::fs::write(
workspace_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"preview\"]\n",
)
.expect("fixture workspace manifest");
std::fs::write(
workspace_root.join("preview/Cargo.toml"),
"[package]\nname = \"waterui-preview\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.expect("fixture member manifest");
std::fs::write(workspace_root.join("preview/src/lib.rs"), "")
.expect("fixture member source");
let ctx = ctx(
Some(workspace_root),
Some(preview_ffi_dir.clone()),
Some(project_root),
crate::project::PackageType::Playground,
);
smol::block_on(crate::templates::preview_ffi::scaffold(
&preview_ffi_dir,
&ctx,
"playground-preview-ffi",
))
.expect("preview ffi scaffold should succeed");
let cargo_toml = std::fs::read_to_string(preview_ffi_dir.join("Cargo.toml"))
.expect("preview ffi Cargo.toml should be written");
let manifest = cargo_toml
.parse::<toml::Table>()
.expect("preview ffi Cargo.toml should parse");
for (feature, ffi_feature) in [
(preview_ffi::APPLE_ABI_FEATURE, "waterui-ffi/c-api"),
(preview_ffi::ANDROID_ABI_FEATURE, "waterui-ffi/android-jni"),
] {
let features = manifest["features"][feature]
.as_array()
.expect("platform preview ABI feature should be an array")
.iter()
.map(|feature| feature.as_str().expect("feature should be a string"))
.collect::<Vec<_>>();
assert_eq!(
features,
["dep:waterui-ffi", ffi_feature, "dep:waterui-preview"]
);
}
assert_eq!(
manifest["dependencies"]["waterui-ffi"]["optional"].as_bool(),
Some(true)
);
assert_eq!(
manifest["dependencies"]["waterui-ffi"]["default-features"].as_bool(),
Some(false)
);
assert!(
!manifest.contains_key("workspace"),
"preview module must not root its own workspace"
);
assert!(
!manifest.contains_key("patch"),
"preview module must inherit `[patch]` from the workspace root"
);
assert!(
!manifest.contains_key("profile"),
"preview module must inherit profiles from the workspace root"
);
assert_eq!(
manifest["dependencies"]["waterui-preview"]["optional"].as_bool(),
Some(true)
);
let crate_types = manifest["lib"]["crate-type"]
.as_array()
.expect("crate-type should be an array")
.iter()
.map(|value| value.as_str().expect("crate type should be a string"))
.collect::<Vec<_>>();
assert_eq!(crate_types, ["dylib"]);
assert_eq!(
manifest["dependencies"]["waterui_test"]["features"]
.as_array()
.expect("app dependency features should be an array")
.iter()
.map(|value| value.as_str().expect("feature should be a string"))
.collect::<Vec<_>>(),
["dev"]
);
assert!(manifest["dependencies"].get("waterui").is_none());
}
#[test]
fn generated_ffi_manifest_emits_only_linked_crate_types() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("project");
let ffi_dir = temp
.path()
.join("cache")
.join("managed_backends")
.join("ffi");
let ctx = ctx(
Some(PathBuf::from("../waterui")),
Some(ffi_dir.clone()),
Some(project_root),
crate::project::PackageType::Playground,
);
smol::block_on(crate::templates::ffi::scaffold(
&ffi_dir,
&ctx,
"playground-ffi",
))
.expect("ffi scaffold should succeed");
let manifest = std::fs::read_to_string(ffi_dir.join("Cargo.toml"))
.expect("ffi Cargo.toml should be written")
.parse::<toml::Table>()
.expect("ffi Cargo.toml should parse");
let crate_types = manifest["lib"]["crate-type"]
.as_array()
.expect("crate-type should be an array")
.iter()
.map(|value| value.as_str().expect("crate type should be a string"))
.collect::<Vec<_>>();
assert_eq!(crate_types, ["staticlib", "cdylib"]);
}
#[test]
fn generated_manifests_keep_debug_info_off_for_dependencies() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("project");
let ffi_dir = temp
.path()
.join("cache")
.join("managed_backends")
.join("ffi");
let ctx = ctx(
Some(PathBuf::from("../waterui")),
Some(ffi_dir.clone()),
Some(project_root),
crate::project::PackageType::Playground,
);
smol::block_on(crate::templates::ffi::scaffold(
&ffi_dir,
&ctx,
"playground-ffi",
))
.expect("ffi scaffold should succeed");
let manifest = std::fs::read_to_string(ffi_dir.join("Cargo.toml"))
.expect("ffi Cargo.toml should be written")
.parse::<toml::Table>()
.expect("ffi Cargo.toml should parse");
let dev = &manifest["profile"]["dev"];
assert_eq!(dev["debug"].as_integer(), Some(1));
assert_eq!(dev["package"]["*"]["debug"].as_bool(), Some(false));
assert_eq!(dev["package"]["*"]["opt-level"].as_integer(), Some(2));
}
#[test]
fn generated_manifests_carry_the_release_profile() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("project");
let ffi_dir = temp
.path()
.join("cache")
.join("managed_backends")
.join("ffi");
let ctx = ctx(
Some(PathBuf::from("../waterui")),
Some(ffi_dir.clone()),
Some(project_root),
crate::project::PackageType::Playground,
);
smol::block_on(crate::templates::ffi::scaffold(
&ffi_dir,
&ctx,
"playground-ffi",
))
.expect("ffi scaffold should succeed");
let manifest = std::fs::read_to_string(ffi_dir.join("Cargo.toml"))
.expect("ffi Cargo.toml should be written")
.parse::<toml::Table>()
.expect("ffi Cargo.toml should parse");
let release = &manifest["profile"]["release"];
assert_eq!(release["lto"].as_bool(), Some(true));
assert_eq!(release["codegen-units"].as_integer(), Some(1));
assert_eq!(release["opt-level"].as_str(), Some("z"));
assert_eq!(release["strip"].as_bool(), Some(true));
assert_eq!(release["panic"].as_str(), Some("abort"));
}
#[test]
fn playground_android_manifest_enables_picture_in_picture_by_default() {
let ctx = playground_ctx();
let template = embedded::ANDROID
.get_file("app/src/main/AndroidManifest.xml.tpl")
.expect("android manifest template must exist")
.contents_utf8()
.expect("android manifest template must be utf-8");
let rendered = render_scaffold_template(
TemplateNamespace::Android,
std::path::Path::new("app/src/main/AndroidManifest.xml.tpl"),
template,
&ctx,
)
.expect("playground android manifest render");
assert!(rendered.contains("android:resizeableActivity=\"true\""));
assert!(rendered.contains("android:supportsPictureInPicture=\"true\""));
}
#[test]
fn playground_apple_project_enables_picture_in_picture_background_mode_by_default() {
let ctx = playground_ctx();
let template = embedded::APPLE
.get_file("AppName.xcodeproj/project.pbxproj.tpl")
.expect("apple project template must exist")
.contents_utf8()
.expect("apple project template must be utf-8");
let rendered = render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new("AppName.xcodeproj/project.pbxproj.tpl"),
template,
&ctx,
)
.expect("playground apple project render");
assert!(
rendered.contains("\"INFOPLIST_KEY_UIBackgroundModes[sdk=iphoneos*][0]\" = audio;")
);
assert!(
rendered
.contains("\"INFOPLIST_KEY_UIBackgroundModes[sdk=iphonesimulator*][0]\" = audio;")
);
assert!(!rendered.contains("libwaterui_app"));
}
#[test]
fn playground_apple_build_script_skips_direct_rust_build() {
let ctx = playground_ctx();
let template = embedded::APPLE
.get_file("build-rust.sh.tpl")
.expect("apple build script template must exist")
.contents_utf8()
.expect("apple build script template must be utf-8");
let rendered = render_scaffold_template(
TemplateNamespace::Apple,
std::path::Path::new("build-rust.sh.tpl"),
template,
&ctx,
)
.expect("playground apple build script render");
assert!(rendered.contains("playground support app is managed by water run/package"));
assert!(rendered.contains("if [ \"true\" = \"true\" ]; then"));
}
}
async fn scaffold_dir(
namespace: TemplateNamespace,
embedded_dir: &Dir<'_>,
base_dir: &Path,
ctx: &TemplateContext,
) -> io::Result<()> {
let mut dirs_to_process = vec![embedded_dir];
while let Some(current_dir) = dirs_to_process.pop() {
for file in current_dir.files() {
let relative_path = file.path();
let is_template = relative_path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == "tpl");
let dest_path = if is_template {
let without_tpl = relative_path.with_extension("");
ctx.transform_path(&without_tpl)
} else {
ctx.transform_path(relative_path)
};
let full_dest = base_dir.join(&dest_path);
if let Some(parent) = full_dest.parent() {
fs::create_dir_all(parent).await?;
}
if is_template {
let content = file
.contents_utf8()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))?;
let rendered = render_scaffold_template(namespace, relative_path, content, ctx)?;
write_file_if_changed(&full_dest, rendered.as_bytes()).await?;
} else {
write_file_if_changed(&full_dest, file.contents()).await?;
}
}
for subdir in current_dir.dirs() {
dirs_to_process.push(subdir);
}
}
Ok(())
}
fn render_dir_outputs(
namespace: TemplateNamespace,
embedded_dir: &Dir<'_>,
ctx: &TemplateContext,
) -> io::Result<Vec<(PathBuf, Vec<u8>)>> {
let mut outputs = Vec::new();
let mut dirs_to_process = vec![embedded_dir];
while let Some(current_dir) = dirs_to_process.pop() {
for file in current_dir.files() {
let relative_path = file.path();
let is_template = relative_path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == "tpl");
if is_template {
let dest_path = ctx.transform_path(&relative_path.with_extension(""));
let content = file
.contents_utf8()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))?;
let rendered = render_scaffold_template(namespace, relative_path, content, ctx)?;
outputs.push((dest_path, rendered.into_bytes()));
} else {
let dest_path = ctx.transform_path(relative_path);
outputs.push((dest_path, file.contents().to_vec()));
}
}
for subdir in current_dir.dirs() {
dirs_to_process.push(subdir);
}
}
Ok(outputs)
}
pub async fn framework_updates(
root: &Path,
previous: &crate::project::Manifest,
next: &crate::project::Manifest,
crate_name: &CrateName,
) -> io::Result<Vec<(PathBuf, Vec<u8>)>> {
use crate::backend::Backend;
use crate::project::PackageType;
if previous.package.package_type == PackageType::Playground {
return Ok(Vec::new());
}
let previous_framework = ResolvedFramework::for_manifest(previous, root)
.await
.map_err(io::Error::other)?;
let next_framework = ResolvedFramework::for_manifest(next, root)
.await
.map_err(io::Error::other)?;
let base = root.join(previous.backends.path());
let mut updates = native_backend_updates(
root,
previous,
next,
crate_name,
&previous_framework,
&next_framework,
)
.await?;
let rust = [
previous
.backends
.gtk4()
.map(|backend| base.join(backend.path())),
previous
.backends
.hydrolysis()
.map(|backend| base.join(backend.path())),
previous
.backends
.esp32()
.map(|backend| base.join(backend.path())),
previous
.backends
.winui()
.map(|backend| base.join(backend.path())),
];
let previous_patches = project_patches(root, previous)?;
for directory in rust.into_iter().flatten() {
let path = directory.join("Cargo.toml");
let mut manifest: toml_edit::DocumentMut = fs::read_to_string(&path)
.await?
.parse()
.map_err(io::Error::other)?;
next_framework
.update_manifest(&mut manifest, &previous_patches)
.map_err(|error| io::Error::other(error.to_string()))?;
updates.push((path, manifest.to_string().into_bytes()));
}
Ok(updates)
}
async fn native_backend_updates(
root: &Path,
previous: &crate::project::Manifest,
next: &crate::project::Manifest,
crate_name: &CrateName,
previous_framework: &ResolvedFramework,
next_framework: &ResolvedFramework,
) -> io::Result<Vec<(PathBuf, Vec<u8>)>> {
use crate::backend::Backend;
use std::collections::BTreeMap;
let base = root.join(previous.backends.path());
let native = [
previous.backends.apple().map(|backend| {
(
TemplateNamespace::Apple,
&embedded::APPLE,
base.join(backend.path()),
apple_app_name(crate_name),
)
}),
previous.backends.android().map(|backend| {
(
TemplateNamespace::Android,
&embedded::ANDROID,
base.join(backend.path()),
previous
.package
.name
.chars()
.filter(|character| character.is_alphanumeric())
.collect(),
)
}),
];
let mut updates = Vec::new();
for (namespace, templates, directory, app_name) in native.into_iter().flatten() {
let context = |manifest: &crate::project::Manifest, framework: &ResolvedFramework| {
TemplateContext::for_project_manifest(
manifest,
crate_name.clone(),
app_name.clone(),
framework,
)
.with_backend_project_path(directory.clone())
.with_project_root_path(root.to_path_buf())
};
let before: BTreeMap<_, _> =
render_dir_outputs(namespace, templates, &context(previous, previous_framework))?
.into_iter()
.collect();
for (path, after) in
render_dir_outputs(namespace, templates, &context(next, next_framework))?
{
let before = &before[&path];
if before == &after {
continue;
}
let destination = directory.join(path);
let existing = fs::read_to_string(&destination).await?;
let before = std::str::from_utf8(before).map_err(io::Error::other)?;
let after = std::str::from_utf8(&after).map_err(io::Error::other)?;
let merged = diffy::merge(before, &existing, after).map_err(|_| {
io::Error::other(format!(
"framework dependency changes conflict with custom edits in {}",
destination.display()
))
})?;
updates.push((destination, merged.into_bytes()));
}
}
let rust = [
previous
.backends
.gtk4()
.map(|backend| base.join(backend.path())),
previous
.backends
.hydrolysis()
.map(|backend| base.join(backend.path())),
previous
.backends
.esp32()
.map(|backend| base.join(backend.path())),
previous
.backends
.winui()
.map(|backend| base.join(backend.path())),
];
let framework = next
.framework
.as_ref()
.expect("channel updates have a resolved framework");
let previous_patches = project_patches(root, previous)?;
for directory in rust.into_iter().flatten() {
let path = directory.join("Cargo.toml");
let mut manifest: toml_edit::DocumentMut = fs::read_to_string(&path)
.await?
.parse()
.map_err(io::Error::other)?;
framework
.update_manifest(&mut manifest, &previous_patches)
.map_err(|error| io::Error::other(error.to_string()))?;
updates.push((path, manifest.to_string().into_bytes()));
}
Ok(updates)
}
async fn write_file_if_changed(path: &Path, contents: &[u8]) -> io::Result<()> {
match fs::read(path).await {
Ok(existing) if existing == contents => return Ok(()),
Ok(_) | Err(_) => {}
}
fs::write(path, contents).await
}
#[derive(serde::Serialize)]
struct SupportCargoManifest {
package: SupportPackageSection,
lib: SupportLibSection,
profile: cargo_toml::Profiles,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
features: std::collections::BTreeMap<String, Vec<String>>,
dependencies: std::collections::BTreeMap<String, SupportDependencyValue>,
workspace: SupportWorkspaceSection,
#[serde(skip_serializing_if = "cargo_toml::PatchSet::is_empty")]
patch: cargo_toml::PatchSet,
}
#[derive(serde::Serialize)]
struct SupportPackageSection {
name: String,
version: String,
edition: String,
}
#[derive(serde::Serialize)]
struct SupportLibSection {
#[serde(rename = "crate-type")]
crate_type: Vec<String>,
}
#[derive(serde::Serialize)]
struct SupportWorkspaceSection {}
#[derive(serde::Serialize)]
#[serde(untagged)]
enum SupportDependencyValue {
Detailed(SupportDependencyDetail),
}
#[derive(serde::Serialize)]
struct SupportDependencyDetail {
#[serde(skip_serializing_if = "Option::is_none")]
package: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
git: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rev: Option<String>,
#[serde(rename = "default-features", skip_serializing_if = "Option::is_none")]
default_features: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
features: Vec<String>,
}
impl From<GeneratedDependencyDetail> for SupportDependencyDetail {
fn from(dependency: GeneratedDependencyDetail) -> Self {
Self {
package: None,
version: dependency.version,
path: dependency.path,
git: dependency.git,
rev: dependency.rev,
default_features: dependency.default_features,
features: dependency.features,
}
}
}
fn generated_profiles() -> cargo_toml::Profiles {
let mut dev = cargo_toml::Profile {
debug: Some(cargo_toml::DebugSetting::Lines),
..Default::default()
};
let mut dependency_override = toml::value::Table::new();
dependency_override.insert("debug".to_string(), toml::Value::Boolean(false));
dependency_override.insert("opt-level".to_string(), toml::Value::Integer(2));
dev.package
.insert("*".to_string(), toml::Value::Table(dependency_override));
let release = cargo_toml::Profile {
lto: Some(cargo_toml::LtoSetting::Fat),
codegen_units: Some(1),
opt_level: Some(toml::Value::String("z".to_string())),
strip: Some(cargo_toml::StripSetting::Symbols),
panic: Some("abort".to_string()),
..Default::default()
};
cargo_toml::Profiles {
dev: Some(dev),
release: Some(release),
..Default::default()
}
}
fn generated_profiles_fingerprint() -> String {
toml::to_string(&generated_profiles()).expect("generated profiles must serialize to TOML")
}
async fn write_support_cargo_toml(
base_dir: &Path,
crate_name: &str,
features: std::collections::BTreeMap<String, Vec<String>>,
dependencies: std::collections::BTreeMap<String, SupportDependencyValue>,
runtime_root: Option<&Path>,
framework: &ResolvedFramework,
) -> io::Result<()> {
let patch = match runtime_root {
Some(root) => {
let root = root.to_path_buf();
smol::unblock(move || collect_workspace_patches(&root)).await?
}
None => framework.patches(),
};
let manifest = SupportCargoManifest {
package: SupportPackageSection {
name: crate_name.to_string(),
version: "0.1.0".to_string(),
edition: "2024".to_string(),
},
lib: SupportLibSection {
crate_type: vec!["rlib".to_string()],
},
profile: generated_profiles(),
features,
dependencies,
workspace: SupportWorkspaceSection {},
patch,
};
let toml_string = toml::to_string_pretty(&manifest)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::create_dir_all(base_dir).await?;
write_file_if_changed(&base_dir.join("Cargo.toml"), toml_string.as_bytes()).await?;
Ok(())
}
#[derive(Clone, Copy)]
enum NativeBackendDependencySource<'a> {
WateruiRoot,
WorkspaceSubdir(&'a str),
WorkspaceDependency,
}
#[derive(Clone, Copy)]
struct NativeBackendDependencySpec<'a> {
crate_name: &'a str,
features: &'a [&'a str],
source: NativeBackendDependencySource<'a>,
}
impl<'a> NativeBackendDependencySpec<'a> {
const fn new(
crate_name: &'a str,
features: &'a [&'a str],
source: NativeBackendDependencySource<'a>,
) -> Self {
Self {
crate_name,
features,
source,
}
}
}
fn compute_native_backend_dependency_path(
ctx: &TemplateContext,
waterui_path: &Path,
subdir: Option<&str>,
) -> String {
if waterui_path.is_absolute() {
let absolute_path = subdir.map_or_else(
|| waterui_path.to_path_buf(),
|subdir| waterui_path.join(subdir),
);
return normalize_path_for_config(&absolute_path);
}
let project_relative_root = PathBuf::from(ctx.project_root_relative_path());
let relative_path = subdir.map_or_else(
|| project_relative_root.join(waterui_path),
|subdir| project_relative_root.join(waterui_path).join(subdir),
);
normalize_path_for_config(&relative_path)
}
async fn write_native_backend_bin_cargo_toml(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
dependencies: &[NativeBackendDependencySpec<'_>],
) -> io::Result<()> {
let toml_string = render_native_backend_bin_cargo_toml(ctx, package_name, dependencies)?;
fs::create_dir_all(base_dir).await?;
write_file_if_changed(&base_dir.join("Cargo.toml"), toml_string.as_bytes()).await
}
fn render_native_backend_bin_cargo_toml(
ctx: &TemplateContext,
package_name: &str,
dependencies: &[NativeBackendDependencySpec<'_>],
) -> io::Result<String> {
use cargo_toml::{Dependency, DependencyDetail, Manifest, Package, Workspace};
let mut manifest = Manifest::<()>::default();
let mut package = Package::new(package_name.to_string(), cargo_semver("0.1.0"));
package.edition = cargo_toml::Inheritable::Set(cargo_toml::Edition::E2024);
manifest.package = Some(package);
manifest.profile = generated_profiles();
manifest.dependencies.insert(
ctx.crate_name.to_string(),
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(ctx.project_root_relative_path()),
..Default::default()
})),
);
for dependency in dependencies {
manifest.dependencies.insert(
dependency.crate_name.to_string(),
Dependency::Detailed(Box::new(
generated_dependency_from_spec(ctx, *dependency)?.into_cargo(),
)),
);
}
manifest.workspace = Some(Workspace::default());
manifest.patch = generated_crate_patches(ctx)?;
toml::to_string_pretty(&manifest)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
fn dependency_path(path: &Path) -> SupportDependencyValue {
SupportDependencyValue::Detailed(SupportDependencyDetail {
package: None,
version: None,
path: Some(normalize_path_for_config(path)),
git: None,
rev: None,
default_features: None,
features: Vec::new(),
})
}
#[derive(serde::Serialize)]
struct GeneratedCargoManifest<T> {
package: GeneratedPackageSection,
lib: GeneratedLibSection,
#[serde(rename = "bin", skip_serializing_if = "Vec::is_empty", default)]
bins: Vec<GeneratedBinSection>,
profile: cargo_toml::Profiles,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty", default)]
features: std::collections::BTreeMap<String, Vec<String>>,
dependencies: std::collections::BTreeMap<String, T>,
#[serde(
rename = "build-dependencies",
skip_serializing_if = "std::collections::BTreeMap::is_empty",
default
)]
build_dependencies: std::collections::BTreeMap<String, T>,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty", default)]
target: std::collections::BTreeMap<String, GeneratedTargetSection<T>>,
workspace: GeneratedWorkspaceSection,
#[serde(skip_serializing_if = "cargo_toml::PatchSet::is_empty", default)]
patch: cargo_toml::PatchSet,
}
#[derive(serde::Serialize)]
struct GeneratedPackageSection {
name: String,
version: String,
edition: String,
#[serde(skip_serializing_if = "Option::is_none")]
autobins: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
authors: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<GeneratedPackageMetadata>,
}
#[derive(serde::Serialize)]
struct GeneratedPackageMetadata {
#[serde(rename = "wasm-pack")]
wasm_pack: GeneratedWasmPackMetadata,
}
#[derive(serde::Serialize)]
struct GeneratedWasmPackMetadata {
profile: GeneratedWasmPackProfiles,
}
#[derive(serde::Serialize)]
struct GeneratedWasmPackProfiles {
release: GeneratedWasmPackProfile,
}
#[derive(serde::Serialize)]
struct GeneratedWasmPackProfile {
#[serde(rename = "wasm-opt")]
wasm_opt: [&'static str; 6],
}
#[derive(serde::Serialize)]
struct GeneratedLibSection {
#[serde(rename = "crate-type")]
crate_type: Vec<String>,
}
#[derive(serde::Serialize)]
struct GeneratedBinSection {
name: String,
path: String,
}
#[derive(serde::Serialize)]
struct GeneratedTargetSection<T> {
dependencies: std::collections::BTreeMap<String, T>,
}
#[derive(serde::Serialize)]
struct GeneratedWorkspaceSection {}
#[derive(serde::Serialize)]
#[serde(untagged)]
enum GeneratedDependencyValue {
Simple(String),
Detailed(GeneratedDependencyDetail),
}
#[derive(serde::Serialize, Clone, Default)]
struct GeneratedDependencyDetail {
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
git: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
package: Option<String>,
#[serde(rename = "default-features", skip_serializing_if = "Option::is_none")]
default_features: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
features: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
optional: bool,
}
impl GeneratedDependencyValue {
const fn detailed(detail: GeneratedDependencyDetail) -> Self {
Self::Detailed(detail)
}
fn simple(version: &str) -> Self {
Self::Simple(version.to_string())
}
}
impl GeneratedDependencyDetail {
fn inline_toml(&self) -> String {
let mut table = toml_edit::ser::to_document(self)
.expect("generated dependency serializes")
.into_table()
.into_inline_table();
table.fmt();
table.to_string()
}
fn into_cargo(self) -> cargo_toml::DependencyDetail {
cargo_toml::DependencyDetail {
version: self.version.map(|version| cargo_version_req(&version)),
path: self.path,
git: self.git,
rev: self.rev,
branch: self.branch,
tag: self.tag,
package: self.package,
default_features: self.default_features.unwrap_or(true),
features: self.features,
optional: self.optional,
..Default::default()
}
}
fn framework(ctx: &TemplateContext, name: &str) -> io::Result<Self> {
ctx.framework
.require_distributable(name)
.map_err(io::Error::other)?;
let dependency = ctx.framework.dependency(name);
Ok(Self {
version: dependency.version.map(|version| version.to_string()),
path: dependency.path,
git: dependency.git,
rev: dependency.rev,
branch: dependency.branch,
tag: dependency.tag,
package: dependency.package,
default_features: None,
features: Vec::new(),
optional: false,
})
}
fn path(path: &Path) -> Self {
Self {
path: Some(normalize_path_for_config(path)),
..Self::default()
}
}
const fn with_default_features(mut self, default_features: bool) -> Self {
self.default_features = Some(default_features);
self
}
const fn with_optional(mut self) -> Self {
self.optional = true;
self
}
fn with_features(mut self, features: &[&str]) -> Self {
self.features = features
.iter()
.map(|feature| (*feature).to_string())
.collect();
self
}
}
fn generated_package(name: &str, authors: Vec<String>) -> GeneratedPackageSection {
GeneratedPackageSection {
name: name.to_string(),
version: "0.1.0".to_string(),
edition: "2024".to_string(),
autobins: None,
authors,
metadata: None,
}
}
fn generated_lib(crate_types: &[&str]) -> GeneratedLibSection {
GeneratedLibSection {
crate_type: crate_types
.iter()
.map(|crate_type| (*crate_type).to_string())
.collect(),
}
}
fn generated_dependency_from_spec(
ctx: &TemplateContext,
spec: NativeBackendDependencySpec<'_>,
) -> io::Result<GeneratedDependencyDetail> {
let mut detail = match (&ctx.waterui_path, spec.source) {
(Some(_), NativeBackendDependencySource::WorkspaceDependency) => {
local_checkout_dependency(ctx, spec.crate_name)?
}
(Some(waterui_path), NativeBackendDependencySource::WateruiRoot) => {
GeneratedDependencyDetail {
path: Some(compute_native_backend_dependency_path(
ctx,
waterui_path,
None,
)),
..GeneratedDependencyDetail::default()
}
}
(Some(waterui_path), NativeBackendDependencySource::WorkspaceSubdir(subdir)) => {
GeneratedDependencyDetail {
path: Some(compute_native_backend_dependency_path(
ctx,
waterui_path,
Some(subdir),
)),
..GeneratedDependencyDetail::default()
}
}
(None, _) => GeneratedDependencyDetail::framework(ctx, spec.crate_name)?,
};
for feature in spec.features {
if !detail.features.iter().any(|declared| declared == feature) {
detail.features.push((*feature).to_string());
}
}
Ok(detail)
}
fn render_generated_cargo_toml<T: serde::Serialize>(
manifest: &GeneratedCargoManifest<T>,
) -> io::Result<String> {
toml::to_string_pretty(manifest)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
async fn write_generated_cargo_toml(base_dir: &Path, toml_string: String) -> io::Result<()> {
fs::create_dir_all(base_dir).await?;
write_file_if_changed(&base_dir.join("Cargo.toml"), toml_string.as_bytes()).await
}
pub mod apple {
use super::{Path, TemplateContext, TemplateNamespace, embedded, fs, io, scaffold_dir};
pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
scaffold_dir(TemplateNamespace::Apple, &embedded::APPLE, base_dir, ctx).await?;
fs::create_dir_all(base_dir.join(&ctx.app_name).join("waterui_assets")).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let script_path = base_dir.join("build-rust.sh");
if script_path.exists() {
let mut perms = fs::metadata(&script_path).await?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms).await?;
}
}
Ok(())
}
}
pub mod android {
use crate::android::toolchain::AndroidSdk;
use super::{
Path, TemplateContext, TemplateNamespace, embedded, fs, io, normalize_path_for_config,
scaffold_dir, write_file_if_changed,
};
pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
scaffold_dir(
TemplateNamespace::Android,
&embedded::ANDROID,
base_dir,
ctx,
)
.await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let gradlew_path = base_dir.join("gradlew");
if gradlew_path.exists() {
let mut perms = fs::metadata(&gradlew_path).await?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&gradlew_path, perms).await?;
}
}
for abi in ["arm64-v8a", "x86_64", "armeabi-v7a", "x86"] {
let jni_dir = base_dir.join(format!("app/src/main/jniLibs/{abi}"));
fs::create_dir_all(&jni_dir).await?;
}
if let Some(sdk_path) = AndroidSdk::detect_path(&crate::toolchain::Host::current()) {
let local_props = base_dir.join("local.properties");
let content = format!("sdk.dir={}\n", normalize_path_for_config(&sdk_path));
write_file_if_changed(&local_props, content.as_bytes()).await?;
}
Ok(())
}
}
pub mod gtk4 {
use super::{
NativeBackendDependencySource, NativeBackendDependencySpec, Path, TemplateContext,
TemplateNamespace, embedded, io, scaffold_dir, write_native_backend_bin_cargo_toml,
};
pub async fn scaffold(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx, package_name).await?;
scaffold_dir(TemplateNamespace::Gtk4, &embedded::GTK4, base_dir, ctx).await
}
pub fn rendered_outputs(
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<Vec<(std::path::PathBuf, Vec<u8>)>> {
let mut outputs = super::render_dir_outputs(TemplateNamespace::Gtk4, &embedded::GTK4, ctx)?;
let features = ctx
.webview_backend_feature()
.into_iter()
.collect::<Vec<_>>();
let dependencies = gtk4_dependencies(&features);
outputs.push((
std::path::PathBuf::from("Cargo.toml"),
super::render_native_backend_bin_cargo_toml(ctx, package_name, &dependencies)?
.into_bytes(),
));
Ok(outputs)
}
async fn generate_cargo_toml(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
let features = ctx
.webview_backend_feature()
.into_iter()
.collect::<Vec<_>>();
let dependencies = gtk4_dependencies(&features);
write_native_backend_bin_cargo_toml(base_dir, ctx, package_name, &dependencies).await
}
const fn gtk4_dependencies<'a>(
features: &'a [&'a str],
) -> [NativeBackendDependencySpec<'a>; 2] {
[
NativeBackendDependencySpec::new(
"waterui-gtk",
features,
NativeBackendDependencySource::WorkspaceDependency,
),
NativeBackendDependencySpec::new(
"waterui",
&[],
NativeBackendDependencySource::WateruiRoot,
),
]
}
}
pub mod winui {
use super::{
NativeBackendDependencySource, NativeBackendDependencySpec, Path, TemplateContext,
TemplateNamespace, embedded, io, scaffold_dir,
};
use cargo_toml::{Dependency, DependencyDetail};
pub async fn scaffold(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx, package_name).await?;
scaffold_dir(TemplateNamespace::WinUi, &embedded::WINUI, base_dir, ctx).await
}
pub fn rendered_outputs(
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<Vec<(std::path::PathBuf, Vec<u8>)>> {
let mut outputs =
super::render_dir_outputs(TemplateNamespace::WinUi, &embedded::WINUI, ctx)?;
outputs.push((
std::path::PathBuf::from("Cargo.toml"),
render_cargo_toml(ctx, package_name)?.into_bytes(),
));
Ok(outputs)
}
async fn generate_cargo_toml(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
super::write_generated_cargo_toml(base_dir, render_cargo_toml(ctx, package_name)?).await
}
fn render_cargo_toml(ctx: &TemplateContext, package_name: &str) -> io::Result<String> {
use cargo_toml::{Manifest, Package, Workspace};
let (backend, gpu_allocator_patch) = winui_backend_dependency(ctx)?;
let mut manifest = Manifest::<()>::default();
let mut package = Package::new(package_name.to_string(), super::cargo_semver("0.1.0"));
package.edition = cargo_toml::Inheritable::Set(cargo_toml::Edition::E2024);
manifest.package = Some(package);
manifest.profile = super::generated_profiles();
manifest.dependencies.insert(
ctx.crate_name.to_string(),
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(ctx.project_root_relative_path()),
..Default::default()
})),
);
manifest.dependencies.insert(
"waterui".to_string(),
Dependency::Detailed(Box::new(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui",
&[],
NativeBackendDependencySource::WateruiRoot,
),
)?
.into_cargo(),
)),
);
manifest
.dependencies
.insert("waterui-winui".to_string(), backend);
manifest.build_dependencies.insert(
"winresource".to_string(),
Dependency::Simple(super::cargo_version_req("0.1")),
);
manifest.build_dependencies.insert(
"windows-reactor-setup".to_string(),
Dependency::Simple(super::cargo_version_req("0.100")),
);
manifest.workspace = Some(Workspace::default());
manifest.patch = winui_patch_set(ctx, gpu_allocator_patch)?;
toml::to_string_pretty(&manifest)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
fn winui_backend_dependency(ctx: &TemplateContext) -> io::Result<(Dependency, Dependency)> {
if let Some(path) = std::env::var_os("WATERUI_WINUI_PATH") {
let path = dunce::canonicalize(path)?;
return Ok((
path_dependency(&path),
path_dependency(&path.join("vendor/gpu-allocator")),
));
}
if let Some(root) = ctx
.waterui_workspace_root()
.and_then(|root| dunce::canonicalize(root).ok())
&& let Some(sibling) = root
.parent()
.map(|parent| parent.join("water-rs/waterui-winui"))
&& sibling.join("Cargo.toml").is_file()
{
return Ok((
path_dependency(&sibling),
path_dependency(&sibling.join("vendor/gpu-allocator")),
));
}
let detail = super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-winui",
&[],
NativeBackendDependencySource::WorkspaceDependency,
),
)?;
let patch = gpu_allocator_patch(&detail)?;
Ok((Dependency::Detailed(Box::new(detail.into_cargo())), patch))
}
fn path_dependency(path: &Path) -> Dependency {
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(super::normalize_path_for_config(path)),
..Default::default()
}))
}
fn gpu_allocator_patch(detail: &super::GeneratedDependencyDetail) -> io::Result<Dependency> {
if let Some(git) = &detail.git {
return Ok(Dependency::Detailed(Box::new(DependencyDetail {
git: Some(git.clone()),
rev: detail.rev.clone(),
..Default::default()
})));
}
if let Some(path) = &detail.path {
return Ok(Dependency::Detailed(Box::new(DependencyDetail {
path: Some(super::normalize_path_for_config(
&Path::new(path).join("vendor/gpu-allocator"),
)),
..Default::default()
})));
}
Err(io::Error::new(
io::ErrorKind::InvalidData,
"`waterui-winui` resolved to a registry dependency, but its `gpu-allocator` \
patch can only pin a git revision or a checkout carrying the vendored member",
))
}
fn winui_patch_set(
ctx: &TemplateContext,
gpu_allocator_patch: Dependency,
) -> io::Result<cargo_toml::PatchSet> {
let mut patch = super::generated_crate_patches(ctx)?;
patch
.entry("crates-io".to_string())
.or_default()
.insert("gpu-allocator".to_string(), gpu_allocator_patch);
Ok(patch)
}
}
pub mod hydrolysis {
use super::{
GeneratedBinSection, GeneratedCargoManifest, GeneratedDependencyDetail,
GeneratedDependencyValue, GeneratedTargetSection, GeneratedWorkspaceSection,
NativeBackendDependencySource, NativeBackendDependencySpec, Path, TemplateContext,
TemplateNamespace, embedded, io, scaffold_dir, write_generated_cargo_toml,
};
use std::collections::BTreeMap;
pub async fn scaffold(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx, package_name).await?;
scaffold_dir(
TemplateNamespace::Hydrolysis,
&embedded::HYDROLYSIS,
base_dir,
ctx,
)
.await
}
pub fn rendered_outputs(
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<Vec<(std::path::PathBuf, Vec<u8>)>> {
let mut outputs =
super::render_dir_outputs(TemplateNamespace::Hydrolysis, &embedded::HYDROLYSIS, ctx)?;
let patch = super::generated_crate_patches(ctx)?;
outputs.push((
std::path::PathBuf::from("Cargo.toml"),
super::render_generated_cargo_toml(&generated_manifest(ctx, package_name, patch)?)?
.into_bytes(),
));
Ok(outputs)
}
fn generated_manifest(
ctx: &TemplateContext,
package_name: &str,
patch: cargo_toml::PatchSet,
) -> io::Result<GeneratedCargoManifest<GeneratedDependencyValue>> {
let mut package = super::generated_package(package_name, Vec::new());
package.autobins = Some(false);
package.metadata = Some(super::GeneratedPackageMetadata {
wasm_pack: super::GeneratedWasmPackMetadata {
profile: super::GeneratedWasmPackProfiles {
release: super::GeneratedWasmPackProfile {
wasm_opt: [
"-O",
"--enable-bulk-memory",
"--enable-mutable-globals",
"--enable-sign-ext",
"--enable-nontrapping-float-to-int",
"--enable-reference-types",
],
},
},
},
});
let mut bins = vec![GeneratedBinSection {
name: package_name.to_string(),
path: "src/main.rs".to_string(),
}];
if requires_cef(ctx) {
bins.push(GeneratedBinSection {
name: crate::project_model::project_types::cef_helper_binary_name(package_name),
path: "src/bin/waterui-cef-helper.rs".to_string(),
});
}
Ok(GeneratedCargoManifest {
package,
lib: super::generated_lib(&["cdylib", "rlib"]),
bins,
profile: super::generated_profiles(),
features: BTreeMap::from([
("waterui-preview-mode".to_string(), Vec::new()),
("waterui-preview-test-mode".to_string(), Vec::new()),
(
"waterui-mcp-mode".to_string(),
vec!["dep:waterui-mcp".to_string()],
),
]),
dependencies: cargo_dependencies(ctx)?,
build_dependencies: BTreeMap::from([(
"winresource".to_string(),
GeneratedDependencyValue::Simple("0.1".to_string()),
)]),
target: cargo_target_dependencies(ctx)?,
workspace: GeneratedWorkspaceSection {},
patch,
})
}
const fn requires_cef(ctx: &TemplateContext) -> bool {
ctx.cef_runtime_enabled()
}
async fn generate_cargo_toml(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
let patch = match ctx.waterui_workspace_root() {
Some(root) => smol::unblock(move || super::collect_workspace_patches(&root)).await?,
None => ctx.framework.patches(),
};
let manifest = generated_manifest(ctx, package_name, patch)?;
write_generated_cargo_toml(base_dir, super::render_generated_cargo_toml(&manifest)?).await
}
fn cargo_dependencies(
ctx: &TemplateContext,
) -> io::Result<BTreeMap<String, GeneratedDependencyValue>> {
Ok(BTreeMap::from([
(
ctx.crate_name.to_string(),
GeneratedDependencyValue::detailed(GeneratedDependencyDetail {
path: Some(ctx.project_root_relative_path()),
..GeneratedDependencyDetail::default()
}),
),
(
"waterui".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui",
&[],
NativeBackendDependencySource::WateruiRoot,
),
)?
.with_default_features(false),
),
),
]))
}
fn cargo_target_dependencies(
ctx: &TemplateContext,
) -> io::Result<BTreeMap<String, GeneratedTargetSection<GeneratedDependencyValue>>> {
Ok(BTreeMap::from([
(
"cfg(not(target_arch = \"wasm32\"))".to_string(),
GeneratedTargetSection {
dependencies: native_target_dependencies(ctx)?,
},
),
(
"cfg(target_arch = \"wasm32\")".to_string(),
GeneratedTargetSection {
dependencies: wasm_target_dependencies(ctx)?,
},
),
]))
}
#[allow(
clippy::too_many_lines,
reason = "linear enumeration of native target dependencies reads clearest as one list"
)]
fn native_target_dependencies(
ctx: &TemplateContext,
) -> io::Result<BTreeMap<String, GeneratedDependencyValue>> {
let mut hydrolysis_features = vec!["winit"];
hydrolysis_features.extend(ctx.webview_backend_feature());
let mut dependencies: BTreeMap<String, GeneratedDependencyValue> = BTreeMap::from([
(
"hydrolysis".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"hydrolysis",
&hydrolysis_features,
NativeBackendDependencySource::WorkspaceDependency,
),
)?
.with_default_features(false),
),
),
(
"pollster".to_string(),
GeneratedDependencyValue::simple("0.4"),
),
(
"waterui-core".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-core",
&[],
NativeBackendDependencySource::WorkspaceSubdir("core"),
),
)?
.with_default_features(false),
),
),
(
"waterui-preview".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-preview",
&[],
NativeBackendDependencySource::WorkspaceSubdir(
"components/devtools/preview/runtime",
),
),
)?
.with_default_features(false),
),
),
(
"waterui-preview-protocol".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-preview-protocol",
&[],
NativeBackendDependencySource::WorkspaceSubdir(
"components/devtools/preview/protocol",
),
),
)?
.with_default_features(false),
),
),
("serde".to_string(), GeneratedDependencyValue::simple("1")),
(
"serde_json".to_string(),
GeneratedDependencyValue::simple("1"),
),
(
"waterui-mcp".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-mcp",
&[],
NativeBackendDependencySource::WorkspaceSubdir(
"components/devtools/mcp/server",
),
),
)?
.with_default_features(false)
.with_optional(),
),
),
(
"waterui-testing".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-testing",
&[],
NativeBackendDependencySource::WorkspaceSubdir("testing"),
),
)?
.with_default_features(false),
),
),
(
"hydrolysis-m3".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"hydrolysis-m3",
&[],
NativeBackendDependencySource::WorkspaceDependency,
),
)?
.with_default_features(false),
),
),
]);
if requires_cef(ctx) {
dependencies.insert(
"waterui-browser-cef".to_string(),
GeneratedDependencyValue::detailed(super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-browser-cef",
&[],
NativeBackendDependencySource::WorkspaceSubdir(
"components/platform/browser-cef",
),
),
)?),
);
}
Ok(dependencies)
}
fn wasm_target_dependencies(
ctx: &TemplateContext,
) -> io::Result<BTreeMap<String, GeneratedDependencyValue>> {
Ok(BTreeMap::from([
(
"hydrolysis".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"hydrolysis",
&["web"],
NativeBackendDependencySource::WorkspaceDependency,
),
)?
.with_default_features(false),
),
),
(
"wasm-bindgen".to_string(),
GeneratedDependencyValue::simple("0.2"),
),
(
"hydrolysis-m3".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"hydrolysis-m3",
&[],
NativeBackendDependencySource::WorkspaceDependency,
),
)?
.with_default_features(false),
),
),
]))
}
}
pub mod esp32 {
use super::{Path, TemplateContext, TemplateNamespace, embedded, io, scaffold_dir};
pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
scaffold_dir(TemplateNamespace::Esp32, &embedded::ESP32, base_dir, ctx).await
}
}
pub mod tui {
use cargo_toml::{Dependency, DependencyDetail, Manifest, Package, Workspace};
use super::{
NativeBackendDependencySource, NativeBackendDependencySpec, Path, PathBuf, TemplateContext,
TemplateNamespace, embedded, io, normalize_path_for_config, scaffold_dir,
write_file_if_changed,
};
use crate::build_info::TUI_BACKEND;
const EXTRA_PATCHES: &[(&str, &str)] = &[
("waterui-internal", "src"),
("waterui-navigation", "components/foundation/navigation"),
];
pub async fn scaffold(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx, package_name).await?;
scaffold_dir(TemplateNamespace::Tui, &embedded::TUI, base_dir, ctx).await
}
pub fn rendered_outputs(
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<Vec<(PathBuf, Vec<u8>)>> {
let mut outputs = super::render_dir_outputs(TemplateNamespace::Tui, &embedded::TUI, ctx)?;
outputs.push((
PathBuf::from("Cargo.toml"),
render_cargo_toml(ctx, package_name)?.into_bytes(),
));
Ok(outputs)
}
async fn generate_cargo_toml(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
let toml_string = render_cargo_toml(ctx, package_name)?;
super::fs::create_dir_all(base_dir).await?;
write_file_if_changed(&base_dir.join("Cargo.toml"), toml_string.as_bytes()).await
}
fn render_cargo_toml(ctx: &TemplateContext, package_name: &str) -> io::Result<String> {
let mut manifest = Manifest::<()>::default();
let mut package = Package::new(package_name.to_string(), super::cargo_semver("0.1.0"));
package.edition = cargo_toml::Inheritable::Set(cargo_toml::Edition::E2024);
manifest.package = Some(package);
manifest.profile = super::generated_profiles();
manifest.dependencies.insert(
ctx.crate_name.to_string(),
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(ctx.project_root_relative_path()),
..Default::default()
})),
);
manifest.dependencies.insert(
"waterui".to_string(),
Dependency::Detailed(Box::new(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui",
&[],
NativeBackendDependencySource::WateruiRoot,
),
)?
.with_default_features(false)
.into_cargo(),
)),
);
manifest
.dependencies
.insert("waterui-tui".to_string(), tui_backend_dependency(ctx)?);
manifest.workspace = Some(Workspace::default());
manifest.patch = tui_patch_set(ctx)?;
toml::to_string_pretty(&manifest)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
fn tui_backend_dependency(ctx: &TemplateContext) -> io::Result<Dependency> {
if let Some(path) = std::env::var_os("WATERUI_TUI_PATH") {
return Ok(path_dependency(&dunce::canonicalize(path)?));
}
if let Some(root) = ctx
.waterui_workspace_root()
.and_then(|root| dunce::canonicalize(root).ok())
&& let Some(sibling) = root.parent().map(|parent| parent.join("water-rs/tui"))
&& sibling.join("Cargo.toml").is_file()
{
return Ok(path_dependency(&sibling));
}
Ok(Dependency::Detailed(Box::new(DependencyDetail {
git: Some(TUI_BACKEND.repository_url.to_string()),
rev: Some(TUI_BACKEND.revision.to_string()),
..Default::default()
})))
}
fn path_dependency(path: &Path) -> Dependency {
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(normalize_path_for_config(path)),
..Default::default()
}))
}
fn tui_patch_set(ctx: &TemplateContext) -> io::Result<cargo_toml::PatchSet> {
let waterui_root = ctx.waterui_workspace_root();
let mut patch = match &waterui_root {
Some(root) => super::collect_workspace_patches(root)?,
None => ctx.framework.patches(),
};
let crates_io = patch.entry("crates-io".to_string()).or_default();
if let Some(root) = &waterui_root {
for &(name, subdir) in EXTRA_PATCHES {
crates_io
.entry(name.to_string())
.or_insert_with(|| path_dependency_patch(&root.join(subdir)));
}
} else if let Some((repository, revision)) = ctx.framework.git_source() {
for &(name, _) in EXTRA_PATCHES {
crates_io.entry(name.to_string()).or_insert_with(|| {
Dependency::Detailed(Box::new(DependencyDetail {
git: Some(repository.to_string()),
rev: Some(revision.to_string()),
..Default::default()
}))
});
}
}
Ok(patch)
}
fn path_dependency_patch(path: &Path) -> Dependency {
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(normalize_path_for_config(path)),
..Default::default()
}))
}
}
async fn propagate_workspace_patches(
manifest: &mut cargo_toml::Manifest<()>,
project_root: &Path,
) -> io::Result<()> {
let project_root = project_root.to_path_buf();
let patches = smol::unblock(move || collect_workspace_patches(&project_root)).await?;
manifest.patch = patches;
Ok(())
}
pub fn collect_workspace_patches(project_root: &Path) -> io::Result<cargo_toml::PatchSet> {
let Some((workspace_dir, source)) = find_workspace_manifest(project_root)? else {
return Ok(cargo_toml::PatchSet::default());
};
let mut patches = source.patch;
for deps in patches.values_mut() {
for dependency in deps.values_mut() {
if let cargo_toml::Dependency::Detailed(detail) = dependency
&& let Some(path) = detail.path.take()
{
detail.path = Some(workspace_dir.join(path).to_string_lossy().into_owned());
}
}
}
patch_framework_git_source(&mut patches);
Ok(patches)
}
fn framework_git_source() -> String {
env!("WATERUI_FRAMEWORK_REPOSITORY")
.trim_end_matches(".git")
.to_string()
}
fn patch_framework_git_source(patches: &mut cargo_toml::PatchSet) {
let path_entries: Vec<(String, String)> = patches
.get("crates-io")
.into_iter()
.flatten()
.filter_map(|(name, dependency)| match dependency {
cargo_toml::Dependency::Detailed(detail) => detail
.path
.as_ref()
.map(|path| (name.clone(), path.clone())),
_ => None,
})
.collect();
if path_entries.is_empty() {
return;
}
let repository_source = patches.entry(framework_git_source()).or_default();
for (name, path) in path_entries {
repository_source.insert(
name,
cargo_toml::Dependency::Detailed(Box::new(cargo_toml::DependencyDetail {
path: Some(path),
..cargo_toml::DependencyDetail::default()
})),
);
}
}
fn generated_crate_patches(ctx: &TemplateContext) -> io::Result<cargo_toml::PatchSet> {
ctx.waterui_workspace_root().map_or_else(
|| Ok(ctx.framework.patches()),
|root| collect_workspace_patches(&root),
)
}
pub fn local_framework_patches(
project_root: &Path,
waterui_path: &Path,
) -> io::Result<cargo_toml::PatchSet> {
let manifest =
cargo_toml::Manifest::from_path(project_root.join(waterui_path).join("Cargo.toml"))
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let mut patches = manifest.patch;
for deps in patches.values_mut() {
for dependency in deps.values_mut() {
if let cargo_toml::Dependency::Detailed(detail) = dependency
&& let Some(path) = detail.path.take()
{
detail.path = Some(normalize_path_for_config(&waterui_path.join(path)));
}
}
}
patch_framework_git_source(&mut patches);
Ok(patches)
}
fn local_checkout_dependency(
ctx: &TemplateContext,
crate_name: &str,
) -> io::Result<GeneratedDependencyDetail> {
let waterui_path = ctx
.waterui_path
.as_ref()
.expect("local_checkout_dependency requires a pinned waterui_path");
let root = ctx.waterui_workspace_root().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"relative waterui_path `{}` has no project root to read the checkout manifest from",
waterui_path.display()
),
)
})?;
let manifest_path = root.join("Cargo.toml");
let manifest = cargo_toml::Manifest::from_path(&manifest_path)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let declared = manifest
.workspace
.as_ref()
.and_then(|workspace| workspace.dependencies.get(crate_name));
let patch = manifest
.patch
.get("crates-io")
.and_then(|deps| deps.get(crate_name));
let resolved = match declared {
Some(dependency) if registry_sourced(dependency) => patch.unwrap_or(dependency),
Some(dependency) => dependency,
None => patch.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"`{crate_name}` is declared in neither [workspace.dependencies] nor \
[patch.crates-io] of {}",
manifest_path.display()
),
)
})?,
};
checkout_dependency_detail(ctx, waterui_path, crate_name, resolved)
}
fn registry_sourced(dependency: &cargo_toml::Dependency) -> bool {
match dependency {
cargo_toml::Dependency::Simple(_) => true,
cargo_toml::Dependency::Detailed(_) => dependency.is_crates_io(),
cargo_toml::Dependency::Inherited(_) => false,
}
}
fn checkout_dependency_detail(
ctx: &TemplateContext,
waterui_path: &Path,
crate_name: &str,
dependency: &cargo_toml::Dependency,
) -> io::Result<GeneratedDependencyDetail> {
match dependency {
cargo_toml::Dependency::Simple(version) => Ok(GeneratedDependencyDetail {
version: Some(version.to_string()),
..GeneratedDependencyDetail::default()
}),
cargo_toml::Dependency::Detailed(detail) => {
if detail.registry.is_some()
|| detail.registry_index.is_some()
|| !detail.unstable.is_empty()
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"`{crate_name}` in the `WaterUI` checkout manifest uses a dependency \
source generated manifests cannot express"
),
));
}
Ok(GeneratedDependencyDetail {
version: detail.version.as_ref().map(ToString::to_string),
path: detail.path.as_ref().map(|path| {
compute_native_backend_dependency_path(ctx, waterui_path, Some(path))
}),
git: detail.git.clone(),
rev: detail.rev.clone(),
branch: detail.branch.clone(),
tag: detail.tag.clone(),
package: detail.package.clone(),
default_features: (!detail.default_features).then_some(false),
features: detail.features.clone(),
optional: detail.optional,
})
}
cargo_toml::Dependency::Inherited(_) => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"`{crate_name}` is a `workspace = true` dependency in the `WaterUI` checkout's \
root manifest, which has no parent workspace to inherit from"
),
)),
}
}
pub fn project_patches(
project_root: &Path,
manifest: &crate::project::Manifest,
) -> io::Result<cargo_toml::PatchSet> {
match (&manifest.framework, &manifest.waterui_path) {
(Some(framework), _) => Ok(framework.patches()),
(None, Some(waterui_path)) => {
local_framework_patches(project_root, Path::new(waterui_path))
}
(None, None) => Ok(cargo_toml::PatchSet::default()),
}
}
pub fn patch_manifest_dir(project_root: &Path) -> io::Result<PathBuf> {
Ok(find_workspace_manifest(project_root)?
.map_or_else(|| project_root.to_path_buf(), |(dir, _)| dir))
}
fn find_workspace_manifest(
project_root: &Path,
) -> io::Result<Option<(PathBuf, cargo_toml::Manifest)>> {
let mut fallback = None;
for dir in project_root.ancestors() {
let manifest_path = dir.join("Cargo.toml");
if !manifest_path.is_file() {
continue;
}
let manifest = cargo_toml::Manifest::from_path(&manifest_path)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
if manifest.workspace.is_some() {
return Ok(Some((dir.to_path_buf(), manifest)));
}
if fallback.is_none() && dir == project_root {
fallback = Some((dir.to_path_buf(), manifest));
}
}
Ok(fallback)
}
pub mod ffi {
use cargo_toml::{Dependency, DependencyDetail, Manifest, Package, Product, Workspace};
use super::{
NativeBackendDependencySource, NativeBackendDependencySpec, Path, TemplateContext,
TemplateNamespace, cargo_semver, embedded, fs, generated_dependency_from_spec,
generated_profiles, io, scaffold_dir, write_file_if_changed,
};
pub async fn scaffold(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx, package_name).await?;
scaffold_dir(TemplateNamespace::Ffi, &embedded::FFI, base_dir, ctx).await
}
pub const LOCKFILE_SEED: &str = "Cargo.lock.seed";
pub async fn seed_lockfile(base_dir: &Path, project_lockfile: &Path) -> io::Result<()> {
let seed = match fs::read(project_lockfile).await {
Ok(seed) => seed,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
tracing::debug!(
lockfile = %project_lockfile.display(),
"project has no lockfile; the managed FFI crate resolves on its own"
);
return Ok(());
}
Err(error) => return Err(error),
};
let seed_copy = base_dir.join(LOCKFILE_SEED);
let managed_lockfile = base_dir.join("Cargo.lock");
let seeded_from = match fs::read(&seed_copy).await {
Ok(previous) => previous == seed,
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(error) => return Err(error),
};
if seeded_from && managed_lockfile.exists() {
return Ok(());
}
tracing::debug!(
lockfile = %project_lockfile.display(),
"seeding the managed FFI crate's Cargo.lock from the project lockfile"
);
fs::write(&managed_lockfile, &seed).await?;
fs::write(&seed_copy, &seed).await
}
async fn generate_cargo_toml(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
let mut manifest = Manifest::<()>::default();
let mut package = Package::new(package_name.to_string(), cargo_semver("0.1.0"));
package.edition = cargo_toml::Inheritable::Set(cargo_toml::Edition::E2024);
package.autobins = false;
manifest.package = Some(package);
manifest.profile = generated_profiles();
manifest.lib = Some(Product {
crate_type: vec!["staticlib".to_string(), "cdylib".to_string()],
..Default::default()
});
if ctx.cef_runtime_enabled() {
manifest.bin.push(Product {
name: Some(crate::project_model::project_types::cef_helper_binary_name(
package_name,
)),
path: Some("src/bin/waterui-cef-helper.rs".to_string()),
..Default::default()
});
}
manifest.dependencies.insert(
ctx.crate_name.to_string(),
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(ctx.project_root_relative_path()),
..Default::default()
})),
);
manifest
.features
.insert("dev".to_string(), vec![format!("{}/dev", ctx.crate_name)]);
for (name, source) in [
("waterui", NativeBackendDependencySource::WateruiRoot),
(
"waterui-ffi",
NativeBackendDependencySource::WorkspaceSubdir("ffi"),
),
] {
let dependency = generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(name, &[], source),
)?
.with_default_features(false)
.into_cargo();
manifest
.dependencies
.insert(name.to_owned(), Dependency::Detailed(Box::new(dependency)));
}
manifest.patch = match ctx.waterui_workspace_root() {
Some(root) => smol::unblock(move || super::collect_workspace_patches(&root)).await?,
None => ctx.framework.patches(),
};
manifest.workspace = Some(Workspace {
members: super::preview_module_members(base_dir).await?,
..Workspace::default()
});
if let Some(project_root) = &ctx.project_root_path {
super::propagate_workspace_patches(&mut manifest, project_root).await?;
}
let toml_string = toml::to_string_pretty(&manifest)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
fs::create_dir_all(base_dir).await?;
write_file_if_changed(&base_dir.join("Cargo.toml"), toml_string.as_bytes()).await?;
Ok(())
}
}
async fn preview_module_members(ffi_crate_dir: &Path) -> io::Result<Vec<String>> {
use smol::stream::StreamExt as _;
let modules_root = ffi_crate_dir.join(PREVIEW_MODULES_DIR);
let mut entries = match fs::read_dir(&modules_root).await {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error),
};
let mut members = Vec::new();
while let Some(entry) = entries.next().await {
let entry = entry?;
if entry.path().join("Cargo.toml").is_file()
&& let Some(name) = entry.file_name().to_str()
{
members.push(format!("{PREVIEW_MODULES_DIR}/{name}"));
}
}
members.sort();
Ok(members)
}
pub const PREVIEW_MODULES_DIR: &str = "modules";
pub mod root {
use super::{
GeneratedCargoManifest, GeneratedDependencyDetail, GeneratedTargetSection,
GeneratedWorkspaceSection, Path, TemplateContext, TemplateNamespace, embedded, fs, io,
render_scaffold_template, write_file_if_changed, write_generated_cargo_toml,
};
use std::collections::BTreeMap;
static ROOT_TEMPLATES: &[(&str, &str)] = &[(".gitignore.tpl", ".gitignore")];
pub async fn scaffold(
base_dir: &Path,
ctx: &TemplateContext,
assets_dir: &str,
) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx).await?;
let assets_readme = format!("{assets_dir}/README.md");
let lib_template = if ctx.web_frontend_arg.is_some() {
"web_lib.rs.tpl"
} else {
"lib.rs.tpl"
};
let templates: Vec<(&str, String)> =
core::iter::once((lib_template, "src/lib.rs".to_string()))
.chain(
ROOT_TEMPLATES
.iter()
.map(|(template, dest)| (*template, (*dest).to_string())),
)
.chain(core::iter::once(("assets_readme.md.tpl", assets_readme)))
.collect();
#[cfg(feature = "svg-icons")]
let templates = {
let mut templates = templates;
templates.push(("icon.svg", format!("{assets_dir}/Icon.svg")));
templates
};
for (template_name, dest) in templates {
if let Some(file) = embedded::ROOT.get_file(template_name) {
let dest_path = base_dir.join(&dest);
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent).await?;
}
let content = file
.contents_utf8()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))?;
let rendered = render_scaffold_template(
TemplateNamespace::Root,
Path::new(template_name),
content,
ctx,
)?;
write_file_if_changed(&dest_path, rendered.as_bytes()).await?;
}
}
#[cfg(not(feature = "svg-icons"))]
{
use crate::project_model::assets::icon::{IconSource, encode_png};
let icon = encode_png(
&IconSource::default_logo()
.render(1024)
.map_err(io::Error::other)?,
)
.map_err(io::Error::other)?;
let dest = base_dir.join(format!("{assets_dir}/Icon.png"));
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(dest, icon).await?;
}
Ok(())
}
async fn generate_cargo_toml(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
let waterui_dependency = waterui_dependency(ctx)?;
let manifest = GeneratedCargoManifest {
package: super::generated_package(ctx.crate_name.as_str(), vec![ctx.author.clone()]),
lib: super::generated_lib(&["lib"]),
bins: Vec::new(),
profile: super::generated_profiles(),
features: BTreeMap::from([(
"dev".to_string(),
vec!["waterui/dynamic_linking".to_string()],
)]),
dependencies: BTreeMap::from([("waterui".to_string(), waterui_dependency.clone())]),
build_dependencies: BTreeMap::new(),
target: native_target_section(waterui_dependency, ctx.web_frontend_arg.is_some()),
workspace: GeneratedWorkspaceSection {},
patch: match &ctx.waterui_path {
Some(waterui_path) => {
let (project_root, waterui_path) =
(base_dir.to_path_buf(), waterui_path.clone());
smol::unblock(move || {
super::local_framework_patches(&project_root, &waterui_path)
})
.await?
}
None => ctx.framework.patches(),
},
};
write_generated_cargo_toml(base_dir, super::render_generated_cargo_toml(&manifest)?).await
}
fn waterui_dependency(ctx: &TemplateContext) -> io::Result<GeneratedDependencyDetail> {
let detail = ctx.waterui_path.as_ref().map_or_else(
|| GeneratedDependencyDetail::framework(ctx, "waterui"),
|waterui_path| Ok(GeneratedDependencyDetail::path(waterui_path)),
)?;
Ok(detail.with_default_features(false))
}
fn native_target_section(
waterui_dependency: GeneratedDependencyDetail,
web_frontend: bool,
) -> BTreeMap<String, GeneratedTargetSection<GeneratedDependencyDetail>> {
let mut waterui_features = vec!["assets", "media", "flow-markdown"];
if web_frontend {
waterui_features.push("webview");
}
BTreeMap::from([(
"cfg(not(any(target_arch = \"wasm32\", target_os = \"espidf\")))".to_string(),
GeneratedTargetSection {
dependencies: BTreeMap::from([(
"waterui".to_string(),
waterui_dependency.with_features(&waterui_features),
)]),
},
)])
}
}
pub mod preview {
use super::{
Path, SupportDependencyDetail, SupportDependencyValue, TemplateContext, TemplateNamespace,
dependency_path, embedded, io, scaffold_dir, write_support_cargo_toml,
};
#[must_use]
pub fn template_fingerprint() -> String {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
let mut dirs_to_process = vec![&embedded::PREVIEW];
while let Some(current_dir) = dirs_to_process.pop() {
for file in current_dir.files() {
hasher.update(file.path().to_string_lossy().as_bytes());
hasher.update(file.contents());
}
for subdir in current_dir.dirs() {
dirs_to_process.push(subdir);
}
}
hasher.update(super::generated_profiles_fingerprint().as_bytes());
hex::encode(hasher.finalize())
}
pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx).await?;
scaffold_dir(
TemplateNamespace::Preview,
&embedded::PREVIEW,
base_dir,
ctx,
)
.await
}
pub(super) async fn resolve_workspace_member_dir(
workspace_root: &Path,
package_name: &str,
) -> io::Result<std::path::PathBuf> {
let manifest = workspace_root.join("Cargo.toml");
let metadata = smol::unblock(move || {
cargo_metadata::MetadataCommand::new()
.manifest_path(&manifest)
.no_deps()
.exec()
})
.await
.map_err(io::Error::other)?;
let member = metadata
.packages
.iter()
.find(|package| package.name == package_name)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!(
"`{package_name}` is not a member of the waterui workspace at {}",
workspace_root.display()
),
)
})?;
member
.manifest_path
.as_std_path()
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| {
io::Error::other(format!(
"failed to derive crate directory for `{package_name}`"
))
})
}
async fn generate_cargo_toml(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
use std::collections::BTreeMap;
let mut dependencies = BTreeMap::new();
if let Some(waterui_path) = &ctx.waterui_path {
dependencies.insert(
"waterui".to_string(),
SupportDependencyValue::Detailed(SupportDependencyDetail {
package: None,
version: None,
path: Some(super::normalize_path_for_config(waterui_path)),
git: None,
rev: None,
default_features: Some(false),
features: Vec::new(),
}),
);
let preview_path =
resolve_workspace_member_dir(waterui_path, "waterui-preview").await?;
dependencies.insert(
"waterui-preview".to_string(),
dependency_path(&preview_path),
);
} else {
dependencies.insert(
"waterui".to_string(),
SupportDependencyValue::Detailed(
super::GeneratedDependencyDetail::framework(ctx, "waterui")?
.with_default_features(false)
.into(),
),
);
dependencies.insert(
"waterui-preview".to_string(),
SupportDependencyValue::Detailed(
super::GeneratedDependencyDetail::framework(ctx, "waterui-preview")?.into(),
),
);
}
let (app_crate_name, app_path) = ctx.preview_app_dependency.as_ref().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"Preview support runtime requires a user crate dependency",
)
})?;
dependencies.insert(
"waterui-preview-app".to_string(),
SupportDependencyValue::Detailed(SupportDependencyDetail {
package: Some(app_crate_name.to_string()),
version: None,
path: Some(super::normalize_path_for_config(app_path)),
git: None,
rev: None,
default_features: None,
features: vec!["dev".to_string()],
}),
);
if !ctx
.preview_runtime_features
.iter()
.any(|feature| feature == "dynamic_linking")
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Preview support runtime features must include waterui/dynamic_linking",
));
}
let features = BTreeMap::from([(
"dev".to_string(),
ctx.preview_runtime_features
.iter()
.map(|feature| format!("waterui/{feature}"))
.collect(),
)]);
write_support_cargo_toml(
base_dir,
ctx.crate_name.as_str(),
features,
dependencies,
ctx.waterui_path.as_deref(),
&ctx.framework,
)
.await
}
}
pub mod preview_ffi {
use cargo_toml::{Dependency, DependencyDetail, Manifest, Package, Product};
use super::{
Path, TemplateContext, TemplateNamespace, cargo_semver,
compute_native_backend_dependency_path, embedded, fs, io, scaffold_dir,
write_file_if_changed,
};
pub const APPLE_ABI_FEATURE: &str = "apple-preview-abi";
pub const ANDROID_ABI_FEATURE: &str = "android-preview-abi";
pub async fn scaffold(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx, package_name).await?;
scaffold_dir(
TemplateNamespace::PreviewFfi,
&embedded::PREVIEW_FFI,
base_dir,
ctx,
)
.await
}
async fn generate_cargo_toml(
base_dir: &Path,
ctx: &TemplateContext,
package_name: &str,
) -> io::Result<()> {
let mut manifest = Manifest::<()>::default();
let mut package = Package::new(package_name.to_string(), cargo_semver("0.1.0"));
package.edition = cargo_toml::Inheritable::Set(cargo_toml::Edition::E2024);
manifest.package = Some(package);
manifest.lib = Some(Product {
crate_type: vec!["dylib".to_string()],
..Default::default()
});
manifest.dependencies.insert(
ctx.crate_name.to_string(),
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(ctx.project_root_relative_path()),
features: vec!["dev".to_string()],
..Default::default()
})),
);
let ffi_dependency = ctx.waterui_path.as_ref().map_or_else(
|| {
let mut dependency = ctx.framework.dependency("waterui-ffi");
dependency.optional = true;
dependency.default_features = false;
dependency
},
|waterui_path| DependencyDetail {
path: Some(compute_native_backend_dependency_path(
ctx,
waterui_path,
Some("ffi"),
)),
optional: true,
default_features: false,
..Default::default()
},
);
manifest.dependencies.insert(
"waterui-ffi".to_string(),
Dependency::Detailed(Box::new(ffi_dependency)),
);
let preview_dependency = if let Some(waterui_path) = &ctx.waterui_path {
let waterui_root = Path::new(&compute_native_backend_dependency_path(
ctx,
waterui_path,
None,
))
.to_path_buf();
let waterui_root = if waterui_root.is_absolute() {
waterui_root
} else {
base_dir.join(waterui_root)
};
let preview_path =
super::preview::resolve_workspace_member_dir(&waterui_root, "waterui-preview")
.await?;
DependencyDetail {
path: Some(super::normalize_path_for_config(&preview_path)),
optional: true,
..Default::default()
}
} else {
let mut dependency = ctx.framework.dependency("waterui-preview");
dependency.optional = true;
dependency
};
manifest.dependencies.insert(
"waterui-preview".to_string(),
Dependency::Detailed(Box::new(preview_dependency)),
);
for (feature, ffi_feature) in [
(APPLE_ABI_FEATURE, "waterui-ffi/c-api"),
(ANDROID_ABI_FEATURE, "waterui-ffi/android-jni"),
] {
manifest.features.insert(
feature.to_string(),
vec![
"dep:waterui-ffi".to_string(),
ffi_feature.to_string(),
"dep:waterui-preview".to_string(),
],
);
}
let toml_string = toml::to_string_pretty(&manifest)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
fs::create_dir_all(base_dir).await?;
write_file_if_changed(&base_dir.join("Cargo.toml"), toml_string.as_bytes()).await?;
Ok(())
}
}
pub mod inspector {
use super::{
Path, TemplateContext, TemplateNamespace, dependency_path, embedded, io, scaffold_dir,
write_support_cargo_toml,
};
#[must_use]
pub fn template_fingerprint() -> String {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
let mut dirs_to_process = vec![&embedded::INSPECTOR];
while let Some(current_dir) = dirs_to_process.pop() {
for file in current_dir.files() {
hasher.update(file.path().to_string_lossy().as_bytes());
hasher.update(file.contents());
}
for subdir in current_dir.dirs() {
dirs_to_process.push(subdir);
}
}
hasher.update(super::generated_profiles_fingerprint().as_bytes());
hex::encode(hasher.finalize())
}
pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx).await?;
scaffold_dir(
TemplateNamespace::Inspector,
&embedded::INSPECTOR,
base_dir,
ctx,
)
.await
}
const INSPECTOR_APP_CRATE: &str = "components/devtools/inspector/app";
async fn generate_cargo_toml(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
use std::collections::BTreeMap;
let waterui_path = ctx.waterui_path.as_ref().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"Inspector support app requires a local waterui_path",
)
})?;
let inspector_app_path = waterui_path.join(INSPECTOR_APP_CRATE);
if !inspector_app_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"Inspector support app requires {} (missing {INSPECTOR_APP_CRATE})",
waterui_path.display()
),
));
}
let mut dependencies = BTreeMap::new();
dependencies.insert("waterui".to_string(), dependency_path(waterui_path));
dependencies.insert(
"waterui-ffi".to_string(),
dependency_path(&waterui_path.join("ffi")),
);
dependencies.insert(
"waterui-inspector-app".to_string(),
dependency_path(&inspector_app_path),
);
let features = BTreeMap::from([(
"dev".to_string(),
vec!["waterui/dynamic_linking".to_string()],
)]);
write_support_cargo_toml(
base_dir,
ctx.crate_name.as_str(),
features,
dependencies,
ctx.waterui_path.as_deref(),
&ctx.framework,
)
.await
}
#[cfg(test)]
mod tests {
#[test]
#[ignore = "clones the pinned framework revision"]
fn the_inspector_app_crate_path_exists() {
let checkout = crate::pinned_framework::checkout();
let crate_path = checkout.join(super::INSPECTOR_APP_CRATE);
assert!(
crate_path.join("Cargo.toml").is_file(),
"inspector app crate is not at {}",
crate_path.display()
);
}
#[test]
fn the_generated_app_declares_the_feature_its_ffi_scaffold_requires() {
let generated = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/project_model/templates.rs");
let source = std::fs::read_to_string(generated).expect("the module is readable");
let inspector = source
.split("pub mod inspector {")
.nth(1)
.expect("the inspector template module exists");
assert!(
inspector.contains("\"dev\".to_string()"),
"the inspector support app is generated without a `dev` feature"
);
}
}
}
#[cfg(test)]
mod template_digest_tests {
#[test]
fn scaffold_template_digest_is_stable() {
assert_eq!(
super::scaffold_template_digest(),
super::scaffold_template_digest()
);
}
#[test]
fn scaffold_template_digest_covers_template_contents() {
let digest = super::scaffold_template_digest();
assert_eq!(digest.len(), 16, "digest must be a 16-char hex prefix");
let hydrolysis_preview = super::embedded::HYDROLYSIS
.get_file("src/preview_runtime.rs.tpl")
.expect("the hydrolysis preview runtime template must be embedded");
assert!(
!hydrolysis_preview.contents().is_empty(),
"the template the digest is meant to track must be non-empty"
);
}
}