Files
clawpanel/src/lib/push-web.js
晴天 e717a7a098 feat(openclaw): P1-0 push.web.* 推送通知 - ClawPanel 关掉也能弹系统通知
OpenClaw 内核已实现 4 个 push.web.* RPC(vapidPublicKey / subscribe / unsubscribe / test),
但 ClawPanel 完全没接。这次打通整条链路:浏览器 → Service Worker → 内核 → 系统通知中心。

## 收益(用户视角)
- ClawPanel 浏览器标签/桌面应用关掉后,Agent / Cron / 渠道消息仍能弹到
  Windows / macOS / iOS / Android 系统通知中心
- 锁屏可见,可离线接收(推送服务由浏览器厂商分发)
- 是下一版主推卖点

## 实施(无需新增 Tauri 命令)
- wsClient.request 直接走 WebSocket 调内核 4 个 RPC

## 前端封装 src/lib/push-web.js
- isPushSupported() / pushPermission() / requestPushPermission()
- ensureServiceWorker() 注册 /push-sw.js(幂等)
- subscribePush() 完整流程:权限 → SW → push.web.vapidPublicKey → PushManager.subscribe → push.web.subscribe 上报内核
- unsubscribePush() 本地取消 + 通知内核清理
- sendTestPush(title, body) 调 push.web.test 广播测试
- getCurrentSubscription() / isLocallySubscribed() 状态查询
- urlBase64ToUint8Array / arrayBufferToBase64Url 工具函数
  (VAPID 公钥 base64url ↔ 二进制,订阅 keys 编码)

## Service Worker public/push-sw.js
- skipWaiting + clients.claim 立即激活
- push 事件:解析 JSON payload → showNotification(含 icon / badge / tag / requireInteraction)
- notificationclick:优先聚焦已打开标签 + postMessage 跳转 url;
  没有窗口就 openWindow 新开
- 所有路径容错(payload 解析失败 fallback 到默认文案)

## UI 页面 src/pages/notifications.js
- 状态行:通知权限 + 订阅状态(彩色徽章)
- 端点摘要(订阅成功后展示截断的 endpoint,方便用户确认)
- 三个动作按钮(互斥):启用 / 取消订阅 / 发测试通知
- 测试通知会显示「已投递到 N 个订阅」提示
- 不支持环境(Tauri 1.x 桌面壳或老浏览器)显示友好的「Push not supported here」空状态
- 全程走 humanizeError 友好错误提示

## i18n src/locales/modules/notifications.js
- 26 个键 × 11 语言全覆盖
- 含权限徽章 / 操作按钮 / 流程提示 / 不支持环境说明

## 入口
- OpenClaw 引擎「配置」section 新增「推送通知」入口
- sidebar.notifications i18n(短词「推送通知 / Push」)
- 路由 /notifications 注册到 OpenClaw 引擎
- Hermes 引擎暂不注册(push.web.* 是 OpenClaw 内核的 RPC)

## CSS
- 加 .push-status-row / .push-status-item / .push-status-label / .push-status-value
- 复用现有 .lazy-deps-badge.{ok,warn,unknown} 样式

## 待跟进
- iOS Safari 16.4+ 需用户先把 ClawPanel 添加到主屏才能收 push(已知限制,文档跟进)
- 真实流量(不只是 push.web.test)需 OpenClaw 内核侧把通知事件主动 send 出来;
  本 PR 把订阅渠道彻底打通,后续内核怎么用现成订阅发送是另一题
- 累计变动:4 新文件 + 4 修改
2026-05-14 04:27:33 +08:00

172 lines
5.5 KiB
JavaScript
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.
/**
* Web Push 推送通知封装P1-0
*
* 对接 OpenClaw 内核的 4 个 push.web.* RPC让 ClawPanel 关掉也能弹系统通知。
*/
import { wsClient } from './ws-client.js'
const SW_URL = '/push-sw.js'
const SW_SCOPE = '/'
const STATE_KEY = 'clawpanel.push.subscribed'
export function isPushSupported() {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
)
}
export function pushPermission() {
if (typeof Notification === 'undefined') return 'unsupported'
return Notification.permission
}
export async function requestPushPermission() {
if (!isPushSupported()) throw new Error('当前环境不支持 Web Push')
return await Notification.requestPermission()
}
async function ensureServiceWorker() {
if (!('serviceWorker' in navigator)) throw new Error('当前环境不支持 Service Worker')
const existing = await navigator.serviceWorker.getRegistration(SW_SCOPE)
if (existing) return existing
return await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE })
}
// base64url → Uint8ArrayPushManager.subscribe 需要二进制 VAPID 公钥)
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i)
return outputArray
}
// ArrayBuffer → base64url订阅完后把 keys 编码发给内核)
function arrayBufferToBase64Url(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* 拿当前 PushSubscription已订阅时返回对象未订阅时 null
*/
export async function getCurrentSubscription() {
if (!isPushSupported()) return null
try {
const reg = await navigator.serviceWorker.getRegistration(SW_SCOPE)
if (!reg) return null
return await reg.pushManager.getSubscription()
} catch {
return null
}
}
/**
* 完整订阅流程:注册 SW → 拿 VAPID → PushManager.subscribe → 上报内核
*/
export async function subscribePush() {
if (!isPushSupported()) throw new Error('当前环境不支持 Web Push')
// 1) 权限
const perm = pushPermission()
if (perm === 'denied') {
throw new Error('通知权限已被拒绝,请在浏览器/系统设置里手动放开')
}
if (perm !== 'granted') {
const result = await requestPushPermission()
if (result !== 'granted') throw new Error('用户拒绝了通知权限')
}
// 2) 注册 SW
const reg = await ensureServiceWorker()
// 3) 拿 VAPID 公钥(如果已订阅就跳过重新订阅)
const existing = await reg.pushManager.getSubscription()
if (existing) {
// 已订阅;确保内核也有记录(兜底再发一次 subscribe
await reportToKernel(existing)
localStorage.setItem(STATE_KEY, '1')
return existing
}
const vapidResp = await wsClient.request('push.web.vapidPublicKey', {})
const vapidPublicKey = vapidResp?.vapidPublicKey
if (!vapidPublicKey) throw new Error('内核未返回 VAPID 公钥(可能未配置 web push')
// 4) PushManager.subscribe
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
// 5) 上报内核入库
await reportToKernel(subscription)
localStorage.setItem(STATE_KEY, '1')
return subscription
}
/**
* 把 PushSubscription 转成内核期望的 { endpoint, keys: {p256dh, auth} } 并上报
*/
async function reportToKernel(subscription) {
const json = subscription.toJSON()
const endpoint = json.endpoint || subscription.endpoint
const p256dhBuf = subscription.getKey ? subscription.getKey('p256dh') : null
const authBuf = subscription.getKey ? subscription.getKey('auth') : null
const p256dh = p256dhBuf ? arrayBufferToBase64Url(p256dhBuf) : json.keys?.p256dh
const auth = authBuf ? arrayBufferToBase64Url(authBuf) : json.keys?.auth
if (!endpoint || !p256dh || !auth) throw new Error('订阅信息不完整')
return await wsClient.request('push.web.subscribe', {
endpoint,
keys: { p256dh, auth },
})
}
/**
* 取消订阅:本地 + 通知内核删除
*/
export async function unsubscribePush() {
const sub = await getCurrentSubscription()
if (!sub) {
localStorage.removeItem(STATE_KEY)
return { removed: 0 }
}
const endpoint = sub.endpoint
try {
await sub.unsubscribe()
} catch {
// 浏览器取消可能失败,但内核侧仍需清理
}
let kernelResp = { removed: 0 }
try {
kernelResp = await wsClient.request('push.web.unsubscribe', { endpoint })
} catch {
// 内核可能拒绝(已不存在),忽略
}
localStorage.removeItem(STATE_KEY)
return kernelResp
}
/**
* 让内核给所有已订阅的浏览器/系统广播一条测试通知
*/
export async function sendTestPush(title, body) {
return await wsClient.request('push.web.test', {
title: title || 'ClawPanel',
body: body || '这是一条测试通知,证明推送链路通了 ✓',
})
}
/**
* 本地缓存的订阅状态(不可靠,仅用于 UI 立即显示,真实状态用 getCurrentSubscription
*/
export function isLocallySubscribed() {
return localStorage.getItem(STATE_KEY) === '1'
}