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 0000000..4d3bd7a Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..dec2403 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..dc7d4f8 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png new file mode 100644 index 0000000..c31dc1d Binary files /dev/null and b/public/icon-maskable-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..0779aef --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Crystal · Native web port", + "short_name": "Crystal", + "description": "A native web port of Pokémon Crystal that reads its graphics and dialogue from your own ROM.", + "id": "./", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "any", + "background_color": "#102b2a", + "theme_color": "#164875", + "icons": [ + { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml" }, + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }, + { + "src": "icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 8d50a5d..85e0912 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -1,6 +1,13 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; +import { listFiles } from "./service-worker.mjs"; +const ICONS = new Set([ + "icon-192.png", + "icon-512.png", + "icon-maskable-512.png", + "apple-touch-icon.png", +]); let files = 0, size = 0; function inspect(directory) { @@ -10,7 +17,15 @@ function inspect(directory) { inspect(path); continue; } - if (!/\.(html|js|css|svg)$/.test(name)) + // The app icons are the only images: rendered from icon.svg, small. + if (ICONS.has(name)) { + if (statSync(path).size > 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
+