use std::{
io,
path::{Path, PathBuf},
};
use crate::build_info::{
ANDROID_BACKEND, APPLE_BACKEND, DEW_VERSION, GTK_BACKEND_VERSION, HYDROLYSIS_M3_VERSION,
HYDROLYSIS_VERSION, PREVIEW_PROTOCOL_VERSION, PREVIEW_VERSION, WATERUI_BROWSER_CEF_VERSION,
WATERUI_CORE_VERSION, WATERUI_FFI_VERSION, WATERUI_VERSION,
};
use askama::Template;
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,
] {
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
})
}
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 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, 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 use_remote_dev_backend: bool,
pub waterui_path: Option<PathBuf>,
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 esp32: Esp32TemplateEntry,
}
impl TemplateContext {
#[must_use]
pub fn for_create_options(
options: &crate::project::CreateOptions,
crate_name: CrateName,
) -> 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: waterui_path
.as_ref()
.map(|path| path.join("backends/android")),
use_remote_dev_backend: waterui_path.is_none(),
waterui_path,
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,
esp32: Esp32TemplateEntry::default(),
}
}
#[must_use]
pub fn for_project_manifest(
manifest: &crate::project::Manifest,
crate_name: CrateName,
app_name: impl Into<String>,
) -> Self {
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: None,
use_remote_dev_backend: manifest.waterui_path.is_none(),
waterui_path: manifest.waterui_path.as_ref().map(PathBuf::from),
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,
esp32: Esp32TemplateEntry::default(),
}
}
#[must_use]
pub fn for_support_playground(
app_display_name: impl Into<String>,
app_name: impl Into<String>,
crate_name: CrateName,
bundle_identifier: BundleIdentifier,
waterui_path: Option<PathBuf>,
accessory: bool,
preview_runtime_fingerprint: Option<String>,
) -> Self {
let android_backend_path = waterui_path
.as_ref()
.map(|waterui_path| waterui_path.join("backends/android"));
let project_root_path = waterui_path.clone();
Self {
app_display_name: app_display_name.into(),
app_name: app_name.into(),
crate_name,
bundle_identifier,
author: String::new(),
android_backend_path,
use_remote_dev_backend: waterui_path.is_none(),
waterui_path,
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,
esp32: Esp32TemplateEntry::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 {
matches!(self.browser.engine, Some(ResolvedWebViewBackend::Cef))
}
#[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 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()
.unwrap_or_else(|error| panic!("{error}"))
.to_string()
}
#[must_use]
#[expect(
clippy::unused_self,
reason = "Askama invokes template context values through instance methods"
)]
pub const fn android_min_api_level(&self) -> u32 {
crate::android::ANDROID_MIN_API_LEVEL
}
#[must_use]
pub fn android_backend_path(&self) -> String {
if self.use_remote_dev_backend {
return String::new();
}
self.compute_android_backend_path().unwrap_or_else(|| {
panic!(
"TemplateContext missing local Android backend path: \
use_remote_dev_backend=false requires waterui_path or android_backend_path"
)
})
}
#[must_use]
#[allow(clippy::unused_self)]
pub fn android_remote_backend_dependency(&self) -> String {
jitpack_dependency_coordinate(ANDROID_BACKEND.repository_url, ANDROID_BACKEND.commit)
}
#[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 compute_relative_backend_path(&self, backend_subdir: &str) -> Option<String> {
let waterui_path = self.waterui_path.as_ref()?;
if waterui_path.is_absolute() {
let absolute_backend_path = waterui_path.join("backends").join(backend_subdir);
return Some(normalize_path_for_config(&absolute_backend_path));
}
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(waterui_path)
.join("backends")
.join(backend_subdir);
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 Some(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(waterui_path);
backend_path.push("backends");
backend_path.push(backend_subdir);
Some(normalize_path_for_config(&backend_path))
}
fn compute_apple_backend_path(&self) -> Option<String> {
self.compute_relative_backend_path("apple")
}
fn compute_android_backend_path(&self) -> Option<String> {
self.android_backend_path
.as_ref()
.map(|path| normalize_path_for_config(path))
.or_else(|| self.compute_relative_backend_path("android"))
}
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(APPLE_BACKEND.repository_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 swift_package_reference_section(&self) -> String {
const PACKAGE_ID: &str = "D01867782E6C82CA00802E96";
let repository_name = github_repository_name(APPLE_BACKEND.repository_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\tkind = revision;\n\
\t\t\t\trevision = \"{}\";\n\
\t\t\t}};\n\
\t\t}};\n\
/* End XCRemoteSwiftPackageReference section */",
APPLE_BACKEND.repository_url,
APPLE_BACKEND.commit,
)
},
|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 */"
)
},
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TemplateNamespace {
Apple,
Android,
Ffi,
Gtk4,
Hydrolysis,
Esp32,
Inspector,
Preview,
PreviewFfi,
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::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, commit: &str) -> String {
let (owner, repo) = github_repository_owner_and_name(repository_url);
format!("com.github.{owner}:{repo}:{commit}")
}
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)
.render()
.map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to render template {display_path}: {error}"),
)
}),
$(
$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_path: Option<String>,
core_path: Option<String>,
dew_version: &'static str,
waterui_version: &'static str,
opt_level_literal: String,
}
impl Esp32CargoTomlTemplate {
fn from_ctx(ctx: &TemplateContext) -> Self {
let dew_path = ctx.waterui_path.as_ref().map(|waterui_path| {
compute_native_backend_dependency_path(
ctx,
waterui_path,
NativeBackendDependencyPathKind::BackendsSubdir("dew"),
)
});
let core_path = ctx.waterui_path.as_ref().map(|waterui_path| {
compute_native_backend_dependency_path(
ctx,
waterui_path,
NativeBackendDependencyPathKind::WorkspaceSubdir("core"),
)
});
Self {
package_name: ctx.crate_name.with_suffix("esp32").to_string(),
app_crate_name: ctx.crate_name.to_string(),
app_crate_path: ctx.project_root_relative_path(),
dew_path,
core_path,
dew_version: DEW_VERSION,
waterui_version: WATERUI_VERSION,
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"),
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"),
FfiLibTemplate => (Ffi, "src/templates/ffi/src/lib.rs.tpl"),
Gtk4MainTemplate => (Gtk4, "src/templates/gtk4/src/main.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"),
HydrolysisWebIndexTemplate => (Hydrolysis, "src/templates/hydrolysis/web/index.html.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"),
}
#[cfg(test)]
mod tests {
use super::{
ANDROID_BACKEND, APPLE_BACKEND, BrowserTemplateContext, Esp32TemplateEntry,
GTK_BACKEND_VERSION, HYDROLYSIS_M3_VERSION, PREVIEW_PROTOCOL_VERSION, PREVIEW_VERSION,
ResolvedWebViewBackend, TemplateContext, TemplateNamespace, WATERUI_BROWSER_CEF_VERSION,
WATERUI_CORE_VERSION, embedded, jitpack_dependency_coordinate, normalize_path_for_config,
preview_ffi, render_scaffold_template,
};
use crate::project_types::{BundleIdentifier, CrateName};
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,
use_remote_dev_backend: waterui_path.is_none(),
waterui_path,
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,
esp32: Esp32TemplateEntry::default(),
}
}
fn app_ctx() -> TemplateContext {
ctx(None, None, None, crate::project::PackageType::App)
}
fn playground_ctx() -> TemplateContext {
TemplateContext::for_support_playground(
"WaterUIApp",
"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("../..")),
false,
None,
)
.with_backend_project_path(PathBuf::from("managed_backends/apple"))
}
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.esp32 = Esp32TemplateEntry::new(Esp32Chip::Esp32S3, 410, 502, 16);
let mut c3 = app_ctx();
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!"));
}
#[test]
fn relative_waterui_path_produces_clean_relative_backend_path() {
let ctx = ctx(
Some(PathBuf::from("../..")),
Some(PathBuf::from("managed_backends/apple")),
None,
crate::project::PackageType::App,
);
let path = ctx
.compute_relative_backend_path("apple")
.expect("expected relative backend path");
assert_eq!(path, "../../../../backends/apple");
assert!(!path.contains("//"));
}
#[test]
fn absolute_waterui_path_is_used_directly() {
let abs = if cfg!(windows) {
PathBuf::from(r"C:\waterui")
} else {
PathBuf::from("/waterui")
};
let ctx = ctx(
Some(abs),
Some(PathBuf::from("apple")),
None,
crate::project::PackageType::App,
);
let path = ctx
.compute_relative_backend_path("apple")
.expect("expected backend path");
let expected = if cfg!(windows) {
"C:/waterui/backends/apple"
} else {
"/waterui/backends/apple"
};
assert_eq!(path, expected);
}
#[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 ctx = ctx(
Some(PathBuf::from("../waterui")),
Some(backend_project_path.clone()),
Some(project_root.clone()),
crate::project::PackageType::Playground,
);
let path = ctx
.compute_relative_backend_path("apple")
.expect("expected backend path");
let expected_backend_path = pathdiff::diff_paths(
project_root.join("../waterui").join("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(APPLE_BACKEND.repository_url));
assert!(rendered.contains(APPLE_BACKEND.commit));
assert!(rendered.contains("kind = revision;"));
}
#[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_commit() {
let ctx = app_ctx();
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 = 26"));
assert!(rendered.contains(&jitpack_dependency_coordinate(
ANDROID_BACKEND.repository_url,
ANDROID_BACKEND.commit,
)));
}
#[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("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_uses_embedded_workspace_version() {
let ctx = app_ctx();
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");
assert!(cargo_toml.contains(&format!("version = \"{GTK_BACKEND_VERSION}\"")));
assert!(!cargo_toml.contains("webview-default"));
}
#[test]
fn generated_native_backends_only_bridge_the_platform_engine_when_no_engine_is_linked() {
let gtk_ctx = app_ctx().with_webview_enabled(true);
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 gtk_wpe_ctx = app_ctx()
.with_webview_enabled(true)
.with_browser_engine(Some(ResolvedWebViewBackend::Wpe));
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(PREVIEW_VERSION),
);
assert_eq!(
native_dependencies["waterui-preview-protocol"]["version"].as_str(),
Some(PREVIEW_PROTOCOL_VERSION),
);
assert_eq!(
native_dependencies["waterui-core"]["version"].as_str(),
Some(WATERUI_CORE_VERSION),
);
assert_eq!(
native_dependencies["hydrolysis-m3"]["version"].as_str(),
Some(HYDROLYSIS_M3_VERSION),
);
assert_eq!(
native_dependencies["waterui-browser-cef"]["version"].as_str(),
Some(WATERUI_BROWSER_CEF_VERSION),
);
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-cef-helper")
&& bin["path"].as_str() == Some("src/bin/waterui-cef-helper.rs")
}));
}
#[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(&format!("version = \"{PREVIEW_VERSION}\"")));
assert!(cargo_toml.contains("default-features = false"));
let manifest = cargo_toml
.parse::<toml::Table>()
.expect("preview Cargo.toml should parse");
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("waterui-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 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 = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("CLI crate should be inside the WaterUI workspace")
.to_path_buf();
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 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)
}
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 {
Simple(String),
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(rename = "default-features", skip_serializing_if = "Option::is_none")]
default_features: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
features: Vec<String>,
}
fn generated_dev_profile() -> 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));
cargo_toml::Profiles {
dev: Some(dev),
..Default::default()
}
}
fn generated_dev_profile_fingerprint() -> String {
toml::to_string(&generated_dev_profile()).expect("generated dev profile 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>,
) -> io::Result<()> {
let patch = match runtime_root {
Some(root) => {
let root = root.to_path_buf();
smol::unblock(move || collect_workspace_patches(&root)).await?
}
None => cargo_toml::PatchSet::default(),
};
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_dev_profile(),
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 NativeBackendDependencyPathKind<'a> {
WateruiRoot,
WorkspaceSubdir(&'a str),
BackendsSubdir(&'a str),
}
#[derive(Clone, Copy)]
struct NativeBackendDependencySpec<'a> {
crate_name: &'a str,
version: &'a str,
features: &'a [&'a str],
path_kind: Option<NativeBackendDependencyPathKind<'a>>,
}
impl<'a> NativeBackendDependencySpec<'a> {
const fn new(
crate_name: &'a str,
version: &'a str,
features: &'a [&'a str],
path_kind: Option<NativeBackendDependencyPathKind<'a>>,
) -> Self {
Self {
crate_name,
version,
features,
path_kind,
}
}
}
fn compute_native_backend_dependency_path(
ctx: &TemplateContext,
waterui_path: &Path,
path_kind: NativeBackendDependencyPathKind<'_>,
) -> String {
if waterui_path.is_absolute() {
let absolute_path = match path_kind {
NativeBackendDependencyPathKind::WateruiRoot => waterui_path.to_path_buf(),
NativeBackendDependencyPathKind::WorkspaceSubdir(subdir) => waterui_path.join(subdir),
NativeBackendDependencyPathKind::BackendsSubdir(subdir) => {
waterui_path.join("backends").join(subdir)
}
};
return normalize_path_for_config(&absolute_path);
}
let project_relative_root = PathBuf::from(ctx.project_root_relative_path());
let relative_path = match path_kind {
NativeBackendDependencyPathKind::WateruiRoot => project_relative_root.join(waterui_path),
NativeBackendDependencyPathKind::WorkspaceSubdir(subdir) => {
project_relative_root.join(waterui_path).join(subdir)
}
NativeBackendDependencyPathKind::BackendsSubdir(subdir) => project_relative_root
.join(waterui_path)
.join("backends")
.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_dev_profile();
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 {
let features = dependency
.features
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>();
if let Some(waterui_path) = &ctx.waterui_path
&& let Some(path_kind) = dependency.path_kind
{
let dependency_path =
compute_native_backend_dependency_path(ctx, waterui_path, path_kind);
manifest.dependencies.insert(
dependency.crate_name.to_string(),
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(dependency_path),
features,
..Default::default()
})),
);
continue;
}
manifest.dependencies.insert(
dependency.crate_name.to_string(),
Dependency::Detailed(Box::new(DependencyDetail {
version: Some(cargo_version_req(dependency.version)),
features,
..Default::default()
})),
);
}
manifest.workspace = Some(Workspace::default());
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)),
default_features: None,
features: Vec::new(),
})
}
fn dependency_version(version: &str) -> SupportDependencyValue {
SupportDependencyValue::Simple(version.to_string())
}
#[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>,
}
#[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)]
struct GeneratedDependencyDetail {
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
path: 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 GeneratedDependencyValue {
const fn detailed(detail: GeneratedDependencyDetail) -> Self {
Self::Detailed(detail)
}
fn simple(version: &str) -> Self {
Self::Simple(version.to_string())
}
}
impl GeneratedDependencyDetail {
fn path(path: &Path) -> Self {
Self {
version: None,
path: Some(normalize_path_for_config(path)),
default_features: None,
features: Vec::new(),
}
}
fn version(version: &str) -> Self {
Self {
version: Some(version.to_string()),
path: None,
default_features: None,
features: Vec::new(),
}
}
const fn with_default_features(mut self, default_features: bool) -> Self {
self.default_features = Some(default_features);
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,
}
}
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<'_>,
) -> GeneratedDependencyDetail {
let detail = if let Some(waterui_path) = &ctx.waterui_path
&& let Some(path_kind) = spec.path_kind
{
GeneratedDependencyDetail {
version: None,
path: Some(compute_native_backend_dependency_path(
ctx,
waterui_path,
path_kind,
)),
default_features: None,
features: Vec::new(),
}
} else {
GeneratedDependencyDetail::version(spec.version)
};
detail.with_features(spec.features)
}
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, io, scaffold_dir};
#[cfg(unix)]
use super::fs;
pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
scaffold_dir(TemplateNamespace::Apple, &embedded::APPLE, base_dir, ctx).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() {
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::{
GTK_BACKEND_VERSION, NativeBackendDependencyPathKind, 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 = [NativeBackendDependencySpec::new(
"waterui-gtk",
GTK_BACKEND_VERSION,
&features,
Some(NativeBackendDependencyPathKind::BackendsSubdir("gtk")),
)];
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 = [NativeBackendDependencySpec::new(
"waterui-gtk",
GTK_BACKEND_VERSION,
&features,
Some(NativeBackendDependencyPathKind::BackendsSubdir("gtk")),
)];
write_native_backend_bin_cargo_toml(base_dir, ctx, package_name, &dependencies).await
}
}
pub mod hydrolysis {
use super::{
GeneratedBinSection, GeneratedCargoManifest, GeneratedDependencyDetail,
GeneratedDependencyValue, GeneratedTargetSection, GeneratedWorkspaceSection,
HYDROLYSIS_M3_VERSION, HYDROLYSIS_VERSION, NativeBackendDependencyPathKind,
NativeBackendDependencySpec, PREVIEW_PROTOCOL_VERSION, PREVIEW_VERSION, Path,
TemplateContext, TemplateNamespace, WATERUI_BROWSER_CEF_VERSION, WATERUI_CORE_VERSION,
WATERUI_VERSION, 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 = collect_runtime_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 collect_runtime_patches(ctx: &TemplateContext) -> io::Result<cargo_toml::PatchSet> {
ctx.waterui_workspace_root().map_or_else(
|| Ok(cargo_toml::PatchSet::default()),
|root| super::collect_workspace_patches(&root),
)
}
fn generated_manifest(
ctx: &TemplateContext,
package_name: &str,
patch: cargo_toml::PatchSet,
) -> GeneratedCargoManifest<GeneratedDependencyValue> {
let mut package = super::generated_package(package_name, Vec::new());
package.autobins = Some(false);
let mut bins = vec![GeneratedBinSection {
name: package_name.to_string(),
path: "src/main.rs".to_string(),
}];
if requires_cef(ctx) {
bins.push(GeneratedBinSection {
name: "waterui-cef-helper".to_string(),
path: "src/bin/waterui-cef-helper.rs".to_string(),
});
}
GeneratedCargoManifest {
package,
lib: super::generated_lib(&["cdylib", "rlib"]),
bins,
profile: super::generated_dev_profile(),
features: BTreeMap::from([
("waterui-preview-mode".to_string(), Vec::new()),
("waterui-preview-test-mode".to_string(), Vec::new()),
]),
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 => cargo_toml::PatchSet::default(),
};
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) -> BTreeMap<String, GeneratedDependencyValue> {
BTreeMap::from([
(
ctx.crate_name.to_string(),
GeneratedDependencyValue::detailed(GeneratedDependencyDetail {
version: None,
path: Some(ctx.project_root_relative_path()),
default_features: None,
features: Vec::new(),
}),
),
(
"waterui".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui",
WATERUI_VERSION,
&["video-gpu"],
Some(NativeBackendDependencyPathKind::WateruiRoot),
),
)
.with_default_features(false),
),
),
])
}
fn cargo_target_dependencies(
ctx: &TemplateContext,
) -> BTreeMap<String, GeneratedTargetSection<GeneratedDependencyValue>> {
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,
) -> 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_VERSION,
&hydrolysis_features,
Some(NativeBackendDependencyPathKind::BackendsSubdir(
"hydrolysis",
)),
),
)
.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",
WATERUI_CORE_VERSION,
&[],
Some(NativeBackendDependencyPathKind::WorkspaceSubdir("core")),
),
)
.with_default_features(false),
),
),
(
"waterui-preview".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-preview",
PREVIEW_VERSION,
&[],
Some(NativeBackendDependencyPathKind::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",
PREVIEW_PROTOCOL_VERSION,
&[],
Some(NativeBackendDependencyPathKind::WorkspaceSubdir(
"components/devtools/preview/protocol",
)),
),
)
.with_default_features(false),
),
),
(
"serde_json".to_string(),
GeneratedDependencyValue::simple("1"),
),
(
"waterui-testing".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"waterui-testing",
WATERUI_VERSION,
&[],
Some(NativeBackendDependencyPathKind::WorkspaceSubdir("testing")),
),
)
.with_default_features(false),
),
),
(
"hydrolysis-m3".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"hydrolysis-m3",
HYDROLYSIS_M3_VERSION,
&[],
Some(NativeBackendDependencyPathKind::BackendsSubdir(
"hydrolysis_m3",
)),
),
)
.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",
WATERUI_BROWSER_CEF_VERSION,
&[],
Some(NativeBackendDependencyPathKind::WorkspaceSubdir(
"components/platform/browser-cef",
)),
),
)),
);
}
dependencies
}
fn wasm_target_dependencies(
ctx: &TemplateContext,
) -> BTreeMap<String, GeneratedDependencyValue> {
BTreeMap::from([
(
"hydrolysis".to_string(),
GeneratedDependencyValue::detailed(
super::generated_dependency_from_spec(
ctx,
NativeBackendDependencySpec::new(
"hydrolysis",
HYDROLYSIS_VERSION,
&["web"],
Some(NativeBackendDependencyPathKind::BackendsSubdir(
"hydrolysis",
)),
),
)
.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",
HYDROLYSIS_M3_VERSION,
&[],
Some(NativeBackendDependencyPathKind::BackendsSubdir(
"hydrolysis_m3",
)),
),
)
.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
}
}
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(())
}
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());
}
}
}
Ok(patches)
}
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::{
NativeBackendDependencyPathKind, Path, TemplateContext, TemplateNamespace,
WATERUI_FFI_VERSION, WATERUI_VERSION, cargo_semver, cargo_version_req,
compute_native_backend_dependency_path, embedded, fs, generated_dev_profile, 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
}
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_dev_profile();
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("waterui-cef-helper".to_string()),
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)]);
let waterui_dependency = ctx.waterui_path.as_ref().map_or_else(
|| {
Dependency::Detailed(Box::new(DependencyDetail {
version: Some(cargo_version_req(WATERUI_VERSION)),
default_features: false,
..Default::default()
}))
},
|waterui_path| {
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(compute_native_backend_dependency_path(
ctx,
waterui_path,
NativeBackendDependencyPathKind::WateruiRoot,
)),
default_features: false,
..Default::default()
}))
},
);
manifest
.dependencies
.insert("waterui".to_string(), waterui_dependency);
let ffi_dependency = ctx.waterui_path.as_ref().map_or_else(
|| {
Dependency::Detailed(Box::new(DependencyDetail {
version: Some(cargo_version_req(WATERUI_FFI_VERSION)),
default_features: false,
..Default::default()
}))
},
|waterui_path| {
Dependency::Detailed(Box::new(DependencyDetail {
path: Some(compute_native_backend_dependency_path(
ctx,
waterui_path,
NativeBackendDependencyPathKind::WorkspaceSubdir("ffi"),
)),
default_features: false,
..Default::default()
}))
},
);
manifest
.dependencies
.insert("waterui-ffi".to_string(), ffi_dependency);
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 crate::templates::WATERUI_VERSION;
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)] = &[
("lib.rs.tpl", "src/lib.rs"),
(".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 assets_icon = format!("{assets_dir}/Icon.svg");
let templates = ROOT_TEMPLATES
.iter()
.map(|(template, dest)| (*template, (*dest).to_string()))
.chain(core::iter::once(("assets_readme.md.tpl", assets_readme)))
.chain(core::iter::once(("icon.svg", assets_icon)));
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?;
}
}
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_dev_profile(),
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),
workspace: GeneratedWorkspaceSection {},
patch: cargo_toml::PatchSet::default(),
};
write_generated_cargo_toml(base_dir, super::render_generated_cargo_toml(&manifest)?).await
}
fn waterui_dependency(ctx: &TemplateContext) -> GeneratedDependencyDetail {
ctx.waterui_path
.as_ref()
.map_or_else(
|| GeneratedDependencyDetail::version(WATERUI_VERSION),
|waterui_path| GeneratedDependencyDetail::path(waterui_path),
)
.with_default_features(false)
}
fn native_target_section(
waterui_dependency: GeneratedDependencyDetail,
) -> BTreeMap<String, GeneratedTargetSection<GeneratedDependencyDetail>> {
BTreeMap::from([(
"cfg(not(any(target_arch = \"wasm32\", target_os = \"espidf\")))".to_string(),
GeneratedTargetSection {
dependencies: BTreeMap::from([(
"waterui".to_string(),
waterui_dependency.with_features(&["assets", "media", "flow-markdown"]),
)]),
},
)])
}
}
pub mod preview {
use crate::templates::{PREVIEW_VERSION, WATERUI_VERSION};
use super::{
Path, SupportDependencyDetail, SupportDependencyValue, TemplateContext, TemplateNamespace,
dependency_path, dependency_version, 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_dev_profile_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)),
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(SupportDependencyDetail {
package: None,
version: Some(WATERUI_VERSION.to_string()),
path: None,
default_features: Some(false),
features: Vec::new(),
}),
);
dependencies.insert(
"waterui-preview".to_string(),
dependency_version(PREVIEW_VERSION),
);
}
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)),
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(),
)
.await
}
}
pub mod preview_ffi {
use cargo_toml::{Dependency, DependencyDetail, Manifest, Package, Product};
use super::{
NativeBackendDependencyPathKind, PREVIEW_VERSION, Path, TemplateContext, TemplateNamespace,
WATERUI_FFI_VERSION, cargo_semver, cargo_version_req,
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(
|| DependencyDetail {
version: Some(cargo_version_req(WATERUI_FFI_VERSION)),
optional: true,
default_features: false,
..Default::default()
},
|waterui_path| DependencyDetail {
path: Some(compute_native_backend_dependency_path(
ctx,
waterui_path,
NativeBackendDependencyPathKind::WorkspaceSubdir("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,
NativeBackendDependencyPathKind::WateruiRoot,
))
.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 {
DependencyDetail {
version: Some(cargo_version_req(PREVIEW_VERSION)),
optional: true,
..Default::default()
}
};
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_dev_profile_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(),
)
.await
}
#[cfg(test)]
mod tests {
#[test]
fn the_inspector_app_crate_path_exists() {
let repository = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("the CLI crate lives inside the repository");
let crate_path = repository.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"
);
}
}