推送通知:Web Push(网页 / PWA)+ 桌面原生通知
页面 / PWA 关闭、或桌面窗口非前台时,收到文件、消息、传输完成 / 失败以系统通知提醒。
判定统一为「页面打开走应用内提示,否则系统通知」,三端各自落地;网页端的判定直接复用
Hub.SendTo 的投递结果,零额外状态。
- Web Push(VAPID,账号无关):服务端 internal/push.Sender 在事件未经 SSE 投递到目标
设备时(即该设备页面已关)发推送,文案按订阅 locale 在服务端渲染(中简 / 中繁 / 英)、
404 / 410 自动清理死订阅;新增 push_subscriptions 表(含 locale,主键为 endpoint 的
SHA-256 以幂等 upsert)+ /api/push/{vapid-key, subscribe POST/DELETE} 端点
- 事件挂接:transfer:incoming / message(离线返 202,文本随推送送达)/ transfer:state
DONE|FAILED,均未投递才推
- 配置 CDROP_VAPID_PUBLIC_KEY / CDROP_VAPID_PRIVATE_KEY(半对即拒启动,都空则推送惰性
关闭)+ just vapid-keygen 生成工具(cmd/vapidgen)
- 前端:service worker 加 push / notificationclick(仍不拦截 /api 与导航);net/push.ts
订阅管理 + push store slice + 设置页「推送通知」面板(仅浏览器 / PWA);登录后 resyncPush
把既有订阅重新登记到当前用户,换用户 / endpoint 轮换 / 切换语言皆自愈
- 桌面原生通知:platform/notify_{darwin,windows,other}.go + notify_darwin.m,仅窗口非
前台时弹原生通知(macOS UNUserNotificationCenter,未签名时安全空操作、待签名后显示;
Windows go-toast 即用);app.go ShowNotification 绑定,前端 notifyNativeIfBackground
复用 hub 事件分发 + 客户端本地化
- 文档:README / .env.example / compose 模板补 CDROP_VAPID_* 配置
注:sqlc v1.31.1 对 query 文件里的多字节字符会错位偏移、生成损坏 SQL,故 query 注释保持纯
ASCII(文件内留有警示)。
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// Package push delivers Web Push (RFC 8291/8292) notifications to a user's
|
||||
// browser / PWA devices whose page is closed — i.e. has no live SSE connection.
|
||||
// The "page open → in-app toast, page closed → system notification" rule lives
|
||||
// at the call sites: each notify-worthy event is first handed to hub.SendTo; the
|
||||
// push fallback fires only when SendTo reports the device offline.
|
||||
//
|
||||
// Notification text is rendered here, per subscription, in the device's stored
|
||||
// locale (the Service Worker only displays the title/body it receives). Callers
|
||||
// describe intent (Type + Params), not pre-rendered strings.
|
||||
//
|
||||
// Desktop clients never appear here — their Go process is resident and decides
|
||||
// locally (window visibility) whether to raise a native OS notification.
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
)
|
||||
|
||||
// pushTimeout bounds a single Notify call (all of one device's subscriptions),
|
||||
// so a hung push service can't leak goroutines when callers fire-and-forget.
|
||||
const pushTimeout = 15 * time.Second
|
||||
|
||||
// notificationTTL is how long the push service should retain an undelivered
|
||||
// message before dropping it. A day is plenty for "you have an incoming file":
|
||||
// past that the transfer has long since expired anyway.
|
||||
const notificationTTL = 24 * 60 * 60
|
||||
|
||||
// Notification kinds. These also travel to the SW as the wire `type` so the
|
||||
// client can route a notificationclick (e.g. deep-link to a transfer).
|
||||
const (
|
||||
KindTransferIncoming = "transfer:incoming"
|
||||
KindTransferDone = "transfer:done"
|
||||
KindTransferFailed = "transfer:failed"
|
||||
KindMessage = "message"
|
||||
)
|
||||
|
||||
// Notification is an intent to notify; the sender localizes it per subscription.
|
||||
// Params carries the substitution values a kind needs:
|
||||
// - transfer:incoming → {sender, filename}
|
||||
// - transfer:done / transfer:failed → {filename}
|
||||
// - message → {sender, text}
|
||||
type Notification struct {
|
||||
Type string
|
||||
Params map[string]string
|
||||
Tag string // client-side collapse key; same tag replaces in place
|
||||
URL string // where notificationclick navigates; defaults to "/"
|
||||
}
|
||||
|
||||
// wirePayload is the JSON the Service Worker receives and shows verbatim.
|
||||
type wirePayload struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// Sender delivers notifications and prunes dead subscriptions. A Sender built
|
||||
// without VAPID keys is inert: Enabled reports false and Notify is a no-op, so
|
||||
// callers can invoke it unconditionally. Safe for concurrent use.
|
||||
type Sender struct {
|
||||
queries *db.Queries
|
||||
pubKey string
|
||||
privKey string
|
||||
subject string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewSender returns a Sender. With either VAPID key empty it is inert (Web Push
|
||||
// disabled). subject is the VAPID contact identity (a mailto: or site URL).
|
||||
func NewSender(queries *db.Queries, pubKey, privKey, subject string) *Sender {
|
||||
if pubKey == "" || privKey == "" {
|
||||
return &Sender{}
|
||||
}
|
||||
return &Sender{
|
||||
queries: queries,
|
||||
pubKey: pubKey,
|
||||
privKey: privKey,
|
||||
subject: subject,
|
||||
client: &http.Client{Timeout: pushTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether Web Push is configured.
|
||||
func (s *Sender) Enabled() bool { return s != nil && s.privKey != "" }
|
||||
|
||||
// PublicKey returns the VAPID public key the browser needs as
|
||||
// applicationServerKey when subscribing. Empty when disabled.
|
||||
func (s *Sender) PublicKey() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.pubKey
|
||||
}
|
||||
|
||||
// Notify delivers n to every push subscription of (userID, deviceName).
|
||||
// Best-effort and safe to call in a goroutine: failures are logged, a
|
||||
// subscription the push service reports gone (404/410) is pruned, and a no-op
|
||||
// results when disabled or the device has no subscriptions.
|
||||
func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n Notification) {
|
||||
if !s.Enabled() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, pushTimeout)
|
||||
defer cancel()
|
||||
|
||||
subs, err := s.queries.ListPushSubscriptionsByUserDevice(ctx, db.ListPushSubscriptionsByUserDeviceParams{
|
||||
UserID: userID,
|
||||
DeviceName: deviceName,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("push: list subscriptions failed",
|
||||
"user", userID, "device", deviceName, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, sub := range subs {
|
||||
payload, err := json.Marshal(render(n, sub.Locale))
|
||||
if err != nil {
|
||||
slog.Error("push: marshal payload failed", "err", err)
|
||||
continue
|
||||
}
|
||||
s.send(ctx, sub, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) send(ctx context.Context, sub db.PushSubscription, payload []byte) {
|
||||
resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
|
||||
Endpoint: sub.Endpoint,
|
||||
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
|
||||
}, &webpush.Options{
|
||||
HTTPClient: s.client,
|
||||
Subscriber: s.subject,
|
||||
VAPIDPublicKey: s.pubKey,
|
||||
VAPIDPrivateKey: s.privKey,
|
||||
TTL: notificationTTL,
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("push: send failed", "endpoint", endpointHost(sub.Endpoint), "err", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
|
||||
// 404/410 means the subscription is permanently gone (the user cleared
|
||||
// site data, the browser rotated the endpoint, the PWA was deleted).
|
||||
// Pruning keeps the table from accreting dead rows we'd retry forever.
|
||||
if err := s.queries.DeletePushSubscription(ctx, sub.ID); err != nil {
|
||||
slog.Error("push: prune dead subscription failed", "id", sub.ID, "err", err)
|
||||
}
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
_ = s.queries.TouchPushSubscription(ctx, db.TouchPushSubscriptionParams{
|
||||
LastUsedAt: time.Now().Unix(),
|
||||
ID: sub.ID,
|
||||
})
|
||||
default:
|
||||
slog.Warn("push: unexpected status",
|
||||
"status", resp.StatusCode, "endpoint", endpointHost(sub.Endpoint))
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionID is the primary key for a subscription: the hex SHA-256 of its
|
||||
// endpoint. Hashing makes re-subscribe idempotent and keeps the (long, opaque)
|
||||
// endpoint out of the PK while staying deterministic for upsert/delete.
|
||||
func SubscriptionID(endpoint string) string {
|
||||
sum := sha256.Sum256([]byte(endpoint))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// endpointHost reduces an endpoint URL to its host for logging — the full path
|
||||
// is an unguessable capability we should not write to logs.
|
||||
func endpointHost(endpoint string) string {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Host == "" {
|
||||
return "?"
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
const defaultLocale = "zh-CN"
|
||||
|
||||
// notifyStrings holds the few server-rendered notification templates, keyed by
|
||||
// locale then string id. The set mirrors the frontend i18n only for these push
|
||||
// strings; message bodies are user text and are never templated here.
|
||||
var notifyStrings = map[string]map[string]string{
|
||||
"zh-CN": {
|
||||
"incoming.title": "收到文件",
|
||||
"incoming.body": "%s 发来 %s",
|
||||
"done.title": "传输完成",
|
||||
"failed.title": "传输失败",
|
||||
},
|
||||
"zh-TW": {
|
||||
"incoming.title": "收到檔案",
|
||||
"incoming.body": "%s 傳來 %s",
|
||||
"done.title": "傳輸完成",
|
||||
"failed.title": "傳輸失敗",
|
||||
},
|
||||
"en-US": {
|
||||
"incoming.title": "Incoming file",
|
||||
"incoming.body": "%s is sending %s",
|
||||
"done.title": "Transfer complete",
|
||||
"failed.title": "Transfer failed",
|
||||
},
|
||||
}
|
||||
|
||||
// render turns an intent into the wire payload, localized to the subscription's
|
||||
// stored locale. Unknown locales fall back to defaultLocale.
|
||||
func render(n Notification, locale string) wirePayload {
|
||||
title, body := localize(n.Type, n.Params, locale)
|
||||
out := wirePayload{Type: n.Type, Title: title, Body: body, Tag: n.Tag, URL: n.URL}
|
||||
if out.URL == "" {
|
||||
out.URL = "/"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localize(typ string, params map[string]string, locale string) (title, body string) {
|
||||
// A message's title is the sender's device name and its body is the user's
|
||||
// own text — neither is a template, so locale is irrelevant.
|
||||
if typ == KindMessage {
|
||||
return params["sender"], params["text"]
|
||||
}
|
||||
|
||||
t, ok := notifyStrings[locale]
|
||||
if !ok {
|
||||
t = notifyStrings[defaultLocale]
|
||||
}
|
||||
switch typ {
|
||||
case KindTransferIncoming:
|
||||
return t["incoming.title"], fmt.Sprintf(t["incoming.body"], params["sender"], params["filename"])
|
||||
case KindTransferDone:
|
||||
return t["done.title"], params["filename"]
|
||||
case KindTransferFailed:
|
||||
return t["failed.title"], params["filename"]
|
||||
default:
|
||||
return params["title"], params["body"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user