const CACHE_NAME = 'whisky-vault-v19-offline'; // CONFIG: Assets const STATIC_ASSETS = [ '/manifest.webmanifest', '/icon-192.png', '/icon-512.png', '/favicon.ico', '/lib/browser-image-compression.js', // Tesseract OCR files for offline scanning (ALL variants for browser compatibility) '/tessdata/worker.min.js', '/tessdata/tesseract-core.wasm.js', '/tessdata/tesseract-core-simd.wasm.js', '/tessdata/tesseract-core-lstm.wasm.js', '/tessdata/tesseract-core-simd-lstm.wasm.js', '/tessdata/tesseract-core-relaxedsimd.wasm.js', '/tessdata/tesseract-core-relaxedsimd-lstm.wasm.js', '/tessdata/eng.traineddata', '/tessdata/eng.traineddata.gz', ]; const CORE_PAGES = [ '/', ]; // Global state to track progress let currentProgress = 0; let isPrecacheFinished = false; // Helper: Broadcast to ALL clients (including those being installed) async function broadcast(message) { try { const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' }); clients.forEach(client => { try { client.postMessage(message); } catch (err) { // Ignore message delivery errors } }); } catch (e) { } } // Helper: Fetch with Timeout & AbortController async function fetchWithTimeout(url, timeoutMs = 30000) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal, cache: 'no-store' }); clearTimeout(id); return response; } catch (e) { clearTimeout(id); throw e; } } // ๐Ÿ—๏ธ INSTALL: Build the Offline-Modus self.addEventListener('install', (event) => { // Take over immediately self.skipWaiting(); event.waitUntil( caches.open(CACHE_NAME).then(async (cache) => { console.log(`๐Ÿ—๏ธ PWA: Building Offline-Modus ${CACHE_NAME}...`); const items = [...STATIC_ASSETS, ...CORE_PAGES]; const total = items.length; let loaded = 0; // Start broadcasting 0% immediately broadcast({ type: 'OFFLINE_PROGRESS', progress: 0 }); for (const url of items) { try { const res = await fetchWithTimeout(url); if (res && res.ok) { await cache.put(url, res); } } catch (error) { console.error(`โš ๏ธ PWA: Pre-cache failed for ${url}:`, error); } finally { loaded++; currentProgress = Math.round((loaded / total) * 100); broadcast({ type: 'OFFLINE_PROGRESS', progress: currentProgress }); } } console.log('โœ… PWA: Offline-Modus is ready'); isPrecacheFinished = true; broadcast({ type: 'OFFLINE_READY', version: CACHE_NAME }); }) ); }); // ๐Ÿงน ACTIVATE: Cleanup old caches self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if (cacheName !== CACHE_NAME) { return caches.delete(cacheName); } }) ); }) ); self.clients.claim(); }); // ๐Ÿ’ฌ MESSAGE: Handle status queries self.addEventListener('message', (event) => { if (event.data?.type === 'CHECK_OFFLINE_STATUS') { event.source.postMessage({ type: 'OFFLINE_STATUS_RESPONSE', isReady: isPrecacheFinished, progress: currentProgress, version: CACHE_NAME }); } }); // ๐Ÿš€ FETCH: Offline-First Strategy self.addEventListener('fetch', (event) => { // ๐Ÿ›ก๏ธ SECURITY/STABILITY: Only process http/https schemes. // Prevents "Failed to execute 'put' on 'Cache': Request scheme 'chrome-extension' is unsupported" if (!event.request.url.startsWith('http')) { return; } const url = new URL(event.request.url); // CRITICAL: Bypass Auth/API/Supabase early and COMPLETELY. // We do not call event.respondWith() for these, letting the browser handle them natively. if ( url.pathname.includes('/auth/') || url.pathname.includes('/api/') || url.hostname.includes('supabase.co') || url.hostname.includes('auth') || event.request.headers.get('Authorization') ) { return; } if (event.request.method !== 'GET') return; // RSC Data if (url.pathname.startsWith('/_next/data/')) { event.respondWith( caches.match(event.request).then((cached) => { if (cached) return cached; return fetchWithTimeout(event.request, 4000) .catch(() => new Response(JSON.stringify({}), { headers: { 'Content-Type': 'application/json' } })); }) ); return; } // ๐Ÿ“ฆ Tesseract OCR files and static libs - CACHE FIRST (critical for offline) if (url.pathname.startsWith('/tessdata/') || url.pathname.startsWith('/lib/')) { event.respondWith( caches.match(event.request).then(async (cachedResponse) => { if (cachedResponse) { console.log(`[SW] Serving from cache: ${url.pathname}`); return cachedResponse; } // Try network, cache the result try { const networkResponse = await fetchWithTimeout(event.request, 30000); if (networkResponse && networkResponse.ok) { const cache = await caches.open(CACHE_NAME); cache.put(event.request, networkResponse.clone()); } return networkResponse; } catch (error) { console.error(`[SW] Failed to fetch ${url.pathname}:`, error); return new Response('File not available offline', { status: 503 }); } }) ); return; } // Navigation & Assets const isNavigation = event.request.mode === 'navigate'; const isAsset = event.request.destination === 'style' || event.request.destination === 'script' || event.request.destination === 'image' || url.pathname.startsWith('/_next/static'); if (isNavigation || isAsset) { event.respondWith( caches.match(event.request).then(async (cachedResponse) => { const fetchPromise = fetchWithTimeout(event.request, 10000) .then(async (networkResponse) => { if (networkResponse && networkResponse.status === 200) { const cache = await caches.open(CACHE_NAME); cache.put(event.request, networkResponse.clone()); } return networkResponse; }).catch(() => { }); if (isNavigation) { if (cachedResponse) return cachedResponse; const shell = await caches.match('/'); if (shell) return shell; } return cachedResponse || fetchPromise || fetch(event.request); }) ); } });