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:
2026-06-27 00:27:55 +08:00
parent 10cf36ecee
commit 44096069a7
59 changed files with 2585 additions and 144 deletions
+15
View File
@@ -71,6 +71,21 @@ desktop-clean:
desktop-doctor: desktop-doctor:
{{wails}} doctor {{wails}} doctor
# ---- iOS 真机 ----
# 列出已连接的真机(手机插 USB 后取 UDID,填进门户 Devices + just ios-device)。
ios-devices:
xcrun devicectl list devices
# 真机构建 + 装机(手动签名)。前置:付费 ADP;门户建好 App ID/App Group/Push/设备/profile
# 按 ios/CDrop/REALDEVICE.md 建好 gitignore 的 ios/CDrop/Local.xcconfigTeam ID + 3 个 profile 名)。
# 模拟器构建不需这些(base ad-hoc)。用法:just ios-device <设备UDID>UDID 见 just ios-devices)。
ios-device udid:
test -f ios/CDrop/Local.xcconfig || { echo "缺 ios/CDrop/Local.xcconfig——见 ios/CDrop/REALDEVICE.md"; exit 1; }
cd ios/CDrop && xcodegen generate
cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -configuration Debug -destination 'generic/platform=iOS' -derivedDataPath build/DD-device build
xcrun devicectl device install app --device {{udid}} ios/CDrop/build/DD-device/Build/Products/Debug-iphoneos/CDrop.app
# ---- deploy plumbing ---- # ---- deploy plumbing ----
# 一次性预热:把 Go modules + node_modules 烤进 cdrop-base:latest。 # 一次性预热:把 Go modules + node_modules 烤进 cdrop-base:latest。
+11 -2
View File
@@ -10,6 +10,7 @@ import (
"syscall" "syscall"
"time" "time"
"commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/calls" "commilitia.net/cdrop/internal/calls"
"commilitia.net/cdrop/internal/clipboard" "commilitia.net/cdrop/internal/clipboard"
"commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/config"
@@ -63,7 +64,15 @@ func main() {
} else { } else {
slog.Info("web push disabled (VAPID keys unset)") slog.Info("web push disabled (VAPID keys unset)")
} }
transfers := transfer.NewService(queries, h, relayMgr, pushSender)
apnsSender := apns.NewSender(queries, cfg.APNSKeyPEM(), cfg.APNSKeyID, cfg.APNSTeamID, cfg.APNSTopic, cfg.APNSEnv)
if apnsSender.Enabled() {
slog.Info("apns enabled", "topic", cfg.APNSTopic, "env", cfg.APNSEnv)
} else {
slog.Info("apns disabled (APNS keys unset)")
}
transfers := transfer.NewService(queries, h, relayMgr, pushSender, apnsSender)
var cfTurn *calls.Provider var cfTurn *calls.Provider
if cfg.CFTurnKeyID != "" && cfg.CFTurnAPIToken != "" { if cfg.CFTurnKeyID != "" && cfg.CFTurnAPIToken != "" {
@@ -91,7 +100,7 @@ func main() {
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: httpapi.New(cfg, dbConn, queries, auth, h, transfers, relayMgr, cfTurn, clip, pushSender).Handler(), Handler: httpapi.New(cfg, dbConn, queries, auth, h, transfers, relayMgr, cfTurn, clip, pushSender, apnsSender).Handler(),
ReadHeaderTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second,
} }
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/SherClockHolmes/webpush-go v1.4.0 github.com/SherClockHolmes/webpush-go v1.4.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/httprate v0.15.0 github.com/go-chi/httprate v0.15.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env/v2 v2.0.0 github.com/knadh/koanf/providers/env/v2 v2.0.0
github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/providers/file v1.2.1
@@ -17,7 +18,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect
+293
View File
@@ -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] + "..."
}
+396
View File
@@ -0,0 +1,396 @@
package apns
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/push"
)
// generateTestKey creates an ephemeral P-256 key and returns it as PEM-encoded
// PKCS8 bytes alongside the typed private key (for verification).
func generateTestKey(t *testing.T) (pemBytes []byte, priv *ecdsa.PrivateKey) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
pemBytes = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
return pemBytes, priv
}
// openTestDB creates a temporary SQLite database and runs Bootstrap. Returns a
// *db.Queries backed by that DB.
func openTestDB(t *testing.T) *db.Queries {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "apns_test.db"))
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
return db.New(conn)
}
// TestNewSenderInertWhenUnconfigured verifies that a Sender with any missing
// config field is inert: Enabled reports false and Notify is a safe no-op.
func TestNewSenderInertWhenUnconfigured(t *testing.T) {
keyPEM, _ := generateTestKey(t)
cases := []struct {
name string
keyPEM []byte
keyID, teamID, topic string
}{
{"no key", nil, "KID", "TID", "topic"},
{"no keyID", keyPEM, "", "TID", "topic"},
{"no teamID", keyPEM, "KID", "", "topic"},
{"no topic", keyPEM, "KID", "TID", ""},
{"all empty", nil, "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := NewSender(nil, tc.keyPEM, tc.keyID, tc.teamID, tc.topic, "sandbox")
if s.Enabled() {
t.Fatal("inert Sender should report Enabled=false")
}
// Notify must be a safe no-op with nil queries.
s.Notify(context.Background(), "u", "d", push.Notification{Type: push.KindMessage})
})
}
}
// TestNilSenderIsSafe verifies that a nil *Sender is safe to call.
func TestNilSenderIsSafe(t *testing.T) {
var s *Sender
if s.Enabled() {
t.Fatal("nil sender should report disabled")
}
s.Notify(context.Background(), "u", "d", push.Notification{}) // must not panic
}
// TestProviderJWT verifies the JWT structure and ES256 signature.
func TestProviderJWT(t *testing.T) {
const keyID = "TESTKEYID1"
const teamID = "TEAMID1234"
keyPEM, priv := generateTestKey(t)
s := NewSender(nil, keyPEM, keyID, teamID, "com.example.app", "sandbox")
if !s.Enabled() {
t.Fatal("sender should be enabled with a valid key")
}
jwtStr, err := s.providerJWT()
if err != nil {
t.Fatalf("providerJWT: %v", err)
}
if jwtStr == "" {
t.Fatal("providerJWT returned empty string")
}
// Parse without verification first to inspect headers.
tok, err := jwt.NewParser().ParseWithClaims(jwtStr, jwt.MapClaims{}, func(t *jwt.Token) (any, error) {
return &priv.PublicKey, nil
})
if err != nil {
t.Fatalf("parse jwt: %v", err)
}
if !tok.Valid {
t.Fatal("jwt should be valid")
}
// Header checks.
alg, _ := tok.Header["alg"].(string)
if alg != "ES256" {
t.Errorf("header alg = %q, want ES256", alg)
}
kid, _ := tok.Header["kid"].(string)
if kid != keyID {
t.Errorf("header kid = %q, want %q", kid, keyID)
}
// Claims checks.
claims, ok := tok.Claims.(jwt.MapClaims)
if !ok {
t.Fatal("claims not MapClaims")
}
iss, _ := claims["iss"].(string)
if iss != teamID {
t.Errorf("claim iss = %q, want %q", iss, teamID)
}
iat, ok := claims["iat"]
if !ok {
t.Error("claim iat missing")
}
// iat should be a recent unix timestamp (within the last minute).
iatF, _ := iat.(float64)
iatTime := time.Unix(int64(iatF), 0)
if time.Since(iatTime) > time.Minute {
t.Errorf("claim iat too old: %v", iatTime)
}
}
// TestJWTIsCached verifies that repeated calls within jwtRefreshAge return the
// same token string, and that forcing the cache stale mints a new one.
func TestJWTIsCached(t *testing.T) {
keyPEM, _ := generateTestKey(t)
s := NewSender(nil, keyPEM, "KID", "TID", "topic", "sandbox")
first, err := s.providerJWT()
if err != nil {
t.Fatalf("first jwt: %v", err)
}
second, err := s.providerJWT()
if err != nil {
t.Fatalf("second jwt: %v", err)
}
if first != second {
t.Error("providerJWT should return the cached token within refresh window")
}
// Expire the cache.
s.mu.Lock()
s.jwtMintedAt = time.Now().Add(-(jwtRefreshAge + time.Second))
s.mu.Unlock()
third, err := s.providerJWT()
if err != nil {
t.Fatalf("third jwt: %v", err)
}
if third == first {
t.Error("providerJWT should mint a new token after refresh window expires")
}
}
// apnsServerRecorder is a minimal httptest server that records the last request
// and responds with a configurable status.
type apnsServerRecorder struct {
srv *httptest.Server
lastPath string
lastAuth string
lastTopic string
lastPushType string
lastBody []byte
respondWith int
}
func newAPNsTestServer(t *testing.T, respondWith int) *apnsServerRecorder {
t.Helper()
rec := &apnsServerRecorder{respondWith: respondWith}
rec.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.lastPath = r.URL.Path
rec.lastAuth = r.Header.Get("Authorization")
rec.lastTopic = r.Header.Get("apns-topic")
rec.lastPushType = r.Header.Get("apns-push-type")
rec.lastBody, _ = io.ReadAll(r.Body)
w.WriteHeader(rec.respondWith)
}))
t.Cleanup(rec.srv.Close)
return rec
}
// TestSendRequest asserts that Notify fires a POST with the correct path and
// headers against the APNs base URL, and that the JSON body has the expected shape.
func TestSendRequest(t *testing.T) {
const deviceToken = "abc123deadbeef"
const topic = "com.example.cdrop"
q := openTestDB(t)
keyPEM, _ := generateTestKey(t)
rec := newAPNsTestServer(t, http.StatusOK)
s := NewSender(q, keyPEM, "KID", "TID", topic, "sandbox")
if !s.Enabled() {
t.Fatal("sender must be enabled")
}
// Point the sender at the test server instead of the real APNs endpoint.
s.base = rec.srv.URL
// Insert an APNs subscription row for user "u", device "d".
now := time.Now().Unix()
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(deviceToken),
UserID: "u",
DeviceName: "d",
Endpoint: deviceToken,
Locale: "en-US",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("seed subscription: %v", err)
}
s.Notify(context.Background(), "u", "d", push.Notification{
Type: push.KindTransferIncoming,
Params: map[string]string{"sender": "Mac", "filename": "a.pdf"},
Tag: "transfer:1",
URL: "/transfers",
})
// Path must be /3/device/{token}.
wantPath := "/3/device/" + deviceToken
if rec.lastPath != wantPath {
t.Errorf("path = %q, want %q", rec.lastPath, wantPath)
}
// Authorization header must start with "bearer ".
if !strings.HasPrefix(rec.lastAuth, "bearer ") {
t.Errorf("Authorization = %q, want bearer ...", rec.lastAuth)
}
// APNs headers.
if rec.lastTopic != topic {
t.Errorf("apns-topic = %q, want %q", rec.lastTopic, topic)
}
if rec.lastPushType != "alert" {
t.Errorf("apns-push-type = %q, want alert", rec.lastPushType)
}
// JSON body shape.
var body apsPayload
if err := json.Unmarshal(rec.lastBody, &body); err != nil {
t.Fatalf("parse body: %v (raw: %s)", err, rec.lastBody)
}
if body.Type != push.KindTransferIncoming {
t.Errorf("body.type = %q, want %q", body.Type, push.KindTransferIncoming)
}
if body.APS.Alert.Title != "Incoming file" {
t.Errorf("body.aps.alert.title = %q, want Incoming file", body.APS.Alert.Title)
}
if !strings.Contains(body.APS.Alert.Body, "a.pdf") {
t.Errorf("body.aps.alert.body = %q, want mention of a.pdf", body.APS.Alert.Body)
}
if body.URL != "/transfers" {
t.Errorf("body.url = %q, want /transfers", body.URL)
}
if body.APS.Sound != "default" {
t.Errorf("body.aps.sound = %q, want default", body.APS.Sound)
}
}
// TestSend410PrunesRow verifies that an HTTP 410 response from the APNs
// endpoint causes the subscription row to be deleted from the database.
func TestSend410PrunesRow(t *testing.T) {
const deviceToken = "deadbeef410token"
q := openTestDB(t)
keyPEM, _ := generateTestKey(t)
rec := newAPNsTestServer(t, http.StatusGone) // 410 = Unregistered
s := NewSender(q, keyPEM, "KID", "TID", "com.example.app", "sandbox")
s.base = rec.srv.URL
now := time.Now().Unix()
subID := push.SubscriptionID(deviceToken)
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: subID,
UserID: "u",
DeviceName: "d",
Endpoint: deviceToken,
Locale: "zh-CN",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("seed subscription: %v", err)
}
s.Notify(context.Background(), "u", "d", push.Notification{
Type: push.KindMessage,
Params: map[string]string{
"sender": "Phone",
"text": "hello",
},
})
// The row must be gone.
subs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "d",
})
if err != nil {
t.Fatalf("list subs: %v", err)
}
if len(subs) != 0 {
t.Errorf("subscription row should be pruned on 410, got %d rows", len(subs))
}
}
// TestWebPushRowsNotReturnedByAPNsQuery asserts that WebPush-platform rows are
// invisible to ListAPNsSubscriptionsByUserDevice and vice versa.
func TestWebPushRowsNotReturnedByAPNsQuery(t *testing.T) {
q := openTestDB(t)
now := time.Now().Unix()
// Insert a webpush row.
const webEndpoint = "https://fcm.googleapis.com/fake"
if err := q.UpsertPushSubscription(context.Background(), db.UpsertPushSubscriptionParams{
ID: push.SubscriptionID(webEndpoint),
UserID: "u",
DeviceName: "d",
Endpoint: webEndpoint,
P256dh: "p",
Auth: "a",
UserAgent: "",
Locale: "en-US",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("upsert webpush: %v", err)
}
// APNs query must not see the webpush row.
apnsSubs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u", DeviceName: "d",
})
if err != nil {
t.Fatalf("list apns: %v", err)
}
if len(apnsSubs) != 0 {
t.Errorf("APNs query returned %d row(s), want 0 (webpush rows must be filtered)", len(apnsSubs))
}
// WebPush query must not see apns rows.
const apnsToken = "aabbccdd1234"
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(apnsToken),
UserID: "u",
DeviceName: "d",
Endpoint: apnsToken,
Locale: "zh-CN",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("upsert apns: %v", err)
}
webSubs, err := q.ListPushSubscriptionsByUserDevice(context.Background(), db.ListPushSubscriptionsByUserDeviceParams{
UserID: "u", DeviceName: "d",
})
if err != nil {
t.Fatalf("list webpush: %v", err)
}
if len(webSubs) != 1 {
t.Errorf("webpush query returned %d row(s), want 1 (apns rows must be filtered)", len(webSubs))
}
}
+57
View File
@@ -13,6 +13,11 @@ import (
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
) )
// errAPNsHalfConfig is returned when some but not all APNs fields are set.
var errAPNsHalfConfig = errors.New(
"CDROP_APNS_KEY_PATH, CDROP_APNS_KEY_ID, CDROP_APNS_TEAM_ID, and CDROP_APNS_TOPIC " +
"must all be set together, or none at all (partial config is always an operator error)")
const envPrefix = "CDROP_" const envPrefix = "CDROP_"
type Config struct { type Config struct {
@@ -86,6 +91,24 @@ type Config struct {
VAPIDPrivateKey string `koanf:"vapid_private_key"` VAPIDPrivateKey string `koanf:"vapid_private_key"`
VAPIDSubject string `koanf:"vapid_subject"` VAPIDSubject string `koanf:"vapid_subject"`
// APNs (Apple Push Notification service): iOS device push channel.
// All four fields must be set together or none (partial config is an error).
// APNSKeyPath — filesystem path to the .p8 (PEM PKCS8 ES256) key Apple issues.
// APNSKeyID — the 10-character Key ID from the Apple Developer portal.
// APNSTeamID — the 10-character Team ID from the Apple Developer portal.
// APNSTopic — the app bundle ID (e.g. "net.commilitia.cdrop").
// APNSEnv — "prod" (default) or "sandbox" (dev/TestFlight builds).
// When unset, APNs is disabled and the register endpoint returns 503.
APNSKeyPath string `koanf:"apns_key_path"`
APNSKeyID string `koanf:"apns_key_id"`
APNSTeamID string `koanf:"apns_team_id"`
APNSTopic string `koanf:"apns_topic"`
APNSEnv string `koanf:"apns_env"`
// apnsKeyPEM is the loaded .p8 file contents, populated by Load() when
// APNSKeyPath is set. Callers read it via APNSKeyPEM().
apnsKeyPEM []byte
// 扫码登录(QR):新设备出码、已登录设备扫码批准,经 Auth Broker 委托签发会话。 // 扫码登录(QR):新设备出码、已登录设备扫码批准,经 Auth Broker 委托签发会话。
// QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503。QRRequestTTLSeconds 是一条扫码 // QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503。QRRequestTTLSeconds 是一条扫码
// 请求的存活秒数(默认 120)。批准后铸的会话寿命取上面的按档 TTL(full / guest)。 // 请求的存活秒数(默认 120)。批准后铸的会话寿命取上面的按档 TTL(full / guest)。
@@ -151,6 +174,17 @@ func Load() (*Config, error) {
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
return nil, err return nil, err
} }
// Load the APNs .p8 key file after validation (validate() already confirmed
// that if APNSKeyPath is set, all other APNs fields are also set).
if cfg.APNSKeyPath != "" {
pem, err := os.ReadFile(cfg.APNSKeyPath)
if err != nil {
return nil, fmt.Errorf("read CDROP_APNS_KEY_PATH %q: %w", cfg.APNSKeyPath, err)
}
cfg.apnsKeyPEM = pem
}
return cfg, nil return cfg, nil
} }
@@ -195,9 +229,32 @@ func (c *Config) validate() error {
"CDROP_VAPID_PUBLIC_KEY and CDROP_VAPID_PRIVATE_KEY must be set together " + "CDROP_VAPID_PUBLIC_KEY and CDROP_VAPID_PRIVATE_KEY must be set together " +
"(run `just vapid-keygen`); set neither to disable Web Push") "(run `just vapid-keygen`); set neither to disable Web Push")
} }
// APNs is optional, but partial config is always an operator error: all four
// fields must be present for APNs to function. Count how many are set; any
// non-zero count that is not four is a mistake.
apnsSet := 0
for _, v := range []string{c.APNSKeyPath, c.APNSKeyID, c.APNSTeamID, c.APNSTopic} {
if v != "" {
apnsSet++
}
}
if apnsSet > 0 && apnsSet < 4 {
return errAPNsHalfConfig
}
return nil return nil
} }
// APNSKeyPEM returns the loaded .p8 key contents. Empty when APNs is
// unconfigured (APNSKeyPath was not set).
func (c *Config) APNSKeyPEM() []byte { return c.apnsKeyPEM }
// APNSEnabled reports whether all APNs fields are configured.
func (c *Config) APNSEnabled() bool {
return c.APNSKeyPath != "" && c.APNSKeyID != "" && c.APNSTeamID != "" && c.APNSTopic != ""
}
// PushEnabled reports whether Web Push is configured (both VAPID keys present). // PushEnabled reports whether Web Push is configured (both VAPID keys present).
func (c *Config) PushEnabled() bool { func (c *Config) PushEnabled() bool {
return c.VAPIDPublicKey != "" && c.VAPIDPrivateKey != "" return c.VAPIDPublicKey != "" && c.VAPIDPrivateKey != ""
+6
View File
@@ -59,6 +59,12 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
"ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil { "ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil {
return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err) return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err)
} }
// push_subscriptions gained a platform column to distinguish Web Push (VAPID)
// from APNs (iOS device token) rows. Existing rows default to 'webpush'.
if err := ensureColumn(ctx, tx, "push_subscriptions", "platform",
"ALTER TABLE push_subscriptions ADD COLUMN platform TEXT NOT NULL DEFAULT 'webpush'"); err != nil {
return fmt.Errorf("migrate push_subscriptions.platform: %w", err)
}
// devices moved to a stable opaque device_id primary key (Auth Broker path A); // devices moved to a stable opaque device_id primary key (Auth Broker path A);
// the old table was keyed (user_id, name). Device rows are ephemeral registrations // the old table was keyed (user_id, name). Device rows are ephemeral registrations
// re-created on the next request / scan, so a legacy table is dropped and recreated // re-created on the next request / scan, so a legacy table is dropped and recreated
@@ -0,0 +1,7 @@
-- Additive migration: add platform column to push_subscriptions to distinguish
-- Web Push (VAPID, browser/PWA) from APNs (iOS device token) rows.
-- This file is the sqlc-facing schema declaration; it is NOT embedded or run
-- directly. At runtime the column is added by ensureColumn(...,"platform",...) in
-- bootstrap.go (idempotent ALTER). Note: 0001_init.sql's CREATE TABLE does NOT
-- include this column — bootstrap's ensureColumn is the sole source on fresh DBs.
ALTER TABLE push_subscriptions ADD COLUMN platform TEXT NOT NULL DEFAULT 'webpush';
+1
View File
@@ -53,6 +53,7 @@ type PushSubscription struct {
Locale string `json:"locale"` Locale string `json:"locale"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"` LastUsedAt int64 `json:"last_used_at"`
Platform string `json:"platform"`
} }
type TransferSession struct { type TransferSession struct {
+125 -15
View File
@@ -34,26 +34,40 @@ func (q *Queries) DeletePushSubscriptionsByUserDevice(ctx context.Context, arg D
return err return err
} }
const listPushSubscriptionsByUserDevice = `-- name: ListPushSubscriptionsByUserDevice :many const listAPNsSubscriptionsByUserDevice = `-- name: ListAPNsSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at
FROM push_subscriptions FROM push_subscriptions
WHERE user_id = ? AND device_name = ? WHERE user_id = ? AND device_name = ? AND platform = 'apns'
` `
type ListPushSubscriptionsByUserDeviceParams struct { type ListAPNsSubscriptionsByUserDeviceParams struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
} }
func (q *Queries) ListPushSubscriptionsByUserDevice(ctx context.Context, arg ListPushSubscriptionsByUserDeviceParams) ([]PushSubscription, error) { type ListAPNsSubscriptionsByUserDeviceRow struct {
rows, err := q.db.QueryContext(ctx, listPushSubscriptionsByUserDevice, arg.UserID, arg.DeviceName) ID string `json:"id"`
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
Endpoint string `json:"endpoint"`
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
UserAgent string `json:"user_agent"`
Locale string `json:"locale"`
Platform string `json:"platform"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
}
func (q *Queries) ListAPNsSubscriptionsByUserDevice(ctx context.Context, arg ListAPNsSubscriptionsByUserDeviceParams) ([]ListAPNsSubscriptionsByUserDeviceRow, error) {
rows, err := q.db.QueryContext(ctx, listAPNsSubscriptionsByUserDevice, arg.UserID, arg.DeviceName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []PushSubscription var items []ListAPNsSubscriptionsByUserDeviceRow
for rows.Next() { for rows.Next() {
var i PushSubscription var i ListAPNsSubscriptionsByUserDeviceRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.UserID, &i.UserID,
@@ -63,6 +77,67 @@ func (q *Queries) ListPushSubscriptionsByUserDevice(ctx context.Context, arg Lis
&i.Auth, &i.Auth,
&i.UserAgent, &i.UserAgent,
&i.Locale, &i.Locale,
&i.Platform,
&i.CreatedAt,
&i.LastUsedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPushSubscriptionsByUserDevice = `-- name: ListPushSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at
FROM push_subscriptions
WHERE user_id = ? AND device_name = ? AND platform = 'webpush'
`
type ListPushSubscriptionsByUserDeviceParams struct {
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
}
type ListPushSubscriptionsByUserDeviceRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
Endpoint string `json:"endpoint"`
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
UserAgent string `json:"user_agent"`
Locale string `json:"locale"`
Platform string `json:"platform"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
}
func (q *Queries) ListPushSubscriptionsByUserDevice(ctx context.Context, arg ListPushSubscriptionsByUserDeviceParams) ([]ListPushSubscriptionsByUserDeviceRow, error) {
rows, err := q.db.QueryContext(ctx, listPushSubscriptionsByUserDevice, arg.UserID, arg.DeviceName)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPushSubscriptionsByUserDeviceRow
for rows.Next() {
var i ListPushSubscriptionsByUserDeviceRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.DeviceName,
&i.Endpoint,
&i.P256dh,
&i.Auth,
&i.UserAgent,
&i.Locale,
&i.Platform,
&i.CreatedAt, &i.CreatedAt,
&i.LastUsedAt, &i.LastUsedAt,
); err != nil { ); err != nil {
@@ -95,10 +170,44 @@ func (q *Queries) TouchPushSubscription(ctx context.Context, arg TouchPushSubscr
return err return err
} }
const upsertAPNsSubscription = `-- name: UpsertAPNsSubscription :exec
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
VALUES (?, ?, ?, ?, '', '', '', ?, 'apns', ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
locale = excluded.locale,
platform = excluded.platform,
last_used_at = excluded.last_used_at
`
type UpsertAPNsSubscriptionParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
Endpoint string `json:"endpoint"`
Locale string `json:"locale"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
}
func (q *Queries) UpsertAPNsSubscription(ctx context.Context, arg UpsertAPNsSubscriptionParams) error {
_, err := q.db.ExecContext(ctx, upsertAPNsSubscription,
arg.ID,
arg.UserID,
arg.DeviceName,
arg.Endpoint,
arg.Locale,
arg.CreatedAt,
arg.LastUsedAt,
)
return err
}
const upsertPushSubscription = `-- name: UpsertPushSubscription :exec const upsertPushSubscription = `-- name: UpsertPushSubscription :exec
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at) INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'webpush', ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id, user_id = excluded.user_id,
device_name = excluded.device_name, device_name = excluded.device_name,
@@ -106,6 +215,7 @@ ON CONFLICT(id) DO UPDATE SET
auth = excluded.auth, auth = excluded.auth,
user_agent = excluded.user_agent, user_agent = excluded.user_agent,
locale = excluded.locale, locale = excluded.locale,
platform = excluded.platform,
last_used_at = excluded.last_used_at last_used_at = excluded.last_used_at
` `
@@ -122,11 +232,11 @@ type UpsertPushSubscriptionParams struct {
LastUsedAt int64 `json:"last_used_at"` LastUsedAt int64 `json:"last_used_at"`
} }
// push_subscriptions: Web Push endpoints for browsers / PWAs. id is the hex // push_subscriptions: Web Push and APNs subscription records.
// SHA-256 of the endpoint, so a re-subscribe from the same browser upserts in // id is hex SHA-256(endpoint/device-token), making upsert idempotent.
// place rather than piling up duplicate rows. Lookups are by (user_id, // platform distinguishes 'webpush' (browser VAPID) from 'apns' (iOS device token).
// device_name): the receiving device of a notify-worthy event. Dead endpoints // Lookups are by (user_id, device_name, platform) so each channel only sees its own rows.
// (push service returns 404/410) are pruned by DeletePushSubscription. // Dead endpoints (push service returns 404/410, APNs returns 410) are pruned by DeletePushSubscription.
// NOTE: keep this file pure ASCII; sqlc v1.31.1 drifts byte offsets on // NOTE: keep this file pure ASCII; sqlc v1.31.1 drifts byte offsets on
// multibyte runes in query files, corrupting the generated SQL. // multibyte runes in query files, corrupting the generated SQL.
func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) error { func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) error {
+25 -9
View File
@@ -1,14 +1,14 @@
-- push_subscriptions: Web Push endpoints for browsers / PWAs. id is the hex -- push_subscriptions: Web Push and APNs subscription records.
-- SHA-256 of the endpoint, so a re-subscribe from the same browser upserts in -- id is hex SHA-256(endpoint/device-token), making upsert idempotent.
-- place rather than piling up duplicate rows. Lookups are by (user_id, -- platform distinguishes 'webpush' (browser VAPID) from 'apns' (iOS device token).
-- device_name): the receiving device of a notify-worthy event. Dead endpoints -- Lookups are by (user_id, device_name, platform) so each channel only sees its own rows.
-- (push service returns 404/410) are pruned by DeletePushSubscription. -- Dead endpoints (push service returns 404/410, APNs returns 410) are pruned by DeletePushSubscription.
-- NOTE: keep this file pure ASCII; sqlc v1.31.1 drifts byte offsets on -- NOTE: keep this file pure ASCII; sqlc v1.31.1 drifts byte offsets on
-- multibyte runes in query files, corrupting the generated SQL. -- multibyte runes in query files, corrupting the generated SQL.
-- name: UpsertPushSubscription :exec -- name: UpsertPushSubscription :exec
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at) INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'webpush', ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id, user_id = excluded.user_id,
device_name = excluded.device_name, device_name = excluded.device_name,
@@ -16,12 +16,28 @@ ON CONFLICT(id) DO UPDATE SET
auth = excluded.auth, auth = excluded.auth,
user_agent = excluded.user_agent, user_agent = excluded.user_agent,
locale = excluded.locale, locale = excluded.locale,
platform = excluded.platform,
last_used_at = excluded.last_used_at; last_used_at = excluded.last_used_at;
-- name: ListPushSubscriptionsByUserDevice :many -- name: ListPushSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at
FROM push_subscriptions FROM push_subscriptions
WHERE user_id = ? AND device_name = ?; WHERE user_id = ? AND device_name = ? AND platform = 'webpush';
-- name: ListAPNsSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at
FROM push_subscriptions
WHERE user_id = ? AND device_name = ? AND platform = 'apns';
-- name: UpsertAPNsSubscription :exec
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
VALUES (?, ?, ?, ?, '', '', '', ?, 'apns', ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
locale = excluded.locale,
platform = excluded.platform,
last_used_at = excluded.last_used_at;
-- name: TouchPushSubscription :exec -- name: TouchPushSubscription :exec
UPDATE push_subscriptions UPDATE push_subscriptions
+14 -7
View File
@@ -80,13 +80,20 @@ func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) {
}) })
if !delivered { if !delivered {
// Peer's page is closed (no live SSE). The message has no DB row, so the // Peer's page is closed (no live SSE). The message has no DB row, so the
// only delivery left is a Web Push carrying the text itself. If the peer // only delivery left is a push notification (Web Push or APNs) carrying
// has a subscription, send it and report 202; otherwise it's truly gone. // the text itself. If any push channel is enabled and may have a
if s.push.Enabled() { // subscription, send and report 202; otherwise the message is truly gone.
go s.push.Notify(context.Background(), claims.UserID, req.To, push.Notification{ n := push.Notification{
Type: push.KindMessage, Type: push.KindMessage,
Params: map[string]string{"sender": from, "text": req.Text}, Params: map[string]string{"sender": from, "text": req.Text},
}) }
if s.push.Enabled() || s.apns.Enabled() {
if s.push.Enabled() {
go s.push.Notify(context.Background(), claims.UserID, req.To, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), claims.UserID, req.To, n)
}
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
return return
} }
+60
View File
@@ -10,6 +10,10 @@ import (
"commilitia.net/cdrop/internal/push" "commilitia.net/cdrop/internal/push"
) )
// maxAPNsRegisterBytes caps the register body. A device token is 64 hex chars
// plus the locale field; 1 KB is generous headroom.
const maxAPNsRegisterBytes = 1024
// maxPushSubscribeBytes caps the subscribe/unsubscribe body. A PushSubscription // maxPushSubscribeBytes caps the subscribe/unsubscribe body. A PushSubscription
// JSON is well under 1 KB (endpoint URL + two short keys); 8 KB is generous // JSON is well under 1 KB (endpoint URL + two short keys); 8 KB is generous
// headroom while still bounding per-request memory. // headroom while still bounding per-request memory.
@@ -96,6 +100,62 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// apnsRegisterReq is the body for POST /api/push/apns/register.
// DeviceToken is the hex-encoded APNs device token the iOS app received from
// the system. Locale is optional; unknown or empty falls back to the server default.
type apnsRegisterReq struct {
DeviceToken string `json:"device_token"`
Locale string `json:"locale"`
}
// handleAPNsRegister stores (or refreshes) an iOS device's APNs token for the
// calling session's device. Idempotent: re-registering the same token upserts
// in place (SHA-256 of the token is the primary key). Returns 204 on success or
// 503 when APNs is not configured.
func (s *Server) handleAPNsRegister(w http.ResponseWriter, r *http.Request) {
if !s.apns.Enabled() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "apns disabled"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
device, _ := jwtauth.DeviceNameFromContext(r.Context())
if device == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"})
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAPNsRegisterBytes)
var req apnsRegisterReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.DeviceToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device_token"})
return
}
locale := req.Locale
if !knownLocales[locale] {
locale = "zh-CN"
}
now := time.Now().Unix()
if err := s.queries.UpsertAPNsSubscription(r.Context(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(req.DeviceToken),
UserID: claims.UserID,
DeviceName: device,
Endpoint: req.DeviceToken,
Locale: locale,
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store failed"})
return
}
w.WriteHeader(http.StatusNoContent)
}
type pushUnsubscribeReq struct { type pushUnsubscribeReq struct {
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
} }
+212
View File
@@ -0,0 +1,212 @@
package httpapi
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/push"
)
// apnsTestKeyPEM generates an ephemeral P-256 key and returns its PEM bytes so
// tests can build an enabled apns.Sender without real Apple credentials.
func apnsTestKeyPEM(t *testing.T) []byte {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
}
// newAPNsTestServer builds a minimal Server with a real in-memory SQLite DB
// and an enabled apns.Sender (using an ephemeral test key). The apns.Sender
// base is left at the default; the endpoint test only exercises DB writes, so
// the Sender never fires a real APNs request.
func newAPNsTestServer(t *testing.T) *Server {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "push_test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
q := db.New(conn)
apnsSender := apns.NewSender(q, apnsTestKeyPEM(t), "TESTKID", "TESTTEAM", "com.example.cdrop", "sandbox")
if !apnsSender.Enabled() {
t.Fatal("apns sender must be enabled for this test")
}
return &Server{
queries: q,
hub: hub.New(q),
apns: apnsSender,
}
}
// TestHandleAPNsRegister_DisabledReturns503 verifies that the register endpoint
// returns 503 when APNs is not configured (inert Sender).
func TestHandleAPNsRegister_DisabledReturns503(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "push_test2.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
q := db.New(conn)
s := &Server{
queries: q,
hub: hub.New(q),
apns: apns.NewSender(nil, nil, "", "", "", ""), // inert
}
body := `{"device_token":"aabbccdd1234"}`
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("disabled: got %d, want 503", w.Code)
}
}
// TestHandleAPNsRegister_StoresRowIdempotently verifies that POST /api/push/apns/register
// stores the subscription row and that a second call with the same token upserts
// in place (idempotent: still exactly one row).
func TestHandleAPNsRegister_StoresRowIdempotently(t *testing.T) {
s := newAPNsTestServer(t)
const deviceToken = "aabbccdd1234deadbeef"
const locale = "en-US"
callRegister := func() int {
body := `{"device_token":"` + deviceToken + `","locale":"` + locale + `"}`
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
return w.Code
}
// First registration.
if code := callRegister(); code != http.StatusNoContent {
t.Fatalf("first register: got %d, want 204", code)
}
// Verify the row exists.
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "iPhone",
})
if err != nil {
t.Fatalf("list subs: %v", err)
}
if len(subs) != 1 {
t.Fatalf("after first register: got %d rows, want 1", len(subs))
}
sub := subs[0]
if sub.Endpoint != deviceToken {
t.Errorf("endpoint = %q, want %q", sub.Endpoint, deviceToken)
}
if sub.Locale != locale {
t.Errorf("locale = %q, want %q", sub.Locale, locale)
}
if sub.Platform != "apns" {
t.Errorf("platform = %q, want apns", sub.Platform)
}
if sub.UserID != "u" {
t.Errorf("user_id = %q, want u", sub.UserID)
}
expectedID := push.SubscriptionID(deviceToken)
if sub.ID != expectedID {
t.Errorf("id = %q, want %q (SHA-256 of token)", sub.ID, expectedID)
}
// Second registration with same token: must upsert in place.
if code := callRegister(); code != http.StatusNoContent {
t.Fatalf("second register: got %d, want 204", code)
}
subs2, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "iPhone",
})
if err != nil {
t.Fatalf("list subs after second register: %v", err)
}
if len(subs2) != 1 {
t.Errorf("after second register: got %d rows, want 1 (should upsert not duplicate)", len(subs2))
}
}
// TestHandleAPNsRegister_MissingToken verifies that a missing device_token
// returns 400.
func TestHandleAPNsRegister_MissingToken(t *testing.T) {
s := newAPNsTestServer(t)
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(`{}`))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("missing token: got %d, want 400", w.Code)
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err == nil {
if resp["error"] != "missing device_token" {
t.Errorf("error msg = %q", resp["error"])
}
}
}
// TestHandleAPNsRegister_UnknownLocaleFallsBack verifies that an unrecognized
// locale falls back to the server default (zh-CN).
func TestHandleAPNsRegister_UnknownLocaleFallsBack(t *testing.T) {
s := newAPNsTestServer(t)
body := `{"device_token":"tok123456","locale":"fr-FR"}`
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
if w.Code != http.StatusNoContent {
t.Fatalf("register: got %d, want 204", w.Code)
}
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "iPhone",
})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(subs) != 1 {
t.Fatalf("want 1 row, got %d", len(subs))
}
if subs[0].Locale != "zh-CN" {
t.Errorf("locale = %q, want zh-CN (fallback)", subs[0].Locale)
}
}
+5
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate" "github.com/go-chi/httprate"
"commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/brokerclient" "commilitia.net/cdrop/internal/brokerclient"
"commilitia.net/cdrop/internal/calls" "commilitia.net/cdrop/internal/calls"
"commilitia.net/cdrop/internal/clipboard" "commilitia.net/cdrop/internal/clipboard"
@@ -36,6 +37,7 @@ type Server struct {
calls *calls.Provider // optional; nil → STUN-only fallback calls *calls.Provider // optional; nil → STUN-only fallback
clipboard *clipboard.Service // optional; nil → 503 on /api/clipboard clipboard *clipboard.Service // optional; nil → 503 on /api/clipboard
push *push.Sender // inert when VAPID keys unset → push endpoints 503 push *push.Sender // inert when VAPID keys unset → push endpoints 503
apns *apns.Sender // inert when APNs keys unset → register endpoint 503
mux *chi.Mux mux *chi.Mux
// broker delegates session lifecycle to the Auth Broker (mint / revoke / refresh). // broker delegates session lifecycle to the Auth Broker (mint / revoke / refresh).
@@ -56,6 +58,7 @@ func New(
cp *calls.Provider, cp *calls.Provider,
clip *clipboard.Service, clip *clipboard.Service,
pushSender *push.Sender, pushSender *push.Sender,
apnsSender *apns.Sender,
) *Server { ) *Server {
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
@@ -68,6 +71,7 @@ func New(
calls: cp, calls: cp,
clipboard: clip, clipboard: clip,
push: pushSender, push: pushSender,
apns: apnsSender,
mux: chi.NewRouter(), mux: chi.NewRouter(),
broker: brokerclient.New(cfg.BrokerBaseURL, cfg.BrokerInternalKey, cfg.BrokerAppOrDefault()), broker: brokerclient.New(cfg.BrokerBaseURL, cfg.BrokerInternalKey, cfg.BrokerAppOrDefault()),
@@ -147,6 +151,7 @@ func (s *Server) routes() {
r.Get("/push/vapid-key", s.handlePushVAPIDKey) r.Get("/push/vapid-key", s.handlePushVAPIDKey)
r.Post("/push/subscribe", s.handlePushSubscribe) r.Post("/push/subscribe", s.handlePushSubscribe)
r.Delete("/push/subscribe", s.handlePushUnsubscribe) r.Delete("/push/subscribe", s.handlePushUnsubscribe)
r.Post("/push/apns/register", s.handleAPNsRegister)
r.Get("/calls/credentials", s.handleCallsCredentials) r.Get("/calls/credentials", s.handleCallsCredentials)
// Full sessions only — restricted guests (scan-login borrows) are // Full sessions only — restricted guests (scan-login borrows) are
+7
View File
@@ -68,6 +68,13 @@ func DeviceNameFromContext(ctx context.Context) (string, bool) {
return n, ok return n, ok
} }
// ContextWithDeviceName attaches a device name to the context — the inverse of
// DeviceNameFromContext. Used by tests that exercise handlers directly without
// running the full auth middleware.
func ContextWithDeviceName(ctx context.Context, name string) context.Context {
return context.WithValue(ctx, deviceCtxKey, name)
}
// DeviceTypeFromContext returns the client-declared device type set by the auth // DeviceTypeFromContext returns the client-declared device type set by the auth
// middleware (browser / macos / windows / linux / ios), defaulting to "browser". // middleware (browser / macos / windows / linux / ios), defaulting to "browser".
func DeviceTypeFromContext(ctx context.Context) string { func DeviceTypeFromContext(ctx context.Context) string {
+13 -6
View File
@@ -137,7 +137,7 @@ func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n Notifi
} }
} }
func (s *Sender) send(ctx context.Context, sub db.PushSubscription, payload []byte) { func (s *Sender) send(ctx context.Context, sub db.ListPushSubscriptionsByUserDeviceRow, payload []byte) {
resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{ resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
Endpoint: sub.Endpoint, Endpoint: sub.Endpoint,
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth}, Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
@@ -192,7 +192,10 @@ func endpointHost(endpoint string) string {
return u.Host return u.Host
} }
const defaultLocale = "zh-CN" // 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 // notifyStrings holds the few server-rendered notification templates, keyed by
// locale then string id. The set mirrors the frontend i18n only for these push // locale then string id. The set mirrors the frontend i18n only for these push
@@ -219,9 +222,9 @@ var notifyStrings = map[string]map[string]string{
} }
// render turns an intent into the wire payload, localized to the subscription's // render turns an intent into the wire payload, localized to the subscription's
// stored locale. Unknown locales fall back to defaultLocale. // stored locale. Unknown locales fall back to DefaultLocale.
func render(n Notification, locale string) wirePayload { func render(n Notification, locale string) wirePayload {
title, body := localize(n.Type, n.Params, locale) title, body := Localize(n.Type, n.Params, locale)
out := wirePayload{Type: n.Type, Title: title, Body: body, Tag: n.Tag, URL: n.URL} out := wirePayload{Type: n.Type, Title: title, Body: body, Tag: n.Tag, URL: n.URL}
if out.URL == "" { if out.URL == "" {
out.URL = "/" out.URL = "/"
@@ -229,7 +232,11 @@ func render(n Notification, locale string) wirePayload {
return out return out
} }
func localize(typ string, params map[string]string, locale string) (title, body string) { // 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 // 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. // own text — neither is a template, so locale is irrelevant.
if typ == KindMessage { if typ == KindMessage {
@@ -238,7 +245,7 @@ func localize(typ string, params map[string]string, locale string) (title, body
t, ok := notifyStrings[locale] t, ok := notifyStrings[locale]
if !ok { if !ok {
t = notifyStrings[defaultLocale] t = notifyStrings[DefaultLocale]
} }
switch typ { switch typ {
case KindTransferIncoming: case KindTransferIncoming:
+1 -1
View File
@@ -107,7 +107,7 @@ func TestRenderUnknownLocaleFallsBackToDefault(t *testing.T) {
Params: map[string]string{"filename": "a.pdf"}, Params: map[string]string{"filename": "a.pdf"},
} }
got := render(n, "fr-FR") got := render(n, "fr-FR")
want := render(n, defaultLocale) want := render(n, DefaultLocale)
if got.Title != want.Title || got.Body != want.Body { if got.Title != want.Title || got.Body != want.Body {
t.Fatalf("unknown locale did not fall back: got %+v want %+v", got, want) t.Fatalf("unknown locale did not fall back: got %+v want %+v", got, want)
} }
+30 -7
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"time" "time"
"commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub" "commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/push" "commilitia.net/cdrop/internal/push"
@@ -49,12 +50,13 @@ type Service struct {
hub *hub.Hub hub *hub.Hub
relay RelayController relay RelayController
push *push.Sender // inert when Web Push is unconfigured; nil-safe push *push.Sender // inert when Web Push is unconfigured; nil-safe
apns *apns.Sender // inert when APNs is unconfigured; nil-safe
now func() time.Time now func() time.Time
newID func() string newID func() string
} }
func NewService(queries *db.Queries, h *hub.Hub, r RelayController, pushSender *push.Sender) *Service { func NewService(queries *db.Queries, h *hub.Hub, r RelayController, pushSender *push.Sender, apnsSender *apns.Sender) *Service {
if r == nil { if r == nil {
r = nopRelay{} r = nopRelay{}
} }
@@ -63,6 +65,7 @@ func NewService(queries *db.Queries, h *hub.Hub, r RelayController, pushSender *
hub: h, hub: h,
relay: r, relay: r,
push: pushSender, push: pushSender,
apns: apnsSender,
now: time.Now, now: time.Now,
newID: defaultNewID, newID: defaultNewID,
} }
@@ -142,13 +145,21 @@ func (s *Service) Init(ctx context.Context, p InitParams) (string, error) {
}, },
}) })
// Receiver's page is closed → no SSE → raise a system notification instead. // Receiver's page is closed → no SSE → raise a system notification instead.
if !delivered && s.push.Enabled() { // Both Web Push (browser/PWA) and APNs (iOS) are attempted; each sender
go s.push.Notify(context.Background(), p.UserID, p.ReceiverName, push.Notification{ // only fires for its own platform rows, so firing both is always correct.
if !delivered {
n := push.Notification{
Type: push.KindTransferIncoming, Type: push.KindTransferIncoming,
Params: map[string]string{"sender": p.SenderName, "filename": p.FileName}, Params: map[string]string{"sender": p.SenderName, "filename": p.FileName},
Tag: "transfer:" + id, Tag: "transfer:" + id,
URL: "/", URL: "/",
}) }
if s.push.Enabled() {
go s.push.Notify(context.Background(), p.UserID, p.ReceiverName, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), p.UserID, p.ReceiverName, n)
}
} }
return id, nil return id, nil
@@ -219,7 +230,9 @@ func (s *Service) Transition(
deliveredReceiver := s.hub.SendTo(userID, sess.ReceiverName, stateEv) deliveredReceiver := s.hub.SendTo(userID, sess.ReceiverName, stateEv)
// Terminal completion/failure → notify any party whose page has closed. // Terminal completion/failure → notify any party whose page has closed.
if s.push.Enabled() && (target == StateDone || target == StateFailed) { // Both Web Push and APNs are attempted; disjoint platform rows mean
// firing both is always correct — each sender no-ops for the other's rows.
if (target == StateDone || target == StateFailed) && (s.push.Enabled() || s.apns.Enabled()) {
kind := push.KindTransferDone kind := push.KindTransferDone
if target == StateFailed { if target == StateFailed {
kind = push.KindTransferFailed kind = push.KindTransferFailed
@@ -230,10 +243,20 @@ func (s *Service) Transition(
Tag: "transfer:" + sessionID, Tag: "transfer:" + sessionID,
} }
if !deliveredSender { if !deliveredSender {
go s.push.Notify(context.Background(), userID, sess.SenderName, n) if s.push.Enabled() {
go s.push.Notify(context.Background(), userID, sess.SenderName, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), userID, sess.SenderName, n)
}
} }
if !deliveredReceiver { if !deliveredReceiver {
go s.push.Notify(context.Background(), userID, sess.ReceiverName, n) if s.push.Enabled() {
go s.push.Notify(context.Background(), userID, sess.ReceiverName, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), userID, sess.ReceiverName, n)
}
} }
} }
+2 -2
View File
@@ -51,7 +51,7 @@ func newTestService(t *testing.T) *Service {
q := openTestDB(t) q := openTestDB(t)
h := newTestHub() h := newTestHub()
t.Cleanup(h.Close) t.Cleanup(h.Close)
return NewService(q, h, nil, nil) return NewService(q, h, nil, nil, nil)
} }
// fakeRelay records Register/Release calls and can be primed to fail Register, // fakeRelay records Register/Release calls and can be primed to fail Register,
@@ -78,7 +78,7 @@ func newTestServiceWithRelay(t *testing.T, r RelayController) *Service {
q := openTestDB(t) q := openTestDB(t)
h := newTestHub() h := newTestHub()
t.Cleanup(h.Close) t.Cleanup(h.Close)
return NewService(q, h, r, nil) return NewService(q, h, r, nil, nil)
} }
func TestInit_AllowsZeroByteFile(t *testing.T) { func TestInit_AllowsZeroByteFile(t *testing.T) {
+3
View File
@@ -6,3 +6,6 @@ build/
DerivedData/ DerivedData/
*.xcuserstate *.xcuserstate
.DS_Store .DS_Store
# 真机签名的账号特定值(Team ID + profile 名)——公开仓库不留账号标识,见 REALDEVICE.md。
Local.xcconfig
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- APNs 推送:注册远程通知需此 entitlement。模拟器(iOS 16+ / Apple Silicon)下 development
即可取令牌、走 sandbox;真机须付费 ADP 开 Push 能力配 profile(账号门控)。 -->
<key>aps-environment</key>
<string>development</string>
<!-- App Group:与 Share Extension 共享待发文件收件箱(见 AppGroup.swift / PLAN §I5)。
模拟器无需签名即生效;真机须付费 ADP 注册该 group(账号门控)。 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.net.commilitia.cdrop</string>
</array>
</dict>
</plist>
+79 -31
View File
@@ -1,40 +1,88 @@
# cdrop iOS 真机自测手册 # cdrop iOS 真机分发手册
账号到位即可照做。模拟器构建不需签名,已全程 compile + 跑通;**真机**要付费 ADP + 签名 + 实体 iPhone,下面是 turnkey 步骤 + 只能真机验的清单 把 cdrop 装到你自己的 iPhone(开发签名,1 年 profile)。代码侧全部就绪并经模拟器验证;真机只差**账号 + 门户 + 签名**这一层。本手册走**手动签名 + 全 CLI**路线——门户你手动建(一次),构建装机一条命令,**全程不碰 Xcode GUI**
## 包名与标识(门户里要用的)
| 项 | 值 |
|---|---|
| 主 app Bundle ID | `net.commilitia.Commilitia-Drop` |
| Share Extension | `net.commilitia.Commilitia-Drop.share` |
| 控件扩展 | `net.commilitia.Commilitia-Drop.widgets` |
| App Group | `group.net.commilitia.cdrop`(与包名相互独立,刻意不同名) |
| APNs topic(服务端 `CDROP_APNS_TOPIC` | `net.commilitia.Commilitia-Drop` |
## 前置 ## 前置
- 付费 Apple Developer Program$99/年)。 - 付费 Apple Developer Program$99/年)。
- 一台 iPhoneiOS 26MacXcode 26。 - 一台 iPhoneiOS 26+ MacXcode 26 命令行工具)
- 已登录的 cdrop(网页 / 桌面)一个——用来扫码批准本机登录。 - 一个已登录的 cdrop(网页 / 桌面)——用来扫码批准本机登录。
## 出工程 + 签名 ---
1. `cd ios/CDrop && xcodegen generate`(从 `project.yml` 生成 `CDrop.xcodeproj`)。
2. Xcode 打开 `CDrop.xcodeproj` → 选 `CDrop` target → Signing & Capabilities
- **Team** 选你的 ADP 团队;**Bundle Identifier** 用你账号下可注册的(默认 `net.commilitia.cdrop`,前缀按你的改)。
- 让 Xcode 自动管理签名(Automatically manage signing)。
3. 连 iPhone、信任、选作运行目标 → Run。
- `project.yml``CODE_SIGNING_ALLOWED: NO` 是给模拟器的;Xcode 真机自动签名会覆盖它,无需手改。
## 引擎可达性(让传输真能连 ## A. 门户操作(developer.apple.com/account,一次性
- 真机上引擎 WebView 默认加载 `https://drop.commilitia.net/engine.html`——**该文件要先随 web 部署到 prod**(`vite build` 已产出 `dist/engine.html`,随正常 web 发布上线)。
- 或本机联调:设环境变量 `CDROP_ENGINE_URL` 指向可达引擎、`CDROP_API_BASE` 指向后端。
## 后续功能要补的 capability(按需,各自要在 Developer Portal 配 App ID > 入口:登录后左侧 **Certificates, Identifiers & Profiles**(证书 / 标识符 / 描述文件总枢纽)——A1–A5 都在这里面;A6 在 **Keys**Team ID 在 **Membership details**10 位,记下)。
- **App Groups**`group.net.commilitia.cdrop`):Share Extension / 控制中心控件与主 app 共享数据。
- **Push Notifications** + APNs `.p8`:传输事件推送。
- **Keychain Sharing**:跨 app / 扩展共享登录态。
- **Associated Domains**:若改用 OIDC Universal Link 回调(扫码登录则不需要)。
## 只能真机验的清单 1. **证书**Apple Development):本机「钥匙串访问」可能已有(「我的证书」分类里看)。没有就:钥匙串访问菜单 → 证书助理 → 从证书颁发机构请求证书 → 存到磁盘 → 门户 **Certificates** → Apple Development → 传 CSR → 下载 `.cer` 双击安装。
- [ ] 液态玻璃在真机渲染(含陀螺仪高光 / 动效)。 2. **App IDs ×3****Identifiers** → → App IDs → AppBundle ID 选 **Explicit**):
- [ ] 扫码登录:本机显码 → 用已登录 cdrop 扫码批准 → app 进入主界面。 - `net.commilitia.Commilitia-Drop` — 勾 **App Groups** + **Push Notifications**
- [ ] **本地网络权限**:首次同内网传输弹 `NSLocalNetworkUsageDescription` 提示;授予后 ICE 收到 host 候选(R-iOS-6);拒绝则回退中继仍可传。 - `net.commilitia.Commilitia-Drop.share` — 勾 **App Groups**
- [ ] 发送:选文件 → 选设备 → 对端收到。 - `net.commilitia.Commilitia-Drop.widgets` — 勾 **App Groups**
- [ ] 接收:对端发来 → 落到 Files(沙盒 Documents)。 3. **App Group****Identifiers** 页类型筛选切到 **App Groups**`group.net.commilitia.cdrop`。再回上面 3 个 App ID 各自的 App Groups 能力里 **Edit/Configure** 关联它(三个都要)。
- [ ] 后台:传输中切后台 → `BGContinuedProcessingTask` 续传行为(R-iOS-1 / R-iOS-3)。 4. **设备****Devices** → → 填 UDID(插上手机后 `just ios-devices`)。
- [ ] 大文件:发送侧内存(当前整文件入内存,大文件需转分块流式 = R-iOS-4) 5. **描述文件 ×3****Profiles** → **iOS App Development**,非 Distribution):每个选对应 App ID + 证书 + 设备,命名清楚(建议 `CDrop Dev` / `CDrop Share Dev` / `CDrop Widgets Dev`)→ 下载 → 双击安装
6. **APNs Auth Key****Keys** → → 勾 Apple Push Notifications service):下载 `AuthKey_XXXXX.p8`(仅一次),记 **Key ID** + **Issuer ID**(服务端发推送用,见 D)。这把 `.p8` 与 A1 的证书是两码事。
## 已知 TODO(真机阶段一并收) > 常见坑:Bundle ID 选 **Explicit** 不是 WildcardApp Group 务必 3 个 App ID 都关联;Profile 选 **Development** 不要 Distribution。
- 会话 Keychain 持久化 + 自动续期(当前会话在内存、重启需重扫;续期端点 `/api/auth/refresh` 已可用,靠 cookie)。
- 发送侧大文件分块流式(R-iOS-4)。 ---
- 剪贴板 App Intent + 控制中心控件(决策 C)、Share Extension——需上面的 capability + 账号才能跑。
## B. 本地签名配置(gitignore,填一次)
`ios/CDrop/Local.xcconfig` 写入(此文件已 gitignore,公开仓库不留你的账号标识):
```
CDROP_TEAM_ID = ABCDE12345
CDROP_PROFILE_APP = CDrop Dev
CDROP_PROFILE_SHARE = CDrop Share Dev
CDROP_PROFILE_WIDGETS = CDrop Widgets Dev
```
- `CDROP_TEAM_ID`A0 的 Team ID;三个 `CDROP_PROFILE_*`A5 你起的 profile 名(**名字**,不是文件路径)。
- 机制:`Signing.xcconfig`(已提交)仅对 **device SDKiphoneos** 套用手动签名 + 这些值;**模拟器**仍走 `project.yml` 的 ad-hoc`CODE_SIGN_IDENTITY = "-"`),故缺 `Local.xcconfig` 也不影响模拟器构建。
---
## C. 构建装机(全 CLI
```sh
just ios-devices # 手机插 USB,读 UDID(填进门户 A4 + 下行)
just ios-device <你的设备UDID> # 真机构建(手动签名)+ devicectl 装机
```
- `just ios-device` `xcodegen generate``xcodebuild`device,手动签名走 `Local.xcconfig`)→ `xcrun devicectl device install app`。无 Xcode GUI。
- 首次装机后,iPhone 上首启该开发者 app 即可直接跑(付费 ADP 开发证书,无需手动「信任开发者」)。
---
## D. 服务端前置
1. **引擎可达**:真机上引擎 WebView 加载 `https://drop.commilitia.net/engine.html`——**该文件随 cdrop 二进制部署到 prod**`//go:embed` 进 binary`just docker-image` 含最新 `dist`)。手机要用,prod 须是含本轮改动的最新部署。
2. **APNs 真发**:把 A6 的 `.p8` 放到服务器,给后端容器配 `CDROP_APNS_KEY_PATH` / `CDROP_APNS_KEY_ID` / `CDROP_APNS_TEAM_ID` / `CDROP_APNS_TOPIC=net.commilitia.Commilitia-Drop` / `CDROP_APNS_ENV`Xcode 开发构建的 device token 属 **sandbox**,故联调填 `sandbox`)。缺配置则推送惰性关闭,其余功能照常。
3. 本机联调可选:环境变量 `CDROP_ENGINE_URL` 指向可达引擎。
---
## E. 只能真机验的清单
代码已实现,下列是真机才能验的点:
- [ ] 液态玻璃真机渲染(陀螺仪高光 / 动效)。
- [ ] 扫码登录:本机显码 → 已登录 cdrop 扫码批准 → 进主界面(强制 full/persist)。
- [ ] **本地网络权限 + 同内网直连**R-iOS-6):首次同内网传输弹 `NSLocalNetworkUsageDescription`;授予后 ICE 收 host 候选、走真直连;拒绝则回退中继仍可传。
- [ ] 发送 / 接收:选文件 → 选设备 → 对端收到;对端发来落 Files(Documents)。
- [ ] **大文件流式发送**(R-iOS-4 已实现):发大文件,WebView 内存应有界(按 Range 块拉取,不整文件入内存)。
- [ ] **后台续传**R-iOS-1 / R-iOS-3):传输中切后台 → `BGContinuedProcessingTask` 系统进度 UI;验 WKWebView JS 是否随之保活(不保活则退化为回前台续传,可接受)。
- [ ] **APNs 推送**:离线设备收「收到文件」通知(需 D2 的 `.p8`)。
- [ ] **Share Extension**:别的 app 分享 → 选 Commilitia Drop → 唤起主 app 选设备发送。
- [ ] **控制中心剪贴板两控件**:控制中心加「上传 / 拉取剪贴板」控件 → 锁屏 / 解锁态点按 → 云剪贴板读写。
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 与主 app 共享的 App GroupShare Extension 把待发文件搬进收件箱(见 AppGroup.swift)。 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.net.commilitia.cdrop</string>
</array>
</dict>
</plist>
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Commilitia Drop</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>20</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>20</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>20</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
+77
View File
@@ -0,0 +1,77 @@
import UIKit
import UniformTypeIdentifiers
// Share Extension share sheet App Group app
// 120MB jetsam PLAN §I5 compose UI
//
final class ShareViewController: UIViewController
{
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
Task { await processAndClose() }
}
private func processAndClose() async
{
let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
var staged = 0
for item in items
{
for provider in item.attachments ?? []
{
if await stage(provider) { staged += 1 }
}
}
let ctx = extensionContext
guard staged > 0, let url = URL(string: "cdrop://share") else
{
ctx?.completeRequest(returningItems: nil)
return
}
// app open completeRequest completeRequest
// self open openViaResponder L8 ctx 使
//
ctx?.open(url)
{ [weak self] ok in
if !ok { self?.openViaResponder(url) }
ctx?.completeRequest(returningItems: nil)
}
}
// App Group loadFileRepresentation
// / /
//
private func stage(_ provider: NSItemProvider) async -> Bool
{
return await withCheckedContinuation
{ cont in
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier)
{ url, _ in
guard let url, let dest = AppGroup.stagedDestination(originalName: url.lastPathComponent)
else { cont.resume(returning: false); return }
do
{
try FileManager.default.copyItem(at: url, to: dest)
cont.resume(returning: true)
}
catch
{
cont.resume(returning: false)
}
}
}
}
// open 沿 UIApplication app open
private func openViaResponder(_ url: URL)
{
var responder: UIResponder? = self
let sel = NSSelectorFromString("openURL:")
while let r = responder
{
if r.responds(to: sel) { _ = r.perform(sel, with: url); return }
responder = r.next
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
// App Group app Share Extension
// 120MB jetsam PLAN §I5 / D target xcodegen
// target
//
// <uuid>
// /
enum AppGroup
{
static let identifier = "group.net.commilitia.cdrop"
// App Group nil
static func inboxURL() -> URL?
{
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
else { return nil }
let inbox = base.appendingPathComponent("SharedInbox", isDirectory: true)
try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true)
return inbox
}
// URL<inbox>/<uuid>/<originalName>
static func stagedDestination(originalName: String) -> URL?
{
guard let inbox = inboxURL() else { return nil }
let dir = inbox.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let safeName = originalName.isEmpty ? "file" : originalName
return dir.appendingPathComponent(safeName)
}
// app remove
static func inboxFiles() -> [URL]
{
guard let inbox = inboxURL() else { return [] }
let dirs = (try? FileManager.default.contentsOfDirectory(at: inbox, includingPropertiesForKeys: nil)) ?? []
return dirs.compactMap
{ dir in
guard dir.hasDirectoryPath else { return nil }
let inner = (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
return inner.first { !$0.hasDirectoryPath }
}
}
// / <uuid>
static func removeStaged(_ fileURL: URL)
{
try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
}
//
// 6 app
static func pruneStale(olderThan seconds: TimeInterval = 6 * 60 * 60)
{
guard let inbox = inboxURL() else { return }
let dirs = (try? FileManager.default.contentsOfDirectory(
at: inbox, includingPropertiesForKeys: [ .creationDateKey ])) ?? []
let cutoff = Date().addingTimeInterval(-seconds)
for dir in dirs
{
let created = (try? dir.resourceValues(forKeys: [ .creationDateKey ]).creationDate) ?? .distantPast
if created < cutoff { try? FileManager.default.removeItem(at: dir) }
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import Foundation
// app +
enum CDropAPI
{
// / EngineController.engineURL URL
static let base = "https://drop.commilitia.net"
private static let widgetDeviceIDKey = "cdrop.widget.device_id"
// device_iddev_ + [A-Za-z0-9_-] validDeviceID
// App Group UserDefaults app
static func widgetDeviceID() -> String
{
let defaults = UserDefaults(suiteName: AppGroup.identifier) ?? .standard
if let existing = defaults.string(forKey: widgetDeviceIDKey) { return existing }
let id = "dev_widget" + UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16)
defaults.set(id, forKey: widgetDeviceIDKey)
return id
}
// id device_id 使
// UI L2
static func clearWidgetDeviceID()
{
UserDefaults(suiteName: AppGroup.identifier)?.removeObject(forKey: widgetDeviceIDKey)
}
}
@@ -1,8 +1,9 @@
import Foundation import Foundation
// i18n app bundle <locale>.json web web/scripts/emit-i18n.mjs // i18n bundle <locale>.json web web/scripts/emit-i18n.mjs
// ios/PLAN.md §5退 zh-CN JSON 退 // ios/PLAN.md §5退 zh-CN JSON 退
// zh-CN loader locale // zh-CN loader localeShared app / Share Extension /
// target bundle i18n JSONresourceURL 退
enum I18n enum I18n
{ {
private static let dict: [String: String] = load() private static let dict: [String: String] = load()
@@ -39,6 +40,10 @@ enum I18n
?? Bundle.main.url(forResource: loc, withExtension: "json") ?? Bundle.main.url(forResource: loc, withExtension: "json")
} }
// localezh-CN / zh-TW / en-US
// APNs 使 UI
static var locale: String { preferredLocale() }
private static func preferredLocale() -> String private static func preferredLocale() -> String
{ {
for lang in Locale.preferredLanguages for lang in Locale.preferredLanguages
+41
View File
@@ -0,0 +1,41 @@
import Foundation
// app /api/auth/device-session
// refresh App Group /api/clipboard
// 401 refresh app
struct WidgetSession: Codable
{
var accessToken: String
var refreshToken: String
let userId: String
}
enum WidgetSessionStore
{
private static func fileURL() -> URL?
{
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.identifier)
else { return nil }
return base.appendingPathComponent("widget_session.json")
}
static func load() -> WidgetSession?
{
guard let url = fileURL(), let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(WidgetSession.self, from: data)
}
static func save(_ session: WidgetSession)
{
guard let url = fileURL(), let data = try? JSONEncoder().encode(session) else { return }
// .completeFileProtection
// UntilFirstUserAuthentication
try? data.write(to: url, options: [ .atomic, .completeFileProtectionUntilFirstUserAuthentication ])
}
static func clear()
{
guard let url = fileURL() else { return }
try? FileManager.default.removeItem(at: url)
}
}
+11
View File
@@ -0,0 +1,11 @@
// 真机签名配置。仅对 device SDK(iphoneos)生效——模拟器仍走 project.yml base 的 ad-hoc
// 签名(CODE_SIGN_IDENTITY = "-"),无需 Apple 账号即可构建 / 跑模拟器。
//
// 账号特定值(Team ID、各 target 的 profile 名)放在同目录 **gitignore** 的 Local.xcconfig
// 里(公开仓库不留账号标识)。Local.xcconfig 不存在时下面的可选 include 跳过,device 构建会
// 因缺 Team/profile 失败——这正是预期(真机构建须先按 REALDEVICE.md 建好 Local.xcconfig)。
#include? "Local.xcconfig"
CODE_SIGN_STYLE[sdk=iphoneos*] = Manual
CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Development
DEVELOPMENT_TEAM[sdk=iphoneos*] = $(CDROP_TEAM_ID)
+35
View File
@@ -0,0 +1,35 @@
import UIKit
// launch UIKit
// APNs SwiftUI App
// @UIApplicationDelegateAdaptor
final class AppDelegate: NSObject, UIApplicationDelegate
{
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool
{
// BGContinuedProcessingTask launch handler launch
BackgroundTaskManager.shared.registerOnce()
return true
}
// APNs PushRegistry
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
)
{
PushRegistry.shared.didRegister(tokenData: deviceToken)
}
// APNs APNs / / 退线 SSE
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
)
{
// Push
}
}
@@ -0,0 +1,106 @@
import BackgroundTasks
import UIKit
// iOS 26 BGContinuedProcessingTask
// UI AirDrop0>0
// progress 0
//
// R-iOS-3BGContinuedProcessingTask + UI
// WKWebView WebContent JS / WebRTC
// 退R-iOS-1
//
// 线register .main sync 线 actor
final class BackgroundTaskManager
{
static let shared = BackgroundTaskManager()
private init() {}
// Info.plist BGTaskSchedulerPermittedIdentifiers "<prefix>.*"
// false退
private static let prefix = "net.commilitia.Commilitia-Drop.transfer"
private static let wildcard = prefix + ".*"
private var triedRegister = false
private var registerOK = false
private var task: BGContinuedProcessingTask?
private var pendingId: String? // attach 0
private var latestFraction: Double = 0
// launch AppDelegate.didFinishLaunching
func registerOnce()
{
guard !triedRegister else { return }
triedRegister = true
registerOK = BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.wildcard, using: .main)
{ [weak self] task in
guard let cpt = task as? BGContinuedProcessingTask
else { task.setTaskCompleted(success: false); return }
self?.attach(cpt)
}
}
// activeCount fraction [0, 1]
func sync(activeCount: Int, fraction: Double)
{
latestFraction = fraction
if activeCount > 0
{
if let task { task.progress.completedUnitCount = Int64(fraction * 100) }
else { submitIfForeground() }
}
else
{
finish(success: true)
}
}
private func submitIfForeground()
{
// / / attach
guard registerOK, task == nil, pendingId == nil else { return }
//
guard UIApplication.shared.applicationState != .background else { return }
let id = "\(Self.prefix).\(UUID().uuidString.prefix(8))"
let request = BGContinuedProcessingTaskRequest(
identifier: id, title: t("ios.bg.title"), subtitle: t("ios.bg.subtitle"))
request.strategy = .queue
do
{
try BGTaskScheduler.shared.submit(request)
pendingId = id
}
catch
{
// /
}
}
private func attach(_ task: BGContinuedProcessingTask)
{
pendingId = nil
self.task = task
task.progress.totalUnitCount = 100
task.progress.completedUnitCount = Int64(latestFraction * 100)
task.expirationHandler = { [weak self] in
self?.task?.setTaskCompleted(success: false)
self?.task = nil
}
}
private func finish(success: Bool)
{
if let task
{
task.progress.completedUnitCount = task.progress.totalUnitCount
task.setTaskCompleted(success: success)
self.task = nil
}
if let pendingId
{
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: pendingId)
self.pendingId = nil
}
}
}
+8
View File
@@ -5,6 +5,7 @@ import SwiftUI
@main @main
struct CDropApp: App struct CDropApp: App
{ {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var auth = AuthManager() @State private var auth = AuthManager()
@State private var engine = EngineController() @State private var engine = EngineController()
@@ -47,5 +48,12 @@ struct AppRoot: View
auth.debugSession() auth.debugSession()
} }
} }
// Share Extension cdrop://share RootView
// AppRoot 便 / onAppear
.onOpenURL
{ url in
guard url.scheme == "cdrop", url.host == "share" else { return }
engine.loadPendingShares()
}
} }
} }
+152 -4
View File
@@ -60,6 +60,10 @@ final class EngineController: NSObject
var presenceCount = 0 var presenceCount = 0
var lastEngineLog = "" var lastEngineLog = ""
// JS "ready" APNs
// didRegister ready flushPushRegistration
private var engineReady = false
// / / // / /
var clipboardStatus = "" var clipboardStatus = ""
var deviceActionStatus = "" var deviceActionStatus = ""
@@ -89,6 +93,7 @@ final class EngineController: NSObject
override init() override init()
{ {
super.init() super.init()
PushRegistry.shared.engine = self
} }
// makeWebView WebView __CDROP_BOOT__device_type:"ios" // makeWebView WebView __CDROP_BOOT__device_type:"ios"
@@ -126,6 +131,8 @@ final class EngineController: NSObject
wv.load(request) wv.load(request)
webView = wv webView = wv
deviceName = currentDeviceName() deviceName = currentDeviceName()
//
PushRegistry.shared.requestAuthorizationAndRegister()
return wv return wv
} }
@@ -135,11 +142,19 @@ final class EngineController: NSObject
{ {
sendCommand("shutdown", payload: [:]) sendCommand("shutdown", payload: [:])
webView = nil webView = nil
engineReady = false
devices = [] devices = []
transfers = [] transfers = []
history = [] history = []
status = t("ios.engine.disconnected") status = t("ios.engine.disconnected")
deviceName = "" deviceName = ""
PushRegistry.shared.reset()
WidgetSessionStore.clear()
// L1访 + outgoing访 /
for (_, url) in outgoing { url.stopAccessingSecurityScopedResource() }
outgoing.removeAll()
// L2 id
CDropAPI.clearWidgetDeviceID()
} }
// Documents Files app / // Documents Files app /
@@ -185,6 +200,24 @@ final class EngineController: NSObject
sendCommand("revokeDevice", payload: [ "name": name ]) sendCommand("revokeDevice", payload: [ "name": name ])
} }
// APNs PushRegistry.didRegisterready
// + /api/push/apns/register token
func flushPushRegistration()
{
guard engineReady, let token = PushRegistry.shared.deviceTokenHex else { return }
sendCommand("registerPush", payload: [ "token": token, "locale": I18n.locale ])
}
// App Group device_id +
// <> ·
func provisionWidgetSessionIfNeeded()
{
guard engineReady, WidgetSessionStore.load() == nil else { return }
let id = CDropAPI.widgetDeviceID()
let label = t("ios.control.sessionLabel", [ "name": currentDeviceName() ])
sendCommand("provisionWidgetSession", payload: [ "device_id": id, "device_name": label ])
}
// JSwindow.__cdropEngineEvent // JSwindow.__cdropEngineEvent
func sendCommand(_ name: String, payload: [String: Any]) func sendCommand(_ name: String, payload: [String: Any])
{ {
@@ -207,6 +240,48 @@ final class EngineController: NSObject
]) ])
} }
// Share Extension App Group cdrop://share
//
var pendingShareFiles: [URL] = []
// pendingShareFiles
func loadPendingShares()
{
AppGroup.pruneStale()
pendingShareFiles = AppGroup.inboxFiles()
}
// App Group move app
// 便 loadPendingShares /
// M1
func sendShares(to device: String)
{
for url in pendingShareFiles
{
if let staged = moveShareToPrivate(url) { sendFile(to: device, fileURL: staged) }
AppGroup.removeStaged(url)
}
pendingShareFiles = []
}
// app
private func moveShareToPrivate(_ url: URL) -> URL?
{
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("ShareOutbox/\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let dest = dir.appendingPathComponent(url.lastPathComponent)
do { try FileManager.default.moveItem(at: url, to: dest); return dest }
catch { return nil }
}
// +
func dismissShares()
{
for url in pendingShareFiles { AppGroup.removeStaged(url) }
pendingShareFiles = []
}
// cdrop-file://<id> URL // cdrop-file://<id> URL
// start...Access // start...Access
private func stageOutgoingFile(_ url: URL) -> String private func stageOutgoingFile(_ url: URL) -> String
@@ -278,6 +353,9 @@ extension EngineController: WKScriptMessageHandler
{ {
case "ready": case "ready":
status = t("ios.engine.ready") status = t("ios.engine.ready")
engineReady = true
flushPushRegistration()
provisionWidgetSessionIfNeeded()
case "probe": case "probe":
// WebRTC-in-WKWebView arch A R-iOS-3 // WebRTC-in-WKWebView arch A R-iOS-3
if let p = payload as? [String: Any] if let p = payload as? [String: Any]
@@ -300,6 +378,7 @@ extension EngineController: WKScriptMessageHandler
if let p = payload as? [String: Any], let raw = p["active"] as? [Any] if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
{ {
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) } transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
syncBackgroundTask()
} }
case "transferDone": case "transferDone":
// sessionId // sessionId
@@ -309,6 +388,7 @@ extension EngineController: WKScriptMessageHandler
history.removeAll { $0.sessionId == item.sessionId } history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0) history.insert(item, at: 0)
if history.count > 30 { history.removeLast(history.count - 30) } if history.count > 30 { history.removeLast(history.count - 30) }
syncBackgroundTask()
} }
case "sendStarted": case "sendStarted":
// transfers // transfers
@@ -362,6 +442,15 @@ extension EngineController: WKScriptMessageHandler
{ {
auth?.updateSession(accessToken: access, refreshToken: refresh) auth?.updateSession(accessToken: access, refreshToken: refresh)
} }
case "widgetSession":
// App Group /
if let p = payload as? [String: Any],
let access = p["access_token"] as? String,
let refresh = p["refresh_token"] as? String,
let userId = p["user_id"] as? String
{
WidgetSessionStore.save(WidgetSession(accessToken: access, refreshToken: refresh, userId: userId))
}
case "authExpired": case "authExpired":
// refresh / Keychain // refresh / Keychain
auth?.logout() auth?.logout()
@@ -375,6 +464,16 @@ extension EngineController: WKScriptMessageHandler
{ {
webView?.evaluateJavaScript("window.__cdropEngineResolve(\(id), \(ok), \(jsonValue(value)));") webView?.evaluateJavaScript("window.__cdropEngineResolve(\(id), \(ok), \(jsonValue(value)));")
} }
// transfers history
// BackgroundTaskManager / / R-iOS-1 / R-iOS-3
private func syncBackgroundTask()
{
let total = transfers.reduce(0) { $0 + $1.fileSize }
let done = transfers.reduce(0) { $0 + ($1.bytesTransferred ?? 0) }
let fraction = total > 0 ? Double(done) / Double(total) : 0
BackgroundTaskManager.shared.sync(activeCount: transfers.count, fraction: fraction)
}
} }
// MARK: - schemecdrop-file // MARK: - schemecdrop-file
@@ -382,8 +481,9 @@ extension EngineController: WKScriptMessageHandler
extension EngineController: WKURLSchemeHandler extension EngineController: WKURLSchemeHandler
{ {
// cdrop-file://<id> JS fetch // cdrop-file://<id> JS fetch
// PLAN §2 / R-iOS-4v1 / // PLAN §2 / R-iOS-4rangeSource `Range` seek
// didReceive jetsam R-iOS-4 // 206 Partial Content jetsam Range
// 退 Range
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
{ {
guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id] guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id]
@@ -392,16 +492,48 @@ extension EngineController: WKURLSchemeHandler
respond(urlSchemeTask, status: 404) respond(urlSchemeTask, status: 404)
return return
} }
guard let data = try? Data(contentsOf: fileURL) guard let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let total = attrs[.size] as? Int,
let handle = try? FileHandle(forReadingFrom: fileURL)
else else
{ {
respond(urlSchemeTask, status: 404) respond(urlSchemeTask, status: 404)
return return
} }
defer { try? handle.close() }
if let range = Self.parseByteRange(urlSchemeTask.request.value(forHTTPHeaderField: "Range"), total: total)
{
let data: Data
do
{
try handle.seek(toOffset: UInt64(range.lowerBound))
data = (try handle.read(upToCount: range.count)) ?? Data()
}
catch
{
respond(urlSchemeTask, status: 500)
return
}
let resp = HTTPURLResponse(url: url, statusCode: 206, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)",
"Content-Range": "bytes \(range.lowerBound)-\(range.upperBound - 1)/\(total)",
"Accept-Ranges": "bytes",
])!
urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
return
}
let data = (try? handle.readToEnd()) ?? Data()
let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: [ headerFields: [
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)", "Content-Length": "\(data.count)",
"Accept-Ranges": "bytes",
])! ])!
urlSchemeTask.didReceive(resp) urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data) urlSchemeTask.didReceive(data)
@@ -410,7 +542,23 @@ extension EngineController: WKURLSchemeHandler
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
{ {
// v1 // read
}
// HTTP `Range: bytes=start-end`end [lowerBound, upperBound)
// end "bytes=start-" EOF / nil
static func parseByteRange(_ header: String?, total: Int) -> Range<Int>?
{
guard let header, header.hasPrefix("bytes="), total > 0 else { return nil }
let spec = header.dropFirst("bytes=".count)
let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2, let start = Int(parts[0]), start >= 0, start < total else { return nil }
let endInclusive: Int
if parts[1].isEmpty { endInclusive = total - 1 }
else if let e = Int(parts[1]) { endInclusive = min(e, total - 1) }
else { return nil }
guard endInclusive >= start else { return nil }
return start ..< (endInclusive + 1)
} }
private func respond(_ task: any WKURLSchemeTask, status: Int) private func respond(_ task: any WKURLSchemeTask, status: Int)
+19
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>net.commilitia.Commilitia-Drop.transfer.*</string>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>zh-Hans</string> <string>zh-Hans</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -24,6 +28,17 @@
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>net.commilitia.Commilitia-Drop</string>
<key>CFBundleURLSchemes</key>
<array>
<string>cdrop</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>1</string>
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>
@@ -41,6 +56,10 @@
<string>Commilitia Drop 使用相机扫描二维码登录新设备。</string> <string>Commilitia Drop 使用相机扫描二维码登录新设备。</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string> <string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key> <key>UIFileSharingEnabled</key>
<true/> <true/>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
+60
View File
@@ -0,0 +1,60 @@
import UIKit
import UserNotifications
// APNs AppDelegate UIKit EngineController
// token POST /api/push/apns/register
// / token
//
// / Push Notifications aps-environment entitlement
// ADP profile .p8iOS 16+ / Apple Silicon
// `simctl push`
@MainActor
final class PushRegistry: NSObject
{
static let shared = PushRegistry()
private override init() { super.init() }
weak var engine: EngineController?
private(set) var deviceTokenHex: String?
private var requested = false
// AppDelegate didRegister
//
func requestAuthorizationAndRegister()
{
guard !requested else { return }
requested = true
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
{ granted, _ in
guard granted else { return }
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
}
}
// AppDelegate 线APNs Data
func didRegister(tokenData: Data)
{
deviceTokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
engine?.flushPushRegistration()
}
// /
func reset()
{
requested = false
deviceTokenHex = nil
}
}
extension PushRegistry: UNUserNotificationCenterDelegate
{
// +
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions
{
return [ .banner, .sound ]
}
}
+7 -1
View File
@@ -354,5 +354,11 @@
"ios.files.empty": "No files received yet", "ios.files.empty": "No files received yet",
"ios.files.share": "Share", "ios.files.share": "Share",
"ios.files.done": "Done", "ios.files.done": "Done",
"ios.detail.openFile": "Open File" "ios.detail.openFile": "Open File",
"ios.bg.title": "Transferring files",
"ios.bg.subtitle": "Continues in the background",
"ios.share.title": "Send {{n}} file(s) to a device",
"ios.control.upload": "Upload Clipboard",
"ios.control.pull": "Pull Clipboard",
"ios.control.sessionLabel": "{{name}} · Control"
} }
+7 -1
View File
@@ -354,5 +354,11 @@
"ios.files.empty": "还没有收到文件", "ios.files.empty": "还没有收到文件",
"ios.files.share": "分享", "ios.files.share": "分享",
"ios.files.done": "完成", "ios.files.done": "完成",
"ios.detail.openFile": "打开文件" "ios.detail.openFile": "打开文件",
"ios.bg.title": "正在传输文件",
"ios.bg.subtitle": "切到后台仍继续传输",
"ios.share.title": "发送 {{n}} 个文件到设备",
"ios.control.upload": "上传剪贴板",
"ios.control.pull": "拉取剪贴板",
"ios.control.sessionLabel": "{{name}} · 控件"
} }
+7 -1
View File
@@ -354,5 +354,11 @@
"ios.files.empty": "尚未收到檔案", "ios.files.empty": "尚未收到檔案",
"ios.files.share": "分享", "ios.files.share": "分享",
"ios.files.done": "完成", "ios.files.done": "完成",
"ios.detail.openFile": "開啟檔案" "ios.detail.openFile": "開啟檔案",
"ios.bg.title": "正在傳輸檔案",
"ios.bg.subtitle": "切到背景仍繼續傳輸",
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
"ios.control.upload": "上傳剪貼簿",
"ios.control.pull": "拉取剪貼簿",
"ios.control.sessionLabel": "{{name}} · 控制項"
} }
+17
View File
@@ -54,6 +54,23 @@ struct RootView: View
.opacity(0) .opacity(0)
.allowsHitTesting(false) .allowsHitTesting(false)
} }
// Share Extension cdrop://share
.onAppear { engine.loadPendingShares() }
.confirmationDialog(
t("ios.share.title", [ "n": String(engine.pendingShareFiles.count) ]),
isPresented: Binding(
get: { !engine.pendingShareFiles.isEmpty },
set: { if !$0 { engine.dismissShares() } }
),
titleVisibility: .visible
)
{
ForEach(engine.sendableDevices())
{ dev in
Button(dev.name) { engine.sendShares(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { engine.dismissShares() }
}
} }
} }
+19 -8
View File
@@ -1,11 +1,22 @@
# cdrop iOS 待办(择期处理) # cdrop iOS 待办
2026-06-24 记录:扫码登录端到端验通后用户列出,留待后续处理(“A”任务覆盖的数据侧除外)。 ## 计划主体(I1I7):已实现(2026-06-27
1. **登录会话显示修复**(未做):iOS 客户端在(web)“登录会话”列表中误显示为“本地账号”,应按 device_type 显示“iOS 客户端”(`deviceType.ios`)。疑似列表按会话 kind(self / 扫码自签)而非设备类型贴标签——需让扫码自签会话也带 device_type 标签呈现 传输(含发送端流式 R-iOS-4)/ 设备 + 会话管理 / 强制 full 登录 / 登出 / 传输详情 / 后台续传(BGContinuedProcessingTask/ APNs(后端 + 原生注册)/ Share Extension / 控制中心剪贴板两控件——全部落地、模拟器验、经 ultracode 审查修讫。详见 `ios/PLAN.md` 里程碑 + `ios/PARITY.md` 漂移日志
2. **设备 / session 管理非空壳**(数据侧已做,管理操作未做):列表数据已由“A”接真实 presence(`DeviceListView``engine.devices`)。剩余:会话管理操作(查看 / 吊销其他会话等)。
3. **禁止临时登录**(未做):iOS app 不应允许 guest / 临时(once)登录;扫码登录应强制 full / persist(“信任此设备”)scope。当前后端 qr 默认 guest、`AuthManager` 也写死 `scope: "guest"`——需 iOS 侧走 full。
4. **退出登录**(已做):设置页“账户”区已加登出(destructive),清 Keychain 会话 + 复位引擎 + 回登录页(`SettingsView.logout``auth.logout()` + `engine.reset()`)。
5. **传输详情**(已做):`TransferDetailView` 按 sessionId 实时查引擎态,展示方向 / 对端 / 状态 / 阶段 / 通道 / 大小 / 进度 / 速度;卡片可点入。剩余可选:ICE 候选明细(`transfer.debug.*` catalog 已有,未接)。
> **A 已完成**:引擎 presence / 传输 → 原生 UI 数据绑定(三屏全替 demo)+ `TransferDetailView` + Keychain 会话持久化 + 登出 + i18n 插值。闭合 #4 / #5#2 数据侧;prod 已部署 `engine.html`presence 生效)。**剩余择期**#1device_type 标签)、#3(强制 full 登录)、#2 会话管理操作、#5 ICE 明细(可选)。全部仍未提交(按用户序:真机测完再 commit)。 ## 早期 5 项(2026-06-24 列)状态
1. **登录会话列表显示**:iOS 设备在(web)「登录会话」列表的标签——broker 迁移后 sessions 已按设备 cache 叠加 typedevice-session 铸入 `device_type:"ios"`),**疑已解决,待真会话核**。
2. 设备 / 会话管理:✅(设备页吊销 + 后端 list/revoke/rename)。
3. 强制 full 登录:✅(iOS 拒非 full + 后端对非 browser 兜底 full/persist)。
4. 登出:✅。
5. 传输详情:✅(`TransferDetailView`;可选 ICE 明细已接)。
## 真机阶段(账号门控,见 REALDEVICE.md
真机签名跑通 + 同内网直连 / 本地网络权限(R-iOS-6)+ 后台挂起行为(R-iOS-3+ APNs 真发 + Share Extension / 控件真机交互验证。代码就绪,等付费 ADP + 门户 + 设备。
## 余项(择期)
- 控件「添加控件」库名 + Shortcuts 标题随设备语言(需为 `CDropWidgets``.xcstrings`;当前简体静态,审查 L7,平台专属可接受)。
- 发送端 `outgoing` 暂存 + 安全作用域访问按传输完成逐项释放(当前登出统一清,审查 L1 余量)。
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 与主 app 共享的 App Group:控件读取 widget_session.json(主 app 代铸的控件会话)。 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.net.commilitia.cdrop</string>
</array>
</dict>
</plist>
+89
View File
@@ -0,0 +1,89 @@
import Foundation
// WidgetSessionStore
// /api/clipboard access 401 /api/auth/refresh
// app RESTPLAN §I4使
// refresh
enum ClipboardClient
{
enum ClientError: Error { case noSession, http(Int) }
// PUT /api/clipboard
static func upload(_ text: String) async throws
{
let body = try JSONSerialization.data(withJSONObject: [
"content": text,
"content_type": "text/plain",
"origin_ts": Int(Date().timeIntervalSince1970 * 1000),
])
_ = try await authed(method: "PUT", path: "/api/clipboard", body: body)
}
// GET /api/clipboard
static func pull() async throws -> String
{
let data = try await authed(method: "GET", path: "/api/clipboard", body: nil)
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return (obj?["content"] as? String) ?? ""
}
// + 401
private static func authed(method: String, path: String, body: Data?) async throws -> Data
{
guard var session = WidgetSessionStore.load() else { throw ClientError.noSession }
do
{
return try await send(method: method, path: path, body: body, token: session.accessToken)
}
catch ClientError.http(401)
{
session = try await refresh(session)
WidgetSessionStore.save(session)
return try await send(method: method, path: path, body: body, token: session.accessToken)
}
}
private static func send(method: String, path: String, body: Data?, token: String) async throws -> Data
{
var req = URLRequest(url: URL(string: CDropAPI.base + path)!)
req.httpMethod = method
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body
{
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let (data, resp) = try await URLSession.shared.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
// 200 409PUT LWW 401
guard code == 200 || code == 409 else { throw ClientError.http(code) }
return data
}
private static func refresh(_ session: WidgetSession) async throws -> WidgetSession
{
let body = try JSONSerialization.data(withJSONObject: [ "refresh_token": session.refreshToken ])
var req = URLRequest(url: URL(string: CDropAPI.base + "/api/auth/refresh")!)
req.httpMethod = "POST"
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, resp) = try await URLSession.shared.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
guard code == 200,
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let access = obj["access_token"] as? String
else
{
// refresh / app
// CAS-before-clear refresh
// intent L3
if WidgetSessionStore.load()?.refreshToken == session.refreshToken
{
WidgetSessionStore.clear()
}
throw ClientError.http(401)
}
let newRefresh = (obj["refresh_token"] as? String) ?? session.refreshToken
return WidgetSession(accessToken: access, refreshToken: newRefresh, userId: session.userId)
}
}
+45
View File
@@ -0,0 +1,45 @@
import AppIntents
import SwiftUI
import WidgetKit
// iOS 18+ ControlWidgetPLAN C / §I4 /
// t() i18n bundle
@main
struct CDropWidgetBundle: WidgetBundle
{
var body: some Widget
{
UploadClipboardControl()
PullClipboardControl()
}
}
struct UploadClipboardControl: ControlWidget
{
var body: some ControlWidgetConfiguration
{
StaticControlConfiguration(kind: "net.commilitia.cdrop.control.upload")
{
ControlWidgetButton(action: UploadClipboardIntent())
{
Label(t("ios.control.upload"), systemImage: "square.and.arrow.up")
}
}
.displayName("上传剪贴板")
}
}
struct PullClipboardControl: ControlWidget
{
var body: some ControlWidgetConfiguration
{
StaticControlConfiguration(kind: "net.commilitia.cdrop.control.pull")
{
ControlWidgetButton(action: PullClipboardIntent())
{
Label(t("ios.control.pull"), systemImage: "square.and.arrow.down")
}
}
.displayName("拉取剪贴板")
}
}
+32
View File
@@ -0,0 +1,32 @@
import AppIntents
import UIKit
//
struct UploadClipboardIntent: AppIntent
{
static var title: LocalizedStringResource = "上传剪贴板"
static var description = IntentDescription("把本机剪贴板内容上传到 Commilitia Drop 云剪贴板")
func perform() async throws -> some IntentResult
{
let text = await MainActor.run { UIPasteboard.general.string ?? "" }
if !text.isEmpty { try? await ClipboardClient.upload(text) }
return .result()
}
}
//
struct PullClipboardIntent: AppIntent
{
static var title: LocalizedStringResource = "拉取剪贴板"
static var description = IntentDescription("把 Commilitia Drop 云剪贴板内容写入本机剪贴板")
func perform() async throws -> some IntentResult
{
if let text = try? await ClipboardClient.pull(), !text.isEmpty
{
await MainActor.run { UIPasteboard.general.string = text }
}
return .result()
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Commilitia Drop</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+88 -2
View File
@@ -7,19 +7,37 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
CODE_SIGNING_ALLOWED: "NO" # 模拟器用 ad-hoc 签名(身份 "-",无需 Apple 账号),使 App Group / Push 等 entitlement 在
# 模拟器实际生效(否则共享容器不创建、远程通知注册失败)。真机签名须付费 ADP 配
# team / provisioning profile(账号门控,见 REALDEVICE.md)。
CODE_SIGN_IDENTITY: "-"
CODE_SIGNING_REQUIRED: "NO" CODE_SIGNING_REQUIRED: "NO"
CODE_SIGNING_ALLOWED: "YES"
targets: targets:
CDrop: CDrop:
type: application type: application
platform: iOS platform: iOS
deploymentTarget: "26.0" deploymentTarget: "26.0"
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources: sources:
- path: Sources - path: Sources
- path: Shared
dependencies:
- target: CDropShare
embed: true
- target: CDropWidgets
embed: true
info: info:
path: Sources/Info.plist path: Sources/Info.plist
properties: properties:
CFBundleDisplayName: Commilitia Drop CFBundleDisplayName: Commilitia Drop
# cdrop:// URL schemeShare Extension 交接后经 cdrop://share 深链唤起主 app 选设备发送。
CFBundleURLTypes:
- CFBundleURLName: net.commilitia.Commilitia-Drop
CFBundleURLSchemes:
- cdrop
# 声明支持的语言,使系统控件(EditButton 的「编辑」、滑动删除的「删除」等系统字符 # 声明支持的语言,使系统控件(EditButton 的「编辑」、滑动删除的「删除」等系统字符
# 串)跟随设备语言本地化,而非永远英文。开发区域设简体。 # 串)跟随设备语言本地化,而非永远英文。开发区域设简体。
CFBundleDevelopmentRegion: zh-Hans CFBundleDevelopmentRegion: zh-Hans
@@ -28,6 +46,13 @@ targets:
- zh-Hant - zh-Hant
- en - en
UILaunchScreen: {} UILaunchScreen: {}
# 后台续传:BGContinuedProcessingTask 的通配标识符须在此声明,系统才允许注册 / 提交
# (见 BackgroundTaskManager)。默认资源(CPU + 网络)无需额外 entitlement。
BGTaskSchedulerPermittedIdentifiers:
- "net.commilitia.Commilitia-Drop.transfer.*"
# 远程通知后台模式:APNs 推送送达 / 后台处理(如「收到文件」通知)。
UIBackgroundModes:
- remote-notification
# 让接收文件落地的 Documents 目录在 Files app「我的 iPhone / Commilitia Drop」下 # 让接收文件落地的 Documents 目录在 Files app「我的 iPhone / Commilitia Drop」下
# 可见、可就地打开(收到的文件有处可开、可被其他 app 取用)。 # 可见、可就地打开(收到的文件有处可开、可被其他 app 取用)。
UIFileSharingEnabled: true UIFileSharingEnabled: true
@@ -40,9 +65,70 @@ targets:
NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。" NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。"
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.cdrop PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# APNs 推送的 aps-environment entitlement(见 CDrop.entitlements)。模拟器取令牌可用;
# 真机签名须付费 ADP 开 Push 能力(账号门控)。
CODE_SIGN_ENTITLEMENTS: CDrop.entitlements
# 真机:用门户 profile 手动签名(仅 device SDK;模拟器走 base ad-hoc)。profile 名取自
# gitignore 的 Local.xcconfig(见 Signing.xcconfig / REALDEVICE.md)。
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_APP)"
# Share Extension:抓分享文件 → App Group 收件箱 → 深链主 app 选设备发送(只交接、不跑引擎,
# 见 PLAN §I5)。只编 ShareViewController + 共享的 AppGroup,绝不拉主 app Sources(避超 120MB)。
CDropShare:
type: app-extension
platform: iOS
deploymentTarget: "26.0"
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources:
- path: Share
- path: Shared
info:
path: Share/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
NSExtensionAttributes:
NSExtensionActivationRule:
NSExtensionActivationSupportsFileWithMaxCount: 20
NSExtensionActivationSupportsImageWithMaxCount: 20
NSExtensionActivationSupportsMovieWithMaxCount: 20
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.share
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_ENTITLEMENTS: Share/CDropShare.entitlements
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_SHARE)"
# 控制中心剪贴板两控件(WidgetKit ControlWidgetiOS 18+PLAN §I4)。纯原生 REST + 控件专用
# 设备会话(不跑引擎)。i18n JSON 一并打包使控件标签随设备语言(t() 经 no-subdir 回退读 bundle)。
CDropWidgets:
type: app-extension
platform: iOS
deploymentTarget: "26.0"
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources:
- path: Widgets
- path: Shared
- path: Sources/Resources/i18n
info:
path: Widgets/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_ENTITLEMENTS: Widgets/CDropWidgets.entitlements
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_WIDGETS)"
schemes: schemes:
CDrop: CDrop:
build: build:
+2
View File
@@ -33,6 +33,7 @@
- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。 - **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。
- **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto;间距 / 圆角 / 字阶各用平台母语。 - **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto;间距 / 圆角 / 字阶各用平台母语。
- **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。 - **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。
- **控制中心控件标签**:运行时控件标签(用户点按的那个)经 `t()` 跟随设备语言;但「添加控件」库名 + Shortcuts 标题是 `LocalizedStringResource` 静态值、`CDropWidgets` target 无 String Catalog,故对 en / zh-TW 用户回退简体字面量(审查 L7,平台专属功能可接受分叉;要彻底本地化须为 Widgets target 加 `.xcstrings`)。
--- ---
@@ -58,3 +59,4 @@
| 2026-06-24 | i18n locales + scripts/emit-i18n.mjs(未提交) | ios/CDrop i18n(未提交) | **强约束②落地**web locales 加 `ios.*` 键 → `emit-i18n.mjs` 产出 JSON → iOS `t()` 读(loader I18n.swift)。用户可见**去代号 cdrop**(品牌用 `app.brand`=Commilitia Drop),显示名改 Commilitia Drop。**改 locale 后须重跑 `node web/scripts/emit-i18n.mjs`**(已记入脚本头) | | 2026-06-24 | i18n locales + scripts/emit-i18n.mjs(未提交) | ios/CDrop i18n(未提交) | **强约束②落地**web locales 加 `ios.*` 键 → `emit-i18n.mjs` 产出 JSON → iOS `t()` 读(loader I18n.swift)。用户可见**去代号 cdrop**(品牌用 `app.brand`=Commilitia Drop),显示名改 Commilitia Drop。**改 locale 后须重跑 `node web/scripts/emit-i18n.mjs`**(已记入脚本头) |
| 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices``presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()``{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html``engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 | | 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices``presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()``{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html``engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 |
| 2026-06-24 | engine/main.tstransfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署** | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreviewproject.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/srelay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html``engine-BC6mZ9zP.js`(含节流 + ice 摘要) | | 2026-06-24 | engine/main.tstransfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署** | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreviewproject.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/srelay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html``engine-BC6mZ9zP.js`(含节流 + ice 摘要) |
| 2026-06-27 | source.ts(新 FileSource+ p2p/relay/transfer 改 src + engine/main.ts 加 registerPush/provisionWidgetSession + i18n 加 `ios.bg.*`/`ios.share.*`/`ios.control.*`(本提交) | 新 BackgroundTaskManager / AppDelegate / PushRegistry / Shared(AppGroup,I18n,CDropAPI,WidgetSession) / Share(ShareViewController) / Widgets(控件+intents+client)EngineController 加 Range handler / 后台 sync / 分享导入 / 控件代铸;project.yml 加两扩展 target + 真机签名 scaffold;包名改 `net.commilitia.Commilitia-Drop`(本提交) | **计划完成(I3I7**:发送端流式(FileSource + 原生 Range,新增**分享发送 + 控件**两条非引擎流程,对应残余项 1/2)/ 后台续传 / APNs(后端 internal/apns + 原生注册,新后端字段 `push_subscriptions.platform` + 端点 `POST /api/push/apns/register` 对应残余项 3/ Share Extension / 控制中心两控件(新允许分叉:控件标签见上)。经 ultracode 多 agent 审查(0 HIGH,修 2 MED + 7 LOW)。web tsc/build + go build/test + xcodebuild 全过;模拟器验登录 / 分享弹窗 / 控件嵌入 / App Group 容器。**真机 + 真发推送 + 分发账号门控**(见 REALDEVICE.md)。**待部署 prod**engine.html(新桥)+ cdrop binaryAPNs 端点 + platform 列) |
+2
View File
@@ -111,6 +111,8 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
| **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 | | **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 |
| **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 | | **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 |
> **实现状态(2026-06-27**I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share ExtensionApp Group 收件箱 + `cdrop://share` 深链);控制中心两控件(**专用 broker 设备会话**,纯原生 REST,不与引擎抢 refresh 轮换)。模拟器验 + ultracode 多 agent 审查修讫(0 HIGH,修 2 MED + 7 LOW)。**I0 账号 / 证书 + 真机签名 / 真发推送 / 旁加载分发**仍账号门控——手册 `ios/CDrop/REALDEVICE.md`(手动签名 + `just ios-device` 全 CLI 装机,门户手动建)。包名 `net.commilitia.Commilitia-Drop`
--- ---
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开) ## 5. 一致性:强约束同步 vs 检查单(决策 D 展开)
+65 -12
View File
@@ -25,6 +25,7 @@ import { apiFetch } from "../net/api";
import { startHub } from "../features/transfer/hub"; import { startHub } from "../features/transfer/hub";
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers"; import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer"; import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer";
import { rangeSource } from "../features/transfer/source";
import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios"; import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
import { useAppStore } from "../store"; import { useAppStore } from "../store";
import type { TransferRecord } from "../store/types"; import type { TransferRecord } from "../store/types";
@@ -166,22 +167,15 @@ function subscribeStore(): void
}); });
} }
// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露,这里取回为 // handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露。R-iOS-4:用
// File 交给引擎发起传输。 // rangeSource 惰性按块拉取(原生据 Range 头 seek 文件、回 206),整文件永不进 WebView
// // 内存——大文件不再有 jetsam 风险,传输层 p2p / relay 透明复用。
// R-iOS-4:整文件 fetch 进内存——小 / 中文件可行;大文件在 iOS 上有 jetsam 风险,按
// slice 向原生拉取(不整文件入内存)是真机验证后的优化点,待与原生发送端同期落地。
async function handleSendFile(p: SendFilePayload): Promise<void> async function handleSendFile(p: SendFilePayload): Promise<void>
{ {
try try
{ {
const resp = await fetch(p.url); const src = rangeSource(p.url, p.name, p.size, p.type ?? "");
if (!resp.ok) { throw new Error(`fetch staged file failed: ${resp.status}`); } const sessionId = await startOutgoingTransfer(p.target, src);
const blob = await resp.blob();
const file = new File([ blob ], p.name, {
type: p.type || blob.type || "application/octet-stream",
});
const sessionId = await startOutgoingTransfer(p.target, file);
notifyNative("sendStarted", { sessionId, name: p.name }); notifyNative("sendStarted", { sessionId, name: p.name });
} }
catch (e) catch (e)
@@ -251,6 +245,21 @@ function bindCommands(): void
if (!name) { return; } if (!name) { return; }
void revokeDevice(name); void revokeDevice(name);
}); });
// APNs 设备令牌登记:原生(AppDelegate)拿到令牌经桥送来,引擎用新鲜会话 token 上报后端。
onNativeEvent("registerPush", (payload) =>
{
const p = payload as { token?: string; locale?: string };
if (!p.token) { return; }
void registerPush(p.token, p.locale ?? "");
});
// 控件专用设备会话代铸:主 app 让引擎(持完整会话)给控制中心控件铸一条独立委派会话
// (独立 device_id),回交原生存 App Group 供控件自用 / 自刷,不与引擎抢 refresh 轮换。
onNativeEvent("provisionWidgetSession", (payload) =>
{
const p = payload as { device_id?: string; device_name?: string };
if (!p.device_id) { return; }
void provisionWidgetSession(p.device_id, p.device_name ?? "");
});
onNativeEvent("shutdown", () => onNativeEvent("shutdown", () =>
{ {
hubCtrl.abort(); hubCtrl.abort();
@@ -258,6 +267,50 @@ function bindCommands(): void
}); });
} }
// registerPush:把原生送来的 APNs 设备令牌上报后端(POST /api/push/apns/register)。走引擎
// 而非原生直连,复用 apiFetch 的新鲜会话 token + 自动续期(同剪贴板 / 吊销范式)。后端未配
// APNs(无 .p8)时返回 503best-effort 忽略即可。
async function registerPush(token: string, locale: string): Promise<void>
{
try
{
await apiFetch("/api/push/apns/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_token: token, locale }),
});
}
catch (e)
{
notifyNative("error", { stage: "push", message: e instanceof Error ? e.message : String(e) });
}
}
// provisionWidgetSession:经 /api/auth/device-session 给控件代铸一条独立委派会话(device_type
// ios),把 access / refresh 回交原生(存 App Group)。控件据此独立调 /api/clipboard 并自刷。
async function provisionWidgetSession(deviceId: string, deviceName: string): Promise<void>
{
try
{
const r = await apiFetch("/api/auth/device-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_id: deviceId, device_name: deviceName, device_type: "ios" }),
});
if (!r.ok) { return; }
const d = (await r.json()) as { access_token: string; refresh_token: string; user_id: string };
notifyNative("widgetSession", {
access_token: d.access_token,
refresh_token: d.refresh_token,
user_id: d.user_id,
});
}
catch (e)
{
notifyNative("error", { stage: "widget", message: e instanceof Error ? e.message : String(e) });
}
}
async function revokeDevice(name: string): Promise<void> async function revokeDevice(name: string): Promise<void>
{ {
try try
+14 -12
View File
@@ -2,6 +2,7 @@ import { apiFetch } from "../../net/api";
import { getICEServers } from "./iceServers"; import { getICEServers } from "./iceServers";
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store"; import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
import { deliverIncoming, openIncomingSink, type IncomingSink } from "./incomingSink"; import { deliverIncoming, openIncomingSink, type IncomingSink } from "./incomingSink";
import type { FileSource } from "./source";
// WebRTC client wired to the brief §2 invariants: // WebRTC client wired to the brief §2 invariants:
// - iceServers: pulled from lib/iceServers (Cloudflare Realtime TURN over // - iceServers: pulled from lib/iceServers (Cloudflare Realtime TURN over
@@ -461,9 +462,9 @@ class Session
} }
} }
async startOutgoing(file: File): Promise<void> async startOutgoing(src: FileSource): Promise<void>
{ {
this.outgoingTotal = file.size; this.outgoingTotal = src.size;
const dc = this.pc.createDataChannel(CHANNEL_NAME, { ordered: true }); const dc = this.pc.createDataChannel(CHANNEL_NAME, { ordered: true });
this.bindDataChannel(dc); this.bindDataChannel(dc);
// bindDataChannel 给 receiver 绑了 onopensender 这里覆盖一下,等 // bindDataChannel 给 receiver 绑了 onopensender 这里覆盖一下,等
@@ -471,7 +472,7 @@ class Session
dc.onopen = () => dc.onopen = () =>
{ {
this.setPhase("dc_open"); this.setPhase("dc_open");
void this.streamFile(file).catch((e) => void this.streamFile(src).catch((e) =>
{ {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error("p2p send failed", e); console.error("p2p send failed", e);
@@ -485,7 +486,7 @@ class Session
await this.sendSignal({ type: "offer", sdp: offer }); await this.sendSignal({ type: "offer", sdp: offer });
} }
private async streamFile(file: File): Promise<void> private async streamFile(src: FileSource): Promise<void>
{ {
if (!this.dc) { throw new Error("no data channel"); } if (!this.dc) { throw new Error("no data channel"); }
const dc = this.dc; const dc = this.dc;
@@ -495,7 +496,7 @@ class Session
await this.markP2PActive(); await this.markP2PActive();
// Header. // Header.
const meta: FileMeta = { name: file.name, size: file.size }; const meta: FileMeta = { name: src.name, size: src.size };
dc.send(JSON.stringify({ type: "meta", ...meta })); dc.send(JSON.stringify({ type: "meta", ...meta }));
// buffer 会被 64KB 一片秒满到 16MiB,之后发送循环长时间阻塞在 waitForBufferLow // buffer 会被 64KB 一片秒满到 16MiB,之后发送循环长时间阻塞在 waitForBufferLow
@@ -508,11 +509,12 @@ class Session
const sendChunk = CHUNK_SIZE; const sendChunk = CHUNK_SIZE;
let offset = 0; let offset = 0;
while (offset < file.size) while (offset < src.size)
{ {
// 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。 // 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。iOS 下
const blockEnd = Math.min(offset + READ_BLOCK_SIZE, file.size); // src.slice 经 Range 向原生拉取该块,整文件不进 WebView 内存(R-iOS-4)。
const block = new Uint8Array(await file.slice(offset, blockEnd).arrayBuffer()); const blockEnd = Math.min(offset + READ_BLOCK_SIZE, src.size);
const block = await src.slice(offset, blockEnd);
// 从内存块按 sendChunk 子片发送;流控(bufferedAmount/水位)逻辑不变。 // 从内存块按 sendChunk 子片发送;流控(bufferedAmount/水位)逻辑不变。
let inBlock = 0; let inBlock = 0;
@@ -573,7 +575,7 @@ class Session
// 端发,这里只在抽干后驱动发送端自身 UI 收尾)。 // 端发,这里只在抽干后驱动发送端自身 UI 收尾)。
await waitForBufferLow(dc, 0); await waitForBufferLow(dc, 0);
this.stopSendProgressPoll(); this.stopSendProgressPoll();
this.bytesSent = file.size; this.bytesSent = src.size;
this.emitProgress(); this.emitProgress();
this.setState("completed"); this.setState("completed");
// /done is intentionally posted by the *receiver* only — they are the // /done is intentionally posted by the *receiver* only — they are the
@@ -847,13 +849,13 @@ function buildPublicSession(sess: Session): P2PSession
export function p2pStartOutgoing( export function p2pStartOutgoing(
sessionId: string, sessionId: string,
receiverName: string, receiverName: string,
file: File, src: FileSource,
): P2PSession ): P2PSession
{ {
const sess = new Session(sessionId, receiverName, "sender"); const sess = new Session(sessionId, receiverName, "sender");
p2pSessions.set(receiverName, sess); p2pSessions.set(receiverName, sess);
sess.stateListeners.add((s) => updateStoreState(sessionId, s)); sess.stateListeners.add((s) => updateStoreState(sessionId, s));
void sess.startOutgoing(file).catch((e) => void sess.startOutgoing(src).catch((e) =>
{ {
// Cancellation (fallback path closes pc → pending createOffer rejects) // Cancellation (fallback path closes pc → pending createOffer rejects)
// is expected, not a transport failure — let the relay flow drive state. // is expected, not a transport failure — let the relay flow drive state.
+9 -7
View File
@@ -1,5 +1,6 @@
import { apiFetch } from "../../net/api"; import { apiFetch } from "../../net/api";
import { deliverIncoming, openIncomingSink } from "./incomingSink"; import { deliverIncoming, openIncomingSink } from "./incomingSink";
import type { FileSource } from "./source";
// Relay path (PROJECT_BRIEF.md §M5/M9). Sender uploads sequential 1 MiB chunks // Relay path (PROJECT_BRIEF.md §M5/M9). Sender uploads sequential 1 MiB chunks
// via POST and receiver streams the response via GET. Streaming POST is NOT // via POST and receiver streams the response via GET. Streaming POST is NOT
@@ -30,20 +31,21 @@ const PER_CHUNK_TIMEOUT_MS = 60_000;
// X-Bytes-Received and don't re-send the duplicate. // X-Bytes-Received and don't re-send the duplicate.
export async function relaySendFile( export async function relaySendFile(
sessionId: string, sessionId: string,
file: File, src: FileSource,
onProgress?: (p: RelayProgress) => void, onProgress?: (p: RelayProgress) => void,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<void> ): Promise<void>
{ {
const url = `/api/relay/${encodeURIComponent(sessionId)}/chunk`; const url = `/api/relay/${encodeURIComponent(sessionId)}/chunk`;
let offset = 0; let offset = 0;
while (offset < file.size) while (offset < src.size)
{ {
if (abortSignal?.aborted) { throw new Error("relay send aborted"); } if (abortSignal?.aborted) { throw new Error("relay send aborted"); }
const end = Math.min(offset + CHUNK, file.size); const end = Math.min(offset + CHUNK, src.size);
const slice = file.slice(offset, end); // iOS 下经 Range 向原生拉该 1 MiB 片,整文件不进 WebView 内存(R-iOS-4)。
const isLast = end === file.size; const slice = await src.slice(offset, end);
const isLast = end === src.size;
const result = await postChunkWithRetry(url, slice, offset, isLast, abortSignal); const result = await postChunkWithRetry(url, slice, offset, isLast, abortSignal);
if (result.skip) if (result.skip)
@@ -55,7 +57,7 @@ export async function relaySendFile(
{ {
offset = end; offset = end;
} }
onProgress?.({ bytes: offset, total: file.size }); onProgress?.({ bytes: offset, total: src.size });
} }
} }
@@ -104,7 +106,7 @@ function withDeadline(outer: AbortSignal | undefined, ms: number): ScopedSignal
async function postChunkWithRetry( async function postChunkWithRetry(
url: string, url: string,
slice: Blob, slice: Uint8Array<ArrayBuffer>,
offset: number, offset: number,
isLast: boolean, isLast: boolean,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
+66
View File
@@ -0,0 +1,66 @@
// FileSource:发送端的「字节来源」抽象,把传输层(p2p / relay)与具体文件载体解耦。
// web / 桌面用真 Fileslice 即内存读);iOS 无头引擎用 Range 拉取——向原生
// WKURLSchemeHandler 按需取片,整文件永不进 WebView 内存,避 jetsamR-iOS-4)。
// 两实现字节语义一致,故 p2p 的块读 / 子片发送、relay 的 1 MiB 分块全程不感知来源差异。
export interface FileSource
{
readonly name: string;
readonly size: number;
readonly type: string;
// 读取 [start, end) 字节区间(end 独占),返回该区间的副本。底层 buffer 固定为
// ArrayBuffer(非 SharedArrayBuffer),以满足 RTCDataChannel.send / fetch body 的类型约束。
slice(start: number, end: number): Promise<Uint8Array<ArrayBuffer>>;
}
// fileSource:包一个已在内存的 File(web / 桌面,以及 iOS「收到的文件再转发」等场景)。
// slice 行为与既有 `new Uint8Array(await file.slice(...).arrayBuffer())` 逐字节一致,
// 故对非 iOS 平台零行为变更。
export function fileSource(file: File): FileSource
{
return {
name: file.name,
size: file.size,
type: file.type || "application/octet-stream",
async slice(start, end)
{
return new Uint8Array(await file.slice(start, end).arrayBuffer());
},
};
}
// rangeSource:经 HTTP Range 向原生 WKURLSchemeHandler 惰性拉取文件片(iOS 大文件流式发送)。
// 每次 slice 只取 [start, end) 一段,WebView 常驻内存上限即单块大小(p2p 的
// READ_BLOCK_SIZE = 4 MiB)。url 形如 `cdrop-file://<id>`;原生据 Range 头 seek 文件、
// 回 206 Partial Content 仅含该区间字节。
export function rangeSource(
url: string,
name: string,
size: number,
type: string,
): FileSource
{
return {
name,
size,
type: type || "application/octet-stream",
async slice(start, end)
{
const resp = await fetch(url, {
headers: { Range: `bytes=${start}-${end - 1}` },
});
if (!resp.ok && resp.status !== 206)
{
throw new Error(`range fetch ${resp.status} for [${start}, ${end})`);
}
const buf = new Uint8Array(await resp.arrayBuffer());
// 正确性兜底:若原生未识别 Range(返回 200 整文件),客户端再切一刀,使返回
// 严格等于请求区间。原生正常回 206 时此分支不触发,内存仍按块有界。
if (resp.status === 200 && buf.byteLength > end - start)
{
return buf.subarray(start, end);
}
return buf;
},
};
}
+11 -10
View File
@@ -1,6 +1,7 @@
import { apiFetch } from "../../net/api"; import { apiFetch } from "../../net/api";
import { p2pStartIncoming, p2pStartOutgoing, p2pCleanup, type P2PSession } from "./p2p"; import { p2pStartIncoming, p2pStartOutgoing, p2pCleanup, type P2PSession } from "./p2p";
import { relayReceiveFile, relaySendFile } from "./relay"; import { relayReceiveFile, relaySendFile } from "./relay";
import type { FileSource } from "./source";
import { useAppStore, type TransferRecord } from "../../store"; import { useAppStore, type TransferRecord } from "../../store";
// Orchestrator that picks P2P first, falls back to Relay on ICE timeout // Orchestrator that picks P2P first, falls back to Relay on ICE timeout
@@ -12,7 +13,7 @@ interface OutgoingState
{ {
sessionId: string; sessionId: string;
peerName: string; peerName: string;
file: File; src: FileSource;
p2p: P2PSession | null; p2p: P2PSession | null;
fallbackTimer: number | null; fallbackTimer: number | null;
abort: AbortController; abort: AbortController;
@@ -25,7 +26,7 @@ const incomingFiles = new Map<string, { name: string; size: number; sender: stri
// arms a 30s watchdog that calls /fallback if ICE hasn't reached `connected`. // arms a 30s watchdog that calls /fallback if ICE hasn't reached `connected`.
export async function startOutgoingTransfer( export async function startOutgoingTransfer(
receiverName: string, receiverName: string,
file: File, src: FileSource,
): Promise<string> ): Promise<string>
{ {
const initR = await apiFetch("/api/transfer/initiate", { const initR = await apiFetch("/api/transfer/initiate", {
@@ -33,8 +34,8 @@ export async function startOutgoingTransfer(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
receiver_name: receiverName, receiver_name: receiverName,
file_name: file.name, file_name: src.name,
file_size: file.size, file_size: src.size,
}), }),
}); });
if (!initR.ok) if (!initR.ok)
@@ -47,8 +48,8 @@ export async function startOutgoingTransfer(
useAppStore.getState().upsertTransfer({ useAppStore.getState().upsertTransfer({
sessionId, sessionId,
direction: "outgoing", direction: "outgoing",
fileName: file.name, fileName: src.name,
fileSize: file.size, fileSize: src.size,
state: "PENDING", state: "PENDING",
peerName: receiverName, peerName: receiverName,
startedAt: Date.now(), startedAt: Date.now(),
@@ -61,7 +62,7 @@ export async function startOutgoingTransfer(
const state: OutgoingState = { const state: OutgoingState = {
sessionId, sessionId,
peerName: receiverName, peerName: receiverName,
file, src,
p2p: null, p2p: null,
fallbackTimer: null, fallbackTimer: null,
abort, abort,
@@ -87,7 +88,7 @@ export async function startOutgoingTransfer(
} }
}, ICE_TIMEOUT_MS); }, ICE_TIMEOUT_MS);
const p2p = p2pStartOutgoing(sessionId, receiverName, file); const p2p = p2pStartOutgoing(sessionId, receiverName, src);
state.p2p = p2p; state.p2p = p2p;
p2p.onState((s) => p2p.onState((s) =>
@@ -134,11 +135,11 @@ export async function handleRelayReady(sessionId: string): Promise<void>
p2pCleanup(out.peerName); p2pCleanup(out.peerName);
try try
{ {
await relaySendFile(sessionId, out.file, (p) => await relaySendFile(sessionId, out.src, (p) =>
{ {
const store = useAppStore.getState(); const store = useAppStore.getState();
const cur = store.activeTransfers[sessionId] const cur = store.activeTransfers[sessionId]
?? currentRecord(sessionId, "outgoing", out.peerName, out.file.name, out.file.size); ?? currentRecord(sessionId, "outgoing", out.peerName, out.src.name, out.src.size);
const grew = p.bytes > (cur.bytesTransferred ?? 0); const grew = p.bytes > (cur.bytesTransferred ?? 0);
store.upsertTransfer({ store.upsertTransfer({
...cur, ...cur,
+6
View File
@@ -409,4 +409,10 @@ export const enUS: Partial<TranslationDict> = {
"ios.files.share": "Share", "ios.files.share": "Share",
"ios.files.done": "Done", "ios.files.done": "Done",
"ios.detail.openFile": "Open File", "ios.detail.openFile": "Open File",
"ios.bg.title": "Transferring files",
"ios.bg.subtitle": "Continues in the background",
"ios.share.title": "Send {{n}} file(s) to a device",
"ios.control.upload": "Upload Clipboard",
"ios.control.pull": "Pull Clipboard",
"ios.control.sessionLabel": "{{name}} · Control",
}; };
+6
View File
@@ -405,4 +405,10 @@ export const zhCN = {
"ios.files.share": "分享", "ios.files.share": "分享",
"ios.files.done": "完成", "ios.files.done": "完成",
"ios.detail.openFile": "打开文件", "ios.detail.openFile": "打开文件",
"ios.bg.title": "正在传输文件",
"ios.bg.subtitle": "切到后台仍继续传输",
"ios.share.title": "发送 {{n}} 个文件到设备",
"ios.control.upload": "上传剪贴板",
"ios.control.pull": "拉取剪贴板",
"ios.control.sessionLabel": "{{name}} · 控件",
} as const; } as const;
+6
View File
@@ -409,4 +409,10 @@ export const zhTW: Partial<TranslationDict> = {
"ios.files.share": "分享", "ios.files.share": "分享",
"ios.files.done": "完成", "ios.files.done": "完成",
"ios.detail.openFile": "開啟檔案", "ios.detail.openFile": "開啟檔案",
"ios.bg.title": "正在傳輸檔案",
"ios.bg.subtitle": "切到背景仍繼續傳輸",
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
"ios.control.upload": "上傳剪貼簿",
"ios.control.pull": "拉取剪貼簿",
"ios.control.sessionLabel": "{{name}} · 控制項",
}; };
+3 -2
View File
@@ -9,6 +9,7 @@ import { ActivityFeed } from "../features/transfer/ActivityFeed";
import { ClipboardPanel } from "../features/clipboard/ClipboardPanel"; import { ClipboardPanel } from "../features/clipboard/ClipboardPanel";
import { MessageList } from "../features/messaging/MessageList"; import { MessageList } from "../features/messaging/MessageList";
import { startOutgoingTransfer } from "../features/transfer/transfer"; import { startOutgoingTransfer } from "../features/transfer/transfer";
import { fileSource } from "../features/transfer/source";
import { sendMessage } from "../features/messaging/messaging"; import { sendMessage } from "../features/messaging/messaging";
import { fetchClipboard, CLIPBOARD_MAX_BYTES } from "../features/clipboard/clipboard"; import { fetchClipboard, CLIPBOARD_MAX_BYTES } from "../features/clipboard/clipboard";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -60,7 +61,7 @@ function HomePage()
if (!selectedDevice || !selectedFile) { return; } if (!selectedDevice || !selectedFile) { return; }
try try
{ {
await startOutgoingTransfer(selectedDevice, selectedFile); await startOutgoingTransfer(selectedDevice, fileSource(selectedFile));
setSelectedFile(null); setSelectedFile(null);
} }
catch (e) catch (e)
@@ -79,7 +80,7 @@ function HomePage()
const handleDropFile = async (deviceName: string, file: File) => const handleDropFile = async (deviceName: string, file: File) =>
{ {
setSelectedDevice(deviceName); setSelectedDevice(deviceName);
try { await startOutgoingTransfer(deviceName, file); } try { await startOutgoingTransfer(deviceName, fileSource(file)); }
catch (e) catch (e)
{ {
toast.error("文件发送失败", e instanceof Error ? e.message : String(e)); toast.error("文件发送失败", e instanceof Error ? e.message : String(e));