From ccd998ab042fdf7c3f3c3ceac9ce9096776051ac Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 17:19:12 +0200 Subject: [PATCH] Add service worker implementation and tests for PWA caching strategy - Implemented a service worker (sw.js) that handles installation, activation, and fetch events. - Added caching logic for public assets and a fallback for offline navigation. - Created unit tests (sw.test.ts) to validate service worker behavior, including asset caching, navigation handling, and activation logic. - Ensured that private API routes and third-party requests are excluded from caching. --- README.md | 4 + build.ts | 3 + bun-env.d.ts | 4 + bun.lock | 5 + docs/PWA.md | 103 +++++++++++ package.json | 2 + scripts/build-pwa.ts | 27 +++ scripts/pwa-icons.ts | 17 ++ scripts/pwa-smoke.ts | 194 ++++++++++++++++++++ src/api.ts | 2 + src/components/AuthProvider.tsx | 26 ++- src/components/PwaControls.tsx | 43 +++++ src/components/ThemeProvider.tsx | 1 + src/components/design-system/PageLayout.tsx | 3 + src/frontend.tsx | 3 + src/index.html | 10 +- src/index.ts | 4 + src/lib/dashboard.ts | 2 + src/pages/Home.tsx | 24 ++- src/pwa/assets.ts | 26 +++ src/pwa/client.ts | 90 +++++++++ src/pwa/manifest.ts | 9 + src/pwa/manifest.webmanifest | 38 ++++ src/pwa/public/icon-192.png | Bin 0 -> 19376 bytes src/pwa/public/icon-512.png | Bin 0 -> 105904 bytes src/pwa/public/icon-maskable-512.png | Bin 0 -> 60763 bytes src/pwa/sw.js | 66 +++++++ src/pwa/sw.test.ts | 99 ++++++++++ styles/design-system.css | 3 + 29 files changed, 795 insertions(+), 13 deletions(-) create mode 100644 docs/PWA.md create mode 100644 scripts/build-pwa.ts create mode 100644 scripts/pwa-icons.ts create mode 100644 scripts/pwa-smoke.ts create mode 100644 src/components/PwaControls.tsx create mode 100644 src/pwa/assets.ts create mode 100644 src/pwa/client.ts create mode 100644 src/pwa/manifest.ts create mode 100644 src/pwa/manifest.webmanifest create mode 100644 src/pwa/public/icon-192.png create mode 100644 src/pwa/public/icon-512.png create mode 100644 src/pwa/public/icon-maskable-512.png create mode 100644 src/pwa/sw.js create mode 100644 src/pwa/sw.test.ts diff --git a/README.md b/README.md index 73abcfe..6a77f45 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Mina Habits +The production app is installable as a PWA, with an offline reconnect screen and +user-controlled updates. Habit data and changes require a connection. See +[PWA behavior and verification](docs/PWA.md). + Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router client includes a homepage for signed-out visitors and a personal habit dashboard. diff --git a/build.ts b/build.ts index 808a90a..742171e 100644 --- a/build.ts +++ b/build.ts @@ -1,6 +1,7 @@ import tailwind from "bun-plugin-tailwind"; import { rm, mkdir, copyFile } from "node:fs/promises"; import path from "node:path"; +import { buildPwa } from "./scripts/build-pwa"; const outdir = path.join(process.cwd(), "dist"); await rm(outdir, { recursive: true, force: true }); @@ -36,3 +37,5 @@ await mkdir(path.join(outdir, "fonts/browser"), { recursive: true }); for (const file of ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]) { await copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`)); } + +await buildPwa(outdir, result.outputs); diff --git a/bun-env.d.ts b/bun-env.d.ts index 6d4416c..ff7c49a 100644 --- a/bun-env.d.ts +++ b/bun-env.d.ts @@ -9,6 +9,10 @@ declare module "*.svg" { } declare module "*.css" {} +declare module "*.webmanifest" { + const content: string; + export default content; +} declare module "*.png" { const path: string; export default path; diff --git a/bun.lock b/bun.lock index d7c92bb..0ccf7cc 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "@types/react-dom": "^19", "drizzle-kit": "^0.31.10", "happy-dom": "^20.14.0", + "playwright": "^1.63.0", "tailwindcss": "^4.1.11", "typescript": "^7.0.2", }, @@ -290,6 +291,10 @@ "lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="], + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], diff --git a/docs/PWA.md b/docs/PWA.md new file mode 100644 index 0000000..18a0f91 --- /dev/null +++ b/docs/PWA.md @@ -0,0 +1,103 @@ +# Progressive web app + +Mina Habits has an installable production build with a public offline app shell. +Its manifest supplies a stable app identity and root scope, standalone display, +192px and 512px icons, a separate maskable icon, app colors and descriptions. +The HTML includes an Apple touch icon, mobile metadata, theme color and safe-area +support. Browser chrome follows the selected light or dark theme. + +Install from **Install app** in the footer. Supporting browsers show their native +install prompt. Other browsers receive instructions for Add to Home Screen or +Add to Dock. Installed windows hide the install action. + +## Offline behavior + +After one successful online visit and service-worker installation, the application +shell, fonts, icons and lazy browser chunks are available offline. Opening or +reloading the app offline shows a reconnect screen instead of the browser error +page. Nested routes also resolve correctly. Accounts retry on reconnection and +periodically while a failed connection is visible, including when the browser +incorrectly reports that it is online. An account request has a 10-second timeout; +page navigations fall back to the cached shell after four seconds. + +**Account access, habit changes, sign-in, exports and Discord actions require a +connection.** Existing dashboard content can remain visible in an open window, +but no account responses are persisted for offline use and no mutations are +queued or replayed. Failed saves must be retried by the user. This is an offline +fallback, not offline habit editing or background synchronization. + +All `/api` traffic, including OAuth callback navigations, bypasses the service +worker. API responses have `Cache-Control: no-store`. Only explicitly public +build assets enter Cache Storage; server JavaScript, source maps, account data, +exports and third-party responses are excluded. Clearing site data removes the +public offline files; it does not remove account records from the server. + +## Build and updates + +`bun run build` runs `scripts/build-pwa.ts` after Bun emits the browser and server +bundles. It derives a release identifier from the worker source and public asset +contents, copies a version-specific public HTML shell, and emits `dist/pwa/sw.js` +with an explicit precache list. A failed precache download prevents installation +of that release. The stable `/sw.js` route is served as JavaScript with +`Cache-Control: no-cache` and root service-worker scope. + +Updates wait until existing windows close or the user chooses **Update app**, +then **Reload and update**. The confirmation tells the user to save unfinished +changes. Only the accepting window reloads; other windows retain their drafts +and receive their own update action. Old static caches remain available for their +lazy chunks. A subsequent activation with no open app windows removes obsolete +Mina Habits caches, leaving unrelated caches untouched. Checking for updates on +return to the app, reconnection and hourly supports long-lived installed windows. + +The source manifest is `src/pwa/manifest.webmanifest`. Bun fingerprints its HTML +link; an explicit `/manifest.webmanifest` endpoint serves the same document. The +manifest's stable `id` keeps app identity independent of its asset filename. + +Install icons reuse `docs/design/mina-habits-icon.png`; regenerate their exact +sizes with `bun scripts/pwa-icons.ts`. The maskable derivative adds opaque padding +to keep the monogram inside the central safe zone. Existing favicon and Apple +icons are preserved. + +## Serving requirements + +Use the normal production build/start or Docker workflow at the root of an HTTPS +origin. Local testing may use `http://localhost` or `http://127.0.0.1`. The current +production configuration already requires an HTTPS origin outside loopback and +uses Caddy for TLS. No new deployment environment variables are required. + +Service-worker registration is deliberately disabled in development/HMR mode; +run the production smoke check to verify caching. Deploy the entire build +atomically, including `dist/pwa`, rather than uploading individual browser files. +Do not apply immutable HTTP caching to `/sw.js` or add a proxy cache for `/api`. + +PWA installation support and its interface vary by browser. Push notifications, +background sync and app-store packaging are optional capabilities, not +installation requirements. This implementation follows the current +[MDN installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable) +and the documented +[service-worker lifecycle](https://web.dev/articles/service-worker-lifecycle). + +## Verification + +```sh +bun run typecheck +bun test +bun x playwright install chromium +bun run test:pwa +bun run test:smoke +``` + +`test:pwa` copies the production build into a temporary directory, starts it with +a disposable SQLite database and no Discord credentials, then runs Chromium. +It verifies manifest parsing and Chrome installability errors, icon dimensions, +MIME/cache headers, first installation, offline startup and nested/lazy routes, +reconnection, responsive layouts, theme color, failed-update recovery, a two-window +update with an unsaved draft, API-cache exclusion, sign-out and mobile install +instructions. Screenshots are written to `.cache/pwa/`. Temporary data and the +server are removed when the check finishes. Worker unit tests cover routing, +installation failure and cache cleanup. + +Before a public release, also exercise installation and standalone relaunch on a +physical iPhone/iPad and Android device over the production HTTPS origin. Desktop +Chromium checks and mobile emulation do not establish native OS installation or +Safari behavior. The implementation does not claim a universal PWA certification. diff --git a/package.json b/package.json index 29ef2a0..e2d0dc2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts", "test:coverage": "bun test --coverage", "test:smoke": "bun run build && bun scripts/api-smoke.ts", + "test:pwa": "bun run build && bun scripts/pwa-smoke.ts", "db:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts", "db:restore": "bun scripts/restore.ts", "monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts" @@ -40,6 +41,7 @@ "@types/react-dom": "^19", "drizzle-kit": "^0.31.10", "happy-dom": "^20.14.0", + "playwright": "^1.63.0", "tailwindcss": "^4.1.11", "typescript": "^7.0.2" } diff --git a/scripts/build-pwa.ts b/scripts/build-pwa.ts new file mode 100644 index 0000000..050b079 --- /dev/null +++ b/scripts/build-pwa.ts @@ -0,0 +1,27 @@ +import { createHash } from "node:crypto"; +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { manifest } from "../src/pwa/manifest"; + +export async function buildPwa(outdir: string, outputs: readonly { path: string }[]) { + const directory = path.join(outdir, "pwa"); + await mkdir(directory, { recursive: true }); + for (const icon of manifest.icons) await copyFile(`src/pwa/public/${path.basename(icon.src)}`, path.join(directory, path.basename(icon.src))); + await Bun.write(path.join(outdir, "manifest.webmanifest"), JSON.stringify(manifest, null, 2)); + // Only browser assets emitted by Bun; index.js is the private server bundle. + const files = outputs.map(output => path.relative(outdir, output.path)).filter(file => + file !== "index.js" && /\.(js|css|png|svg|woff2|webmanifest)$/.test(file)); + files.push(...manifest.icons.map(icon => icon.src.slice(1)), "manifest.webmanifest"); + for await (const file of new Bun.Glob("fonts/browser/*.woff2").scan(outdir)) files.push(file); + files.sort(); + const template = await Bun.file("src/pwa/sw.js").text(); + const html = await Bun.file(path.join(outdir, "index.html")).text(); + const hash = createHash("sha256").update(template).update(html); + for (const file of files) hash.update(file).update(await Bun.file(path.join(outdir, file)).arrayBuffer().then(Buffer.from)); + const version = hash.digest("hex").slice(0, 16); + const shell = `/pwa/shell-${version}.html`; + await Bun.write(path.join(outdir, shell.slice(1)), html); + const config = { version, shell, assets: [...files.map(file => `/${file}`), shell] }; + await Bun.write(path.join(directory, "sw.js"), template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config)))); + console.log(` PWA ${version}: ${config.assets.length} public assets`); +} diff --git a/scripts/pwa-icons.ts b/scripts/pwa-icons.ts new file mode 100644 index 0000000..4242e95 --- /dev/null +++ b/scripts/pwa-icons.ts @@ -0,0 +1,17 @@ +import { createCanvas, loadImage } from "@napi-rs/canvas"; +import { mkdir } from "node:fs/promises"; + +// Package the approved master at exact platform sizes; do not regenerate the artwork. +const master = await loadImage("docs/design/mina-habits-icon.png"); +await mkdir("src/pwa/public", { recursive: true }); +for (const [name, size, scale] of [ + ["icon-192", 192, 1], ["icon-512", 512, 1], ["icon-maskable-512", 512, 0.7], +] as const) { + const canvas = createCanvas(size, size); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, size, size); + const inset = size * (1 - scale) / 2; + ctx.drawImage(master, inset, inset, size * scale, size * scale); + await Bun.write(`src/pwa/public/${name}.png`, canvas.toBuffer("image/png")); +} diff --git a/scripts/pwa-smoke.ts b/scripts/pwa-smoke.ts new file mode 100644 index 0000000..d084cde --- /dev/null +++ b/scripts/pwa-smoke.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import { cp, mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { chromium } from "playwright"; +import { Database } from "bun:sqlite"; +import { hashToken } from "../src/auth"; + +// Exercise a copied production bundle and disposable account. No live data or Discord calls. +const directory = await mkdtemp(path.join(tmpdir(), "minabot-pwa-")); +const project = process.cwd(); +const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); +const port = probe.port!; +probe.stop(true); +const origin = `http://127.0.0.1:${port}`; +const databasePath = path.join(directory, "test.sqlite"); +const env = { ...process.env, NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), APP_ORIGIN: origin, + DATABASE_PATH: databasePath, BACKUP_ENABLED: "false", DISCORD_CLIENT_ID: "", DISCORD_CLIENT_SECRET: "", DISCORD_PUBLIC_KEY: "", + DISCORD_BOT_TOKEN: "", DISCORD_SHARING_CHANNEL_ID: "", AUTH_COOKIE_SECRET: "pwa-test-only-secret-at-least-32-characters" }; +let server: ReturnType | undefined; +let browser: Awaited> | undefined; +const screenshots = path.resolve(".cache/pwa"); +try { + await cp("dist", path.join(directory, "dist"), { recursive: true }); + await symlink(path.join(project, "node_modules"), path.join(directory, "node_modules"), "dir"); + const migration = Bun.spawn([process.execPath, "scripts/migrate.ts"], { env, stdout: "pipe", stderr: "pipe" }); + assert.equal(await migration.exited, 0, "Production migrations failed"); + const sqlite = new Database(databasePath); + const now = Date.now(); + const token = "p".repeat(43); + sqlite.query("INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("pwa", "pwa", "PWA Test", "UTC", now, now); + sqlite.query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(hashToken(token), "pwa", now, now + 3600000); + sqlite.close(); + server = Bun.spawn([process.execPath, "index.js"], { cwd: path.join(directory, "dist"), env, stdout: "pipe", stderr: "pipe" }); + let healthy = false; + for (let attempt = 0; attempt < 100; attempt++) { + try { if ((await fetch(`${origin}/api/health`)).ok) { healthy = true; break; } } catch { /* Starting. */ } + if (server.exitCode !== null) throw new Error(await new Response(server.stderr as ReadableStream).text()); + await Bun.sleep(50); + } + assert(healthy, "Production server failed to start"); + const swResponse = await fetch(`${origin}/sw.js`); + assert.equal(swResponse.headers.get("service-worker-allowed"), "/"); + assert.equal(swResponse.headers.get("cache-control"), "no-cache"); + assert.match(swResponse.headers.get("content-type")!, /javascript/); + assert.equal((await fetch(`${origin}/pwa/index.js`)).status, 404); + assert.equal((await fetch(`${origin}/pwa/missing.png`)).status, 404); + assert.equal((await fetch(`${origin}/pwa/icon-192.png`, { method: "POST" })).status, 405); + assert.equal((await fetch(`${origin}/api/me`)).headers.get("cache-control"), "no-store"); + + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); + const pageErrors: string[] = []; + page.on("pageerror", error => pageErrors.push(error.message)); + await page.goto(origin); + await page.getByRole("heading", { name: /Small steps/ }).waitFor(); + await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller)); + assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0, "First install must not announce an update"); + const cdp = await context.newCDPSession(page); + const manifestResult = await cdp.send("Page.getAppManifest"); + assert.equal(manifestResult.errors.length, 0, JSON.stringify(manifestResult.errors)); + const manifest = JSON.parse(manifestResult.data!); + assert.equal(manifest.id, "/"); + assert.equal(manifest.display, "standalone"); + assert.equal(manifest.scope, "/"); + for (const icon of manifest.icons) { + const response = await fetch(new URL(icon.src, origin)); + assert.match(response.headers.get("content-type")!, /image\/png/); + const png = Buffer.from(await response.arrayBuffer()); + assert.equal(`${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`, icon.sizes); + } + const installability = await cdp.send("Page.getInstallabilityErrors"); + assert.deepEqual(installability.installabilityErrors, []); + console.log("PASS Chromium installability, manifest, icon sizes, MIME types and HTTP cache policy"); + await mkdir(screenshots, { recursive: true }); + await page.screenshot({ path: path.join(screenshots, "desktop.png"), fullPage: true }); + await page.getByRole("radio", { name: "Dark", exact: true }).check(); + assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#191e1b"); + await page.getByRole("radio", { name: "Light", exact: true }).check(); + + await context.setOffline(true); + await page.goto(`${origin}/a/deep/link`); + await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor(); + assert(await page.getByText("We can’t connect to Mina Habits.", { exact: false }).isVisible()); + await page.setViewportSize({ width: 375, height: 812 }); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Offline mobile layout overflows"); + await page.screenshot({ path: path.join(screenshots, "offline-mobile.png"), fullPage: true }); + await page.goto(`${origin}/design-system`); + await page.getByRole("heading", { name: /design system/i }).first().waitFor(); + await context.setOffline(false); + await page.goto(origin); + await page.getByRole("heading", { name: /Small steps/ }).waitFor(); + await context.setOffline(true); + await page.reload(); + await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor(); + await context.setOffline(false); + await page.getByRole("heading", { name: /Small steps/ }).waitFor(); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Online mobile layout overflows"); + await page.screenshot({ path: path.join(screenshots, "mobile.png"), fullPage: true }); + console.log("PASS offline cold start, nested routes, lazy routes, mobile layouts, theme and automatic reconnection"); + + // Keep an unsaved draft in another open window while applying an update here. + const other = await context.newPage(); + await other.goto(origin); + const draft = other.getByRole("textbox", { name: "Total glasses of water", exact: true }); + await draft.fill("6"); + const workerPath = path.join(directory, "dist/pwa/sw.js"); + const original = await Bun.file(workerPath).text(); + const configMatch = original.match(/const CONFIG = JSON.parse\((.+)\);/)!; + const config = JSON.parse(JSON.parse(configMatch[1]!)); + // A broken release must leave the active worker and existing offline assets usable. + const broken = { ...config, version: `${config.version}-broken`, assets: [...config.assets, "/pwa/missing.png"] }; + await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(broken))});`)); + await page.evaluate(async () => { + const registration = (await navigator.serviceWorker.getRegistration())!; + await new Promise((resolve, reject) => { + registration.addEventListener("updatefound", () => { + const worker = registration.installing!; + worker.addEventListener("statechange", () => { if (worker.state === "redundant") resolve(); }); + }, { once: true }); + void registration.update().catch(reject); + }); + }); + assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0); + await context.setOffline(true); + await page.reload(); + await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor(); + await context.setOffline(false); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + await page.getByRole("heading", { name: /Small steps/ }).waitFor(); + config.version += "-qa"; + await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(config))});`)); + await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration())!.update(); }); + await page.getByRole("button", { name: "Update app", exact: true }).waitFor(); + await page.getByRole("button", { name: "Update app", exact: true }).click(); + await page.getByRole("button", { name: "Later", exact: true }).click(); + assert.equal(await draft.inputValue(), "6"); + await page.getByRole("button", { name: "Update app", exact: true }).click(); + await Promise.all([page.waitForEvent("load"), page.getByRole("button", { name: "Reload and update", exact: true }).click()]); + await page.getByRole("heading", { name: /Small steps/ }).waitFor(); + assert.equal(await draft.inputValue(), "6", "Updating another window discarded its draft"); + await other.getByRole("button", { name: "Update app", exact: true }).waitFor(); + const keys = await page.evaluate(() => caches.keys()); + assert(keys.some(key => key.endsWith("-qa")) && keys.length >= 2, "Old window assets were not retained"); + console.log("PASS failed-update recovery, update waiting, deferred reload and preservation of another window’s draft"); + + await context.addCookies([{ name: "minabot_session", value: token, url: origin }]); + await page.reload(); + await page.getByRole("button", { name: "Sign out", exact: true }).waitFor(); + const privateResponse = await page.evaluate(async () => ({ status: (await fetch("/api/me")).status })); + assert.equal(privateResponse.status, 200); + await context.setOffline(true); + assert.equal(await page.evaluate(async () => { try { await fetch("/api/me"); return true; } catch { return false; } }), false, "Account response was cached"); + await context.setOffline(false); + await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await page.getByRole("heading", { name: /Small steps/ }).waitFor(); + const cacheUrls = await page.evaluate(async () => { + const urls: string[] = []; + for (const key of await caches.keys()) for (const request of await (await caches.open(key)).keys()) urls.push(new URL(request.url).pathname); + return urls; + }); + assert(!cacheUrls.some(url => url.startsWith("/api") || url.endsWith(".map") || url === "/index.js"), "Private files entered Cache Storage"); + assert.deepEqual(pageErrors, []); + console.log("PASS authenticated API bypass, sign-out privacy and no uncaught browser errors"); + const manualContext = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true }); + await manualContext.addInitScript(() => { + window.addEventListener("beforeinstallprompt", event => { event.preventDefault(); event.stopImmediatePropagation(); }); + }); + const manual = await manualContext.newPage(); + await manual.goto(origin); + await manual.getByRole("button", { name: "Install app", exact: true }).click(); + await manual.getByRole("dialog", { name: "Keep Mina Habits close." }).waitFor(); + assert(await manual.getByText("iPhone or iPad:", { exact: false }).isVisible()); + assert(await manual.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Install dialog overflows"); + await manual.screenshot({ path: path.join(screenshots, "install-mobile.png"), fullPage: true }); + await manual.getByRole("button", { name: "Got it", exact: true }).click(); + await manual.evaluate(() => window.dispatchEvent(new Event("appinstalled"))); + assert.equal(await manual.getByRole("button", { name: "Install app", exact: true }).count(), 0); + await manualContext.close(); + console.log("PASS mobile installation guidance and installed-state controls"); + console.log(`Screenshots: ${screenshots}`); +} catch (error) { + const page = browser?.contexts()[0]?.pages()[0]; + if (page) { + console.error("Browser at failure:", await page.locator("body").innerText()); + await page.screenshot({ path: path.join(screenshots, "failure.png"), fullPage: true }); + } + throw error; +} finally { + await browser?.close(); + if (server) { server.kill(); await server.exited; } + await rm(directory, { recursive: true, force: true }); +} diff --git a/src/api.ts b/src/api.ts index 77afff3..d28e53e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -18,6 +18,8 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi app.use("*", async (c, next) => { const requestId = crypto.randomUUID(); c.set("requestId", requestId); c.header("X-Request-ID", requestId); + // Accounts, calendars, exports and OAuth responses must never enter HTTP caches. + c.header("Cache-Control", "no-store"); await next(); }); const auth = createAuth(db, config, request, now); diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx index 24d3976..0fa7163 100644 --- a/src/components/AuthProvider.tsx +++ b/src/components/AuthProvider.tsx @@ -11,6 +11,7 @@ const authErrors: Record = { }; function signIn() { + if (navigator.onLine === false) return; let timezone = "UTC"; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ } window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`); @@ -21,6 +22,7 @@ type AuthState = { loading: boolean; busy: boolean; accountError: string; + connectionError: boolean; error: string; signIn: () => void; signOut: () => Promise; @@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [accountError, setAccountError] = useState(""); + const [connectionError, setConnectionError] = useState(false); const [actionError, setActionError] = useState(""); const [attempt, setAttempt] = useState(0); const { search } = useLocation(); @@ -42,15 +45,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { const controller = new AbortController(); async function loadUser() { + let reachedServer = false; try { - const response = await fetch("/api/me", { signal: controller.signal }); + setAccountError(""); + setConnectionError(false); + const response = await fetch("/api/me", { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) }); + reachedServer = true; if (controller.signal.aborted) return; if (response.status === 401) { setUser(null); return; } if (!response.ok) throw new Error("Could not load your account. Please try again."); const account: PublicUser = await response.json(); if (!controller.signal.aborted) setUser(account); } catch (error) { - if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account."); + if (!controller.signal.aborted) { + setConnectionError(!reachedServer); + setAccountError(!reachedServer ? "Could not connect to Mina Habits. Check your connection and try again." : error instanceof Error ? error.message : "Could not load your account."); + } } finally { if (!controller.signal.aborted) setLoading(false); } @@ -59,6 +69,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => controller.abort(); }, [attempt]); + useEffect(() => { + const reconnect = () => { if (accountError) retry(); }; + const check = () => { if (connectionError && navigator.onLine !== false && document.visibilityState === "visible") retry(); }; + const timer = window.setInterval(check, 15_000); + window.addEventListener("online", reconnect); + window.addEventListener("focus", check); + return () => { window.clearInterval(timer); window.removeEventListener("online", reconnect); window.removeEventListener("focus", check); }; + }, [accountError, connectionError]); + function retry() { setAccountError(""); setLoading(true); @@ -69,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setBusy(true); setActionError(""); try { + if (navigator.onLine === false) throw new Error("Reconnect to sign out."); const response = await fetch("/api/auth/logout", { method: "POST" }); if (!response.ok) throw new Error("Could not sign out. Please try again."); setUser(null); @@ -78,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } return ( - + {children} ); diff --git a/src/components/PwaControls.tsx b/src/components/PwaControls.tsx new file mode 100644 index 0000000..c2fbfb8 --- /dev/null +++ b/src/components/PwaControls.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { applyUpdate, installApp, useOnline, usePwa } from "../pwa/client"; +import { Button } from "./design-system/primitives"; +import { Modal } from "./design-system/Modal"; + +export function InstallApp() { + const { enabled, installed, installPrompt } = usePwa(); + const [help, setHelp] = useState(false); + if (!enabled || installed) return null; + return <> + + {help && setHelp(false)}> +
+

Open Mina Habits from your home screen or dock.

+

iPhone or iPad: open the Share menu, then choose Add to Home Screen.

+

Mac Safari: choose File → Add to Dock.

+

Chrome or Edge: use the install icon in the address bar, or Install app in the browser menu.

+

Installation options depend on your browser. Your habits still need a connection to load and save.

+
+
+
} + ; +} + +export function PwaStatus() { + const online = useOnline(); + const { updateReady, error } = usePwa(); + const [confirmUpdate, setConfirmUpdate] = useState(false); + return <> + {!online &&
You’re offline. Reconnect to load or save your habits. Changes are not queued.
} + {error &&
{error}
} + {updateReady &&
+ A new version of Mina Habits is ready. + +
} + {confirmUpdate && setConfirmUpdate(false)}> +
+

This window will reload. Save any unfinished changes first.

+
+
+
} + ; +} diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 62cda6d..6c2c880 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -13,6 +13,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { document.documentElement.dataset.theme = theme; document.documentElement.classList.toggle("dark", theme === "dark"); document.documentElement.style.colorScheme = theme; + document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#191e1b" : "#ffffff"); }, [theme]); useEffect(() => { const media = window.matchMedia?.("(prefers-color-scheme: dark)"); diff --git a/src/components/design-system/PageLayout.tsx b/src/components/design-system/PageLayout.tsx index 7decf1f..d7821d3 100644 --- a/src/components/design-system/PageLayout.tsx +++ b/src/components/design-system/PageLayout.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { Link } from "react-router"; import { ThemeControl } from "../ThemeProvider"; import { Button } from "./primitives"; +import { InstallApp, PwaStatus } from "../PwaControls"; /** The existing design-system page shell, shared without page-specific styling. */ export function PageLayout({ @@ -34,10 +35,12 @@ export function PageLayout({
{header}
+ {children}