From ce8129703f69f2cd2a17408e64b443b57b5f4c06 Mon Sep 17 00:00:00 2001 From: SamyKhelfa Date: Sat, 3 Oct 2026 20:51:57 +0200 Subject: [PATCH] Make Crystal an installable web app with an offline shell (#39) A manifest and icons rendered from icon.svg make the app installable. The native build emits a service worker that precaches exactly the built files and passes everything else to the network, so ROMs never reach its cache. New versions wait for the player's Update, which saves before reloading. Savegames offers installation, iPhone steps and persistent storage, and a full disk gets its own save error. Acceptance on physical iPhone and Android devices remains. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/PLAN.md | 11 ++- index.html | 5 ++ playwright.config.ts | 19 +++-- public/apple-touch-icon.png | Bin 0 -> 3615 bytes public/icon-192.png | Bin 0 -> 4761 bytes public/icon-512.png | Bin 0 -> 13804 bytes public/icon-maskable-512.png | Bin 0 -> 9122 bytes public/manifest.webmanifest | 23 ++++++ scripts/check-release.mjs | 35 ++++++++- scripts/make-icons.mjs | 36 +++++++++ scripts/service-worker.mjs | 100 +++++++++++++++++++++++++ src/main.ts | 63 +++++++++++++++- src/platform/pwa.ts | 134 +++++++++++++++++++++++++++++++++ src/ui/shell.ts | 3 +- src/ui/styles.css | 44 ++++++++++- tests/pwa-browser.spec.ts | 56 ++++++++++++++ tests/service-worker.test.ts | 140 +++++++++++++++++++++++++++++++++++ vite.config.ts | 3 + 20 files changed, 661 insertions(+), 15 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/icon-maskable-512.png create mode 100644 public/manifest.webmanifest create mode 100644 scripts/make-icons.mjs create mode 100644 scripts/service-worker.mjs create mode 100644 src/platform/pwa.ts create mode 100644 tests/pwa-browser.spec.ts create mode 100644 tests/service-worker.test.ts diff --git a/README.md b/README.md index 367e458..e77db0e 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run check:release # Check the native distribution npm run build:reference # Optional reference in dist-reference/ ``` -Serve `dist/` through an HTTPS static host, or use `npm run preview` locally. Subdirectory hosting is supported with `npm run build -- --base=/Crystal/`. Use HTTPS from another device; ROM verification and save locking require a secure context. +Serve `dist/` through an HTTPS static host, or use `npm run preview` locally. Over HTTPS the app can be installed from Savegames (or Safari's Add to Home Screen) and opens offline; select the ROM at each launch. Updates wait until you choose Update. Subdirectory hosting is supported with `npm run build -- --base=/Crystal/`. Use HTTPS from another device; ROM verification and save locking require a secure context. ## Development diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 81fa9de..a508075 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,7 +30,7 @@ flowchart LR | `src/app` | Runtime-independent load/play/pause/backup lifecycle | | `src/storage` | Versioned envelope, IndexedDB transactions and ordered writes | | `src/ui`, `src/main.ts` | Accessible game tabs, UI composition and DOM dialogue, no ROM offsets | -| `src/platform` | Browser locks and future native platform adapters | +| `src/platform` | Browser locks, the installable app (service worker, install, storage) and future native adapters | | `src/reference`, `src/emulator` | Original-game reference, excluded from the native build | | `reference/public` | Licensed emulator files for the reference build only | diff --git a/docs/PLAN.md b/docs/PLAN.md index e44c4ce..6790879 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -235,6 +235,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli - "Open in this port" lists the landmarks with at least one map in `NATIVE_MAPS`, so the atlas never suggests travel through unported areas. - `tests/atlas.test.ts` compares every landmark position with `data/maps/landmarks.asm` and the flypoints with `data/maps/flypoints.asm`. +## Implemented: the installable web app + +- `public/manifest.webmanifest` makes Crystal installable (standalone, relative start URL and scope, so subdirectory hosting works). `scripts/make-icons.mjs` renders the PNG icons, including a maskable one and the Apple touch icon, from the project's own `icon.svg`. +- The native build emits `sw.js` (`scripts/service-worker.mjs`, a Vite plugin). It precaches exactly the built files under a cache named by their contents, serves them first and passes every other request to the network. ROMs are read from local files and never fetched, so they never reach the cache. The reference build has no worker, and development registers none. +- Updates wait. A new version installs in the background, and a banner offers Update; the page saves, then the worker takes over and the page reloads. Nothing reloads during play without that choice. Activation deletes older shells. +- Savegames gains App and Storage. Install uses the browser's prompt where it exists; iPhone and iPad get Safari's Share, Add to Home Screen steps. Storage shows whether saves are persistent and how much is used, and asks for persistence on request (and once at start when installed). A full disk gets its own save error, which points to backups. +- `check:release` accepts only the four known icons beside the text assets and checks that the worker's list matches the build. Tests run the generated worker against a fake cache, and a Chromium test opens the production build offline. +- The ROM is still selected at each launch; keeping it is a separate, explicit preference for later. + ## Implemented: interface settings - Settings, beside Help, sets the text size (Standard, Large, Largest: 100, 125 and 150 %) and the keyboard key of each button. Every font size in the stylesheet scales with `--text-scale`; the dialogue box scrolls instead of growing past the screen. @@ -478,7 +487,7 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba - [x] Scalable text and configurable keyboard controls - [ ] Fuller accessibility: announcements for every native screen and touch control remapping - [ ] Save slots with migration, atomic writes and import previews -- [ ] Installable web app with offline app shell, safe updates and quota handling +- [x] Installable web app with offline app shell, safe updates and quota handling - [ ] Acceptance on physical iPhone and Android devices The first presentation increment replaces the landing page and handheld frame with a restrained Crystal-style interface. Game, Savegames, Map, Pokédex, Link and Friends stay above a viewport-filling native canvas. The renderer draws more world tiles as space grows instead of stretching a 160 × 144 image. The game and Map tab share a live local minimap; the atlas and unsupported tabs remain honest planned states. The linked UI reference could not be inspected through automated access because the site returned a Cloudflare challenge, so exact screenshot matching is not claimed. diff --git a/index.html b/index.html index 34ead91..9f7f82f 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,11 @@ /> Crystal · Native web port + + + + +
diff --git a/playwright.config.ts b/playwright.config.ts index 4d2c23b..46b91db 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,9 +10,18 @@ export default defineConfig({ use: { ...devices["iPhone 13"], defaultBrowserType: "webkit" }, }, ], - webServer: { - command: "npm run dev -- --host 127.0.0.1", - url: "http://127.0.0.1:5173", - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: "npm run dev -- --host 127.0.0.1", + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + }, + // The production build, for the service worker and offline checks. + { + command: "npm run build && npx vite preview --host 127.0.0.1 --port 4173 --strictPort", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ], }); diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4d3bd7ad78d7a5f110d87592896adb6d4821107c GIT binary patch literal 3615 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 G3I79m;Jk1E literal 0 HcmV?d00001 diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..dec24035e73298989bda1b5ca0fc56454085ddcf GIT binary patch literal 4761 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 HToV5WwcMdA literal 0 HcmV?d00001 diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..dc7d4f8cee0e1adb6d1c8d05ce4247575b2a5e6b GIT binary patch literal 13804 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 32_000) + throw new Error(`App icon is unexpectedly large: ${path}`); + files++; + size += statSync(path).size; + continue; + } + if (!/\.(html|js|css|svg|webmanifest)$/.test(name)) throw new Error(`Unexpected production asset: ${path}`); if (/reference|vendor|binjgb/i.test(path)) throw new Error(`Reference tool in production: ${path}`); @@ -25,6 +40,22 @@ function inspect(directory) { } } inspect("dist"); + +// The service worker precaches exactly the shell: every built file, and +// nothing that could hold game data. +const worker = readFileSync("dist/sw.js", "utf8"); +const shell = JSON.parse(/const SHELL = (\[.*\]);/.exec(worker)?.[1] ?? "null"); +if (!Array.isArray(shell)) throw new Error("dist/sw.js has no app shell list."); +const expected = [ + "./", + ...listFiles("dist") + .filter((file) => file !== "sw.js" && file !== "index.html") + .map((file) => `./${file}`), +].sort(); +if (JSON.stringify([...shell].sort()) !== JSON.stringify(expected)) + throw new Error("The service worker's shell does not match the build."); +if (shell.some((file) => /\.(gbc?|rom|sav)$|crystal-save/i.test(file))) + throw new Error("The service worker would cache game data."); console.log( - `Native release verified: ${files} HTML/JS/CSS/SVG files, ${size} bytes; no emulator, ROM, extracted binary assets or reference checkout.`, + `Native release verified: ${files} files (HTML, JS, CSS, SVG, manifest, service worker and ${ICONS.size} icons), ${size} bytes; no emulator, ROM, extracted binary assets or reference checkout.`, ); diff --git a/scripts/make-icons.mjs b/scripts/make-icons.mjs new file mode 100644 index 0000000..f12b36a --- /dev/null +++ b/scripts/make-icons.mjs @@ -0,0 +1,36 @@ +// Renders public/icon.svg into the PNG icons the manifest and iOS need. +// Run after changing the SVG: node scripts/make-icons.mjs +import { readFileSync } from "node:fs"; +import { chromium } from "@playwright/test"; + +const svg = readFileSync("public/icon.svg", "utf8"); +// The maskable icon keeps the artwork inside the central safe zone (80 %). +const icons = [ + { file: "public/icon-192.png", size: 192, inset: 0 }, + { file: "public/icon-512.png", size: 512, inset: 0 }, + { + file: "public/icon-maskable-512.png", + size: 512, + inset: 0.1, + background: "#102b2a", + }, + { + file: "public/apple-touch-icon.png", + size: 180, + inset: 0.06, + background: "#102b2a", + }, +]; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +for (const { file, size, inset, background } of icons) { + await page.setViewportSize({ width: size, height: size }); + const pad = size * inset; + await page.setContent( + `
${svg.replace("`, + ); + await page.screenshot({ path: file, omitBackground: !background }); + console.log(`${file} ${size}×${size}`); +} +await browser.close(); diff --git a/scripts/service-worker.mjs b/scripts/service-worker.mjs new file mode 100644 index 0000000..3809a16 --- /dev/null +++ b/scripts/service-worker.mjs @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +/** Every file under `directory`, as paths relative to it with "/". */ +export function listFiles(directory) { + const files = []; + const walk = (dir) => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else files.push(relative(directory, path).split("\\").join("/")); + } + }; + walk(directory); + return files; +} + +/** The app shell's service worker. It precaches `files` (paths relative + * to the worker) under a cache named by `version`, serves them first and + * leaves every other request to the network. A new version waits until + * the page asks it to take over, so play is never reloaded by surprise. */ +export function serviceWorkerSource(files, version) { + const shell = [ + "./", + ...files.filter((file) => file !== "index.html").map((file) => `./${file}`), + ]; + return `// Generated at build time by scripts/service-worker.mjs. +const CACHE = ${JSON.stringify(`crystal-shell-${version}`)}; +const SHELL = ${JSON.stringify(shell)}; +const SCOPE = new URL("./", self.location).pathname; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + for (const key of await caches.keys()) + if (key.startsWith("crystal-shell-") && key !== CACHE) await caches.delete(key); + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("message", (event) => { + if (event.data === "skip-waiting") self.skipWaiting(); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== self.location.origin || !url.pathname.startsWith(SCOPE)) return; + event.respondWith( + (async () => { + const cache = await caches.open(CACHE); + // Pages load the cached shell; other files are cached by URL. Shell + // files never vary, but hosts may send Vary: Origin, which module + // script requests would otherwise fail to match. + const cached = + request.mode === "navigate" + ? await cache.match("./", { ignoreVary: true }) + : await cache.match(request, { ignoreSearch: true, ignoreVary: true }); + return cached ?? fetch(request); + })(), + ); +}); +`; +} + +/** Vite plugin: write sw.js next to the native build's files once they + * exist, versioned by their names and contents. */ +export function serviceWorkerPlugin() { + let outDir = "dist"; + return { + name: "crystal-service-worker", + apply: "build", + configResolved(config) { + outDir = resolve(config.root, config.build.outDir); + }, + writeBundle: { + sequential: true, + order: "post", + handler() { + const files = listFiles(outDir) + .filter((file) => file !== "sw.js") + .sort(); + const hash = createHash("sha256"); + for (const file of files) + hash.update(file).update(readFileSync(join(outDir, file))); + writeFileSync( + join(outDir, "sw.js"), + serviceWorkerSource(files, hash.digest("hex").slice(0, 16)), + ); + }, + }, + }; +} diff --git a/src/main.ts b/src/main.ts index 31fcd7e..11af7aa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -45,6 +45,12 @@ import { bindControls } from "./input/bindings"; import { validateRom } from "./rom/validate"; import { BrowserSaveRepository, encodeSave } from "./storage/saves"; import { acquireSessionLock } from "./platform/session-lock"; +import { + InstallableApp, + quotaExceeded, + requestPersistentStorage, + storageStatus, +} from "./platform/pwa"; const root = document.querySelector("#app")!; renderShell(root); @@ -1591,6 +1597,7 @@ const tabs = bindTabs( (id) => { if (id === "pokedex") showPokedexTab(); if (id === "map") showAtlas(); + if (id === "saves") showAppSettings(); }, ); @@ -1650,8 +1657,9 @@ async function save() { saveStatus.classList.remove("error"); }) .catch((error) => { - saveStatus.textContent = - "Session could not be saved. Export a backup to keep your progress."; + saveStatus.textContent = quotaExceeded(error) + ? "This device is out of space for saves. Export a backup, then free some storage." + : "Session could not be saved. Export a backup to keep your progress."; saveStatus.classList.add("error"); throw error; }) @@ -1785,6 +1793,57 @@ get("save-file").addEventListener("change", async (event) => { } }); +/** The installable app: updates wait for the player, and the Savegames + * tab offers installation and persistent storage. */ +const app = new InstallableApp({ + onUpdateReady: () => { + get("update-banner").hidden = false; + }, + onInstallChange: () => showAppSettings(), +}); +void app.register(); +// An installed app asks once at start; browsers usually grant it then. +if (app.installed) void requestPersistentStorage(); +get("update-app").addEventListener("click", async () => { + // Save first: the reload ends the session. + pause(); + await save()?.catch(() => {}); + if (!app.update()) message("The update is no longer waiting. Reload the page to get it."); +}); +function showAppSettings() { + const install = get("install-app"); + install.hidden = app.installed || !app.canPrompt; + get("install-status").textContent = app.installed + ? "Crystal is installed. The app opens offline; select your ROM each time you open it." + : app.canPrompt + ? "Install Crystal to open it from your home screen and play the app offline. Select your ROM each time you open it." + : app.appleDevice + ? "To install Crystal, open this page in Safari, choose Share, then Add to Home Screen." + : "This browser does not offer to install Crystal. It still saves here and works online."; + void storageStatus().then((status) => { + const button = get("persist-storage"); + button.hidden = !status || status.persisted; + const used = + status?.usage !== undefined && status.quota + ? ` Using ${(status.usage / 1_048_576).toFixed(1)} MB of ${(status.quota / 1_048_576).toFixed(0)} MB.` + : ""; + get("storage-status").textContent = !status + ? "This browser does not report its storage. Export backups to keep your progress safe." + : status.persisted + ? `Saves are kept on this device even when space runs low.${used}` + : `The browser may clear saves when space runs low. Keep them, or export backups.${used}`; + }); +} +get("install-app").addEventListener("click", async () => { + await app.install(); + showAppSettings(); +}); +get("persist-storage").addEventListener("click", async () => { + const granted = await requestPersistentStorage(); + if (!granted) message("The browser kept its storage rules. Export backups to keep your progress safe."); + showAppSettings(); +}); + get("help").addEventListener("click", () => { pause(); showHelpKeys(); diff --git a/src/platform/pwa.ts b/src/platform/pwa.ts new file mode 100644 index 0000000..f228cfa --- /dev/null +++ b/src/platform/pwa.ts @@ -0,0 +1,134 @@ +/** The installable app: the service worker that keeps the app shell + * offline, the update it may have waiting, the browser's install prompt + * and persistent storage for saves. ROMs never pass through any of it. */ + +interface InstallPrompt extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +export interface AppHost { + /** A new version is installed and waits for the player to update. */ + onUpdateReady(): void; + /** The browser can show its install prompt, or no longer can. */ + onInstallChange(): void; +} + +export class InstallableApp { + private registration?: ServiceWorkerRegistration; + private installPrompt?: InstallPrompt; + private updating = false; + + constructor(private host: AppHost) { + window.addEventListener("beforeinstallprompt", (event) => { + event.preventDefault(); + this.installPrompt = event as InstallPrompt; + host.onInstallChange(); + }); + window.addEventListener("appinstalled", () => { + this.installPrompt = undefined; + host.onInstallChange(); + }); + } + + /** Register the worker in production builds over a secure context. */ + async register() { + if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return; + if (!window.isSecureContext) return; + const base = import.meta.env.BASE_URL; + try { + this.registration = await navigator.serviceWorker.register( + `${base}sw.js`, + { + scope: base, + }, + ); + } catch { + // The game still runs online without the worker. + return; + } + const registration = this.registration; + // A worker waiting at load, or one that finishes installing while a + // page is already controlled, is an update. + if (registration.waiting && navigator.serviceWorker.controller) + this.host.onUpdateReady(); + registration.addEventListener("updatefound", () => { + const worker = registration.installing; + worker?.addEventListener("statechange", () => { + if (worker.state === "installed" && navigator.serviceWorker.controller) + this.host.onUpdateReady(); + }); + }); + navigator.serviceWorker.addEventListener("controllerchange", () => { + // Only an update the player asked for reloads the page. + if (this.updating) window.location.reload(); + }); + } + + /** Let the waiting version take over; the page reloads when it does. */ + update() { + const waiting = this.registration?.waiting; + if (!waiting) return false; + this.updating = true; + waiting.postMessage("skip-waiting"); + return true; + } + + /** Running from the home screen or as an installed window. */ + get installed() { + return ( + window.matchMedia("(display-mode: standalone)").matches || + (navigator as Navigator & { standalone?: boolean }).standalone === true + ); + } + /** The browser offers its own install prompt (Chrome, Edge, Android). */ + get canPrompt() { + return Boolean(this.installPrompt); + } + /** iPhone and iPad Safari install through Share, Add to Home Screen. */ + get appleDevice() { + return ( + /iPhone|iPad|iPod/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) + ); + } + async install() { + const prompt = this.installPrompt; + if (!prompt) return false; + this.installPrompt = undefined; + await prompt.prompt(); + const { outcome } = await prompt.userChoice; + this.host.onInstallChange(); + return outcome === "accepted"; + } +} + +/** Storage that the browser keeps under pressure, and how much is used. */ +export async function storageStatus(): Promise< + { persisted: boolean; usage?: number; quota?: number } | undefined +> { + const storage = navigator.storage; + if (!storage?.persisted) return undefined; + try { + const [persisted, estimate] = await Promise.all([ + storage.persisted(), + storage.estimate?.() ?? Promise.resolve(undefined), + ]); + return { persisted, usage: estimate?.usage, quota: estimate?.quota }; + } catch { + return undefined; + } +} +export async function requestPersistentStorage(): Promise { + try { + return (await navigator.storage?.persist?.()) ?? false; + } catch { + return false; + } +} + +/** A write refused because the device or the site is out of space. */ +export const quotaExceeded = (error: unknown) => + error instanceof DOMException && + (error.name === "QuotaExceededError" || + error.name === "NS_ERROR_DOM_QUOTA_REACHED"); diff --git a/src/ui/shell.ts b/src/ui/shell.ts index 9d1b068..8041b42 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -10,6 +10,7 @@ export function renderShell(root: HTMLElement) { CRYSTAL
STANDBY
+