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 修改
This commit is contained in:
晴天
2026-05-14 04:27:33 +08:00
parent b852ebb6ee
commit e717a7a098
8 changed files with 517 additions and 1 deletions

171
src/lib/push-web.js Normal file
View File

@@ -0,0 +1,171 @@
/**
* 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'
}