diff --git a/build/pwa-vite-plugin.ts b/build/pwa-vite-plugin.ts new file mode 100644 index 0000000..32c7174 --- /dev/null +++ b/build/pwa-vite-plugin.ts @@ -0,0 +1,160 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { Plugin } from "vite"; + +/** + * Public files that are not referenced by the page markup but are still needed + * for an installed app to start without a network connection. + */ +const STATIC_PRECACHE = [ + "/manifest.webmanifest", + "/favicon.ico", + "/favicon-32.png", + "/favicon-dark.svg", + "/apple-touch-icon.png", + "/icon-512.png", + "/mouse-preview.svg", +]; + +/** + * Pages whose emitted markup is scanned for the hashed assets to precache. + * The license gate and the control app are deliberately absent: both are + * verified per request by functions/_middleware.js, so caching either one + * would let a revoked license keep working offline. + */ +const PRECACHE_PAGES: { file: string; url: string }[] = [ + { file: "index.html", url: "/" }, + { file: "demo.html", url: "/demo.html" }, +]; + +/** Same-origin paths the worker must never serve from its own cache. */ +const BYPASS_SOURCE = [ + "/^\\/api\\//", + "/^\\/control-app/", + "/^\\/protected-assets\\//", + "/^\\/control(?:\\.html)?$/", +].join(", "); + +function renderServiceWorker(version: string, precache: string[]): string { + return `// Generated by build/pwa-vite-plugin.ts. Do not edit by hand. +const CACHE = "openmouse-${version}"; +const FONT_CACHE = "openmouse-fonts"; +const PRECACHE = ${JSON.stringify(precache, null, 2)}; + +/** + * Licensed routes. These are validated per request by the Cloudflare + * middleware and must always reach the network, so an expired or revoked + * session cannot be served from a local cache. + */ +const BYPASS = [${BYPASS_SOURCE}]; + +const FONT_ORIGINS = ["https://fonts.googleapis.com", "https://fonts.gstatic.com"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE) + .then((cache) => cache.addAll(PRECACHE)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all( + keys + .filter((key) => key !== CACHE && key !== FONT_CACHE) + .map((key) => caches.delete(key)), + )) + .then(() => self.clients.claim()), + ); +}); + +/** Serves the cached copy immediately and refreshes it in the background. */ +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + const network = fetch(request) + .then((response) => { + if (response.ok || response.type === "opaque") cache.put(request, response.clone()); + return response; + }) + .catch(() => cached); + return cached ?? network; +} + +/** Prefers fresh markup, falling back to the cache when the network is gone. */ +async function networkFirst(request) { + const cache = await caches.open(CACHE); + try { + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; + } catch (error) { + const cached = await cache.match(request) ?? await cache.match("/"); + if (cached) return cached; + throw error; + } +} + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + + if (FONT_ORIGINS.includes(url.origin)) { + event.respondWith(staleWhileRevalidate(request, FONT_CACHE)); + return; + } + + if (url.origin !== self.location.origin) return; + if (BYPASS.some((pattern) => pattern.test(url.pathname))) return; + + if (request.mode === "navigate") { + event.respondWith(networkFirst(request)); + return; + } + + event.respondWith( + caches.match(request).then((cached) => cached ?? fetch(request)), + ); +}); +`; +} + +/** Emits a service worker that precaches the public pages and their assets. */ +export function pwa(appVersion: string): Plugin { + let root = process.cwd(); + let outputDirectory = "dist"; + + return { + name: "openmouse-pwa", + apply: "build", + configResolved(config) { + root = config.root; + outputDirectory = config.build.outDir; + }, + // Runs against the written output: Vite injects the hashed asset tags into + // the markup after generateBundle, so the emitted HTML is only complete on disk. + async closeBundle() { + const outputRoot = resolve(root, outputDirectory); + const urls = new Set(STATIC_PRECACHE); + + for (const page of PRECACHE_PAGES) { + const markup = await readFile(resolve(outputRoot, page.file), "utf8"); + + urls.add(page.url); + for (const [, asset] of markup.matchAll(/(?:href|src)="(\/assets\/[^"]+)"/g)) { + urls.add(asset); + } + } + + const precache = [...urls].sort(); + const version = `${appVersion}-${createHash("sha256").update(precache.join("\n")).digest("hex").slice(0, 8)}`; + + await writeFile(resolve(outputRoot, "sw.js"), renderServiceWorker(version, precache)); + }, + }; +} diff --git a/demo.html b/demo.html index 1cac51a..e3e00ae 100644 --- a/demo.html +++ b/demo.html @@ -8,6 +8,7 @@ + diff --git a/index.html b/index.html index e80fd1a..213b190 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,7 @@ + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..c08dc63 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,36 @@ +{ + "id": "/", + "name": "OpenMouse", + "short_name": "OpenMouse", + "description": "Manage supported gaming mice in one place — DPI, polling rate, battery, and sensor settings.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#09090b", + "categories": ["utilities", "productivity"], + "icons": [ + { + "src": "/favicon-32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/src/demo.ts b/src/demo.ts index 7eecc5e..f99bfec 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -1,4 +1,7 @@ import "./demo.css"; +import { registerServiceWorker } from "./register-sw"; + +registerServiceWorker(); const demoApp = document.querySelector("#demo-app"); diff --git a/src/main.ts b/src/main.ts index f49b3f9..8a3aca0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,7 @@ import "./styles.css"; +import { registerServiceWorker } from "./register-sw"; + +registerServiceWorker(); const app = document.querySelector("#app"); diff --git a/src/register-sw.ts b/src/register-sw.ts new file mode 100644 index 0000000..0301c49 --- /dev/null +++ b/src/register-sw.ts @@ -0,0 +1,15 @@ +/** + * Registers the generated service worker so the public pages stay available + * without a connection. The worker only exists in a production build, and it + * deliberately never caches the licensed control app — see + * build/pwa-vite-plugin.ts. + */ +export function registerServiceWorker(): void { + if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return; + + window.addEventListener("load", () => { + void navigator.serviceWorker.register("/sw.js").catch(() => { + // Offline support is an enhancement; the pages work without it. + }); + }); +} diff --git a/vite.config.ts b/vite.config.ts index 98bea69..41bce92 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; import { resolve } from "node:path"; import { readFileSync } from "node:fs"; +import { pwa } from "./build/pwa-vite-plugin"; import { sites } from "./build/sites-vite-plugin"; const packageVersion = JSON.parse( @@ -10,7 +11,7 @@ const packageVersion = JSON.parse( const buildChannel = process.env.OPENMOUSE_BUILD_CHANNEL ?? "insiders"; export default defineConfig({ - plugins: [sites()], + plugins: [sites(), pwa(packageVersion.version)], define: { __APP_VERSION__: JSON.stringify(packageVersion.version), __BUILD_CHANNEL__: JSON.stringify(buildChannel),