Skip to main content

waterui_cli/project_model/
assets.rs

1//! Asset and font management for `WaterUI` projects.
2//!
3//! This module provides functionality to:
4//! - Scan the project manifest (`[[assets.font]]` in `Water.toml`) and
5//!   dependency crates (`[[package.metadata.waterui.assets.font]]`) for font
6//!   declarations
7//! - Resolve fonts from local paths, the font cache, or the built-in registry
8//! - Copy assets to platform-specific locations
9
10use 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/// A font the CLI can fetch when a crate names it and nothing else.
34#[derive(Debug, Clone, Deserialize)]
35struct RegistryFont {
36    /// Family name a crate declares.
37    name: String,
38    /// Where the face, or an archive containing it, is fetched from.
39    url: String,
40}
41
42/// The built-in font registry.
43///
44/// The list is data, so it lives in `assets/fonts.toml` rather than in Rust
45/// literals, and is parsed rather than compiled into a table. Fonts it offers
46/// can be declared by name alone — in a crate's Cargo.toml:
47///
48/// ```toml
49/// [[package.metadata.waterui.assets.font]]
50/// name = "Inter"
51/// ```
52///
53/// or in the project's `Water.toml`:
54///
55/// ```toml
56/// [[assets.font]]
57/// name = "Inter"
58/// ```
59///
60/// Icon pack fonts (Font Awesome, Material Icons, Lucide, ...) do NOT belong
61/// here. They declare their own faces in their own Cargo.toml with
62/// `remote_path` and `required-feature`.
63#[derive(Debug, Clone, Deserialize)]
64struct FontRegistry {
65    #[serde(rename = "font")]
66    fonts: Vec<RegistryFont>,
67}
68
69impl FontRegistry {
70    /// The registry shipped with this CLI.
71    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    /// Where `name` is fetched from, if the registry offers it.
77    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/// A font declaration — from `[[assets.font]]` in `Water.toml` or a crate's
88/// `[package.metadata.waterui.assets.font]` Cargo.toml metadata.
89#[derive(Debug, Clone)]
90pub struct FontDeclaration {
91    /// Font family name (used as `font_family` in Text).
92    pub name: String,
93    /// Source of the font file.
94    pub source: FontSource,
95    /// Crate or project that declared this font.
96    pub crate_name: String,
97}
98
99/// Source of a font file.
100#[derive(Debug, Clone)]
101pub enum FontSource {
102    /// Font bundled with the crate at a local path.
103    Local {
104        /// Absolute path to the crate root.
105        crate_root: PathBuf,
106        /// Relative path within the crate.
107        relative_path: PathBuf,
108    },
109    /// Font that must be fetched out of band into the font cache.
110    Remote {
111        /// URL to fetch the font from when pre-seeding the cache.
112        url: String,
113    },
114    /// Font from the built-in registry.
115    BuiltIn,
116}
117
118/// A resolved font with its absolute path.
119#[derive(Debug, Clone)]
120pub struct ResolvedFont {
121    /// Font family name.
122    pub name: String,
123    /// Absolute path to the font file.
124    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/// Font metadata from Cargo.toml `[package.metadata.waterui.assets]`.
140#[derive(Debug, Deserialize)]
141struct WaterUIMetadata {
142    #[serde(default)]
143    assets: AssetsMetadata,
144    /// Permissions this crate cannot work without, keyed by logical permission.
145    #[serde(default)]
146    permissions: BTreeMap<PermissionKey, PermissionRequirement>,
147}
148
149/// One crate's declaration that it needs a permission to function.
150#[derive(Debug, Deserialize)]
151struct PermissionRequirement {
152    /// Human-readable justification, shown to the application author.
153    reason: String,
154    /// Only required when this cargo feature is enabled on the declaring crate.
155    #[serde(default, rename = "required-feature")]
156    required_feature: Option<String>,
157}
158
159/// A permission some dependency needs, and why.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct RequiredPermission {
162    /// Crate that declared the requirement.
163    pub package: String,
164    /// The logical permission.
165    pub key: PermissionKey,
166    /// Why that crate needs it.
167    pub reason: String,
168    /// How the requirement was established.
169    pub evidence: PermissionEvidence,
170}
171
172/// How confident the audit is that a permission is actually needed.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum PermissionEvidence {
175    /// The crate declared the requirement in its own manifest metadata.
176    Declared,
177    /// Inferred from the shape of the dependency graph; may be a false
178    /// positive, so the report is phrased as a suggestion.
179    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    /// Optional feature that must be enabled for this font to be included.
196    /// If specified, the font will only be bundled if this feature is enabled
197    /// for the declaring package.
198    #[serde(default, rename = "required-feature")]
199    required_feature: Option<String>,
200}
201
202/// Fonts the project itself declares as `[[assets.font]]` tables in
203/// `Water.toml`.
204///
205/// The fields mirror what a crate writes under
206/// `[package.metadata.waterui.assets.font]`; `local_path` is resolved against
207/// the project root rather than a crate root. A declaration is one source:
208/// setting both paths — or an absolute `local_path` — is a manifest error.
209fn 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                // A rooted path in any flavor — `/x`, `\x`, `C:\x`, `C:x` —
222                // escapes the project root on some platform, so reject it on
223                // all of them.
224                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
258/// Scans the project manifest and the built crate's dependencies for font
259/// declarations.
260///
261/// `[[assets.font]]` tables in `Water.toml` declare the app's own fonts and
262/// rank ahead of dependency declarations between equal sources; the
263/// dependency half comes from [`scan_crate_font_declarations`].
264pub 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
273/// Scans `build_manifest`'s dependency graph for
274/// `[package.metadata.waterui.assets.font]` declarations via `cargo metadata`.
275///
276/// `build_manifest` is the `Cargo.toml` of the crate this build compiles,
277/// i.e. the generated backend or FFI crate. That crate depends on the app, so
278/// the graph carries both the app's authored dependencies and the backend's
279/// own (the theme crate and friends); scanning the app manifest instead
280/// would miss the backend's declarations entirely.
281///
282/// Fonts with a `required-feature` field will only be included if that feature
283/// is enabled for the declaring package (checked via cargo metadata's resolved graph).
284async 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    // Run cargo metadata to get all packages
291    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    // Build map of package_id -> enabled features from resolved graph
309    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        // Skip if no waterui metadata
325        let Some(waterui) = package.metadata.get("waterui") else {
326            continue;
327        };
328
329        // Parse the metadata
330        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        // Get enabled features for this package from resolve
342        let enabled_features = enabled_features_map
343            .get(&package.id)
344            .cloned()
345            .unwrap_or_default();
346
347        // Process font declarations
348        for font_meta in waterui_meta.assets.font {
349            // Skip if required feature is not enabled
350            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                // Local path - resolve relative to crate root
371                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                // Just name - use built-in registry
386                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
401/// Resolves and satisfies font declarations for a build.
402///
403/// Resolution is [`resolve_declarations`]: same `name` → keep only one font,
404/// local > remote > built-in, `Water.toml` ahead of dependencies on a tie.
405/// Satisfaction is [`satisfy_font`]: remote and built-in declarations resolve
406/// to files already in the font cache — a build performs no network access,
407/// so a declaration that is not already cached is an error naming the font,
408/// its URL, and `water fetch`.
409pub 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, &registry).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
427/// Deduplicates font declarations into the set a build or a fetch then
428/// satisfies — the resolution half of [`resolve_fonts`], shared so a fetch
429/// can never disagree with the build that follows it.
430///
431/// Rules:
432/// - Same `name` → keep only one font
433/// - Priority: local > remote > built-in; between equal sources the earlier
434///   declaration wins, so `Water.toml` overrides a dependency on a tie
435///
436/// Winners come out sorted by family name so reports read deterministically.
437fn resolve_declarations(declarations: Vec<FontDeclaration>) -> Vec<FontDeclaration> {
438    // Group by name
439    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            // Sort by priority: Local > Remote > BuiltIn, take the first.
448            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
460/// Satisfies one resolved declaration against crate-local files and the font
461/// cache — the build path, which never touches the network.
462///
463/// A declaration that cannot be satisfied is an error, never a skip:
464/// skipping renders the app in whatever face the shaper falls back to, which
465/// is the silent wrong-typeface this module exists to rule out.
466async 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            // A crate-local font missing at build time means the crate did
479            // not package what it declares — say so, with the path that was
480            // expected.
481            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                // Declared by name alone and the registry has no such
497                // family: nothing can satisfy it, so this fails here rather
498                // than at the first glyph the shaper draws in some other
499                // face.
500                return Err(unsatisfiable_builtin_font(decl));
501            };
502            cached_font(name, url, cache_dir).await
503        }
504    }
505}
506
507/// The report for a crate-local declaration whose file is absent — used by
508/// both the build and `water fetch`, which reports the same thing it cannot
509/// fix.
510fn 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
519/// The report for a by-name declaration the built-in registry does not know —
520/// used by both the build and `water fetch`.
521fn 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
531/// Gets the cache directory holding fonts fetched out of band.
532fn 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
566/// Resolves a remotely-declared font to a file already in the font cache.
567///
568/// A build performs no network access: when the declaration is not already
569/// cached, this fails naming the font, its URL and the cache directory, so the
570/// user can run `water fetch` and retry the build.
571async 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
577/// Probes the font cache for the face `url` declares.
578///
579/// `Some` is exactly the path [`cached_font`] resolves to; `None` means
580/// nothing usable is cached and `water fetch` can place it. A zero-length
581/// entry is dropped and reported absent.
582async fn cached_font_entry(
583    name: &str,
584    url: &str,
585    cache_dir: &Path,
586) -> eyre::Result<Option<PathBuf>> {
587    // Use URL hash as filename to avoid conflicts
588    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
661/// The error for a remote font declaration that is not already in the font
662/// cache. Builds perform no network access, so the download is a separate,
663/// opt-in command — the message names the font, the URL it is fetched from,
664/// the cache directory, and `water fetch`.
665fn 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/// What seeding the font cache did for one resolved font declaration.
689///
690/// `water fetch` and `water create` report these; a build never produces
691/// them because it never fetches — it turns the same states into errors.
692#[derive(Debug)]
693pub enum FetchOutcome {
694    /// Already satisfied — a crate-local file that exists, or a cache entry
695    /// — so nothing was downloaded.
696    Satisfied {
697        /// Font family name.
698        name: String,
699        /// The file a build resolves this font to.
700        path: PathBuf,
701    },
702    /// The declaration's URL was downloaded into the font cache.
703    Fetched {
704        /// Font family name.
705        name: String,
706        /// The file a build resolves this font to — the `.ttf` cache entry
707        /// itself, or the one face extracted out of a downloaded archive.
708        path: PathBuf,
709    },
710    /// No download can satisfy the declaration: a crate-local file that does
711    /// not exist, or a family name the built-in registry does not know.
712    /// `error` is the same report a build gives it — saying so is the point.
713    Unsatisfiable {
714        /// Font family name.
715        name: String,
716        /// What a build reports for this declaration.
717        error: eyre::Report,
718    },
719}
720
721/// Seeds the font cache with every font a build of `project` would demand.
722///
723/// `water fetch` and the tail of `water create` share this one path:
724/// declarations resolve exactly as a build resolves them —
725/// [`manifest_font_declarations`] plus [`scan_crate_font_declarations`] over
726/// the manifest of each crate a build compiles, then [`resolve_declarations`]
727/// — and whatever the cache does not already hold is downloaded into the
728/// entry the build then looks for. Builds keep their no-network guarantee;
729/// this is the explicit, opt-in network step.
730///
731/// # Errors
732///
733/// Returns an error when a backend crate cannot be scaffolded, a manifest
734/// cannot be scanned, or a download fails — the error names the backend, or
735/// the font and its URL. Declarations fetching can never satisfy arrive as
736/// [`FetchOutcome::Unsatisfiable`] instead, carrying the same report the
737/// build gives them.
738pub 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
746/// The crate manifests a build of `project` scans for font declarations —
747/// produced before they are scanned, the same way the build that compiles
748/// them produces them.
749///
750/// The project crate is always scanned; beyond it, the manifests that matter
751/// are the generated crates': the theme crate's
752/// `[package.metadata.waterui.assets.font]` entries are reachable only
753/// through the backend manifest that depends on it. Apple and Android builds
754/// scan the FFI companion, which `Project::open` scaffolds whenever either
755/// backend is managed; if it is still absent it is scaffolded here. Each of
756/// the GTK4, Hydrolysis and `WinUI` crates is re-scaffolded with the
757/// current templates when missing or stale, exactly as the build and preview
758/// paths regenerate it. Scaffolding writes template files — nothing
759/// compiles. The ESP32 harness never takes part: no build scans it for
760/// fonts — `dew`'s fonts come from `[backends.esp32]` as plain files.
761///
762/// The scanned set follows the project: an app contributes the crates of the
763/// backends it has configured, a playground the crates for the backends this
764/// host can run — Hydrolysis anywhere, GTK4 on Linux, `WinUI` on Windows —
765/// since the CLI manages them all. A crate that cannot be produced is an
766/// error naming its backend — silently dropping it is how a build's font
767/// demand and a fetch's scan disagree.
768async 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
795/// A generated backend crate — GTK4, Hydrolysis or `WinUI` — whose manifest
796/// a build scans for font declarations.
797trait FontScanCrate: crate::backend::Backend {
798    /// The backend's name as `water backend` reports it.
799    const NAME: &'static str;
800    /// Whether a build of `project` can ever compile this crate: a
801    /// playground can run every backend this host supports — the CLI manages
802    /// all of them — while an app builds only the backends it has
803    /// configured.
804    fn wanted(project: &Project) -> bool;
805    /// Whether the crate on disk is missing or behind the current templates
806    /// — the check the build and preview paths apply before regenerating.
807    fn stale(project: &Project) -> impl Future<Output = eyre::Result<bool>> + Send;
808}
809
810/// Scaffolds `B`'s crate the way the build that compiles it would —
811/// [`crate::backend::reinit_backend`] when it is missing or stale — and
812/// pushes its `Cargo.toml` onto `manifests`. A crate that cannot be produced
813/// is an error naming the backend, never a skip.
814async 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        // GTK4 compiles on Linux hosts only, so a playground elsewhere never
839        // builds this crate.
840        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        // `WinUI` compiles on Windows hosts only, so a playground elsewhere
861        // never builds this crate.
862        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
870/// How `fetch_fonts` downloads one URL to a path — a seam a test closes
871/// with a stub so it never reaches the network.
872type FontFetch =
873    for<'a> fn(&'a str, &'a Path) -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
874
875/// Fetches into `cache_dir` every font `declarations` resolves to.
876///
877/// Resolution is the same [`resolve_fonts`] applies —
878/// [`resolve_declarations`] — so a fetch can never disagree with the build
879/// that follows it, and a declaration already satisfied reports
880/// [`FetchOutcome::Satisfied`] without being downloaded again.
881///
882/// # Errors
883///
884/// A failed download aborts the run with an error naming the font and its
885/// URL; a quiet skip would leave the next build failing on a font this run
886/// was supposed to place.
887async 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            // Whatever fetching cannot fix — a crate-local file that is not
897            // there — is reported exactly as the build reports it.
898            FontSource::Local { .. } => match satisfy_font(&decl, cache_dir, &registry).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, &registry) {
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                    // Only a `BuiltIn` declaration reaches this arm.
925                    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
937/// The URL a declaration is fetched from, when it has one: `Remote`
938/// declarations carry their own; `BuiltIn` ones resolve through the
939/// registry; `Local` ones have none.
940fn 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
948/// Downloads one remote font into the cache entry a build looks for.
949///
950/// `url` lands at `{sha256(url)}.ttf` — or `.zip` for an archive — the same
951/// name the build probes, via a `.partial` sibling so an interrupted
952/// transfer never reads as a cached font. An archive is extracted the way
953/// the build's first resolution extracts it.
954async 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
991/// `GET`s `url` into `dest` — the same request shape `framework.rs` and
992/// `browser_runtime.rs` send for their own downloads: a `zenwave` client, a
993/// `GET` under the crate's user agent, streamed to the path.
994fn 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
1009/// Finds a font file in an extracted zip archive.
1010async fn find_font_in_extracted_zip(zip_path: &Path, name: &str) -> eyre::Result<PathBuf> {
1011    let extract_dir = zip_path.with_extension("");
1012
1013    // Extract if not already done
1014    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        // Font Awesome's generated Rust bindings require the archive metadata.
1030        // Other font ZIPs do not contain icons.json and must not be treated as
1031        // damaged Font Awesome distributions.
1032        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    // Find a font file (.ttf or .otf)
1040    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
1057/// Copies Font Awesome icons.json to the fontawesome cache directory.
1058///
1059/// This is needed by the fontawesome7 crate's build.rs to generate icon definitions.
1060async fn copy_fontawesome_icons_json(extract_dir: &Path) -> eyre::Result<()> {
1061    // Look for metadata/icons.json in the extracted archive
1062    let icons_json = find_file_recursive(extract_dir, "icons.json").await?;
1063
1064    // Determine version from parent directory name (e.g., "fontawesome-free-7.1.0-desktop")
1065    let version = extract_fontawesome_version(extract_dir);
1066
1067    // Copy to fontawesome cache directory
1068    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
1081/// Recursively finds a file by name in a directory.
1082async 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
1104/// Extracts Font Awesome version from directory structure.
1105fn extract_fontawesome_version(extract_dir: &Path) -> String {
1106    // Try to find version from directory names like "fontawesome-free-7.1.0-desktop"
1107    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                // Extract version: "fontawesome-free-7.1.0-desktop" -> "7.1.0"
1112                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() // Default fallback
1127}
1128
1129/// Recursively finds a font file in a directory.
1130///
1131/// Supports TTF and OTF formats.
1132async 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
1185/// Extract style keyword from font family name for matching.
1186///
1187/// Handles patterns like:
1188/// - "FontAwesome7Free-Solid" -> "solid"
1189/// - "FontAwesome7Free-Regular" -> "regular"
1190/// - "FontAwesome7Free-Brands" -> "brands"
1191fn extract_style_keyword(name: &str) -> Option<String> {
1192    // Check for common style suffixes
1193    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
1204/// Computes SHA256 hash of a string as hex.
1205fn 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
1213/// Stage project assets for Apple packaging (Asset Catalog + raw resources).
1214///
1215/// `sccache_path` feeds the host library build whose symbol table carries the
1216/// `include_bundle!` mount metadata; pass `None` when no sccache binary was
1217/// detected. Returns the staged manifest so callers can scan it (fonts, for
1218/// example) without rebuilding the host artifact.
1219pub 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
1229/// Stage project assets for Android packaging (res + assets/raw).
1230pub 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/// Render the project's macOS `.icns` app icon for hand-assembled bundles.
1241///
1242/// Gated to macOS along with the rest of the `.icns` chain, whose only caller is
1243/// the macOS packaging path.
1244#[cfg(target_os = "macos")]
1245pub fn project_macos_icns(project: &Project) -> eyre::Result<Vec<u8>> {
1246    unified::macos_icns(project)
1247}
1248
1249/// Render the project's Windows `.ico` app icon for resource embedding.
1250pub fn project_windows_ico(project: &Project) -> eyre::Result<Vec<u8>> {
1251    unified::windows_ico(project)
1252}
1253
1254/// Install the app icon into a hicolor icon-theme tree for Linux desktops.
1255pub async fn stage_hicolor_icons(project: &Project, icons_root: &Path) -> eyre::Result<()> {
1256    unified::stage_hicolor_icons(project, icons_root).await
1257}
1258
1259/// Stage project assets for GTK4 packaging (resources + gresource bundle).
1260pub 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
1270/// Resolves the fonts declared inside an already-staged bundle manifest.
1271pub fn scan_project_font_assets(manifest: &BundleManifest) -> eyre::Result<Vec<ResolvedFont>> {
1272    unified::scan_project_fonts(manifest)
1273}
1274
1275pub use unified::LaunchAssets;
1276
1277/// Resolve the project's launch screen and load its artwork.
1278pub fn project_launch_assets(project: &Project) -> eyre::Result<LaunchAssets> {
1279    unified::launch_assets(project)
1280}
1281
1282/// Stage project assets for web packaging.
1283pub 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
1287/// Copies fonts to a destination directory.
1288pub 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
1309/// Stage fonts for the Hydrolysis web runtime using the existing CLI font pipeline.
1310///
1311/// The web runtime discovers no system fonts — `load_web_fonts` fetches exactly
1312/// what `waterui-fonts.json` lists and asserts its `default_family` registered
1313/// — so the bundled set is whatever the project declared through
1314/// `[[assets.font]]` in `Water.toml` or a dependency's
1315/// `[package.metadata.waterui.assets.font]` entries. A project that declares no
1316/// fonts cannot render text on the web at all, so staging fails fast rather
1317/// than shipping a site that panics on startup.
1318pub 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    // `default_family` must name a face the manifest actually carries. Roboto
1328    // stays the default when the project declares it — the same family the
1329    // runtime's Material baseline was written against — otherwise the first
1330    // declared family takes the slot.
1331    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
1385/// Returns whether `feature` is enabled on `package` in the resolved
1386/// dependency graph of `build_manifest`.
1387///
1388/// `build_manifest` is the `Cargo.toml` of the crate the build actually
1389/// compiles — the generated FFI crate for Apple and Android, the generated
1390/// backend crate for the self-drawn backends. That crate depends on the app,
1391/// so its graph carries both the app's authored dependencies and the backend's
1392/// own; resolving any other manifest misses declarations only the backend's
1393/// dependencies make.
1394///
1395/// Optional `WaterUI` capabilities are cargo features on the FFI crate, and the
1396/// native backends must compile the matching component only when the app turned
1397/// that capability on. The resolved graph is the single source of truth for
1398/// that, so backends never guess from the manifest text.
1399///
1400/// # Errors
1401///
1402/// Returns an error when `cargo metadata` cannot be read.
1403pub 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
1447/// An optional `WaterUI` capability, and what in the app's resolved graph says
1448/// the app has it.
1449///
1450/// The name is also the feature the FFI crate exports the capability's C
1451/// surface under, so one entry drives both the FFI build and the native
1452/// backend's conditional compilation.
1453struct Capability {
1454    /// The capability's name, shared with `waterui-ffi`'s feature of the same
1455    /// name.
1456    name: &'static str,
1457    /// The crate that actually provides the capability.
1458    package: &'static str,
1459    /// The feature on that crate that carries it, or `None` when depending on
1460    /// the crate at all is the opt-in.
1461    feature: Option<&'static str>,
1462}
1463
1464/// Optional `WaterUI` capabilities, each keyed on the crate that actually
1465/// provides it.
1466///
1467/// The source of truth is the *providing* crate, never a facade toggle. An app
1468/// that sets `waterui = { default-features = false }` can still pull the GPU
1469/// stack in through a side door — every SVG icon pack renders through
1470/// `waterui-svg`, which enables `waterui-graphics/gpu` on its own — and such an
1471/// app emits `GpuSurface` views at runtime. Keying on the facade's `gpu` there
1472/// pruned the FFI exports and the native backend while the authoring layer kept
1473/// producing GPU views, which is a guaranteed panic on first render. Reading
1474/// the resolved graph's `waterui-graphics/gpu` instead makes the exported
1475/// surface follow what the app can actually express.
1476///
1477/// `gpu` is default-on for the facade, but the generated FFI crate must set
1478/// `default-features = false` (the `c-api` and `android-jni` ABIs are mutually
1479/// exclusive), which drops it. Forwarding it here is what keeps the GPU C
1480/// surface present for apps whose graphs carry the GPU stack.
1481///
1482/// `map` has no feature at all: `waterui-map` is a component crate an app
1483/// depends on directly, exactly like an icon pack or a browser engine, so
1484/// linking it *is* the opt-in.
1485const 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    // The `Video`/`Media` playback FFI surface and the `waterkit_audio`
1497    // keep-alive behind it. `waterui-video` is an optional dependency of the
1498    // facade (`media`/`video` features), so linking it *is* the opt-in — an app
1499    // that never plays media stops rooting the codec/streaming graph through
1500    // `waterui_video_*` exports.
1501    Capability {
1502        name: "media",
1503        package: "waterui-video",
1504        feature: None,
1505    },
1506    // The `WebView` FFI surface (bridge script, JS replies, cookie jar).
1507    // `waterui-webview` is optional on the facade and the browser-cef crate
1508    // reaches it through its own `webview` feature, so a plain `links` check
1509    // covers both entry points.
1510    Capability {
1511        name: "webview",
1512        package: "waterui-webview",
1513        feature: None,
1514    },
1515];
1516
1517/// Returns whether this app's resolved graph carries the named capability.
1518///
1519/// This is the one predicate every consumer of a capability must share: the
1520/// FFI build forwards the capability's feature, and the native backend build
1521/// compiles the matching components, from this same answer. The FFI features
1522/// are passed on the build command line rather than written into the
1523/// generated manifest, so re-resolving `waterui-ffi`'s own features from the
1524/// manifest graph would always read them as off — the backend then prunes
1525/// components whose symbols the dylib does export.
1526///
1527/// `build_manifest` is the manifest of the crate being built — the resolved
1528/// graph the feature check reads.
1529///
1530/// # Errors
1531///
1532/// Returns an error when `cargo metadata` cannot be read.
1533pub 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
1548/// Returns the `waterui-ffi` features to enable for this app's capabilities.
1549///
1550/// An app opts into a capability through its dependency graph — a component
1551/// crate it depends on (`waterui-map`), or a crate that carries the capability
1552/// with it (an SVG icon pack carries `waterui-graphics/gpu`). The generated FFI
1553/// crate is what exports that capability's C surface, so the resolved graph's
1554/// choice has to reach its build; reading it back out keeps one declaration in
1555/// the app's manifest.
1556///
1557/// `build_manifest` is the manifest of the crate being built — the FFI
1558/// companion for Apple and Android builds.
1559///
1560/// # Errors
1561///
1562/// Returns an error when `cargo metadata` cannot be read.
1563pub 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
1576/// Returns the `waterui-ffi` features that select `WaterUI`'s own realizations
1577/// of the semantic components the facade carries, for a platform with no
1578/// native primitive to bridge.
1579///
1580/// Apple bridges `AVPlayer`, so an Apple build asks for none of these and links
1581/// no player. Every other platform draws the video itself, and the
1582/// application's composition root — `waterui::app::App` — is what installs it,
1583/// so the choice travels as a facade feature rather than as a backend
1584/// dependency. The realization is opt-in: linking it pulls decoders such as
1585/// rav1d and symphonia into the artifact, so the FFI build selects it only when
1586/// the application declared `waterui`'s `video-gpu` feature (or the
1587/// `waterui-video-gpu` crate) in its own dependency graph.
1588///
1589/// Realizations that live in their own crates — `waterui-map-gpu` — are not
1590/// here. The application depends on such a crate directly and installs it from
1591/// its own `app(env)`, the way it installs a browser engine, so no build flag
1592/// selects it.
1593///
1594/// `build_manifest` is the manifest of the crate being built — the FFI
1595/// companion whose `video` feature this list feeds.
1596///
1597/// # Errors
1598///
1599/// Returns an error when `cargo metadata` cannot be read.
1600pub 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    /// A `FontFetch` stub for declarations that must never be downloaded.
1620    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    /// A `FontFetch` stub that writes a fake font file to `dest`.
1628    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    /// A `FontFetch` stub whose download always fails.
1641    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    /// A remote declaration that is not already cached must fail naming the
1711    /// font, the URL, and the cache directory — and name `water fetch`, the
1712    /// command that downloads it, since builds never access the network.
1713    #[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    /// `water fetch` downloads a missing remote font into the cache entry
1736    /// the build looks for — `{sha256(url)}.ttf` — and the build's own probe
1737    /// then resolves it. The downloader is a stub: a test never reaches the
1738    /// real network.
1739    #[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    /// `water fetch` is a no-op for a font that is already cached: the
1777    /// downloader is never invoked and the outcome reports it satisfied.
1778    #[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    /// A failed download is an error naming the font and its URL — never a
1806    /// quiet skip that leaves the next build failing on the same font.
1807    #[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    /// A font declared by a name the registry does not know is reported
1833    /// exactly as the build reports it — fetching cannot fix it.
1834    #[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    /// A crate-local font that is missing is reported exactly as the build
1860    /// reports it — fetching cannot fix it, and saying so is the point.
1861    #[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        // Icon pack fonts should NOT be in the built-in registry
1897        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    /// The registry must offer a face carrying an OpenType `MATH` table, and it
1912    /// must be reached through the archive code path.
1913    #[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    /// `STIX Two Math` ships in the same archive as eight `STIX Two Text` faces,
1927    /// none of which carry a `MATH` table. Picking a Text face would leave
1928    /// formula layout with no table to read, and nothing downstream would say
1929    /// so — the font would simply be there and be useless.
1930    #[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); // SHA256 = 32 bytes = 64 hex chars
1964    }
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    /// A crate that declares a font it does not ship must stop the build.
2011    /// Skipping it renders the app in whatever face the shaper falls back to
2012    /// — the silent wrong typeface, which is worse than a failed build
2013    /// because nothing in the output says the declaration went unmet.
2014    #[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
2067/// Collects the permissions this app's dependencies declare they need.
2068///
2069/// A crate states its own requirement in its manifest, so the CLI never has to
2070/// know what any component does:
2071///
2072/// ```toml
2073/// [package.metadata.waterui.permissions]
2074/// internet = { reason = "downloads map styles and vector tiles" }
2075/// ```
2076///
2077/// Declarations gated behind a cargo feature are skipped unless the resolved
2078/// graph actually enabled that feature for the declaring crate.
2079///
2080/// The scan runs `cargo metadata` on `build_manifest` — the `Cargo.toml` of
2081/// the crate this build compiles, i.e. the generated backend or FFI crate.
2082/// That crate depends on the app, so its graph carries both the app's authored
2083/// dependencies and the backend's own (a theme crate and friends); resolving
2084/// the app's manifest instead would miss the backend's declarations entirely.
2085///
2086/// # Errors
2087///
2088/// Returns an error when `cargo metadata` cannot be read.
2089pub 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
2168/// HTTP client crates whose presence almost always means the app talks to the
2169/// network at runtime. Presence is a hint, not proof — a client can sit behind
2170/// a disabled feature of some dependency — so hits are reported as
2171/// [`PermissionEvidence::Inferred`].
2172const HTTP_CLIENT_CRATES: &[&str] = &[
2173    "attohttpc",
2174    "curl",
2175    "hyper",
2176    "isahc",
2177    "reqwest",
2178    "surf",
2179    "ureq",
2180    "zenwave",
2181];
2182
2183/// Suggests the `internet` permission when the dependency graph contains a
2184/// known HTTP client and nothing declared that permission outright.
2185///
2186/// A declared requirement always carries better evidence and a better message,
2187/// so the inference stays quiet as soon as one exists.
2188fn 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
2216/// Selects the declared requirements this app has not satisfied.
2217///
2218/// `relevant` decides whether a permission means anything on the platform being
2219/// built, so an iOS build stays quiet about `internet` (which iOS never
2220/// declares) while an Android build does not. Keeping this separate from the
2221/// reporting makes the selection itself testable.
2222fn 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
2233/// Reports dependencies that need a permission this app has not enabled.
2234pub 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
2262/// Renders a permission key the way it is written in `Water.toml`.
2263fn 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    /// Writes a minimal compilable crate — `[package]` for `name`, an empty
2300    /// `src/lib.rs`, and `extra` verbatim manifest TOML — and returns the
2301    /// manifest path a scan can be pointed at.
2302    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    /// The scan resolves the graph of the manifest it is handed — the crate
2317    /// the build actually compiles. A permission declared only through the
2318    /// managed backend's dependency tree is reported from the backend's
2319    /// manifest and is invisible from the FFI companion's.
2320    #[test]
2321    fn a_permission_declared_in_the_built_crates_graph_is_scanned() {
2322        let project = tempdir().expect("temp project");
2323        // `theme` stands in for a managed backend's own dependency, such as
2324        // hydrolysis-m3: the backend crate links it, the FFI crate does not.
2325        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    /// `package_feature_enabled` reads the same graph: a feature a dependency
2358    /// carries only through the managed backend's manifest reports enabled
2359    /// there and absent from the FFI companion's.
2360    #[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    /// iOS never declares network access, so warning about it there would be
2434    /// noise — and a warning that cries wolf stops being read.
2435    #[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}