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

169
src/pages/notifications.js Normal file
View File

@@ -0,0 +1,169 @@
/**
* 推送通知设置P1-0
*
* 对接 OpenClaw 内核 push.web.* RPC
* - 启用/关闭浏览器系统级推送
* - 展示当前订阅状态(含端点摘要)
* - 发测试通知(让用户立刻确认链路通了)
*
* 即使 ClawPanel 关掉,系统通知中心依然能收到 Agent / Cron / 渠道消息。
*/
import { t } from '../lib/i18n.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import {
isPushSupported,
pushPermission,
getCurrentSubscription,
subscribePush,
unsubscribePush,
sendTestPush,
} from '../lib/push-web.js'
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<div>
<h1 class="page-title">${escapeHtml(t('notifications.title'))}</h1>
<p class="page-desc">${escapeHtml(t('notifications.desc'))}</p>
</div>
</div>
<div id="push-content">
<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escapeHtml(t('common.loading'))}…</div>
</div>
`
loadAndRender(page)
return page
}
async function loadAndRender(page) {
const content = page.querySelector('#push-content')
if (!isPushSupported()) {
content.innerHTML = `
<div class="empty-state">
<div class="empty-icon">🚫</div>
<div class="empty-title">${escapeHtml(t('notifications.unsupportedTitle'))}</div>
<div class="empty-desc">${escapeHtml(t('notifications.unsupportedDesc'))}</div>
</div>
`
return
}
const perm = pushPermission()
let sub = null
try { sub = await getCurrentSubscription() } catch {}
content.innerHTML = `
<div class="config-section">
<div class="config-section-title">${escapeHtml(t('notifications.statusTitle'))}</div>
<div class="push-status-row">
<div class="push-status-item">
<div class="push-status-label">${escapeHtml(t('notifications.permissionLabel'))}</div>
<div class="push-status-value">${renderPermBadge(perm)}</div>
</div>
<div class="push-status-item">
<div class="push-status-label">${escapeHtml(t('notifications.subscriptionLabel'))}</div>
<div class="push-status-value">${
sub
? `<span class="lazy-deps-badge ok">✓ ${escapeHtml(t('notifications.subscribed'))}</span>`
: `<span class="lazy-deps-badge warn">${escapeHtml(t('notifications.notSubscribed'))}</span>`
}</div>
</div>
</div>
${sub ? `
<div class="form-hint" style="margin-top:var(--space-md);word-break:break-all">
<strong>${escapeHtml(t('notifications.endpointLabel'))}</strong>
<code style="display:inline-block;font-size:11px;margin-left:8px;color:var(--text-tertiary)">${escapeHtml(truncateEndpoint(sub.endpoint))}</code>
</div>
` : ''}
</div>
<div class="config-section">
<div class="config-section-title">${escapeHtml(t('notifications.actionsTitle'))}</div>
<div style="display:flex;gap:12px;flex-wrap:wrap">
${sub
? `<button class="btn btn-secondary btn-sm" id="btn-unsub">${escapeHtml(t('notifications.unsubscribeBtn'))}</button>
<button class="btn btn-primary btn-sm" id="btn-test">${escapeHtml(t('notifications.testBtn'))}</button>`
: `<button class="btn btn-primary btn-sm" id="btn-sub">${escapeHtml(t('notifications.subscribeBtn'))}</button>`
}
</div>
<div class="form-hint" style="margin-top:var(--space-sm)">${escapeHtml(t('notifications.hint'))}</div>
</div>
`
// 绑定按钮
page.querySelector('#btn-sub')?.addEventListener('click', async (e) => {
const btn = e.currentTarget
btn.disabled = true
const orig = btn.textContent
btn.textContent = t('notifications.subscribing') + '…'
try {
await subscribePush()
toast(t('notifications.subscribeSuccess'), 'success')
loadAndRender(page)
} catch (err) {
toast(humanizeError(err, t('notifications.subscribeFailed')), 'error')
} finally {
btn.disabled = false
btn.textContent = orig
}
})
page.querySelector('#btn-unsub')?.addEventListener('click', async (e) => {
const btn = e.currentTarget
btn.disabled = true
try {
await unsubscribePush()
toast(t('notifications.unsubscribeSuccess'), 'success')
loadAndRender(page)
} catch (err) {
toast(humanizeError(err, t('notifications.unsubscribeFailed')), 'error')
} finally {
btn.disabled = false
}
})
page.querySelector('#btn-test')?.addEventListener('click', async (e) => {
const btn = e.currentTarget
btn.disabled = true
const orig = btn.textContent
btn.textContent = t('notifications.sending') + '…'
try {
const resp = await sendTestPush(
t('notifications.testTitle'),
t('notifications.testBody')
)
const count = Array.isArray(resp?.results) ? resp.results.length : 0
toast({
message: t('notifications.testSent'),
hint: count ? t('notifications.testDelivered', { n: count }) : '',
}, 'success')
} catch (err) {
toast(humanizeError(err, t('notifications.testFailed')), 'error')
} finally {
btn.disabled = false
btn.textContent = orig
}
})
}
function renderPermBadge(perm) {
if (perm === 'granted') return `<span class="lazy-deps-badge ok">✓ ${escapeHtml(t('notifications.permGranted'))}</span>`
if (perm === 'denied') return `<span class="lazy-deps-badge warn">${escapeHtml(t('notifications.permDenied'))}</span>`
if (perm === 'default') return `<span class="lazy-deps-badge unknown">${escapeHtml(t('notifications.permDefault'))}</span>`
return `<span class="lazy-deps-badge unknown">${escapeHtml(t('notifications.permUnsupported'))}</span>`
}
function truncateEndpoint(ep) {
if (!ep) return ''
if (ep.length <= 80) return ep
return ep.slice(0, 40) + '…' + ep.slice(-30)
}
function escapeHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}