1use std::{
7 io,
8 path::{Path, PathBuf},
9};
10
11const WATERUI_VERSION: &str = "0.2";
12const WATERUI_FFI_VERSION: &str = "0.2";
13
14use include_dir::{Dir, include_dir};
15use smol::fs;
16
17fn normalize_path_for_config(path: &Path) -> String {
20 path.to_string_lossy().replace('\\', "/")
21}
22
23mod embedded {
25 use super::{Dir, include_dir};
26
27 pub static APPLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/apple");
28 pub static ANDROID: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/android");
29 pub static ROOT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates");
30}
31
32#[derive(Debug, Clone)]
34pub struct TemplateContext {
35 pub app_display_name: String,
37 pub app_name: String,
39 pub crate_name: String,
41 pub bundle_identifier: String,
43 pub author: String,
45 pub android_backend_path: Option<PathBuf>,
47 pub use_remote_dev_backend: bool,
49 pub waterui_path: Option<PathBuf>,
51 pub backend_project_path: Option<PathBuf>,
55 pub android_permissions: Vec<String>,
57}
58
59impl TemplateContext {
60 #[must_use]
62 pub fn render(&self, template: &str) -> String {
63 let android_namespace = self.bundle_identifier.replace('-', "_");
65
66 template
67 .replace("__APP_DISPLAY_NAME__", &self.app_display_name)
68 .replace("__APP_NAME__", &self.app_name)
69 .replace("__CRATE_NAME__", &self.crate_name)
70 .replace("__ANDROID_NAMESPACE__", &android_namespace)
71 .replace("__BUNDLE_IDENTIFIER__", &self.bundle_identifier)
72 .replace("__AUTHOR__", &self.author)
73 .replace(
74 "__ANDROID_BACKEND_PATH__",
75 &self.compute_android_backend_path().unwrap_or_default(),
76 )
77 .replace(
78 "__USE_REMOTE_DEV_BACKEND__",
79 if self.use_remote_dev_backend {
80 "true"
81 } else {
82 "false"
83 },
84 )
85 .replace(
86 "__SWIFT_PACKAGE_REFERENCE_ENTRY__",
87 &self.swift_package_reference_entry(),
88 )
89 .replace(
90 "__SWIFT_PACKAGE_REFERENCE_SECTION__",
91 &self.swift_package_reference_section(),
92 )
93 .replace("__IOS_PERMISSION_KEYS__", "")
94 .replace("__ANDROID_PERMISSIONS__", &self.android_permissions_xml())
95 .replace(
96 "__PROJECT_ROOT_RELATIVE_PATH__",
97 &self.project_root_relative_path(),
98 )
99 }
100
101 #[must_use]
103 pub fn transform_path(&self, path: &Path) -> PathBuf {
104 let path_str = path.to_string_lossy();
105 PathBuf::from(path_str.replace("AppName", &self.app_name))
106 }
107
108 fn compute_relative_backend_path(&self, backend_subdir: &str) -> Option<String> {
112 let waterui_path = self.waterui_path.as_ref()?;
113
114 if waterui_path.is_absolute() {
117 let absolute_backend_path = waterui_path.join("backends").join(backend_subdir);
118 return Some(normalize_path_for_config(&absolute_backend_path));
119 }
120
121 let project_depth = self
124 .backend_project_path
125 .as_ref()
126 .map_or(1, |p| p.components().count());
127
128 let mut backend_path = PathBuf::new();
132 for _ in 0..project_depth {
133 backend_path.push("..");
134 }
135 backend_path.push(waterui_path);
136 backend_path.push("backends");
137 backend_path.push(backend_subdir);
138
139 Some(normalize_path_for_config(&backend_path))
140 }
141
142 fn compute_apple_backend_path(&self) -> Option<String> {
144 self.compute_relative_backend_path("apple")
145 }
146
147 fn compute_android_backend_path(&self) -> Option<String> {
149 self.compute_relative_backend_path("android")
150 }
151
152 fn project_root_relative_path(&self) -> String {
157 let depth = self
158 .backend_project_path
159 .as_ref()
160 .map_or(1, |p| p.components().count());
161
162 (0..depth).map(|_| "..").collect::<Vec<_>>().join("/")
163 }
164
165 fn android_permissions_xml(&self) -> String {
167 if self.android_permissions.is_empty() {
168 return String::new();
169 }
170
171 self.android_permissions
172 .iter()
173 .map(|perm| {
174 let android_perm = match perm.to_lowercase().as_str() {
175 "internet" => "android.permission.INTERNET",
176 "camera" => "android.permission.CAMERA",
177 "microphone" => "android.permission.RECORD_AUDIO",
178 "location" => "android.permission.ACCESS_FINE_LOCATION",
179 "coarse_location" => "android.permission.ACCESS_COARSE_LOCATION",
180 "storage" => "android.permission.READ_EXTERNAL_STORAGE",
181 "write_storage" => "android.permission.WRITE_EXTERNAL_STORAGE",
182 "bluetooth" => "android.permission.BLUETOOTH",
183 "bluetooth_admin" => "android.permission.BLUETOOTH_ADMIN",
184 "vibrate" => "android.permission.VIBRATE",
185 "wake_lock" => "android.permission.WAKE_LOCK",
186 other => return format!(" <uses-permission android:name=\"{other}\" />"),
188 };
189 format!(" <uses-permission android:name=\"{android_perm}\" />")
190 })
191 .collect::<Vec<_>>()
192 .join("\n")
193 }
194
195 fn swift_package_reference_entry(&self) -> String {
197 const PACKAGE_ID: &str = "D01867782E6C82CA00802E96";
198 const INDENT: &str = "\t\t\t\t";
199
200 self.compute_apple_backend_path().map_or_else(
201 || {
202 format!(
203 "{INDENT}{PACKAGE_ID} /* XCRemoteSwiftPackageReference \"apple-backend\" */,"
204 )
205 },
206 |backend_path| {
207 format!(
208 "{INDENT}{PACKAGE_ID} /* XCLocalSwiftPackageReference \"{backend_path}\" */,"
209 )
210 },
211 )
212 }
213
214 fn swift_package_reference_section(&self) -> String {
216 const PACKAGE_ID: &str = "D01867782E6C82CA00802E96";
217 const REPO_URL: &str = "https://github.com/water-rs/apple-backend.git";
218 const MIN_VERSION: &str = "0.2.0";
219
220 self.compute_apple_backend_path().map_or_else(
221 || {
222 format!(
223 "/* Begin XCRemoteSwiftPackageReference section */\n\
224 \t\t{PACKAGE_ID} /* XCRemoteSwiftPackageReference \"apple-backend\" */ = {{\n\
225 \t\t\tisa = XCRemoteSwiftPackageReference;\n\
226 \t\t\trepositoryURL = \"{REPO_URL}\";\n\
227 \t\t\trequirement = {{\n\
228 \t\t\t\tkind = upToNextMajorVersion;\n\
229 \t\t\t\tminimumVersion = {MIN_VERSION};\n\
230 \t\t\t}};\n\
231 \t\t}};\n\
232 /* End XCRemoteSwiftPackageReference section */"
233 )
234 },
235 |backend_path| {
236 format!(
237 "/* Begin XCLocalSwiftPackageReference section */\n\
238 \t\t{PACKAGE_ID} /* XCLocalSwiftPackageReference \"{backend_path}\" */ = {{\n\
239 \t\t\tisa = XCLocalSwiftPackageReference;\n\
240 \t\t\trelativePath = \"{backend_path}\";\n\
241 \t\t}};\n\
242 /* End XCLocalSwiftPackageReference section */"
243 )
244 },
245 )
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::TemplateContext;
252 use std::path::PathBuf;
253
254 fn ctx(
255 waterui_path: Option<PathBuf>,
256 backend_project_path: Option<PathBuf>,
257 ) -> TemplateContext {
258 TemplateContext {
259 app_display_name: String::new(),
260 app_name: String::new(),
261 crate_name: String::new(),
262 bundle_identifier: "com.example.test".to_string(),
263 author: String::new(),
264 android_backend_path: None,
265 use_remote_dev_backend: waterui_path.is_none(),
266 waterui_path,
267 backend_project_path,
268 android_permissions: Vec::new(),
269 }
270 }
271
272 #[test]
273 fn relative_waterui_path_produces_clean_relative_backend_path() {
274 let ctx = ctx(
275 Some(PathBuf::from("../..")),
276 Some(PathBuf::from(".water/apple")),
277 );
278
279 let path = ctx
280 .compute_relative_backend_path("apple")
281 .expect("expected relative backend path");
282
283 assert_eq!(path, "../../../../backends/apple");
284 assert!(!path.contains("//"));
285 }
286
287 #[test]
288 fn absolute_waterui_path_is_used_directly() {
289 let abs = if cfg!(windows) {
290 PathBuf::from(r"C:\waterui")
291 } else {
292 PathBuf::from("/waterui")
293 };
294
295 let ctx = ctx(Some(abs), Some(PathBuf::from("apple")));
296 let path = ctx
297 .compute_relative_backend_path("apple")
298 .expect("expected backend path");
299
300 let expected = if cfg!(windows) {
301 "C:/waterui/backends/apple"
302 } else {
303 "/waterui/backends/apple"
304 };
305 assert_eq!(path, expected);
306 }
307}
308
309async fn scaffold_dir(
311 embedded_dir: &Dir<'_>,
312 base_dir: &Path,
313 ctx: &TemplateContext,
314) -> io::Result<()> {
315 let mut dirs_to_process = vec![embedded_dir];
317
318 while let Some(current_dir) = dirs_to_process.pop() {
319 for file in current_dir.files() {
321 let relative_path = file.path();
322
323 let is_template = relative_path
325 .extension()
326 .and_then(|ext| ext.to_str())
327 .is_some_and(|ext| ext == "tpl");
328
329 let dest_path = if is_template {
330 let without_tpl = relative_path.with_extension("");
332 ctx.transform_path(&without_tpl)
333 } else {
334 ctx.transform_path(relative_path)
336 };
337
338 let full_dest = base_dir.join(&dest_path);
339
340 if let Some(parent) = full_dest.parent() {
342 fs::create_dir_all(parent).await?;
343 }
344
345 if is_template {
347 let content = file
349 .contents_utf8()
350 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))?;
351 let rendered = ctx.render(content);
352 fs::write(&full_dest, rendered).await?;
353 } else {
354 fs::write(&full_dest, file.contents()).await?;
356 }
357 }
358
359 for subdir in current_dir.dirs() {
361 dirs_to_process.push(subdir);
362 }
363 }
364
365 Ok(())
366}
367
368pub mod apple {
370 use super::{Path, TemplateContext, embedded, fs, io, scaffold_dir};
371
372 pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
378 scaffold_dir(&embedded::APPLE, base_dir, ctx).await?;
379
380 #[cfg(unix)]
382 {
383 use std::os::unix::fs::PermissionsExt;
384 let script_path = base_dir.join("build-rust.sh");
385 if script_path.exists() {
386 let mut perms = fs::metadata(&script_path).await?.permissions();
387 perms.set_mode(0o755);
388 fs::set_permissions(&script_path, perms).await?;
389 }
390 }
391
392 Ok(())
393 }
394}
395
396pub mod android {
398 use crate::android::toolchain::AndroidSdk;
399
400 use super::{Path, TemplateContext, embedded, fs, io, normalize_path_for_config, scaffold_dir};
401
402 pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
407 scaffold_dir(&embedded::ANDROID, base_dir, ctx).await?;
408
409 #[cfg(unix)]
411 {
412 use std::os::unix::fs::PermissionsExt;
413 let gradlew_path = base_dir.join("gradlew");
414 if gradlew_path.exists() {
415 let mut perms = fs::metadata(&gradlew_path).await?.permissions();
416 perms.set_mode(0o755);
417 fs::set_permissions(&gradlew_path, perms).await?;
418 }
419 }
420
421 for abi in ["arm64-v8a", "x86_64", "armeabi-v7a", "x86"] {
423 let jni_dir = base_dir.join(format!("app/src/main/jniLibs/{abi}"));
424 fs::create_dir_all(&jni_dir).await?;
425 }
426
427 if let Some(sdk_path) = AndroidSdk::detect_path() {
429 let local_props = base_dir.join("local.properties");
430 let content = format!("sdk.dir={}\n", normalize_path_for_config(&sdk_path));
431 fs::write(&local_props, content).await?;
432 }
433
434 Ok(())
435 }
436}
437
438pub mod root {
440 use crate::templates::{WATERUI_FFI_VERSION, WATERUI_VERSION};
441
442 use super::{Path, TemplateContext, embedded, fs, io, normalize_path_for_config};
443
444 static ROOT_TEMPLATES: &[&str] = &["lib.rs.tpl", ".gitignore.tpl"];
446
447 pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
453 generate_cargo_toml(base_dir, ctx).await?;
455
456 for template_name in ROOT_TEMPLATES {
458 if let Some(file) = embedded::ROOT.get_file(template_name) {
459 let dest_name = template_name.strip_suffix(".tpl").unwrap_or(template_name);
460 let dest_path = if dest_name == "lib.rs" {
461 base_dir.join("src").join(dest_name)
462 } else {
463 base_dir.join(dest_name)
464 };
465
466 if let Some(parent) = dest_path.parent() {
468 fs::create_dir_all(parent).await?;
469 }
470
471 let content = file
472 .contents_utf8()
473 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))?;
474 let rendered = ctx.render(content);
475 fs::write(&dest_path, rendered).await?;
476 }
477 }
478 Ok(())
479 }
480
481 async fn generate_cargo_toml(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
483 use serde::Serialize;
484 use std::collections::BTreeMap;
485
486 #[derive(Serialize)]
487 struct CargoManifest {
488 package: PackageSection,
489 lib: LibSection,
490 dependencies: BTreeMap<String, DependencyValue>,
491 workspace: WorkspaceSection,
492 }
493
494 #[derive(Serialize)]
495 struct PackageSection {
496 name: String,
497 version: String,
498 edition: String,
499 authors: Vec<String>,
500 }
501
502 #[derive(Serialize)]
503 struct LibSection {
504 #[serde(rename = "crate-type")]
505 crate_type: Vec<String>,
506 }
507
508 #[derive(Serialize)]
509 struct WorkspaceSection {}
510
511 #[derive(Serialize)]
512 #[serde(untagged)]
513 enum DependencyValue {
514 Simple(String),
515 Detailed(DependencyDetail),
516 }
517
518 #[derive(Serialize)]
519 struct DependencyDetail {
520 path: String,
521 }
522
523 let mut dependencies = BTreeMap::new();
524
525 if let Some(waterui_path) = &ctx.waterui_path {
526 dependencies.insert(
528 "waterui".to_string(),
529 DependencyValue::Detailed(DependencyDetail {
530 path: normalize_path_for_config(waterui_path),
531 }),
532 );
533
534 let ffi_path = waterui_path.join("ffi");
535 dependencies.insert(
536 "waterui-ffi".to_string(),
537 DependencyValue::Detailed(DependencyDetail {
538 path: normalize_path_for_config(&ffi_path),
539 }),
540 );
541 } else {
542 dependencies.insert(
544 "waterui".to_string(),
545 DependencyValue::Simple(WATERUI_VERSION.to_string()),
546 );
547 dependencies.insert(
548 "waterui-ffi".to_string(),
549 DependencyValue::Simple(WATERUI_FFI_VERSION.to_string()),
550 );
551 }
552
553 let manifest = CargoManifest {
554 package: PackageSection {
555 name: ctx.crate_name.clone(),
556 version: "0.1.0".to_string(),
557 edition: "2024".to_string(),
558 authors: vec![ctx.author.clone()],
559 },
560 lib: LibSection {
561 crate_type: vec![
562 "staticlib".to_string(),
563 "cdylib".to_string(),
564 "rlib".to_string(),
565 ],
566 },
567 dependencies,
568 workspace: WorkspaceSection {},
569 };
570
571 let toml_string = toml::to_string_pretty(&manifest)
573 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
574
575 let cargo_path = base_dir.join("Cargo.toml");
576 fs::write(&cargo_path, toml_string).await?;
577
578 Ok(())
579 }
580}