use std::{
io,
path::{Path, PathBuf},
};
const WATERUI_VERSION: &str = "0.2";
const WATERUI_FFI_VERSION: &str = "0.2";
use include_dir::{Dir, include_dir};
use smol::fs;
fn normalize_path_for_config(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
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 ROOT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates");
}
#[derive(Debug, Clone)]
pub struct TemplateContext {
pub app_display_name: String,
pub app_name: String,
pub crate_name: String,
pub bundle_identifier: String,
pub author: String,
pub android_backend_path: Option<PathBuf>,
pub use_remote_dev_backend: bool,
pub waterui_path: Option<PathBuf>,
pub backend_project_path: Option<PathBuf>,
pub android_permissions: Vec<String>,
}
impl TemplateContext {
#[must_use]
pub fn render(&self, template: &str) -> String {
let android_namespace = self.bundle_identifier.replace('-', "_");
template
.replace("__APP_DISPLAY_NAME__", &self.app_display_name)
.replace("__APP_NAME__", &self.app_name)
.replace("__CRATE_NAME__", &self.crate_name)
.replace("__ANDROID_NAMESPACE__", &android_namespace)
.replace("__BUNDLE_IDENTIFIER__", &self.bundle_identifier)
.replace("__AUTHOR__", &self.author)
.replace(
"__ANDROID_BACKEND_PATH__",
&self.compute_android_backend_path().unwrap_or_default(),
)
.replace(
"__USE_REMOTE_DEV_BACKEND__",
if self.use_remote_dev_backend {
"true"
} else {
"false"
},
)
.replace(
"__SWIFT_PACKAGE_REFERENCE_ENTRY__",
&self.swift_package_reference_entry(),
)
.replace(
"__SWIFT_PACKAGE_REFERENCE_SECTION__",
&self.swift_package_reference_section(),
)
.replace("__IOS_PERMISSION_KEYS__", "")
.replace("__ANDROID_PERMISSIONS__", &self.android_permissions_xml())
.replace(
"__PROJECT_ROOT_RELATIVE_PATH__",
&self.project_root_relative_path(),
)
}
#[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));
}
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.compute_relative_backend_path("android")
}
fn project_root_relative_path(&self) -> String {
let depth = self
.backend_project_path
.as_ref()
.map_or(1, |p| p.components().count());
(0..depth).map(|_| "..").collect::<Vec<_>>().join("/")
}
fn android_permissions_xml(&self) -> String {
if self.android_permissions.is_empty() {
return String::new();
}
self.android_permissions
.iter()
.map(|perm| {
let android_perm = match perm.to_lowercase().as_str() {
"internet" => "android.permission.INTERNET",
"camera" => "android.permission.CAMERA",
"microphone" => "android.permission.RECORD_AUDIO",
"location" => "android.permission.ACCESS_FINE_LOCATION",
"coarse_location" => "android.permission.ACCESS_COARSE_LOCATION",
"storage" => "android.permission.READ_EXTERNAL_STORAGE",
"write_storage" => "android.permission.WRITE_EXTERNAL_STORAGE",
"bluetooth" => "android.permission.BLUETOOTH",
"bluetooth_admin" => "android.permission.BLUETOOTH_ADMIN",
"vibrate" => "android.permission.VIBRATE",
"wake_lock" => "android.permission.WAKE_LOCK",
other => return format!(" <uses-permission android:name=\"{other}\" />"),
};
format!(" <uses-permission android:name=\"{android_perm}\" />")
})
.collect::<Vec<_>>()
.join("\n")
}
fn swift_package_reference_entry(&self) -> String {
const PACKAGE_ID: &str = "D01867782E6C82CA00802E96";
const INDENT: &str = "\t\t\t\t";
self.compute_apple_backend_path().map_or_else(
|| {
format!(
"{INDENT}{PACKAGE_ID} /* XCRemoteSwiftPackageReference \"apple-backend\" */,"
)
},
|backend_path| {
format!(
"{INDENT}{PACKAGE_ID} /* XCLocalSwiftPackageReference \"{backend_path}\" */,"
)
},
)
}
fn swift_package_reference_section(&self) -> String {
const PACKAGE_ID: &str = "D01867782E6C82CA00802E96";
const REPO_URL: &str = "https://github.com/water-rs/apple-backend.git";
const MIN_VERSION: &str = "0.2.0";
self.compute_apple_backend_path().map_or_else(
|| {
format!(
"/* Begin XCRemoteSwiftPackageReference section */\n\
\t\t{PACKAGE_ID} /* XCRemoteSwiftPackageReference \"apple-backend\" */ = {{\n\
\t\t\tisa = XCRemoteSwiftPackageReference;\n\
\t\t\trepositoryURL = \"{REPO_URL}\";\n\
\t\t\trequirement = {{\n\
\t\t\t\tkind = upToNextMajorVersion;\n\
\t\t\t\tminimumVersion = {MIN_VERSION};\n\
\t\t\t}};\n\
\t\t}};\n\
/* End XCRemoteSwiftPackageReference section */"
)
},
|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 */"
)
},
)
}
}
#[cfg(test)]
mod tests {
use super::TemplateContext;
use std::path::PathBuf;
fn ctx(
waterui_path: Option<PathBuf>,
backend_project_path: Option<PathBuf>,
) -> TemplateContext {
TemplateContext {
app_display_name: String::new(),
app_name: String::new(),
crate_name: String::new(),
bundle_identifier: "com.example.test".to_string(),
author: String::new(),
android_backend_path: None,
use_remote_dev_backend: waterui_path.is_none(),
waterui_path,
backend_project_path,
android_permissions: Vec::new(),
}
}
#[test]
fn relative_waterui_path_produces_clean_relative_backend_path() {
let ctx = ctx(
Some(PathBuf::from("../..")),
Some(PathBuf::from(".water/apple")),
);
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")));
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);
}
}
async fn scaffold_dir(
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 = ctx.render(content);
fs::write(&full_dest, rendered).await?;
} else {
fs::write(&full_dest, file.contents()).await?;
}
}
for subdir in current_dir.dirs() {
dirs_to_process.push(subdir);
}
}
Ok(())
}
pub mod apple {
use super::{Path, TemplateContext, embedded, fs, io, scaffold_dir};
pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
scaffold_dir(&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, embedded, fs, io, normalize_path_for_config, scaffold_dir};
pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
scaffold_dir(&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));
fs::write(&local_props, content).await?;
}
Ok(())
}
}
pub mod root {
use crate::templates::{WATERUI_FFI_VERSION, WATERUI_VERSION};
use super::{Path, TemplateContext, embedded, fs, io, normalize_path_for_config};
static ROOT_TEMPLATES: &[&str] = &["lib.rs.tpl", ".gitignore.tpl"];
pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
generate_cargo_toml(base_dir, ctx).await?;
for template_name in ROOT_TEMPLATES {
if let Some(file) = embedded::ROOT.get_file(template_name) {
let dest_name = template_name.strip_suffix(".tpl").unwrap_or(template_name);
let dest_path = if dest_name == "lib.rs" {
base_dir.join("src").join(dest_name)
} else {
base_dir.join(dest_name)
};
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 = ctx.render(content);
fs::write(&dest_path, rendered).await?;
}
}
Ok(())
}
async fn generate_cargo_toml(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize)]
struct CargoManifest {
package: PackageSection,
lib: LibSection,
dependencies: BTreeMap<String, DependencyValue>,
workspace: WorkspaceSection,
}
#[derive(Serialize)]
struct PackageSection {
name: String,
version: String,
edition: String,
authors: Vec<String>,
}
#[derive(Serialize)]
struct LibSection {
#[serde(rename = "crate-type")]
crate_type: Vec<String>,
}
#[derive(Serialize)]
struct WorkspaceSection {}
#[derive(Serialize)]
#[serde(untagged)]
enum DependencyValue {
Simple(String),
Detailed(DependencyDetail),
}
#[derive(Serialize)]
struct DependencyDetail {
path: String,
}
let mut dependencies = BTreeMap::new();
if let Some(waterui_path) = &ctx.waterui_path {
dependencies.insert(
"waterui".to_string(),
DependencyValue::Detailed(DependencyDetail {
path: normalize_path_for_config(waterui_path),
}),
);
let ffi_path = waterui_path.join("ffi");
dependencies.insert(
"waterui-ffi".to_string(),
DependencyValue::Detailed(DependencyDetail {
path: normalize_path_for_config(&ffi_path),
}),
);
} else {
dependencies.insert(
"waterui".to_string(),
DependencyValue::Simple(WATERUI_VERSION.to_string()),
);
dependencies.insert(
"waterui-ffi".to_string(),
DependencyValue::Simple(WATERUI_FFI_VERSION.to_string()),
);
}
let manifest = CargoManifest {
package: PackageSection {
name: ctx.crate_name.clone(),
version: "0.1.0".to_string(),
edition: "2024".to_string(),
authors: vec![ctx.author.clone()],
},
lib: LibSection {
crate_type: vec![
"staticlib".to_string(),
"cdylib".to_string(),
"rlib".to_string(),
],
},
dependencies,
workspace: WorkspaceSection {},
};
let toml_string = toml::to_string_pretty(&manifest)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let cargo_path = base_dir.join("Cargo.toml");
fs::write(&cargo_path, toml_string).await?;
Ok(())
}
}