File size: 8,464 Bytes
793e953
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
/**
 * 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<string> {
  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<ServiceWorkerRegistration | null> {
  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<NotificationPermission> {
  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<PushSubscriptionJSON> {
  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<boolean> {
  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<PushSubscriptionJSON | null> {
  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<void> {
  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<void> {
  try {
    await unsubscribePush(endpoint)
    console.log('[Push] 后端订阅已取消')
  } catch (err) {
    console.error('[Push] 后端取消订阅失败:', err)
    throw err
  }
}