This commit is contained in:
Giancarmine Salucci
2026-02-18 01:21:44 +01:00
parent 54321fd7c9
commit 49bccf8f15
84 changed files with 14474 additions and 13925 deletions

View File

@@ -1,6 +1,6 @@
/**
* PWA Installation Manager
*
*
* Handles PWA installation flow with cross-browser support.
* Provides beforeinstallprompt event handling, user engagement detection,
* and dismissal state management for the install prompt.
@@ -9,193 +9,193 @@
import { browser } from '$app/environment';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export class PWAInstallManager {
private deferredPrompt: BeforeInstallPromptEvent | null = null;
private listeners: Array<(canInstall: boolean) => void> = [];
private installable = false;
private deferredPrompt: BeforeInstallPromptEvent | null = null;
private listeners: Array<(canInstall: boolean) => void> = [];
private installable = false;
constructor() {
if (browser) {
this.initializeInstallPrompt();
}
}
constructor() {
if (browser) {
this.initializeInstallPrompt();
}
}
/**
* Initialize PWA install prompt event listeners
*/
private initializeInstallPrompt(): void {
// Listen for beforeinstallprompt event (Chrome, Edge)
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault();
this.deferredPrompt = e as BeforeInstallPromptEvent;
this.installable = true;
this.notifyListeners(true);
console.log('[PWA] Install prompt available');
});
/**
* Initialize PWA install prompt event listeners
*/
private initializeInstallPrompt(): void {
// Listen for beforeinstallprompt event (Chrome, Edge)
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault();
this.deferredPrompt = e as BeforeInstallPromptEvent;
this.installable = true;
this.notifyListeners(true);
console.log('[PWA] Install prompt available');
});
// Listen for app installation completion
window.addEventListener('appinstalled', () => {
console.log('[PWA] App was installed');
this.installable = false;
this.deferredPrompt = null;
this.notifyListeners(false);
// Clear dismissal state since user installed
this.clearDismissed();
});
// Listen for app installation completion
window.addEventListener('appinstalled', () => {
console.log('[PWA] App was installed');
this.installable = false;
this.deferredPrompt = null;
this.notifyListeners(false);
// Check if already installed
if (this.isStandalone()) {
console.log('[PWA] App is already running in standalone mode');
}
}
// Clear dismissal state since user installed
this.clearDismissed();
});
/**
* Check if PWA can be installed
*/
public canInstall(): boolean {
return this.installable && this.deferredPrompt !== null;
}
// Check if already installed
if (this.isStandalone()) {
console.log('[PWA] App is already running in standalone mode');
}
}
/**
* Show the browser's install prompt
*
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
*/
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!this.deferredPrompt) {
console.warn('[PWA] Install prompt not available');
return 'unavailable';
}
/**
* Check if PWA can be installed
*/
public canInstall(): boolean {
return this.installable && this.deferredPrompt !== null;
}
try {
await this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
this.deferredPrompt = null;
this.installable = false;
this.notifyListeners(false);
console.log(`[PWA] Install prompt ${outcome}`);
return outcome;
} catch (error) {
console.error('[PWA] Install prompt failed:', error);
return 'dismissed';
}
}
/**
* Show the browser's install prompt
*
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
*/
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!this.deferredPrompt) {
console.warn('[PWA] Install prompt not available');
return 'unavailable';
}
/**
* Register a callback for install state changes
*
* @param callback Function to call when install state changes
* @returns Unsubscribe function
*/
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
this.listeners.push(callback);
// Call immediately with current state
callback(this.canInstall());
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
try {
await this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
/**
* Notify all listeners of state change
*/
private notifyListeners(canInstall: boolean): void {
this.listeners.forEach(callback => {
try {
callback(canInstall);
} catch (error) {
console.error('[PWA] Error in install state listener:', error);
}
});
}
this.deferredPrompt = null;
this.installable = false;
this.notifyListeners(false);
/**
* Check if app is running in standalone mode (already installed)
*/
public isStandalone(): boolean {
if (!browser) return false;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
console.log(`[PWA] Install prompt ${outcome}`);
return outcome;
} catch (error) {
console.error('[PWA] Install prompt failed:', error);
return 'dismissed';
}
}
/**
* Check if user has dismissed the install prompt
*/
public isDismissed(): boolean {
if (!browser) return false;
return localStorage.getItem('pwa-install-dismissed') === 'true';
}
/**
* Register a callback for install state changes
*
* @param callback Function to call when install state changes
* @returns Unsubscribe function
*/
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
this.listeners.push(callback);
/**
* Mark install prompt as dismissed by user
*/
public setDismissed(): void {
if (browser) {
localStorage.setItem('pwa-install-dismissed', 'true');
console.log('[PWA] Install prompt dismissed by user');
}
}
// Call immediately with current state
callback(this.canInstall());
/**
* Clear dismissal state (called when app is installed)
*/
public clearDismissed(): void {
if (browser) {
localStorage.removeItem('pwa-install-dismissed');
}
}
return () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
};
}
/**
* Get browser-specific installation instructions
*/
public getInstallInstructions(): string {
if (!browser) return 'Install instructions not available';
/**
* Notify all listeners of state change
*/
private notifyListeners(canInstall: boolean): void {
this.listeners.forEach((callback) => {
try {
callback(canInstall);
} catch (error) {
console.error('[PWA] Error in install state listener:', error);
}
});
}
const userAgent = navigator.userAgent.toLowerCase();
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
return 'Tap the Share button and select "Add to Home Screen"';
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('firefox')) {
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
}
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
}
/**
* Check if app is running in standalone mode (already installed)
*/
public isStandalone(): boolean {
if (!browser) return false;
/**
* Get current browser name for UI customization
*/
public getBrowserName(): string {
if (!browser) return 'unknown';
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
if (userAgent.includes('firefox')) return 'firefox';
if (userAgent.includes('edg')) return 'edge';
return 'unknown';
}
/**
* Check if user has dismissed the install prompt
*/
public isDismissed(): boolean {
if (!browser) return false;
return localStorage.getItem('pwa-install-dismissed') === 'true';
}
/**
* Mark install prompt as dismissed by user
*/
public setDismissed(): void {
if (browser) {
localStorage.setItem('pwa-install-dismissed', 'true');
console.log('[PWA] Install prompt dismissed by user');
}
}
/**
* Clear dismissal state (called when app is installed)
*/
public clearDismissed(): void {
if (browser) {
localStorage.removeItem('pwa-install-dismissed');
}
}
/**
* Get browser-specific installation instructions
*/
public getInstallInstructions(): string {
if (!browser) return 'Install instructions not available';
const userAgent = navigator.userAgent.toLowerCase();
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
return 'Tap the Share button and select "Add to Home Screen"';
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('edg')) {
return 'Look for the install button in your browser address bar';
} else if (userAgent.includes('firefox')) {
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
}
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
}
/**
* Get current browser name for UI customization
*/
public getBrowserName(): string {
if (!browser) return 'unknown';
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
if (userAgent.includes('firefox')) return 'firefox';
if (userAgent.includes('edg')) return 'edge';
return 'unknown';
}
}
// Singleton instance for application-wide use
export const pwaInstallManager = new PWAInstallManager();
export const pwaInstallManager = new PWAInstallManager();

View File

@@ -1,379 +1,371 @@
/**
* Client-side Push Notification Manager
*
*
* Handles push notification subscription/unsubscription
* and permission management in the browser.
*
*
* SSR-Safe: All browser API access is guarded and lazily initialized
*/
import { browser } from '$app/environment';
interface NotificationState {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
}
class PushNotificationManager {
private state: NotificationState = {
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
};
private state: NotificationState = {
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
};
private listeners: Array<(state: NotificationState) => void> = [];
private registration: ServiceWorkerRegistration | null = null;
private _clientId: string | null = null;
private _initialized = false;
private listeners: Array<(state: NotificationState) => void> = [];
private registration: ServiceWorkerRegistration | null = null;
private _clientId: string | null = null;
private _initialized = false;
constructor() {
// SSR-safe constructor: no browser API access
// Initialization happens lazily when needed
}
constructor() {
// SSR-safe constructor: no browser API access
// Initialization happens lazily when needed
}
/**
* Lazy initialization - only runs in browser context
*/
private ensureInitialized(): void {
if (this._initialized || !browser) return;
this._initialized = true;
this.checkSupport();
this.initializeServiceWorker();
}
/**
* Lazy initialization - only runs in browser context
*/
private ensureInitialized(): void {
if (this._initialized || !browser) return;
/**
* Get clientId lazily - only generates in browser context
*/
private get clientId(): string {
if (!this._clientId && browser) {
this._clientId = this.generateClientId();
}
return this._clientId || 'ssr-fallback';
}
this._initialized = true;
this.checkSupport();
this.initializeServiceWorker();
}
/**
* Subscribe to state changes
*/
onStateChange(callback: (state: NotificationState) => void): () => void {
this.ensureInitialized(); // Ensure initialized before sending state
this.listeners.push(callback);
callback(this.state); // Send initial state
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
/**
* Get clientId lazily - only generates in browser context
*/
private get clientId(): string {
if (!this._clientId && browser) {
this._clientId = this.generateClientId();
}
return this._clientId || 'ssr-fallback';
}
/**
* Get current state
*/
getState(): NotificationState {
this.ensureInitialized();
return { ...this.state };
}
/**
* Subscribe to state changes
*/
onStateChange(callback: (state: NotificationState) => void): () => void {
this.ensureInitialized(); // Ensure initialized before sending state
/**
* Check if push notifications are supported
* SSR-safe: guarded with browser check
*/
private checkSupport(): void {
if (!browser) {
this.state.supported = false;
this.state.permission = 'denied';
return;
}
this.state.supported = (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
this.state.permission = this.state.supported ? Notification.permission : 'denied';
}
this.listeners.push(callback);
callback(this.state); // Send initial state
/**
* Initialize service worker registration
* SSR-safe: guarded with browser and support checks
*/
private async initializeServiceWorker(): Promise<void> {
if (!browser || !this.state.supported) return;
return () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
};
}
try {
// Wait for service worker to be ready
this.registration = await navigator.serviceWorker.ready;
console.log('[PushManager] Service worker ready');
// Check if already subscribed
const subscription = await this.registration.pushManager.getSubscription();
this.state.subscribed = !!subscription;
this.notifyListeners();
} catch (error) {
console.error('[PushManager] Service worker initialization failed:', error);
this.state.error = 'Service worker not available';
this.notifyListeners();
}
}
/**
* Get current state
*/
getState(): NotificationState {
this.ensureInitialized();
return { ...this.state };
}
/**
* Request notification permission
*/
async requestPermission(): Promise<boolean> {
this.ensureInitialized();
if (!browser || !this.state.supported) {
this.state.error = 'Push notifications not supported';
this.notifyListeners();
return false;
}
/**
* Check if push notifications are supported
* SSR-safe: guarded with browser check
*/
private checkSupport(): void {
if (!browser) {
this.state.supported = false;
this.state.permission = 'denied';
return;
}
if (this.state.permission === 'granted') {
return true;
}
this.state.supported =
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
try {
this.state.loading = true;
this.notifyListeners();
this.state.permission = this.state.supported ? Notification.permission : 'denied';
}
const permission = await Notification.requestPermission();
this.state.permission = permission;
this.state.error = permission === 'denied' ? 'Permission denied' : null;
this.state.loading = false;
this.notifyListeners();
return permission === 'granted';
} catch (error) {
console.error('[PushManager] Permission request failed:', error);
this.state.error = 'Failed to request permission';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Initialize service worker registration
* SSR-safe: guarded with browser and support checks
*/
private async initializeServiceWorker(): Promise<void> {
if (!browser || !this.state.supported) return;
/**
* Subscribe to push notifications
*/
async subscribe(): Promise<boolean> {
if (!await this.requestPermission()) {
return false;
}
try {
// Wait for service worker to be ready
this.registration = await navigator.serviceWorker.ready;
console.log('[PushManager] Service worker ready');
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
// Check if already subscribed
const subscription = await this.registration.pushManager.getSubscription();
this.state.subscribed = !!subscription;
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
this.notifyListeners();
} catch (error) {
console.error('[PushManager] Service worker initialization failed:', error);
this.state.error = 'Service worker not available';
this.notifyListeners();
}
}
// Get VAPID public key from server
const vapidResponse = await fetch('/api/notifications/vapid-key');
if (!vapidResponse.ok) {
throw new Error('Failed to get VAPID key');
}
const { publicKey } = await vapidResponse.json();
// Create push subscription
const subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(publicKey)
});
/**
* Request notification permission
*/
async requestPermission(): Promise<boolean> {
this.ensureInitialized();
// Send subscription to server
const subscribeResponse = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription.toJSON(),
clientId: this.clientId
})
});
if (!browser || !this.state.supported) {
this.state.error = 'Push notifications not supported';
this.notifyListeners();
return false;
}
if (!subscribeResponse.ok) {
throw new Error('Failed to register subscription with server');
}
if (this.state.permission === 'granted') {
return true;
}
this.state.subscribed = true;
this.state.loading = false;
this.notifyListeners();
console.log('[PushManager] Successfully subscribed to push notifications');
return true;
try {
this.state.loading = true;
this.notifyListeners();
} catch (error) {
console.error('[PushManager] Subscription failed:', error);
this.state.error = 'Failed to subscribe to notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
const permission = await Notification.requestPermission();
this.state.permission = permission;
this.state.error = permission === 'denied' ? 'Permission denied' : null;
/**
* Unsubscribe from push notifications
*/
async unsubscribe(): Promise<boolean> {
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
this.state.loading = false;
this.notifyListeners();
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
return permission === 'granted';
} catch (error) {
console.error('[PushManager] Permission request failed:', error);
this.state.error = 'Failed to request permission';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
// Get current subscription
const subscription = await this.registration.pushManager.getSubscription();
if (subscription) {
// Unsubscribe from push service
await subscription.unsubscribe();
// Remove from server
await fetch('/api/notifications/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
clientId: this.clientId
})
});
}
/**
* Subscribe to push notifications
*/
async subscribe(): Promise<boolean> {
if (!(await this.requestPermission())) {
return false;
}
this.state.subscribed = false;
this.state.loading = false;
this.notifyListeners();
console.log('[PushManager] Successfully unsubscribed from push notifications');
return true;
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
} catch (error) {
console.error('[PushManager] Unsubscription failed:', error);
this.state.error = 'Failed to unsubscribe from notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
/**
* Toggle subscription state
*/
async toggleSubscription(): Promise<boolean> {
if (this.state.subscribed) {
return await this.unsubscribe();
} else {
return await this.subscribe();
}
}
// Get VAPID public key from server
const vapidResponse = await fetch('/api/notifications/vapid-key');
if (!vapidResponse.ok) {
throw new Error('Failed to get VAPID key');
}
/**
* Generate unique client ID
* SSR-safe: guarded with browser check, uses localStorage only in browser
*/
private generateClientId(): string {
if (!browser) return '';
const stored = localStorage.getItem('push-client-id');
if (stored) return stored;
const { publicKey } = await vapidResponse.json();
const id = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('push-client-id', id);
return id;
}
// Create push subscription
const subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(publicKey)
});
/**
* Convert URL-safe base64 string to Uint8Array
* Enhanced with validation and error handling for VAPID keys
* SSR-safe: uses window.atob only in browser context
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
if (!browser) {
return new Uint8Array(0);
}
// Send subscription to server
const subscribeResponse = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription.toJSON(),
clientId: this.clientId
})
});
// Input validation
if (!base64String || typeof base64String !== 'string') {
console.error('[PushManager] Invalid VAPID key: empty or non-string');
return new Uint8Array(0);
}
if (!subscribeResponse.ok) {
throw new Error('Failed to register subscription with server');
}
// Remove whitespace and validate format
const cleanKey = base64String.trim();
if (cleanKey.length === 0) {
console.error('[PushManager] Invalid VAPID key: empty string');
return new Uint8Array(0);
}
this.state.subscribed = true;
this.state.loading = false;
this.notifyListeners();
// VAPID keys should be 65 characters (unpadded base64)
if (cleanKey.length !== 65) {
console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`);
}
console.log('[PushManager] Successfully subscribed to push notifications');
return true;
} catch (error) {
console.error('[PushManager] Subscription failed:', error);
this.state.error = 'Failed to subscribe to notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
try {
// Add proper padding
const padding = '='.repeat((4 - cleanKey.length % 4) % 4);
const base64 = (cleanKey + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
/**
* Unsubscribe from push notifications
*/
async unsubscribe(): Promise<boolean> {
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
// Validate base64 format before decoding
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
if (!base64Regex.test(base64)) {
throw new Error('Invalid base64 characters');
}
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
// Get current subscription
const subscription = await this.registration.pushManager.getSubscription();
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
if (subscription) {
// Unsubscribe from push service
await subscription.unsubscribe();
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
return outputArray;
// Remove from server
await fetch('/api/notifications/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
clientId: this.clientId
})
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
throw new Error(`Invalid VAPID key format: ${errorMessage}`);
}
}
this.state.subscribed = false;
this.state.loading = false;
this.notifyListeners();
/**
* Notify all listeners of state change
*/
private notifyListeners(): void {
this.listeners.forEach(callback => {
try {
callback({ ...this.state });
} catch (error) {
console.error('[PushManager] Listener error:', error);
}
});
}
console.log('[PushManager] Successfully unsubscribed from push notifications');
return true;
} catch (error) {
console.error('[PushManager] Unsubscription failed:', error);
this.state.error = 'Failed to unsubscribe from notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Toggle subscription state
*/
async toggleSubscription(): Promise<boolean> {
if (this.state.subscribed) {
return await this.unsubscribe();
} else {
return await this.subscribe();
}
}
/**
* Generate unique client ID
* SSR-safe: guarded with browser check, uses localStorage only in browser
*/
private generateClientId(): string {
if (!browser) return '';
const stored = localStorage.getItem('push-client-id');
if (stored) return stored;
const id = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('push-client-id', id);
return id;
}
/**
* Convert URL-safe base64 string to Uint8Array
* Enhanced with validation and error handling for VAPID keys
* SSR-safe: uses window.atob only in browser context
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
if (!browser) {
return new Uint8Array(0);
}
// Input validation
if (!base64String || typeof base64String !== 'string') {
console.error('[PushManager] Invalid VAPID key: empty or non-string');
return new Uint8Array(0);
}
// Remove whitespace and validate format
const cleanKey = base64String.trim();
if (cleanKey.length === 0) {
console.error('[PushManager] Invalid VAPID key: empty string');
return new Uint8Array(0);
}
// VAPID keys should be 65 characters (unpadded base64)
if (cleanKey.length !== 65) {
console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`);
}
try {
// Add proper padding
const padding = '='.repeat((4 - (cleanKey.length % 4)) % 4);
const base64 = (cleanKey + padding).replace(/-/g, '+').replace(/_/g, '/');
// Validate base64 format before decoding
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
if (!base64Regex.test(base64)) {
throw new Error('Invalid base64 characters');
}
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
return outputArray;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey);
throw new Error(`Invalid VAPID key format: ${errorMessage}`);
}
}
/**
* Notify all listeners of state change
*/
private notifyListeners(): void {
this.listeners.forEach((callback) => {
try {
callback({ ...this.state });
} catch (error) {
console.error('[PushManager] Listener error:', error);
}
});
}
}
// Singleton instance
export const pushNotificationManager = new PushNotificationManager();
export type { NotificationState };
export type { NotificationState };

View File

@@ -1,201 +1,201 @@
/**
* Service Worker Message Handler
*
*
* Handles messages from service worker (like notification actions)
* and coordinates with the main application.
*/
import { pushState } from "$app/navigation";
import { pushState } from '$app/navigation';
interface ServiceWorkerMessage {
type: string;
action?: string;
data?: any;
type: string;
action?: string;
data?: any;
}
class ServiceWorkerMessageHandler {
private retryCallbacks = new Map<string, () => void>();
private retryCallbacks = new Map<string, () => void>();
constructor() {
this.initializeMessageListener();
}
constructor() {
this.initializeMessageListener();
}
/**
* Listen for messages from service worker
*/
private initializeMessageListener(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
}
}
/**
* Listen for messages from service worker
*/
private initializeMessageListener(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
}
}
/**
* Handle messages from service worker
*/
private handleMessage(message: ServiceWorkerMessage): void {
console.log('[SW-Handler] Message received:', message);
/**
* Handle messages from service worker
*/
private handleMessage(message: ServiceWorkerMessage): void {
console.log('[SW-Handler] Message received:', message);
switch (message.type) {
case 'notification-action':
this.handleNotificationAction(message.action, message.data);
break;
default:
console.log('[SW-Handler] Unknown message type:', message.type);
}
}
switch (message.type) {
case 'notification-action':
this.handleNotificationAction(message.action, message.data);
break;
/**
* Handle notification action clicks
*/
private handleNotificationAction(action: string | undefined, data: any): void {
if (!action || !data?.itemId) {
console.warn('[SW-Handler] Invalid notification action:', { action, data });
return;
}
default:
console.log('[SW-Handler] Unknown message type:', message.type);
}
}
switch (action) {
case 'view':
this.handleViewAction(data.itemId);
break;
case 'retry':
this.handleRetryAction(data.itemId);
break;
default:
console.log('[SW-Handler] Unknown notification action:', action);
}
}
/**
* Handle notification action clicks
*/
private handleNotificationAction(action: string | undefined, data: any): void {
if (!action || !data?.itemId) {
console.warn('[SW-Handler] Invalid notification action:', { action, data });
return;
}
/**
* Handle "view" action - scroll to item and highlight
*/
private handleViewAction(itemId: string): void {
console.log('[SW-Handler] View action for item:', itemId);
// Find the queue item card and scroll to it
const element = document.querySelector(`[data-queue-item="${itemId}"]`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
// Add temporary highlight effect
element.classList.add('ring-2', 'ring-blue-500');
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-500');
}, 3000);
} else {
// If not found, navigate to homepage with highlight
const url = new URL(window.location.href);
url.searchParams.set('highlight', itemId);
pushState(url, {});
// Refresh page to show the item
//window.location.reload();
}
}
switch (action) {
case 'view':
this.handleViewAction(data.itemId);
break;
/**
* Handle "retry" action - trigger retry for failed item
*/
private async handleRetryAction(itemId: string): Promise<void> {
console.log('[SW-Handler] Retry action for item:', itemId);
// Check if there's a registered callback
const callback = this.retryCallbacks.get(itemId);
if (callback) {
callback();
return;
}
case 'retry':
this.handleRetryAction(data.itemId);
break;
// Fallback: direct API call
try {
const response = await fetch(`/api/queue/${itemId}/retry`, {
method: 'POST'
});
if (response.ok) {
console.log('[SW-Handler] Retry initiated via API');
// Show user feedback
this.showRetryFeedback(true);
} else {
throw new Error('Retry request failed');
}
} catch (error) {
console.error('[SW-Handler] Retry failed:', error);
this.showRetryFeedback(false);
}
}
default:
console.log('[SW-Handler] Unknown notification action:', action);
}
}
/**
* Register retry callback for a queue item
*/
registerRetryCallback(itemId: string, callback: () => void): void {
this.retryCallbacks.set(itemId, callback);
}
/**
* Handle "view" action - scroll to item and highlight
*/
private handleViewAction(itemId: string): void {
console.log('[SW-Handler] View action for item:', itemId);
/**
* Unregister retry callback
*/
unregisterRetryCallback(itemId: string): void {
this.retryCallbacks.delete(itemId);
}
// Find the queue item card and scroll to it
const element = document.querySelector(`[data-queue-item="${itemId}"]`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
/**
* Show retry feedback to user
*/
private showRetryFeedback(success: boolean): void {
// Create temporary toast notification
const toast = document.createElement('div');
toast.className = `fixed bottom-4 left-4 px-4 py-2 rounded-lg text-white text-sm font-medium z-50 ${
success ? 'bg-green-600' : 'bg-red-600'
}`;
toast.textContent = success
? 'Retry initiated - check the queue for updates'
: 'Failed to retry - please try again manually';
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
document.body.removeChild(toast);
}, 5000);
}
// Add temporary highlight effect
element.classList.add('ring-2', 'ring-blue-500');
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-500');
}, 3000);
} else {
// If not found, navigate to homepage with highlight
const url = new URL(window.location.href);
url.searchParams.set('highlight', itemId);
pushState(url, {});
/**
* Send message to service worker
*/
async sendMessageToSW(message: any): Promise<any> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service worker not supported');
}
// Refresh page to show the item
//window.location.reload();
}
}
const registration = await navigator.serviceWorker.ready;
if (!registration.active) {
throw new Error('Service worker not active');
}
/**
* Handle "retry" action - trigger retry for failed item
*/
private async handleRetryAction(itemId: string): Promise<void> {
console.log('[SW-Handler] Retry action for item:', itemId);
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data);
};
// Check if there's a registered callback
const callback = this.retryCallbacks.get(itemId);
if (callback) {
callback();
return;
}
registration.active?.postMessage(message, [channel.port2]);
// Fallback: direct API call
try {
const response = await fetch(`/api/queue/${itemId}/retry`, {
method: 'POST'
});
// Timeout after 5 seconds
setTimeout(() => {
reject(new Error('Service worker message timeout'));
}, 5000);
});
}
if (response.ok) {
console.log('[SW-Handler] Retry initiated via API');
// Show user feedback
this.showRetryFeedback(true);
} else {
throw new Error('Retry request failed');
}
} catch (error) {
console.error('[SW-Handler] Retry failed:', error);
this.showRetryFeedback(false);
}
}
/**
* Register retry callback for a queue item
*/
registerRetryCallback(itemId: string, callback: () => void): void {
this.retryCallbacks.set(itemId, callback);
}
/**
* Unregister retry callback
*/
unregisterRetryCallback(itemId: string): void {
this.retryCallbacks.delete(itemId);
}
/**
* Show retry feedback to user
*/
private showRetryFeedback(success: boolean): void {
// Create temporary toast notification
const toast = document.createElement('div');
toast.className = `fixed bottom-4 left-4 px-4 py-2 rounded-lg text-white text-sm font-medium z-50 ${
success ? 'bg-green-600' : 'bg-red-600'
}`;
toast.textContent = success
? 'Retry initiated - check the queue for updates'
: 'Failed to retry - please try again manually';
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
document.body.removeChild(toast);
}, 5000);
}
/**
* Send message to service worker
*/
async sendMessageToSW(message: any): Promise<any> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service worker not supported');
}
const registration = await navigator.serviceWorker.ready;
if (!registration.active) {
throw new Error('Service worker not active');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data);
};
registration.active?.postMessage(message, [channel.port2]);
// Timeout after 5 seconds
setTimeout(() => {
reject(new Error('Service worker message timeout'));
}, 5000);
});
}
}
// Singleton instance
export const serviceWorkerMessageHandler = new ServiceWorkerMessageHandler();
export const serviceWorkerMessageHandler = new ServiceWorkerMessageHandler();