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