@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
text/typescript
/**
* 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;
}
}