UNPKG

@jager-ai/holy-pwa

Version:

Progressive Web App (PWA) utilities and templates extracted from Holy Habit project with manifest generation, service worker management, and offline support

396 lines (340 loc) 10.7 kB
/** * PWA Manager * * Utility class for managing PWA installation, updates, and lifecycle * Extracted from Holy Habit PWA management features */ import { PWAInstallationState, PWALifecycleEvents, PWACapabilities, PWAError } from '../types/PWA'; export class PWAManager { private installPrompt: any = null; private registration: ServiceWorkerRegistration | null = null; private events: PWALifecycleEvents = {}; constructor(events?: PWALifecycleEvents) { this.events = events || {}; this.setupEventListeners(); } /** * Initialize PWA manager and register service worker * * @param serviceWorkerPath - Path to service worker file * @returns Registration promise */ async initialize(serviceWorkerPath: string = '/service-worker.js'): Promise<ServiceWorkerRegistration | null> { if (!this.isServiceWorkerSupported()) { console.warn('Service Workers are not supported in this browser'); return null; } try { this.registration = await navigator.serviceWorker.register(serviceWorkerPath); console.log('Service Worker registered successfully'); // Setup update checking this.setupUpdateHandling(); // Fire install event if available if (this.events.onInstall) { this.events.onInstall(); } return this.registration; } catch (error) { console.error('Service Worker registration failed:', error); throw new PWAError('Service Worker registration failed', 'SERVICE_WORKER_FAILED', error); } } /** * Get PWA installation state * * @returns Current installation state */ getInstallationState(): PWAInstallationState { return { isInstallable: this.installPrompt !== null, isInstalled: this.isStandalone(), installPrompt: this.installPrompt, userChoice: undefined // Will be set after user interaction }; } /** * Prompt user to install PWA * * @returns User choice result */ async promptInstall(): Promise<'accepted' | 'dismissed' | null> { if (!this.installPrompt) { throw new PWAError('Install prompt not available', 'INSTALL_FAILED'); } try { // Show the install prompt this.installPrompt.prompt(); // Wait for user choice const choiceResult = await this.installPrompt.userChoice; if (choiceResult.outcome === 'accepted') { console.log('User accepted the install prompt'); this.installPrompt = null; // Clear the prompt return 'accepted'; } else { console.log('User dismissed the install prompt'); return 'dismissed'; } } catch (error) { console.error('Install prompt failed:', error); throw new PWAError('Install prompt failed', 'INSTALL_FAILED', error); } } /** * Check if PWA is running in standalone mode * * @returns True if running as standalone app */ isStandalone(): boolean { return window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone === true; } /** * Get PWA capabilities * * @returns Object with capability flags */ getCapabilities(): PWACapabilities { return { serviceWorker: 'serviceWorker' in navigator, pushNotifications: 'PushManager' in window && 'serviceWorker' in navigator, backgroundSync: 'serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype, indexedDB: 'indexedDB' in window, webShare: 'share' in navigator, installPrompt: this.installPrompt !== null, standalone: this.isStandalone() }; } /** * Check for PWA updates * * @returns True if update is available */ async checkForUpdates(): Promise<boolean> { if (!this.registration) { return false; } try { await this.registration.update(); // Check if there's a waiting service worker if (this.registration.waiting) { if (this.events.onUpdateAvailable) { this.events.onUpdateAvailable('new-version'); } return true; } return false; } catch (error) { console.error('Update check failed:', error); return false; } } /** * Apply pending update */ async applyUpdate(): Promise<void> { if (!this.registration?.waiting) { throw new PWAError('No update available to apply', 'SERVICE_WORKER_FAILED'); } // Send message to service worker to skip waiting this.registration.waiting.postMessage({ type: 'SKIP_WAITING' }); // Reload the page to activate new service worker window.location.reload(); } /** * Request push notification permission * * @returns Permission state */ async requestNotificationPermission(): Promise<NotificationPermission> { if (!('Notification' in window)) { throw new PWAError('Notifications not supported', 'NOTIFICATION_DENIED'); } if (Notification.permission === 'granted') { return 'granted'; } if (Notification.permission === 'denied') { throw new PWAError('Notifications denied by user', 'NOTIFICATION_DENIED'); } // Request permission const permission = await Notification.requestPermission(); if (permission === 'denied') { throw new PWAError('User denied notification permission', 'NOTIFICATION_DENIED'); } return permission; } /** * Subscribe to push notifications * * @param vapidKey - VAPID public key * @returns Push subscription */ async subscribeToPush(vapidKey: string): Promise<PushSubscription> { if (!this.registration) { throw new PWAError('Service Worker not registered', 'SERVICE_WORKER_FAILED'); } await this.requestNotificationPermission(); try { const subscription = await this.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlBase64ToUint8Array(vapidKey) }); return subscription; } catch (error) { console.error('Push subscription failed:', error); throw new PWAError('Push subscription failed', 'NOTIFICATION_DENIED', error); } } /** * Get current push subscription * * @returns Current subscription or null */ async getPushSubscription(): Promise<PushSubscription | null> { if (!this.registration) { return null; } return await this.registration.pushManager.getSubscription(); } /** * Unsubscribe from push notifications * * @returns True if successful */ async unsubscribeFromPush(): Promise<boolean> { const subscription = await this.getPushSubscription(); if (subscription) { return await subscription.unsubscribe(); } return false; } /** * Show offline notification * * @param message - Notification message */ showOfflineNotification(message: string = 'You are currently offline'): void { if ('Notification' in window && Notification.permission === 'granted') { new Notification('Connection Status', { body: message, icon: '/assets/icons/icon-192x192.png', tag: 'offline-status' }); } } /** * Show online notification * * @param message - Notification message */ showOnlineNotification(message: string = 'You are back online'): void { if ('Notification' in window && Notification.permission === 'granted') { new Notification('Connection Status', { body: message, icon: '/assets/icons/icon-192x192.png', tag: 'online-status' }); } } /** * Setup event listeners for PWA lifecycle */ private setupEventListeners(): void { // Install prompt event window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); this.installPrompt = e; console.log('Install prompt available'); }); // App installed event window.addEventListener('appinstalled', () => { console.log('PWA was installed'); this.installPrompt = null; }); // Online/offline events window.addEventListener('online', () => { console.log('App is online'); if (this.events.onOnline) { this.events.onOnline(); } this.showOnlineNotification(); }); window.addEventListener('offline', () => { console.log('App is offline'); if (this.events.onOffline) { this.events.onOffline(); } this.showOfflineNotification(); }); } /** * Setup service worker update handling */ private setupUpdateHandling(): void { if (!this.registration) return; // Check for updates periodically setInterval(() => { this.registration?.update(); }, 60000); // Check every minute // Handle service worker state changes this.registration.addEventListener('updatefound', () => { const newWorker = this.registration?.installing; if (newWorker) { newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // New service worker available if (this.events.onUpdateAvailable) { this.events.onUpdateAvailable('update-available'); } } }); } }); // Handle service worker messages navigator.serviceWorker.addEventListener('message', (event) => { if (event.data.type === 'UPDATE_APPLIED') { if (this.events.onUpdateApplied) { this.events.onUpdateApplied(); } } }); } /** * Check if service workers are supported */ private isServiceWorkerSupported(): boolean { return 'serviceWorker' in navigator; } /** * Convert VAPID key to Uint8Array */ private 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; } /** * Update event handlers */ updateEvents(newEvents: Partial<PWALifecycleEvents>): void { this.events = { ...this.events, ...newEvents }; } /** * Get service worker registration */ getRegistration(): ServiceWorkerRegistration | null { return this.registration; } /** * Clear install prompt (useful for custom install flows) */ clearInstallPrompt(): void { this.installPrompt = null; } }