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
@@ -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)),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user