diff --git a/src/lib/stores/offlineSync.ts b/src/lib/stores/offlineSync.ts index bfacbfa..a6fa049 100644 --- a/src/lib/stores/offlineSync.ts +++ b/src/lib/stores/offlineSync.ts @@ -1,24 +1,46 @@ import { writable } from 'svelte/store'; import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public'; import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot'; -import { resolveSpriteUrl } from '$lib/utils/spriteUrl'; +import { resolveSpriteUrl, spriteRoot } from '$lib/utils/spriteUrl'; export type OfflineSyncStatus = { - state: 'idle' | 'syncing' | 'ready' | 'partial' | 'error'; + state: 'idle' | 'syncing' | 'ready' | 'error'; generatedAt: string | null; message: string | null; }; +export type ArtworkDownloadStatus = { + // unknown: not checked yet; missing: some sprites aren't saved; done: every sprite is saved. + state: 'unknown' | 'missing' | 'downloading' | 'done' | 'error'; + missingBytes: number | null; + message: string | null; +}; + export const offlineSyncStatus = writable({ state: 'idle', generatedAt: null, message: null }); +export const artworkDownloadStatus = writable({ + state: 'unknown', + missingBytes: null, + message: null +}); + const SYNC_EVENT = 'livingdex:offline-sync'; const OFFLINE_CACHE_PREFIX = 'livingdex-offline-'; const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`; const OFFLINE_META_URL = '/__offline/current'; +// Must match OFFLINE_META_FORMAT in static/offline-worker.js. Older copies are always re-synced so +// the worker can migrate them (e.g. drop the full-size artwork cache). +const OFFLINE_META_FORMAT = 2; +// A page load reuses an offline copy this recent instead of downloading the whole collection again. +// Changes made in the app request a sync explicitly, so this only delays picking up edits made on +// another device. +const SNAPSHOT_FRESH_MS = 15 * 60 * 1000; + +type OfflineMeta = { userId: string; generatedAt?: string; format?: number }; async function workerMessage( message: unknown, @@ -50,6 +72,14 @@ export function requestOfflineSync(): void { if (typeof window !== 'undefined') window.dispatchEvent(new Event(SYNC_EVENT)); } +async function readOfflineMeta(): Promise { + if (!('caches' in window)) return null; + if (!(await caches.keys()).includes(OFFLINE_META_CACHE)) return null; + const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL); + const meta = await response?.json().catch(() => null); + return typeof meta?.userId === 'string' ? meta : null; +} + async function deleteOfflineCaches(): Promise { if (typeof window === 'undefined' || !('caches' in window)) return; const names = await caches.keys(); @@ -60,13 +90,9 @@ async function deleteOfflineCaches(): Promise { export async function claimOfflineData(userId: string): Promise { if (typeof window === 'undefined') return; - if ('caches' in window) { - const names = await caches.keys(); - if (names.includes(OFFLINE_META_CACHE)) { - const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL); - const meta = await response?.json().catch(() => null); - if (typeof meta?.userId !== 'string' || meta.userId !== userId) await deleteOfflineCaches(); - } + if ('caches' in window && (await caches.keys()).includes(OFFLINE_META_CACHE)) { + const meta = await readOfflineMeta(); + if (meta?.userId !== userId) await deleteOfflineCaches(); } if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) { await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false); @@ -79,9 +105,69 @@ export async function clearOfflineData(): Promise { if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) { await workerMessage({ type: 'CLEAR_OFFLINE_DATA' }, 10_000, false); } + // Sprites are kept across sign-out (they aren't account data), so the artwork status stays valid. offlineSyncStatus.set({ state: 'idle', generatedAt: null, message: null }); } +function currentSpriteRoot(): string { + return spriteRoot(PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'); +} + +function applyArtworkResult(result: Record): void { + const missing = Number(result.missing ?? 0); + const failed = Number(result.failedArtwork ?? 0); + if (failed > 0) { + artworkDownloadStatus.set({ + state: 'error', + missingBytes: Number(result.missingBytes ?? 0), + message: `${failed} artwork files could not be saved` + }); + } else { + artworkDownloadStatus.set({ + state: missing > 0 ? 'missing' : 'done', + missingBytes: Number(result.missingBytes ?? 0), + message: null + }); + } +} + +/** Checks whether every sprite (all forms, shiny and female) is already saved on this device. */ +export async function checkArtworkStatus(): Promise { + if (typeof window === 'undefined') return; + try { + applyArtworkResult( + await workerMessage({ type: 'ARTWORK_STATUS', spriteRoot: currentSpriteRoot() }, 30_000) + ); + } catch (error) { + // Leave the link hidden rather than offering a download that can't be checked. + console.error('Unable to check saved artwork', error); + } +} + +/** + * Artwork is normally cached as it is viewed. This saves every sprite that exists - all forms, + * shiny and female variants, not just the saved dexes - skipping any that are already cached. + */ +export async function downloadAllArtwork(): Promise { + if (typeof window === 'undefined' || !navigator.onLine) return; + artworkDownloadStatus.update((status) => ({ ...status, state: 'downloading', message: null })); + try { + // Generous timeout: the worker fetches each missing sprite with its own 15s limit. + applyArtworkResult( + await workerMessage( + { type: 'CACHE_ALL_ARTWORK', spriteRoot: currentSpriteRoot() }, + 30 * 60 * 1000 + ) + ); + } catch (error) { + artworkDownloadStatus.update((status) => ({ + ...status, + state: 'error', + message: error instanceof Error ? error.message : String(error) + })); + } +} + export function startOfflineSync(getUserId: () => string | null): () => void { let timer: number | null = null; let stopped = false; @@ -89,7 +175,7 @@ export function startOfflineSync(getUserId: () => string | null): () => void { let rerun = false; let lastGeneratedAt: string | null = null; - const synchronize = async () => { + const synchronize = async (reuseFreshCopy: boolean) => { if (stopped || !navigator.onLine) return; if (running) { rerun = true; @@ -98,9 +184,24 @@ export function startOfflineSync(getUserId: () => string | null): () => void { const userId = getUserId(); if (!userId || !('serviceWorker' in navigator)) return; running = true; - offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null }); try { await claimOfflineData(userId); + if (reuseFreshCopy) { + const meta = await readOfflineMeta(); + const age = meta?.generatedAt ? Date.now() - Date.parse(meta.generatedAt) : Infinity; + if ( + meta?.userId === userId && + meta.format === OFFLINE_META_FORMAT && + age >= 0 && + age < SNAPSHOT_FRESH_MS + ) { + lastGeneratedAt = meta.generatedAt ?? null; + offlineSyncStatus.set({ state: 'ready', generatedAt: lastGeneratedAt, message: null }); + void checkArtworkStatus(); + return; + } + } + offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null }); const response = await fetch('/api/offline-snapshot', { credentials: 'include', headers: { Accept: 'application/json' } @@ -109,32 +210,19 @@ export function startOfflineSync(getUserId: () => string | null): () => void { const snapshot = (await response.json()) as OfflineSnapshot; if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session'); const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'; - const artworkUrls = Array.from( - new Set( - snapshot.pokedexes.flatMap(({ pokedex, entries }) => - entries.map(({ pokedexEntry }) => { - const url = resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal); - ( - pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string } - ).offlineSpriteUrl = url; - return url; - }) - ) - ) - ); + // The worker derives which artwork belongs to the collection from these URLs, and the offline + // viewer renders them. + for (const { pokedex, entries } of snapshot.pokedexes) { + for (const { pokedexEntry } of entries) { + (pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }).offlineSpriteUrl = + resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal); + } + } if (getUserId() !== userId) throw new Error('Session changed during offline synchronization'); - const result = await workerMessage({ - type: 'SYNC_OFFLINE_SNAPSHOT', - snapshot, - artworkUrls - }); - const failed = Number(result.failedArtwork ?? 0); - offlineSyncStatus.set({ - state: failed > 0 ? 'partial' : 'ready', - generatedAt: snapshot.generatedAt, - message: failed > 0 ? `${failed} artwork files could not be cached` : null - }); + await workerMessage({ type: 'SYNC_OFFLINE_SNAPSHOT', snapshot }); + offlineSyncStatus.set({ state: 'ready', generatedAt: snapshot.generatedAt, message: null }); lastGeneratedAt = snapshot.generatedAt; + void checkArtworkStatus(); } catch (error) { offlineSyncStatus.set({ state: 'error', @@ -150,19 +238,21 @@ export function startOfflineSync(getUserId: () => string | null): () => void { } }; - const schedule = () => { + const scheduleSync = (reuseFreshCopy: boolean) => { if (timer !== null) window.clearTimeout(timer); timer = window.setTimeout(() => { timer = null; - void synchronize(); + void synchronize(reuseFreshCopy); }, 1_000); }; + // Explicit requests (edits, Retry, a new sign-in) and reconnecting always fetch a new copy. + const schedule = () => scheduleSync(false); // Best effort: ask the browser not to evict the offline artwork cache under storage pressure. void navigator.storage?.persist?.().catch(() => undefined); window.addEventListener(SYNC_EVENT, schedule); window.addEventListener('online', schedule); - schedule(); + scheduleSync(true); return () => { stopped = true; if (timer !== null) window.clearTimeout(timer); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0296e2b..fa7668d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,8 +7,9 @@ import SignOut from '$lib/components/SignOut.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import { + artworkDownloadStatus, claimOfflineData, - clearOfflineData, + downloadAllArtwork, offlineSyncStatus, requestOfflineSync, startOfflineSync @@ -65,14 +66,21 @@ const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { + const previousUserId = localUser?.id ?? null; if (session) { localUser = session.user; - void claimOfflineData(session.user.id) - .then(requestOfflineSync) - .catch((error) => console.error('Unable to claim offline data', error)); + // SIGNED_IN also fires when a tab regains focus, so only a change of account fetches a new + // offline copy. Page loads and token refreshes reuse the saved one while it is fresh. + if (event === 'SIGNED_IN' && session.user.id !== previousUserId) { + void claimOfflineData(session.user.id) + .then(requestOfflineSync) + .catch((error) => console.error('Unable to claim offline data', error)); + } } else { + // Offline data is only cleared by the Sign Out button (or another account claiming it). + // An expired or rejected session must not throw away artwork that would then have to be + // downloaded again after signing back in. localUser = null; - if (event === 'SIGNED_OUT') void clearOfflineData(); } user.set(localUser); }); @@ -90,6 +98,11 @@ }; }); + function formatMegabytes(bytes: number) { + const megabytes = bytes / 1048576; + return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`; + } + async function getUser() { const { data: { session } @@ -214,10 +227,12 @@ Offline copy could not be refreshed: {$offlineSyncStatus.message} - {:else if localUser && $offlineSyncStatus.state === 'partial'} + {:else if localUser && $artworkDownloadStatus.state === 'error'}
- Offline data is saved, but {$offlineSyncStatus.message}. - + Offline data is saved, but artwork could not be saved: {$artworkDownloadStatus.message}. +
{/if} {#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'} @@ -225,6 +240,15 @@ {:else if isOnline && localUser && $offlineSyncStatus.generatedAt}

Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}. + {#if $artworkDownloadStatus.state === 'downloading'} + Saving all artwork for offline… + {:else if $artworkDownloadStatus.state === 'missing'} + + {/if}

{/if} diff --git a/static/offline-worker.js b/static/offline-worker.js index 6619a3c..1582cab 100644 --- a/static/offline-worker.js +++ b/static/offline-worker.js @@ -1,6 +1,16 @@ const OFFLINE_CACHE_PREFIX = 'livingdex-offline-'; const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`; const OFFLINE_META_URL = '/__offline/current'; +// Must match OFFLINE_META_FORMAT in src/lib/stores/offlineSync.ts. Bumped when artwork moved to the +// shared sprite cache, so pages re-sync older copies instead of reusing them. +const OFFLINE_META_FORMAT = 2; +// Sprites never change at a given URL and aren't user data, so one cache serves every account and is +// kept forever: it deliberately sits outside OFFLINE_CACHE_PREFIX, which sign-out, account changes +// and sync cleanup all delete. Only bump the version if the files at existing URLs are replaced. +const SPRITE_CACHE = 'livingdex-sprites-v1'; +// Every sprite file (all forms, shiny and female) with its size; generated by +// scripts/sprite-manifest.mjs. Kept in SPRITE_CACHE, so it changes exactly when the sprites do. +const SPRITE_MANIFEST_URL = '/sprites-small/manifest.json'; const ARTWORK_FETCH_TIMEOUT_MS = 15_000; let offlineEpoch = 0; let offlineOperation = Promise.resolve(); @@ -16,10 +26,50 @@ function dataCacheName(userId, generation) { return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`; } -// Sprites never change at a given URL, so each user keeps one artwork cache that is topped up -// incrementally instead of being rebuilt on every sync. -function artworkCacheName(userId) { - return `${OFFLINE_CACHE_PREFIX}art-v2-${userId}`; +// The old per-user artwork caches hold full-size (or opaque, quota-padded) sprites. +function isObsoleteArtworkCache(name) { + return name.startsWith(`${OFFLINE_CACHE_PREFIX}art-`); +} + +async function deleteObsoleteArtworkCaches() { + await Promise.all( + (await caches.keys()).filter(isObsoleteArtworkCache).map((name) => caches.delete(name)) + ); +} + +async function loadSpriteManifest(cache) { + let response = await cache.match(SPRITE_MANIFEST_URL); + if (!response) { + response = await fetch(SPRITE_MANIFEST_URL); + if (!response.ok) throw new Error(`Sprite list unavailable (HTTP ${response.status})`); + await cache.put(SPRITE_MANIFEST_URL, response.clone()); + } + const manifest = await response.json(); + if (manifest?.version !== 1 || !Array.isArray(manifest.files)) + throw new Error('Unsupported sprite list'); + return manifest.files; +} + +// Maps every sprite in the manifest to its absolute URL under `root` (the page's sprite folder). +async function allSpriteSizes(cache, root) { + const sizes = new Map(); + for (const [file, size] of await loadSpriteManifest(cache)) { + const url = new URL(`${root}/${file}`, self.location.origin); + if (!isSpriteUrl(url)) throw new Error('Invalid sprite location'); + sizes.set(url.href, size); + } + return sizes; +} + +async function missingSprites(cache, root) { + const sizes = await allSpriteSizes(cache, root); + const cached = new Set((await cache.keys()).map((request) => request.url)); + const missing = [...sizes.keys()].filter((url) => !cached.has(url)); + return { + total: sizes.size, + missing, + missingBytes: missing.reduce((bytes, url) => bytes + sizes.get(url), 0) + }; } // Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it. @@ -46,23 +96,13 @@ async function notifyOfflineDataCleared() { for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' }); } -async function cacheArtwork(cache, urls, isCurrent) { - const wanted = new Set(urls.map((url) => new URL(url, self.location.origin).href)); - const cachedRequests = await cache.keys(); - const cached = new Set(cachedRequests.map((request) => request.url)); - await Promise.all( - cachedRequests - .filter((request) => !wanted.has(request.url)) - .map((request) => cache.delete(request)) - ); - const missing = [...wanted].filter((url) => !cached.has(url)); - +async function fetchSprites(cache, missing) { let next = 0; let failed = 0; const workers = Array.from({ length: Math.min(6, missing.length) }, async () => { for (;;) { const index = next++; - if (index >= missing.length || !isCurrent()) return; + if (index >= missing.length) return; const url = missing[index]; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS); @@ -120,6 +160,32 @@ self.addEventListener('message', (event) => { ); return; } + // Sprites aren't account data, so these work for whoever is signed in and survive sign-out. + if (event.data?.type === 'ARTWORK_STATUS' || event.data?.type === 'CACHE_ALL_ARTWORK') { + const download = event.data.type === 'CACHE_ALL_ARTWORK'; + const root = String(event.data.spriteRoot ?? ''); + event.waitUntil( + (async () => { + const cache = await caches.open(SPRITE_CACHE); + let status = await missingSprites(cache, root); + let failedArtwork = 0; + if (download && status.missing.length > 0) { + failedArtwork = await fetchSprites(cache, status.missing); + status = await missingSprites(cache, root); + } + reply({ + ok: true, + total: status.total, + missing: status.missing.length, + missingBytes: status.missingBytes, + failedArtwork + }); + })().catch((error) => + reply({ ok: false, error: error instanceof Error ? error.message : String(error) }) + ) + ); + return; + } if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return; const syncEpoch = offlineEpoch; const syncUserId = claimedUserId; @@ -140,13 +206,7 @@ self.addEventListener('message', (event) => { const previousMeta = await currentOfflineMeta(); if (previousMeta?.userId && previousMeta.userId !== snapshot.userId) await clearOfflineData(); - // Per-sync artwork caches of opaque responses could fill the whole storage quota, so drop - // them before writing anything new. - await Promise.all( - (await caches.keys()) - .filter((name) => name.startsWith(`${OFFLINE_CACHE_PREFIX}art-v1-`)) - .map((name) => caches.delete(name)) - ); + await deleteObsoleteArtworkCaches(); const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, ''); const generation = `${timestamp}-${crypto.randomUUID()}`; @@ -161,18 +221,15 @@ self.addEventListener('message', (event) => { if (!isCurrent()) throw new Error('Offline synchronization was superseded by an account change'); - // Commit the collection before any artwork so a slow or failing sprite download can never - // prevent the offline copy from being saved. - const artworkCache = artworkCacheName(snapshot.userId); const metaCache = await caches.open(OFFLINE_META_CACHE); await metaCache.put( OFFLINE_META_URL, new Response( JSON.stringify({ + format: OFFLINE_META_FORMAT, userId: snapshot.userId, generatedAt: snapshot.generatedAt, - dataCache: nextData, - artworkCache + dataCache: nextData }), { headers: { 'Content-Type': 'application/json' } @@ -192,19 +249,14 @@ self.addEventListener('message', (event) => { .filter( (name) => name.startsWith(OFFLINE_CACHE_PREFIX) && - ![OFFLINE_META_CACHE, nextData, artworkCache].includes(name) + ![OFFLINE_META_CACHE, nextData].includes(name) ) .map((name) => caches.delete(name)) ); - const failedArtwork = await cacheArtwork( - await caches.open(artworkCache), - Array.from(new Set(event.data.artworkUrls ?? [])), - isCurrent - ); - if (!isCurrent()) - throw new Error('Offline synchronization was superseded by an account change'); - reply({ ok: true, failedArtwork }); + // Artwork is not downloaded here: the fetch handler caches sprites as they are viewed, and + // CACHE_ALL_ARTWORK fetches the rest only when the user asks for it. + reply({ ok: true }); } catch (error) { if (!committed && nextData) await caches.delete(nextData).catch(() => undefined); reply({ ok: false, error: error instanceof Error ? error.message : String(error) }); @@ -213,6 +265,10 @@ self.addEventListener('message', (event) => { ); }); +self.addEventListener('activate', (event) => { + event.waitUntil(deleteObsoleteArtworkCaches()); +}); + self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); if (event.request.mode === 'navigate' && !['/offline', '/offline.html'].includes(url.pathname)) { @@ -227,17 +283,14 @@ self.addEventListener('fetch', (event) => { ); return; } - if (event.request.destination !== 'image') return; + if (event.request.destination !== 'image' || !isSpriteUrl(url)) return; event.respondWith( (async () => { - const meta = await currentOfflineMeta(); - if (!meta?.artworkCache) return fetch(event.request); - const cache = await caches.open(meta.artworkCache); - const cached = await cache.match(event.request, { ignoreSearch: true }); + // Cache-first with fill-on-miss for everyone, signed in or not: a sprite is downloaded once + // and served from the cache from then on. + const cache = await caches.open(SPRITE_CACHE); + const cached = await cache.match(url.href, { ignoreSearch: true }); if (cached) return cached; - if (!isSpriteUrl(url)) return fetch(event.request); - // Cache-first with fill-on-miss: a sprite shown online is stored once and served from the - // cache from then on, so the bulk sync never has to download it again. try { const response = await fetch(url.href, { mode: url.origin === self.location.origin ? 'same-origin' : 'cors'