Skip to main content

pamoja_dashboard/
assets.rs

1//! The static page assets, served either embedded or live from disk.
2//!
3//! In production the page is baked into the firmware with `include_bytes!`, so there
4//! is no filesystem dependency and the dashboard is part of the image. In development
5//! the same directory is read from disk on every request, so editing the app and
6//! reloading shows the change with no recompile. The dashboard is a multi-file
7//! ES-module zQuery app, so both modes resolve any nested path under the web root.
8
9#[cfg(feature = "serve")]
10use std::path::PathBuf;
11
12/// One bundled file: its URL path, its MIME type, and its bytes.
13struct 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// Only the full bundle embeds the per-locale JSON; the floor tier carries none.
23#[cfg(not(feature = "tier-c"))]
24const JSON: &str = "application/json; charset=utf-8";
25
26// The smallest tier embeds only the self-contained floor page at `/`: one ultra-minimal,
27// gzippable document that renders the status table with the smallest possible script, and
28// degrades to the device's server-rendered `/lite` table when scripting is off. The rich app
29// modules are not part of this image.
30#[cfg(feature = "tier-c")]
31const EMBEDDED: &[Asset] = &[Asset {
32    path: "/",
33    content_type: HTML,
34    bytes: include_bytes!("../web/lite.html"),
35}];
36
37// The full bundle, embedded at compile time. `/` maps to the page shell. The order does not
38// matter; lookups are by exact path.
39#[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    // The floor page is also reachable from the full app (the top bar's "Lite" link), so a
52    // viewer on a weak phone can drop to it; it is excluded from the page-load footprint.
53    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    // English is always embedded as the fallback locale; the other seed locales are
219    // feature-gated so a constrained build embeds only the languages it needs (see
220    // `locale_asset`).
221    Asset {
222        path: "/app/i18n/en.json",
223        content_type: JSON,
224        bytes: include_bytes!("../web/app/i18n/en.json"),
225    },
226];
227
228// The optional seed-locale bundles, each baked in only when its feature is on, so Tier B can
229// drop the languages a deployment does not need from flash. English is not here; it is always
230// embedded in `EMBEDDED`.
231#[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/// The locale tags this build actually embeds, in menu order (English first).
250///
251/// The page reads this from `GET /locales` and offers only these languages, so a Tier B build
252/// that dropped a locale from flash never shows it in the switcher.
253///
254/// # Returns
255///
256/// The embedded locale tags; empty on a floor (`tier-c`) build, which ships no locale bundles.
257#[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/// The locale tags this build embeds; empty on a floor (`tier-c`) build.
274///
275/// # Returns
276///
277/// An empty list: the floor page bakes in a single English page and serves no locale bundles.
278#[cfg(feature = "tier-c")]
279pub(crate) fn embedded_locales() -> Vec<&'static str> {
280    Vec::new()
281}
282
283// Resolves an embedded asset: the fixed bundle first, then any feature-gated locale bundle.
284fn 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
295// The MIME type for a file, by extension, for the directory (development) mode.
296fn 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/// Where the page assets come from.
317#[derive(Clone, Debug)]
318pub enum Assets {
319    /// Baked into the binary at compile time: the production path.
320    Embedded,
321    /// Read from a directory on each request: the hot-reloading development path.
322    #[cfg(feature = "serve")]
323    Dir(PathBuf),
324}
325
326impl Assets {
327    /// Resolves a request path to a file's MIME type and bytes.
328    ///
329    /// The request path `"/"` resolves to the page shell. In [`Assets::Dir`] mode any
330    /// file under the directory is served (typed by extension), read fresh from disk so
331    /// edits show up on reload; a path that escapes the directory resolves to `None`.
332    ///
333    /// # Arguments
334    ///
335    /// * `path` - the request path, such as `"/app/app.js"`.
336    ///
337    /// # Returns
338    ///
339    /// The MIME type and the file's bytes, or `None` if no asset matches.
340    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                // Refuse anything that tries to climb out of the asset directory.
351                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        // The floor image is the single self-contained page and nothing else: the rich app
384        // modules must not be baked in, so the firmware stays tiny.
385        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        // The full app links to the floor page (the top bar's "Lite" link), so it must be
395        // embedded beside the shell, not only in the tier-c image.
396        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        // The feature and helper modules live under app/lib (with the visualizations split
406        // into app/lib/viz), so the embedded bundle must resolve those nested paths.
407        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        // app.js imports these unconditionally (pairing/control and its pure-JS crypto); a
417        // missing one 404s and the page never mounts, so the embedded bundle must carry them.
418        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        // The Rust `Viz` vocabulary and the page's renderer are one contract across languages:
434        // every kind a profile can choose must be one `viz/index.js` can draw, or a custom
435        // element would silently fall back to a sparkline. This checks the embedded renderer
436        // references each kind, so the two cannot drift apart unnoticed.
437        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        // English is always the fallback, and whatever a build reports it embeds must actually
461        // resolve - so a Tier B subset and its `GET /locales` list can never disagree.
462        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        // The floor page bakes in a single English page and serves no locale JSON.
476        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        // A regression tripwire for the firmware image: the embedded bundle (the app plus the
484        // locales this build kept) stays small enough to fit a constrained flash partition.
485        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}