1use axum::body::{Body, Bytes};
11use axum::http::{StatusCode, header};
12use axum::response::Response;
13
14const FRONTEND_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets");
15
16const EMBEDDED_INDEX: &str = include_str!("../assets/index.html");
17const EMBEDDED_CSS: &str = include_str!("../assets/style.css");
18const EMBEDDED_JS: &str = include_str!("../assets/app.js");
19
20const FAVICON_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4f6df5"/><circle cx="14" cy="14" r="7" fill="none" stroke="#fff" stroke-width="3"/><line x1="19.5" y1="19.5" x2="26" y2="26" stroke="#fff" stroke-width="3" stroke-linecap="round"/></svg>"##;
23
24pub fn frontend_dir() -> std::path::PathBuf {
28 std::env::var_os("PHRONA_FRONTEND_DIR")
29 .map(std::path::PathBuf::from)
30 .unwrap_or_else(|| std::path::PathBuf::from(FRONTEND_DIR))
31}
32
33fn embedded_asset(name: &str) -> Option<&'static [u8]> {
34 match name {
35 "index.html" => Some(EMBEDDED_INDEX.as_bytes()),
36 "style.css" => Some(EMBEDDED_CSS.as_bytes()),
37 "app.js" => Some(EMBEDDED_JS.as_bytes()),
38 _ => None,
39 }
40}
41
42fn ok_response(body: Body, mime: &'static str) -> Response {
43 Response::builder()
44 .status(StatusCode::OK)
45 .header(header::CONTENT_TYPE, mime)
46 .body(body)
47 .unwrap_or_else(|_| Response::new(Body::empty()))
48}
49
50fn serve(name: &str, mime: &'static str) -> Response {
51 match std::fs::read(frontend_dir().join(name)) {
52 Ok(body) => ok_response(Body::from(body), mime),
53 Err(_) => match embedded_asset(name) {
56 Some(asset) => ok_response(Body::from(Bytes::from_static(asset)), mime),
57 None => Response::builder()
58 .status(StatusCode::NOT_FOUND)
59 .body(Body::from("not found"))
60 .unwrap(),
61 },
62 }
63}
64
65pub async fn index(req: axum::extract::Request) -> Response {
68 let p = req.uri().path().trim_start_matches('/');
69 match p {
70 "" => serve("index.html", "text/html; charset=utf-8"),
71 "style.css" => serve("style.css", "text/css; charset=utf-8"),
72 "app.js" => serve("app.js", "text/javascript; charset=utf-8"),
73 "favicon.ico" | "favicon.svg" => ok_response(
76 Body::from(Bytes::from_static(FAVICON_SVG.as_bytes())),
77 "image/svg+xml",
78 ),
79 _ => serve("index.html", "text/html; charset=utf-8"),
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 #[test]
88 fn embedded_assets_cover_the_ui() {
89 assert!(!EMBEDDED_INDEX.is_empty());
90 assert!(EMBEDDED_INDEX.contains("<html") || EMBEDDED_INDEX.contains("<!doctype"));
91 assert!(!EMBEDDED_CSS.is_empty());
92 assert!(!EMBEDDED_JS.is_empty());
93 for name in ["index.html", "style.css", "app.js"] {
94 assert!(embedded_asset(name).is_some(), "{name} missing");
95 }
96 assert!(embedded_asset("favicon.ico").is_none());
97 }
98
99 #[tokio::test]
100 async fn fallback_serves_embedded_when_dir_missing() {
101 let dir = frontend_dir();
102 let gone = dir.join("__definitely_missing__");
103 assert!(std::fs::read(&gone).is_err());
104 let resp = serve("app.js", "text/javascript; charset=utf-8");
105 assert_eq!(resp.status(), StatusCode::OK);
106 let body = axum::body::to_bytes(resp.into_body(), 1 << 20)
107 .await
108 .unwrap();
109 assert!(!body.is_empty());
110 assert_eq!(body, EMBEDDED_JS.as_bytes());
111 }
112}