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