/** * Push Notification 工具模块 * * 封装浏览器 Push API 的订阅、取消订阅、权限管理等逻辑。 * 所有后端通信委托给 src/api/index.ts 中的 push 相关 API 函数。 */ import { subscribePush, unsubscribePush, getVapidPublicKey, } from '../api/index' // ================================================================ // 常量 // ================================================================ /** Service Worker 注册路径(相对于 public 目录,构建后位于根路径) */ const SW_PATH = '/sw.js' /** 缓存的 VAPID 公钥(从后端动态获取,避免前后端密钥不匹配) */ let _cachedVapidPublicKey: string | null = null /** * 获取 VAPID 公钥(优先缓存,其次云端获取,兜底环境变量) */ async function _getVapidPublicKey(): Promise { if (_cachedVapidPublicKey) return _cachedVapidPublicKey try { _cachedVapidPublicKey = await getVapidPublicKey() console.log('[Push] VAPID 公钥已从后端获取') return _cachedVapidPublicKey! } catch (err) { console.warn('[Push] 无法从后端获取 VAPID 公钥,回退到环境变量:', err) const fallback = import.meta.env.VITE_VAPID_PUBLIC_KEY if (fallback) { _cachedVapidPublicKey = fallback return fallback } throw new Error('VAPID 公钥不可用,推送通知功能暂无法使用') } } // ================================================================ // 工具函数 // ================================================================ /** * 将 URL-safe Base64 字符串转换为 Uint8Array * * Push API 的 applicationServerKey 需要 ArrayBuffer 格式, * 而 VAPID 公钥通常以 URL-safe Base64 存储,此函数完成该转换。 * * @param base64String - URL-safe Base64 编码的 VAPID 公钥 * @returns 对应的 Uint8Array */ export function urlBase64ToUint8Array(base64String: string): Uint8Array { // 补齐 Base64 标准长度所需的 padding 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 } // ================================================================ // Service Worker 注册 // ================================================================ let swRegistration: ServiceWorkerRegistration | null = null /** * 获取或注册 Service Worker。 * * 已注册的实例会被缓存,多次调用返回同一个 registration。 */ export async function getSWRegistration(): Promise { if (swRegistration) { return swRegistration } if (!('serviceWorker' in navigator)) { console.warn('[Push] 当前浏览器不支持 Service Worker') return null } try { swRegistration = await navigator.serviceWorker.register(SW_PATH, { scope: '/', }) console.log('[Push] Service Worker 已注册:', swRegistration.scope) return swRegistration } catch (err) { console.error('[Push] Service Worker 注册失败:', err) return null } } // ================================================================ // 通知权限 // ================================================================ /** * 检查浏览器是否支持 Push Notification。 */ export function isPushSupported(): boolean { return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window } /** * 获取当前通知权限状态。 */ export function getNotificationPermission(): NotificationPermission { if (!('Notification' in window)) { return 'denied' } return Notification.permission } /** * 请求通知权限。 * * @returns 用户选择的权限状态 */ export async function requestNotificationPermission(): Promise { if (!('Notification' in window)) { console.warn('[Push] 当前浏览器不支持 Notification API') return 'denied' } try { const permission = await Notification.requestPermission() console.log('[Push] 通知权限:', permission) return permission } catch (err) { console.error('[Push] 请求通知权限失败:', err) return 'denied' } } // ================================================================ // Push 订阅管理 // ================================================================ /** * 订阅 Push Notification。 * * 流程: * 1. 确认浏览器支持 & 获取 SW registration * 2. 使用 VAPID 公钥创建 push subscription * 3. 将 subscription 序列化后返回 * * @param publicKey - VAPID 公钥(Base64 字符串),不传则使用默认配置 * @returns 订阅对象 { endpoint, keys: { p256dh, auth } } */ export async function subscribeToPush( publicKey?: string, ): Promise { if (!isPushSupported()) { throw new Error('当前浏览器不支持 Push Notification') } const registration = await getSWRegistration() if (!registration) { throw new Error('Service Worker 未注册,无法订阅推送') } const permission = getNotificationPermission() if (permission !== 'granted') { throw new Error('通知权限未授予,无法订阅推送') } const applicationServerKey = urlBase64ToUint8Array( await _getVapidPublicKey(), ) try { const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey as unknown as BufferSource, }) console.log('[Push] 订阅成功:', subscription.endpoint) const subJSON = subscription.toJSON() return subJSON } catch (err) { console.error('[Push] 订阅失败:', err) throw new Error(`Push 订阅失败: ${err instanceof Error ? err.message : String(err)}`) } } /** * 取消 Push Notification 订阅。 * * @returns 是否成功取消 */ export async function unsubscribeFromPush(): Promise { const registration = await getSWRegistration() if (!registration) { console.warn('[Push] Service Worker 未注册,无需取消订阅') return false } try { const subscription = await registration.pushManager.getSubscription() if (!subscription) { console.log('[Push] 当前没有活跃的推送订阅') return false } const result = await subscription.unsubscribe() console.log('[Push] 取消订阅:', result ? '成功' : '失败') return result } catch (err) { console.error('[Push] 取消订阅失败:', err) return false } } /** * 获取当前 Push Subscription。 * * @returns 当前订阅对象的 JSON 表示,无订阅时返回 null */ export async function getPushSubscription(): Promise { const registration = await getSWRegistration() if (!registration) { return null } try { const subscription = await registration.pushManager.getSubscription() if (!subscription) { return null } return subscription.toJSON() } catch (err) { console.error('[Push] 获取订阅状态失败:', err) return null } } // ================================================================ // 后端同步 // ================================================================ /** * 将订阅对象注册到后端。 * * @param subscription - push 订阅 JSON 对象 */ export async function registerSubscriptionWithBackend( subscription: PushSubscriptionJSON, ): Promise { if (!subscription.endpoint) { throw new Error('订阅对象缺少 endpoint') } try { await subscribePush({ endpoint: subscription.endpoint, keys: { p256dh: subscription.keys?.p256dh || '', auth: subscription.keys?.auth || '', }, }) console.log('[Push] 订阅已同步到后端') } catch (err) { console.error('[Push] 后端订阅注册失败:', err) throw err } } /** * 从后端取消注册指定端点的订阅。 * * @param endpoint - push 订阅的 endpoint URL */ export async function unregisterSubscriptionFromBackend( endpoint: string, ): Promise { try { await unsubscribePush(endpoint) console.log('[Push] 后端订阅已取消') } catch (err) { console.error('[Push] 后端取消订阅失败:', err) throw err } }