/* ObsiGate — Service Worker (Push notifications + Offline cache) * Version: 1.0.0 * Scope: / */ const CACHE_NAME = 'obsigate-v1'; const OFFLINE_CACHE = 'obsigate-offline-v1'; // Files to cache for offline access const PRECACHE_URLS = [ '/', '/index.html', '/manifest.json', '/frontend/js/app.js', '/frontend/js/state.js', '/frontend/js/auth.js', '/frontend/js/ui.js', '/frontend/js/sidebar.js', '/frontend/js/viewer.js', '/frontend/js/search.js', '/frontend/js/config.js', '/frontend/js/utils.js', '/frontend/js/i18n.js', '/frontend/js/offline.js', '/frontend/js/offline-db.js', '/frontend/style.css', '/static/lucide.min.js' ]; // ── Install: precache static assets ──────────────────────────────────────── self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { console.log('[SW] Precaching static assets'); return cache.addAll(PRECACHE_URLS); }).then(() => self.skipWaiting()) ); }); // ── Activate: clean old caches ───────────────────────────────────────────── self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((name) => name !== CACHE_NAME && name !== OFFLINE_CACHE) .map((name) => caches.delete(name)) ); }).then(() => self.clients.claim()) ); }); // ── Fetch: Network-first for API, Cache-first for static ─────────────────── self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Skip non-GET if (event.request.method !== 'GET') return; // Skip cross-origin if (url.origin !== location.origin) return; // API requests: Network-first with offline fallback if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirstStrategy(event.request)); return; } // Static assets: Cache-first event.respondWith(cacheFirstStrategy(event.request)); }); // Network-first strategy (for API) async function networkFirstStrategy(request) { try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(OFFLINE_CACHE); cache.put(request, response.clone()); } return response; } catch (error) { const cached = await caches.match(request); if (cached) return cached; return new Response(JSON.stringify({ error: 'Offline' }), { status: 503, headers: { 'Content-Type': 'application/json' } }); } } // Cache-first strategy (for static assets) async function cacheFirstStrategy(request) { const cached = await caches.match(request); if (cached) { // Stale-while-revalidate: update cache in background fetch(request).then((response) => { if (response.ok) { caches.open(CACHE_NAME).then((cache) => cache.put(request, response)); } }).catch(() => {}); return cached; } try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(CACHE_NAME); cache.put(request, response.clone()); } return response; } catch (error) { // Return offline page for navigation requests if (request.mode === 'navigate') { return caches.match('/index.html'); } return new Response('Offline', { status: 503 }); } } // ── Push Event: Display notification ─────────────────────────────────────── self.addEventListener('push', (event) => { if (!event.data) return; const data = event.data.json(); const options = { body: data.body || data.message || 'Nouvelle notification', icon: '/static/icon-192.png', badge: '/static/badge-72.png', vibrate: [200, 100, 200], tag: data.tag || 'obsigate-notification', renotify: true, data: data.data || {}, actions: [ { action: 'open', title: 'Ouvrir' }, { action: 'dismiss', title: 'Ignorer' } ], requireInteraction: false }; event.waitUntil( self.registration.showNotification(data.title || 'ObsiGate', options) ); }); // ── Notification Click: Open the app ─────────────────────────────────────── self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.action === 'dismiss') return; // Default action or 'open' action: navigate to the file const urlToOpen = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { // Try to focus existing window for (const client of clientList) { if (client.url.includes(urlToOpen) && 'focus' in client) { return client.focus(); } } // Open new window if (clients.openWindow) { return clients.openWindow(urlToOpen); } }) ); }); // ── Notification Close ───────────────────────────────────────────────────── self.addEventListener('notificationclose', (event) => { console.log('[SW] Notification closed:', event.notification.tag); }); // ── Background Sync: Periodic index sync ────────────────────────────────── self.addEventListener('sync', (event) => { if (event.tag === 'sync-file-index') { event.waitUntil(syncFileIndex()); } }); async function syncFileIndex() { try { const resp = await fetch('/api/search/advanced?q=**&vault=all&limit=200'); if (resp.ok) { const data = await resp.json(); const files = (data.results || []).map(r => ({ vault: r.vault, path: r.path, title: r.title, tags: r.tags || [], snippet: r.snippet || '', modified: r.modified || '' })); // Send to main thread for IndexedDB storage const clients = await self.clients.matchAll(); clients.forEach(client => client.postMessage({ type: 'OFFLINE_INDEX_SYNC', files })); } } catch (e) { console.warn('[SW] Background sync failed:', e); } } // ── Periodic Background Sync (if supported) ──────────────────────────────── self.addEventListener('periodicsync', (event) => { if (event.tag === 'periodic-index-sync') { event.waitUntil(syncFileIndex()); } }); // ── Message from main thread ─────────────────────────────────────────────── self.addEventListener('message', (event) => { if (event.data?.type === 'SKIP_WAITING') { self.skipWaiting(); } });