From 20b3279bb54241afddddbb653ee3c40d61d2afaf Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 19 Feb 2026 20:05:23 +0100 Subject: [PATCH] feat(pwa): make dashboard installable as a progressive web app Add web manifest, service worker with cache-first static assets and network-first navigation, and register from the App component head. Co-Authored-By: Claude Opus 4.6 --- assets/manifest.json | 17 +++++++++++ assets/sw.js | 67 ++++++++++++++++++++++++++++++++++++++++++++ src/app.rs | 20 +++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 assets/manifest.json create mode 100644 assets/sw.js diff --git a/assets/manifest.json b/assets/manifest.json new file mode 100644 index 0000000..13e7a89 --- /dev/null +++ b/assets/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "CERTifAI Dashboard", + "short_name": "CERTifAI", + "description": "Self-hosted GenAI infrastructure management dashboard", + "start_url": "/dashboard", + "display": "standalone", + "background_color": "#0f1117", + "theme_color": "#4B3FE0", + "icons": [ + { + "src": "/assets/logo.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/assets/sw.js b/assets/sw.js new file mode 100644 index 0000000..f5db1a2 --- /dev/null +++ b/assets/sw.js @@ -0,0 +1,67 @@ +// CERTifAI Service Worker — network-first with offline fallback cache. +// Static assets (CSS, JS, WASM, fonts) use cache-first for speed. +// API and navigation requests always try the network first. + +const CACHE_NAME = "certifai-v1"; + +// Pre-cache the app shell on install +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => + cache.addAll([ + "/", + "/dashboard", + "/assets/logo.svg", + "/assets/favicon.ico", + ]) + ) + ); + self.skipWaiting(); +}); + +// Clean up old caches on activate +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + + // Skip non-GET and API requests (let them go straight to network) + if (event.request.method !== "GET" || url.pathname.startsWith("/api/")) { + return; + } + + // Cache-first for static assets (hashed filenames make this safe) + const isStatic = /\.(js|wasm|css|ico|svg|png|jpg|woff2?)(\?|$)/.test(url.pathname); + if (isStatic) { + event.respondWith( + caches.match(event.request).then((cached) => + cached || fetch(event.request).then((resp) => { + const clone = resp.clone(); + caches.open(CACHE_NAME).then((c) => c.put(event.request, clone)); + return resp; + }) + ) + ); + return; + } + + // Network-first for navigation / HTML + event.respondWith( + fetch(event.request) + .then((resp) => { + const clone = resp.clone(); + caches.open(CACHE_NAME).then((c) => c.put(event.request, clone)); + return resp; + }) + .catch(() => caches.match(event.request)) + ); +}); diff --git a/src/app.rs b/src/app.rs index 87a5486..8c982cd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -52,6 +52,7 @@ pub enum Route { const FAVICON: Asset = asset!("/assets/favicon.ico"); const MAIN_CSS: Asset = asset!("/assets/main.css"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); +const MANIFEST: Asset = asset!("/assets/manifest.json"); /// Google Fonts URL for Inter (body) and Space Grotesk (headings). const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\ @@ -64,6 +65,14 @@ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\ pub fn App() -> Element { rsx! { document::Link { rel: "icon", href: FAVICON } + document::Link { rel: "manifest", href: MANIFEST } + document::Meta { name: "theme-color", content: "#4B3FE0" } + document::Meta { name: "apple-mobile-web-app-capable", content: "yes" } + document::Meta { + name: "apple-mobile-web-app-status-bar-style", + content: "black-translucent", + } + document::Link { rel: "apple-touch-icon", href: FAVICON } document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" } document::Link { rel: "preconnect", @@ -73,6 +82,17 @@ pub fn App() -> Element { document::Link { rel: "stylesheet", href: GOOGLE_FONTS } document::Link { rel: "stylesheet", href: TAILWIND_CSS } document::Link { rel: "stylesheet", href: MAIN_CSS } + + // Register PWA service worker + document::Script { + r#" + if ('serviceWorker' in navigator) {{ + navigator.serviceWorker.register('/assets/sw.js') + .catch(function(e) {{ console.warn('SW registration failed:', e); }}); + }} + "# + } + div { "data-theme": "certifai-dark", Router:: {} } } }