mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-05 15:49:43 +08:00
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:
171
src/lib/push-web.js
Normal file
171
src/lib/push-web.js
Normal 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 → Uint8Array(PushManager.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'
|
||||
}
|
||||
Reference in New Issue
Block a user