/** * PWA Module * * Handles Progressive Web App functionality: * - Service Worker registration * - Install prompt handling * - Offline detection * - Update notifications * * Note: The Service Worker (sw.js) handles caching. * The SharedWorker (shared-db-worker.js) handles database. * They are separate workers with different purposes. */ // ============================================================================ // Service Worker Registration // ============================================================================ let swRegistration = null; export async function registerServiceWorker() { if (!('serviceWorker' in navigator)) { console.warn('[PWA] Service Workers not supported'); return null; } try { swRegistration = await navigator.serviceWorker.register('/sw.js', { scope: '/' }); console.log('[PWA] Service Worker registered:', swRegistration.scope); // Handle updates swRegistration.addEventListener('updatefound', () => { const newWorker = swRegistration.installing; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // New version available console.log('[PWA] New version available'); showUpdateNotification(); } }); }); return swRegistration; } catch (error) { console.error('[PWA] Service Worker registration failed:', error); return null; } } /** * Remove any service worker registered against this origin, and the caches it * filled. Used on the development server, where the service worker is not * registered at all (see initPWA) — without this, a worker installed by an * earlier session would go on controlling the page for as long as it exists. * * It is not only a tidiness problem. On the dev server the worker caches * `/main.js` and `/src/*.js` cache-first, so it can hand back a stale module * and fight hot reloading; and in Safari a worker script that reaches the * browser through a controlling service worker loses its cross-origin-isolation * headers, so SQLocal's worker is refused and the local database never opens. * * @returns {Promise} true if anything was removed */ export async function unregisterServiceWorkers() { if (!('serviceWorker' in navigator)) return false; let removed = false; try { for (const registration of await navigator.serviceWorker.getRegistrations()) { if (await registration.unregister()) removed = true; } // Clear the app caches on every load, not only on the load that did the // unregistering. An unregistered worker goes on controlling the page it is // already attached to until the next navigation, so it refills those caches // behind us during that one load; deleting them unconditionally is what // makes this converge. Nothing in a development build reads them anyway. // // The tile caches are left alone: they hold what an officer downloaded for // field work, they are expensive to refill, and they are equally valid // with no service worker in sight. if ('caches' in window) { const names = await caches.keys(); await Promise.all( names.filter((name) => !name.startsWith('tiles-')).map((name) => caches.delete(name)), ); } if (removed) { console.log('[PWA] Service worker unregistered (development build) — reload once more to drop it completely'); } } catch (error) { console.warn('[PWA] Could not unregister the service worker:', error); } return removed; } // ============================================================================ // Install Prompt // ============================================================================ let deferredPrompt = null; let installButton = null; /** * Initialize install prompt handling * @param {string|HTMLElement} buttonSelector - Button element or selector */ export function initInstallPrompt(buttonSelector = '#install-btn') { installButton = typeof buttonSelector === 'string' ? document.querySelector(buttonSelector) : buttonSelector; if (!installButton) { console.warn('[PWA] Install button not found:', buttonSelector); return; } // Initially hide the button installButton.style.display = 'none'; // Listen for the beforeinstallprompt event window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; // Show the install button installButton.style.display = 'block'; console.log('[PWA] Install prompt ready'); }); // Handle install button click installButton.addEventListener('click', async () => { if (!deferredPrompt) { // Show manual instructions for Safari showManualInstallInstructions(); return; } deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; console.log('[PWA] Install prompt outcome:', outcome); deferredPrompt = null; installButton.style.display = 'none'; }); // Hide button if app is already installed window.addEventListener('appinstalled', () => { console.log('[PWA] App installed'); deferredPrompt = null; installButton.style.display = 'none'; }); // Check if running as installed PWA if (window.matchMedia('(display-mode: standalone)').matches) { installButton.style.display = 'none'; } } function showManualInstallInstructions() { const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); let message = 'To install this app:\n\n'; if (isIOS) { message += '1. Tap the Share button (square with arrow)\n'; message += '2. Scroll down and tap "Add to Home Screen"'; } else if (isSafari) { message += '1. Click File menu\n'; message += '2. Click "Add to Dock"'; } else { message += '1. Click the menu button (three dots)\n'; message += '2. Click "Install" or "Add to Home Screen"'; } alert(message); } // ============================================================================ // Offline Detection // ============================================================================ let offlineIndicator = null; const offlineListeners = new Set(); /** * Initialize offline detection * @param {string|HTMLElement} indicatorSelector - Element to show when offline */ export function initOfflineDetection(indicatorSelector = '#offline-indicator') { offlineIndicator = typeof indicatorSelector === 'string' ? document.querySelector(indicatorSelector) : indicatorSelector; // Set initial state updateOfflineUI(!navigator.onLine); // Listen for online/offline events window.addEventListener('online', () => { console.log('[PWA] Back online'); updateOfflineUI(false); notifyOfflineListeners(false); }); window.addEventListener('offline', () => { console.log('[PWA] Gone offline'); updateOfflineUI(true); notifyOfflineListeners(true); }); } function updateOfflineUI(isOffline) { if (offlineIndicator) { offlineIndicator.style.display = isOffline ? 'block' : 'none'; } // Also toggle a class on body for CSS styling document.body.classList.toggle('is-offline', isOffline); } /** * Subscribe to offline state changes * @param {Function} listener - Callback(isOffline: boolean) * @returns {Function} Unsubscribe function */ export function onOfflineChange(listener) { offlineListeners.add(listener); // Immediately call with current state listener(!navigator.onLine); return () => offlineListeners.delete(listener); } function notifyOfflineListeners(isOffline) { for (const listener of offlineListeners) { try { listener(isOffline); } catch (e) { console.error('[PWA] Offline listener error:', e); } } } /** * Check if currently online */ export function isOnline() { return navigator.onLine; } // ============================================================================ // Update Handling // ============================================================================ let updateCallback = null; /** * Set callback for when updates are available * @param {Function} callback - Called when new version is ready */ export function onUpdateAvailable(callback) { updateCallback = callback; } function showUpdateNotification() { if (updateCallback) { updateCallback(); return; } // Default behavior if (confirm('A new version is available. Reload now?')) { applyUpdate(); } } /** * Apply pending update (reload with new version) */ export function applyUpdate() { if (swRegistration?.waiting) { swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' }); } window.location.reload(); } // ============================================================================ // Communication with Service Worker // ============================================================================ /** * Send a message to the service worker * @param {Object} message - Message to send */ export function postToServiceWorker(message) { navigator.serviceWorker.controller?.postMessage(message); } /** * Request the service worker to cache specific modules * @param {string[]} moduleNames - Array of module names to cache */ export function cacheModules(moduleNames) { postToServiceWorker({ type: 'CACHE_MODULES', payload: { modules: moduleNames } }); } /** * Request the service worker to clear user-specific caches * (Call this on logout) */ export function clearUserCaches() { postToServiceWorker({ type: 'CLEAR_USER_CACHE' }); } /** * Get the Service Worker we can postMessage to. Resolves with: * • `navigator.serviceWorker.controller` if it's already in control of the * page (fastest path), or * • `registration.active` once `navigator.serviceWorker.ready` resolves * (covers the first-load case before the SW has claimed the page). * * Rejects after `timeoutMs` if no SW becomes available — which would only * happen in a private/incognito context, an unsupported browser, or when * registration genuinely failed. * * @param {{ timeoutMs?: number }} [opts] * @returns {Promise} */ export async function getActiveServiceWorker({ timeoutMs = 10000 } = {}) { if (!('serviceWorker' in navigator)) { throw new Error('Service Workers not supported in this browser'); } // Fastest path — page is already SW-controlled if (navigator.serviceWorker.controller) { return navigator.serviceWorker.controller; } // Otherwise wait for the registration to become ready (active SW exists // for this scope, even if it hasn't claimed THIS page yet) const ready = navigator.serviceWorker.ready; const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Service-worker readiness timeout')), timeoutMs) ); const registration = await Promise.race([ready, timeout]); // The controller may have appeared while we were waiting; otherwise use // the registration's active worker (we can still postMessage to it — caches // are shared across the origin) const sw = navigator.serviceWorker.controller || registration.active; if (!sw) { throw new Error('No active service worker available'); } return sw; } /** * Subscribe to controller-change events. The callback fires whenever a new * Service Worker takes control of the page (e.g. after an SW update or first * activation on initial load). Useful for re-querying SW-backed state once * the SW has actually taken over. * * @param {() => void} callback * @returns {() => void} unsubscribe function */ export function onServiceWorkerControllerChange(callback) { if (!('serviceWorker' in navigator)) return () => {}; const handler = () => { try { callback(); } catch (e) { console.error('[PWA] controllerchange handler error:', e); } }; navigator.serviceWorker.addEventListener('controllerchange', handler); return () => navigator.serviceWorker.removeEventListener('controllerchange', handler); } /** * Send a message to the service worker and wait for a single reply with the * given response type. Waits for the SW to become available if it isn't yet. * Resolves with the reply payload, or rejects after a timeout. * * @template T * @param {string} requestType - Message type to send (e.g. 'GET_TILE_STATS') * @param {string} responseType - Message type expected back (e.g. 'TILE_STATS') * @param {Object} [extra={}] - Extra fields merged into the outgoing message * @param {number} [timeoutMs=5000] Reply timeout (after the SW is available) * @param {number} [readyTimeoutMs=10000] Timeout for the SW to be available * @returns {Promise} */ async function requestFromServiceWorker(requestType, responseType, extra = {}, timeoutMs = 5000, readyTimeoutMs = 10000) { const sw = await getActiveServiceWorker({ timeoutMs: readyTimeoutMs }); return new Promise((resolve, reject) => { const channel = new MessageChannel(); const timer = setTimeout(() => { channel.port1.close(); reject(new Error(`Service-worker reply "${responseType}" timed out`)); }, timeoutMs); channel.port1.onmessage = (event) => { if (event.data?.type === responseType) { clearTimeout(timer); channel.port1.close(); const { type, ...rest } = event.data; resolve(rest); } }; sw.postMessage({ type: requestType, ...extra }, [channel.port2]); }); } /** * Get statistics about tiles cached locally on this device, broken down by * provider. Waits up to `readyTimeoutMs` for the service worker to become * available. Returns null only if the SW genuinely cannot be reached * (private mode, registration failure, or timeout). * * @returns {Promise<{ * totals: { count: number, estBytes: number }, * byProvider: Array<{ key: string, label: string, count: number, limit: number, estBytes: number }> * } | null>} */ export async function getTileCacheStats() { try { const reply = await requestFromServiceWorker('GET_TILE_STATS', 'TILE_STATS'); return reply.stats; } catch (err) { console.warn('[PWA] getTileCacheStats failed:', err); return null; } } /** * Delete every cached tile from this device. Doesn't touch the app shell, * modules, or API caches — only the per-provider tile buckets. * Waits for the SW to be available before sending the request. * * @returns {Promise} true if the request was acknowledged */ export async function clearTileCaches() { try { await requestFromServiceWorker('CLEAR_TILE_CACHES', 'TILE_CACHES_CLEARED'); return true; } catch (err) { console.warn('[PWA] clearTileCaches failed:', err); return false; } } /** * Delete cached tiles for a single provider. `cacheName` must match one of * the per-provider caches reported by `getTileCacheStats()` (e.g. * `tiles-osm-v4`, `tiles-topo-v4`). Unknown names are rejected by the SW. * * @param {string} cacheName * @returns {Promise} true if the cache was actually deleted */ export async function clearTileCacheForProvider(cacheName) { if (!cacheName) return false; try { const reply = await requestFromServiceWorker( 'CLEAR_TILE_CACHE', 'TILE_CACHE_CLEARED', { cacheName }, ); return !!reply.deleted; } catch (err) { console.warn(`[PWA] clearTileCacheForProvider(${cacheName}) failed:`, err); return false; } } /** * Get total disk used by this origin (Cache API + IndexedDB + OPFS). * Returns null if the Storage API is not available. * * @returns {Promise<{ usage: number, quota: number } | null>} */ export async function getStorageEstimate() { if (!navigator.storage?.estimate) return null; try { const { usage, quota } = await navigator.storage.estimate(); return { usage: usage || 0, quota: quota || 0 }; } catch (err) { console.warn('[PWA] getStorageEstimate failed:', err); return null; } } // ============================================================================ // Auto-initialization // ============================================================================ /** * Initialize all PWA features * @param {Object} options */ export async function initPWA(options = {}) { const { installButton = '#install-btn', offlineIndicator = '#offline-indicator', autoRegisterSW = true } = options; if (autoRegisterSW) { await registerServiceWorker(); } else { // Nothing should be controlling the page when registration is off; clear // out anything an earlier session left behind. await unregisterServiceWorkers(); } initInstallPrompt(installButton); initOfflineDetection(offlineIndicator); console.log('[PWA] Initialized'); } // Export for direct use export default { registerServiceWorker, initInstallPrompt, initOfflineDetection, initPWA, isOnline, onOfflineChange, onUpdateAvailable, applyUpdate, postToServiceWorker, cacheModules, clearUserCaches, getTileCacheStats, clearTileCaches, clearTileCacheForProvider, getStorageEstimate, getActiveServiceWorker, onServiceWorkerControllerChange, };