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.xcconfig(Team 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/年)。
- 一台 iPhone(iOS 26),Mac 装 Xcode 26。 - 一台 iPhone(iOS 26)+ Mac(Xcode 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 → App,Bundle 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** 不是 Wildcard;App 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 SDK(iphoneos)** 套用手动签名 + 这些值;**模拟器**仍走 `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 Group:Share 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_id(dev_ 前缀 + [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 只需选对 locale。Shared 文件——主 app / Share Extension / 控件扩展共用;
// 各 target 各读自己 bundle 的 i18n JSON(resourceURL 的无子目录回退兼容扩展打平的资源布局)。
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")
} }
// 当前解析出的 locale(zh-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(类比 AirDrop)。任务生命周期绑定活跃传输集合:0→>0(且前台)时
// 提交、随字节更新 progress、回到 0 时完成。
//
// 真机验证点(R-iOS-3):BGContinuedProcessingTask 给「应用」后台运行时间 + 系统进度 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.didRegister)或引擎刚就绪(ready)任一时机调用。
// 仅在引擎已就绪 + 有令牌时经桥上报,把令牌交后端 /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 ])
}
// 推命令给引擎(原生 → JS,window.__cdropEngineEvent)。 // 推命令给引擎(原生 → JS,window.__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: - 发送侧自定义 scheme(cdrop-file) // MARK: - 发送侧自定义 scheme(cdrop-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-4)。v1 整文件读入内存返回(小 / 中文件可行);大文件改分块 // 见 PLAN §2 / R-iOS-4)。引擎(rangeSource)按块带 `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)与后端 .p8。模拟器(iOS 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”任务覆盖的数据侧除外)。 ## 计划主体(I1–I7):已实现(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 生效)。**剩余择期**:#1(device_type 标签)、#3(强制 full 登录)、#2 会话管理操作、#5 ICE 明细(可选)。全部仍未提交(按用户序:真机测完再 commit)。 ## 早期 5 项(2026-06-24 列)状态
1. **登录会话列表显示**:iOS 设备在(web)「登录会话」列表的标签——broker 迁移后 sessions 已按设备 cache 叠加 type(device-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,故不能复用引擎桥——这里走纯原生 REST(PLAN §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 与 409(PUT 时云端有更新的 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+ ControlWidget,PLAN 决策 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 scheme:Share 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 ControlWidget,iOS 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.ts:transfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署**) | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreview;project.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`(Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`(UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/s(relay 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.ts:transfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署**) | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreview;project.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`(Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`(UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/s(relay 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`(本提交) | **计划完成(I3–I7)**:发送端流式(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 binary(APNs 端点 + 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-4,FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share Extension(App 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)时返回 503,best-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 绑了 onopen;sender 这里覆盖一下,等 // bindDataChannel 给 receiver 绑了 onopen;sender 这里覆盖一下,等
@@ -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 / 桌面用真 File(slice 即内存读);iOS 无头引擎用 Range 拉取——向原生
// WKURLSchemeHandler 按需取片,整文件永不进 WebView 内存,避 jetsam(R-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));