92a03aeafd
页面 / 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(文件内留有警示)。
208 lines
7.3 KiB
TypeScript
208 lines
7.3 KiB
TypeScript
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<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;
|
||
}
|