- Replace random key generation with the web-push library for generating cryptographically secure VAPID keys. - Update console output to guide users on adding keys to their environment configuration. - Enhance error handling for VAPID key generation issues. - Add web-push dependency to package.json and package-lock.json for proper functionality.
239 lines
No EOL
7 KiB
TypeScript
239 lines
No EOL
7 KiB
TypeScript
// Push notification service for browser push API
|
|
import { config, configUtils } from '@/lib/config'
|
|
|
|
export interface PushSubscriptionData {
|
|
endpoint: string
|
|
keys: {
|
|
p256dh: string
|
|
auth: string
|
|
}
|
|
}
|
|
|
|
export interface NotificationPayload {
|
|
title: string
|
|
body: string
|
|
icon?: string
|
|
badge?: string
|
|
data?: Record<string, any>
|
|
tag?: string
|
|
requireInteraction?: boolean
|
|
actions?: Array<{
|
|
action: string
|
|
title: string
|
|
icon?: string
|
|
}>
|
|
}
|
|
|
|
export class PushNotificationService {
|
|
private static instance: PushNotificationService
|
|
|
|
static getInstance(): PushNotificationService {
|
|
if (!this.instance) {
|
|
this.instance = new PushNotificationService()
|
|
}
|
|
return this.instance
|
|
}
|
|
|
|
// Check if push notifications are supported
|
|
isSupported(): boolean {
|
|
return 'serviceWorker' in navigator &&
|
|
'PushManager' in window &&
|
|
'Notification' in window
|
|
}
|
|
|
|
// Check if push notifications are configured
|
|
isConfigured(): boolean {
|
|
return configUtils.hasPushConfig()
|
|
}
|
|
|
|
// Get current notification permission
|
|
getPermission(): NotificationPermission {
|
|
return Notification.permission
|
|
}
|
|
|
|
// Request notification permission
|
|
async requestPermission(): Promise<NotificationPermission> {
|
|
if (!this.isSupported()) {
|
|
throw new Error('Push notifications are not supported')
|
|
}
|
|
|
|
const permission = await Notification.requestPermission()
|
|
console.log('Notification permission:', permission)
|
|
return permission
|
|
}
|
|
|
|
// Convert Uint8Array to base64 URL-safe string
|
|
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
|
|
}
|
|
|
|
// Subscribe to push notifications
|
|
async subscribe(): Promise<PushSubscriptionData> {
|
|
if (!this.isSupported()) {
|
|
throw new Error('Push notifications are not supported')
|
|
}
|
|
|
|
if (!this.isConfigured()) {
|
|
throw new Error('Push notifications are not configured. Missing VAPID key.')
|
|
}
|
|
|
|
if (this.getPermission() !== 'granted') {
|
|
const permission = await this.requestPermission()
|
|
if (permission !== 'granted') {
|
|
throw new Error('Push notification permission denied')
|
|
}
|
|
}
|
|
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready
|
|
|
|
// Check if already subscribed
|
|
const existingSubscription = await registration.pushManager.getSubscription()
|
|
if (existingSubscription) {
|
|
console.log('Using existing push subscription')
|
|
return this.subscriptionToData(existingSubscription)
|
|
}
|
|
|
|
// Create new subscription
|
|
console.log('Creating new push subscription with VAPID key:', config.push.vapidPublicKey.substring(0, 20) + '...')
|
|
|
|
const applicationServerKey = this.urlBase64ToUint8Array(config.push.vapidPublicKey)
|
|
console.log('VAPID key converted to Uint8Array, length:', applicationServerKey.length)
|
|
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey
|
|
})
|
|
|
|
const subscriptionData = this.subscriptionToData(subscription)
|
|
|
|
console.log('Push subscription created successfully:', {
|
|
endpoint: subscriptionData.endpoint,
|
|
hasKeys: !!subscriptionData.keys.p256dh && !!subscriptionData.keys.auth
|
|
})
|
|
return subscriptionData
|
|
|
|
} catch (error) {
|
|
console.error('Failed to subscribe to push notifications:', error)
|
|
|
|
// Provide more specific error information
|
|
if (error instanceof DOMException) {
|
|
if (error.name === 'InvalidStateError') {
|
|
console.error('VAPID key format issue detected. Please check your VAPID public key.')
|
|
} else if (error.name === 'NotAllowedError') {
|
|
console.error('Push notifications blocked by user or browser policy.')
|
|
} else if (error.name === 'NotSupportedError') {
|
|
console.error('Push notifications not supported by this browser.')
|
|
}
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Unsubscribe from push notifications
|
|
async unsubscribe(): Promise<void> {
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready
|
|
const subscription = await registration.pushManager.getSubscription()
|
|
|
|
if (subscription) {
|
|
await subscription.unsubscribe()
|
|
console.log('Push subscription removed')
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to unsubscribe from push notifications:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Get current subscription
|
|
async getSubscription(): Promise<PushSubscriptionData | null> {
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready
|
|
const subscription = await registration.pushManager.getSubscription()
|
|
|
|
if (subscription) {
|
|
return this.subscriptionToData(subscription)
|
|
}
|
|
|
|
return null
|
|
} catch (error) {
|
|
console.error('Failed to get push subscription:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Convert PushSubscription to our data format
|
|
private subscriptionToData(subscription: PushSubscription): PushSubscriptionData {
|
|
const keys = subscription.getKey('p256dh')
|
|
const auth = subscription.getKey('auth')
|
|
|
|
if (!keys || !auth) {
|
|
throw new Error('Failed to get subscription keys')
|
|
}
|
|
|
|
return {
|
|
endpoint: subscription.endpoint,
|
|
keys: {
|
|
p256dh: btoa(String.fromCharCode(...new Uint8Array(keys))),
|
|
auth: btoa(String.fromCharCode(...new Uint8Array(auth)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show a local notification (for testing)
|
|
async showLocalNotification(payload: NotificationPayload): Promise<void> {
|
|
if (!this.isSupported()) {
|
|
throw new Error('Notifications are not supported')
|
|
}
|
|
|
|
if (this.getPermission() !== 'granted') {
|
|
await this.requestPermission()
|
|
}
|
|
|
|
if (this.getPermission() !== 'granted') {
|
|
throw new Error('Notification permission denied')
|
|
}
|
|
|
|
// Send message to service worker to show notification
|
|
const registration = await navigator.serviceWorker.ready
|
|
|
|
if (registration.active) {
|
|
registration.active.postMessage({
|
|
type: 'SHOW_NOTIFICATION',
|
|
payload
|
|
})
|
|
} else {
|
|
// Fallback: show browser notification directly
|
|
new Notification(payload.title, {
|
|
body: payload.body,
|
|
icon: payload.icon || '/pwa-192x192.png',
|
|
badge: payload.badge || '/pwa-192x192.png',
|
|
tag: payload.tag,
|
|
requireInteraction: payload.requireInteraction,
|
|
data: payload.data
|
|
})
|
|
}
|
|
}
|
|
|
|
// Check if user is subscribed to push notifications
|
|
async isSubscribed(): Promise<boolean> {
|
|
const subscription = await this.getSubscription()
|
|
return subscription !== null
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const pushService = PushNotificationService.getInstance() |