import { BUILD } from './build-info.ts' import { COLORS, type PageCtx } from './ui.ts' export function manifest(ctx: PageCtx) { const a = ctx.assetBase return { id: `${ctx.appBase}/`, name: 'Asteroids', short_name: 'Asteroids', description: 'Classic vector Asteroids with synthesized chiptune sound. Keyboard or touch, works offline.', lang: 'en', start_url: `${ctx.appBase}/`, scope: `${ctx.appBase}/`, display: 'fullscreen', display_override: ['fullscreen', 'standalone', 'minimal-ui'], orientation: 'any', background_color: COLORS.bg, theme_color: COLORS.bg, categories: ['games', 'entertainment'], icons: [ { src: `${a}/icons/icon-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: `${a}/icons/icon-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any' }, { src: `${a}/icons/icon-maskable-512.png`, sizes: '512x512', type: 'image/png', purpose: 'maskable' }, ], } } /** * Service worker, versioned by BUILD.buildId (a hash of the app + assets). * Pattern from @kentcdodds/package-app-kit/sw: versioned cache, old caches deleted on * activate, manifest network-first, never cache errors/opaque responses. Differences: * pages are served cache-first from the build's own cache so a running version stays * consistent, and a new worker waits until the player taps UPDATE (SKIP_WAITING). */ export function serviceWorker(ctx: PageCtx) { const precache = [ `${ctx.appBase}/`, `${ctx.appBase}/about`, `${ctx.appBase}/manifest.webmanifest`, ...['og.jpg', 'icons/favicon.svg', 'icons/favicon-32.png', 'icons/apple-touch-icon.png', 'icons/icon-192.png', 'icons/icon-512.png', 'icons/icon-maskable-512.png'].map((p) => `${ctx.assetBase}/${p}`), ] return `"use strict"; var VERSION = ${JSON.stringify(BUILD.buildId)}; var CACHE = "asteroids-" + VERSION; var BASE = ${JSON.stringify(ctx.appBase)}; var PRECACHE = ${JSON.stringify(precache)}; function ok(r) { return Boolean(r && r.ok && r.type !== "opaque" && r.type !== "error"); } function rel(url) { var p = url.pathname; if (BASE && p.indexOf(BASE) === 0) p = p.slice(BASE.length); return p.replace(/\\/+$/, "") || "/"; } self.addEventListener("install", function (event) { event.waitUntil(caches.open(CACHE).then(function (cache) { return Promise.all(PRECACHE.map(function (u) { return fetch(new Request(u, { cache: "reload", credentials: "same-origin" })).then(function (r) { if (ok(r)) return cache.put(u, r); }); })); })); // First install has nothing to replace, so take over right away. Later versions wait for UPDATE. if (!self.registration.active) self.skipWaiting(); }); self.addEventListener("message", function (event) { if (event.data === "SKIP_WAITING") self.skipWaiting(); if (event.data === "VERSION" && event.ports[0]) event.ports[0].postMessage(VERSION); }); self.addEventListener("activate", function (event) { event.waitUntil(caches.keys().then(function (keys) { return Promise.all(keys.filter(function (k) { return k.indexOf("asteroids-") === 0 && k !== CACHE; }).map(function (k) { return caches.delete(k); })); }).then(function () { return self.clients.claim(); })); }); self.addEventListener("fetch", function (event) { var req = event.request; if (req.method !== "GET") return; var url = new URL(req.url); if (url.origin !== self.location.origin) return; var path = rel(url); if (path === "/version.json" || path === "/health" || path === "/sw.js") return; if (path === "/manifest.webmanifest") { event.respondWith(fetch(req, { credentials: "same-origin", cache: "no-store" }).then(function (r) { return ok(r) ? r : caches.match(req).then(function (c) { return c || r; }); }).catch(function () { return caches.match(req).then(function (c) { return c || Response.error(); }); })); return; } if (req.mode === "navigate" || path === "/" || path === "/about") { var key = BASE + (path === "/about" ? "/about" : "/"); event.respondWith(caches.open(CACHE).then(function (cache) { return cache.match(key).then(function (cached) { if (cached) return cached; return fetch(req).then(function (r) { if (ok(r)) cache.put(key, r.clone()); return r; }); }); })); return; } event.respondWith(caches.open(CACHE).then(function (cache) { return cache.match(req).then(function (cached) { return cached || fetch(req).then(function (r) { if (ok(r)) cache.put(req, r.clone()); return r; }); }); })); }); ` } /** * Browser-side PWA glue shared by the game and About pages: registers the worker, * shows the arcade UPDATE AVAILABLE toast when a new version is waiting, wires the * INSTALL button to the native prompt, and exposes window.__pwa for the About page/tests. */ export const pwaScript = String.raw` (() => { const root = document.documentElement, base = root.dataset.appBase || '', build = root.dataset.build || ''; const toast = document.getElementById('toast'), installBtn = document.getElementById('installBtn'); const state = { build, registered: false, waiting: false, installable: false, lastCheck: 0 }; let reg = null, deferred = null, reloading = false; const listeners = new Set(); const emit = () => listeners.forEach((f) => { try { f(state) } catch {} }); function showUpdate(worker) { state.waiting = true; emit(); if (!toast) return; toast.hidden = false; const btn = toast.querySelector('[data-update]'); btn.onclick = () => { btn.textContent = 'UPDATING…'; worker.postMessage('SKIP_WAITING'); }; const later = toast.querySelector('[data-later]'); if (later) later.onclick = () => { toast.hidden = true; }; } function track(r) { if (r.waiting && navigator.serviceWorker.controller) showUpdate(r.waiting); r.addEventListener('updatefound', () => { const w = r.installing; if (!w) return; w.addEventListener('statechange', () => { if (w.state === 'installed' && navigator.serviceWorker.controller) showUpdate(w); }); }); } async function check() { state.lastCheck = Date.now(); if (!reg) return { ok: false, reason: 'unsupported' }; try { await reg.update(); } catch (e) { return { ok: false, reason: 'offline' }; } // Give a found update a moment to install so callers can report it. for (let i = 0; i < 40 && reg.installing; i++) await new Promise((r) => setTimeout(r, 100)); return { ok: true, waiting: Boolean(reg.waiting) }; } if ('serviceWorker' in navigator && location.protocol !== 'file:') { navigator.serviceWorker.addEventListener('controllerchange', () => { if (state.waiting && !reloading) { reloading = true; location.reload(); } }); navigator.serviceWorker.register(base + '/sw.js', { scope: base + '/' }).then((r) => { reg = r; state.registered = true; emit(); track(r); setInterval(() => { if (document.visibilityState === 'visible') check(); }, 30 * 60 * 1000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Date.now() - state.lastCheck > 60000) check(); }); }).catch(() => {}); } const standalone = matchMedia('(display-mode: standalone)').matches || matchMedia('(display-mode: fullscreen)').matches || navigator.standalone === true; addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); if (standalone) return; deferred = e; state.installable = true; emit(); if (installBtn) installBtn.hidden = false; }); addEventListener('appinstalled', () => { deferred = null; state.installable = false; emit(); if (installBtn) installBtn.hidden = true; }); if (installBtn) installBtn.addEventListener('click', async (e) => { e.preventDefault(); installBtn.blur(); if (!deferred) return; deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; state.installable = false; installBtn.hidden = true; emit(); }); window.__pwa = { state, check, onChange: (f) => { listeners.add(f); f(state); } }; })(); `