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 <noreply@anthropic.com>
This commit is contained in:
1 parent
f289615bd2
commit
ce8129703f
20 files changed
+661
-15
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
+10
-1
@@ -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.
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
/>
|
||||
<title>Crystal · Native web port</title>
|
||||
<link rel="icon" href="%BASE_URL%icon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Crystal" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+14
-5
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.`,
|
||||
);
|
||||
@@ -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(
|
||||
`<body style="margin:0;background:${background ?? "transparent"}"><div style="padding:${pad}px;width:${size - 2 * pad}px;height:${size - 2 * pad}px">${svg.replace("<svg ", '<svg width="100%" height="100%" ')}</div></body>`,
|
||||
);
|
||||
await page.screenshot({ path: file, omitBackground: !background });
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -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)),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
+61
-2
@@ -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<HTMLElement>("#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<HTMLInputElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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();
|
||||
|
||||
@@ -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<void>;
|
||||
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<boolean> {
|
||||
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");
|
||||
+2
-1
@@ -10,6 +10,7 @@ export function renderShell(root: HTMLElement) {
|
||||
<a class="brand" href="./" aria-label="Crystal home">CRYSTAL</a>
|
||||
<div class="header-state"><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span><button id="settings" class="text-button">Settings</button><button id="help" class="text-button">Help</button></div>
|
||||
</header>
|
||||
<div id="update-banner" class="update-banner" role="status" hidden><span>A new version of Crystal is ready.</span><button id="update-app" class="text-button">Update</button></div>
|
||||
<main class="app-shell">
|
||||
<nav class="tabs" role="tablist" aria-label="Crystal sections">
|
||||
${tab("game", "Game", true)}${tab("saves", "Savegames")}${tab("map", "Map")}${tab("pokedex", "Pokédex")}${tab("link", "Link")}${tab("friends", "Friends")}
|
||||
@@ -62,7 +63,7 @@ export function renderShell(root: HTMLElement) {
|
||||
<header><p class="menu-title">SAVEGAMES</p><h2>Your local session</h2></header>
|
||||
<div class="save-actions"><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button><button id="import-save" disabled>Import native backup</button><input id="save-file" class="file-input" type="file" accept=".json"></div>
|
||||
<div class="save-copy"><p>Native backups include your character, position and completed interactions. They are separate from cartridge .sav files.</p><p>Export before clearing browser data.</p></div>
|
||||
<div class="replace-rom"><h3>Cartridge</h3><p>Select a ROM again after reloading, or choose another compatible copy.</p><label class="menu-button file-button" for="rom-file"><span aria-hidden="true">▶</span> Choose or replace ROM</label></div>
|
||||
<div class="replace-rom"><h3>Cartridge</h3><p>Select a ROM again after reloading, or choose another compatible copy.</p><label class="menu-button file-button" for="rom-file"><span aria-hidden="true">▶</span> Choose or replace ROM</label></div><div class="app-settings"><h3>App</h3><p id="install-status">Install Crystal to open it from your home screen and play the app offline. Select your ROM again each time you open it.</p><button id="install-app" class="menu-button" hidden>Install Crystal</button><h3>Storage</h3><p id="storage-status">Checking this browser's storage…</p><button id="persist-storage" class="menu-button" hidden>Keep saves on this device</button></div>
|
||||
</section>
|
||||
<section id="panel-map" class="tab-panel menu-panel map-panel" role="tabpanel" aria-labelledby="tab-map" data-panel="map" hidden>
|
||||
<header><p class="menu-title">MAP</p><h2 id="map-location">No area loaded</h2><p id="map-position">Choose a ROM to see your position.</p></header>
|
||||
|
||||
+42
-2
@@ -69,7 +69,22 @@ label:focus-within {
|
||||
#app {
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
/* Named rows keep the main area flexible whether or not an update
|
||||
banner shows. */
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
grid-template-areas: "header" "banner" "main" "bar";
|
||||
}
|
||||
#app > .site-header {
|
||||
grid-area: header;
|
||||
}
|
||||
#app > .update-banner {
|
||||
grid-area: banner;
|
||||
}
|
||||
#app > .app-shell {
|
||||
grid-area: main;
|
||||
}
|
||||
#app > .global-bar {
|
||||
grid-area: bar;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
@@ -915,9 +930,34 @@ dialog h2 {
|
||||
padding-top: 20px;
|
||||
border-top: 3px double #747269;
|
||||
}
|
||||
.replace-rom h3 {
|
||||
.replace-rom h3,
|
||||
.app-settings h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.app-settings {
|
||||
max-width: 720px;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 3px double #747269;
|
||||
}
|
||||
.app-settings p {
|
||||
color: #4e5157;
|
||||
}
|
||||
.app-settings .menu-button {
|
||||
width: fit-content;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
/* A waiting update: shown until the player chooses to reload. */
|
||||
.update-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px 14px;
|
||||
padding: 6px 12px;
|
||||
color: var(--cream);
|
||||
background: var(--blue);
|
||||
}
|
||||
.replace-rom .menu-button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const PREVIEW = "http://127.0.0.1:4173/";
|
||||
|
||||
// Playwright's WebKit does not run service workers; Chromium covers them.
|
||||
test.beforeEach(({ browserName }) =>
|
||||
test.skip(
|
||||
browserName !== "chromium",
|
||||
"Service workers run in Chromium here.",
|
||||
),
|
||||
);
|
||||
|
||||
test("the production build installs its shell and opens offline", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(PREVIEW);
|
||||
const manifest = await page
|
||||
.locator('link[rel="manifest"]')
|
||||
.getAttribute("href");
|
||||
const response = await page.request.get(new URL(manifest!, PREVIEW).href);
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.json()).toMatchObject({
|
||||
display: "standalone",
|
||||
start_url: "./",
|
||||
});
|
||||
// The worker installs, then controls the page after a reload.
|
||||
await page.evaluate(() => navigator.serviceWorker.ready);
|
||||
await page.reload();
|
||||
expect(
|
||||
await page.evaluate(() => Boolean(navigator.serviceWorker.controller)),
|
||||
).toBe(true);
|
||||
|
||||
await context.setOffline(true);
|
||||
await page.reload();
|
||||
await expect(page.locator("#status")).toContainText("Choose your ROM");
|
||||
await expect(page.locator("#rom-file")).toBeAttached();
|
||||
// The Savegames tab describes installation and storage.
|
||||
await page.locator("#tab-saves").click();
|
||||
await expect(page.locator("#install-status")).not.toBeEmpty();
|
||||
await expect(page.locator("#storage-status")).not.toHaveText(/Checking/);
|
||||
await context.setOffline(false);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("the development server registers no service worker", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(500);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
async () => (await navigator.serviceWorker.getRegistrations()).length,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { serviceWorkerSource } from "../scripts/service-worker.mjs";
|
||||
|
||||
/** Run the generated worker against a fake CacheStorage and network. */
|
||||
function worker(files: string[], version: string, existing: string[] = []) {
|
||||
const stores = new Map<string, Map<string, Response>>();
|
||||
for (const name of existing) stores.set(name, new Map());
|
||||
const caches = {
|
||||
open: async (name: string) => {
|
||||
if (!stores.has(name)) stores.set(name, new Map());
|
||||
const store = stores.get(name)!;
|
||||
const key = (request: Request | string) =>
|
||||
new URL(
|
||||
typeof request === "string" ? request : request.url,
|
||||
"https://crystal.test/game/sw.js",
|
||||
).href;
|
||||
return {
|
||||
addAll: async (urls: string[]) => {
|
||||
for (const url of urls)
|
||||
store.set(key(url), new Response(`cached ${url}`));
|
||||
},
|
||||
match: async (
|
||||
request: Request | string,
|
||||
options?: { ignoreSearch?: boolean },
|
||||
) => {
|
||||
const url = new URL(key(request));
|
||||
if (options?.ignoreSearch) url.search = "";
|
||||
return store.get(url.href)?.clone();
|
||||
},
|
||||
};
|
||||
},
|
||||
keys: async () => [...stores.keys()],
|
||||
delete: async (name: string) => stores.delete(name),
|
||||
};
|
||||
const fetch = vi.fn(
|
||||
async (request: Request) => new Response(`network ${request.url}`),
|
||||
);
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const self = {
|
||||
location: new URL("https://crystal.test/game/sw.js"),
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) =>
|
||||
listeners.set(type, listener),
|
||||
clients: { claim: vi.fn(async () => {}) },
|
||||
skipWaiting: vi.fn(),
|
||||
};
|
||||
new Function("self", "caches", "fetch", serviceWorkerSource(files, version))(
|
||||
self,
|
||||
caches,
|
||||
fetch,
|
||||
);
|
||||
const run = async (type: string, event: Record<string, unknown> = {}) => {
|
||||
let pending: Promise<unknown> = Promise.resolve();
|
||||
let response: Promise<Response> | undefined;
|
||||
listeners.get(type)!({
|
||||
...event,
|
||||
waitUntil: (promise: Promise<unknown>) => (pending = promise),
|
||||
respondWith: (promise: Promise<Response>) => (response = promise),
|
||||
});
|
||||
await pending;
|
||||
return response && (await response);
|
||||
};
|
||||
const request = (
|
||||
url: string,
|
||||
init: { mode?: string; method?: string } = {},
|
||||
) =>
|
||||
({
|
||||
url,
|
||||
method: init.method ?? "GET",
|
||||
mode: init.mode ?? "no-cors",
|
||||
}) as unknown as Request;
|
||||
return { stores, fetch, self, run, request };
|
||||
}
|
||||
|
||||
describe("the app shell service worker", () => {
|
||||
const files = [
|
||||
"assets/index-abc.js",
|
||||
"assets/index-abc.css",
|
||||
"icon.svg",
|
||||
"index.html",
|
||||
"manifest.webmanifest",
|
||||
];
|
||||
|
||||
it("precaches the shell, with the page as ./", async () => {
|
||||
const sw = worker(files, "v1");
|
||||
await sw.run("install");
|
||||
expect([...sw.stores.get("crystal-shell-v1")!.keys()].sort()).toEqual(
|
||||
[
|
||||
"https://crystal.test/game/",
|
||||
"https://crystal.test/game/assets/index-abc.css",
|
||||
"https://crystal.test/game/assets/index-abc.js",
|
||||
"https://crystal.test/game/icon.svg",
|
||||
"https://crystal.test/game/manifest.webmanifest",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("serves the shell offline and leaves other requests to the network", async () => {
|
||||
const sw = worker(files, "v1");
|
||||
await sw.run("install");
|
||||
const page = await sw.run("fetch", {
|
||||
request: sw.request("https://crystal.test/game/?resume", {
|
||||
mode: "navigate",
|
||||
}),
|
||||
});
|
||||
expect(await page!.text()).toBe("cached ./");
|
||||
const script = await sw.run("fetch", {
|
||||
request: sw.request("https://crystal.test/game/assets/index-abc.js?v=2"),
|
||||
});
|
||||
expect(await script!.text()).toBe("cached ./assets/index-abc.js");
|
||||
const unknown = await sw.run("fetch", {
|
||||
request: sw.request("https://crystal.test/game/missing.png"),
|
||||
});
|
||||
expect(await unknown!.text()).toBe(
|
||||
"network https://crystal.test/game/missing.png",
|
||||
);
|
||||
// Other origins, other paths and writes are not intercepted at all.
|
||||
for (const request of [
|
||||
sw.request("https://elsewhere.test/game/icon.svg"),
|
||||
sw.request("https://crystal.test/other/icon.svg"),
|
||||
sw.request("https://crystal.test/game/icon.svg", { method: "POST" }),
|
||||
])
|
||||
expect(await sw.run("fetch", { request })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes older shells when it activates and waits to take over", async () => {
|
||||
const sw = worker(files, "v2", ["crystal-shell-v1", "unrelated"]);
|
||||
await sw.run("install");
|
||||
await sw.run("activate");
|
||||
expect([...sw.stores.keys()].sort()).toEqual([
|
||||
"crystal-shell-v2",
|
||||
"unrelated",
|
||||
]);
|
||||
expect(sw.self.clients.claim).toHaveBeenCalled();
|
||||
expect(sw.self.skipWaiting).not.toHaveBeenCalled();
|
||||
await sw.run("message", { data: "something else" });
|
||||
expect(sw.self.skipWaiting).not.toHaveBeenCalled();
|
||||
await sw.run("message", { data: "skip-waiting" });
|
||||
expect(sw.self.skipWaiting).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { serviceWorkerPlugin } from "./scripts/service-worker.mjs";
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
publicDir: mode === "reference" ? "reference/public" : "public",
|
||||
// Only the native player is installable; the reference stays a dev tool.
|
||||
plugins: mode === "reference" ? [] : [serviceWorkerPlugin()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
// A user ROM may sit at the repository root. Never serve it or extracted
|
||||
|
||||
Reference in new issue
Block a user