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));