Files
clawpanel/public/push-sw.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

73 lines
2.0 KiB
JavaScript

/**
* ClawPanel Web Push Service Worker
*
* 接收来自 OpenClaw 内核(通过 web-push 协议)的推送,
* 调 showNotification 弹出系统级通知(即使 ClawPanel 已关闭)。
*
* 点通知 → 尝试聚焦已打开的 ClawPanel 标签;都没开就打开一个新窗口。
*/
self.addEventListener('install', (event) => {
// 立刻激活,不等老 SW 退出
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
// 立刻接管所有已打开的客户端
event.waitUntil(self.clients.claim())
})
self.addEventListener('push', (event) => {
let payload = {}
try {
if (event.data) {
// 优先按 JSON 解析;失败时把整段文本当 body
try {
payload = event.data.json()
} catch (_) {
payload = { body: event.data.text() }
}
}
} catch (_) {
payload = {}
}
const title = payload.title || 'ClawPanel'
const body = payload.body || ''
const url = payload.url || payload.click_action || '/'
const options = {
body,
icon: payload.icon || '/icon.png',
badge: payload.badge || '/icon.png',
tag: payload.tag || 'clawpanel',
data: { url },
requireInteraction: !!payload.requireInteraction,
}
event.waitUntil(self.registration.showNotification(title, options))
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const targetUrl = event.notification?.data?.url || '/'
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
// 已有 ClawPanel 标签 → 聚焦 + 跳到 targetUrl
for (const client of clientsList) {
if ('focus' in client) {
try {
client.postMessage({ type: 'push-navigate', url: targetUrl })
} catch (_) {}
return client.focus()
}
}
// 没有任何 ClawPanel 窗口 → 开一个新窗口
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl)
}
})
)
})