Skip to main content

wavecraft_dev_server/
assets.rs

1//! Static asset embedding for the React UI
2//!
3//! This module embeds the built React application (`ui/dist/`) directly
4//! into the Rust binary using `include_dir!`. Assets are served via a
5//! custom protocol handler in the WebView.
6
7use include_dir::{Dir, include_dir};
8use std::borrow::Cow;
9use std::path::{Component, Path, PathBuf};
10
11/// Embedded fallback UI assets bundled with the crate.
12///
13/// These assets are used when `ui/dist` is not available on disk.
14static UI_ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/assets/ui-dist");
15
16/// Get an embedded asset by path
17///
18/// # Arguments
19/// * `path` - Relative path within the `ui/dist/` directory (e.g., "index.html", "assets/main.js")
20///
21/// # Returns
22/// * `Some((bytes, mime_type))` if asset exists
23/// * `None` if asset not found
24pub fn get_asset(path: &str) -> Option<(Cow<'static, [u8]>, &'static str)> {
25    // Normalize path (remove leading slash)
26    let path = path.trim_start_matches('/');
27
28    // Special case: empty path or "/" -> index.html
29    let path = if path.is_empty() { "index.html" } else { path };
30
31    if let Some(contents) = try_disk_asset(path) {
32        let mime_type = mime_type_from_path(path);
33        return Some((Cow::Owned(contents), mime_type));
34    }
35
36    // Get file from embedded fallback directory
37    let file = UI_ASSETS.get_file(path)?;
38
39    // Infer MIME type from extension
40    let mime_type = mime_type_from_path(path);
41
42    Some((Cow::Borrowed(file.contents()), mime_type))
43}
44
45fn try_disk_asset(path: &str) -> Option<Vec<u8>> {
46    if !is_safe_relative_path(path) {
47        return None;
48    }
49
50    let base_dir = ui_dist_dir();
51    let asset_path = base_dir.join(path);
52
53    if !asset_path.exists() {
54        return None;
55    }
56
57    std::fs::read(asset_path).ok()
58}
59
60fn ui_dist_dir() -> PathBuf {
61    Path::new(env!("CARGO_MANIFEST_DIR"))
62        .join("../../../ui/dist")
63}
64
65fn is_safe_relative_path(path: &str) -> bool {
66    let candidate = Path::new(path);
67    !candidate.is_absolute()
68        && candidate
69            .components()
70            .all(|component| component != Component::ParentDir)
71}
72
73/// Infer MIME type from file extension
74fn mime_type_from_path(path: &str) -> &'static str {
75    let extension = path.split('.').next_back().unwrap_or("");
76
77    match extension {
78        "html" => "text/html",
79        "css" => "text/css",
80        "js" | "mjs" => "application/javascript",
81        "json" => "application/json",
82        "png" => "image/png",
83        "jpg" | "jpeg" => "image/jpeg",
84        "svg" => "image/svg+xml",
85        "woff" => "font/woff",
86        "woff2" => "font/woff2",
87        "ttf" => "font/ttf",
88        "ico" => "image/x-icon",
89        _ => "application/octet-stream",
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use include_dir::Dir;
97
98    /// List all embedded assets (for debugging)
99    fn list_assets() -> Vec<String> {
100        let mut paths = Vec::new();
101        collect_paths(&UI_ASSETS, "", &mut paths);
102        paths
103    }
104
105    fn collect_paths(dir: &Dir, prefix: &str, paths: &mut Vec<String>) {
106        for file in dir.files() {
107            paths.push(format!("{}{}", prefix, file.path().display()));
108        }
109        for subdir in dir.dirs() {
110            let subdir_name = subdir.path().file_name().unwrap().to_str().unwrap();
111            collect_paths(subdir, &format!("{}{}/", prefix, subdir_name), paths);
112        }
113    }
114
115    #[test]
116    fn test_mime_type_inference() {
117        assert_eq!(mime_type_from_path("index.html"), "text/html");
118        assert_eq!(mime_type_from_path("style.css"), "text/css");
119        assert_eq!(mime_type_from_path("app.js"), "application/javascript");
120        assert_eq!(mime_type_from_path("data.json"), "application/json");
121        assert_eq!(
122            mime_type_from_path("unknown.xyz"),
123            "application/octet-stream"
124        );
125    }
126
127    #[test]
128    fn test_list_assets() {
129        // This test will fail until ui/dist/ is created with the React build
130        // For now, just verify the function doesn't panic
131        let assets = list_assets();
132        // If ui/dist is empty or doesn't exist, assets will be empty
133        // Once we build the React app, this should have entries
134        println!("Found {} embedded assets", assets.len());
135        for asset in assets.iter().take(10) {
136            println!("  - {}", asset);
137        }
138    }
139
140    #[test]
141    fn test_get_index_html() {
142        let (content, mime_type) =
143            get_asset("index.html").expect("index.html should exist after React build");
144
145        assert_eq!(mime_type, "text/html");
146        assert!(!content.is_empty());
147
148        // Verify it's valid HTML
149        let html = std::str::from_utf8(content.as_ref()).unwrap();
150        assert!(html.contains("<!DOCTYPE html") || html.contains("<!doctype html"));
151    }
152}