veza/apps/web/src/services/pwa.ts

299 lines
7.6 KiB
TypeScript

/**
* PWA Service - Progressive Web App functionality
* Handles service worker registration, installation prompts, and offline capabilities
*/
export interface PWAInstallPrompt {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export interface PWAStatus {
isInstallable: boolean;
isInstalled: boolean;
isOnline: boolean;
serviceWorkerReady: boolean;
updateAvailable: boolean;
}
import { logger } from '@/utils/logger';
class PWAService {
private installPrompt: PWAInstallPrompt | null = null;
private registration: ServiceWorkerRegistration | null = null;
private statusCallbacks: Set<(status: PWAStatus) => void> = new Set();
constructor() {
this.initialize();
}
private async initialize() {
// Register service worker
await this.registerServiceWorker();
// Listen for install prompt
this.setupInstallPrompt();
// Listen for online/offline events
this.setupOnlineDetection();
// Check for updates
this.checkForUpdates();
}
/**
* Register the service worker
* v0.801: Re-enabled with safe strategy - JS/CSS chunks always from network
*/
private async registerServiceWorker(): Promise<void> {
if (import.meta.env.DEV) {
logger.info('[PWA] Service Worker disabled in development mode');
return;
}
if ('serviceWorker' in navigator) {
try {
this.registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
logger.info('[PWA] Service Worker registered successfully');
this.registration.addEventListener('updatefound', () => {
const newWorker = this.registration!.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
logger.info('[PWA] New service worker available');
this.notifyStatusChange();
}
});
}
});
this.notifyStatusChange();
} catch (error) {
logger.error('[PWA] Service Worker registration failed:', { error });
}
}
}
/**
* Setup install prompt handling
*/
private setupInstallPrompt(): void {
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
this.installPrompt = event as any;
logger.info('[PWA] Install prompt available');
this.notifyStatusChange();
});
// Listen for app installed event
window.addEventListener('appinstalled', () => {
logger.info('[PWA] App installed successfully');
this.installPrompt = null;
this.notifyStatusChange();
});
}
/**
* Setup online/offline detection
*/
private setupOnlineDetection(): void {
window.addEventListener('online', () => {
logger.info('[PWA] Back online');
this.notifyStatusChange();
});
window.addEventListener('offline', () => {
logger.info('[PWA] Gone offline');
this.notifyStatusChange();
});
}
/**
* Check for service worker updates
*/
private async checkForUpdates(): Promise<void> {
if (this.registration) {
try {
await this.registration.update();
} catch (error) {
logger.error('[PWA] Failed to check for updates:', { error });
}
}
}
/**
* Prompt user to install the app
*/
public async promptInstall(): Promise<boolean> {
if (!this.installPrompt) {
logger.warn('[PWA] Install prompt not available');
return false;
}
try {
await this.installPrompt.prompt();
const choice = await this.installPrompt.userChoice;
if (choice.outcome === 'accepted') {
logger.info('[PWA] User accepted install prompt');
this.installPrompt = null;
return true;
} else {
logger.info('[PWA] User dismissed install prompt');
return false;
}
} catch (error) {
logger.error('[PWA] Install prompt failed:', { error });
return false;
}
}
/**
* Update the service worker
*/
public async updateServiceWorker(): Promise<void> {
if (this.registration && this.registration.waiting) {
// Tell the waiting service worker to skip waiting
this.registration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Reload the page to activate the new service worker
window.location.reload();
}
}
/**
* Get current PWA status
*/
public getStatus(): PWAStatus {
return {
isInstallable: !!this.installPrompt,
isInstalled: this.isAppInstalled(),
isOnline: navigator.onLine,
serviceWorkerReady: !!this.registration,
updateAvailable: !!this.registration?.waiting,
};
}
/**
* Check if app is installed
*/
private isAppInstalled(): boolean {
// Check if running in standalone mode (installed PWA)
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone ||
document.referrer.includes('android-app://')
);
}
/**
* Subscribe to status changes
*/
public onStatusChange(callback: (status: PWAStatus) => void): () => void {
this.statusCallbacks.add(callback);
// Return unsubscribe function
return () => {
this.statusCallbacks.delete(callback);
};
}
/**
* Notify all subscribers of status change
*/
private notifyStatusChange(): void {
const status = this.getStatus();
this.statusCallbacks.forEach((callback) => {
try {
callback(status);
} catch (error) {
logger.error('[PWA] Status callback error:', { error });
}
});
}
/**
* Clear all caches
*/
public async clearCaches(): Promise<void> {
if (this.registration) {
const messageChannel = new MessageChannel();
return new Promise((resolve) => {
messageChannel.port1.onmessage = (event) => {
if (event.data.type === 'CACHE_CLEARED') {
resolve();
}
};
this.registration!.active?.postMessage({ type: 'CLEAR_CACHE' }, [
messageChannel.port2,
]);
});
}
}
/**
* Get service worker version
*/
public async getVersion(): Promise<string> {
if (this.registration && this.registration.active) {
const messageChannel = new MessageChannel();
return new Promise((resolve) => {
messageChannel.port1.onmessage = (event) => {
if (event.data.type === 'VERSION') {
resolve(event.data.payload.version);
}
};
this.registration!.active!.postMessage({ type: 'GET_VERSION' }, [
messageChannel.port2,
]);
});
}
return 'unknown';
}
/**
* Show notification (if permission granted)
*/
public async showNotification(
title: string,
options?: NotificationOptions,
): Promise<void> {
if ('Notification' in window && Notification.permission === 'granted') {
if (this.registration) {
await this.registration.showNotification(title, {
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
...options,
});
}
}
}
/**
* Request notification permission
*/
public async requestNotificationPermission(): Promise<NotificationPermission> {
if ('Notification' in window) {
return await Notification.requestPermission();
}
return 'denied';
}
}
// Export singleton instance
export const pwaService = new PWAService();
// Re-export for direct import
export { type PWAStatus as PWAStatusType };