diff --git a/admin/index.html b/admin/index.html index 2ecc421..8f95fb8 100644 --- a/admin/index.html +++ b/admin/index.html @@ -3,6 +3,9 @@ + + + NestQuest Admin diff --git a/admin/public/icons/icon-192.png b/admin/public/icons/icon-192.png new file mode 100644 index 0000000..4435870 Binary files /dev/null and b/admin/public/icons/icon-192.png differ diff --git a/admin/public/icons/icon-512.png b/admin/public/icons/icon-512.png new file mode 100644 index 0000000..63419f2 Binary files /dev/null and b/admin/public/icons/icon-512.png differ diff --git a/admin/public/icons/icon-maskable-512.png b/admin/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..ae98c76 Binary files /dev/null and b/admin/public/icons/icon-maskable-512.png differ diff --git a/admin/public/manifest.webmanifest b/admin/public/manifest.webmanifest new file mode 100644 index 0000000..5cbcc1c --- /dev/null +++ b/admin/public/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "NestQuest Admin", + "short_name": "NestQuest", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f4f4f6", + "theme_color": "#3b3ab8", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/admin/public/sw.js b/admin/public/sw.js new file mode 100644 index 0000000..b64fe2c --- /dev/null +++ b/admin/public/sw.js @@ -0,0 +1,77 @@ +/* NestQuest Admin service worker — hand-written, served from the site root. + Precaches the app shell, serves static assets cache-first, and falls back + to the cached shell for navigations when offline. Only same-origin GET + requests are handled; cross-origin requests (Authentik, the API host) are + never intercepted or cached. */ + +const VERSION = "v1"; +const SHELL_CACHE = `nestquest-admin-shell-${VERSION}`; +const RUNTIME_CACHE = `nestquest-admin-runtime-${VERSION}`; +const CURRENT_CACHES = [SHELL_CACHE, RUNTIME_CACHE]; + +const SHELL_URLS = [ + "/", + "/index.html", + "/manifest.webmanifest", + "/icons/icon-192.png", + "/icons/icon-512.png", + "/icons/icon-maskable-512.png", +]; + +// Request destinations treated as static assets (cache-first). Anything else +// same-origin (e.g. API calls) goes straight to the network. +const STATIC_DESTINATIONS = ["script", "style", "image", "font", "manifest"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(SHELL_CACHE) + .then((cache) => cache.addAll(SHELL_URLS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.filter((key) => !CURRENT_CACHES.includes(key)).map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + if (new URL(request.url).origin !== self.location.origin) return; + + if (request.mode === "navigate") { + event.respondWith( + fetch(request).catch(() => + caches.match("/index.html", { cacheName: SHELL_CACHE }).then( + (cached) => cached || Response.error(), + ), + ), + ); + return; + } + + if (STATIC_DESTINATIONS.includes(request.destination)) { + event.respondWith(cacheFirst(request)); + } +}); + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok && response.type === "basic") { + const cache = await caches.open(RUNTIME_CACHE); + await cache.put(request, response.clone()); + } + return response; +} diff --git a/admin/scripts/generate-icons.mjs b/admin/scripts/generate-icons.mjs new file mode 100644 index 0000000..89cd01f --- /dev/null +++ b/admin/scripts/generate-icons.mjs @@ -0,0 +1,80 @@ +// Generates the committed PWA icons under public/icons/ as genuine PNGs. +// Pure Node (zlib only) — no network, no image dependencies. +// Run: node scripts/generate-icons.mjs +import { writeFileSync } from "node:fs"; +import { deflateSync } from "node:zlib"; +import { fileURLToPath } from "node:url"; + +// Brand tokens (design/tokens/nestquest-admin-tokens.css). +const BLUE = [0x3b, 0x3a, 0xb8]; +const PURPLE = [0xa0, 0x35, 0xcc]; +const WHITE = [0xff, 0xff, 0xff]; + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +function encodePng(size, pixel) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // colour type: RGB + const raw = Buffer.alloc(size * (size * 3 + 1)); + for (let y = 0; y < size; y++) { + const row = y * (size * 3 + 1); + raw[row] = 0; // filter: none + for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3); + } + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest") +// around a white dot ("quest"). `scale` shrinks the mark for the maskable +// safe zone (inner 80% circle). +function brandMark(size, scale) { + const c = size / 2; + const outer = size * 0.34 * scale; + const inner = size * 0.24 * scale; + const dot = size * 0.1 * scale; + return (x, y) => { + const d = Math.hypot(x + 0.5 - c, y + 0.5 - c); + if ((d <= outer && d >= inner) || d <= dot) return WHITE; + const t = (x + y) / (2 * (size - 1)); + return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t)); + }; +} + +const outDir = new URL("../public/icons/", import.meta.url); +const icons = [ + ["icon-192.png", 192, 1], + ["icon-512.png", 512, 1], + ["icon-maskable-512.png", 512, 0.75], +]; +for (const [name, size, scale] of icons) { + const path = fileURLToPath(new URL(name, outDir)); + writeFileSync(path, encodePng(size, brandMark(size, scale))); + console.log(`wrote ${path}`); +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 35e1ff7..5f81c94 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import AppRoutes from "./AppRoutes"; import "./index.css"; +import { registerServiceWorker } from "./pwa/register"; createRoot(document.getElementById("root")!).render( , -); \ No newline at end of file +); + +void registerServiceWorker(); diff --git a/admin/src/pwa/pwa.test.ts b/admin/src/pwa/pwa.test.ts new file mode 100644 index 0000000..72e15ed --- /dev/null +++ b/admin/src/pwa/pwa.test.ts @@ -0,0 +1,261 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerServiceWorker } from "./register"; + +const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const adminPath = (rel: string) => resolve(adminRoot, rel); +const publicPath = (urlPath: string) => adminPath(`public${urlPath}`); + +interface ManifestIcon { + src: string; + sizes: string; + type: string; + purpose?: string; +} + +const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8")); +const icons: ManifestIcon[] = manifest.icons; +const swSource = readFileSync(publicPath("/sw.js"), "utf8"); + +const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]); +const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/); + +describe("web app manifest", () => { + it("meets the installability criteria", () => { + expect(manifest.name).toBe("NestQuest Admin"); + expect(manifest.short_name).toBe("NestQuest"); + expect(manifest.start_url).toBe("/"); + expect(manifest.scope).toBe("/"); + expect(manifest.display).toBe("standalone"); + expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i); + expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i); + + const any = icons.filter((i) => purposes(i).includes("any")); + expect(any.some((i) => sizeOf(i) >= 192)).toBe(true); + expect(any.some((i) => sizeOf(i) >= 512)).toBe(true); + expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true); + }); + + it("uses the admin design token colours", () => { + const css = readFileSync(adminPath("src/index.css"), "utf8"); + expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`); + expect(css).toContain(`--nq-a-page: ${manifest.background_color};`); + }); + + it.each(icons.map((i) => [i.src, i] as const))( + "icon %s is a real PNG of its declared size", + (_src, icon) => { + const path = publicPath(icon.src); + expect(existsSync(path)).toBe(true); + const png = readFileSync(path); + expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(png.toString("ascii", 12, 16)).toBe("IHDR"); + const [w, h] = icon.sizes.split("x").map(Number); + expect(png.readUInt32BE(16)).toBe(w); + expect(png.readUInt32BE(20)).toBe(h); + expect(icon.type).toBe("image/png"); + }, + ); +}); + +describe("index.html and entry point", () => { + const html = readFileSync(adminPath("index.html"), "utf8"); + + it("links the manifest, theme colour and apple touch icon", () => { + expect(html).toContain(''); + expect(html).toContain(``); + expect(html).toContain(''); + }); + + it("main.tsx registers the service worker", () => { + const main = readFileSync(adminPath("src/main.tsx"), "utf8"); + expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/); + expect(main).toMatch(/registerServiceWorker\(\)/); + }); +}); + +// Minimal service-worker global scope: runs public/sw.js against in-memory +// caches and a stubbed network, so handler behaviour is checked without I/O. +function loadServiceWorker(network: (url: string) => Promise) { + const listeners: Record void> = {}; + const stores = new Map>(); + const openStore = (name: string) => { + if (!stores.has(name)) stores.set(name, new Map()); + return stores.get(name)!; + }; + const keyOf = (req: string | { url: string }) => + new URL(typeof req === "string" ? req : req.url, "https://admin.test").href; + const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req))); + + const caches = { + open: async (name: string) => { + const store = openStore(name); + return { + addAll: async (urls: string[]) => { + for (const u of urls) store.set(keyOf(u), await network(keyOf(u))); + }, + put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res), + }; + }, + keys: async () => [...stores.keys()], + delete: async (name: string) => stores.delete(name), + match: async (req: string | { url: string }, opts?: { cacheName?: string }) => { + const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()]; + for (const n of names) { + const hit = stores.get(n)?.get(keyOf(req)); + if (hit) return hit; + } + return undefined; + }, + }; + const self = { + location: new URL("https://admin.test/"), + addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn), + skipWaiting: vi.fn(async () => undefined), + clients: { claim: vi.fn(async () => undefined) }, + }; + const ResponseStub = { error: () => new FakeResponse("", false, "error") }; + new Function("self", "caches", "fetch", "Response", swSource)(self, caches, fetchFn, ResponseStub); + + const dispatch = async (type: string, init: object = {}) => { + let pending: Promise | undefined; + const event = { + ...init, + waitUntil: (p: Promise) => (pending = p), + respondWith: (p: Promise) => (pending = p), + }; + listeners[type](event); + return { responded: pending !== undefined, result: await pending }; + }; + return { listeners, stores, self, fetchFn, dispatch, openStore }; +} + +class FakeResponse { + constructor( + readonly body: string, + readonly ok = true, + readonly type = "basic", + ) {} + clone() { + return new FakeResponse(this.body, this.ok, this.type); + } +} + +const request = (url: string, extra: object = {}) => ({ + url: new URL(url, "https://admin.test").href, + method: "GET", + mode: "cors", + destination: "", + ...extra, +}); + +describe("service worker (public/sw.js)", () => { + it("is a static asset served from the site root", () => { + expect(existsSync(publicPath("/sw.js"))).toBe(true); + expect(swSource).toMatch(/const SHELL_CACHE = /); + expect(swSource).toMatch(/addEventListener\("install"/); + expect(swSource).toMatch(/addEventListener\("activate"/); + expect(swSource).toMatch(/addEventListener\("fetch"/); + }); + + it("precaches the shell on install, prunes stale caches on activate", async () => { + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`)); + sw.openStore("nestquest-admin-shell-v0"); + + await sw.dispatch("install"); + expect(sw.self.skipWaiting).toHaveBeenCalled(); + const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]); + for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) { + expect(precached).toContain(`https://admin.test${path}`); + } + + await sw.dispatch("activate"); + expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false); + expect(sw.self.clients.claim).toHaveBeenCalled(); + }); + + it("serves the cached shell for navigations when offline", async () => { + let online = true; + const sw = loadServiceWorker(async (url) => { + if (!online) throw new TypeError("Failed to fetch"); + return new FakeResponse(`net:${url}`); + }); + await sw.dispatch("install"); + online = false; + + const { result } = await sw.dispatch("fetch", { + request: request("/children/42", { mode: "navigate", destination: "document" }), + }); + expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html"); + }); + + it("serves static assets cache-first and fills the runtime cache", async () => { + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`)); + const asset = request("/assets/index-abc123.js", { destination: "script" }); + + const first = await sw.dispatch("fetch", { request: asset }); + expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js"); + expect(sw.fetchFn).toHaveBeenCalledTimes(1); + + const second = await sw.dispatch("fetch", { request: asset }); + expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js"); + expect(sw.fetchFn).toHaveBeenCalledTimes(1); + }); + + it("never handles cross-origin, non-GET or non-static requests", async () => { + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`)); + const cases = [ + request("https://auth.example.com/logo.png", { destination: "image" }), + request("/assets/app.js", { destination: "script", method: "POST" }), + request("/api/admin/children"), + ]; + for (const req of cases) { + const { responded } = await sw.dispatch("fetch", { request: req }); + expect(responded).toBe(false); + } + expect(sw.fetchFn).not.toHaveBeenCalled(); + }); +}); + +describe("registerServiceWorker", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const stubServiceWorker = (register: ReturnType) => + vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } }); + + it("does not register outside production builds", async () => { + const register = vi.fn(); + stubServiceWorker(register); + expect(import.meta.env.PROD).toBe(false); + await registerServiceWorker(); + expect(register).not.toHaveBeenCalled(); + }); + + it("registers /sw.js in production", async () => { + vi.stubEnv("PROD", true); + const register = vi.fn().mockResolvedValue({}); + stubServiceWorker(register); + await registerServiceWorker(); + expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" }); + }); + + it("logs instead of throwing when registration fails", async () => { + vi.stubEnv("PROD", true); + stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom"))); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(registerServiceWorker()).resolves.toBeUndefined(); + expect(error).toHaveBeenCalled(); + }); + + it("does nothing when the browser lacks service workers", async () => { + vi.stubEnv("PROD", true); + vi.stubGlobal("navigator", {}); + await expect(registerServiceWorker()).resolves.toBeUndefined(); + }); +}); diff --git a/admin/src/pwa/register.ts b/admin/src/pwa/register.ts new file mode 100644 index 0000000..14f00f6 --- /dev/null +++ b/admin/src/pwa/register.ts @@ -0,0 +1,13 @@ +/** + * Service worker registration for the installable admin PWA. + * Registers /sw.js only in production builds, so dev servers and tests never + * run a worker. Failures are logged, never thrown — the app works without it. + */ +export async function registerServiceWorker(): Promise { + if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return; + try { + await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + } catch (error) { + console.error("Service worker registration failed:", error); + } +}