diff --git a/build/pwa-vite-plugin.ts b/build/pwa-vite-plugin.ts index 32c7174b..2ba693c0 100644 --- a/build/pwa-vite-plugin.ts +++ b/build/pwa-vite-plugin.ts @@ -1,6 +1,4 @@ import { createHash } from "node:crypto"; -import { readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; import type { Plugin } from "vite"; /** @@ -11,30 +9,54 @@ const STATIC_PRECACHE = [ "/manifest.webmanifest", "/favicon.ico", "/favicon-32.png", - "/favicon-dark.svg", "/apple-touch-icon.png", "/icon-512.png", - "/mouse-preview.svg", + "/favicon-dark.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. + * Pages each Cloudflare Pages target builds, mirroring the rollup inputs in + * vite.config.ts. The app target is the gated control panel on its own + * subdomain; the public support pages live on the marketing domain, and the + * landing target reaches its root through the _redirects file that + * build/sites-vite-plugin.ts writes. */ -const PRECACHE_PAGES: { file: string; url: string }[] = [ - { file: "index.html", url: "/" }, - { file: "demo.html", url: "/demo.html" }, -]; +const TARGET_PAGES: Record = { + app: ["index.html"], + landing: ["landing.html", "check.html", "supported.html", "donate.html"], +}; + +export const ROOT_PAGE: Record = { + app: "index.html", + landing: "landing.html", +}; + +function rootPage(target: string): string { + return ROOT_PAGE[target] ?? ROOT_PAGE.app; +} + +/** Pages whose emitted markup is scanned for the hashed assets to precache. */ +export function precachePages(target: string): string[] { + return TARGET_PAGES[target] ?? TARGET_PAGES.app; +} + +/** The root page is served from "/", every other page from its own filename. */ +export function pageUrl(file: string, target: string): string { + return file === rootPage(target) ? "/" : `/${file}`; +} -/** Same-origin paths the worker must never serve from its own cache. */ -const BYPASS_SOURCE = [ - "/^\\/api\\//", - "/^\\/control-app/", - "/^\\/protected-assets\\//", - "/^\\/control(?:\\.html)?$/", -].join(", "); +/** + * Same-origin paths the worker must never serve from its own cache. The + * control app and its bundle are validated per request by the Cloudflare + * middleware on main, so caching either would let a revoked license keep + * working. They do not exist on dev; the entries cost nothing there. + */ +export const BYPASS = [ + /^\/api\//, + /^\/control-app/, + /^\/protected-assets\//, + /^\/control(?:\.html)?$/, +]; function renderServiceWorker(version: string, precache: string[]): string { return `// Generated by build/pwa-vite-plugin.ts. Do not edit by hand. @@ -42,19 +64,17 @@ 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}]; +/** Vote and request endpoints are rate limited per request and must stay live. */ +const BYPASS = [${BYPASS.map(String).join(", ")}]; 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)) + // Per entry, so one missing file cannot reject the whole install and + // leave the app with no offline copy at all. + .then((cache) => Promise.allSettled(PRECACHE.map((url) => cache.add(url)))) .then(() => self.skipWaiting()), ); }); @@ -71,16 +91,36 @@ self.addEventListener("activate", (event) => { ); }); +/** + * Precached entries are stored by cache.add(), which sends no Origin header, + * while module scripts and stylesheets do send one. Responses carrying + * "Vary: Origin" would miss on every lookup without this. + */ +const MATCH = { ignoreVary: true }; + +/** + * The licensed routes answer with "Cache-Control: private, no-store". Opaque + * font responses carry no readable headers, so the directive check passes them + * through and they stay cacheable despite reporting ok === false. + */ +function storable(response) { + // Retired pages 301 to the docs site, and the Cache API rejects a redirected + // response stored against a navigation request. + if (response.redirected) return false; + if ((response.headers.get("Cache-Control") ?? "").includes("no-store")) return false; + return response.ok || response.type === "opaque"; +} + /** 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 cached = await cache.match(request, MATCH); const network = fetch(request) .then((response) => { - if (response.ok || response.type === "opaque") cache.put(request, response.clone()); + if (storable(response)) cache.put(request, response.clone()); return response; }) - .catch(() => cached); + .catch(() => cached ?? Response.error()); return cached ?? network; } @@ -89,15 +129,32 @@ async function networkFirst(request) { const cache = await caches.open(CACHE); try { const response = await fetch(request); - if (response.ok) cache.put(request, response.clone()); + if (storable(response)) cache.put(request, response.clone()); return response; } catch (error) { - const cached = await cache.match(request) ?? await cache.match("/"); + const cached = await cache.match(request, MATCH) ?? await cache.match("/", MATCH); if (cached) return cached; throw error; } } +/** + * Fills the cache as same-origin assets are requested. The precache list only + * covers what the pages link, so lazily imported chunks land here instead. + * Device art is served from R2 and never reaches this handler. + */ +async function cacheFirst(request) { + const cached = await caches.match(request, MATCH); + if (cached) return cached; + + const response = await fetch(request); + if (storable(response)) { + const cache = await caches.open(CACHE); + await cache.put(request, response.clone()); + } + return response; +} + self.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") return; @@ -117,44 +174,43 @@ self.addEventListener("fetch", (event) => { return; } - event.respondWith( - caches.match(request).then((cached) => cached ?? fetch(request)), - ); + event.respondWith(cacheFirst(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"; - +export function pwa(appVersion: string, buildTarget: string): Plugin { 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); + // "post" so Vite's HTML plugin has already injected the hashed asset tags + // into each page's bundle entry. Reading the emitted source here rather + // than the output directory keeps this independent of write ordering. + generateBundle: { + order: "post", + handler(_options, bundle) { + const urls = new Set(STATIC_PRECACHE); + + for (const file of precachePages(buildTarget)) { + const emitted = bundle[file]; + if (emitted?.type !== "asset") { + this.error(`${file} is missing from the bundle; the precache list would be wrong.`); + } + + urls.add(pageUrl(file, buildTarget)); + for (const [, asset] of String(emitted.source).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)}`; + const precache = [...urls].sort(); + // The target is in the cache name because both Pages projects deploy + // from this repo and serve a different page from "/". + const version = `${buildTarget}-${appVersion}-${createHash("sha256").update(precache.join("\n")).digest("hex").slice(0, 8)}`; - await writeFile(resolve(outputRoot, "sw.js"), renderServiceWorker(version, precache)); + this.emitFile({ type: "asset", fileName: "sw.js", source: renderServiceWorker(version, precache) }); + }, }, }; } diff --git a/check.html b/check.html index 7225ac19..1054681b 100644 --- a/check.html +++ b/check.html @@ -4,15 +4,39 @@ - - - + + + + + Mouse Check — OpenMouse HID Diagnostics + + + + + + + + +
diff --git a/donate.html b/donate.html index a89fa264..9c3bf433 100644 --- a/donate.html +++ b/donate.html @@ -4,9 +4,21 @@ + Support OpenMouse — Donate + + + + + + + + + + + diff --git a/index.html b/index.html index 0d03bddd..540b8234 100644 --- a/index.html +++ b/index.html @@ -18,8 +18,20 @@ + + + OpenMouse Control + + + + + + + + +
diff --git a/landing.html b/landing.html index 6cd01509..7f1f48d2 100644 --- a/landing.html +++ b/landing.html @@ -4,13 +4,24 @@ + OpenMouse — Free, open source mouse configurator + + + + + + + + + + diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 00000000..101d26c4 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-32.png b/public/favicon-32.png new file mode 100644 index 00000000..bc9dd905 Binary files /dev/null and b/public/favicon-32.png differ diff --git a/public/favicon-dark.svg b/public/favicon-dark.svg new file mode 100644 index 00000000..0cf367af --- /dev/null +++ b/public/favicon-dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 00000000..94cafb5b Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 00000000..a0140b15 Binary files /dev/null and b/public/og-image.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..99137391 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://openmouse.app/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 00000000..1fb9ae80 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,27 @@ + + + + https://openmouse.app/ + 2026-09-02 + weekly + 1.0 + + + https://openmouse.app/supported.html + 2026-08-30 + weekly + 1.0 + + + https://openmouse.app/check.html + 2026-08-30 + monthly + 0.8 + + + https://openmouse.app/donate.html + 2026-09-02 + monthly + 0.5 + + diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index 6be9a45b..00000000 --- a/public/sw.js +++ /dev/null @@ -1,55 +0,0 @@ -const CACHE = "openmouse"; -const CACHEABLE_HOSTS = ["fonts.googleapis.com", "fonts.gstatic.com"]; - -function isImmutable(url) { - if (url.origin === self.location.origin) { - return url.pathname.startsWith("/assets/") || url.pathname === "/favicon.ico"; - } - return CACHEABLE_HOSTS.includes(url.hostname); -} - -self.addEventListener("install", (event) => { - event.waitUntil((async () => { - const cache = await caches.open(CACHE); - await cache.addAll(["/index.html", "/favicon.ico"]).catch(() => undefined); - await self.skipWaiting(); - })()); -}); - -self.addEventListener("activate", (event) => { - event.waitUntil((async () => { - const names = await caches.keys(); - await Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name))); - await self.clients.claim(); - })()); -}); - -self.addEventListener("fetch", (event) => { - const request = event.request; - if (request.method !== "GET") return; - - if (request.mode === "navigate") { - event.respondWith((async () => { - try { - const response = await fetch(request); - (await caches.open(CACHE)).put("/index.html", response.clone()); - return response; - } catch { - return await caches.match("/index.html") ?? Response.error(); - } - })()); - return; - } - - if (!isImmutable(new URL(request.url))) return; - - event.respondWith((async () => { - const cached = await caches.match(request); - if (cached) return cached; - const response = await fetch(request); - if (response.ok || response.type === "opaque") { - (await caches.open(CACHE)).put(request, response.clone()); - } - return response; - })()); -}); diff --git a/src/check.tsx b/src/check.tsx index bb5b549c..f33f5c3e 100644 --- a/src/check.tsx +++ b/src/check.tsx @@ -1,6 +1,8 @@ import { useState, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import "./check.css"; +import { mountOfflineBanner } from "./offline-banner"; +import { registerServiceWorker } from "./register-sw"; import { interfaceThemeSlug, loadInterfacePreferences } from "./interface-preferences"; import { SCAN_FILTERS, @@ -197,3 +199,6 @@ function CheckApp(): ReactNode { const root = document.querySelector("#check-app"); if (!root) throw new Error("check-app root not found"); createRoot(root).render(); + +registerServiceWorker(); +mountOfflineBanner(); diff --git a/src/control.tsx b/src/control.tsx index b0cf4cbf..8285ec21 100644 --- a/src/control.tsx +++ b/src/control.tsx @@ -8,6 +8,8 @@ import { UnsupportedNotice } from "./app/UnsupportedNotice"; import { unsupportedNotice } from "./browser-support"; import { start } from "./device/controller"; import { isBeforeLaunch } from "./launch"; +import { mountOfflineBanner } from "./offline-banner"; +import { registerServiceWorker } from "./register-sw"; import { MIN_HEIGHT, MIN_WIDTH, useViewportTooSmall } from "./app/useViewportTooSmall"; const controlApp = document.querySelector("#control-app"); @@ -31,7 +33,8 @@ const notice = unsupportedNotice({ chromium: isChromium(), }); -if (import.meta.env.PROD) void navigator.serviceWorker?.register("/sw.js").catch(() => undefined); +registerServiceWorker(); +mountOfflineBanner(); function LaunchHero(): ReactNode { return ( diff --git a/src/donate.tsx b/src/donate.tsx index bb5e26d3..c86e193d 100644 --- a/src/donate.tsx +++ b/src/donate.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import "./donate.css"; +import { mountOfflineBanner } from "./offline-banner"; +import { registerServiceWorker } from "./register-sw"; const ORG = "OpenMouse-Project"; const REFRESH_MS = 15 * 60 * 1000; @@ -686,3 +688,6 @@ function DonateApp(): ReactNode { const root = document.querySelector("#donate-app"); if (!root) throw new Error("donate-app root not found"); createRoot(root).render(); + +registerServiceWorker(); +mountOfflineBanner(); diff --git a/src/landing.tsx b/src/landing.tsx index 61209221..4f2975dd 100644 --- a/src/landing.tsx +++ b/src/landing.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import "./landing.css"; +import { mountOfflineBanner } from "./offline-banner"; +import { registerServiceWorker } from "./register-sw"; import { DiscordIcon, DISCORD_URL, @@ -148,3 +150,6 @@ if (!landingApp) { } createRoot(landingApp).render(); + +registerServiceWorker(); +mountOfflineBanner(); diff --git a/src/offline-banner.css b/src/offline-banner.css new file mode 100644 index 00000000..692d3fd0 --- /dev/null +++ b/src/offline-banner.css @@ -0,0 +1,33 @@ +.offline-banner { + position: fixed; + inset-inline: 0; + bottom: 1rem; + z-index: 2147483000; + width: fit-content; + max-width: calc(100vw - 2rem); + margin-inline: auto; + padding: 0.5rem 1rem; + border: 1px solid rgb(255 255 255 / 0.14); + border-radius: 999px; + background: #09090b; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.35); + color: #fafafa; + font-size: 0.8125rem; + line-height: 1.4; + text-align: center; + pointer-events: none; + opacity: 0; + transform: translateY(0.5rem); + transition: opacity 160ms ease, transform 160ms ease; +} + +.offline-banner.is-visible { + opacity: 1; + transform: none; +} + +@media (prefers-reduced-motion: reduce) { + .offline-banner { + transition: none; + } +} diff --git a/src/offline-banner.ts b/src/offline-banner.ts new file mode 100644 index 00000000..fbab8498 --- /dev/null +++ b/src/offline-banner.ts @@ -0,0 +1,25 @@ +import "./offline-banner.css"; + +const MESSAGE = "You're offline. Anything that needs the network will not update."; + +/** + * Announces a dropped connection. The banner stays empty while online so the + * live region only speaks on an actual change. + */ +export function mountOfflineBanner(): void { + const banner = document.createElement("div"); + banner.className = "offline-banner"; + banner.setAttribute("role", "status"); + banner.setAttribute("aria-live", "polite"); + + const sync = (): void => { + const offline = !navigator.onLine; + banner.classList.toggle("is-visible", offline); + banner.textContent = offline ? MESSAGE : ""; + }; + + sync(); + window.addEventListener("online", sync); + window.addEventListener("offline", sync); + document.body.append(banner); +} diff --git a/src/pwa-precache.test.ts b/src/pwa-precache.test.ts new file mode 100644 index 00000000..44801aaa --- /dev/null +++ b/src/pwa-precache.test.ts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync } from "node:fs"; +import { BYPASS, pageUrl, precachePages } from "../build/pwa-vite-plugin.ts"; + +test("every page in the repo is precached by one of the build targets", () => { + const pages = readdirSync(".").filter((name) => name.endsWith(".html")).sort(); + const covered = [...new Set([...precachePages("app"), ...precachePages("landing")])].sort(); + + assert.deepEqual(covered, pages); +}); + +test("each target serves its own root page from /", () => { + assert.equal(pageUrl("index.html", "app"), "/"); + assert.equal(pageUrl("landing.html", "landing"), "/"); +}); + +test("a page that is not the target's root keeps its own path", () => { + assert.equal(pageUrl("check.html", "app"), "/check.html"); + assert.equal(pageUrl("landing.html", "app"), "/landing.html"); + assert.equal(pageUrl("index.html", "landing"), "/index.html"); +}); + +test("an unknown target falls back to the app page set", () => { + assert.deepEqual(precachePages("nonsense"), precachePages("app")); +}); + +const bypassed = (path: string): boolean => BYPASS.some((pattern) => pattern.test(path)); + +test("vote and request endpoints bypass the cache", () => { + assert.equal(bypassed("/api/mouse-vote"), true); + assert.equal(bypassed("/api/voting-config"), true); +}); + +// These live on main, behind the licence middleware. Caching either one would +// let a revoked session keep working, so the bypass has to survive a merge. +test("the licensed control app and its bundle bypass the cache", () => { + assert.equal(bypassed("/control-app.html"), true); + assert.equal(bypassed("/control-app"), true); + assert.equal(bypassed("/protected-assets/control-abc123.js"), true); + assert.equal(bypassed("/control"), true); + assert.equal(bypassed("/control.html"), true); +}); + +test("ordinary pages and assets are still cached", () => { + assert.equal(bypassed("/"), false); + assert.equal(bypassed("/supported.html"), false); + assert.equal(bypassed("/assets/main-abc123.js"), false); + assert.equal(bypassed("/devices/razer-viper.webp"), false); + assert.equal(bypassed("/contributors.html"), false); +}); diff --git a/src/register-sw.ts b/src/register-sw.ts index 0301c49a..61f11e91 100644 --- a/src/register-sw.ts +++ b/src/register-sw.ts @@ -1,8 +1,6 @@ /** * 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. + * without a connection. The worker only exists in a production build. */ export function registerServiceWorker(): void { if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return; diff --git a/src/supported.ts b/src/supported.ts index cd0edca8..edf15408 100644 --- a/src/supported.ts +++ b/src/supported.ts @@ -1,4 +1,6 @@ import "./supported.css"; +import { mountOfflineBanner } from "./offline-banner"; +import { registerServiceWorker } from "./register-sw"; import { MICE, STATUS, TABS, type Mouse, type Status } from "./supported-mice.ts"; import { fetchLiveData, mergeLiveMice, type LiveData } from "./supported-live.ts"; @@ -263,3 +265,6 @@ document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") void refresh(); }); window.addEventListener("focus", () => void refresh()); + +registerServiceWorker(); +mountOfflineBanner(); diff --git a/supported.html b/supported.html index 89357a95..99c8f9c4 100644 --- a/supported.html +++ b/supported.html @@ -4,8 +4,34 @@ + Supported Devices — OpenMouse - + + + + + + + + + + + + + +
diff --git a/vite.config.ts b/vite.config.ts index 2dba8b52..9e107d09 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; +import { pwa } from "./build/pwa-vite-plugin"; import { sites } from "./build/sites-vite-plugin"; const rootDir = fileURLToPath(new URL(".", import.meta.url)); @@ -19,7 +20,7 @@ const buildChannel = process.env.OPENMOUSE_BUILD_CHANNEL ?? "insiders"; const buildTarget = process.env.OPENMOUSE_BUILD_TARGET ?? "app"; export default defineConfig({ - plugins: [sites({ target: buildTarget })], + plugins: [sites({ target: buildTarget }), pwa(packageVersion.version, buildTarget)], resolve: { // Prefix aliases, so react-dom/client and react/jsx-runtime follow too. alias: {