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")).join("../../../ui/dist")
62}
63
64fn is_safe_relative_path(path: &str) -> bool {
65    let candidate = Path::new(path);
66    !candidate.is_absolute()
67        && candidate
68            .components()
69            .all(|component| component != Component::ParentDir)
70}
71
72/// Infer MIME type from file extension
73fn mime_type_from_path(path: &str) -> &'static str {
74    let extension = path.split('.').next_back().unwrap_or("");
75
76    match extension {
77        "html" => "text/html",
78        "css" => "text/css",
79        "js" | "mjs" => "application/javascript",
80        "json" => "application/json",
81        "png" => "image/png",
82        "jpg" | "jpeg" => "image/jpeg",
83        "svg" => "image/svg+xml",
84        "woff" => "font/woff",
85        "woff2" => "font/woff2",
86        "ttf" => "font/ttf",
87        "ico" => "image/x-icon",
88        _ => "application/octet-stream",
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use include_dir::Dir;
96
97    /// List all embedded assets (for debugging)
98    fn list_assets() -> Vec<String> {
99        let mut paths = Vec::new();
100        collect_paths(&UI_ASSETS, "", &mut paths);
101        paths
102    }
103
104    fn collect_paths(dir: &Dir, prefix: &str, paths: &mut Vec<String>) {
105        for file in dir.files() {
106            paths.push(format!("{}{}", prefix, file.path().display()));
107        }
108        for subdir in dir.dirs() {
109            let subdir_name = subdir.path().file_name().unwrap().to_str().unwrap();
110            collect_paths(subdir, &format!("{}{}/", prefix, subdir_name), paths);
111        }
112    }
113
114    #[test]
115    fn test_mime_type_inference() {
116        assert_eq!(mime_type_from_path("index.html"), "text/html");
117        assert_eq!(mime_type_from_path("style.css"), "text/css");
118        assert_eq!(mime_type_from_path("app.js"), "application/javascript");
119        assert_eq!(mime_type_from_path("data.json"), "application/json");
120        assert_eq!(
121            mime_type_from_path("unknown.xyz"),
122            "application/octet-stream"
123        );
124    }
125
126    #[test]
127    fn test_list_assets() {
128        // This test will fail until ui/dist/ is created with the React build
129        // For now, just verify the function doesn't panic
130        let assets = list_assets();
131        // If ui/dist is empty or doesn't exist, assets will be empty
132        // Once we build the React app, this should have entries
133        println!("Found {} embedded assets", assets.len());
134        for asset in assets.iter().take(10) {
135            println!("  - {}", asset);
136        }
137    }
138
139    #[test]
140    fn test_get_index_html() {
141        let (content, mime_type) =
142            get_asset("index.html").expect("index.html should exist after React build");
143
144        assert_eq!(mime_type, "text/html");
145        assert!(!content.is_empty());
146
147        // Verify it's valid HTML
148        let html = std::str::from_utf8(content.as_ref()).unwrap();
149        assert!(html.contains("<!DOCTYPE html") || html.contains("<!doctype html"));
150    }
151}