/** * PWA Utilities * Service worker registration, update handling, and install prompt management */ export interface PWAStatus { isInstalled: boolean; isStandalone: boolean; canInstall: boolean; isOnline: boolean; updateAvailable: boolean; } interface BeforeInstallPromptEvent extends Event { readonly platforms: string[]; readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string; }>; prompt(): Promise; } let deferredPrompt: BeforeInstallPromptEvent | null = null; let swRegistration: ServiceWorkerRegistration | null = null; // Check if running as installed PWA export function isInstalled(): boolean { if (typeof window === 'undefined') return false; return ( window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone === true || document.referrer.includes('android-app://') ); } // Check if running in standalone mode export function isStandalone(): boolean { if (typeof window === 'undefined') return false; return window.matchMedia('(display-mode: standalone)').matches; } // Check if app can be installed export function canInstall(): boolean { return deferredPrompt !== null; } // Get current PWA status export function getPWAStatus(): PWAStatus { return { isInstalled: isInstalled(), isStandalone: isStandalone(), canInstall: canInstall(), isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true, updateAvailable: false, // Updated by service worker }; } // Prompt user to install PWA export async function promptInstall(): Promise { if (!deferredPrompt) { return false; } await deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; deferredPrompt = null; return outcome === 'accepted'; } // Register service worker export async function registerServiceWorker(): Promise { if (typeof window === 'undefined' || !('serviceWorker' in navigator)) { return null; } try { const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/', }); swRegistration = registration; // Check for updates periodically setInterval(() => { registration.update(); }, 60 * 60 * 1000); // Check every hour // Handle updates registration.addEventListener('updatefound', () => { const newWorker = registration.installing; if (!newWorker) return; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // New update available dispatchPWAEvent('updateavailable'); } }); }); return registration; } catch (error) { console.error('Service worker registration failed:', error); return null; } } // Apply pending update export async function applyUpdate(): Promise { if (!swRegistration?.waiting) return; // Tell service worker to skip waiting swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' }); // Reload page after new service worker takes over navigator.serviceWorker.addEventListener('controllerchange', () => { window.location.reload(); }); } // Unregister service worker export async function unregisterServiceWorker(): Promise { if (!swRegistration) return false; const success = await swRegistration.unregister(); if (success) { swRegistration = null; } return success; } // Clear all caches export async function clearCaches(): Promise { if (typeof caches === 'undefined') return; const cacheNames = await caches.keys(); await Promise.all(cacheNames.map((name) => caches.delete(name))); } // Subscribe to push notifications export async function subscribeToPush(vapidPublicKey: string): Promise { if (!swRegistration) { console.error('Service worker not registered'); return null; } try { const subscription = await swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }); return subscription; } catch (error) { console.error('Push subscription failed:', error); return null; } } // Unsubscribe from push notifications export async function unsubscribeFromPush(): Promise { if (!swRegistration) return false; try { const subscription = await swRegistration.pushManager.getSubscription(); if (subscription) { return await subscription.unsubscribe(); } return true; } catch { return false; } } // Check push notification permission export function getPushPermission(): NotificationPermission { if (typeof Notification === 'undefined') return 'denied'; return Notification.permission; } // Request push notification permission export async function requestPushPermission(): Promise { if (typeof Notification === 'undefined') return 'denied'; return await Notification.requestPermission(); } // Initialize PWA export function initPWA(): () => void { if (typeof window === 'undefined') return () => {}; // Listen for install prompt const handleBeforeInstallPrompt = (e: Event) => { e.preventDefault(); deferredPrompt = e as BeforeInstallPromptEvent; dispatchPWAEvent('caninstall'); }; // Listen for app installed const handleAppInstalled = () => { deferredPrompt = null; dispatchPWAEvent('installed'); }; // Listen for online/offline const handleOnline = () => dispatchPWAEvent('online'); const handleOffline = () => dispatchPWAEvent('offline'); window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.addEventListener('appinstalled', handleAppInstalled); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); // Register service worker registerServiceWorker(); return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('appinstalled', handleAppInstalled); window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; } // PWA event dispatcher function dispatchPWAEvent(type: string, detail?: any): void { window.dispatchEvent(new CustomEvent(`pwa:${type}`, { detail })); } // Utility to convert VAPID key function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } // Share API wrapper export async function share(data: ShareData): Promise { if (!navigator.share) { return false; } try { await navigator.share(data); return true; } catch (error) { if ((error as Error).name !== 'AbortError') { console.error('Share failed:', error); } return false; } } // Check if Web Share API is supported export function canShare(data?: ShareData): boolean { if (!navigator.share) return false; if (!data) return true; return navigator.canShare?.(data) ?? true; }