iOS 计划完成:流式发送 / 后台续传 / APNs / Share Extension / 控制中心控件 + 真机分发预备

- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource
- 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI
- APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式
- Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎)
- 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换)
- 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册
- I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键
- ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
This commit is contained in:
2026-06-27 00:27:55 +08:00
parent 10cf36ecee
commit 44096069a7
59 changed files with 2585 additions and 144 deletions
+15
View File
@@ -71,6 +71,21 @@ desktop-clean:
desktop-doctor:
{{wails}} doctor
# ---- iOS 真机 ----
# 列出已连接的真机(手机插 USB 后取 UDID,填进门户 Devices + just ios-device)。
ios-devices:
xcrun devicectl list devices
# 真机构建 + 装机(手动签名)。前置:付费 ADP;门户建好 App ID/App Group/Push/设备/profile
# 按 ios/CDrop/REALDEVICE.md 建好 gitignore 的 ios/CDrop/Local.xcconfigTeam ID + 3 个 profile 名)。
# 模拟器构建不需这些(base ad-hoc)。用法:just ios-device <设备UDID>UDID 见 just ios-devices)。
ios-device udid:
test -f ios/CDrop/Local.xcconfig || { echo "缺 ios/CDrop/Local.xcconfig——见 ios/CDrop/REALDEVICE.md"; exit 1; }
cd ios/CDrop && xcodegen generate
cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -configuration Debug -destination 'generic/platform=iOS' -derivedDataPath build/DD-device build
xcrun devicectl device install app --device {{udid}} ios/CDrop/build/DD-device/Build/Products/Debug-iphoneos/CDrop.app
# ---- deploy plumbing ----
# 一次性预热:把 Go modules + node_modules 烤进 cdrop-base:latest。
+11 -2
View File
@@ -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,
}
+1 -1
View File
@@ -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
+293
View File
@@ -0,0 +1,293 @@
// Package apns delivers Apple Push Notification service (APNs) notifications to
// iOS devices whose SSE connection is closed. It mirrors the shape of the push
// (Web Push) package: an inert Sender when unconfigured, per-subscription
// localization via the shared push.Localize machinery, and automatic pruning of
// dead device tokens on HTTP 410 (Unregistered) responses.
//
// APNs uses provider-token authentication: a cached ES256 JWT signed with the
// team's .p8 key, refreshed every ~50 minutes (Apple requires 20-60 minutes).
// HTTP/2 is negotiated automatically by Go's net/http Transport over TLS.
package apns
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/push"
)
// apnsTimeout bounds a single Notify call (including all subscriptions for one
// device), so a hung APNs endpoint cannot leak goroutines.
const apnsTimeout = 15 * time.Second
// jwtRefreshAge is how long we cache a provider JWT before minting a fresh one.
// Apple requires a new token between 20 and 60 minutes; 50 minutes gives a safe margin.
const jwtRefreshAge = 50 * time.Minute
// prodBase and sandboxBase are the APNs HTTP/2 endpoints.
const (
prodBase = "https://api.push.apple.com"
sandboxBase = "https://api.sandbox.push.apple.com"
)
// apsPayload is the JSON body for an APNs alert notification.
type apsPayload struct {
APS apsEnvelope `json:"aps"`
Type string `json:"type"`
URL string `json:"url"`
}
type apsEnvelope struct {
Alert apsAlert `json:"alert"`
Sound string `json:"sound"`
}
type apsAlert struct {
Title string `json:"title"`
Body string `json:"body,omitempty"`
}
// Sender delivers APNs notifications and prunes dead device tokens. A Sender
// built without all required config (key/keyID/teamID/topic) is inert: Enabled
// reports false and Notify is a no-op. Safe for concurrent use.
type Sender struct {
queries *db.Queries
key *ecdsa.PrivateKey
keyID string
teamID string
topic string
base string // APNs base URL; settable for tests
client *http.Client
mu sync.Mutex
cachedJWT string
jwtMintedAt time.Time
}
// NewSender returns a configured APNs Sender. keyPEM is the .p8 PKCS8 EC private
// key Apple issues. env is "prod" (default) or "sandbox". When any of keyPEM,
// keyID, teamID, or topic is empty/nil the returned Sender is inert (Enabled
// returns false, Notify is a no-op) so callers can invoke it unconditionally.
// A PEM parse failure is logged and also yields an inert Sender rather than a
// hard error, so startup can proceed with APNs disabled.
func NewSender(queries *db.Queries, keyPEM []byte, keyID, teamID, topic, env string) *Sender {
if len(keyPEM) == 0 || keyID == "" || teamID == "" || topic == "" {
return &Sender{}
}
block, _ := pem.Decode(keyPEM)
if block == nil {
slog.Error("apns: cannot decode PEM block from .p8 key; APNs disabled")
return &Sender{}
}
raw, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
slog.Error("apns: cannot parse PKCS8 private key; APNs disabled", "err", err)
return &Sender{}
}
ecKey, ok := raw.(*ecdsa.PrivateKey)
if !ok {
slog.Error("apns: .p8 key is not an ECDSA private key; APNs disabled")
return &Sender{}
}
base := prodBase
switch strings.ToLower(strings.TrimSpace(env)) {
case "sandbox":
base = sandboxBase
case "", "prod", "production":
base = prodBase
default:
// Unknown env is an operator typo; default to prod but log loudly so the
// sandbox/prod mismatch footgun (dev tokens → prod endpoint → BadDeviceToken)
// surfaces instead of silently selecting the wrong endpoint.
slog.Warn("apns: unknown CDROP_APNS_ENV, defaulting to production", "env", env)
}
return &Sender{
queries: queries,
key: ecKey,
keyID: keyID,
teamID: teamID,
topic: topic,
base: base,
client: &http.Client{Timeout: apnsTimeout},
}
}
// Enabled reports whether APNs is configured.
func (s *Sender) Enabled() bool { return s != nil && s.key != nil }
// Notify delivers n to every APNs subscription of (userID, deviceName).
// Best-effort and safe to call in a goroutine: failures are logged, a token the
// APNs service marks Unregistered (410) is pruned, and a no-op results when
// disabled or the device has no APNs subscriptions.
func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n push.Notification) {
if !s.Enabled() {
return
}
ctx, cancel := context.WithTimeout(ctx, apnsTimeout)
defer cancel()
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(ctx, db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: userID,
DeviceName: deviceName,
})
if err != nil {
slog.Error("apns: list subscriptions failed",
"user", userID, "device", deviceName, "err", err)
return
}
if len(subs) == 0 {
return
}
jwtStr, err := s.providerJWT()
if err != nil {
slog.Error("apns: jwt signing failed", "err", err)
return
}
for _, sub := range subs {
title, body := push.Localize(n.Type, n.Params, sub.Locale)
url := n.URL
if url == "" {
url = "/"
}
payload := apsPayload{
APS: apsEnvelope{
Alert: apsAlert{Title: title, Body: body},
Sound: "default",
},
Type: n.Type,
URL: url,
}
s.send(ctx, sub.ID, sub.Endpoint, jwtStr, payload)
}
}
func (s *Sender) send(
ctx context.Context,
subID, deviceToken, jwtStr string,
payload apsPayload,
) {
body, err := json.Marshal(payload)
if err != nil {
slog.Error("apns: marshal payload failed", "err", err)
return
}
reqURL := fmt.Sprintf("%s/3/device/%s", s.base, deviceToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
if err != nil {
slog.Error("apns: build request failed", "err", err)
return
}
req.Header.Set("Authorization", "bearer "+jwtStr)
req.Header.Set("apns-topic", s.topic)
req.Header.Set("apns-push-type", "alert")
req.Header.Set("apns-priority", "10")
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
slog.Warn("apns: send failed", "token_prefix", tokenPrefix(deviceToken), "err", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
_ = s.queries.TouchPushSubscription(ctx, db.TouchPushSubscriptionParams{
LastUsedAt: time.Now().Unix(),
ID: subID,
})
return
}
// Non-2xx: APNs returns a small JSON body whose `reason` names the exact failure
// (BadDeviceToken, InvalidProviderToken, BadTopic, ExpiredProviderToken, …). It is
// the single most useful diagnostic, so always surface it rather than logging a
// bare numeric status.
reason := apnsReason(resp.Body)
switch {
case resp.StatusCode == http.StatusGone, reason == "BadDeviceToken", reason == "DeviceTokenNotForTopic":
// 410 Unregistered, or a permanently-invalid token (the app was uninstalled,
// the token rotated, or it's malformed / for the wrong topic). Prune the row
// so we stop re-firing a guaranteed-fail request on every future Notify.
if err := s.queries.DeletePushSubscription(ctx, subID); err != nil {
slog.Error("apns: prune dead token failed", "id", subID, "reason", reason, "err", err)
}
case reason == "ExpiredProviderToken":
// Clock skew pushed the cached provider JWT past Apple's 60-min limit before
// our 50-min refresh. Bust the cache so the next call re-mints immediately;
// otherwise every push fails for the rest of the refresh window.
s.mu.Lock()
s.cachedJWT = ""
s.mu.Unlock()
slog.Warn("apns: provider token expired, busting cache", "token_prefix", tokenPrefix(deviceToken))
default:
slog.Warn("apns: rejected",
"status", resp.StatusCode, "reason", reason, "token_prefix", tokenPrefix(deviceToken))
}
}
// apnsReason extracts the `reason` field from an APNs error response body (small
// JSON like {"reason":"BadDeviceToken"}). Returns "" if absent or unparseable.
func apnsReason(body io.Reader) string {
var e struct {
Reason string `json:"reason"`
}
_ = json.NewDecoder(io.LimitReader(body, 1024)).Decode(&e)
return e.Reason
}
// providerJWT returns a cached ES256 provider JWT, re-minting when the cached
// token is older than jwtRefreshAge. Safe for concurrent callers (mutex-protected).
func (s *Sender) providerJWT() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.cachedJWT != "" && time.Since(s.jwtMintedAt) < jwtRefreshAge {
return s.cachedJWT, nil
}
now := time.Now()
tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": s.teamID,
"iat": now.Unix(),
})
tok.Header["kid"] = s.keyID
signed, err := tok.SignedString(s.key)
if err != nil {
return "", fmt.Errorf("sign apns jwt: %w", err)
}
s.cachedJWT = signed
s.jwtMintedAt = now
return signed, nil
}
// tokenPrefix returns the first 8 hex chars of a device token for log
// messages — enough to correlate without exposing the full opaque token.
func tokenPrefix(token string) string {
if len(token) <= 8 {
return token
}
return token[:8] + "..."
}
+396
View File
@@ -0,0 +1,396 @@
package apns
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/push"
)
// generateTestKey creates an ephemeral P-256 key and returns it as PEM-encoded
// PKCS8 bytes alongside the typed private key (for verification).
func generateTestKey(t *testing.T) (pemBytes []byte, priv *ecdsa.PrivateKey) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
pemBytes = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
return pemBytes, priv
}
// openTestDB creates a temporary SQLite database and runs Bootstrap. Returns a
// *db.Queries backed by that DB.
func openTestDB(t *testing.T) *db.Queries {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "apns_test.db"))
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
return db.New(conn)
}
// TestNewSenderInertWhenUnconfigured verifies that a Sender with any missing
// config field is inert: Enabled reports false and Notify is a safe no-op.
func TestNewSenderInertWhenUnconfigured(t *testing.T) {
keyPEM, _ := generateTestKey(t)
cases := []struct {
name string
keyPEM []byte
keyID, teamID, topic string
}{
{"no key", nil, "KID", "TID", "topic"},
{"no keyID", keyPEM, "", "TID", "topic"},
{"no teamID", keyPEM, "KID", "", "topic"},
{"no topic", keyPEM, "KID", "TID", ""},
{"all empty", nil, "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := NewSender(nil, tc.keyPEM, tc.keyID, tc.teamID, tc.topic, "sandbox")
if s.Enabled() {
t.Fatal("inert Sender should report Enabled=false")
}
// Notify must be a safe no-op with nil queries.
s.Notify(context.Background(), "u", "d", push.Notification{Type: push.KindMessage})
})
}
}
// TestNilSenderIsSafe verifies that a nil *Sender is safe to call.
func TestNilSenderIsSafe(t *testing.T) {
var s *Sender
if s.Enabled() {
t.Fatal("nil sender should report disabled")
}
s.Notify(context.Background(), "u", "d", push.Notification{}) // must not panic
}
// TestProviderJWT verifies the JWT structure and ES256 signature.
func TestProviderJWT(t *testing.T) {
const keyID = "TESTKEYID1"
const teamID = "TEAMID1234"
keyPEM, priv := generateTestKey(t)
s := NewSender(nil, keyPEM, keyID, teamID, "com.example.app", "sandbox")
if !s.Enabled() {
t.Fatal("sender should be enabled with a valid key")
}
jwtStr, err := s.providerJWT()
if err != nil {
t.Fatalf("providerJWT: %v", err)
}
if jwtStr == "" {
t.Fatal("providerJWT returned empty string")
}
// Parse without verification first to inspect headers.
tok, err := jwt.NewParser().ParseWithClaims(jwtStr, jwt.MapClaims{}, func(t *jwt.Token) (any, error) {
return &priv.PublicKey, nil
})
if err != nil {
t.Fatalf("parse jwt: %v", err)
}
if !tok.Valid {
t.Fatal("jwt should be valid")
}
// Header checks.
alg, _ := tok.Header["alg"].(string)
if alg != "ES256" {
t.Errorf("header alg = %q, want ES256", alg)
}
kid, _ := tok.Header["kid"].(string)
if kid != keyID {
t.Errorf("header kid = %q, want %q", kid, keyID)
}
// Claims checks.
claims, ok := tok.Claims.(jwt.MapClaims)
if !ok {
t.Fatal("claims not MapClaims")
}
iss, _ := claims["iss"].(string)
if iss != teamID {
t.Errorf("claim iss = %q, want %q", iss, teamID)
}
iat, ok := claims["iat"]
if !ok {
t.Error("claim iat missing")
}
// iat should be a recent unix timestamp (within the last minute).
iatF, _ := iat.(float64)
iatTime := time.Unix(int64(iatF), 0)
if time.Since(iatTime) > time.Minute {
t.Errorf("claim iat too old: %v", iatTime)
}
}
// TestJWTIsCached verifies that repeated calls within jwtRefreshAge return the
// same token string, and that forcing the cache stale mints a new one.
func TestJWTIsCached(t *testing.T) {
keyPEM, _ := generateTestKey(t)
s := NewSender(nil, keyPEM, "KID", "TID", "topic", "sandbox")
first, err := s.providerJWT()
if err != nil {
t.Fatalf("first jwt: %v", err)
}
second, err := s.providerJWT()
if err != nil {
t.Fatalf("second jwt: %v", err)
}
if first != second {
t.Error("providerJWT should return the cached token within refresh window")
}
// Expire the cache.
s.mu.Lock()
s.jwtMintedAt = time.Now().Add(-(jwtRefreshAge + time.Second))
s.mu.Unlock()
third, err := s.providerJWT()
if err != nil {
t.Fatalf("third jwt: %v", err)
}
if third == first {
t.Error("providerJWT should mint a new token after refresh window expires")
}
}
// apnsServerRecorder is a minimal httptest server that records the last request
// and responds with a configurable status.
type apnsServerRecorder struct {
srv *httptest.Server
lastPath string
lastAuth string
lastTopic string
lastPushType string
lastBody []byte
respondWith int
}
func newAPNsTestServer(t *testing.T, respondWith int) *apnsServerRecorder {
t.Helper()
rec := &apnsServerRecorder{respondWith: respondWith}
rec.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.lastPath = r.URL.Path
rec.lastAuth = r.Header.Get("Authorization")
rec.lastTopic = r.Header.Get("apns-topic")
rec.lastPushType = r.Header.Get("apns-push-type")
rec.lastBody, _ = io.ReadAll(r.Body)
w.WriteHeader(rec.respondWith)
}))
t.Cleanup(rec.srv.Close)
return rec
}
// TestSendRequest asserts that Notify fires a POST with the correct path and
// headers against the APNs base URL, and that the JSON body has the expected shape.
func TestSendRequest(t *testing.T) {
const deviceToken = "abc123deadbeef"
const topic = "com.example.cdrop"
q := openTestDB(t)
keyPEM, _ := generateTestKey(t)
rec := newAPNsTestServer(t, http.StatusOK)
s := NewSender(q, keyPEM, "KID", "TID", topic, "sandbox")
if !s.Enabled() {
t.Fatal("sender must be enabled")
}
// Point the sender at the test server instead of the real APNs endpoint.
s.base = rec.srv.URL
// Insert an APNs subscription row for user "u", device "d".
now := time.Now().Unix()
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(deviceToken),
UserID: "u",
DeviceName: "d",
Endpoint: deviceToken,
Locale: "en-US",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("seed subscription: %v", err)
}
s.Notify(context.Background(), "u", "d", push.Notification{
Type: push.KindTransferIncoming,
Params: map[string]string{"sender": "Mac", "filename": "a.pdf"},
Tag: "transfer:1",
URL: "/transfers",
})
// Path must be /3/device/{token}.
wantPath := "/3/device/" + deviceToken
if rec.lastPath != wantPath {
t.Errorf("path = %q, want %q", rec.lastPath, wantPath)
}
// Authorization header must start with "bearer ".
if !strings.HasPrefix(rec.lastAuth, "bearer ") {
t.Errorf("Authorization = %q, want bearer ...", rec.lastAuth)
}
// APNs headers.
if rec.lastTopic != topic {
t.Errorf("apns-topic = %q, want %q", rec.lastTopic, topic)
}
if rec.lastPushType != "alert" {
t.Errorf("apns-push-type = %q, want alert", rec.lastPushType)
}
// JSON body shape.
var body apsPayload
if err := json.Unmarshal(rec.lastBody, &body); err != nil {
t.Fatalf("parse body: %v (raw: %s)", err, rec.lastBody)
}
if body.Type != push.KindTransferIncoming {
t.Errorf("body.type = %q, want %q", body.Type, push.KindTransferIncoming)
}
if body.APS.Alert.Title != "Incoming file" {
t.Errorf("body.aps.alert.title = %q, want Incoming file", body.APS.Alert.Title)
}
if !strings.Contains(body.APS.Alert.Body, "a.pdf") {
t.Errorf("body.aps.alert.body = %q, want mention of a.pdf", body.APS.Alert.Body)
}
if body.URL != "/transfers" {
t.Errorf("body.url = %q, want /transfers", body.URL)
}
if body.APS.Sound != "default" {
t.Errorf("body.aps.sound = %q, want default", body.APS.Sound)
}
}
// TestSend410PrunesRow verifies that an HTTP 410 response from the APNs
// endpoint causes the subscription row to be deleted from the database.
func TestSend410PrunesRow(t *testing.T) {
const deviceToken = "deadbeef410token"
q := openTestDB(t)
keyPEM, _ := generateTestKey(t)
rec := newAPNsTestServer(t, http.StatusGone) // 410 = Unregistered
s := NewSender(q, keyPEM, "KID", "TID", "com.example.app", "sandbox")
s.base = rec.srv.URL
now := time.Now().Unix()
subID := push.SubscriptionID(deviceToken)
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: subID,
UserID: "u",
DeviceName: "d",
Endpoint: deviceToken,
Locale: "zh-CN",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("seed subscription: %v", err)
}
s.Notify(context.Background(), "u", "d", push.Notification{
Type: push.KindMessage,
Params: map[string]string{
"sender": "Phone",
"text": "hello",
},
})
// The row must be gone.
subs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "d",
})
if err != nil {
t.Fatalf("list subs: %v", err)
}
if len(subs) != 0 {
t.Errorf("subscription row should be pruned on 410, got %d rows", len(subs))
}
}
// TestWebPushRowsNotReturnedByAPNsQuery asserts that WebPush-platform rows are
// invisible to ListAPNsSubscriptionsByUserDevice and vice versa.
func TestWebPushRowsNotReturnedByAPNsQuery(t *testing.T) {
q := openTestDB(t)
now := time.Now().Unix()
// Insert a webpush row.
const webEndpoint = "https://fcm.googleapis.com/fake"
if err := q.UpsertPushSubscription(context.Background(), db.UpsertPushSubscriptionParams{
ID: push.SubscriptionID(webEndpoint),
UserID: "u",
DeviceName: "d",
Endpoint: webEndpoint,
P256dh: "p",
Auth: "a",
UserAgent: "",
Locale: "en-US",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("upsert webpush: %v", err)
}
// APNs query must not see the webpush row.
apnsSubs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u", DeviceName: "d",
})
if err != nil {
t.Fatalf("list apns: %v", err)
}
if len(apnsSubs) != 0 {
t.Errorf("APNs query returned %d row(s), want 0 (webpush rows must be filtered)", len(apnsSubs))
}
// WebPush query must not see apns rows.
const apnsToken = "aabbccdd1234"
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(apnsToken),
UserID: "u",
DeviceName: "d",
Endpoint: apnsToken,
Locale: "zh-CN",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("upsert apns: %v", err)
}
webSubs, err := q.ListPushSubscriptionsByUserDevice(context.Background(), db.ListPushSubscriptionsByUserDeviceParams{
UserID: "u", DeviceName: "d",
})
if err != nil {
t.Fatalf("list webpush: %v", err)
}
if len(webSubs) != 1 {
t.Errorf("webpush query returned %d row(s), want 1 (apns rows must be filtered)", len(webSubs))
}
}
+57
View File
@@ -13,6 +13,11 @@ import (
"github.com/knadh/koanf/v2"
)
// 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 != ""
+6
View File
@@ -59,6 +59,12 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
"ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil {
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
@@ -0,0 +1,7 @@
-- Additive migration: add platform column to push_subscriptions to distinguish
-- Web Push (VAPID, browser/PWA) from APNs (iOS device token) rows.
-- This file is the sqlc-facing schema declaration; it is NOT embedded or run
-- directly. At runtime the column is added by ensureColumn(...,"platform",...) in
-- bootstrap.go (idempotent ALTER). Note: 0001_init.sql's CREATE TABLE does NOT
-- include this column — bootstrap's ensureColumn is the sole source on fresh DBs.
ALTER TABLE push_subscriptions ADD COLUMN platform TEXT NOT NULL DEFAULT 'webpush';
+1
View File
@@ -53,6 +53,7 @@ type PushSubscription struct {
Locale string `json:"locale"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
Platform string `json:"platform"`
}
type TransferSession struct {
+125 -15
View File
@@ -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 {
+25 -9
View File
@@ -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
+12 -5
View File
@@ -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{
// 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
}
+60
View File
@@ -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"`
}
+212
View File
@@ -0,0 +1,212 @@
package httpapi
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/push"
)
// apnsTestKeyPEM generates an ephemeral P-256 key and returns its PEM bytes so
// tests can build an enabled apns.Sender without real Apple credentials.
func apnsTestKeyPEM(t *testing.T) []byte {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
}
// newAPNsTestServer builds a minimal Server with a real in-memory SQLite DB
// and an enabled apns.Sender (using an ephemeral test key). The apns.Sender
// base is left at the default; the endpoint test only exercises DB writes, so
// the Sender never fires a real APNs request.
func newAPNsTestServer(t *testing.T) *Server {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "push_test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
q := db.New(conn)
apnsSender := apns.NewSender(q, apnsTestKeyPEM(t), "TESTKID", "TESTTEAM", "com.example.cdrop", "sandbox")
if !apnsSender.Enabled() {
t.Fatal("apns sender must be enabled for this test")
}
return &Server{
queries: q,
hub: hub.New(q),
apns: apnsSender,
}
}
// TestHandleAPNsRegister_DisabledReturns503 verifies that the register endpoint
// returns 503 when APNs is not configured (inert Sender).
func TestHandleAPNsRegister_DisabledReturns503(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "push_test2.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
q := db.New(conn)
s := &Server{
queries: q,
hub: hub.New(q),
apns: apns.NewSender(nil, nil, "", "", "", ""), // inert
}
body := `{"device_token":"aabbccdd1234"}`
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("disabled: got %d, want 503", w.Code)
}
}
// TestHandleAPNsRegister_StoresRowIdempotently verifies that POST /api/push/apns/register
// stores the subscription row and that a second call with the same token upserts
// in place (idempotent: still exactly one row).
func TestHandleAPNsRegister_StoresRowIdempotently(t *testing.T) {
s := newAPNsTestServer(t)
const deviceToken = "aabbccdd1234deadbeef"
const locale = "en-US"
callRegister := func() int {
body := `{"device_token":"` + deviceToken + `","locale":"` + locale + `"}`
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
return w.Code
}
// First registration.
if code := callRegister(); code != http.StatusNoContent {
t.Fatalf("first register: got %d, want 204", code)
}
// Verify the row exists.
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "iPhone",
})
if err != nil {
t.Fatalf("list subs: %v", err)
}
if len(subs) != 1 {
t.Fatalf("after first register: got %d rows, want 1", len(subs))
}
sub := subs[0]
if sub.Endpoint != deviceToken {
t.Errorf("endpoint = %q, want %q", sub.Endpoint, deviceToken)
}
if sub.Locale != locale {
t.Errorf("locale = %q, want %q", sub.Locale, locale)
}
if sub.Platform != "apns" {
t.Errorf("platform = %q, want apns", sub.Platform)
}
if sub.UserID != "u" {
t.Errorf("user_id = %q, want u", sub.UserID)
}
expectedID := push.SubscriptionID(deviceToken)
if sub.ID != expectedID {
t.Errorf("id = %q, want %q (SHA-256 of token)", sub.ID, expectedID)
}
// Second registration with same token: must upsert in place.
if code := callRegister(); code != http.StatusNoContent {
t.Fatalf("second register: got %d, want 204", code)
}
subs2, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "iPhone",
})
if err != nil {
t.Fatalf("list subs after second register: %v", err)
}
if len(subs2) != 1 {
t.Errorf("after second register: got %d rows, want 1 (should upsert not duplicate)", len(subs2))
}
}
// TestHandleAPNsRegister_MissingToken verifies that a missing device_token
// returns 400.
func TestHandleAPNsRegister_MissingToken(t *testing.T) {
s := newAPNsTestServer(t)
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(`{}`))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("missing token: got %d, want 400", w.Code)
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err == nil {
if resp["error"] != "missing device_token" {
t.Errorf("error msg = %q", resp["error"])
}
}
}
// TestHandleAPNsRegister_UnknownLocaleFallsBack verifies that an unrecognized
// locale falls back to the server default (zh-CN).
func TestHandleAPNsRegister_UnknownLocaleFallsBack(t *testing.T) {
s := newAPNsTestServer(t)
body := `{"device_token":"tok123456","locale":"fr-FR"}`
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
if w.Code != http.StatusNoContent {
t.Fatalf("register: got %d, want 204", w.Code)
}
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "iPhone",
})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(subs) != 1 {
t.Fatalf("want 1 row, got %d", len(subs))
}
if subs[0].Locale != "zh-CN" {
t.Errorf("locale = %q, want zh-CN (fallback)", subs[0].Locale)
}
}
+5
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/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
+7
View File
@@ -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 {
+13 -6
View File
@@ -137,7 +137,7 @@ func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n Notifi
}
}
func (s *Sender) send(ctx context.Context, sub db.PushSubscription, payload []byte) {
func (s *Sender) send(ctx context.Context, sub db.ListPushSubscriptionsByUserDeviceRow, payload []byte) {
resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
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:
+1 -1
View File
@@ -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)
}
+28 -5
View File
@@ -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,11 +243,21 @@ func (s *Service) Transition(
Tag: "transfer:" + sessionID,
}
if !deliveredSender {
if s.push.Enabled() {
go s.push.Notify(context.Background(), userID, sess.SenderName, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), userID, sess.SenderName, n)
}
}
if !deliveredReceiver {
if 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)
}
}
}
if target == StateRelayActive {
+2 -2
View File
@@ -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) {
+3
View File
@@ -6,3 +6,6 @@ build/
DerivedData/
*.xcuserstate
.DS_Store
# 真机签名的账号特定值(Team ID + profile 名)——公开仓库不留账号标识,见 REALDEVICE.md。
Local.xcconfig
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- APNs 推送:注册远程通知需此 entitlement。模拟器(iOS 16+ / Apple Silicon)下 development
即可取令牌、走 sandbox;真机须付费 ADP 开 Push 能力配 profile(账号门控)。 -->
<key>aps-environment</key>
<string>development</string>
<!-- App Group:与 Share Extension 共享待发文件收件箱(见 AppGroup.swift / PLAN §I5)。
模拟器无需签名即生效;真机须付费 ADP 注册该 group(账号门控)。 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.net.commilitia.cdrop</string>
</array>
</dict>
</plist>
+79 -31
View File
@@ -1,40 +1,88 @@
# cdrop iOS 真机自测手册
# cdrop iOS 真机分发手册
账号到位即可照做。模拟器构建不需签名,已全程 compile + 跑通;**真机**要付费 ADP + 签名 + 实体 iPhone,下面是 turnkey 步骤 + 只能真机验的清单
把 cdrop 装到你自己的 iPhone(开发签名,1 年 profile)。代码侧全部就绪并经模拟器验证;真机只差**账号 + 门户 + 签名**这一层。本手册走**手动签名 + 全 CLI**路线——门户你手动建(一次),构建装机一条命令,**全程不碰 Xcode GUI**
## 包名与标识(门户里要用的)
| 项 | 值 |
|---|---|
| 主 app Bundle ID | `net.commilitia.Commilitia-Drop` |
| Share Extension | `net.commilitia.Commilitia-Drop.share` |
| 控件扩展 | `net.commilitia.Commilitia-Drop.widgets` |
| App Group | `group.net.commilitia.cdrop`(与包名相互独立,刻意不同名) |
| APNs topic(服务端 `CDROP_APNS_TOPIC` | `net.commilitia.Commilitia-Drop` |
## 前置
- 付费 Apple Developer Program$99/年)。
- 一台 iPhoneiOS 26MacXcode 26。
- 已登录的 cdrop(网页 / 桌面)一个——用来扫码批准本机登录。
- 一台 iPhoneiOS 26+ MacXcode 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 → AppBundle 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** 不是 WildcardApp 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 SDKiphoneos** 套用手动签名 + 这些值;**模拟器**仍走 `project.yml` 的 ad-hoc`CODE_SIGN_IDENTITY = "-"`),故缺 `Local.xcconfig` 也不影响模拟器构建。
---
## C. 构建装机(全 CLI
```sh
just ios-devices # 手机插 USB,读 UDID(填进门户 A4 + 下行)
just ios-device <你的设备UDID> # 真机构建(手动签名)+ devicectl 装机
```
- `just ios-device` `xcodegen generate``xcodebuild`device,手动签名走 `Local.xcconfig`)→ `xcrun devicectl device install app`。无 Xcode GUI。
- 首次装机后,iPhone 上首启该开发者 app 即可直接跑(付费 ADP 开发证书,无需手动「信任开发者」)。
---
## D. 服务端前置
1. **引擎可达**:真机上引擎 WebView 加载 `https://drop.commilitia.net/engine.html`——**该文件随 cdrop 二进制部署到 prod**`//go:embed` 进 binary`just docker-image` 含最新 `dist`)。手机要用,prod 须是含本轮改动的最新部署。
2. **APNs 真发**:把 A6 的 `.p8` 放到服务器,给后端容器配 `CDROP_APNS_KEY_PATH` / `CDROP_APNS_KEY_ID` / `CDROP_APNS_TEAM_ID` / `CDROP_APNS_TOPIC=net.commilitia.Commilitia-Drop` / `CDROP_APNS_ENV`Xcode 开发构建的 device token 属 **sandbox**,故联调填 `sandbox`)。缺配置则推送惰性关闭,其余功能照常。
3. 本机联调可选:环境变量 `CDROP_ENGINE_URL` 指向可达引擎。
---
## E. 只能真机验的清单
代码已实现,下列是真机才能验的点:
- [ ] 液态玻璃真机渲染(陀螺仪高光 / 动效)。
- [ ] 扫码登录:本机显码 → 已登录 cdrop 扫码批准 → 进主界面(强制 full/persist)。
- [ ] **本地网络权限 + 同内网直连**R-iOS-6):首次同内网传输弹 `NSLocalNetworkUsageDescription`;授予后 ICE 收 host 候选、走真直连;拒绝则回退中继仍可传。
- [ ] 发送 / 接收:选文件 → 选设备 → 对端收到;对端发来落 Files(Documents)。
- [ ] **大文件流式发送**(R-iOS-4 已实现):发大文件,WebView 内存应有界(按 Range 块拉取,不整文件入内存)。
- [ ] **后台续传**R-iOS-1 / R-iOS-3):传输中切后台 → `BGContinuedProcessingTask` 系统进度 UI;验 WKWebView JS 是否随之保活(不保活则退化为回前台续传,可接受)。
- [ ] **APNs 推送**:离线设备收「收到文件」通知(需 D2 的 `.p8`)。
- [ ] **Share Extension**:别的 app 分享 → 选 Commilitia Drop → 唤起主 app 选设备发送。
- [ ] **控制中心剪贴板两控件**:控制中心加「上传 / 拉取剪贴板」控件 → 锁屏 / 解锁态点按 → 云剪贴板读写。
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 与主 app 共享的 App GroupShare Extension 把待发文件搬进收件箱(见 AppGroup.swift)。 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.net.commilitia.cdrop</string>
</array>
</dict>
</plist>
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Commilitia Drop</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>20</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>20</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>20</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
+77
View File
@@ -0,0 +1,77 @@
import UIKit
import UniformTypeIdentifiers
// Share Extension share sheet App Group app
// 120MB jetsam PLAN §I5 compose UI
//
final class ShareViewController: UIViewController
{
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
Task { await processAndClose() }
}
private func processAndClose() async
{
let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
var staged = 0
for item in items
{
for provider in item.attachments ?? []
{
if await stage(provider) { staged += 1 }
}
}
let ctx = extensionContext
guard staged > 0, let url = URL(string: "cdrop://share") else
{
ctx?.completeRequest(returningItems: nil)
return
}
// app open completeRequest completeRequest
// self open openViaResponder L8 ctx 使
//
ctx?.open(url)
{ [weak self] ok in
if !ok { self?.openViaResponder(url) }
ctx?.completeRequest(returningItems: nil)
}
}
// App Group loadFileRepresentation
// / /
//
private func stage(_ provider: NSItemProvider) async -> Bool
{
return await withCheckedContinuation
{ cont in
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier)
{ url, _ in
guard let url, let dest = AppGroup.stagedDestination(originalName: url.lastPathComponent)
else { cont.resume(returning: false); return }
do
{
try FileManager.default.copyItem(at: url, to: dest)
cont.resume(returning: true)
}
catch
{
cont.resume(returning: false)
}
}
}
}
// open 沿 UIApplication app open
private func openViaResponder(_ url: URL)
{
var responder: UIResponder? = self
let sel = NSSelectorFromString("openURL:")
while let r = responder
{
if r.responds(to: sel) { _ = r.perform(sel, with: url); return }
responder = r.next
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
// App Group app Share Extension
// 120MB jetsam PLAN §I5 / D target xcodegen
// target
//
// <uuid>
// /
enum AppGroup
{
static let identifier = "group.net.commilitia.cdrop"
// App Group nil
static func inboxURL() -> URL?
{
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
else { return nil }
let inbox = base.appendingPathComponent("SharedInbox", isDirectory: true)
try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true)
return inbox
}
// URL<inbox>/<uuid>/<originalName>
static func stagedDestination(originalName: String) -> URL?
{
guard let inbox = inboxURL() else { return nil }
let dir = inbox.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let safeName = originalName.isEmpty ? "file" : originalName
return dir.appendingPathComponent(safeName)
}
// app remove
static func inboxFiles() -> [URL]
{
guard let inbox = inboxURL() else { return [] }
let dirs = (try? FileManager.default.contentsOfDirectory(at: inbox, includingPropertiesForKeys: nil)) ?? []
return dirs.compactMap
{ dir in
guard dir.hasDirectoryPath else { return nil }
let inner = (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
return inner.first { !$0.hasDirectoryPath }
}
}
// / <uuid>
static func removeStaged(_ fileURL: URL)
{
try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
}
//
// 6 app
static func pruneStale(olderThan seconds: TimeInterval = 6 * 60 * 60)
{
guard let inbox = inboxURL() else { return }
let dirs = (try? FileManager.default.contentsOfDirectory(
at: inbox, includingPropertiesForKeys: [ .creationDateKey ])) ?? []
let cutoff = Date().addingTimeInterval(-seconds)
for dir in dirs
{
let created = (try? dir.resourceValues(forKeys: [ .creationDateKey ]).creationDate) ?? .distantPast
if created < cutoff { try? FileManager.default.removeItem(at: dir) }
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import Foundation
// app +
enum CDropAPI
{
// / EngineController.engineURL URL
static let base = "https://drop.commilitia.net"
private static let widgetDeviceIDKey = "cdrop.widget.device_id"
// device_iddev_ + [A-Za-z0-9_-] validDeviceID
// App Group UserDefaults app
static func widgetDeviceID() -> String
{
let defaults = UserDefaults(suiteName: AppGroup.identifier) ?? .standard
if let existing = defaults.string(forKey: widgetDeviceIDKey) { return existing }
let id = "dev_widget" + UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16)
defaults.set(id, forKey: widgetDeviceIDKey)
return id
}
// id device_id 使
// UI L2
static func clearWidgetDeviceID()
{
UserDefaults(suiteName: AppGroup.identifier)?.removeObject(forKey: widgetDeviceIDKey)
}
}
@@ -1,8 +1,9 @@
import Foundation
// i18n app bundle <locale>.json web web/scripts/emit-i18n.mjs
// ios/PLAN.md §5退 zh-CN JSON 退
// zh-CN loader locale
// i18n bundle <locale>.json web web/scripts/emit-i18n.mjs
// ios/PLAN.md §5退 zh-CN JSON 退
// zh-CN loader localeShared app / Share Extension /
// target bundle i18n JSONresourceURL 退
enum I18n
{
private static let dict: [String: String] = load()
@@ -39,6 +40,10 @@ enum I18n
?? Bundle.main.url(forResource: loc, withExtension: "json")
}
// localezh-CN / zh-TW / en-US
// APNs 使 UI
static var locale: String { preferredLocale() }
private static func preferredLocale() -> String
{
for lang in Locale.preferredLanguages
+41
View File
@@ -0,0 +1,41 @@
import Foundation
// app /api/auth/device-session
// refresh App Group /api/clipboard
// 401 refresh app
struct WidgetSession: Codable
{
var accessToken: String
var refreshToken: String
let userId: String
}
enum WidgetSessionStore
{
private static func fileURL() -> URL?
{
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.identifier)
else { return nil }
return base.appendingPathComponent("widget_session.json")
}
static func load() -> WidgetSession?
{
guard let url = fileURL(), let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(WidgetSession.self, from: data)
}
static func save(_ session: WidgetSession)
{
guard let url = fileURL(), let data = try? JSONEncoder().encode(session) else { return }
// .completeFileProtection
// UntilFirstUserAuthentication
try? data.write(to: url, options: [ .atomic, .completeFileProtectionUntilFirstUserAuthentication ])
}
static func clear()
{
guard let url = fileURL() else { return }
try? FileManager.default.removeItem(at: url)
}
}
+11
View File
@@ -0,0 +1,11 @@
// 真机签名配置。仅对 device SDK(iphoneos)生效——模拟器仍走 project.yml base 的 ad-hoc
// 签名(CODE_SIGN_IDENTITY = "-"),无需 Apple 账号即可构建 / 跑模拟器。
//
// 账号特定值(Team ID、各 target 的 profile 名)放在同目录 **gitignore** 的 Local.xcconfig
// 里(公开仓库不留账号标识)。Local.xcconfig 不存在时下面的可选 include 跳过,device 构建会
// 因缺 Team/profile 失败——这正是预期(真机构建须先按 REALDEVICE.md 建好 Local.xcconfig)。
#include? "Local.xcconfig"
CODE_SIGN_STYLE[sdk=iphoneos*] = Manual
CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Development
DEVELOPMENT_TEAM[sdk=iphoneos*] = $(CDROP_TEAM_ID)
+35
View File
@@ -0,0 +1,35 @@
import UIKit
// launch UIKit
// APNs SwiftUI App
// @UIApplicationDelegateAdaptor
final class AppDelegate: NSObject, UIApplicationDelegate
{
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool
{
// BGContinuedProcessingTask launch handler launch
BackgroundTaskManager.shared.registerOnce()
return true
}
// APNs PushRegistry
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
)
{
PushRegistry.shared.didRegister(tokenData: deviceToken)
}
// APNs APNs / / 退线 SSE
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
)
{
// Push
}
}
@@ -0,0 +1,106 @@
import BackgroundTasks
import UIKit
// iOS 26 BGContinuedProcessingTask
// UI AirDrop0>0
// progress 0
//
// R-iOS-3BGContinuedProcessingTask + UI
// WKWebView WebContent JS / WebRTC
// 退R-iOS-1
//
// 线register .main sync 线 actor
final class BackgroundTaskManager
{
static let shared = BackgroundTaskManager()
private init() {}
// Info.plist BGTaskSchedulerPermittedIdentifiers "<prefix>.*"
// false退
private static let prefix = "net.commilitia.Commilitia-Drop.transfer"
private static let wildcard = prefix + ".*"
private var triedRegister = false
private var registerOK = false
private var task: BGContinuedProcessingTask?
private var pendingId: String? // attach 0
private var latestFraction: Double = 0
// launch AppDelegate.didFinishLaunching
func registerOnce()
{
guard !triedRegister else { return }
triedRegister = true
registerOK = BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.wildcard, using: .main)
{ [weak self] task in
guard let cpt = task as? BGContinuedProcessingTask
else { task.setTaskCompleted(success: false); return }
self?.attach(cpt)
}
}
// activeCount fraction [0, 1]
func sync(activeCount: Int, fraction: Double)
{
latestFraction = fraction
if activeCount > 0
{
if let task { task.progress.completedUnitCount = Int64(fraction * 100) }
else { submitIfForeground() }
}
else
{
finish(success: true)
}
}
private func submitIfForeground()
{
// / / attach
guard registerOK, task == nil, pendingId == nil else { return }
//
guard UIApplication.shared.applicationState != .background else { return }
let id = "\(Self.prefix).\(UUID().uuidString.prefix(8))"
let request = BGContinuedProcessingTaskRequest(
identifier: id, title: t("ios.bg.title"), subtitle: t("ios.bg.subtitle"))
request.strategy = .queue
do
{
try BGTaskScheduler.shared.submit(request)
pendingId = id
}
catch
{
// /
}
}
private func attach(_ task: BGContinuedProcessingTask)
{
pendingId = nil
self.task = task
task.progress.totalUnitCount = 100
task.progress.completedUnitCount = Int64(latestFraction * 100)
task.expirationHandler = { [weak self] in
self?.task?.setTaskCompleted(success: false)
self?.task = nil
}
}
private func finish(success: Bool)
{
if let task
{
task.progress.completedUnitCount = task.progress.totalUnitCount
task.setTaskCompleted(success: success)
self.task = nil
}
if let pendingId
{
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: pendingId)
self.pendingId = nil
}
}
}
+8
View File
@@ -5,6 +5,7 @@ import SwiftUI
@main
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()
}
}
}
+152 -4
View File
@@ -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.didRegisterready
// + /api/push/apns/register token
func flushPushRegistration()
{
guard engineReady, let token = PushRegistry.shared.deviceTokenHex else { return }
sendCommand("registerPush", payload: [ "token": token, "locale": I18n.locale ])
}
// App Group device_id +
// <> ·
func provisionWidgetSessionIfNeeded()
{
guard engineReady, WidgetSessionStore.load() == nil else { return }
let id = CDropAPI.widgetDeviceID()
let label = t("ios.control.sessionLabel", [ "name": currentDeviceName() ])
sendCommand("provisionWidgetSession", payload: [ "device_id": id, "device_name": label ])
}
// JSwindow.__cdropEngineEvent
func sendCommand(_ name: String, payload: [String: Any])
{
@@ -207,6 +240,48 @@ final class EngineController: NSObject
])
}
// Share Extension App Group cdrop://share
//
var pendingShareFiles: [URL] = []
// pendingShareFiles
func loadPendingShares()
{
AppGroup.pruneStale()
pendingShareFiles = AppGroup.inboxFiles()
}
// App Group move app
// 便 loadPendingShares /
// M1
func sendShares(to device: String)
{
for url in pendingShareFiles
{
if let staged = moveShareToPrivate(url) { sendFile(to: device, fileURL: staged) }
AppGroup.removeStaged(url)
}
pendingShareFiles = []
}
// app
private func moveShareToPrivate(_ url: URL) -> URL?
{
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("ShareOutbox/\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let dest = dir.appendingPathComponent(url.lastPathComponent)
do { try FileManager.default.moveItem(at: url, to: dest); return dest }
catch { return nil }
}
// +
func dismissShares()
{
for url in pendingShareFiles { AppGroup.removeStaged(url) }
pendingShareFiles = []
}
// cdrop-file://<id> URL
// 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: - schemecdrop-file
@@ -382,8 +481,9 @@ extension EngineController: WKScriptMessageHandler
extension EngineController: WKURLSchemeHandler
{
// cdrop-file://<id> JS fetch
// PLAN §2 / R-iOS-4v1 /
// didReceive jetsam R-iOS-4
// PLAN §2 / R-iOS-4rangeSource `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<Int>?
{
guard let header, header.hasPrefix("bytes="), total > 0 else { return nil }
let spec = header.dropFirst("bytes=".count)
let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2, let start = Int(parts[0]), start >= 0, start < total else { return nil }
let endInclusive: Int
if parts[1].isEmpty { endInclusive = total - 1 }
else if let e = Int(parts[1]) { endInclusive = min(e, total - 1) }
else { return nil }
guard endInclusive >= start else { return nil }
return start ..< (endInclusive + 1)
}
private func respond(_ task: any WKURLSchemeTask, status: Int)
+19
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>net.commilitia.Commilitia-Drop.transfer.*</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>zh-Hans</string>
<key>CFBundleDisplayName</key>
@@ -24,6 +28,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>net.commilitia.Commilitia-Drop</string>
<key>CFBundleURLSchemes</key>
<array>
<string>cdrop</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSSupportsOpeningDocumentsInPlace</key>
@@ -41,6 +56,10 @@
<string>Commilitia Drop 使用相机扫描二维码登录新设备。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchScreen</key>
+60
View File
@@ -0,0 +1,60 @@
import UIKit
import UserNotifications
// APNs AppDelegate UIKit EngineController
// token POST /api/push/apns/register
// / token
//
// / Push Notifications aps-environment entitlement
// ADP profile .p8iOS 16+ / Apple Silicon
// `simctl push`
@MainActor
final class PushRegistry: NSObject
{
static let shared = PushRegistry()
private override init() { super.init() }
weak var engine: EngineController?
private(set) var deviceTokenHex: String?
private var requested = false
// AppDelegate didRegister
//
func requestAuthorizationAndRegister()
{
guard !requested else { return }
requested = true
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
{ granted, _ in
guard granted else { return }
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
}
}
// AppDelegate 线APNs Data
func didRegister(tokenData: Data)
{
deviceTokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
engine?.flushPushRegistration()
}
// /
func reset()
{
requested = false
deviceTokenHex = nil
}
}
extension PushRegistry: UNUserNotificationCenterDelegate
{
// +
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions
{
return [ .banner, .sound ]
}
}
+7 -1
View File
@@ -354,5 +354,11 @@
"ios.files.empty": "No files received yet",
"ios.files.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"
}
+7 -1
View File
@@ -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}} · 控件"
}
+7 -1
View File
@@ -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}} · 控制項"
}
+17
View File
@@ -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() }
}
}
}
+19 -8
View File
@@ -1,11 +1,22 @@
# cdrop iOS 待办(择期处理)
# cdrop iOS 待办
2026-06-24 记录:扫码登录端到端验通后用户列出,留待后续处理(“A”任务覆盖的数据侧除外)。
## 计划主体(I1I7):已实现(2026-06-27
1. **登录会话显示修复**(未做):iOS 客户端在(web)“登录会话”列表中误显示为“本地账号”,应按 device_type 显示“iOS 客户端”(`deviceType.ios`)。疑似列表按会话 kind(self / 扫码自签)而非设备类型贴标签——需让扫码自签会话也带 device_type 标签呈现
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 生效)。**剩余择期**#1device_type 标签)、#3(强制 full 登录)、#2 会话管理操作、#5 ICE 明细(可选)。全部仍未提交(按用户序:真机测完再 commit)。
## 早期 5 项(2026-06-24 列)状态
1. **登录会话列表显示**:iOS 设备在(web)「登录会话」列表的标签——broker 迁移后 sessions 已按设备 cache 叠加 typedevice-session 铸入 `device_type:"ios"`),**疑已解决,待真会话核**。
2. 设备 / 会话管理:✅(设备页吊销 + 后端 list/revoke/rename)。
3. 强制 full 登录:✅(iOS 拒非 full + 后端对非 browser 兜底 full/persist)。
4. 登出:✅。
5. 传输详情:✅(`TransferDetailView`;可选 ICE 明细已接)。
## 真机阶段(账号门控,见 REALDEVICE.md
真机签名跑通 + 同内网直连 / 本地网络权限(R-iOS-6)+ 后台挂起行为(R-iOS-3+ APNs 真发 + Share Extension / 控件真机交互验证。代码就绪,等付费 ADP + 门户 + 设备。
## 余项(择期)
- 控件「添加控件」库名 + Shortcuts 标题随设备语言(需为 `CDropWidgets``.xcstrings`;当前简体静态,审查 L7,平台专属可接受)。
- 发送端 `outgoing` 暂存 + 安全作用域访问按传输完成逐项释放(当前登出统一清,审查 L1 余量)。
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 与主 app 共享的 App Group:控件读取 widget_session.json(主 app 代铸的控件会话)。 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.net.commilitia.cdrop</string>
</array>
</dict>
</plist>
+89
View File
@@ -0,0 +1,89 @@
import Foundation
// WidgetSessionStore
// /api/clipboard access 401 /api/auth/refresh
// app RESTPLAN §I4使
// refresh
enum ClipboardClient
{
enum ClientError: Error { case noSession, http(Int) }
// PUT /api/clipboard
static func upload(_ text: String) async throws
{
let body = try JSONSerialization.data(withJSONObject: [
"content": text,
"content_type": "text/plain",
"origin_ts": Int(Date().timeIntervalSince1970 * 1000),
])
_ = try await authed(method: "PUT", path: "/api/clipboard", body: body)
}
// GET /api/clipboard
static func pull() async throws -> String
{
let data = try await authed(method: "GET", path: "/api/clipboard", body: nil)
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return (obj?["content"] as? String) ?? ""
}
// + 401
private static func authed(method: String, path: String, body: Data?) async throws -> Data
{
guard var session = WidgetSessionStore.load() else { throw ClientError.noSession }
do
{
return try await send(method: method, path: path, body: body, token: session.accessToken)
}
catch ClientError.http(401)
{
session = try await refresh(session)
WidgetSessionStore.save(session)
return try await send(method: method, path: path, body: body, token: session.accessToken)
}
}
private static func send(method: String, path: String, body: Data?, token: String) async throws -> Data
{
var req = URLRequest(url: URL(string: CDropAPI.base + path)!)
req.httpMethod = method
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body
{
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let (data, resp) = try await URLSession.shared.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
// 200 409PUT LWW 401
guard code == 200 || code == 409 else { throw ClientError.http(code) }
return data
}
private static func refresh(_ session: WidgetSession) async throws -> WidgetSession
{
let body = try JSONSerialization.data(withJSONObject: [ "refresh_token": session.refreshToken ])
var req = URLRequest(url: URL(string: CDropAPI.base + "/api/auth/refresh")!)
req.httpMethod = "POST"
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, resp) = try await URLSession.shared.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
guard code == 200,
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let access = obj["access_token"] as? String
else
{
// refresh / app
// CAS-before-clear refresh
// intent L3
if WidgetSessionStore.load()?.refreshToken == session.refreshToken
{
WidgetSessionStore.clear()
}
throw ClientError.http(401)
}
let newRefresh = (obj["refresh_token"] as? String) ?? session.refreshToken
return WidgetSession(accessToken: access, refreshToken: newRefresh, userId: session.userId)
}
}
+45
View File
@@ -0,0 +1,45 @@
import AppIntents
import SwiftUI
import WidgetKit
// iOS 18+ ControlWidgetPLAN C / §I4 /
// t() i18n bundle
@main
struct CDropWidgetBundle: WidgetBundle
{
var body: some Widget
{
UploadClipboardControl()
PullClipboardControl()
}
}
struct UploadClipboardControl: ControlWidget
{
var body: some ControlWidgetConfiguration
{
StaticControlConfiguration(kind: "net.commilitia.cdrop.control.upload")
{
ControlWidgetButton(action: UploadClipboardIntent())
{
Label(t("ios.control.upload"), systemImage: "square.and.arrow.up")
}
}
.displayName("上传剪贴板")
}
}
struct PullClipboardControl: ControlWidget
{
var body: some ControlWidgetConfiguration
{
StaticControlConfiguration(kind: "net.commilitia.cdrop.control.pull")
{
ControlWidgetButton(action: PullClipboardIntent())
{
Label(t("ios.control.pull"), systemImage: "square.and.arrow.down")
}
}
.displayName("拉取剪贴板")
}
}
+32
View File
@@ -0,0 +1,32 @@
import AppIntents
import UIKit
//
struct UploadClipboardIntent: AppIntent
{
static var title: LocalizedStringResource = "上传剪贴板"
static var description = IntentDescription("把本机剪贴板内容上传到 Commilitia Drop 云剪贴板")
func perform() async throws -> some IntentResult
{
let text = await MainActor.run { UIPasteboard.general.string ?? "" }
if !text.isEmpty { try? await ClipboardClient.upload(text) }
return .result()
}
}
//
struct PullClipboardIntent: AppIntent
{
static var title: LocalizedStringResource = "拉取剪贴板"
static var description = IntentDescription("把 Commilitia Drop 云剪贴板内容写入本机剪贴板")
func perform() async throws -> some IntentResult
{
if let text = try? await ClipboardClient.pull(), !text.isEmpty
{
await MainActor.run { UIPasteboard.general.string = text }
}
return .result()
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Commilitia Drop</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+88 -2
View File
@@ -7,19 +7,37 @@ options:
settings:
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 schemeShare 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 ControlWidgetiOS 18+PLAN §I4)。纯原生 REST + 控件专用
# 设备会话(不跑引擎)。i18n JSON 一并打包使控件标签随设备语言(t() 经 no-subdir 回退读 bundle)。
CDropWidgets:
type: app-extension
platform: iOS
deploymentTarget: "26.0"
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources:
- path: Widgets
- path: Shared
- path: Sources/Resources/i18n
info:
path: Widgets/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_ENTITLEMENTS: Widgets/CDropWidgets.entitlements
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_WIDGETS)"
schemes:
CDrop:
build:
+2
View File
@@ -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.tstransfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署** | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreviewproject.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/srelay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html``engine-BC6mZ9zP.js`(含节流 + ice 摘要) |
| 2026-06-27 | source.ts(新 FileSource+ p2p/relay/transfer 改 src + engine/main.ts 加 registerPush/provisionWidgetSession + i18n 加 `ios.bg.*`/`ios.share.*`/`ios.control.*`(本提交) | 新 BackgroundTaskManager / AppDelegate / PushRegistry / Shared(AppGroup,I18n,CDropAPI,WidgetSession) / Share(ShareViewController) / Widgets(控件+intents+client)EngineController 加 Range handler / 后台 sync / 分享导入 / 控件代铸;project.yml 加两扩展 target + 真机签名 scaffold;包名改 `net.commilitia.Commilitia-Drop`(本提交) | **计划完成(I3I7**:发送端流式(FileSource + 原生 Range,新增**分享发送 + 控件**两条非引擎流程,对应残余项 1/2)/ 后台续传 / APNs(后端 internal/apns + 原生注册,新后端字段 `push_subscriptions.platform` + 端点 `POST /api/push/apns/register` 对应残余项 3/ Share Extension / 控制中心两控件(新允许分叉:控件标签见上)。经 ultracode 多 agent 审查(0 HIGH,修 2 MED + 7 LOW)。web tsc/build + go build/test + xcodebuild 全过;模拟器验登录 / 分享弹窗 / 控件嵌入 / App Group 容器。**真机 + 真发推送 + 分发账号门控**(见 REALDEVICE.md)。**待部署 prod**engine.html(新桥)+ cdrop binaryAPNs 端点 + platform 列) |
+2
View File
@@ -111,6 +111,8 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
| **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 |
| **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 |
> **实现状态(2026-06-27**I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share ExtensionApp Group 收件箱 + `cdrop://share` 深链);控制中心两控件(**专用 broker 设备会话**,纯原生 REST,不与引擎抢 refresh 轮换)。模拟器验 + ultracode 多 agent 审查修讫(0 HIGH,修 2 MED + 7 LOW)。**I0 账号 / 证书 + 真机签名 / 真发推送 / 旁加载分发**仍账号门控——手册 `ios/CDrop/REALDEVICE.md`(手动签名 + `just ios-device` 全 CLI 装机,门户手动建)。包名 `net.commilitia.Commilitia-Drop`。
---
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开)
+65 -12
View File
@@ -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<void>
{
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)时返回 503best-effort 忽略即可。
async function registerPush(token: string, locale: string): Promise<void>
{
try
{
await apiFetch("/api/push/apns/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_token: token, locale }),
});
}
catch (e)
{
notifyNative("error", { stage: "push", message: e instanceof Error ? e.message : String(e) });
}
}
// provisionWidgetSession:经 /api/auth/device-session 给控件代铸一条独立委派会话(device_type
// ios),把 access / refresh 回交原生(存 App Group)。控件据此独立调 /api/clipboard 并自刷。
async function provisionWidgetSession(deviceId: string, deviceName: string): Promise<void>
{
try
{
const r = await apiFetch("/api/auth/device-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_id: deviceId, device_name: deviceName, device_type: "ios" }),
});
if (!r.ok) { return; }
const d = (await r.json()) as { access_token: string; refresh_token: string; user_id: string };
notifyNative("widgetSession", {
access_token: d.access_token,
refresh_token: d.refresh_token,
user_id: d.user_id,
});
}
catch (e)
{
notifyNative("error", { stage: "widget", message: e instanceof Error ? e.message : String(e) });
}
}
async function revokeDevice(name: string): Promise<void>
{
try
+14 -12
View File
@@ -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<void>
async startOutgoing(src: FileSource): Promise<void>
{
this.outgoingTotal = file.size;
this.outgoingTotal = src.size;
const dc = this.pc.createDataChannel(CHANNEL_NAME, { ordered: true });
this.bindDataChannel(dc);
// bindDataChannel 给 receiver 绑了 onopensender 这里覆盖一下,等
@@ -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<void>
private async streamFile(src: FileSource): Promise<void>
{
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.
+9 -7
View File
@@ -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<void>
{
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<ArrayBuffer>,
offset: number,
isLast: boolean,
abortSignal?: AbortSignal,
+66
View File
@@ -0,0 +1,66 @@
// FileSource:发送端的「字节来源」抽象,把传输层(p2p / relay)与具体文件载体解耦。
// web / 桌面用真 Fileslice 即内存读);iOS 无头引擎用 Range 拉取——向原生
// WKURLSchemeHandler 按需取片,整文件永不进 WebView 内存,避 jetsamR-iOS-4)。
// 两实现字节语义一致,故 p2p 的块读 / 子片发送、relay 的 1 MiB 分块全程不感知来源差异。
export interface FileSource
{
readonly name: string;
readonly size: number;
readonly type: string;
// 读取 [start, end) 字节区间(end 独占),返回该区间的副本。底层 buffer 固定为
// ArrayBuffer(非 SharedArrayBuffer),以满足 RTCDataChannel.send / fetch body 的类型约束。
slice(start: number, end: number): Promise<Uint8Array<ArrayBuffer>>;
}
// fileSource:包一个已在内存的 File(web / 桌面,以及 iOS「收到的文件再转发」等场景)。
// slice 行为与既有 `new Uint8Array(await file.slice(...).arrayBuffer())` 逐字节一致,
// 故对非 iOS 平台零行为变更。
export function fileSource(file: File): FileSource
{
return {
name: file.name,
size: file.size,
type: file.type || "application/octet-stream",
async slice(start, end)
{
return new Uint8Array(await file.slice(start, end).arrayBuffer());
},
};
}
// rangeSource:经 HTTP Range 向原生 WKURLSchemeHandler 惰性拉取文件片(iOS 大文件流式发送)。
// 每次 slice 只取 [start, end) 一段,WebView 常驻内存上限即单块大小(p2p 的
// READ_BLOCK_SIZE = 4 MiB)。url 形如 `cdrop-file://<id>`;原生据 Range 头 seek 文件、
// 回 206 Partial Content 仅含该区间字节。
export function rangeSource(
url: string,
name: string,
size: number,
type: string,
): FileSource
{
return {
name,
size,
type: type || "application/octet-stream",
async slice(start, end)
{
const resp = await fetch(url, {
headers: { Range: `bytes=${start}-${end - 1}` },
});
if (!resp.ok && resp.status !== 206)
{
throw new Error(`range fetch ${resp.status} for [${start}, ${end})`);
}
const buf = new Uint8Array(await resp.arrayBuffer());
// 正确性兜底:若原生未识别 Range(返回 200 整文件),客户端再切一刀,使返回
// 严格等于请求区间。原生正常回 206 时此分支不触发,内存仍按块有界。
if (resp.status === 200 && buf.byteLength > end - start)
{
return buf.subarray(start, end);
}
return buf;
},
};
}
+11 -10
View File
@@ -1,6 +1,7 @@
import { apiFetch } from "../../net/api";
import { 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<string, { name: string; size: number; sender: stri
// arms a 30s watchdog that calls /fallback if ICE hasn't reached `connected`.
export async function startOutgoingTransfer(
receiverName: string,
file: File,
src: FileSource,
): Promise<string>
{
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<void>
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,
+6
View File
@@ -409,4 +409,10 @@ export const enUS: Partial<TranslationDict> = {
"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",
};
+6
View File
@@ -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;
+6
View File
@@ -409,4 +409,10 @@ export const zhTW: Partial<TranslationDict> = {
"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}} · 控制項",
};
+3 -2
View File
@@ -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));