299 lines
7.6 KiB
TypeScript
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 };
|