feat: fix push notifications and enhance PWA experience
- Fix InvalidCharacterError in push notifications with proper VAPID key validation - Add attractive PWA install prompt component with cross-browser support - Make notification settings always visible regardless of queue status - Implement PWA install manager with user engagement detection - Use SvelteKit navigation APIs instead of browser history API - Add comprehensive error handling and logging - Include cross-browser compatibility and responsive design - Add development tooling improvements Fixes push notification bugs and significantly improves PWA user experience with modern, accessible interface components and proper error handling.
This commit is contained in:
201
src/lib/client/PWAInstallManager.ts
Normal file
201
src/lib/client/PWAInstallManager.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
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;
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
// Check if already installed
|
||||
if (this.isStandalone()) {
|
||||
console.log('[PWA] App is already running in standalone mode');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if PWA can be installed
|
||||
*/
|
||||
public canInstall(): boolean {
|
||||
return this.installable && this.deferredPrompt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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://')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
@@ -302,26 +302,61 @@ class PushNotificationManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert VAPID key to Uint8Array
|
||||
* 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 {
|
||||
if (!browser) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
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);
|
||||
// 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}`);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* and coordinates with the main application.
|
||||
*/
|
||||
|
||||
import { pushState } from "$app/navigation";
|
||||
|
||||
interface ServiceWorkerMessage {
|
||||
type: string;
|
||||
action?: string;
|
||||
@@ -91,10 +93,10 @@ class ServiceWorkerMessageHandler {
|
||||
// If not found, navigate to homepage with highlight
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('highlight', itemId);
|
||||
window.history.pushState({}, '', url.toString());
|
||||
pushState(url, {});
|
||||
|
||||
// Refresh page to show the item
|
||||
window.location.reload();
|
||||
//window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const queueConfig = {
|
||||
|
||||
/** Web Push notification settings */
|
||||
push: {
|
||||
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BDummyPublicKeyForDevelopment',
|
||||
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'DummyPrivateKeyForDevelopment'
|
||||
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
|
||||
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680'
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user