1use std::collections::{BTreeMap, HashMap, HashSet};
11use std::future::Future;
12use std::path::{Component, Path, PathBuf};
13use std::pin::Pin;
14
15use cargo_metadata::PackageId;
16use eyre::{Context, OptionExt};
17use serde::{Deserialize, Serialize};
18use smol::fs;
19use tracing::{debug, info, warn};
20use walkdir::WalkDir;
21
22use waterui_assets_planner::BundleManifest;
23use zenwave::{Client as _, Method};
24
25use crate::build::BuildProgress;
26use crate::project::Project;
27use crate::project_model::project_types::PermissionKey;
28
29pub mod icon;
30mod unified;
31mod web;
32
33#[derive(Debug, Clone, Deserialize)]
35struct RegistryFont {
36 name: String,
38 url: String,
40}
41
42#[derive(Debug, Clone, Deserialize)]
64struct FontRegistry {
65 #[serde(rename = "font")]
66 fonts: Vec<RegistryFont>,
67}
68
69impl FontRegistry {
70 fn builtin() -> eyre::Result<Self> {
72 toml::from_str(include_str!("assets/fonts.toml"))
73 .wrap_err("built-in font registry `src/project_model/assets/fonts.toml` is malformed")
74 }
75
76 fn url(&self, name: &str) -> Option<&str> {
78 self.fonts
79 .iter()
80 .find(|font| font.name == name)
81 .map(|font| font.url.as_str())
82 }
83}
84const HYDROLYSIS_DEFAULT_FONT_FAMILY: &str = "Roboto";
85const HYDROLYSIS_WEB_FONT_MANIFEST_FILE_NAME: &str = "waterui-fonts.json";
86
87#[derive(Debug, Clone)]
90pub struct FontDeclaration {
91 pub name: String,
93 pub source: FontSource,
95 pub crate_name: String,
97}
98
99#[derive(Debug, Clone)]
101pub enum FontSource {
102 Local {
104 crate_root: PathBuf,
106 relative_path: PathBuf,
108 },
109 Remote {
111 url: String,
113 },
114 BuiltIn,
116}
117
118#[derive(Debug, Clone)]
120pub struct ResolvedFont {
121 pub name: String,
123 pub path: PathBuf,
125}
126
127#[derive(Debug, Serialize)]
128struct HydrolysisWebFontManifest {
129 default_family: String,
130 fonts: Vec<HydrolysisWebFontManifestEntry>,
131}
132
133#[derive(Debug, Serialize)]
134struct HydrolysisWebFontManifestEntry {
135 name: String,
136 file_name: String,
137}
138
139#[derive(Debug, Deserialize)]
141struct WaterUIMetadata {
142 #[serde(default)]
143 assets: AssetsMetadata,
144 #[serde(default)]
146 permissions: BTreeMap<PermissionKey, PermissionRequirement>,
147}
148
149#[derive(Debug, Deserialize)]
151struct PermissionRequirement {
152 reason: String,
154 #[serde(default, rename = "required-feature")]
156 required_feature: Option<String>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct RequiredPermission {
162 pub package: String,
164 pub key: PermissionKey,
166 pub reason: String,
168 pub evidence: PermissionEvidence,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum PermissionEvidence {
175 Declared,
177 Inferred,
180}
181
182#[derive(Debug, Default, Deserialize)]
183struct AssetsMetadata {
184 #[serde(default)]
185 font: Vec<FontMetadata>,
186}
187
188#[derive(Debug, Deserialize)]
189struct FontMetadata {
190 name: String,
191 #[serde(default)]
192 local_path: Option<String>,
193 #[serde(default)]
194 remote_path: Option<String>,
195 #[serde(default, rename = "required-feature")]
199 required_feature: Option<String>,
200}
201
202fn manifest_font_declarations(
210 manifest: &crate::project::Manifest,
211 root: &Path,
212) -> eyre::Result<Vec<FontDeclaration>> {
213 let Some(assets) = manifest.assets.as_ref() else {
214 return Ok(Vec::new());
215 };
216 let mut declarations = Vec::with_capacity(assets.font.len());
217 for font in &assets.font {
218 let source = match (&font.local_path, &font.remote_path) {
219 (Some(local_path), None) => {
220 let relative_path = PathBuf::from(local_path);
221 if matches!(
225 relative_path.components().next(),
226 Some(Component::Prefix(_) | Component::RootDir)
227 ) {
228 eyre::bail!(
229 "[[assets.font]] entry '{}' in Water.toml: local_path must be \
230 relative to the project root",
231 font.name
232 );
233 }
234 FontSource::Local {
235 crate_root: root.to_path_buf(),
236 relative_path,
237 }
238 }
239 (None, Some(url)) => FontSource::Remote { url: url.clone() },
240 (None, None) => FontSource::BuiltIn,
241 (Some(_), Some(_)) => {
242 eyre::bail!(
243 "[[assets.font]] entry '{}' in Water.toml sets both local_path and \
244 remote_path; a declaration has exactly one source",
245 font.name
246 );
247 }
248 };
249 declarations.push(FontDeclaration {
250 name: font.name.clone(),
251 source,
252 crate_name: manifest.package.name.clone(),
253 });
254 }
255 Ok(declarations)
256}
257
258pub async fn scan_fonts(
265 project: &Project,
266 build_manifest: &Path,
267) -> eyre::Result<Vec<FontDeclaration>> {
268 let mut declarations = manifest_font_declarations(project.manifest(), project.root())?;
269 declarations.extend(scan_crate_font_declarations(build_manifest).await?);
270 Ok(declarations)
271}
272
273async fn scan_crate_font_declarations(build_manifest: &Path) -> eyre::Result<Vec<FontDeclaration>> {
285 debug!(
286 "Scanning fonts from dependencies via cargo metadata on {}",
287 build_manifest.display()
288 );
289
290 let manifest_path = build_manifest.to_path_buf();
292 let metadata = smol::unblock({
293 let manifest_path = manifest_path.clone();
294 move || {
295 cargo_metadata::MetadataCommand::new()
296 .manifest_path(&manifest_path)
297 .exec()
298 }
299 })
300 .await
301 .wrap_err_with(|| {
302 format!(
303 "Failed to run cargo metadata on {}",
304 build_manifest.display()
305 )
306 })?;
307
308 let enabled_features_map: HashMap<&PackageId, HashSet<&str>> = metadata
310 .resolve
311 .as_ref()
312 .map(|resolve| {
313 resolve
314 .nodes
315 .iter()
316 .map(|node| (&node.id, node.features.iter().map(|f| f.as_str()).collect()))
317 .collect()
318 })
319 .unwrap_or_default();
320
321 let mut fonts = Vec::new();
322
323 for package in &metadata.packages {
324 let Some(waterui) = package.metadata.get("waterui") else {
326 continue;
327 };
328
329 let waterui_meta: WaterUIMetadata = match serde_json::from_value(waterui.clone()) {
331 Ok(m) => m,
332 Err(e) => {
333 warn!(
334 "Failed to parse waterui metadata for {}: {}",
335 package.name, e
336 );
337 continue;
338 }
339 };
340
341 let enabled_features = enabled_features_map
343 .get(&package.id)
344 .cloned()
345 .unwrap_or_default();
346
347 for font_meta in waterui_meta.assets.font {
349 if let Some(required) = &font_meta.required_feature
351 && !enabled_features.contains(required.as_str())
352 {
353 debug!(
354 "Skipping font '{}': feature '{}' not enabled for {}",
355 font_meta.name, required, package.name
356 );
357 continue;
358 }
359
360 let source = if let Some(local_path) = font_meta.local_path {
361 let local_path = PathBuf::from(local_path);
362 if local_path.is_absolute() {
363 warn!(
364 "Skipping font '{}': local_path must be relative (crate: {})",
365 font_meta.name, package.name
366 );
367 continue;
368 }
369
370 let crate_root = package
372 .manifest_path
373 .parent()
374 .ok_or_eyre("Package has no parent directory")?
375 .as_std_path()
376 .to_path_buf();
377
378 FontSource::Local {
379 crate_root,
380 relative_path: local_path,
381 }
382 } else if let Some(url) = font_meta.remote_path {
383 FontSource::Remote { url }
384 } else {
385 FontSource::BuiltIn
387 };
388
389 fonts.push(FontDeclaration {
390 name: font_meta.name,
391 source,
392 crate_name: package.name.to_string(),
393 });
394 }
395 }
396
397 info!("Found {} font declarations from dependencies", fonts.len());
398 Ok(fonts)
399}
400
401pub async fn resolve_fonts(declarations: Vec<FontDeclaration>) -> eyre::Result<Vec<ResolvedFont>> {
410 let cache_dir = cache_dir()?;
411 let registry = FontRegistry::builtin()?;
412
413 let mut resolved = Vec::new();
414 for decl in resolve_declarations(declarations) {
415 let path = satisfy_font(&decl, &cache_dir, ®istry).await?;
416 debug!("Resolved font '{}' -> {}", decl.name, path.display());
417 resolved.push(ResolvedFont {
418 name: decl.name,
419 path,
420 });
421 }
422
423 info!("Resolved {} fonts", resolved.len());
424 Ok(resolved)
425}
426
427fn resolve_declarations(declarations: Vec<FontDeclaration>) -> Vec<FontDeclaration> {
438 let mut by_name: HashMap<String, Vec<FontDeclaration>> = HashMap::new();
440 for decl in declarations {
441 by_name.entry(decl.name.clone()).or_default().push(decl);
442 }
443
444 let mut resolved: Vec<FontDeclaration> = by_name
445 .into_values()
446 .map(|mut decls| {
447 decls.sort_by_key(|d| match &d.source {
449 FontSource::Local { .. } => 0,
450 FontSource::Remote { .. } => 1,
451 FontSource::BuiltIn => 2,
452 });
453 decls.into_iter().next().unwrap()
454 })
455 .collect();
456 resolved.sort_by(|left, right| left.name.cmp(&right.name));
457 resolved
458}
459
460async fn satisfy_font(
467 decl: &FontDeclaration,
468 cache_dir: &Path,
469 registry: &FontRegistry,
470) -> eyre::Result<PathBuf> {
471 let name = &decl.name;
472 match &decl.source {
473 FontSource::Local {
474 crate_root,
475 relative_path,
476 } => match resolve_local_font_path(crate_root, relative_path) {
477 Ok(Some(full_path)) => Ok(full_path),
478 Ok(None) => Err(unsatisfiable_local_font(
482 decl,
483 &crate_root.join(relative_path),
484 )),
485 Err(e) => Err(e).wrap_err_with(|| {
486 format!(
487 "font '{name}' has an invalid local path '{}' (declared by {})",
488 relative_path.display(),
489 decl.crate_name
490 )
491 }),
492 },
493 FontSource::Remote { url } => cached_font(name, url, cache_dir).await,
494 FontSource::BuiltIn => {
495 let Some(url) = registry.url(name) else {
496 return Err(unsatisfiable_builtin_font(decl));
501 };
502 cached_font(name, url, cache_dir).await
503 }
504 }
505}
506
507fn unsatisfiable_local_font(decl: &FontDeclaration, expected: &Path) -> eyre::Report {
511 eyre::eyre!(
512 "font '{}' is declared by {} at '{}', which does not exist in that crate",
513 decl.name,
514 decl.crate_name,
515 expected.display(),
516 )
517}
518
519fn unsatisfiable_builtin_font(decl: &FontDeclaration) -> eyre::Report {
522 eyre::eyre!(
523 "font '{}' is declared by {} by name alone, but no font of that name \
524 is in the built-in registry — give the declaration a `local_path` or a \
525 `remote_path`",
526 decl.name,
527 decl.crate_name
528 )
529}
530
531fn cache_dir() -> eyre::Result<PathBuf> {
533 let cache = dirs::cache_dir()
534 .map(|root| root.join("waterui").join("fonts"))
535 .ok_or_eyre("Could not determine cache directory")?;
536 Ok(cache)
537}
538
539fn resolve_local_font_path(
540 crate_root: &Path,
541 relative_path: &Path,
542) -> eyre::Result<Option<PathBuf>> {
543 let full_path = crate_root.join(relative_path);
544 if !full_path.exists() {
545 return Ok(None);
546 }
547
548 let canonical_root = crate_root
549 .canonicalize()
550 .wrap_err_with(|| format!("Failed to canonicalize crate root {}", crate_root.display()))?;
551 let canonical_path = full_path
552 .canonicalize()
553 .wrap_err_with(|| format!("Failed to canonicalize font path {}", full_path.display()))?;
554
555 if !canonical_path.starts_with(&canonical_root) {
556 eyre::bail!(
557 "path escapes crate root ({} -> {})",
558 full_path.display(),
559 canonical_path.display()
560 );
561 }
562
563 Ok(Some(canonical_path))
564}
565
566async fn cached_font(name: &str, url: &str, cache_dir: &Path) -> eyre::Result<PathBuf> {
572 cached_font_entry(name, url, cache_dir)
573 .await?
574 .ok_or_else(|| uncached_font_error(name, url, cache_dir))
575}
576
577async fn cached_font_entry(
583 name: &str,
584 url: &str,
585 cache_dir: &Path,
586) -> eyre::Result<Option<PathBuf>> {
587 let hash = sha256_hex(url);
589 if is_zip_url(url) {
590 return cached_zip_font(name, cache_dir, &hash).await;
591 }
592
593 cached_file_font(name, cache_dir, &hash).await
594}
595
596fn is_zip_url(url: &str) -> bool {
597 let path = url.split(['?', '#']).next().unwrap_or(url);
598 path.rsplit_once('.')
599 .is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("zip"))
600}
601
602async fn cached_zip_font(
603 name: &str,
604 cache_dir: &Path,
605 hash: &str,
606) -> eyre::Result<Option<PathBuf>> {
607 let extract_dir = cache_dir.join(hash);
608 if extract_dir.exists() {
609 debug!(
610 "Font '{}' already extracted at {}",
611 name,
612 extract_dir.display()
613 );
614 return find_font_file(&extract_dir, name).await.map(Some);
615 }
616
617 let cache_file = cache_dir.join(format!("{hash}.zip"));
618 if let Some(cache_len) = cached_file_len(name, &cache_file).await? {
619 if cache_len == 0 {
620 warn!(
621 "Ignoring empty cached font '{}' at {}",
622 name,
623 cache_file.display()
624 );
625 let _ = fs::remove_file(&cache_file).await;
626 } else {
627 debug!("Font '{}' already cached at {}", name, cache_file.display());
628 return find_font_in_extracted_zip(&cache_file, name)
629 .await
630 .map(Some);
631 }
632 }
633
634 Ok(None)
635}
636
637async fn cached_file_font(
638 name: &str,
639 cache_dir: &Path,
640 hash: &str,
641) -> eyre::Result<Option<PathBuf>> {
642 let cache_file = cache_dir.join(format!("{hash}.ttf"));
643
644 if let Some(cache_len) = cached_file_len(name, &cache_file).await? {
645 if cache_len == 0 {
646 warn!(
647 "Ignoring empty cached font '{}' at {}",
648 name,
649 cache_file.display()
650 );
651 let _ = fs::remove_file(&cache_file).await;
652 } else {
653 debug!("Font '{}' already cached at {}", name, cache_file.display());
654 return Ok(Some(cache_file));
655 }
656 }
657
658 Ok(None)
659}
660
661fn uncached_font_error(name: &str, url: &str, cache_dir: &Path) -> eyre::Report {
666 eyre::eyre!(
667 "font '{name}' is declared remote ({url}) but is not in the font cache at {}; \
668 builds never access the network — run `water fetch` to download the project's \
669 fonts and retry the build",
670 cache_dir.display(),
671 )
672}
673
674async fn cached_file_len(name: &str, cache_file: &Path) -> eyre::Result<Option<u64>> {
675 match fs::metadata(cache_file).await {
676 Ok(metadata) => Ok(Some(metadata.len())),
677 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
678 Err(error) => Err(error).wrap_err_with(|| {
679 format!(
680 "Failed to read cached font metadata for '{}' at {}",
681 name,
682 cache_file.display()
683 )
684 }),
685 }
686}
687
688#[derive(Debug)]
693pub enum FetchOutcome {
694 Satisfied {
697 name: String,
699 path: PathBuf,
701 },
702 Fetched {
704 name: String,
706 path: PathBuf,
709 },
710 Unsatisfiable {
714 name: String,
716 error: eyre::Report,
718 },
719}
720
721pub async fn seed_font_cache(project: &Project) -> eyre::Result<Vec<FetchOutcome>> {
739 let mut declarations = manifest_font_declarations(project.manifest(), project.root())?;
740 for manifest in ensure_font_scan_manifests(project).await? {
741 declarations.extend(scan_crate_font_declarations(&manifest).await?);
742 }
743 fetch_fonts(declarations, &cache_dir()?, download_font).await
744}
745
746async fn ensure_font_scan_manifests(project: &Project) -> eyre::Result<Vec<PathBuf>> {
769 let mut manifests = vec![project.root().join("Cargo.toml")];
770
771 if project.is_playground()
772 || project.apple_backend().is_some()
773 || project.android_backend().is_some()
774 {
775 let manifest = project.ffi_crate_path().join("Cargo.toml");
776 if !manifest.is_file() {
777 project.scaffold_ffi_companion().await.map_err(|error| {
778 eyre::eyre!("could not scaffold the Apple/Android FFI companion crate: {error}")
779 })?;
780 }
781 manifests.push(manifest);
782 }
783
784 ensure_backend_manifest::<crate::gtk4::backend::Gtk4Backend>(project, &mut manifests).await?;
785 ensure_backend_manifest::<crate::hydrolysis::backend::HydrolysisBackend>(
786 project,
787 &mut manifests,
788 )
789 .await?;
790 ensure_backend_manifest::<crate::winui::backend::WinUiBackend>(project, &mut manifests).await?;
791
792 Ok(manifests)
793}
794
795trait FontScanCrate: crate::backend::Backend {
798 const NAME: &'static str;
800 fn wanted(project: &Project) -> bool;
805 fn stale(project: &Project) -> impl Future<Output = eyre::Result<bool>> + Send;
808}
809
810async fn ensure_backend_manifest<B: FontScanCrate>(
815 project: &Project,
816 manifests: &mut Vec<PathBuf>,
817) -> eyre::Result<()> {
818 if !B::wanted(project) {
819 return Ok(());
820 }
821 let stale = B::stale(project)
822 .await
823 .wrap_err_with(|| format!("could not inspect the {} backend crate", B::NAME))?;
824 if stale {
825 crate::backend::reinit_backend::<B>(project)
826 .await
827 .map_err(|error| {
828 eyre::eyre!("could not scaffold the {} backend crate: {error}", B::NAME)
829 })?;
830 }
831 manifests.push(project.backend_path::<B>().join("Cargo.toml"));
832 Ok(())
833}
834
835impl FontScanCrate for crate::gtk4::backend::Gtk4Backend {
836 const NAME: &'static str = "GTK4";
837 fn wanted(project: &Project) -> bool {
838 project.gtk4_backend().is_some() || (project.is_playground() && cfg!(target_os = "linux"))
841 }
842 async fn stale(project: &Project) -> eyre::Result<bool> {
843 Self::requires_regeneration(project).await
844 }
845}
846
847impl FontScanCrate for crate::hydrolysis::backend::HydrolysisBackend {
848 const NAME: &'static str = "hydrolysis";
849 fn wanted(project: &Project) -> bool {
850 project.is_playground() || project.hydrolysis_backend().is_some()
851 }
852 async fn stale(project: &Project) -> eyre::Result<bool> {
853 Self::requires_regeneration(project).await
854 }
855}
856
857impl FontScanCrate for crate::winui::backend::WinUiBackend {
858 const NAME: &'static str = "WinUI";
859 fn wanted(project: &Project) -> bool {
860 project.winui_backend().is_some()
863 || (project.is_playground() && cfg!(target_os = "windows"))
864 }
865 async fn stale(project: &Project) -> eyre::Result<bool> {
866 Self::requires_regeneration(project).await
867 }
868}
869
870type FontFetch =
873 for<'a> fn(&'a str, &'a Path) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
874
875async fn fetch_fonts(
888 declarations: Vec<FontDeclaration>,
889 cache_dir: &Path,
890 fetch: FontFetch,
891) -> eyre::Result<Vec<FetchOutcome>> {
892 let registry = FontRegistry::builtin()?;
893 let mut outcomes = Vec::new();
894 for decl in resolve_declarations(declarations) {
895 let outcome = match &decl.source {
896 FontSource::Local { .. } => match satisfy_font(&decl, cache_dir, ®istry).await {
899 Ok(path) => FetchOutcome::Satisfied {
900 name: decl.name.clone(),
901 path,
902 },
903 Err(error) => FetchOutcome::Unsatisfiable {
904 name: decl.name.clone(),
905 error,
906 },
907 },
908 FontSource::Remote { .. } | FontSource::BuiltIn => {
909 match declaration_url(&decl, ®istry) {
910 Some(url) => {
911 if let Some(path) = cached_font_entry(&decl.name, url, cache_dir).await? {
912 FetchOutcome::Satisfied {
913 name: decl.name.clone(),
914 path,
915 }
916 } else {
917 let path = fetch_remote_font(&decl.name, url, cache_dir, fetch).await?;
918 FetchOutcome::Fetched {
919 name: decl.name.clone(),
920 path,
921 }
922 }
923 }
924 None => FetchOutcome::Unsatisfiable {
926 name: decl.name.clone(),
927 error: unsatisfiable_builtin_font(&decl),
928 },
929 }
930 }
931 };
932 outcomes.push(outcome);
933 }
934 Ok(outcomes)
935}
936
937fn declaration_url<'a>(decl: &'a FontDeclaration, registry: &'a FontRegistry) -> Option<&'a str> {
941 match &decl.source {
942 FontSource::Remote { url } => Some(url.as_str()),
943 FontSource::BuiltIn => registry.url(&decl.name),
944 FontSource::Local { .. } => None,
945 }
946}
947
948async fn fetch_remote_font(
955 name: &str,
956 url: &str,
957 cache_dir: &Path,
958 fetch: FontFetch,
959) -> eyre::Result<PathBuf> {
960 waterui_assets_core::ensure_http_allowed(url)
961 .map_err(|error| eyre::eyre!("font '{name}' cannot be fetched from {url}: {error}"))?;
962 fs::create_dir_all(cache_dir).await?;
963
964 let hash = sha256_hex(url);
965 let extension = if is_zip_url(url) { "zip" } else { "ttf" };
966 let cache_file = cache_dir.join(format!("{hash}.{extension}"));
967 let partial = cache_dir.join(format!("{hash}.{extension}.partial"));
968 fetch(url, &partial)
969 .await
970 .wrap_err_with(|| format!("font '{name}' could not be fetched from {url}"))?;
971 if fs::metadata(&partial).await?.len() == 0 {
972 let _ = fs::remove_file(&partial).await;
973 eyre::bail!("font '{name}' fetched from {url} is empty");
974 }
975 fs::rename(&partial, &cache_file).await.wrap_err_with(|| {
976 format!(
977 "failed to place font '{name}' fetched from {url} at {}",
978 cache_file.display()
979 )
980 })?;
981
982 if is_zip_url(url) {
983 find_font_in_extracted_zip(&cache_file, name)
984 .await
985 .wrap_err_with(|| format!("font '{name}' fetched from {url}"))
986 } else {
987 Ok(cache_file)
988 }
989}
990
991fn download_font<'a>(
995 url: &'a str,
996 dest: &'a Path,
997) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>> {
998 Box::pin(async move {
999 let mut client = zenwave::client();
1000 client
1001 .method(Method::GET, url)?
1002 .header("User-Agent", env!("CARGO_PKG_NAME"))?
1003 .download_to_path(dest)
1004 .await?;
1005 Ok(())
1006 })
1007}
1008
1009async fn find_font_in_extracted_zip(zip_path: &Path, name: &str) -> eyre::Result<PathBuf> {
1011 let extract_dir = zip_path.with_extension("");
1012
1013 if !extract_dir.exists() {
1015 fs::create_dir_all(&extract_dir).await?;
1016
1017 let zip_path = zip_path.to_path_buf();
1018 let extract_dir_clone = extract_dir.clone();
1019 let zip_path_for_extraction = zip_path.clone();
1020
1021 smol::unblock(move || {
1022 let file = std::fs::File::open(&zip_path_for_extraction)?;
1023 let mut archive = zip::ZipArchive::new(file)?;
1024 archive.extract(&extract_dir_clone)?;
1025 Ok::<_, eyre::Report>(())
1026 })
1027 .await?;
1028
1029 if name.to_ascii_lowercase().contains("fontawesome") {
1033 copy_fontawesome_icons_json(&extract_dir).await?;
1034 }
1035 }
1036
1037 remove_extracted_font_archive(zip_path).await?;
1038
1039 let font_file = find_font_file(&extract_dir, name).await?;
1041 Ok(font_file)
1042}
1043
1044async fn remove_extracted_font_archive(zip_path: &Path) -> eyre::Result<()> {
1045 match fs::remove_file(zip_path).await {
1046 Ok(()) => Ok(()),
1047 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1048 Err(error) => Err(error).wrap_err_with(|| {
1049 format!(
1050 "Failed to remove extracted font archive at {}",
1051 zip_path.display()
1052 )
1053 }),
1054 }
1055}
1056
1057async fn copy_fontawesome_icons_json(extract_dir: &Path) -> eyre::Result<()> {
1061 let icons_json = find_file_recursive(extract_dir, "icons.json").await?;
1063
1064 let version = extract_fontawesome_version(extract_dir);
1066
1067 let fontawesome_cache = dirs::cache_dir()
1069 .map(|root| root.join("waterui").join("fontawesome"))
1070 .ok_or_eyre("Could not determine cache directory")?;
1071
1072 fs::create_dir_all(&fontawesome_cache).await?;
1073
1074 let dest = fontawesome_cache.join(format!("fontawesome-{version}-icons.json"));
1075 fs::copy(&icons_json, &dest).await?;
1076
1077 debug!("Copied icons.json to {}", dest.display());
1078 Ok(())
1079}
1080
1081async fn find_file_recursive(dir: &Path, filename: &str) -> eyre::Result<PathBuf> {
1083 let dir = dir.to_path_buf();
1084 let filename = filename.to_string();
1085 smol::unblock(move || {
1086 for entry in WalkDir::new(&dir) {
1087 let entry = entry?;
1088 if !entry.file_type().is_file() {
1089 continue;
1090 }
1091 if entry
1092 .file_name()
1093 .to_str()
1094 .is_some_and(|name| name == filename)
1095 {
1096 return Ok(entry.into_path());
1097 }
1098 }
1099 eyre::bail!("File '{}' not found in {}", filename, dir.display());
1100 })
1101 .await
1102}
1103
1104fn extract_fontawesome_version(extract_dir: &Path) -> String {
1106 if let Ok(entries) = std::fs::read_dir(extract_dir) {
1108 for entry in entries.flatten() {
1109 let name = entry.file_name().to_string_lossy().to_string();
1110 if name.starts_with("fontawesome-") {
1111 let parts: Vec<&str> = name.split('-').collect();
1113 for (i, part) in parts.iter().enumerate() {
1114 if part.chars().next().is_some_and(|c| c.is_ascii_digit()) {
1115 return parts[i..]
1116 .join("-")
1117 .split('-')
1118 .next()
1119 .unwrap_or("7.1.0")
1120 .to_string();
1121 }
1122 }
1123 }
1124 }
1125 }
1126 "7.1.0".to_string() }
1128
1129async fn find_font_file(dir: &Path, name: &str) -> eyre::Result<PathBuf> {
1133 let dir = dir.to_path_buf();
1134 let name = name.to_string();
1135 smol::unblock(move || {
1136 let mut candidates = Vec::new();
1137 let name_lower = name.to_lowercase();
1138
1139 let style_keyword =
1140 extract_style_keyword(&name_lower).unwrap_or_else(|| "regular".to_string());
1141
1142 for entry in WalkDir::new(&dir) {
1143 let entry = entry?;
1144 if !entry.file_type().is_file() {
1145 continue;
1146 }
1147
1148 let path = entry.into_path();
1149 let Some(ext) = path.extension() else {
1150 continue;
1151 };
1152 let ext = ext.to_string_lossy().to_lowercase();
1153 if ext != "ttf" && ext != "otf" {
1154 continue;
1155 }
1156
1157 let file_name = path
1158 .file_stem()
1159 .unwrap_or_default()
1160 .to_string_lossy()
1161 .to_lowercase();
1162
1163 if file_name.contains(&style_keyword) {
1164 let name_parts: Vec<&str> = name_lower.split_whitespace().collect();
1165 let matches_base = name_parts
1166 .iter()
1167 .take(3)
1168 .all(|part| file_name.contains(part));
1169 if matches_base {
1170 return Ok(path);
1171 }
1172 }
1173
1174 candidates.push(path);
1175 }
1176
1177 candidates
1178 .into_iter()
1179 .next()
1180 .ok_or_else(|| eyre::eyre!("No font file found in zip for '{}'", name))
1181 })
1182 .await
1183}
1184
1185fn extract_style_keyword(name: &str) -> Option<String> {
1192 let styles = [
1194 "solid", "regular", "brands", "light", "thin", "bold", "medium",
1195 ];
1196 for style in styles {
1197 if name.ends_with(style) || name.contains(&format!("-{style}")) {
1198 return Some(style.to_string());
1199 }
1200 }
1201 None
1202}
1203
1204fn sha256_hex(s: &str) -> String {
1206 use sha2::{Digest, Sha256};
1207 let mut hasher = Sha256::new();
1208 hasher.update(s.as_bytes());
1209 let result = hasher.finalize();
1210 hex::encode(result)
1211}
1212
1213pub async fn stage_project_assets_for_apple(
1220 project: &Project,
1221 dest_dir: &Path,
1222 sccache_path: Option<&Path>,
1223 dev_server: bool,
1224 progress: Option<&BuildProgress>,
1225) -> eyre::Result<BundleManifest> {
1226 unified::stage_for_apple(project, dest_dir, sccache_path, dev_server, progress).await
1227}
1228
1229pub async fn stage_project_assets_for_android(
1231 project: &Project,
1232 backend_path: &Path,
1233 sccache_path: Option<&Path>,
1234 dev_server: bool,
1235 progress: Option<&BuildProgress>,
1236) -> eyre::Result<BundleManifest> {
1237 unified::stage_for_android(project, backend_path, sccache_path, dev_server, progress).await
1238}
1239
1240#[cfg(target_os = "macos")]
1245pub fn project_macos_icns(project: &Project) -> eyre::Result<Vec<u8>> {
1246 unified::macos_icns(project)
1247}
1248
1249pub fn project_windows_ico(project: &Project) -> eyre::Result<Vec<u8>> {
1251 unified::windows_ico(project)
1252}
1253
1254pub async fn stage_hicolor_icons(project: &Project, icons_root: &Path) -> eyre::Result<()> {
1256 unified::stage_hicolor_icons(project, icons_root).await
1257}
1258
1259pub async fn stage_project_assets_for_gtk(
1261 project: &Project,
1262 resources_dir: &Path,
1263 sccache_path: Option<&Path>,
1264 dev_server: bool,
1265 progress: Option<&BuildProgress>,
1266) -> eyre::Result<BundleManifest> {
1267 unified::stage_for_gtk(project, resources_dir, sccache_path, dev_server, progress).await
1268}
1269
1270pub fn scan_project_font_assets(manifest: &BundleManifest) -> eyre::Result<Vec<ResolvedFont>> {
1272 unified::scan_project_fonts(manifest)
1273}
1274
1275pub use unified::LaunchAssets;
1276
1277pub fn project_launch_assets(project: &Project) -> eyre::Result<LaunchAssets> {
1279 unified::launch_assets(project)
1280}
1281
1282pub async fn stage_project_assets_for_web(project: &Project, site_root: &Path) -> eyre::Result<()> {
1284 web::stage_for_web(project, site_root).await
1285}
1286
1287pub async fn copy_fonts(fonts: &[ResolvedFont], dest: &Path) -> eyre::Result<()> {
1289 fs::create_dir_all(dest).await?;
1290
1291 for font in fonts {
1292 let file_name = font
1293 .path
1294 .file_name()
1295 .ok_or_eyre("Font path has no filename")?;
1296 let dest_path = dest.join(file_name);
1297
1298 debug!(
1299 "Copying font {} -> {}",
1300 font.path.display(),
1301 dest_path.display()
1302 );
1303 fs::copy(&font.path, &dest_path).await?;
1304 }
1305
1306 Ok(())
1307}
1308
1309pub async fn stage_hydrolysis_web_fonts(
1319 project: &Project,
1320 backend_path: &Path,
1321 site_root: &Path,
1322) -> eyre::Result<()> {
1323 let mut resolved_fonts =
1324 resolve_fonts(scan_fonts(project, &backend_path.join("Cargo.toml")).await?).await?;
1325 resolved_fonts.sort_by(|left, right| left.name.cmp(&right.name));
1326
1327 let Some(default_family) = resolved_fonts
1332 .iter()
1333 .find(|font| font.name == HYDROLYSIS_DEFAULT_FONT_FAMILY)
1334 .or_else(|| resolved_fonts.first())
1335 else {
1336 eyre::bail!(
1337 "the Hydrolysis web runtime has no system fonts to fall back on, so a web \
1338 build must bundle at least one declared font; declare one in Water.toml:\n\n\
1339 \x20 [[assets.font]]\n\x20 name = \"{HYDROLYSIS_DEFAULT_FONT_FAMILY}\"\n\n\
1340 or through `[package.metadata.waterui.assets.font]` in a dependency's \
1341 Cargo.toml"
1342 );
1343 };
1344 let default_family = default_family.name.clone();
1345
1346 let fonts_dest = site_root.join("fonts");
1347 copy_fonts(&resolved_fonts, &fonts_dest).await?;
1348 write_hydrolysis_web_font_manifest(&resolved_fonts, &fonts_dest, &default_family).await?;
1349 Ok(())
1350}
1351
1352async fn write_hydrolysis_web_font_manifest(
1353 fonts: &[ResolvedFont],
1354 fonts_dest: &Path,
1355 default_family: &str,
1356) -> eyre::Result<()> {
1357 let mut manifest_fonts = Vec::with_capacity(fonts.len());
1358
1359 for font in fonts {
1360 let file_name = font
1361 .path
1362 .file_name()
1363 .ok_or_eyre("Font path has no filename")?
1364 .to_string_lossy()
1365 .into_owned();
1366 manifest_fonts.push(HydrolysisWebFontManifestEntry {
1367 name: font.name.clone(),
1368 file_name,
1369 });
1370 }
1371
1372 let manifest = HydrolysisWebFontManifest {
1373 default_family: default_family.to_string(),
1374 fonts: manifest_fonts,
1375 };
1376 let payload = serde_json::to_vec_pretty(&manifest)?;
1377 fs::write(
1378 fonts_dest.join(HYDROLYSIS_WEB_FONT_MANIFEST_FILE_NAME),
1379 payload,
1380 )
1381 .await?;
1382 Ok(())
1383}
1384
1385pub async fn package_feature_enabled(
1404 build_manifest: &Path,
1405 package: &str,
1406 feature: &str,
1407) -> eyre::Result<bool> {
1408 let manifest_path = build_manifest.to_path_buf();
1409 let metadata = smol::unblock({
1410 let manifest_path = manifest_path.clone();
1411 move || {
1412 cargo_metadata::MetadataCommand::new()
1413 .manifest_path(&manifest_path)
1414 .exec()
1415 }
1416 })
1417 .await
1418 .wrap_err_with(|| {
1419 format!(
1420 "Failed to run cargo metadata on {}",
1421 build_manifest.display()
1422 )
1423 })?;
1424
1425 let Some(resolve) = metadata.resolve.as_ref() else {
1426 return Ok(false);
1427 };
1428 let enabled = metadata
1429 .packages
1430 .iter()
1431 .filter(|candidate| candidate.name.as_str() == package)
1432 .any(|candidate| {
1433 resolve
1434 .nodes
1435 .iter()
1436 .filter(|node| node.id == candidate.id)
1437 .any(|node| {
1438 node.features
1439 .iter()
1440 .any(|enabled| enabled.as_str() == feature)
1441 })
1442 });
1443 debug!("resolved feature {package}/{feature}: {enabled}");
1444 Ok(enabled)
1445}
1446
1447struct Capability {
1454 name: &'static str,
1457 package: &'static str,
1459 feature: Option<&'static str>,
1462}
1463
1464const OPTIONAL_CAPABILITIES: &[Capability] = &[
1486 Capability {
1487 name: "gpu",
1488 package: "waterui-graphics",
1489 feature: Some("gpu"),
1490 },
1491 Capability {
1492 name: "map",
1493 package: "waterui-map",
1494 feature: None,
1495 },
1496 Capability {
1502 name: "media",
1503 package: "waterui-video",
1504 feature: None,
1505 },
1506 Capability {
1511 name: "webview",
1512 package: "waterui-webview",
1513 feature: None,
1514 },
1515];
1516
1517pub async fn capability_enabled(
1534 project: &Project,
1535 build_manifest: &Path,
1536 capability: &str,
1537) -> eyre::Result<bool> {
1538 let capability = OPTIONAL_CAPABILITIES
1539 .iter()
1540 .find(|candidate| candidate.name == capability)
1541 .unwrap_or_else(|| panic!("unknown WaterUI capability: {capability}"));
1542 match capability.feature {
1543 Some(feature) => package_feature_enabled(build_manifest, capability.package, feature).await,
1544 None => project.links_runtime_package(capability.package).await,
1545 }
1546}
1547
1548pub async fn capability_ffi_features(
1564 project: &Project,
1565 build_manifest: &Path,
1566) -> eyre::Result<Vec<String>> {
1567 let mut features = Vec::new();
1568 for capability in OPTIONAL_CAPABILITIES {
1569 if capability_enabled(project, build_manifest, capability.name).await? {
1570 features.push(format!("waterui-ffi/{}", capability.name));
1571 }
1572 }
1573 Ok(features)
1574}
1575
1576pub async fn self_drawn_realization_features(
1601 project: &Project,
1602 build_manifest: &Path,
1603) -> eyre::Result<Vec<String>> {
1604 let mut features = Vec::new();
1605 let opted_in = package_feature_enabled(build_manifest, "waterui", "video-gpu").await?
1606 || project.links_runtime_package("waterui-video-gpu").await?;
1607 if opted_in {
1608 features.push("waterui-ffi/video".to_string());
1609 }
1610 Ok(features)
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615 use super::*;
1616 use std::fs;
1617 use tempfile::tempdir;
1618
1619 fn must_not_download<'a>(
1621 _url: &'a str,
1622 _dest: &'a Path,
1623 ) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>> {
1624 panic!("this declaration has no download to perform")
1625 }
1626
1627 fn place_font<'a>(
1629 url: &'a str,
1630 dest: &'a Path,
1631 ) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>> {
1632 assert_eq!(url, "https://example.com/inter.ttf");
1633 let dest = dest.to_path_buf();
1634 Box::pin(async move {
1635 std::fs::write(dest, b"font-bytes")?;
1636 Ok(())
1637 })
1638 }
1639
1640 fn fail_download<'a>(
1642 _url: &'a str,
1643 _dest: &'a Path,
1644 ) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>> {
1645 Box::pin(async { Err(eyre::eyre!("connection refused")) })
1646 }
1647
1648 fn manifest_with_fonts(toml_fonts: &str) -> crate::project::Manifest {
1649 toml::from_str(&format!(
1650 "[package]\ntype = \"app\"\nname = \"Demo\"\n\
1651 bundle_identifier = \"dev.example.demo\"\n\n{toml_fonts}"
1652 ))
1653 .expect("manifest parses")
1654 }
1655
1656 #[test]
1657 fn water_toml_font_with_a_name_alone_uses_the_registry() {
1658 let manifest = manifest_with_fonts("[[assets.font]]\nname = \"Inter\"");
1659 let declarations =
1660 manifest_font_declarations(&manifest, Path::new("/project")).expect("declarations");
1661 assert_eq!(declarations.len(), 1);
1662 assert_eq!(declarations[0].name, "Inter");
1663 assert_eq!(declarations[0].crate_name, "Demo");
1664 assert!(matches!(declarations[0].source, FontSource::BuiltIn));
1665 }
1666
1667 #[test]
1668 fn water_toml_font_local_path_resolves_against_the_project_root() {
1669 let manifest = manifest_with_fonts(
1670 "[[assets.font]]\nname = \"My Font\"\nlocal_path = \"fonts/my.ttf\"",
1671 );
1672 let declarations =
1673 manifest_font_declarations(&manifest, Path::new("/project")).expect("declarations");
1674 let FontSource::Local {
1675 crate_root,
1676 relative_path,
1677 } = &declarations[0].source
1678 else {
1679 panic!("expected a local font source");
1680 };
1681 assert_eq!(crate_root, Path::new("/project"));
1682 assert_eq!(relative_path, Path::new("fonts/my.ttf"));
1683 }
1684
1685 #[test]
1686 fn water_toml_font_rejects_conflicting_sources() {
1687 let manifest = manifest_with_fonts(
1688 "[[assets.font]]\nname = \"X\"\nlocal_path = \"a.ttf\"\nremote_path = \"https://x\"",
1689 );
1690 assert!(manifest_font_declarations(&manifest, Path::new("/project")).is_err());
1691 }
1692
1693 #[test]
1694 fn water_toml_font_rejects_an_absolute_local_path() {
1695 let manifest =
1696 manifest_with_fonts("[[assets.font]]\nname = \"X\"\nlocal_path = \"/abs/x.ttf\"");
1697 assert!(manifest_font_declarations(&manifest, Path::new("/project")).is_err());
1698 }
1699
1700 #[test]
1701 fn a_manifest_without_assets_declares_no_fonts() {
1702 let manifest = manifest_with_fonts("");
1703 assert!(
1704 manifest_font_declarations(&manifest, Path::new("/project"))
1705 .expect("declarations")
1706 .is_empty()
1707 );
1708 }
1709
1710 #[test]
1714 fn an_uncached_remote_font_names_the_font_url_and_cache_dir() {
1715 let cache_dir = tempdir().expect("temp cache dir");
1716 let error = smol::block_on(cached_font(
1717 "Inter",
1718 "https://example.com/inter.ttf",
1719 cache_dir.path(),
1720 ))
1721 .expect_err("an uncached remote font must surface as an error");
1722 let message = error.to_string();
1723 assert!(message.contains("Inter"), "{message}");
1724 assert!(
1725 message.contains("https://example.com/inter.ttf"),
1726 "{message}"
1727 );
1728 assert!(
1729 message.contains(&cache_dir.path().display().to_string()),
1730 "{message}"
1731 );
1732 assert!(message.contains("water fetch"), "{message}");
1733 }
1734
1735 #[test]
1740 fn fetch_places_a_missing_remote_font_where_the_build_looks() {
1741 let cache_dir = tempdir().expect("temp cache dir");
1742 let url = "https://example.com/inter.ttf";
1743 let expected = cache_dir.path().join(format!("{}.ttf", sha256_hex(url)));
1744
1745 let outcomes = smol::block_on(fetch_fonts(
1746 vec![FontDeclaration {
1747 name: "Inter".to_string(),
1748 source: FontSource::Remote {
1749 url: url.to_string(),
1750 },
1751 crate_name: "some-theme".to_string(),
1752 }],
1753 cache_dir.path(),
1754 place_font,
1755 ))
1756 .expect("fetch succeeds");
1757
1758 let [FetchOutcome::Fetched { name, path }] = outcomes.as_slice() else {
1759 panic!("expected one Fetched outcome, got {outcomes:?}");
1760 };
1761 assert_eq!(name, "Inter");
1762 assert_eq!(
1763 path, &expected,
1764 "the fetched font lands at the cache entry the build probes"
1765 );
1766 assert_eq!(
1767 fs::read(&expected).expect("read cached font"),
1768 b"font-bytes"
1769 );
1770
1771 let resolved = smol::block_on(cached_font("Inter", url, cache_dir.path()))
1772 .expect("the build resolves the font the fetch placed");
1773 assert_eq!(resolved, expected);
1774 }
1775
1776 #[test]
1779 fn fetch_is_a_no_op_for_a_font_already_in_the_cache() {
1780 let cache_dir = tempdir().expect("temp cache dir");
1781 let url = "https://example.com/inter.ttf";
1782 let cached = cache_dir.path().join(format!("{}.ttf", sha256_hex(url)));
1783 fs::write(&cached, b"cached-font").expect("seed the cache entry");
1784
1785 let outcomes = smol::block_on(fetch_fonts(
1786 vec![FontDeclaration {
1787 name: "Inter".to_string(),
1788 source: FontSource::Remote {
1789 url: url.to_string(),
1790 },
1791 crate_name: "some-theme".to_string(),
1792 }],
1793 cache_dir.path(),
1794 must_not_download,
1795 ))
1796 .expect("fetch succeeds");
1797
1798 let [FetchOutcome::Satisfied { name, path }] = outcomes.as_slice() else {
1799 panic!("expected one Satisfied outcome, got {outcomes:?}");
1800 };
1801 assert_eq!(name, "Inter");
1802 assert_eq!(path, &cached);
1803 }
1804
1805 #[test]
1808 fn a_failed_download_is_an_error_naming_the_font_and_url() {
1809 let cache_dir = tempdir().expect("temp cache dir");
1810
1811 let error = smol::block_on(fetch_fonts(
1812 vec![FontDeclaration {
1813 name: "Inter".to_string(),
1814 source: FontSource::Remote {
1815 url: "https://example.com/inter.ttf".to_string(),
1816 },
1817 crate_name: "some-theme".to_string(),
1818 }],
1819 cache_dir.path(),
1820 fail_download,
1821 ))
1822 .expect_err("a failed download must be an error");
1823 let message = format!("{error:#}");
1824 assert!(message.contains("Inter"), "{message}");
1825 assert!(
1826 message.contains("https://example.com/inter.ttf"),
1827 "{message}"
1828 );
1829 assert!(message.contains("connection refused"), "{message}");
1830 }
1831
1832 #[test]
1835 fn fetch_reports_an_unknown_builtin_name_the_way_the_build_does() {
1836 let cache_dir = tempdir().expect("temp cache dir");
1837
1838 let outcomes = smol::block_on(fetch_fonts(
1839 vec![FontDeclaration {
1840 name: "No Such Family".to_string(),
1841 source: FontSource::BuiltIn,
1842 crate_name: "some-theme".to_string(),
1843 }],
1844 cache_dir.path(),
1845 must_not_download,
1846 ))
1847 .expect("an unsatisfiable declaration is an outcome, not a fetch error");
1848
1849 let [FetchOutcome::Unsatisfiable { name, error }] = outcomes.as_slice() else {
1850 panic!("expected one Unsatisfiable outcome, got {outcomes:?}");
1851 };
1852 assert_eq!(name, "No Such Family");
1853 let message = format!("{error:#}");
1854 assert!(message.contains("No Such Family"), "{message}");
1855 assert!(message.contains("some-theme"), "{message}");
1856 assert!(message.contains("built-in registry"), "{message}");
1857 }
1858
1859 #[test]
1862 fn fetch_reports_a_missing_local_font_the_way_the_build_does() {
1863 let cache_dir = tempdir().expect("temp cache dir");
1864 let root = tempdir().expect("temp root");
1865
1866 let outcomes = smol::block_on(fetch_fonts(
1867 vec![FontDeclaration {
1868 name: "Roboto".to_string(),
1869 source: FontSource::Local {
1870 crate_root: root.path().to_path_buf(),
1871 relative_path: PathBuf::from("assets/fonts/Roboto-Variable.ttf"),
1872 },
1873 crate_name: "some-theme".to_string(),
1874 }],
1875 cache_dir.path(),
1876 must_not_download,
1877 ))
1878 .expect("an unsatisfiable declaration is an outcome, not a fetch error");
1879
1880 let [FetchOutcome::Unsatisfiable { name, error }] = outcomes.as_slice() else {
1881 panic!("expected one Unsatisfiable outcome, got {outcomes:?}");
1882 };
1883 assert_eq!(name, "Roboto");
1884 let message = format!("{error:#}");
1885 assert!(message.contains("Roboto"), "{message}");
1886 assert!(message.contains("Roboto-Variable.ttf"), "{message}");
1887 }
1888
1889 #[test]
1890 fn test_font_registry_has_entries() {
1891 let registry = FontRegistry::builtin().expect("registry parses");
1892 assert!(!registry.fonts.is_empty());
1893 assert!(registry.url("Inter").is_some());
1894 assert!(registry.url("Roboto").is_some());
1895 assert!(registry.url("Noto Sans CJK SC").is_some());
1896 assert!(
1898 !registry
1899 .fonts
1900 .iter()
1901 .any(|font| font.name.contains("Font Awesome"))
1902 );
1903 assert!(
1904 !registry
1905 .fonts
1906 .iter()
1907 .any(|font| font.name.contains("Material Design"))
1908 );
1909 }
1910
1911 #[test]
1914 fn font_registry_offers_a_math_face() {
1915 let registry = FontRegistry::builtin().expect("registry parses");
1916 let url = registry
1917 .url("STIX Two Math")
1918 .expect("registry must offer an OpenType MATH font");
1919 assert!(
1920 is_zip_url(url),
1921 "the math font must resolve through the archive path so the single \
1922 matching face is extracted rather than the whole distribution"
1923 );
1924 }
1925
1926 #[test]
1931 fn math_face_wins_over_its_text_siblings_in_the_same_archive() {
1932 let extracted = tempdir().expect("temp dir");
1933 let root = extracted.path().join("static_otf");
1934 fs::create_dir_all(&root).expect("create extract dir");
1935 for face in [
1936 "STIXTwoMath-Regular.otf",
1937 "STIXTwoText-Bold.otf",
1938 "STIXTwoText-BoldItalic.otf",
1939 "STIXTwoText-Italic.otf",
1940 "STIXTwoText-Medium.otf",
1941 "STIXTwoText-MediumItalic.otf",
1942 "STIXTwoText-Regular.otf",
1943 "STIXTwoText-SemiBold.otf",
1944 "STIXTwoText-SemiBoldItalic.otf",
1945 ] {
1946 fs::write(root.join(face), []).expect("write face");
1947 }
1948
1949 let selected = smol::block_on(find_font_file(extracted.path(), "STIX Two Math"))
1950 .expect("math face must be found");
1951
1952 assert_eq!(
1953 selected.file_name().expect("selected face has a name"),
1954 "STIXTwoMath-Regular.otf",
1955 "selected {} instead of the Math face",
1956 selected.display()
1957 );
1958 }
1959
1960 #[test]
1961 fn test_sha256_hex() {
1962 let hash = sha256_hex("hello");
1963 assert_eq!(hash.len(), 64); }
1965
1966 #[test]
1967 fn test_http_allowlist() {
1968 for url in [
1969 "http://localhost/font.ttf",
1970 "http://127.0.0.1:8080/font.ttf",
1971 "http://[::1]/font.ttf",
1972 "https://example.com/font.ttf",
1973 ] {
1974 assert!(
1975 waterui_assets_core::ensure_http_allowed(url).is_ok(),
1976 "expected to allow {url}"
1977 );
1978 }
1979 }
1980
1981 #[test]
1982 fn test_http_rejects_non_loopback_and_prefix_bypass() {
1983 for url in [
1984 "http://example.com/font.ttf",
1985 "http://localhost.evil.com/font.ttf",
1986 "http://127.0.0.1.evil.com/font.ttf",
1987 ] {
1988 assert!(
1989 waterui_assets_core::ensure_http_allowed(url).is_err(),
1990 "expected to reject {url}"
1991 );
1992 }
1993 }
1994
1995 #[test]
1996 fn zip_font_cache_uses_extracted_directory_without_archive() {
1997 let cache_dir = tempdir().expect("temp cache dir");
1998 let url = "https://example.com/inter.zip";
1999 let extracted_dir = cache_dir.path().join(sha256_hex(url));
2000 fs::create_dir_all(&extracted_dir).expect("create extracted dir");
2001 let extracted_font = extracted_dir.join("inter-regular.ttf");
2002 fs::write(&extracted_font, b"font").expect("write extracted font");
2003
2004 let resolved = smol::block_on(cached_font("Inter", url, cache_dir.path()))
2005 .expect("reuse extracted cache");
2006
2007 assert_eq!(resolved, extracted_font);
2008 }
2009
2010 #[test]
2015 fn a_declared_local_font_that_is_missing_fails_the_build() {
2016 let root = tempdir().expect("temp root");
2017 let error = smol::block_on(resolve_fonts(vec![FontDeclaration {
2018 name: "Roboto".to_string(),
2019 source: FontSource::Local {
2020 crate_root: root.path().to_path_buf(),
2021 relative_path: PathBuf::from("assets/fonts/Roboto-Variable.ttf"),
2022 },
2023 crate_name: "some-theme".to_string(),
2024 }]))
2025 .expect_err("a missing crate-local font must be an error");
2026
2027 let message = format!("{error:#}");
2028 assert!(
2029 message.contains("Roboto") && message.contains("some-theme"),
2030 "the error must name the font and the crate that declared it: {message}"
2031 );
2032 assert!(
2033 message.contains("Roboto-Variable.ttf"),
2034 "the error must name the path that was expected: {message}"
2035 );
2036 }
2037
2038 #[test]
2039 fn test_resolve_local_font_path_rejects_escape() {
2040 let root = tempdir().expect("temp root");
2041 let outside = tempdir().expect("temp outside");
2042 let outside_font = outside.path().join("outside.ttf");
2043 fs::write(&outside_font, b"font").expect("write outside font");
2044
2045 let rel_escape = Path::new("..").join(outside.path().file_name().expect("outside name"));
2046 let rel_escape = rel_escape.join("outside.ttf");
2047
2048 let result = resolve_local_font_path(root.path(), &rel_escape);
2049 assert!(result.is_err(), "expected path traversal to be rejected");
2050 }
2051
2052 #[test]
2053 fn test_resolve_local_font_path_accepts_inside_root() {
2054 let root = tempdir().expect("temp root");
2055 let inside_dir = root.path().join("fonts");
2056 fs::create_dir_all(&inside_dir).expect("create fonts dir");
2057 let font_path = inside_dir.join("inside.ttf");
2058 fs::write(&font_path, b"font").expect("write inside font");
2059
2060 let resolved = resolve_local_font_path(root.path(), Path::new("fonts/inside.ttf"))
2061 .expect("resolve should succeed")
2062 .expect("font should exist");
2063 assert_eq!(resolved, font_path.canonicalize().expect("canonical path"));
2064 }
2065}
2066
2067pub async fn scan_required_permissions(
2090 build_manifest: &Path,
2091) -> eyre::Result<Vec<RequiredPermission>> {
2092 let manifest_path = build_manifest.to_path_buf();
2093 let metadata = smol::unblock({
2094 let manifest_path = manifest_path.clone();
2095 move || {
2096 cargo_metadata::MetadataCommand::new()
2097 .manifest_path(&manifest_path)
2098 .exec()
2099 }
2100 })
2101 .await
2102 .wrap_err_with(|| {
2103 format!(
2104 "Failed to run cargo metadata on {}",
2105 build_manifest.display()
2106 )
2107 })?;
2108
2109 let enabled_features: HashMap<&PackageId, HashSet<&str>> = metadata
2110 .resolve
2111 .as_ref()
2112 .map(|resolve| {
2113 resolve
2114 .nodes
2115 .iter()
2116 .map(|node| (&node.id, node.features.iter().map(|f| f.as_str()).collect()))
2117 .collect()
2118 })
2119 .unwrap_or_default();
2120
2121 let mut required = Vec::new();
2122 for package in &metadata.packages {
2123 let Some(waterui) = package.metadata.get("waterui") else {
2124 continue;
2125 };
2126 let parsed: WaterUIMetadata = match serde_json::from_value(waterui.clone()) {
2127 Ok(parsed) => parsed,
2128 Err(error) => {
2129 warn!(
2130 "Failed to parse waterui metadata for {}: {error}",
2131 package.name
2132 );
2133 continue;
2134 }
2135 };
2136 let features = enabled_features
2137 .get(&package.id)
2138 .cloned()
2139 .unwrap_or_default();
2140 for (key, requirement) in parsed.permissions {
2141 if let Some(gate) = &requirement.required_feature
2142 && !features.contains(gate.as_str())
2143 {
2144 debug!(
2145 "Skipping {key:?} for {}: feature `{gate}` is not enabled",
2146 package.name
2147 );
2148 continue;
2149 }
2150 required.push(RequiredPermission {
2151 package: package.name.to_string(),
2152 key,
2153 reason: requirement.reason.clone(),
2154 evidence: PermissionEvidence::Declared,
2155 });
2156 }
2157 }
2158 if let Some(inferred) = infer_internet_from_http_clients(&metadata.packages, &required) {
2159 required.push(inferred);
2160 }
2161 required.sort_by(|left, right| {
2162 (left.key, left.package.as_str()).cmp(&(right.key, right.package.as_str()))
2163 });
2164 required.dedup();
2165 Ok(required)
2166}
2167
2168const HTTP_CLIENT_CRATES: &[&str] = &[
2173 "attohttpc",
2174 "curl",
2175 "hyper",
2176 "isahc",
2177 "reqwest",
2178 "surf",
2179 "ureq",
2180 "zenwave",
2181];
2182
2183fn infer_internet_from_http_clients(
2189 packages: &[cargo_metadata::Package],
2190 declared: &[RequiredPermission],
2191) -> Option<RequiredPermission> {
2192 if declared
2193 .iter()
2194 .any(|requirement| requirement.key == PermissionKey::Internet)
2195 {
2196 return None;
2197 }
2198 let mut clients: Vec<&str> = packages
2199 .iter()
2200 .map(|package| package.name.as_str())
2201 .filter(|name| HTTP_CLIENT_CRATES.contains(name))
2202 .collect();
2203 clients.sort_unstable();
2204 clients.dedup();
2205 if clients.is_empty() {
2206 return None;
2207 }
2208 Some(RequiredPermission {
2209 package: clients.join(", "),
2210 key: PermissionKey::Internet,
2211 reason: String::from("the dependency graph contains an HTTP client"),
2212 evidence: PermissionEvidence::Inferred,
2213 })
2214}
2215
2216fn missing_permissions<'a>(
2223 enabled: &HashSet<PermissionKey>,
2224 required: &'a [RequiredPermission],
2225 relevant: impl Fn(PermissionKey) -> bool,
2226) -> Vec<&'a RequiredPermission> {
2227 required
2228 .iter()
2229 .filter(|requirement| !enabled.contains(&requirement.key) && relevant(requirement.key))
2230 .collect()
2231}
2232
2233pub fn warn_missing_permissions(
2235 project: &Project,
2236 required: &[RequiredPermission],
2237 relevant: impl Fn(PermissionKey) -> bool,
2238) {
2239 let enabled: HashSet<PermissionKey> = project
2240 .manifest()
2241 .permissions
2242 .iter()
2243 .filter(|(_, entry)| entry.is_enabled())
2244 .map(|(key, _)| *key)
2245 .collect();
2246
2247 for requirement in missing_permissions(&enabled, required, relevant) {
2248 let key = permission_toml_key(requirement.key);
2249 match requirement.evidence {
2250 PermissionEvidence::Declared => warn!(
2251 "{} needs the `{key}` permission ({}). Add it to Water.toml:\n\n [permissions.{key}]\n enable = true\n",
2252 requirement.package, requirement.reason
2253 ),
2254 PermissionEvidence::Inferred => warn!(
2255 "This app likely needs the `{key}` permission: {} ({}). If it talks to the network, add it to Water.toml:\n\n [permissions.{key}]\n enable = true\n",
2256 requirement.reason, requirement.package
2257 ),
2258 }
2259 }
2260}
2261
2262fn permission_toml_key(key: PermissionKey) -> String {
2264 serde_json::to_value(key)
2265 .ok()
2266 .and_then(|value| value.as_str().map(str::to_owned))
2267 .unwrap_or_else(|| format!("{key:?}"))
2268}
2269
2270#[cfg(test)]
2271mod permission_audit_tests {
2272 use super::*;
2273 use tempfile::tempdir;
2274
2275 fn requirement(key: PermissionKey) -> RequiredPermission {
2276 RequiredPermission {
2277 package: String::from("waterui-map-gpu"),
2278 key,
2279 reason: String::from("downloads map styles and vector tiles"),
2280 evidence: PermissionEvidence::Declared,
2281 }
2282 }
2283
2284 fn package(name: &str) -> cargo_metadata::Package {
2285 let manifest = format!(
2286 r#"{{
2287 "name": "{name}",
2288 "version": "1.0.0",
2289 "id": "registry+https://github.com/rust-lang/crates.io-index#{name}@1.0.0",
2290 "dependencies": [],
2291 "targets": [],
2292 "features": {{}},
2293 "manifest_path": "/dev/null/Cargo.toml"
2294 }}"#
2295 );
2296 serde_json::from_str(&manifest).expect("synthesize a cargo package")
2297 }
2298
2299 fn write_crate(dir: &Path, name: &str, extra: &str) -> PathBuf {
2303 std::fs::create_dir_all(dir.join("src")).expect("crate src dir");
2304 let manifest = dir.join("Cargo.toml");
2305 std::fs::write(
2306 &manifest,
2307 format!(
2308 "[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n{extra}"
2309 ),
2310 )
2311 .expect("crate manifest");
2312 std::fs::write(dir.join("src/lib.rs"), "").expect("crate source");
2313 manifest
2314 }
2315
2316 #[test]
2321 fn a_permission_declared_in_the_built_crates_graph_is_scanned() {
2322 let project = tempdir().expect("temp project");
2323 write_crate(
2326 &project.path().join("theme"),
2327 "theme",
2328 "[package.metadata.waterui.permissions]\n\
2329 internet = { reason = \"downloads map styles and vector tiles\" }\n",
2330 );
2331 let ffi_manifest = write_crate(&project.path().join("ffi"), "app-ffi", "");
2332 let backend_manifest = write_crate(
2333 &project.path().join("hydrolysis"),
2334 "app-hydrolysis",
2335 "[dependencies]\ntheme = { path = \"../theme\" }\n",
2336 );
2337
2338 let required = smol::block_on(scan_required_permissions(&backend_manifest))
2339 .expect("scan the built crate's graph");
2340 assert!(
2341 required
2342 .iter()
2343 .any(|requirement| requirement.package == "theme"
2344 && requirement.key == PermissionKey::Internet
2345 && requirement.evidence == PermissionEvidence::Declared),
2346 "the backend graph must report the permission `theme` declares"
2347 );
2348
2349 let ffi = smol::block_on(scan_required_permissions(&ffi_manifest))
2350 .expect("scan the ffi crate's graph");
2351 assert!(
2352 ffi.is_empty(),
2353 "the ffi graph does not carry `theme` and must stay silent"
2354 );
2355 }
2356
2357 #[test]
2361 fn a_feature_enabled_in_the_built_crates_graph_is_seen() {
2362 let project = tempdir().expect("temp project");
2363 write_crate(
2364 &project.path().join("theme"),
2365 "theme",
2366 "[features]\nextra = []\n",
2367 );
2368 let ffi_manifest = write_crate(&project.path().join("ffi"), "app-ffi", "");
2369 let backend_manifest = write_crate(
2370 &project.path().join("hydrolysis"),
2371 "app-hydrolysis",
2372 "[dependencies]\ntheme = { path = \"../theme\", features = [\"extra\"] }\n",
2373 );
2374
2375 assert!(
2376 smol::block_on(package_feature_enabled(&backend_manifest, "theme", "extra"))
2377 .expect("scan the built crate's graph")
2378 );
2379 assert!(
2380 !smol::block_on(package_feature_enabled(&ffi_manifest, "theme", "extra"))
2381 .expect("scan the ffi crate's graph")
2382 );
2383 }
2384
2385 #[test]
2386 fn an_http_client_in_the_graph_suggests_internet() {
2387 let packages = vec![package("serde"), package("zenwave")];
2388
2389 let inferred = infer_internet_from_http_clients(&packages, &[])
2390 .expect("zenwave should trigger the suggestion");
2391
2392 assert_eq!(inferred.key, PermissionKey::Internet);
2393 assert_eq!(inferred.evidence, PermissionEvidence::Inferred);
2394 assert!(inferred.package.contains("zenwave"));
2395 }
2396
2397 #[test]
2398 fn a_declared_internet_requirement_silences_the_inference() {
2399 let packages = vec![package("reqwest")];
2400 let declared = vec![requirement(PermissionKey::Internet)];
2401
2402 assert!(infer_internet_from_http_clients(&packages, &declared).is_none());
2403 }
2404
2405 #[test]
2406 fn a_graph_without_http_clients_suggests_nothing() {
2407 let packages = vec![package("serde"), package("tracing")];
2408
2409 assert!(infer_internet_from_http_clients(&packages, &[]).is_none());
2410 }
2411
2412 #[test]
2413 fn a_declared_permission_the_app_enabled_is_not_reported() {
2414 let enabled = HashSet::from([PermissionKey::Internet]);
2415 let required = vec![requirement(PermissionKey::Internet)];
2416
2417 assert_eq!(
2418 missing_permissions(&enabled, &required, |_| true),
2419 [] as [&RequiredPermission; 0]
2420 );
2421 }
2422
2423 #[test]
2424 fn a_missing_permission_is_reported_once() {
2425 let required = vec![requirement(PermissionKey::Internet)];
2426
2427 let missing = missing_permissions(&HashSet::new(), &required, |_| true);
2428
2429 assert_eq!(missing.len(), 1);
2430 assert_eq!(missing[0].key, PermissionKey::Internet);
2431 }
2432
2433 #[test]
2436 fn a_permission_the_platform_does_not_declare_stays_quiet() {
2437 let required = vec![requirement(PermissionKey::Internet)];
2438
2439 let android = missing_permissions(&HashSet::new(), &required, |key| {
2440 key.android_permission_name().is_some()
2441 });
2442 let ios = missing_permissions(&HashSet::new(), &required, |key| {
2443 key.ios_plist_key().is_some()
2444 });
2445
2446 assert_eq!(android.len(), 1, "Android must ask for INTERNET");
2447 assert!(ios.is_empty(), "iOS declares no network permission");
2448 }
2449
2450 #[test]
2451 fn the_reported_key_matches_the_water_toml_spelling() {
2452 assert_eq!(permission_toml_key(PermissionKey::Internet), "internet");
2453 assert_eq!(
2454 permission_toml_key(PermissionKey::CoarseLocation),
2455 "coarse_location"
2456 );
2457 }
2458}