From c6e91c2eafbc6f990128db1d98fe056561e9d425 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Tue, 7 Apr 2026 01:02:53 +0300 Subject: [PATCH] feat(frontend): add PWA support (service worker, manifest, pwa util) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/service-worker.ts: cache-first app shell (build + static assets), network-only for /api/, offline fallback to SPA shell - static/manifest.webmanifest: name/short_name Tanabata, theme #312F45, standalone display, start_url /files, icon paths for 192/512/maskable - src/lib/utils/pwa.ts: resetPwa() — unregisters SW + clears all caches - app.html: link manifest, theme-color meta, Apple PWA metas - settings page: refactored to use resetPwa() from utils Note: add /static/images/icon-192.png, icon-512.png, icon-maskable-512.png for full installability. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.html | 6 ++ frontend/src/lib/utils/pwa.ts | 14 +++++ frontend/src/routes/settings/+page.svelte | 10 +--- frontend/src/service-worker.ts | 68 +++++++++++++++++++++++ frontend/static/manifest.webmanifest | 31 +++++++++++ 5 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/utils/pwa.ts create mode 100644 frontend/src/service-worker.ts create mode 100644 frontend/static/manifest.webmanifest diff --git a/frontend/src/app.html b/frontend/src/app.html index c639b02..99492c9 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,6 +3,12 @@ + + + + + + %sveltekit.head% diff --git a/frontend/src/lib/utils/pwa.ts b/frontend/src/lib/utils/pwa.ts new file mode 100644 index 0000000..951ca05 --- /dev/null +++ b/frontend/src/lib/utils/pwa.ts @@ -0,0 +1,14 @@ +/** + * Unregisters all service workers and clears all caches, then reloads. + * Use this when the app feels stale or to force a clean re-fetch of all assets. + */ +export async function resetPwa(): Promise { + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + } + if ('caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.map((k) => caches.delete(k))); + } +} \ No newline at end of file diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 9120312..28fb2b7 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -3,6 +3,7 @@ import { authStore } from '$lib/stores/auth'; import { themeStore, toggleTheme } from '$lib/stores/theme'; import { appSettings } from '$lib/stores/appSettings'; + import { resetPwa as doPwaReset } from '$lib/utils/pwa'; import type { User, Session, SessionList } from '$lib/api/types'; // ---- Profile ---- @@ -91,14 +92,7 @@ pwaResetting = true; pwaSuccess = false; try { - if ('serviceWorker' in navigator) { - const registrations = await navigator.serviceWorker.getRegistrations(); - await Promise.all(registrations.map((r) => r.unregister())); - } - if ('caches' in window) { - const keys = await caches.keys(); - await Promise.all(keys.map((k) => caches.delete(k))); - } + await doPwaReset(); pwaSuccess = true; setTimeout(() => (pwaSuccess = false), 3000); } finally { diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts new file mode 100644 index 0000000..761104b --- /dev/null +++ b/frontend/src/service-worker.ts @@ -0,0 +1,68 @@ +/// +/// + +import { build, files, version } from '$service-worker'; + +declare const self: ServiceWorkerGlobalScope; + +// Cache name is versioned so a new deploy invalidates the old shell. +const CACHE = `app-shell-${version}`; + +// App shell: all Vite-emitted JS/CSS chunks + static assets (fonts, icons, manifest). +const SHELL = [...build, ...files]; + +// ---- Install: pre-cache the app shell ---- +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(SHELL)) + ); + // Activate immediately without waiting for old tabs to close. + self.skipWaiting(); +}); + +// ---- Activate: remove stale caches from previous versions ---- +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +// ---- Fetch: cache-first for shell assets, network-only for API ---- +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Only handle same-origin GET requests. + if (request.method !== 'GET' || url.origin !== self.location.origin) return; + + // API and authentication calls must always go to the network. + if (url.pathname.startsWith('/api/')) return; + + event.respondWith(respond(request)); +}); + +async function respond(request: Request): Promise { + const cache = await caches.open(CACHE); + + // Shell assets are pre-cached — serve from cache immediately. + const cached = await cache.match(request); + if (cached) return cached; + + // Everything else (navigation, dynamic routes): network first. + try { + const response = await fetch(request); + // Cache successful responses for navigation so the app works offline. + if (response.status === 200) { + cache.put(request, response.clone()); + } + return response; + } catch { + // Offline fallback: return the cached SPA shell for navigation requests. + const fallback = await cache.match('/'); + if (fallback) return fallback; + return new Response('Offline', { status: 503 }); + } +} \ No newline at end of file diff --git a/frontend/static/manifest.webmanifest b/frontend/static/manifest.webmanifest new file mode 100644 index 0000000..2f96fca --- /dev/null +++ b/frontend/static/manifest.webmanifest @@ -0,0 +1,31 @@ +{ + "name": "Tanabata", + "short_name": "Tanabata", + "description": "Multi-user tag-based file manager", + "start_url": "/files", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#312F45", + "theme_color": "#312F45", + "icons": [ + { + "src": "/images/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/images/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/images/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file