Files
Commilitia-Drop/internal/push/sender.go
T
admin 44096069a7 iOS 计划完成:流式发送 / 后台续传 / APNs / Share Extension / 控制中心控件 + 真机分发预备
- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource
- 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI
- APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式
- Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎)
- 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换)
- 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册
- I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键
- ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
2026-06-27 00:33:35 +08:00

261 lines
8.8 KiB
Go

// 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.ListPushSubscriptionsByUserDeviceRow, 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
}
// DefaultLocale is the fallback when a subscription carries an unrecognized or
// empty locale. Exported so the apns package (and future channels) can use the
// same constant without re-declaring it.
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
}
// Localize resolves the notification title and body for the given type, params,
// and locale. Unknown locales fall back to DefaultLocale. Exported so other
// delivery channels (e.g. APNs) can share the string tables without duplicating
// them.
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"]
}
}