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.
This commit is contained in:
syntaxbullet
2026-09-08 17:19:12 +02:00
parent e081e9acbf
commit ccd998ab04
29 changed files with 795 additions and 13 deletions

View File

@@ -1,5 +1,9 @@
# Mina Habits # 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 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. client includes a homepage for signed-out visitors and a personal habit dashboard.

View File

@@ -1,6 +1,7 @@
import tailwind from "bun-plugin-tailwind"; import tailwind from "bun-plugin-tailwind";
import { rm, mkdir, copyFile } from "node:fs/promises"; import { rm, mkdir, copyFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { buildPwa } from "./scripts/build-pwa";
const outdir = path.join(process.cwd(), "dist"); const outdir = path.join(process.cwd(), "dist");
await rm(outdir, { recursive: true, force: true }); 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"]) { 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 copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`));
} }
await buildPwa(outdir, result.outputs);

4
bun-env.d.ts vendored
View File

@@ -9,6 +9,10 @@ declare module "*.svg" {
} }
declare module "*.css" {} declare module "*.css" {}
declare module "*.webmanifest" {
const content: string;
export default content;
}
declare module "*.png" { declare module "*.png" {
const path: string; const path: string;
export default path; export default path;

View File

@@ -26,6 +26,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0", "happy-dom": "^20.14.0",
"playwright": "^1.63.0",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "^7.0.2", "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=="], "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": ["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=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],

103
docs/PWA.md Normal file
View File

@@ -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.

View File

@@ -14,6 +14,7 @@
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts", "db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
"test:coverage": "bun test --coverage", "test:coverage": "bun test --coverage",
"test:smoke": "bun run build && bun scripts/api-smoke.ts", "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:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts",
"db:restore": "bun scripts/restore.ts", "db:restore": "bun scripts/restore.ts",
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts" "monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
@@ -40,6 +41,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0", "happy-dom": "^20.14.0",
"playwright": "^1.63.0",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "^7.0.2" "typescript": "^7.0.2"
} }

27
scripts/build-pwa.ts Normal file
View File

@@ -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`);
}

17
scripts/pwa-icons.ts Normal file
View File

@@ -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"));
}

194
scripts/pwa-smoke.ts Normal file
View File

@@ -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<typeof Bun.spawn> | undefined;
let browser: Awaited<ReturnType<typeof chromium.launch>> | 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<void>((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 });
}

View File

@@ -18,6 +18,8 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
c.set("requestId", requestId); c.header("X-Request-ID", requestId); 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(); await next();
}); });
const auth = createAuth(db, config, request, now); const auth = createAuth(db, config, request, now);

View File

@@ -11,6 +11,7 @@ const authErrors: Record<string, string> = {
}; };
function signIn() { function signIn() {
if (navigator.onLine === false) return;
let timezone = "UTC"; let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ } try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`); window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
@@ -21,6 +22,7 @@ type AuthState = {
loading: boolean; loading: boolean;
busy: boolean; busy: boolean;
accountError: string; accountError: string;
connectionError: boolean;
error: string; error: string;
signIn: () => void; signIn: () => void;
signOut: () => Promise<void>; signOut: () => Promise<void>;
@@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [accountError, setAccountError] = useState(""); const [accountError, setAccountError] = useState("");
const [connectionError, setConnectionError] = useState(false);
const [actionError, setActionError] = useState(""); const [actionError, setActionError] = useState("");
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const { search } = useLocation(); const { search } = useLocation();
@@ -42,15 +45,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
async function loadUser() { async function loadUser() {
let reachedServer = false;
try { 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 (controller.signal.aborted) return;
if (response.status === 401) { setUser(null); return; } if (response.status === 401) { setUser(null); return; }
if (!response.ok) throw new Error("Could not load your account. Please try again."); if (!response.ok) throw new Error("Could not load your account. Please try again.");
const account: PublicUser = await response.json(); const account: PublicUser = await response.json();
if (!controller.signal.aborted) setUser(account); if (!controller.signal.aborted) setUser(account);
} catch (error) { } 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 { } finally {
if (!controller.signal.aborted) setLoading(false); if (!controller.signal.aborted) setLoading(false);
} }
@@ -59,6 +69,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => controller.abort(); return () => controller.abort();
}, [attempt]); }, [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() { function retry() {
setAccountError(""); setAccountError("");
setLoading(true); setLoading(true);
@@ -69,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setBusy(true); setBusy(true);
setActionError(""); setActionError("");
try { try {
if (navigator.onLine === false) throw new Error("Reconnect to sign out.");
const response = await fetch("/api/auth/logout", { method: "POST" }); const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again."); if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null); setUser(null);
@@ -78,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
return ( return (
<AuthContext.Provider value={{ user, loading, busy, accountError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}> <AuthContext.Provider value={{ user, loading, busy, accountError, connectionError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -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 <>
<Button variant="text" onClick={() => { if (installPrompt) void installApp(); else setHelp(true); }}>Install app</Button>
{help && <Modal id="install-app" size="compact" title="Keep Mina Habits close." onClose={() => setHelp(false)}>
<div className="ds-form-stack">
<p>Open Mina Habits from your home screen or dock.</p>
<p><strong>iPhone or iPad:</strong> open the Share menu, then choose Add to Home Screen.</p>
<p><strong>Mac Safari:</strong> choose File → Add to Dock.</p>
<p><strong>Chrome or Edge:</strong> use the install icon in the address bar, or Install app in the browser menu.</p>
<p className="ds-supporting-copy">Installation options depend on your browser. Your habits still need a connection to load and save.</p>
<div className="ds-actions"><Button onClick={() => setHelp(false)}>Got it</Button></div>
</div>
</Modal>}
</>;
}
export function PwaStatus() {
const online = useOnline();
const { updateReady, error } = usePwa();
const [confirmUpdate, setConfirmUpdate] = useState(false);
return <>
{!online && <div className="ds-form-feedback" role="status">You’re offline. Reconnect to load or save your habits. Changes are not queued.</div>}
{error && <div className="ds-form-feedback" role="status">{error}</div>}
{updateReady && <div className="ds-action-row" role="status">
<span>A new version of Mina Habits is ready.</span>
<Button variant="secondary" onClick={() => setConfirmUpdate(true)}>Update app</Button>
</div>}
{confirmUpdate && <Modal id="update-app" size="compact" title="Ready to update?" onClose={() => setConfirmUpdate(false)}>
<div className="ds-form-stack">
<p>This window will reload. Save any unfinished changes first.</p>
<div className="ds-actions"><Button onClick={applyUpdate}>Reload and update</Button><Button variant="text" onClick={() => setConfirmUpdate(false)}>Later</Button></div>
</div>
</Modal>}
</>;
}

View File

@@ -13,6 +13,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark"); document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#191e1b" : "#ffffff");
}, [theme]); }, [theme]);
useEffect(() => { useEffect(() => {
const media = window.matchMedia?.("(prefers-color-scheme: dark)"); const media = window.matchMedia?.("(prefers-color-scheme: dark)");

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { ThemeControl } from "../ThemeProvider"; import { ThemeControl } from "../ThemeProvider";
import { Button } from "./primitives"; import { Button } from "./primitives";
import { InstallApp, PwaStatus } from "../PwaControls";
/** The existing design-system page shell, shared without page-specific styling. */ /** The existing design-system page shell, shared without page-specific styling. */
export function PageLayout({ export function PageLayout({
@@ -34,10 +35,12 @@ export function PageLayout({
<div className="ds-header-actions">{header}<ThemeControl /></div> <div className="ds-header-actions">{header}<ThemeControl /></div>
</header> </header>
<main id={mainId} className="ds-main" tabIndex={-1}> <main id={mainId} className="ds-main" tabIndex={-1}>
<PwaStatus />
{children} {children}
<footer className="ds-footer"> <footer className="ds-footer">
<span className="ds-wordmark">Mina Habits.</span> <span className="ds-wordmark">Mina Habits.</span>
<span>A little, every day.</span> <span>A little, every day.</span>
<InstallApp />
<Button <Button
variant="text" variant="text"
onClick={() => window.scrollTo({ top: 0, behavior: "instant" })} onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}

View File

@@ -3,6 +3,9 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import { App } from "./App"; import { App } from "./App";
import "../styles/globals.css"; import "../styles/globals.css";
import { startPwa } from "./pwa/client";
if (process.env.NODE_ENV === "production") startPwa();
const container = document.getElementById("root")!; const container = document.getElementById("root")!;
const root = import.meta.hot const root = import.meta.hot

View File

@@ -2,7 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<base href="/" />
<link rel="manifest" href="./pwa/manifest.webmanifest" />
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Mina Habits" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon.png" /> <link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png" />
<title>Mina Habits</title> <title>Mina Habits</title>
@@ -11,5 +18,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<noscript>Mina Habits needs JavaScript to display your habits. Enable JavaScript and reload this page.</noscript>
</body> </body>
</html> </html>

View File

@@ -1,4 +1,5 @@
import { browserFont } from "./browser-fonts"; import { browserFont } from "./browser-fonts";
import { pwaAsset } from "./pwa/assets";
import { startReminders } from "./reminders/worker"; import { startReminders } from "./reminders/worker";
import { backupConfig, startBackups } from "./ops/backups"; import { backupConfig, startBackups } from "./ops/backups";
import { databasePath } from "./db/config"; import { databasePath } from "./db/config";
@@ -30,6 +31,9 @@ const server = Bun.serve({
hostname: process.env.HOST ?? "127.0.0.1", hostname: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT ?? 3000), port: Number(process.env.PORT ?? 3000),
routes: { routes: {
"/manifest.webmanifest": pwaAsset,
"/sw.js": pwaAsset,
"/pwa/*": pwaAsset,
"/fonts/browser/*": browserFont, "/fonts/browser/*": browserFont,
"/api": req => app.fetch(req), "/api": req => app.fetch(req),
"/api/*": req => app.fetch(req), "/api/*": req => app.fetch(req),

View File

@@ -14,6 +14,8 @@ export async function habitRequest<T>(
path: string, path: string,
options?: RequestInit, options?: RequestInit,
): Promise<T> { ): Promise<T> {
if (typeof navigator !== "undefined" && navigator.onLine === false)
throw new Error("You’re offline. Reconnect and try again. Changes have not been saved or queued.");
const response = await fetch(`/api${path}`, options); const response = await fetch(`/api${path}`, options);
if (!response.ok) { if (!response.ok) {
if (response.status === 401) if (response.status === 401)

View File

@@ -1,4 +1,5 @@
import { deferred } from "../components/design-system/deferred"; import { deferred } from "../components/design-system/deferred";
import { useOnline } from "../pwa/client";
import { CalendarCacheProvider } from "../components/CalendarCache"; import { CalendarCacheProvider } from "../components/CalendarCache";
import { Archive, Plus, Settings2 } from "lucide-react"; import { Archive, Plus, Settings2 } from "lucide-react";
const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits)); const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits));
@@ -30,22 +31,24 @@ import type { Schedule } from "../habits/contracts";
const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress)); const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress));
export function Home() { export function Home() {
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth(); const online = useOnline();
const { user, loading, busy, error, accountError, connectionError, signIn, signOut, retry } = useAuth();
const disconnected = !online || connectionError;
return ( return (
<PageLayout <PageLayout
header={ header={
loading ? ( loading ? (
<span className="ds-library-label">Loading account…</span> <span className="ds-library-label">Loading account…</span>
) : user ? ( ) : user ? (
<Button variant="text" disabled={busy} onClick={() => void signOut()}> <Button variant="text" disabled={busy || !online} onClick={() => void signOut()}>
{busy ? "Signing out…" : "Sign out"} {busy ? "Signing out…" : "Sign out"}
</Button> </Button>
) : ( ) : (
!accountError && <DiscordSignInButton variant="secondary" onClick={signIn} /> !accountError && <DiscordSignInButton disabled={!online} variant="secondary" onClick={signIn} />
) )
} }
> >
{error && ( {error && !disconnected && (
<div className="ds-form-feedback" role="alert"> <div className="ds-form-feedback" role="alert">
{error} {error}
</div> </div>
@@ -56,9 +59,9 @@ export function Home() {
</section> </section>
) : accountError ? ( ) : accountError ? (
<section className="ds-section"> <section className="ds-section">
<h1 className="type-section">Let’s try that again.</h1> <h1 className="type-section">{disconnected ? "A moment to reconnect." : "Let’s try that again."}</h1>
<Card headingLevel={2} heading="Account unavailable"> <Card headingLevel={2} heading={disconnected ? "Your habits will be here." : "Account unavailable"}>
<p>Your account couldn’t be loaded.</p> <p>{disconnected ? "We can’t connect to Mina Habits. Check your internet connection to load your account and check in. We’ll try again automatically." : "Your account couldn’t be loaded."}</p>
<div className="ds-actions"> <div className="ds-actions">
<Button onClick={retry}>Try again</Button> <Button onClick={retry}>Try again</Button>
</div> </div>
@@ -74,6 +77,7 @@ export function Home() {
} }
function Welcome({ onSignIn }: { onSignIn: () => void }) { function Welcome({ onSignIn }: { onSignIn: () => void }) {
const online = useOnline();
const [water, setWater] = useState(3); const [water, setWater] = useState(3);
const [read, setRead] = useState(false); const [read, setRead] = useState(false);
return ( return (
@@ -93,7 +97,7 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) {
day at a time. day at a time.
</p> </p>
<div className="ds-actions"> <div className="ds-actions">
<DiscordSignInButton onClick={onSignIn} /> <DiscordSignInButton disabled={!online} onClick={onSignIn} />
<ButtonLink href="#try-it">Try it below ↓</ButtonLink> <ButtonLink href="#try-it">Try it below ↓</ButtonLink>
</div> </div>
<p className="type-small">Sign in with your Discord account to save your habits.</p> <p className="type-small">Sign in with your Discord account to save your habits.</p>
@@ -230,14 +234,16 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
// Refresh after returning to the page and across the account's local midnight. // Refresh after returning to the page and across the account's local midnight.
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
if (!saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible") if (navigator.onLine !== false && !saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
setAttempt((value) => value + 1); setAttempt((value) => value + 1);
}; };
const timer = window.setInterval(refresh, 60_000); const timer = window.setInterval(refresh, 60_000);
window.addEventListener("focus", refresh); window.addEventListener("focus", refresh);
window.addEventListener("online", refresh);
return () => { return () => {
window.clearInterval(timer); window.clearInterval(timer);
window.removeEventListener("focus", refresh); window.removeEventListener("focus", refresh);
window.removeEventListener("online", refresh);
}; };
}, []); }, []);

26
src/pwa/assets.ts Normal file
View File

@@ -0,0 +1,26 @@
import path from "node:path";
import { manifest } from "./manifest";
/** Serve only public PWA files. Never expose the server bundle or source maps. */
export function pwaAsset(request: Request): Response {
const pathname = new URL(request.url).pathname;
const headers = { "Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff" };
if (!["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 405, headers: { ...headers, Allow: "GET, HEAD" } });
if (pathname === "/manifest.webmanifest") {
return new Response(request.method === "HEAD" ? null : JSON.stringify(manifest), { headers: { ...headers, "Content-Type": "application/manifest+json" } });
}
const production = process.env.NODE_ENV === "production";
const filename = pathname.slice("/pwa/".length);
const icon = /^icon-(192|512|maskable-512)\.png$/.test(filename);
const shell = production && /^shell-[a-f0-9]{16}\.html$/.test(filename);
if (pathname !== "/sw.js" && !icon && !shell) return new Response(null, { status: 404, headers });
if (pathname === "/sw.js" && !production) return new Response(null, { status: 404, headers });
const file = Bun.file(path.resolve(production ? "pwa" : "src/pwa/public", pathname === "/sw.js" ? "sw.js" : filename));
if (!file.size) return new Response(null, { status: 404, headers });
return new Response(request.method === "HEAD" ? null : file, { headers: {
...headers,
"Content-Type": icon ? "image/png" : shell ? "text/html; charset=utf-8" : "text/javascript; charset=utf-8",
...(pathname === "/sw.js" ? { "Service-Worker-Allowed": "/" } : {}),
...(shell ? { "Cache-Control": "public, max-age=31536000, immutable" } : {}),
} });
}

90
src/pwa/client.ts Normal file
View File

@@ -0,0 +1,90 @@
import { useSyncExternalStore } from "react";
type InstallPrompt = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
type PwaState = { enabled: boolean; installed: boolean; installPrompt: InstallPrompt | null; updateReady: boolean; error: string };
let state: PwaState = { enabled: false, installed: false, installPrompt: null, updateReady: false, error: "" };
const listeners = new Set<() => void>();
let registration: ServiceWorkerRegistration | undefined;
let reloading = false;
let started = false;
const publish = (change: Partial<PwaState>) => {
state = { ...state, ...change };
listeners.forEach(listener => listener());
};
const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
export const usePwa = () => useSyncExternalStore(subscribe, () => state, () => state);
const subscribeOnline = (listener: () => void) => {
window.addEventListener("online", listener);
window.addEventListener("offline", listener);
return () => { window.removeEventListener("online", listener); window.removeEventListener("offline", listener); };
};
export const useOnline = () => useSyncExternalStore(subscribeOnline, () => navigator.onLine !== false, () => true);
/** Called once before React renders so early install events cannot be missed. */
export function startPwa() {
if (started || !window.isSecureContext || !("serviceWorker" in navigator)) return;
started = true;
const standalone = window.matchMedia("(display-mode: standalone)");
const installed = () => standalone.matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
publish({ enabled: true, installed: installed() });
standalone.addEventListener("change", () => publish({ installed: installed() }));
window.addEventListener("beforeinstallprompt", event => {
event.preventDefault();
publish({ installPrompt: event as InstallPrompt });
});
window.addEventListener("appinstalled", () => publish({ installed: true, installPrompt: null }));
let controller = navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (reloading) window.location.reload();
else if (controller) publish({ updateReady: true });
controller = navigator.serviceWorker.controller;
});
const register = async () => {
try {
registration = await navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" });
publish({ updateReady: Boolean(registration.waiting) });
const observeInstall = () => {
const worker = registration?.installing;
worker?.addEventListener("statechange", () => {
if (worker.state === "installed") publish({ updateReady: Boolean(navigator.serviceWorker.controller), error: "" });
if (worker.state === "redundant" && !navigator.serviceWorker.controller)
publish({ error: "Offline setup didn’t finish. Reconnect and reload to try again." });
});
};
registration.addEventListener("updatefound", observeInstall);
observeInstall();
const check = () => {
if (navigator.onLine && document.visibilityState === "visible") void registration?.update().catch(() => {});
};
document.addEventListener("visibilitychange", check);
window.addEventListener("online", check);
window.setInterval(check, 60 * 60 * 1000);
} catch {
publish({ error: "Offline setup is unavailable. Reconnect and reload to try again." });
}
};
if (document.readyState === "complete") void register();
else window.addEventListener("load", () => { void register(); }, { once: true });
}
export async function installApp(): Promise<boolean> {
const prompt = state.installPrompt;
if (!prompt) return false;
publish({ installPrompt: null });
try {
await prompt.prompt();
await prompt.userChoice;
} catch { publish({ error: "Could not open installation. Try your browser’s install menu." }); }
return true;
}
export function applyUpdate() {
if (!state.updateReady) return;
if (registration?.waiting) {
reloading = true;
registration.waiting.postMessage({ type: "ACTIVATE_UPDATE" });
} else window.location.reload();
}

9
src/pwa/manifest.ts Normal file
View File

@@ -0,0 +1,9 @@
import source from "./manifest.webmanifest" with { type: "text" };
export const manifest = JSON.parse(source) as {
id: string; name: string; short_name: string; description: string;
lang: string; dir: string; start_url: string; scope: string; display: string;
background_color: string; theme_color: string; categories: string[];
prefer_related_applications: boolean;
icons: { src: string; sizes: string; type: string; purpose: string }[];
};

View File

@@ -0,0 +1,38 @@
{
"id": "/",
"name": "Mina Habits",
"short_name": "Mina Habits",
"description": "A quiet place to track your habits and see your progress.",
"lang": "en",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"categories": [
"productivity",
"lifestyle"
],
"prefer_related_applications": false,
"icons": [
{
"src": "/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

BIN
src/pwa/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
src/pwa/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

66
src/pwa/sw.js Normal file
View File

@@ -0,0 +1,66 @@
/* Build injects a content-derived version and an explicit public-asset allowlist. */
const CONFIG = JSON.parse("__PWA_CONFIG__");
const PREFIX = "mina-habits-static-";
const CACHE = PREFIX + CONFIG.version;
const ASSETS = new Set(CONFIG.assets);
self.addEventListener("install", event => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
// addAll is atomic: a failed download cannot activate a partial release.
await cache.addAll(CONFIG.assets.map(url => new Request(url, { cache: "reload", credentials: "omit" })));
})());
});
self.addEventListener("activate", event => {
event.waitUntil((async () => {
// A user can accept an update while other windows still hold older code.
// Preserve their lazy chunks until a later activation with no open windows.
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
if (windows.length === 0) {
await Promise.all((await caches.keys()).filter(key => key.startsWith(PREFIX) && key !== CACHE).map(key => caches.delete(key)));
}
await self.clients.claim();
})());
});
self.addEventListener("message", event => {
if (event.data?.type === "ACTIVATE_UPDATE") event.waitUntil(self.skipWaiting());
});
async function cachedAsset(request) {
const current = await caches.open(CACHE);
const cached = await current.match(request);
if (cached) return cached;
// Old windows can still request fingerprinted chunks after an accepted update.
for (const key of await caches.keys()) {
if (key.startsWith(PREFIX) && key !== CACHE) {
const response = await (await caches.open(key)).match(request);
if (response) return response;
}
}
return fetch(request);
}
async function navigate(request) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(request, { signal: controller.signal });
if (response.ok) return response;
} catch { /* Unreachable server or offline: use the public shell. */ }
finally { clearTimeout(timeout); }
return (await (await caches.open(CACHE)).match(CONFIG.shell)) || Response.error();
}
self.addEventListener("fetch", event => {
const request = event.request;
const url = new URL(request.url);
// In particular: no account data, OAuth callbacks, exports, writes or third-party responses.
if (request.method !== "GET" || url.origin !== self.location.origin || /^\/api(?:\/|$)/.test(url.pathname)) return;
if (request.mode === "navigate") {
event.respondWith(navigate(request));
} else if (ASSETS.has(url.pathname) || /^\/chunk-[\w-]+\.js$/.test(url.pathname)) {
event.respondWith(cachedAsset(request));
}
});

99
src/pwa/sw.test.ts Normal file
View File

@@ -0,0 +1,99 @@
import { expect, test } from "bun:test";
import { runInNewContext } from "node:vm";
const template = await Bun.file(new URL("./sw.js", import.meta.url)).text();
const origin = "https://mina.example";
const cacheName = "mina-habits-static-test";
function worker({ offline = false, windows = 0, failInstall = false } = {}) {
const events = new Map<string, (event: any) => void>();
const stores = new Map<string, Map<string, Response>>([
[cacheName, new Map([[`${origin}/pwa/shell-test.html`, new Response("offline shell")]])],
["mina-habits-static-old", new Map([[`${origin}/chunk-old.js`, new Response("old chunk")]])],
["unrelated-cache", new Map()],
]);
const deleted: string[] = [];
const fetched: string[] = [];
let skipped = false;
let claimed = false;
let installed: Request[] = [];
const requestOptions: RequestInit[] = [];
const url = (input: Request | string) => new URL(typeof input === "string" ? input : input.url, origin).href;
const config = { version: "test", shell: "/pwa/shell-test.html", assets: ["/chunk-new.js", "/pwa/shell-test.html"] };
runInNewContext(template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config))), {
self: {
location: { origin }, addEventListener: (type: string, handler: (event: any) => void) => events.set(type, handler),
skipWaiting: async () => { skipped = true; },
clients: { matchAll: async () => Array.from({ length: windows }, () => ({})), claim: async () => { claimed = true; } },
},
caches: {
open: async (name: string) => ({
match: async (request: Request | string) => stores.get(name)?.get(url(request))?.clone(),
addAll: async (requests: Request[]) => { if (failInstall) throw new Error("download failed"); installed = requests; },
}),
keys: async () => [...stores.keys()],
delete: async (name: string) => { deleted.push(name); return stores.delete(name); },
},
fetch: async (request: Request) => { fetched.push(request.url); if (offline) throw new TypeError("offline"); return new Response("network"); },
Request: class extends Request { constructor(input: string, init?: RequestInit) { super(url(input), init); requestOptions.push(init ?? {}); } },
Response, Set, URL, AbortController, setTimeout, clearTimeout,
});
async function lifecycle(type: string, data?: unknown) {
let promise: Promise<unknown> | undefined;
events.get(type)!({ data, waitUntil: (value: Promise<unknown>) => { promise = value; } });
await promise;
}
function request(pathname: string, method = "GET", mode = "cors") {
let response: Promise<Response> | undefined;
events.get("fetch")!({ request: { url: new URL(pathname, origin).href, method, mode }, respondWith: (value: Promise<Response>) => { response = value; } });
return response;
}
return { request, lifecycle, deleted, fetched, requestOptions, get skipped() { return skipped; }, get claimed() { return claimed; }, get installed() { return installed; } };
}
test("service worker excludes private API and OAuth navigations, writes and other origins", () => {
const sw = worker();
for (const route of ["/api", "/api/me", "/api/account/export", "/api/auth/discord/callback?code=private"]) {
expect(sw.request(route, "GET", "navigate")).toBeUndefined();
}
expect(sw.request("/chunk-new.js", "POST")).toBeUndefined();
expect(sw.request("https://discord.com/api/me")).toBeUndefined();
expect(sw.request("/private.json")).toBeUndefined();
expect(sw.request("/index.js")).toBeUndefined();
expect(sw.request("/chunk-new.js.map")).toBeUndefined();
});
test("network navigation is fresh while offline navigation uses only the public shell", async () => {
expect(await (await worker().request("/", "GET", "navigate"))!.text()).toBe("network");
const sw = worker({ offline: true });
expect(await (await sw.request("/a/deep/link", "GET", "navigate"))!.text()).toBe("offline shell");
});
test("older windows can load their cached lazy chunks after an update", async () => {
const sw = worker({ offline: true });
expect(await (await sw.request("/chunk-old.js"))!.text()).toBe("old chunk");
expect(sw.fetched).toEqual([]);
});
test("install excludes credentials and waits for every public asset without forcing activation", async () => {
const sw = worker();
await sw.lifecycle("install");
expect(sw.installed).toHaveLength(2);
expect(sw.requestOptions.every(options => options.credentials === "omit" && options.cache === "reload")).toBe(true);
expect(sw.skipped).toBe(false);
await expect(worker({ failInstall: true }).lifecycle("install")).rejects.toThrow("download failed");
});
test("activation preserves old windows and cleans only its own obsolete caches when windows close", async () => {
const occupied = worker({ windows: 2 });
await occupied.lifecycle("activate");
expect(occupied.deleted).toEqual([]);
expect(occupied.claimed).toBe(true);
const empty = worker();
await empty.lifecycle("activate");
expect(empty.deleted).toEqual(["mina-habits-static-old"]);
await empty.lifecycle("message", { type: "unrelated" });
expect(empty.skipped).toBe(false);
await empty.lifecycle("message", { type: "ACTIVATE_UPDATE" });
expect(empty.skipped).toBe(true);
});

View File

@@ -52,6 +52,8 @@
position: relative; position: relative;
margin: -8px; margin: -8px;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
color: var(--ds-ink); color: var(--ds-ink);
background: var(--ds-paper); background: var(--ds-paper);
@apply type-body; @apply type-body;
@@ -770,6 +772,7 @@
border-top: 1px solid var(--ds-rule); border-top: 1px solid var(--ds-rule);
padding: 28px 0 40px; padding: 28px 0 40px;
display: flex; display: flex;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 20px; gap: 20px;