Files
Commilitia-Drop/web/src/net/push.ts
T
admin 92a03aeafd 推送通知:Web Push(网页 / PWA)+ 桌面原生通知
页面 / PWA 关闭、或桌面窗口非前台时,收到文件、消息、传输完成 / 失败以系统通知提醒。
判定统一为「页面打开走应用内提示,否则系统通知」,三端各自落地;网页端的判定直接复用
Hub.SendTo 的投递结果,零额外状态。

- Web Push(VAPID,账号无关):服务端 internal/push.Sender 在事件未经 SSE 投递到目标
  设备时(即该设备页面已关)发推送,文案按订阅 locale 在服务端渲染(中简 / 中繁 / 英)、
  404 / 410 自动清理死订阅;新增 push_subscriptions 表(含 locale,主键为 endpoint 的
  SHA-256 以幂等 upsert)+ /api/push/{vapid-key, subscribe POST/DELETE} 端点
- 事件挂接:transfer:incoming / message(离线返 202,文本随推送送达)/ transfer:state
  DONE|FAILED,均未投递才推
- 配置 CDROP_VAPID_PUBLIC_KEY / CDROP_VAPID_PRIVATE_KEY(半对即拒启动,都空则推送惰性
  关闭)+ just vapid-keygen 生成工具(cmd/vapidgen)
- 前端:service worker 加 push / notificationclick(仍不拦截 /api 与导航);net/push.ts
  订阅管理 + push store slice + 设置页「推送通知」面板(仅浏览器 / PWA);登录后 resyncPush
  把既有订阅重新登记到当前用户,换用户 / endpoint 轮换 / 切换语言皆自愈
- 桌面原生通知:platform/notify_{darwin,windows,other}.go + notify_darwin.m,仅窗口非
  前台时弹原生通知(macOS UNUserNotificationCenter,未签名时安全空操作、待签名后显示;
  Windows go-toast 即用);app.go ShowNotification 绑定,前端 notifyNativeIfBackground
  复用 hub 事件分发 + 客户端本地化
- 文档:README / .env.example / compose 模板补 CDROP_VAPID_* 配置

注:sqlc v1.31.1 对 query 文件里的多字节字符会错位偏移、生成损坏 SQL,故 query 注释保持纯
ASCII(文件内留有警示)。
2026-06-18 16:56:47 +08:00

208 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.tsxdev 下无 SWserviceWorker.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<ServiceWorkerRegistration | null>
{
if (!pushSupported()) { return null; }
try { return await navigator.serviceWorker.ready; }
catch { return null; }
}
async function existingSubscription(): Promise<PushSubscription | null>
{
const reg = await registration();
if (!reg) { return null; }
try { return await reg.pushManager.getSubscription(); }
catch { return null; }
}
// currentPushState:供设置页初始化展示当前订阅 / 权限状态。
export async function currentPushState(): Promise<PushState>
{
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<VapidKeyResp | null>
{
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<boolean>
{
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<void>
{
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<PushState>
{
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<PushState>
{
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<void>
{
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<ArrayBuffer>
{
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;
}