1#[cfg(feature = "serve")]
10use std::path::PathBuf;
11
12struct Asset {
14 path: &'static str,
15 content_type: &'static str,
16 bytes: &'static [u8],
17}
18
19const HTML: &str = "text/html; charset=utf-8";
20const CSS: &str = "text/css; charset=utf-8";
21const JS: &str = "application/javascript; charset=utf-8";
22#[cfg(not(feature = "tier-c"))]
24const JSON: &str = "application/json; charset=utf-8";
25
26#[cfg(feature = "tier-c")]
31const EMBEDDED: &[Asset] = &[Asset {
32 path: "/",
33 content_type: HTML,
34 bytes: include_bytes!("../web/lite.html"),
35}];
36
37#[cfg(not(feature = "tier-c"))]
40const EMBEDDED: &[Asset] = &[
41 Asset {
42 path: "/",
43 content_type: HTML,
44 bytes: include_bytes!("../web/index.html"),
45 },
46 Asset {
47 path: "/global.css",
48 content_type: CSS,
49 bytes: include_bytes!("../web/global.css"),
50 },
51 Asset {
54 path: "/lite.html",
55 content_type: HTML,
56 bytes: include_bytes!("../web/lite.html"),
57 },
58 Asset {
59 path: "/zquery.min.js",
60 content_type: JS,
61 bytes: include_bytes!("../web/zquery.min.js"),
62 },
63 Asset {
64 path: "/app/app.js",
65 content_type: JS,
66 bytes: include_bytes!("../web/app/app.js"),
67 },
68 Asset {
69 path: "/app/store.js",
70 content_type: JS,
71 bytes: include_bytes!("../web/app/store.js"),
72 },
73 Asset {
74 path: "/app/routes.js",
75 content_type: JS,
76 bytes: include_bytes!("../web/app/routes.js"),
77 },
78 Asset {
79 path: "/app/nav.js",
80 content_type: JS,
81 bytes: include_bytes!("../web/app/nav.js"),
82 },
83 Asset {
84 path: "/app/lib/feed.js",
85 content_type: JS,
86 bytes: include_bytes!("../web/app/lib/feed.js"),
87 },
88 Asset {
89 path: "/app/lib/edits.js",
90 content_type: JS,
91 bytes: include_bytes!("../web/app/lib/edits.js"),
92 },
93 Asset {
94 path: "/app/lib/catalog.js",
95 content_type: JS,
96 bytes: include_bytes!("../web/app/lib/catalog.js"),
97 },
98 Asset {
99 path: "/app/lib/discovery.js",
100 content_type: JS,
101 bytes: include_bytes!("../web/app/lib/discovery.js"),
102 },
103 Asset {
104 path: "/app/lib/parallax.js",
105 content_type: JS,
106 bytes: include_bytes!("../web/app/lib/parallax.js"),
107 },
108 Asset {
109 path: "/app/lib/detail.js",
110 content_type: JS,
111 bytes: include_bytes!("../web/app/lib/detail.js"),
112 },
113 Asset {
114 path: "/app/lib/i18n.js",
115 content_type: JS,
116 bytes: include_bytes!("../web/app/lib/i18n.js"),
117 },
118 Asset {
119 path: "/app/lib/pair.js",
120 content_type: JS,
121 bytes: include_bytes!("../web/app/lib/pair.js"),
122 },
123 Asset {
124 path: "/app/lib/crypto/bytes.js",
125 content_type: JS,
126 bytes: include_bytes!("../web/app/lib/crypto/bytes.js"),
127 },
128 Asset {
129 path: "/app/lib/crypto/sha256.js",
130 content_type: JS,
131 bytes: include_bytes!("../web/app/lib/crypto/sha256.js"),
132 },
133 Asset {
134 path: "/app/lib/crypto/hmac.js",
135 content_type: JS,
136 bytes: include_bytes!("../web/app/lib/crypto/hmac.js"),
137 },
138 Asset {
139 path: "/app/lib/crypto/hkdf.js",
140 content_type: JS,
141 bytes: include_bytes!("../web/app/lib/crypto/hkdf.js"),
142 },
143 Asset {
144 path: "/app/lib/viz/index.js",
145 content_type: JS,
146 bytes: include_bytes!("../web/app/lib/viz/index.js"),
147 },
148 Asset {
149 path: "/app/lib/viz/util.js",
150 content_type: JS,
151 bytes: include_bytes!("../web/app/lib/viz/util.js"),
152 },
153 Asset {
154 path: "/app/lib/viz/links.js",
155 content_type: JS,
156 bytes: include_bytes!("../web/app/lib/viz/links.js"),
157 },
158 Asset {
159 path: "/app/lib/viz/charts.js",
160 content_type: JS,
161 bytes: include_bytes!("../web/app/lib/viz/charts.js"),
162 },
163 Asset {
164 path: "/app/lib/viz/gauges.js",
165 content_type: JS,
166 bytes: include_bytes!("../web/app/lib/viz/gauges.js"),
167 },
168 Asset {
169 path: "/app/lib/viz/glyphs.js",
170 content_type: JS,
171 bytes: include_bytes!("../web/app/lib/viz/glyphs.js"),
172 },
173 Asset {
174 path: "/app/components/top-bar.js",
175 content_type: JS,
176 bytes: include_bytes!("../web/app/components/top-bar.js"),
177 },
178 Asset {
179 path: "/app/components/dashboard-page.js",
180 content_type: JS,
181 bytes: include_bytes!("../web/app/components/dashboard-page.js"),
182 },
183 Asset {
184 path: "/app/components/sensor-modal.js",
185 content_type: JS,
186 bytes: include_bytes!("../web/app/components/sensor-modal.js"),
187 },
188 Asset {
189 path: "/app/components/pairing-modal.js",
190 content_type: JS,
191 bytes: include_bytes!("../web/app/components/pairing-modal.js"),
192 },
193 Asset {
194 path: "/app/components/manage-modal.js",
195 content_type: JS,
196 bytes: include_bytes!("../web/app/components/manage-modal.js"),
197 },
198 Asset {
199 path: "/app/components/group-modal.js",
200 content_type: JS,
201 bytes: include_bytes!("../web/app/components/group-modal.js"),
202 },
203 Asset {
204 path: "/app/components/mesh-modal.js",
205 content_type: JS,
206 bytes: include_bytes!("../web/app/components/mesh-modal.js"),
207 },
208 Asset {
209 path: "/app/components/network-view.js",
210 content_type: JS,
211 bytes: include_bytes!("../web/app/components/network-view.js"),
212 },
213 Asset {
214 path: "/app/components/alarm-bar.js",
215 content_type: JS,
216 bytes: include_bytes!("../web/app/components/alarm-bar.js"),
217 },
218 Asset {
222 path: "/app/i18n/en.json",
223 content_type: JSON,
224 bytes: include_bytes!("../web/app/i18n/en.json"),
225 },
226];
227
228#[cfg(not(feature = "tier-c"))]
232fn locale_asset(path: &str) -> Option<(&'static str, &'static [u8])> {
233 let bytes: &'static [u8] = match path {
234 #[cfg(feature = "locale-sw")]
235 "/app/i18n/sw.json" => include_bytes!("../web/app/i18n/sw.json"),
236 #[cfg(feature = "locale-ar")]
237 "/app/i18n/ar.json" => include_bytes!("../web/app/i18n/ar.json"),
238 #[cfg(feature = "locale-fr")]
239 "/app/i18n/fr.json" => include_bytes!("../web/app/i18n/fr.json"),
240 #[cfg(feature = "locale-pt")]
241 "/app/i18n/pt.json" => include_bytes!("../web/app/i18n/pt.json"),
242 #[cfg(feature = "locale-hi")]
243 "/app/i18n/hi.json" => include_bytes!("../web/app/i18n/hi.json"),
244 _ => return None,
245 };
246 Some((JSON, bytes))
247}
248
249#[cfg(not(feature = "tier-c"))]
258pub(crate) fn embedded_locales() -> Vec<&'static str> {
259 let mut tags = vec!["en"];
260 #[cfg(feature = "locale-sw")]
261 tags.push("sw");
262 #[cfg(feature = "locale-ar")]
263 tags.push("ar");
264 #[cfg(feature = "locale-fr")]
265 tags.push("fr");
266 #[cfg(feature = "locale-pt")]
267 tags.push("pt");
268 #[cfg(feature = "locale-hi")]
269 tags.push("hi");
270 tags
271}
272
273#[cfg(feature = "tier-c")]
279pub(crate) fn embedded_locales() -> Vec<&'static str> {
280 Vec::new()
281}
282
283fn embedded_get(path: &str) -> Option<(&'static str, Vec<u8>)> {
285 if let Some(asset) = EMBEDDED.iter().find(|a| a.path == path) {
286 return Some((asset.content_type, asset.bytes.to_vec()));
287 }
288 #[cfg(not(feature = "tier-c"))]
289 if let Some((content_type, bytes)) = locale_asset(path) {
290 return Some((content_type, bytes.to_vec()));
291 }
292 None
293}
294
295fn mime_for(path: &str) -> &'static str {
297 if path.ends_with(".html") {
298 HTML
299 } else if path.ends_with(".css") {
300 CSS
301 } else if path.ends_with(".js") || path.ends_with(".mjs") {
302 JS
303 } else if path.ends_with(".svg") {
304 "image/svg+xml"
305 } else if path.ends_with(".json") {
306 "application/json; charset=utf-8"
307 } else if path.ends_with(".ico") {
308 "image/x-icon"
309 } else if path.ends_with(".woff2") {
310 "font/woff2"
311 } else {
312 "application/octet-stream"
313 }
314}
315
316#[derive(Clone, Debug)]
318pub enum Assets {
319 Embedded,
321 #[cfg(feature = "serve")]
323 Dir(PathBuf),
324}
325
326impl Assets {
327 pub fn get(&self, path: &str) -> Option<(&'static str, Vec<u8>)> {
341 match self {
342 Assets::Embedded => embedded_get(path),
343 #[cfg(feature = "serve")]
344 Assets::Dir(root) => {
345 let relative = if path == "/" {
346 "index.html"
347 } else {
348 path.trim_start_matches('/')
349 };
350 if relative.contains("..") {
352 return None;
353 }
354 let bytes = std::fs::read(root.join(relative)).ok()?;
355 Some((mime_for(relative), bytes))
356 }
357 }
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn embedded_serves_the_shell_for_root() {
367 let (content_type, bytes) = Assets::Embedded.get("/").expect("shell present");
368 assert_eq!(content_type, HTML);
369 assert!(!bytes.is_empty());
370 }
371
372 #[cfg(not(feature = "tier-c"))]
373 #[test]
374 fn embedded_serves_the_app_entry_and_framework() {
375 assert!(Assets::Embedded.get("/zquery.min.js").is_some());
376 assert!(Assets::Embedded.get("/app/app.js").is_some());
377 assert!(Assets::Embedded.get("/global.css").is_some());
378 }
379
380 #[cfg(feature = "tier-c")]
381 #[test]
382 fn tier_c_embeds_only_the_floor_page() {
383 let (content_type, _) = Assets::Embedded.get("/").expect("floor page present");
386 assert_eq!(content_type, HTML);
387 assert!(Assets::Embedded.get("/app/app.js").is_none());
388 assert!(Assets::Embedded.get("/zquery.min.js").is_none());
389 }
390
391 #[cfg(not(feature = "tier-c"))]
392 #[test]
393 fn the_full_bundle_also_embeds_the_floor_page() {
394 let (content_type, _) = Assets::Embedded
397 .get("/lite.html")
398 .expect("floor page embedded");
399 assert_eq!(content_type, HTML);
400 }
401
402 #[cfg(not(feature = "tier-c"))]
403 #[test]
404 fn embedded_serves_the_lib_modules() {
405 assert!(Assets::Embedded.get("/app/lib/feed.js").is_some());
408 assert!(Assets::Embedded.get("/app/lib/catalog.js").is_some());
409 assert!(Assets::Embedded.get("/app/lib/discovery.js").is_some());
410 assert!(Assets::Embedded.get("/app/lib/viz/index.js").is_some());
411 }
412
413 #[cfg(not(feature = "tier-c"))]
414 #[test]
415 fn embedded_serves_the_pairing_and_crypto_modules() {
416 for path in [
419 "/app/components/pairing-modal.js",
420 "/app/lib/pair.js",
421 "/app/lib/crypto/bytes.js",
422 "/app/lib/crypto/sha256.js",
423 "/app/lib/crypto/hmac.js",
424 "/app/lib/crypto/hkdf.js",
425 ] {
426 assert!(Assets::Embedded.get(path).is_some(), "missing {path}");
427 }
428 }
429
430 #[cfg(not(feature = "tier-c"))]
431 #[test]
432 fn every_viz_kind_is_known_to_the_page_renderer() {
433 let (_, bytes) = Assets::Embedded
438 .get("/app/lib/viz/index.js")
439 .expect("viz/index.js is embedded");
440 let js = String::from_utf8(bytes).expect("viz/index.js is utf8");
441 for viz in pamoja_profile::Viz::ALL {
442 let token = format!("'{}'", viz.kind());
443 assert!(
444 js.contains(&token),
445 "the page renderer does not know viz kind {}",
446 viz.kind()
447 );
448 }
449 }
450
451 #[cfg(all(not(feature = "tier-c"), feature = "all-locales"))]
452 #[test]
453 fn the_full_build_embeds_all_six_seed_locales() {
454 assert_eq!(embedded_locales(), ["en", "sw", "ar", "fr", "pt", "hi"]);
455 }
456
457 #[cfg(not(feature = "tier-c"))]
458 #[test]
459 fn english_is_embedded_and_every_listed_locale_is_served() {
460 assert_eq!(embedded_locales().first(), Some(&"en"));
463 for locale in embedded_locales() {
464 let path = format!("/app/i18n/{locale}.json");
465 assert!(
466 Assets::Embedded.get(&path).is_some(),
467 "listed but not served: {locale}"
468 );
469 }
470 }
471
472 #[cfg(feature = "tier-c")]
473 #[test]
474 fn tier_c_embeds_no_locale_bundles() {
475 assert!(embedded_locales().is_empty());
477 assert!(Assets::Embedded.get("/app/i18n/en.json").is_none());
478 }
479
480 #[cfg(not(feature = "tier-c"))]
481 #[test]
482 fn the_embedded_bundle_fits_the_flash_budget() {
483 const FLASH_BUDGET: usize = 600 * 1024;
486 let mut total: usize = EMBEDDED.iter().map(|a| a.bytes.len()).sum();
487 for locale in embedded_locales() {
488 if locale == "en" {
489 continue;
490 }
491 if let Some((_, bytes)) = locale_asset(&format!("/app/i18n/{locale}.json")) {
492 total += bytes.len();
493 }
494 }
495 assert!(
496 total <= FLASH_BUDGET,
497 "embedded bundle is {total} bytes, over the {FLASH_BUDGET}-byte flash budget"
498 );
499 }
500
501 #[test]
502 fn an_unknown_path_resolves_to_nothing() {
503 assert!(Assets::Embedded.get("/secret").is_none());
504 }
505
506 #[test]
507 fn mime_types_follow_the_extension() {
508 assert_eq!(mime_for("index.html"), HTML);
509 assert_eq!(mime_for("app/app.js"), JS);
510 assert_eq!(mime_for("global.css"), CSS);
511 }
512}