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