diff --git a/Justfile b/Justfile
index 440a6cb..bb81582 100644
--- a/Justfile
+++ b/Justfile
@@ -71,6 +71,21 @@ desktop-clean:
desktop-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 ----
# 一次性预热:把 Go modules + node_modules 烤进 cdrop-base:latest。
diff --git a/cmd/cdropd/main.go b/cmd/cdropd/main.go
index cfc55d4..31bcc73 100644
--- a/cmd/cdropd/main.go
+++ b/cmd/cdropd/main.go
@@ -10,6 +10,7 @@ import (
"syscall"
"time"
+ "commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/calls"
"commilitia.net/cdrop/internal/clipboard"
"commilitia.net/cdrop/internal/config"
@@ -63,7 +64,15 @@ func main() {
} else {
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
if cfg.CFTurnKeyID != "" && cfg.CFTurnAPIToken != "" {
@@ -91,7 +100,7 @@ func main() {
srv := &http.Server{
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,
}
diff --git a/go.mod b/go.mod
index db0a78f..4d659d3 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/go-chi/chi/v5 v5.2.5
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/providers/env/v2 v2.0.0
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/fsnotify/fsnotify v1.9.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/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
diff --git a/internal/apns/sender.go b/internal/apns/sender.go
new file mode 100644
index 0000000..a8274d4
--- /dev/null
+++ b/internal/apns/sender.go
@@ -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] + "..."
+}
diff --git a/internal/apns/sender_test.go b/internal/apns/sender_test.go
new file mode 100644
index 0000000..9e7441b
--- /dev/null
+++ b/internal/apns/sender_test.go
@@ -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))
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 2a2140c..fca7c53 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -13,6 +13,11 @@ import (
"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_"
type Config struct {
@@ -86,6 +91,24 @@ type Config struct {
VAPIDPrivateKey string `koanf:"vapid_private_key"`
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 委托签发会话。
// QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503。QRRequestTTLSeconds 是一条扫码
// 请求的存活秒数(默认 120)。批准后铸的会话寿命取上面的按档 TTL(full / guest)。
@@ -151,6 +174,17 @@ func Load() (*Config, error) {
if err := cfg.validate(); err != nil {
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
}
@@ -195,9 +229,32 @@ func (c *Config) validate() error {
"CDROP_VAPID_PUBLIC_KEY and CDROP_VAPID_PRIVATE_KEY must be set together " +
"(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
}
+// 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).
func (c *Config) PushEnabled() bool {
return c.VAPIDPublicKey != "" && c.VAPIDPrivateKey != ""
diff --git a/internal/db/bootstrap.go b/internal/db/bootstrap.go
index baaaa9c..9f2a016 100644
--- a/internal/db/bootstrap.go
+++ b/internal/db/bootstrap.go
@@ -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 {
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);
// 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
diff --git a/internal/db/migrations/0002_push_platform.sql b/internal/db/migrations/0002_push_platform.sql
new file mode 100644
index 0000000..ba52c73
--- /dev/null
+++ b/internal/db/migrations/0002_push_platform.sql
@@ -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';
diff --git a/internal/db/models.go b/internal/db/models.go
index a702e27..3bdb92c 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -53,6 +53,7 @@ type PushSubscription struct {
Locale string `json:"locale"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
+ Platform string `json:"platform"`
}
type TransferSession struct {
diff --git a/internal/db/push_subscriptions.sql.go b/internal/db/push_subscriptions.sql.go
index e9898f9..9d7054d 100644
--- a/internal/db/push_subscriptions.sql.go
+++ b/internal/db/push_subscriptions.sql.go
@@ -34,26 +34,40 @@ func (q *Queries) DeletePushSubscriptionsByUserDevice(ctx context.Context, arg D
return err
}
-const listPushSubscriptionsByUserDevice = `-- name: ListPushSubscriptionsByUserDevice :many
-SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at
+const listAPNsSubscriptionsByUserDevice = `-- 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 = ?
+WHERE user_id = ? AND device_name = ? AND platform = 'apns'
`
-type ListPushSubscriptionsByUserDeviceParams struct {
+type ListAPNsSubscriptionsByUserDeviceParams struct {
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
}
-func (q *Queries) ListPushSubscriptionsByUserDevice(ctx context.Context, arg ListPushSubscriptionsByUserDeviceParams) ([]PushSubscription, error) {
- rows, err := q.db.QueryContext(ctx, listPushSubscriptionsByUserDevice, arg.UserID, arg.DeviceName)
+type ListAPNsSubscriptionsByUserDeviceRow 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) ListAPNsSubscriptionsByUserDevice(ctx context.Context, arg ListAPNsSubscriptionsByUserDeviceParams) ([]ListAPNsSubscriptionsByUserDeviceRow, error) {
+ rows, err := q.db.QueryContext(ctx, listAPNsSubscriptionsByUserDevice, arg.UserID, arg.DeviceName)
if err != nil {
return nil, err
}
defer rows.Close()
- var items []PushSubscription
+ var items []ListAPNsSubscriptionsByUserDeviceRow
for rows.Next() {
- var i PushSubscription
+ var i ListAPNsSubscriptionsByUserDeviceRow
if err := rows.Scan(
&i.ID,
&i.UserID,
@@ -63,6 +77,67 @@ func (q *Queries) ListPushSubscriptionsByUserDevice(ctx context.Context, arg Lis
&i.Auth,
&i.UserAgent,
&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.LastUsedAt,
); err != nil {
@@ -95,10 +170,44 @@ func (q *Queries) TouchPushSubscription(ctx context.Context, arg TouchPushSubscr
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
-INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at)
-VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'webpush', ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
@@ -106,6 +215,7 @@ ON CONFLICT(id) DO UPDATE SET
auth = excluded.auth,
user_agent = excluded.user_agent,
locale = excluded.locale,
+ platform = excluded.platform,
last_used_at = excluded.last_used_at
`
@@ -122,11 +232,11 @@ type UpsertPushSubscriptionParams struct {
LastUsedAt int64 `json:"last_used_at"`
}
-// push_subscriptions: Web Push endpoints for browsers / PWAs. id is the hex
-// SHA-256 of the endpoint, so a re-subscribe from the same browser upserts in
-// place rather than piling up duplicate rows. Lookups are by (user_id,
-// device_name): the receiving device of a notify-worthy event. Dead endpoints
-// (push service returns 404/410) are pruned by DeletePushSubscription.
+// push_subscriptions: Web Push and APNs subscription records.
+// id is hex SHA-256(endpoint/device-token), making upsert idempotent.
+// platform distinguishes 'webpush' (browser VAPID) from 'apns' (iOS device token).
+// Lookups are by (user_id, device_name, platform) so each channel only sees its own rows.
+// 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
// multibyte runes in query files, corrupting the generated SQL.
func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) error {
diff --git a/internal/db/queries/push_subscriptions.sql b/internal/db/queries/push_subscriptions.sql
index c02d0c9..ad844fc 100644
--- a/internal/db/queries/push_subscriptions.sql
+++ b/internal/db/queries/push_subscriptions.sql
@@ -1,14 +1,14 @@
--- push_subscriptions: Web Push endpoints for browsers / PWAs. id is the hex
--- SHA-256 of the endpoint, so a re-subscribe from the same browser upserts in
--- place rather than piling up duplicate rows. Lookups are by (user_id,
--- device_name): the receiving device of a notify-worthy event. Dead endpoints
--- (push service returns 404/410) are pruned by DeletePushSubscription.
+-- push_subscriptions: Web Push and APNs subscription records.
+-- id is hex SHA-256(endpoint/device-token), making upsert idempotent.
+-- platform distinguishes 'webpush' (browser VAPID) from 'apns' (iOS device token).
+-- Lookups are by (user_id, device_name, platform) so each channel only sees its own rows.
+-- 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
-- multibyte runes in query files, corrupting the generated SQL.
-- name: UpsertPushSubscription :exec
-INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at)
-VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'webpush', ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
@@ -16,12 +16,28 @@ ON CONFLICT(id) DO UPDATE SET
auth = excluded.auth,
user_agent = excluded.user_agent,
locale = excluded.locale,
+ platform = excluded.platform,
last_used_at = excluded.last_used_at;
-- 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
-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
UPDATE push_subscriptions
diff --git a/internal/httpapi/message.go b/internal/httpapi/message.go
index 9cf626d..a53bd14 100644
--- a/internal/httpapi/message.go
+++ b/internal/httpapi/message.go
@@ -80,13 +80,20 @@ func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) {
})
if !delivered {
// 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
- // has a subscription, send it and report 202; otherwise it's truly gone.
- if s.push.Enabled() {
- go s.push.Notify(context.Background(), claims.UserID, req.To, push.Notification{
- Type: push.KindMessage,
- Params: map[string]string{"sender": from, "text": req.Text},
- })
+ // only delivery left is a push notification (Web Push or APNs) carrying
+ // the text itself. If any push channel is enabled and may have a
+ // subscription, send and report 202; otherwise the message is truly gone.
+ n := push.Notification{
+ Type: push.KindMessage,
+ 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)
return
}
diff --git a/internal/httpapi/push.go b/internal/httpapi/push.go
index a069973..ddcf67c 100644
--- a/internal/httpapi/push.go
+++ b/internal/httpapi/push.go
@@ -10,6 +10,10 @@ import (
"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
// JSON is well under 1 KB (endpoint URL + two short keys); 8 KB is generous
// 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)
}
+// 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 {
Endpoint string `json:"endpoint"`
}
diff --git a/internal/httpapi/push_test.go b/internal/httpapi/push_test.go
new file mode 100644
index 0000000..2fb7bd8
--- /dev/null
+++ b/internal/httpapi/push_test.go
@@ -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)
+ }
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index deac452..ad4d15b 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
+ "commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/brokerclient"
"commilitia.net/cdrop/internal/calls"
"commilitia.net/cdrop/internal/clipboard"
@@ -36,6 +37,7 @@ type Server struct {
calls *calls.Provider // optional; nil → STUN-only fallback
clipboard *clipboard.Service // optional; nil → 503 on /api/clipboard
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
// broker delegates session lifecycle to the Auth Broker (mint / revoke / refresh).
@@ -56,6 +58,7 @@ func New(
cp *calls.Provider,
clip *clipboard.Service,
pushSender *push.Sender,
+ apnsSender *apns.Sender,
) *Server {
s := &Server{
cfg: cfg,
@@ -68,6 +71,7 @@ func New(
calls: cp,
clipboard: clip,
push: pushSender,
+ apns: apnsSender,
mux: chi.NewRouter(),
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.Post("/push/subscribe", s.handlePushSubscribe)
r.Delete("/push/subscribe", s.handlePushUnsubscribe)
+ r.Post("/push/apns/register", s.handleAPNsRegister)
r.Get("/calls/credentials", s.handleCallsCredentials)
// Full sessions only — restricted guests (scan-login borrows) are
diff --git a/internal/jwtauth/claims.go b/internal/jwtauth/claims.go
index e9d4a21..fc3ac51 100644
--- a/internal/jwtauth/claims.go
+++ b/internal/jwtauth/claims.go
@@ -68,6 +68,13 @@ func DeviceNameFromContext(ctx context.Context) (string, bool) {
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
// middleware (browser / macos / windows / linux / ios), defaulting to "browser".
func DeviceTypeFromContext(ctx context.Context) string {
diff --git a/internal/push/sender.go b/internal/push/sender.go
index b9077b9..e2447bc 100644
--- a/internal/push/sender.go
+++ b/internal/push/sender.go
@@ -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{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
@@ -192,7 +192,10 @@ func endpointHost(endpoint string) string {
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
// 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
-// stored locale. Unknown locales fall back to defaultLocale.
+// stored locale. Unknown locales fall back to DefaultLocale.
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}
if out.URL == "" {
out.URL = "/"
@@ -229,7 +232,11 @@ func render(n Notification, locale string) wirePayload {
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
// own text — neither is a template, so locale is irrelevant.
if typ == KindMessage {
@@ -238,7 +245,7 @@ func localize(typ string, params map[string]string, locale string) (title, body
t, ok := notifyStrings[locale]
if !ok {
- t = notifyStrings[defaultLocale]
+ t = notifyStrings[DefaultLocale]
}
switch typ {
case KindTransferIncoming:
diff --git a/internal/push/sender_test.go b/internal/push/sender_test.go
index 4908f0e..ae98b9e 100644
--- a/internal/push/sender_test.go
+++ b/internal/push/sender_test.go
@@ -107,7 +107,7 @@ func TestRenderUnknownLocaleFallsBackToDefault(t *testing.T) {
Params: map[string]string{"filename": "a.pdf"},
}
got := render(n, "fr-FR")
- want := render(n, defaultLocale)
+ want := render(n, DefaultLocale)
if got.Title != want.Title || got.Body != want.Body {
t.Fatalf("unknown locale did not fall back: got %+v want %+v", got, want)
}
diff --git a/internal/transfer/service.go b/internal/transfer/service.go
index 7d722a6..ea65091 100644
--- a/internal/transfer/service.go
+++ b/internal/transfer/service.go
@@ -8,6 +8,7 @@ import (
"fmt"
"time"
+ "commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/push"
@@ -49,12 +50,13 @@ type Service struct {
hub *hub.Hub
relay RelayController
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
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 {
r = nopRelay{}
}
@@ -63,6 +65,7 @@ func NewService(queries *db.Queries, h *hub.Hub, r RelayController, pushSender *
hub: h,
relay: r,
push: pushSender,
+ apns: apnsSender,
now: time.Now,
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.
- if !delivered && s.push.Enabled() {
- go s.push.Notify(context.Background(), p.UserID, p.ReceiverName, push.Notification{
+ // Both Web Push (browser/PWA) and APNs (iOS) are attempted; each sender
+ // only fires for its own platform rows, so firing both is always correct.
+ if !delivered {
+ n := push.Notification{
Type: push.KindTransferIncoming,
Params: map[string]string{"sender": p.SenderName, "filename": p.FileName},
Tag: "transfer:" + id,
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
@@ -219,7 +230,9 @@ func (s *Service) Transition(
deliveredReceiver := s.hub.SendTo(userID, sess.ReceiverName, stateEv)
// 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
if target == StateFailed {
kind = push.KindTransferFailed
@@ -230,10 +243,20 @@ func (s *Service) Transition(
Tag: "transfer:" + sessionID,
}
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 {
- 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)
+ }
}
}
diff --git a/internal/transfer/service_test.go b/internal/transfer/service_test.go
index 572bf07..72d1e3b 100644
--- a/internal/transfer/service_test.go
+++ b/internal/transfer/service_test.go
@@ -51,7 +51,7 @@ func newTestService(t *testing.T) *Service {
q := openTestDB(t)
h := newTestHub()
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,
@@ -78,7 +78,7 @@ func newTestServiceWithRelay(t *testing.T, r RelayController) *Service {
q := openTestDB(t)
h := newTestHub()
t.Cleanup(h.Close)
- return NewService(q, h, r, nil)
+ return NewService(q, h, r, nil, nil)
}
func TestInit_AllowsZeroByteFile(t *testing.T) {
diff --git a/ios/CDrop/.gitignore b/ios/CDrop/.gitignore
index fa76db5..6f96c85 100644
--- a/ios/CDrop/.gitignore
+++ b/ios/CDrop/.gitignore
@@ -6,3 +6,6 @@ build/
DerivedData/
*.xcuserstate
.DS_Store
+
+# 真机签名的账号特定值(Team ID + profile 名)——公开仓库不留账号标识,见 REALDEVICE.md。
+Local.xcconfig
diff --git a/ios/CDrop/CDrop.entitlements b/ios/CDrop/CDrop.entitlements
new file mode 100644
index 0000000..fe08abd
--- /dev/null
+++ b/ios/CDrop/CDrop.entitlements
@@ -0,0 +1,16 @@
+
+
+
+
+
+ aps-environment
+ development
+
+ com.apple.security.application-groups
+
+ group.net.commilitia.cdrop
+
+
+
diff --git a/ios/CDrop/REALDEVICE.md b/ios/CDrop/REALDEVICE.md
index f82b0f8..86f823a 100644
--- a/ios/CDrop/REALDEVICE.md
+++ b/ios/CDrop/REALDEVICE.md
@@ -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/年)。
-- 一台 iPhone(iOS 26),Mac 装 Xcode 26。
-- 已登录的 cdrop(网页 / 桌面)一个——用来扫码批准本机登录。
+- 一台 iPhone(iOS 26)+ Mac(Xcode 26 命令行工具)。
+- 一个已登录的 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 真机自动签名会覆盖它,无需手改。
+---
-## 引擎可达性(让传输真能连)
-- 真机上引擎 WebView 默认加载 `https://drop.commilitia.net/engine.html`——**该文件要先随 web 部署到 prod**(`vite build` 已产出 `dist/engine.html`,随正常 web 发布上线)。
-- 或本机联调:设环境变量 `CDROP_ENGINE_URL` 指向可达引擎、`CDROP_API_BASE` 指向后端。
+## A. 门户操作(developer.apple.com/account,一次性)
-## 后续功能要补的 capability(按需,各自要在 Developer Portal 配 App ID)
-- **App Groups**(`group.net.commilitia.cdrop`):Share Extension / 控制中心控件与主 app 共享数据。
-- **Push Notifications** + APNs `.p8`:传输事件推送。
-- **Keychain Sharing**:跨 app / 扩展共享登录态。
-- **Associated Domains**:若改用 OIDC Universal Link 回调(扫码登录则不需要)。
+> 入口:登录后左侧 **Certificates, Identifiers & Profiles**(证书 / 标识符 / 描述文件总枢纽)——A1–A5 都在这里面;A6 在 **Keys**;Team ID 在 **Membership details**(10 位,记下)。
-## 只能真机验的清单
-- [ ] 液态玻璃在真机渲染(含陀螺仪高光 / 动效)。
-- [ ] 扫码登录:本机显码 → 用已登录 cdrop 扫码批准 → app 进入主界面。
-- [ ] **本地网络权限**:首次同内网传输弹 `NSLocalNetworkUsageDescription` 提示;授予后 ICE 收到 host 候选(R-iOS-6);拒绝则回退中继仍可传。
-- [ ] 发送:选文件 → 选设备 → 对端收到。
-- [ ] 接收:对端发来 → 落到 Files(沙盒 Documents)。
-- [ ] 后台:传输中切后台 → `BGContinuedProcessingTask` 续传行为(R-iOS-1 / R-iOS-3)。
-- [ ] 大文件:发送侧内存(当前整文件入内存,大文件需转分块流式 = R-iOS-4)。
+1. **证书**(Apple Development):本机「钥匙串访问」可能已有(「我的证书」分类里看)。没有就:钥匙串访问菜单 → 证书助理 → 从证书颁发机构请求证书 → 存到磁盘 → 门户 **Certificates** → + → Apple Development → 传 CSR → 下载 `.cer` 双击安装。
+2. **App IDs ×3**(**Identifiers** → + → App IDs → App,Bundle ID 选 **Explicit**):
+ - `net.commilitia.Commilitia-Drop` — 勾 **App Groups** + **Push Notifications**
+ - `net.commilitia.Commilitia-Drop.share` — 勾 **App Groups**
+ - `net.commilitia.Commilitia-Drop.widgets` — 勾 **App Groups**
+3. **App Group**:**Identifiers** 页类型筛选切到 **App Groups** → + → `group.net.commilitia.cdrop`。再回上面 3 个 App ID 各自的 App Groups 能力里 **Edit/Configure** 关联它(三个都要)。
+4. **设备**:**Devices** → + → 填 UDID(插上手机后 `just ios-devices` 读)。
+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(真机阶段一并收)
-- 会话 Keychain 持久化 + 自动续期(当前会话在内存、重启需重扫;续期端点 `/api/auth/refresh` 已可用,靠 cookie)。
-- 发送侧大文件分块流式(R-iOS-4)。
-- 剪贴板 App Intent + 控制中心控件(决策 C)、Share Extension——需上面的 capability + 账号才能跑。
+> 常见坑:Bundle ID 选 **Explicit** 不是 Wildcard;App Group 务必 3 个 App ID 都关联;Profile 选 **Development** 不要 Distribution。
+
+---
+
+## 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 选设备发送。
+- [ ] **控制中心剪贴板两控件**:控制中心加「上传 / 拉取剪贴板」控件 → 锁屏 / 解锁态点按 → 云剪贴板读写。
diff --git a/ios/CDrop/Share/CDropShare.entitlements b/ios/CDrop/Share/CDropShare.entitlements
new file mode 100644
index 0000000..ae503b8
--- /dev/null
+++ b/ios/CDrop/Share/CDropShare.entitlements
@@ -0,0 +1,11 @@
+
+
+
+
+
+ com.apple.security.application-groups
+
+ group.net.commilitia.cdrop
+
+
+
diff --git a/ios/CDrop/Share/Info.plist b/ios/CDrop/Share/Info.plist
new file mode 100644
index 0000000..88e8e68
--- /dev/null
+++ b/ios/CDrop/Share/Info.plist
@@ -0,0 +1,43 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Commilitia Drop
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionAttributes
+
+ NSExtensionActivationRule
+
+ NSExtensionActivationSupportsFileWithMaxCount
+ 20
+ NSExtensionActivationSupportsImageWithMaxCount
+ 20
+ NSExtensionActivationSupportsMovieWithMaxCount
+ 20
+
+
+ NSExtensionPointIdentifier
+ com.apple.share-services
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).ShareViewController
+
+
+
diff --git a/ios/CDrop/Share/ShareViewController.swift b/ios/CDrop/Share/ShareViewController.swift
new file mode 100644
index 0000000..13a4b80
--- /dev/null
+++ b/ios/CDrop/Share/ShareViewController.swift
@@ -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
+ }
+ }
+}
diff --git a/ios/CDrop/Shared/AppGroup.swift b/ios/CDrop/Shared/AppGroup.swift
new file mode 100644
index 0000000..daba1c0
--- /dev/null
+++ b/ios/CDrop/Shared/AppGroup.swift
@@ -0,0 +1,66 @@
+import Foundation
+
+// App Group:主 app 与 Share Extension 共享容器,用于交接待发文件——扩展只把文件搬进收件箱、
+// 不跑传输引擎(避 120MB jetsam,见 PLAN §I5 / 决策 D)。两 target 共用此文件(xcodegen 多
+// target 引用)。
+//
+// 收件箱布局:每个待发文件放进独立的 子目录,文件本身保留原名(发送时直接用作传输
+// 文件名);发送 / 取消后删掉该子目录即清理。
+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(//),并建好父目录。
+ 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 }
+ }
+ }
+
+ // 发送 / 取消后清理:删掉该文件所在的 子目录。
+ 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) }
+ }
+ }
+}
diff --git a/ios/CDrop/Shared/CDropAPI.swift b/ios/CDrop/Shared/CDropAPI.swift
new file mode 100644
index 0000000..1aff7fa
--- /dev/null
+++ b/ios/CDrop/Shared/CDropAPI.swift
@@ -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)
+ }
+}
diff --git a/ios/CDrop/Sources/I18n.swift b/ios/CDrop/Shared/I18n.swift
similarity index 71%
rename from ios/CDrop/Sources/I18n.swift
rename to ios/CDrop/Shared/I18n.swift
index cf6b92e..8de081c 100644
--- a/ios/CDrop/Sources/I18n.swift
+++ b/ios/CDrop/Shared/I18n.swift
@@ -1,8 +1,9 @@
import Foundation
-// i18n:读 app bundle 内的 .json(由 web 单一真源经 web/scripts/emit-i18n.mjs 产出,
-// 强约束②,见 ios/PLAN.md §5)。跟随系统语言、回退 zh-CN。各 JSON 已含完整回退(缺失键
-// 在产出时已并入 zh-CN 值),故 loader 只需选对 locale。
+// i18n:读 bundle 内的 .json(由 web 单一真源经 web/scripts/emit-i18n.mjs 产出,强约束②,
+// 见 ios/PLAN.md §5)。跟随系统语言、回退 zh-CN。各 JSON 已含完整回退(缺失键在产出时已并入
+// zh-CN 值),故 loader 只需选对 locale。Shared 文件——主 app / Share Extension / 控件扩展共用;
+// 各 target 各读自己 bundle 的 i18n JSON(resourceURL 的无子目录回退兼容扩展打平的资源布局)。
enum I18n
{
private static let dict: [String: String] = load()
@@ -39,6 +40,10 @@ enum I18n
?? 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
{
for lang in Locale.preferredLanguages
diff --git a/ios/CDrop/Shared/WidgetSession.swift b/ios/CDrop/Shared/WidgetSession.swift
new file mode 100644
index 0000000..79163f7
--- /dev/null
+++ b/ios/CDrop/Shared/WidgetSession.swift
@@ -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)
+ }
+}
diff --git a/ios/CDrop/Signing.xcconfig b/ios/CDrop/Signing.xcconfig
new file mode 100644
index 0000000..f55150a
--- /dev/null
+++ b/ios/CDrop/Signing.xcconfig
@@ -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)
diff --git a/ios/CDrop/Sources/AppDelegate.swift b/ios/CDrop/Sources/AppDelegate.swift
new file mode 100644
index 0000000..66180f0
--- /dev/null
+++ b/ios/CDrop/Sources/AppDelegate.swift
@@ -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 能力后这里不会触发。
+ }
+}
diff --git a/ios/CDrop/Sources/BackgroundTaskManager.swift b/ios/CDrop/Sources/BackgroundTaskManager.swift
new file mode 100644
index 0000000..0773306
--- /dev/null
+++ b/ios/CDrop/Sources/BackgroundTaskManager.swift
@@ -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 须含 ".*",
+ // 否则注册返回 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
+ }
+ }
+}
diff --git a/ios/CDrop/Sources/CDropApp.swift b/ios/CDrop/Sources/CDropApp.swift
index a17aeb8..13eaabb 100644
--- a/ios/CDrop/Sources/CDropApp.swift
+++ b/ios/CDrop/Sources/CDropApp.swift
@@ -5,6 +5,7 @@ import SwiftUI
@main
struct CDropApp: App
{
+ @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var auth = AuthManager()
@State private var engine = EngineController()
@@ -47,5 +48,12 @@ struct AppRoot: View
auth.debugSession()
}
}
+ // Share Extension 深链:cdrop://share 唤起 → 导入收件箱待发文件(RootView 据此弹选设备)。
+ // 放在 AppRoot 以便登录 / 未登录任一界面都接得到;未登录时文件留收件箱,登录后 onAppear 补扫。
+ .onOpenURL
+ { url in
+ guard url.scheme == "cdrop", url.host == "share" else { return }
+ engine.loadPendingShares()
+ }
}
}
diff --git a/ios/CDrop/Sources/Engine/EngineController.swift b/ios/CDrop/Sources/Engine/EngineController.swift
index cc35a25..23ea916 100644
--- a/ios/CDrop/Sources/Engine/EngineController.swift
+++ b/ios/CDrop/Sources/Engine/EngineController.swift
@@ -60,6 +60,10 @@ final class EngineController: NSObject
var presenceCount = 0
var lastEngineLog = ""
+ // 引擎 JS 是否已就绪(收到 "ready" 通知)。门控 APNs 令牌登记:令牌可能先于引擎到达,
+ // 故 didRegister 与 ready 双触发 flushPushRegistration,任一顺序都登记得上。
+ private var engineReady = false
+
// 剪贴板 / 设备管理操作的瞬时反馈(设置页 / 设备页展示)。
var clipboardStatus = ""
var deviceActionStatus = ""
@@ -89,6 +93,7 @@ final class EngineController: NSObject
override init()
{
super.init()
+ PushRegistry.shared.engine = self
}
// makeWebView:构建离屏 WebView——注入 __CDROP_BOOT__(device_type:"ios")、注册消息
@@ -126,6 +131,8 @@ final class EngineController: NSObject
wv.load(request)
webView = wv
deviceName = currentDeviceName()
+ // 已登录、引擎启动 → 请求通知授权并注册远程通知(幂等)。令牌回来后经引擎桥上报。
+ PushRegistry.shared.requestAuthorizationAndRegister()
return wv
}
@@ -135,11 +142,19 @@ final class EngineController: NSObject
{
sendCommand("shutdown", payload: [:])
webView = nil
+ engineReady = false
devices = []
transfers = []
history = []
status = t("ios.engine.disconnected")
deviceName = ""
+ PushRegistry.shared.reset()
+ WidgetSessionStore.clear()
+ // L1:释放暂存待发文件的安全作用域访问 + 清空 outgoing,登出不残留访问声明 / 条目。
+ for (_, url) in outgoing { url.stopAccessingSecurityScopedResource() }
+ outgoing.removeAll()
+ // L2:清掉控件设备 id,下次登录铸全新控件身份。
+ CDropAPI.clearWidgetDeviceID()
}
// 收到的文件(Documents 沙盒,与 Files app 同一批)。供「收到的文件」视图浏览 / 转发。
@@ -185,6 +200,24 @@ final class EngineController: NSObject
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)。
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:// 引用。文件选择器给的 URL 是安全作用域
// 资源,需 start...Access 才能读。
private func stageOutgoingFile(_ url: URL) -> String
@@ -278,6 +353,9 @@ extension EngineController: WKScriptMessageHandler
{
case "ready":
status = t("ios.engine.ready")
+ engineReady = true
+ flushPushRegistration()
+ provisionWidgetSessionIfNeeded()
case "probe":
// 本机 WebRTC-in-WKWebView 探针结果(验 arch A 命门 R-iOS-3)。
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]
{
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
+ syncBackgroundTask()
}
case "transferDone":
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
@@ -309,6 +388,7 @@ extension EngineController: WKScriptMessageHandler
history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0)
if history.count > 30 { history.removeLast(history.count - 30) }
+ syncBackgroundTask()
}
case "sendStarted":
// 随后的 transfers 事件会带出该活跃项,这里无需额外处理。
@@ -362,6 +442,15 @@ extension EngineController: WKScriptMessageHandler
{
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":
// 引擎确证会话已失效(refresh 过期 / 吊销)→ 清 Keychain 回登录页。
auth?.logout()
@@ -375,6 +464,16 @@ extension EngineController: WKScriptMessageHandler
{
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)
@@ -382,8 +481,9 @@ extension EngineController: WKScriptMessageHandler
extension EngineController: WKURLSchemeHandler
{
// 发送侧:原生把暂存的待发文件经 cdrop-file:// 喂给引擎(JS fetch 它得到字节,
- // 见 PLAN §2 / R-iOS-4)。v1 整文件读入内存返回(小 / 中文件可行);大文件改分块
- // didReceive 流式以避 jetsam = R-iOS-4 优化点。
+ // 见 PLAN §2 / R-iOS-4)。引擎(rangeSource)按块带 `Range` 头拉取——这里 seek 文件
+ // 仅读该区间、回 206 Partial Content,整文件永不入内存,避大文件 jetsam。无 Range
+ // 头时回退整文件(防御路径;引擎正常总带 Range)。
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
{
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)
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
{
respond(urlSchemeTask, status: 404)
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",
headerFields: [
"Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)",
+ "Accept-Ranges": "bytes",
])!
urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data)
@@ -410,7 +542,23 @@ extension EngineController: WKURLSchemeHandler
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?
+ {
+ 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)
diff --git a/ios/CDrop/Sources/Info.plist b/ios/CDrop/Sources/Info.plist
index 46da4f3..d10f3d5 100644
--- a/ios/CDrop/Sources/Info.plist
+++ b/ios/CDrop/Sources/Info.plist
@@ -2,6 +2,10 @@
+ BGTaskSchedulerPermittedIdentifiers
+
+ net.commilitia.Commilitia-Drop.transfer.*
+
CFBundleDevelopmentRegion
zh-Hans
CFBundleDisplayName
@@ -24,6 +28,17 @@
APPL
CFBundleShortVersionString
1.0
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ net.commilitia.Commilitia-Drop
+ CFBundleURLSchemes
+
+ cdrop
+
+
+
CFBundleVersion
1
LSSupportsOpeningDocumentsInPlace
@@ -41,6 +56,10 @@
Commilitia Drop 使用相机扫描二维码登录新设备。
NSLocalNetworkUsageDescription
Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。
+ UIBackgroundModes
+
+ remote-notification
+
UIFileSharingEnabled
UILaunchScreen
diff --git a/ios/CDrop/Sources/PushRegistry.swift b/ios/CDrop/Sources/PushRegistry.swift
new file mode 100644
index 0000000..58c8aff
--- /dev/null
+++ b/ios/CDrop/Sources/PushRegistry.swift
@@ -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 ]
+ }
+}
diff --git a/ios/CDrop/Sources/Resources/i18n/en-US.json b/ios/CDrop/Sources/Resources/i18n/en-US.json
index bf09a9a..ce5d319 100644
--- a/ios/CDrop/Sources/Resources/i18n/en-US.json
+++ b/ios/CDrop/Sources/Resources/i18n/en-US.json
@@ -354,5 +354,11 @@
"ios.files.empty": "No files received yet",
"ios.files.share": "Share",
"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"
}
diff --git a/ios/CDrop/Sources/Resources/i18n/zh-CN.json b/ios/CDrop/Sources/Resources/i18n/zh-CN.json
index 6f3d9bc..3af806c 100644
--- a/ios/CDrop/Sources/Resources/i18n/zh-CN.json
+++ b/ios/CDrop/Sources/Resources/i18n/zh-CN.json
@@ -354,5 +354,11 @@
"ios.files.empty": "还没有收到文件",
"ios.files.share": "分享",
"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}} · 控件"
}
diff --git a/ios/CDrop/Sources/Resources/i18n/zh-TW.json b/ios/CDrop/Sources/Resources/i18n/zh-TW.json
index 219d5c6..6448052 100644
--- a/ios/CDrop/Sources/Resources/i18n/zh-TW.json
+++ b/ios/CDrop/Sources/Resources/i18n/zh-TW.json
@@ -354,5 +354,11 @@
"ios.files.empty": "尚未收到檔案",
"ios.files.share": "分享",
"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}} · 控制項"
}
diff --git a/ios/CDrop/Sources/RootView.swift b/ios/CDrop/Sources/RootView.swift
index 6cf1bd4..14d9851 100644
--- a/ios/CDrop/Sources/RootView.swift
+++ b/ios/CDrop/Sources/RootView.swift
@@ -54,6 +54,23 @@ struct RootView: View
.opacity(0)
.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() }
+ }
}
}
diff --git a/ios/CDrop/TODO.md b/ios/CDrop/TODO.md
index db9ad73..6d81442 100644
--- a/ios/CDrop/TODO.md
+++ b/ios/CDrop/TODO.md
@@ -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 标签呈现。
-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 已有,未接)。
+传输(含发送端流式 R-iOS-4)/ 设备 + 会话管理 / 强制 full 登录 / 登出 / 传输详情 / 后台续传(BGContinuedProcessingTask)/ APNs(后端 + 原生注册)/ Share Extension / 控制中心剪贴板两控件——全部落地、模拟器验、经 ultracode 审查修讫。详见 `ios/PLAN.md` 里程碑 + `ios/PARITY.md` 漂移日志。
-> **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 余量)。
diff --git a/ios/CDrop/Widgets/CDropWidgets.entitlements b/ios/CDrop/Widgets/CDropWidgets.entitlements
new file mode 100644
index 0000000..ece16d8
--- /dev/null
+++ b/ios/CDrop/Widgets/CDropWidgets.entitlements
@@ -0,0 +1,11 @@
+
+
+
+
+
+ com.apple.security.application-groups
+
+ group.net.commilitia.cdrop
+
+
+
diff --git a/ios/CDrop/Widgets/ClipboardClient.swift b/ios/CDrop/Widgets/ClipboardClient.swift
new file mode 100644
index 0000000..681af64
--- /dev/null
+++ b/ios/CDrop/Widgets/ClipboardClient.swift
@@ -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)
+ }
+}
diff --git a/ios/CDrop/Widgets/ClipboardControls.swift b/ios/CDrop/Widgets/ClipboardControls.swift
new file mode 100644
index 0000000..7b1c630
--- /dev/null
+++ b/ios/CDrop/Widgets/ClipboardControls.swift
@@ -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("拉取剪贴板")
+ }
+}
diff --git a/ios/CDrop/Widgets/ClipboardIntents.swift b/ios/CDrop/Widgets/ClipboardIntents.swift
new file mode 100644
index 0000000..b7ec736
--- /dev/null
+++ b/ios/CDrop/Widgets/ClipboardIntents.swift
@@ -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()
+ }
+}
diff --git a/ios/CDrop/Widgets/Info.plist b/ios/CDrop/Widgets/Info.plist
new file mode 100644
index 0000000..29cf963
--- /dev/null
+++ b/ios/CDrop/Widgets/Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Commilitia Drop
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/ios/CDrop/project.yml b/ios/CDrop/project.yml
index 0cff85d..e347cca 100644
--- a/ios/CDrop/project.yml
+++ b/ios/CDrop/project.yml
@@ -7,19 +7,37 @@ options:
settings:
base:
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_ALLOWED: "YES"
targets:
CDrop:
type: application
platform: iOS
deploymentTarget: "26.0"
+ configFiles:
+ Debug: Signing.xcconfig
+ Release: Signing.xcconfig
sources:
- path: Sources
+ - path: Shared
+ dependencies:
+ - target: CDropShare
+ embed: true
+ - target: CDropWidgets
+ embed: true
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
+ # cdrop:// URL scheme:Share Extension 交接后经 cdrop://share 深链唤起主 app 选设备发送。
+ CFBundleURLTypes:
+ - CFBundleURLName: net.commilitia.Commilitia-Drop
+ CFBundleURLSchemes:
+ - cdrop
# 声明支持的语言,使系统控件(EditButton 的「编辑」、滑动删除的「删除」等系统字符
# 串)跟随设备语言本地化,而非永远英文。开发区域设简体。
CFBundleDevelopmentRegion: zh-Hans
@@ -28,6 +46,13 @@ targets:
- zh-Hant
- en
UILaunchScreen: {}
+ # 后台续传:BGContinuedProcessingTask 的通配标识符须在此声明,系统才允许注册 / 提交
+ # (见 BackgroundTaskManager)。默认资源(CPU + 网络)无需额外 entitlement。
+ BGTaskSchedulerPermittedIdentifiers:
+ - "net.commilitia.Commilitia-Drop.transfer.*"
+ # 远程通知后台模式:APNs 推送送达 / 后台处理(如「收到文件」通知)。
+ UIBackgroundModes:
+ - remote-notification
# 让接收文件落地的 Documents 目录在 Files app「我的 iPhone / Commilitia Drop」下
# 可见、可就地打开(收到的文件有处可开、可被其他 app 取用)。
UIFileSharingEnabled: true
@@ -40,9 +65,70 @@ targets:
NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。"
settings:
base:
- PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.cdrop
+ PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop
TARGETED_DEVICE_FAMILY: "1,2"
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:
CDrop:
build:
diff --git a/ios/PARITY.md b/ios/PARITY.md
index 82f4247..2fccf45 100644
--- a/ios/PARITY.md
+++ b/ios/PARITY.md
@@ -33,6 +33,7 @@
- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。
- **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto;间距 / 圆角 / 字阶各用平台母语。
- **平台专属**:控制中心控件、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 | 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-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 列) |
diff --git a/ios/PLAN.md b/ios/PLAN.md
index 7aa491a..3d1a7c1 100644
--- a/ios/PLAN.md
+++ b/ios/PLAN.md
@@ -111,6 +111,8 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
| **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 |
| **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 展开)
diff --git a/web/src/engine/main.ts b/web/src/engine/main.ts
index 8553f2d..2b83c5b 100644
--- a/web/src/engine/main.ts
+++ b/web/src/engine/main.ts
@@ -25,6 +25,7 @@ import { apiFetch } from "../net/api";
import { startHub } from "../features/transfer/hub";
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer";
+import { rangeSource } from "../features/transfer/source";
import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
import { useAppStore } from "../store";
import type { TransferRecord } from "../store/types";
@@ -166,22 +167,15 @@ function subscribeStore(): void
});
}
-// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露,这里取回为
-// File 交给引擎发起传输。
-//
-// R-iOS-4:整文件 fetch 进内存——小 / 中文件可行;大文件在 iOS 上有 jetsam 风险,按
-// slice 向原生拉取(不整文件入内存)是真机验证后的优化点,待与原生发送端同期落地。
+// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露。R-iOS-4:用
+// rangeSource 惰性按块拉取(原生据 Range 头 seek 文件、回 206),整文件永不进 WebView
+// 内存——大文件不再有 jetsam 风险,传输层 p2p / relay 透明复用。
async function handleSendFile(p: SendFilePayload): Promise
{
try
{
- const resp = await fetch(p.url);
- if (!resp.ok) { throw new Error(`fetch staged file failed: ${resp.status}`); }
- 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);
+ const src = rangeSource(p.url, p.name, p.size, p.type ?? "");
+ const sessionId = await startOutgoingTransfer(p.target, src);
notifyNative("sendStarted", { sessionId, name: p.name });
}
catch (e)
@@ -251,6 +245,21 @@ function bindCommands(): void
if (!name) { return; }
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", () =>
{
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
+{
+ 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
+{
+ 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
{
try
diff --git a/web/src/features/transfer/p2p.ts b/web/src/features/transfer/p2p.ts
index 40d4407..051ba6a 100644
--- a/web/src/features/transfer/p2p.ts
+++ b/web/src/features/transfer/p2p.ts
@@ -2,6 +2,7 @@ import { apiFetch } from "../../net/api";
import { getICEServers } from "./iceServers";
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
import { deliverIncoming, openIncomingSink, type IncomingSink } from "./incomingSink";
+import type { FileSource } from "./source";
// WebRTC client wired to the brief §2 invariants:
// - iceServers: pulled from lib/iceServers (Cloudflare Realtime TURN over
@@ -461,9 +462,9 @@ class Session
}
}
- async startOutgoing(file: File): Promise
+ async startOutgoing(src: FileSource): Promise
{
- this.outgoingTotal = file.size;
+ this.outgoingTotal = src.size;
const dc = this.pc.createDataChannel(CHANNEL_NAME, { ordered: true });
this.bindDataChannel(dc);
// bindDataChannel 给 receiver 绑了 onopen;sender 这里覆盖一下,等
@@ -471,7 +472,7 @@ class Session
dc.onopen = () =>
{
this.setPhase("dc_open");
- void this.streamFile(file).catch((e) =>
+ void this.streamFile(src).catch((e) =>
{
// eslint-disable-next-line no-console
console.error("p2p send failed", e);
@@ -485,7 +486,7 @@ class Session
await this.sendSignal({ type: "offer", sdp: offer });
}
- private async streamFile(file: File): Promise
+ private async streamFile(src: FileSource): Promise
{
if (!this.dc) { throw new Error("no data channel"); }
const dc = this.dc;
@@ -495,7 +496,7 @@ class Session
await this.markP2PActive();
// 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 }));
// buffer 会被 64KB 一片秒满到 16MiB,之后发送循环长时间阻塞在 waitForBufferLow
@@ -508,11 +509,12 @@ class Session
const sendChunk = CHUNK_SIZE;
let offset = 0;
- while (offset < file.size)
+ while (offset < src.size)
{
- // 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。
- const blockEnd = Math.min(offset + READ_BLOCK_SIZE, file.size);
- const block = new Uint8Array(await file.slice(offset, blockEnd).arrayBuffer());
+ // 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。iOS 下
+ // src.slice 经 Range 向原生拉取该块,整文件不进 WebView 内存(R-iOS-4)。
+ const blockEnd = Math.min(offset + READ_BLOCK_SIZE, src.size);
+ const block = await src.slice(offset, blockEnd);
// 从内存块按 sendChunk 子片发送;流控(bufferedAmount/水位)逻辑不变。
let inBlock = 0;
@@ -573,7 +575,7 @@ class Session
// 端发,这里只在抽干后驱动发送端自身 UI 收尾)。
await waitForBufferLow(dc, 0);
this.stopSendProgressPoll();
- this.bytesSent = file.size;
+ this.bytesSent = src.size;
this.emitProgress();
this.setState("completed");
// /done is intentionally posted by the *receiver* only — they are the
@@ -847,13 +849,13 @@ function buildPublicSession(sess: Session): P2PSession
export function p2pStartOutgoing(
sessionId: string,
receiverName: string,
- file: File,
+ src: FileSource,
): P2PSession
{
const sess = new Session(sessionId, receiverName, "sender");
p2pSessions.set(receiverName, sess);
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)
// is expected, not a transport failure — let the relay flow drive state.
diff --git a/web/src/features/transfer/relay.ts b/web/src/features/transfer/relay.ts
index 1aa8f4e..0827713 100644
--- a/web/src/features/transfer/relay.ts
+++ b/web/src/features/transfer/relay.ts
@@ -1,5 +1,6 @@
import { apiFetch } from "../../net/api";
import { deliverIncoming, openIncomingSink } from "./incomingSink";
+import type { FileSource } from "./source";
// 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
@@ -30,20 +31,21 @@ const PER_CHUNK_TIMEOUT_MS = 60_000;
// X-Bytes-Received and don't re-send the duplicate.
export async function relaySendFile(
sessionId: string,
- file: File,
+ src: FileSource,
onProgress?: (p: RelayProgress) => void,
abortSignal?: AbortSignal,
): Promise
{
const url = `/api/relay/${encodeURIComponent(sessionId)}/chunk`;
let offset = 0;
- while (offset < file.size)
+ while (offset < src.size)
{
if (abortSignal?.aborted) { throw new Error("relay send aborted"); }
- const end = Math.min(offset + CHUNK, file.size);
- const slice = file.slice(offset, end);
- const isLast = end === file.size;
+ const end = Math.min(offset + CHUNK, src.size);
+ // iOS 下经 Range 向原生拉该 1 MiB 片,整文件不进 WebView 内存(R-iOS-4)。
+ const slice = await src.slice(offset, end);
+ const isLast = end === src.size;
const result = await postChunkWithRetry(url, slice, offset, isLast, abortSignal);
if (result.skip)
@@ -55,7 +57,7 @@ export async function relaySendFile(
{
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(
url: string,
- slice: Blob,
+ slice: Uint8Array,
offset: number,
isLast: boolean,
abortSignal?: AbortSignal,
diff --git a/web/src/features/transfer/source.ts b/web/src/features/transfer/source.ts
new file mode 100644
index 0000000..b8bf091
--- /dev/null
+++ b/web/src/features/transfer/source.ts
@@ -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>;
+}
+
+// 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://`;原生据 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;
+ },
+ };
+}
diff --git a/web/src/features/transfer/transfer.ts b/web/src/features/transfer/transfer.ts
index 1a3b541..87d3a34 100644
--- a/web/src/features/transfer/transfer.ts
+++ b/web/src/features/transfer/transfer.ts
@@ -1,6 +1,7 @@
import { apiFetch } from "../../net/api";
import { p2pStartIncoming, p2pStartOutgoing, p2pCleanup, type P2PSession } from "./p2p";
import { relayReceiveFile, relaySendFile } from "./relay";
+import type { FileSource } from "./source";
import { useAppStore, type TransferRecord } from "../../store";
// Orchestrator that picks P2P first, falls back to Relay on ICE timeout
@@ -12,7 +13,7 @@ interface OutgoingState
{
sessionId: string;
peerName: string;
- file: File;
+ src: FileSource;
p2p: P2PSession | null;
fallbackTimer: number | null;
abort: AbortController;
@@ -25,7 +26,7 @@ const incomingFiles = new Map
{
const initR = await apiFetch("/api/transfer/initiate", {
@@ -33,8 +34,8 @@ export async function startOutgoingTransfer(
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
receiver_name: receiverName,
- file_name: file.name,
- file_size: file.size,
+ file_name: src.name,
+ file_size: src.size,
}),
});
if (!initR.ok)
@@ -47,8 +48,8 @@ export async function startOutgoingTransfer(
useAppStore.getState().upsertTransfer({
sessionId,
direction: "outgoing",
- fileName: file.name,
- fileSize: file.size,
+ fileName: src.name,
+ fileSize: src.size,
state: "PENDING",
peerName: receiverName,
startedAt: Date.now(),
@@ -61,7 +62,7 @@ export async function startOutgoingTransfer(
const state: OutgoingState = {
sessionId,
peerName: receiverName,
- file,
+ src,
p2p: null,
fallbackTimer: null,
abort,
@@ -87,7 +88,7 @@ export async function startOutgoingTransfer(
}
}, ICE_TIMEOUT_MS);
- const p2p = p2pStartOutgoing(sessionId, receiverName, file);
+ const p2p = p2pStartOutgoing(sessionId, receiverName, src);
state.p2p = p2p;
p2p.onState((s) =>
@@ -134,11 +135,11 @@ export async function handleRelayReady(sessionId: string): Promise
p2pCleanup(out.peerName);
try
{
- await relaySendFile(sessionId, out.file, (p) =>
+ await relaySendFile(sessionId, out.src, (p) =>
{
const store = useAppStore.getState();
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);
store.upsertTransfer({
...cur,
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts
index 9bdd44b..c3e4781 100644
--- a/web/src/i18n/locales/en-US.ts
+++ b/web/src/i18n/locales/en-US.ts
@@ -409,4 +409,10 @@ export const enUS: Partial = {
"ios.files.share": "Share",
"ios.files.done": "Done",
"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",
};
diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts
index b9deb80..7c51482 100644
--- a/web/src/i18n/locales/zh-CN.ts
+++ b/web/src/i18n/locales/zh-CN.ts
@@ -405,4 +405,10 @@ export const zhCN = {
"ios.files.share": "分享",
"ios.files.done": "完成",
"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;
diff --git a/web/src/i18n/locales/zh-TW.ts b/web/src/i18n/locales/zh-TW.ts
index 9b694f6..b3ad692 100644
--- a/web/src/i18n/locales/zh-TW.ts
+++ b/web/src/i18n/locales/zh-TW.ts
@@ -409,4 +409,10 @@ export const zhTW: Partial = {
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "開啟檔案",
+ "ios.bg.title": "正在傳輸檔案",
+ "ios.bg.subtitle": "切到背景仍繼續傳輸",
+ "ios.share.title": "傳送 {{n}} 個檔案到裝置",
+ "ios.control.upload": "上傳剪貼簿",
+ "ios.control.pull": "拉取剪貼簿",
+ "ios.control.sessionLabel": "{{name}} · 控制項",
};
diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx
index 99872a6..6641bc3 100644
--- a/web/src/routes/index.tsx
+++ b/web/src/routes/index.tsx
@@ -9,6 +9,7 @@ import { ActivityFeed } from "../features/transfer/ActivityFeed";
import { ClipboardPanel } from "../features/clipboard/ClipboardPanel";
import { MessageList } from "../features/messaging/MessageList";
import { startOutgoingTransfer } from "../features/transfer/transfer";
+import { fileSource } from "../features/transfer/source";
import { sendMessage } from "../features/messaging/messaging";
import { fetchClipboard, CLIPBOARD_MAX_BYTES } from "../features/clipboard/clipboard";
import { t } from "../i18n";
@@ -60,7 +61,7 @@ function HomePage()
if (!selectedDevice || !selectedFile) { return; }
try
{
- await startOutgoingTransfer(selectedDevice, selectedFile);
+ await startOutgoingTransfer(selectedDevice, fileSource(selectedFile));
setSelectedFile(null);
}
catch (e)
@@ -79,7 +80,7 @@ function HomePage()
const handleDropFile = async (deviceName: string, file: File) =>
{
setSelectedDevice(deviceName);
- try { await startOutgoingTransfer(deviceName, file); }
+ try { await startOutgoingTransfer(deviceName, fileSource(file)); }
catch (e)
{
toast.error("文件发送失败", e instanceof Error ? e.message : String(e));