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 回调
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
// Package apns delivers Apple Push Notification service (APNs) notifications to
|
||||
// iOS devices whose SSE connection is closed. It mirrors the shape of the push
|
||||
// (Web Push) package: an inert Sender when unconfigured, per-subscription
|
||||
// localization via the shared push.Localize machinery, and automatic pruning of
|
||||
// dead device tokens on HTTP 410 (Unregistered) responses.
|
||||
//
|
||||
// APNs uses provider-token authentication: a cached ES256 JWT signed with the
|
||||
// team's .p8 key, refreshed every ~50 minutes (Apple requires 20-60 minutes).
|
||||
// HTTP/2 is negotiated automatically by Go's net/http Transport over TLS.
|
||||
package apns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
"commilitia.net/cdrop/internal/push"
|
||||
)
|
||||
|
||||
// apnsTimeout bounds a single Notify call (including all subscriptions for one
|
||||
// device), so a hung APNs endpoint cannot leak goroutines.
|
||||
const apnsTimeout = 15 * time.Second
|
||||
|
||||
// jwtRefreshAge is how long we cache a provider JWT before minting a fresh one.
|
||||
// Apple requires a new token between 20 and 60 minutes; 50 minutes gives a safe margin.
|
||||
const jwtRefreshAge = 50 * time.Minute
|
||||
|
||||
// prodBase and sandboxBase are the APNs HTTP/2 endpoints.
|
||||
const (
|
||||
prodBase = "https://api.push.apple.com"
|
||||
sandboxBase = "https://api.sandbox.push.apple.com"
|
||||
)
|
||||
|
||||
// apsPayload is the JSON body for an APNs alert notification.
|
||||
type apsPayload struct {
|
||||
APS apsEnvelope `json:"aps"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type apsEnvelope struct {
|
||||
Alert apsAlert `json:"alert"`
|
||||
Sound string `json:"sound"`
|
||||
}
|
||||
|
||||
type apsAlert struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// Sender delivers APNs notifications and prunes dead device tokens. A Sender
|
||||
// built without all required config (key/keyID/teamID/topic) is inert: Enabled
|
||||
// reports false and Notify is a no-op. Safe for concurrent use.
|
||||
type Sender struct {
|
||||
queries *db.Queries
|
||||
key *ecdsa.PrivateKey
|
||||
keyID string
|
||||
teamID string
|
||||
topic string
|
||||
base string // APNs base URL; settable for tests
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
cachedJWT string
|
||||
jwtMintedAt time.Time
|
||||
}
|
||||
|
||||
// NewSender returns a configured APNs Sender. keyPEM is the .p8 PKCS8 EC private
|
||||
// key Apple issues. env is "prod" (default) or "sandbox". When any of keyPEM,
|
||||
// keyID, teamID, or topic is empty/nil the returned Sender is inert (Enabled
|
||||
// returns false, Notify is a no-op) so callers can invoke it unconditionally.
|
||||
// A PEM parse failure is logged and also yields an inert Sender rather than a
|
||||
// hard error, so startup can proceed with APNs disabled.
|
||||
func NewSender(queries *db.Queries, keyPEM []byte, keyID, teamID, topic, env string) *Sender {
|
||||
if len(keyPEM) == 0 || keyID == "" || teamID == "" || topic == "" {
|
||||
return &Sender{}
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(keyPEM)
|
||||
if block == nil {
|
||||
slog.Error("apns: cannot decode PEM block from .p8 key; APNs disabled")
|
||||
return &Sender{}
|
||||
}
|
||||
raw, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
slog.Error("apns: cannot parse PKCS8 private key; APNs disabled", "err", err)
|
||||
return &Sender{}
|
||||
}
|
||||
ecKey, ok := raw.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
slog.Error("apns: .p8 key is not an ECDSA private key; APNs disabled")
|
||||
return &Sender{}
|
||||
}
|
||||
|
||||
base := prodBase
|
||||
switch strings.ToLower(strings.TrimSpace(env)) {
|
||||
case "sandbox":
|
||||
base = sandboxBase
|
||||
case "", "prod", "production":
|
||||
base = prodBase
|
||||
default:
|
||||
// Unknown env is an operator typo; default to prod but log loudly so the
|
||||
// sandbox/prod mismatch footgun (dev tokens → prod endpoint → BadDeviceToken)
|
||||
// surfaces instead of silently selecting the wrong endpoint.
|
||||
slog.Warn("apns: unknown CDROP_APNS_ENV, defaulting to production", "env", env)
|
||||
}
|
||||
|
||||
return &Sender{
|
||||
queries: queries,
|
||||
key: ecKey,
|
||||
keyID: keyID,
|
||||
teamID: teamID,
|
||||
topic: topic,
|
||||
base: base,
|
||||
client: &http.Client{Timeout: apnsTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether APNs is configured.
|
||||
func (s *Sender) Enabled() bool { return s != nil && s.key != nil }
|
||||
|
||||
// Notify delivers n to every APNs subscription of (userID, deviceName).
|
||||
// Best-effort and safe to call in a goroutine: failures are logged, a token the
|
||||
// APNs service marks Unregistered (410) is pruned, and a no-op results when
|
||||
// disabled or the device has no APNs subscriptions.
|
||||
func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n push.Notification) {
|
||||
if !s.Enabled() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, apnsTimeout)
|
||||
defer cancel()
|
||||
|
||||
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(ctx, db.ListAPNsSubscriptionsByUserDeviceParams{
|
||||
UserID: userID,
|
||||
DeviceName: deviceName,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("apns: list subscriptions failed",
|
||||
"user", userID, "device", deviceName, "err", err)
|
||||
return
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
jwtStr, err := s.providerJWT()
|
||||
if err != nil {
|
||||
slog.Error("apns: jwt signing failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, sub := range subs {
|
||||
title, body := push.Localize(n.Type, n.Params, sub.Locale)
|
||||
url := n.URL
|
||||
if url == "" {
|
||||
url = "/"
|
||||
}
|
||||
payload := apsPayload{
|
||||
APS: apsEnvelope{
|
||||
Alert: apsAlert{Title: title, Body: body},
|
||||
Sound: "default",
|
||||
},
|
||||
Type: n.Type,
|
||||
URL: url,
|
||||
}
|
||||
s.send(ctx, sub.ID, sub.Endpoint, jwtStr, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) send(
|
||||
ctx context.Context,
|
||||
subID, deviceToken, jwtStr string,
|
||||
payload apsPayload,
|
||||
) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
slog.Error("apns: marshal payload failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/3/device/%s", s.base, deviceToken)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
slog.Error("apns: build request failed", "err", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "bearer "+jwtStr)
|
||||
req.Header.Set("apns-topic", s.topic)
|
||||
req.Header.Set("apns-push-type", "alert")
|
||||
req.Header.Set("apns-priority", "10")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("apns: send failed", "token_prefix", tokenPrefix(deviceToken), "err", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
_ = s.queries.TouchPushSubscription(ctx, db.TouchPushSubscriptionParams{
|
||||
LastUsedAt: time.Now().Unix(),
|
||||
ID: subID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Non-2xx: APNs returns a small JSON body whose `reason` names the exact failure
|
||||
// (BadDeviceToken, InvalidProviderToken, BadTopic, ExpiredProviderToken, …). It is
|
||||
// the single most useful diagnostic, so always surface it rather than logging a
|
||||
// bare numeric status.
|
||||
reason := apnsReason(resp.Body)
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusGone, reason == "BadDeviceToken", reason == "DeviceTokenNotForTopic":
|
||||
// 410 Unregistered, or a permanently-invalid token (the app was uninstalled,
|
||||
// the token rotated, or it's malformed / for the wrong topic). Prune the row
|
||||
// so we stop re-firing a guaranteed-fail request on every future Notify.
|
||||
if err := s.queries.DeletePushSubscription(ctx, subID); err != nil {
|
||||
slog.Error("apns: prune dead token failed", "id", subID, "reason", reason, "err", err)
|
||||
}
|
||||
case reason == "ExpiredProviderToken":
|
||||
// Clock skew pushed the cached provider JWT past Apple's 60-min limit before
|
||||
// our 50-min refresh. Bust the cache so the next call re-mints immediately;
|
||||
// otherwise every push fails for the rest of the refresh window.
|
||||
s.mu.Lock()
|
||||
s.cachedJWT = ""
|
||||
s.mu.Unlock()
|
||||
slog.Warn("apns: provider token expired, busting cache", "token_prefix", tokenPrefix(deviceToken))
|
||||
default:
|
||||
slog.Warn("apns: rejected",
|
||||
"status", resp.StatusCode, "reason", reason, "token_prefix", tokenPrefix(deviceToken))
|
||||
}
|
||||
}
|
||||
|
||||
// apnsReason extracts the `reason` field from an APNs error response body (small
|
||||
// JSON like {"reason":"BadDeviceToken"}). Returns "" if absent or unparseable.
|
||||
func apnsReason(body io.Reader) string {
|
||||
var e struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(body, 1024)).Decode(&e)
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
// providerJWT returns a cached ES256 provider JWT, re-minting when the cached
|
||||
// token is older than jwtRefreshAge. Safe for concurrent callers (mutex-protected).
|
||||
func (s *Sender) providerJWT() (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.cachedJWT != "" && time.Since(s.jwtMintedAt) < jwtRefreshAge {
|
||||
return s.cachedJWT, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
|
||||
"iss": s.teamID,
|
||||
"iat": now.Unix(),
|
||||
})
|
||||
tok.Header["kid"] = s.keyID
|
||||
|
||||
signed, err := tok.SignedString(s.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign apns jwt: %w", err)
|
||||
}
|
||||
|
||||
s.cachedJWT = signed
|
||||
s.jwtMintedAt = now
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// tokenPrefix returns the first 8 hex chars of a device token for log
|
||||
// messages — enough to correlate without exposing the full opaque token.
|
||||
func tokenPrefix(token string) string {
|
||||
if len(token) <= 8 {
|
||||
return token
|
||||
}
|
||||
return token[:8] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user