import { apiFetch } from "./api"; import { isDesktop } from "./desktop"; import { getLocale } from "../i18n"; import { useAppStore } from "../store"; // Web Push 订阅管理。仅浏览器 / PWA——桌面壳走原生通知,不在此列。 // // 分工:「页面打开→应用内 toast,页面关闭→系统通知」的判定在服务端(Hub.SendTo // 投递失败即推 Web Push,见后端 internal/push)。前端这里只做三件事:把本设备的 // 推送端点登记到服务端(按设备名定位收件设备)、开关订阅、登录后重新登记。 interface VapidKeyResp { enabled: boolean; public_key: string; } export interface PushState { supported: boolean; permission: NotificationPermission; // "default" | "granted" | "denied" subscribed: boolean; // 浏览器已持有 pushManager 订阅 } // pushSupported:需 SW + PushManager + Notification 三者俱全、非桌面壳、且为 prod // 构建(SW 仅在 prod 注册,见 main.tsx;dev 下无 SW,serviceWorker.ready 永不 // resolve)。任一不满足即视为不支持,设置页隐藏入口。 export function pushSupported(): boolean { return import.meta.env.PROD && !isDesktop() && typeof navigator !== "undefined" && "serviceWorker" in navigator && typeof window !== "undefined" && "PushManager" in window && "Notification" in window; } function permissionNow(): NotificationPermission { return typeof Notification !== "undefined" ? Notification.permission : "default"; } async function registration(): Promise { if (!pushSupported()) { return null; } try { return await navigator.serviceWorker.ready; } catch { return null; } } async function existingSubscription(): Promise { const reg = await registration(); if (!reg) { return null; } try { return await reg.pushManager.getSubscription(); } catch { return null; } } // currentPushState:供设置页初始化展示当前订阅 / 权限状态。 export async function currentPushState(): Promise { if (!pushSupported()) { return { supported: false, permission: "default", subscribed: false }; } const sub = await existingSubscription(); return { supported: true, permission: permissionNow(), subscribed: sub !== null }; } async function fetchVapidKey(): Promise { try { const r = await apiFetch("/api/push/vapid-key"); if (!r.ok) { return null; } return await r.json() as VapidKeyResp; } catch { return null; } } // postSubscription:把浏览器订阅 + 当前 locale 登记到服务端(apiFetch 带上 // X-Device-Name,服务端按设备名归属)。服务端以 endpoint 的哈希为主键 upsert, // 故重复登记 / 换用户 / locale 变更都幂等覆盖。 async function postSubscription(sub: PushSubscription): Promise { const json = sub.toJSON(); if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { return false; } try { const r = await apiFetch("/api/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: json.endpoint, keys: { p256dh: json.keys.p256dh, auth: json.keys.auth }, locale: getLocale(), }), }); return r.ok; } catch { return false; } } async function deleteServerSubscription(endpoint: string): Promise { try { await apiFetch("/api/push/subscribe", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint }), }); } catch { /* ignore:服务端订阅清理失败只是体验降级 */ } } // enablePush:用户在设置里开启。请求通知权限 → 创建(或复用)浏览器订阅 → 登记 // 服务端。返回最终状态;权限被拒 / 服务端未配置 VAPID / 订阅失败时 subscribed=false。 export async function enablePush(): Promise { if (!pushSupported()) { return { supported: false, permission: "default", subscribed: false }; } let perm = permissionNow(); if (perm === "default") { try { perm = await Notification.requestPermission(); } catch { perm = permissionNow(); } } useAppStore.getState().setPushPermission(perm); if (perm !== "granted") { return { supported: true, permission: perm, subscribed: false }; } const vapid = await fetchVapidKey(); if (!vapid?.enabled || !vapid.public_key) { return { supported: true, permission: perm, subscribed: false }; } const reg = await registration(); if (!reg) { return { supported: true, permission: perm, subscribed: false }; } let sub = await reg.pushManager.getSubscription(); if (!sub) { try { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapid.public_key), }); } catch { return { supported: true, permission: perm, subscribed: false }; } } const ok = await postSubscription(sub); useAppStore.getState().setPushSubscribed(ok); return { supported: true, permission: perm, subscribed: ok }; } // disablePush:用户在设置里关闭。删服务端 + 退订浏览器,使再次登录的 resyncPush // 不会自动恢复(浏览器无订阅即视为用户不想要)。 export async function disablePush(): Promise { const sub = await existingSubscription(); if (sub) { await deleteServerSubscription(sub.endpoint); try { await sub.unsubscribe(); } catch { /* ignore */ } } useAppStore.getState().setPushSubscribed(false); return { supported: pushSupported(), permission: permissionNow(), subscribed: false }; } // resyncPush:登录后(root effect 内)调用。若浏览器已有订阅且权限在握,把它重新 // 登记到当前用户名下——让「同一浏览器换用户登录」「endpoint 轮换」「切换 locale」 // 自愈,且无需再弹权限。无订阅则不动(用户从未开启推送)。fire-and-forget。 export async function resyncPush(): Promise { if (!pushSupported() || permissionNow() !== "granted") { return; } const sub = await existingSubscription(); if (!sub) { return; } const ok = await postSubscription(sub); useAppStore.getState().setPushSubscribed(ok); } // urlBase64ToUint8Array:把 VAPID base64url 公钥转成 applicationServerKey 需要的 // 字节数组(标准 Web Push 样板)。 function urlBase64ToUint8Array(base64: string): Uint8Array { const padding = "=".repeat((4 - (base64.length % 4)) % 4); const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(b64); // Back the view with a concrete ArrayBuffer (not ArrayBufferLike) so it // satisfies BufferSource for applicationServerKey under TS 5.7 strict typing. const out = new Uint8Array(new ArrayBuffer(raw.length)); for (let i = 0; i < raw.length; i += 1) { out[i] = raw.charCodeAt(i); } return out; }