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