diff --git a/src/app/main.ts b/src/app/main.ts index 7ab2103..f578cb7 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -7,7 +7,12 @@ */ import "./styles.css"; import { deleteShellCaches } from "../platform/cache/shell-cache.js"; -import { registerSW, unregisterAllServiceWorkers } from "../platform/sw/register.js"; +import { + registerSW, + skipWaitingForUpdate, + unregisterAllServiceWorkers, + watchForWaitingUpdate, +} from "../platform/sw/register.js"; import { evaluateBootReadiness } from "./readiness.js"; import type { BootReadinessReport } from "../domain/readiness/types.js"; import { chipForState } from "../domain/readiness/logic.js"; @@ -30,6 +35,7 @@ import { Router, routeForPath, normalizePath } from "../ui/router/router.js"; import { createHomeView } from "../ui/views/home/home.js"; import { createStatusView, createPrepGuidanceView } from "../ui/views/status/status.js"; import { createEmergencyView } from "../ui/views/emergency/emergency.js"; +import { showUpdateBanner } from "../ui/components/update-banner.js"; import { createMapView } from "../ui/views/map/map.js"; import { loadMapData, type MapHandle } from "./map.js"; import type { MapViewData } from "../domain/map/types.js"; @@ -378,9 +384,31 @@ function mount(): { router: Router; cleanup: () => void } { void deleteShellCaches(); } else { // Stage 3: register SW after boot — never blocks rendering (offline-safe) - void registerSW().catch(() => { - // registration failure is non-blocking — shell remains usable - }); + void registerSW() + .then((result) => { + // "Restart to update": when a newer shell has installed and waits + // behind the current one, offer an explicit restart. The SW never + // claims clients mid-session on its own (ARCH §8) — this is the + // sanctioned opt-in. + if (result.status === "registered") { + watchForWaitingUpdate(result.registration, (reg) => { + showUpdateBanner(root, () => { + // One reload per controllerchange; the swap lands right after + // SKIP_WAITING wakes the waiting worker. + let reloaded = false; + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (reloaded) return; + reloaded = true; + location.reload(); + }); + skipWaitingForUpdate(reg); + }); + }); + } + }) + .catch(() => { + // registration failure is non-blocking — shell remains usable + }); } // Ensure the deterministic deep-link set exists: all four destinations must be reachable diff --git a/src/app/styles.css b/src/app/styles.css index bc6887e..0d35296 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -921,3 +921,44 @@ html.dark .app-main__icon { background: var(--chip-amber-bg); color: var(--chip-amber-fg); } + +/* Restart-to-update banner — appears only when a newer shell is installed + and waiting; tap applies (SKIP_WAITING + reload), Later dismisses. */ +.update-banner { + position: fixed; + inset: auto 0 0 0; + z-index: 20; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)) + max(1rem, env(safe-area-inset-left)); + background: color-mix(in srgb, var(--bg) 92%, transparent); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + border-top: 1px solid var(--border-accent); +} +.update-banner__text { + flex: 1; + font-size: 0.9375rem; + color: var(--fg); +} +.update-banner__apply { + min-height: 44px; + padding: 0.5rem 1rem; + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--accent-contrast); + font-weight: 600; + font-size: 0.875rem; +} +.update-banner__dismiss { + min-height: 44px; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--muted); + font-size: 0.875rem; +} diff --git a/src/platform/sw/register.ts b/src/platform/sw/register.ts index a2ca05b..89878ca 100644 --- a/src/platform/sw/register.ts +++ b/src/platform/sw/register.ts @@ -32,6 +32,37 @@ export function skipWaitingForUpdate(registration?: ServiceWorkerRegistration): if (waiting) waiting.postMessage({ type: "SKIP_WAITING" }); } +/** + * Notify `onWaiting` when a newer shell has installed and is waiting behind + * the current one (never mid-session by itself — the user opts in via + * skipWaitingForUpdate). Covers both a registration that already has a + * `waiting` worker and one discovered later through `updatefound`. + * Only fires when a controller exists (a true update, not first install). + */ +export function watchForWaitingUpdate( + registration: ServiceWorkerRegistration, + onWaiting: (registration: ServiceWorkerRegistration) => void, +): void { + const hasController = + typeof navigator !== "undefined" && + "serviceWorker" in navigator && + navigator.serviceWorker.controller !== null; + if (!hasController) return; // first install activates on its own — nothing to offer + if (registration.waiting) { + onWaiting(registration); + return; + } + registration.addEventListener("updatefound", () => { + const installing = registration.installing; + if (!installing) return; + installing.addEventListener("statechange", () => { + if (installing.state === "installed" && registration.waiting) { + onWaiting(registration); + } + }); + }); +} + /** * Unregister every service worker (dev-mode scrub only — production never * calls this). Resolves true when a controlling SW was removed and the diff --git a/src/ui/components/update-banner.ts b/src/ui/components/update-banner.ts new file mode 100644 index 0000000..b0a6817 --- /dev/null +++ b/src/ui/components/update-banner.ts @@ -0,0 +1,41 @@ +/** + * "Restart to update" banner — shown when a newer shell is installed and + * waiting behind the current one. The SW never claims clients mid-session + * (ARCH §8: no auto skipWaiting/clients.claim), so activating an update is + * an explicit user action wired by the app layer via onApply. Dismiss hides + * it; the next full start picks the update up anyway. Text-only DOM via + * textContent (B-7); no storage, no network, no platform imports (B-1). + */ +export function showUpdateBanner(root: HTMLElement, onApply: () => void): void { + if (document.querySelector(".update-banner")) return; // idempotent + + const banner = document.createElement("div"); + banner.className = "update-banner"; + banner.setAttribute("role", "status"); + + const label = document.createElement("span"); + label.className = "update-banner__text"; + label.textContent = "A new version of Lumen is ready."; + banner.append(label); + + const apply = document.createElement("button"); + apply.type = "button"; + apply.className = "update-banner__apply"; + apply.textContent = "Restart to update"; + apply.addEventListener("click", () => { + onApply(); + }); + banner.append(apply); + + const dismiss = document.createElement("button"); + dismiss.type = "button"; + dismiss.className = "update-banner__dismiss"; + dismiss.textContent = "Later"; + dismiss.setAttribute("aria-label", "Dismiss update notice"); + dismiss.addEventListener("click", () => { + banner.remove(); + }); + banner.append(dismiss); + + root.append(banner); +} diff --git a/tests/unit/sw.test.ts b/tests/unit/sw.test.ts index 0db2dc2..aac8514 100644 --- a/tests/unit/sw.test.ts +++ b/tests/unit/sw.test.ts @@ -6,7 +6,11 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { readFileSync, existsSync } from "node:fs"; -import { registerSW, skipWaitingForUpdate } from "../../src/platform/sw/register.js"; +import { + registerSW, + skipWaitingForUpdate, + watchForWaitingUpdate, +} from "../../src/platform/sw/register.js"; const SW_PATH = "public/sw.js"; @@ -148,6 +152,68 @@ describe("SW registration — page bridge", () => { }); }); +describe("watchForWaitingUpdate — restart-to-update discovery", () => { + function withController(controller: unknown): void { + Object.defineProperty(globalThis, "navigator", { + value: { serviceWorker: { controller } }, + writable: true, + configurable: true, + }); + } + + it("no controller (first install) → never fires", () => { + withController(null); + const onWaiting = vi.fn(); + const listeners: Record void> = {}; + const reg = { + waiting: { postMessage: vi.fn() }, + addEventListener: (ev: string, cb: () => void) => { + listeners[ev] = cb; + }, + } as unknown as ServiceWorkerRegistration; + watchForWaitingUpdate(reg, onWaiting); + expect(onWaiting).not.toHaveBeenCalled(); + expect(listeners.updatefound).toBeUndefined(); + }); + + it("already-waiting worker fires immediately", () => { + withController({ scriptURL: "/sw.js" }); + const onWaiting = vi.fn(); + const reg = { + waiting: { postMessage: vi.fn() }, + addEventListener: vi.fn(), + } as unknown as ServiceWorkerRegistration; + watchForWaitingUpdate(reg, onWaiting); + expect(onWaiting).toHaveBeenCalledWith(reg); + }); + + it("updatefound → installed waiting worker fires", () => { + withController({ scriptURL: "/sw.js" }); + const onWaiting = vi.fn(); + let foundCb: (() => void) | undefined; + const stateCbs: (() => void)[] = []; + const installed = { + state: "installed", + addEventListener: (_ev: string, cb: () => void) => { + stateCbs.push(cb); + }, + }; + const reg = { + waiting: null as unknown, + installing: installed, + addEventListener: (ev: string, cb: () => void) => { + if (ev === "updatefound") foundCb = cb; + }, + } as unknown as ServiceWorkerRegistration; + watchForWaitingUpdate(reg, onWaiting); + expect(onWaiting).not.toHaveBeenCalled(); + foundCb?.(); + (reg as { waiting: unknown }).waiting = { postMessage: vi.fn() }; + for (const cb of stateCbs) cb(); + expect(onWaiting).toHaveBeenCalledTimes(1); + }); +}); + describe("Cache safety — versioning and shell assets", () => { it("vite build injects hashed assets into precache manifest", () => { const viteConfig = readFileSync("vite.config.ts", "utf8"); diff --git a/tests/unit/update-banner.test.ts b/tests/unit/update-banner.test.ts new file mode 100644 index 0000000..e7037f9 --- /dev/null +++ b/tests/unit/update-banner.test.ts @@ -0,0 +1,54 @@ +/** + * @vitest-environment jsdom + * "Restart to update" banner — appears once, apply fires the opt-in, + * Later removes it. Pure DOM: no SW, no storage, no network. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { showUpdateBanner } from "../../src/ui/components/update-banner.js"; + +beforeEach(() => { + document.body.replaceChildren(); +}); + +function root(): HTMLElement { + const el = document.createElement("div"); + document.body.append(el); + return el; +} + +describe("update banner", () => { + it("renders banner with apply and dismiss once (idempotent)", () => { + const host = root(); + showUpdateBanner(host, () => undefined); + showUpdateBanner(host, () => undefined); + expect(document.querySelectorAll(".update-banner").length).toBe(1); + expect(host.textContent).toContain("new version"); + expect(host.querySelector(".update-banner__apply")?.textContent).toBe("Restart to update"); + }); + + it("apply invokes the opt-in callback exactly once per click", () => { + const host = root(); + const onApply = vi.fn(); + showUpdateBanner(host, onApply); + host.querySelector(".update-banner__apply")?.dispatchEvent(new MouseEvent("click")); + expect(onApply).toHaveBeenCalledTimes(1); + }); + + it("Later removes the banner without calling apply", () => { + const host = root(); + const onApply = vi.fn(); + showUpdateBanner(host, onApply); + host.querySelector(".update-banner__dismiss")?.dispatchEvent(new MouseEvent("click")); + expect(document.querySelector(".update-banner")).toBeNull(); + expect(onApply).not.toHaveBeenCalled(); + // Re-show after dismiss is allowed (a later update can re-offer). + showUpdateBanner(host, onApply); + expect(document.querySelectorAll(".update-banner").length).toBe(1); + }); + + it("renders no raw HTML from anywhere (textContent-only)", () => { + const host = root(); + showUpdateBanner(host, () => undefined); + expect(host.querySelector(".update-banner")?.innerHTML).not.toMatch(/