diff --git a/README.md b/README.md index e523eb7..27151b6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ The current MVP slice includes: - disabled-by-default, serial-pinned ADB status, APK installation, package inventory, and validated app shortcuts; - live MG4 speed, gear, and steering-angle telemetry with a dedicated visual Car dashboard; - a responsive 1920×720-oriented setup, login, and dashboard UI; +- an installable PWA shell with standalone landscape display and cached dashboard assets; - an idempotent Raspberry Pi installer with systemd start-on-boot support; - atomic, dashboard-triggered Git updates with automatic verification and rollback safety; - optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot; @@ -106,6 +107,8 @@ npm run dev:web Open `http://127.0.0.1:5173`. The development server proxies API requests to `http://127.0.0.1:8787`. +The production build includes a web app manifest and service worker. PWA installation and offline shell caching require a secure browser context: HTTPS in production, or `localhost` during local use. The dashboard still requires the Pi connection for live data and authenticated actions. + Local development data is stored in `server/data/companion.db` and ignored by Git. ## Quality gate diff --git a/server/src/app.ts b/server/src/app.ts index d31aba2..63b8941 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -723,6 +723,42 @@ export async function buildApp(config: AppConfig): Promise { reply.type("text/html; charset=utf-8"); return reply.send(createReadStream(resolve(webRoot, "index.html"))); }); + const publicFiles: Record = { + "/manifest.webmanifest": { + file: "manifest.webmanifest", + type: "application/manifest+json; charset=utf-8", + cacheControl: "no-cache" + }, + "/service-worker.js": { + file: "service-worker.js", + type: "text/javascript; charset=utf-8", + cacheControl: "no-cache" + }, + "/icon.svg": { file: "icon.svg", type: "image/svg+xml", cacheControl: "public, max-age=86400" }, + "/pwa-192x192.png": { file: "pwa-192x192.png", type: "image/png", cacheControl: "public, max-age=86400" }, + "/pwa-512x512.png": { file: "pwa-512x512.png", type: "image/png", cacheControl: "public, max-age=86400" }, + "/pwa-maskable-512x512.png": { + file: "pwa-maskable-512x512.png", + type: "image/png", + cacheControl: "public, max-age=86400" + }, + "/apple-touch-icon.png": { + file: "apple-touch-icon.png", + type: "image/png", + cacheControl: "public, max-age=86400" + } + }; + for (const [route, asset] of Object.entries(publicFiles)) { + app.get(route, async (_request, reply) => { + const assetPath = resolve(webRoot, asset.file); + if (!existsSync(assetPath) || !statSync(assetPath).isFile()) { + return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Asset not found" } }); + } + reply.type(asset.type); + reply.header("Cache-Control", asset.cacheControl); + return reply.send(createReadStream(assetPath)); + }); + } app.get<{ Params: { file: string } }>("/assets/:file", async (request, reply) => { if (!/^[a-zA-Z0-9._-]+$/.test(request.params.file)) { return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Asset not found" } }); diff --git a/web/index.html b/web/index.html index 957363a..9a380cb 100644 --- a/web/index.html +++ b/web/index.html @@ -5,6 +5,13 @@ + + + + + + + Pi Car Companion diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..9eff0ce Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/icon.svg b/web/public/icon.svg new file mode 100644 index 0000000..8cb6826 --- /dev/null +++ b/web/public/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..0ee062e --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,33 @@ +{ + "id": "/", + "name": "Pi Car Companion", + "short_name": "Pi Car", + "description": "Local dashboard for your Raspberry Pi car companion.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "landscape", + "background_color": "#10130f", + "theme_color": "#10130f", + "categories": ["navigation", "utilities"], + "icons": [ + { + "src": "/pwa-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/pwa-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/pwa-maskable-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/web/public/pwa-192x192.png b/web/public/pwa-192x192.png new file mode 100644 index 0000000..38a8481 Binary files /dev/null and b/web/public/pwa-192x192.png differ diff --git a/web/public/pwa-512x512.png b/web/public/pwa-512x512.png new file mode 100644 index 0000000..7debd53 Binary files /dev/null and b/web/public/pwa-512x512.png differ diff --git a/web/public/pwa-maskable-512x512.png b/web/public/pwa-maskable-512x512.png new file mode 100644 index 0000000..7debd53 Binary files /dev/null and b/web/public/pwa-maskable-512x512.png differ diff --git a/web/public/service-worker.js b/web/public/service-worker.js new file mode 100644 index 0000000..8a803aa --- /dev/null +++ b/web/public/service-worker.js @@ -0,0 +1,63 @@ +const CACHE_NAME = "pi-car-companion-v1"; +const APP_SHELL = [ + "/", + "/manifest.webmanifest", + "/icon.svg", + "/pwa-192x192.png", + "/pwa-512x512.png", + "/pwa-maskable-512x512.png", + "/apple-touch-icon.png" +]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin || url.pathname.startsWith("/api/") || url.pathname === "/healthz") return; + + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put("/", copy))); + } + return response; + }) + .catch(() => caches.match("/")) + ); + return; + } + + if (url.pathname.startsWith("/assets/") || APP_SHELL.includes(url.pathname)) { + event.respondWith( + caches.match(request).then( + (cached) => + cached ?? + fetch(request).then((response) => { + if (response.ok) { + const copy = response.clone(); + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, copy))); + } + return response; + }) + ) + ); + } +}); diff --git a/web/src/main.tsx b/web/src/main.tsx index 33837d7..29ea15a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -8,3 +8,9 @@ createRoot(document.getElementById("root")!).render( ); + +if (import.meta.env.PROD && "serviceWorker" in navigator) { + window.addEventListener("load", () => { + void navigator.serviceWorker.register("/service-worker.js"); + }); +}