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

- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource
- 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI
- APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式
- Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎)
- 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换)
- 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册
- I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键
- ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
This commit is contained in:
2026-06-27 00:27:55 +08:00
parent 10cf36ecee
commit 44096069a7
59 changed files with 2585 additions and 144 deletions
+35
View File
@@ -0,0 +1,35 @@
import UIKit
// launch UIKit
// APNs SwiftUI App
// @UIApplicationDelegateAdaptor
final class AppDelegate: NSObject, UIApplicationDelegate
{
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool
{
// BGContinuedProcessingTask launch handler launch
BackgroundTaskManager.shared.registerOnce()
return true
}
// APNs PushRegistry
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
)
{
PushRegistry.shared.didRegister(tokenData: deviceToken)
}
// APNs APNs / / 退线 SSE
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
)
{
// Push
}
}
@@ -0,0 +1,106 @@
import BackgroundTasks
import UIKit
// iOS 26 BGContinuedProcessingTask
// UI AirDrop0>0
// progress 0
//
// R-iOS-3BGContinuedProcessingTask + UI
// WKWebView WebContent JS / WebRTC
// 退R-iOS-1
//
// 线register .main sync 线 actor
final class BackgroundTaskManager
{
static let shared = BackgroundTaskManager()
private init() {}
// Info.plist BGTaskSchedulerPermittedIdentifiers "<prefix>.*"
// false退
private static let prefix = "net.commilitia.Commilitia-Drop.transfer"
private static let wildcard = prefix + ".*"
private var triedRegister = false
private var registerOK = false
private var task: BGContinuedProcessingTask?
private var pendingId: String? // attach 0
private var latestFraction: Double = 0
// launch AppDelegate.didFinishLaunching
func registerOnce()
{
guard !triedRegister else { return }
triedRegister = true
registerOK = BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.wildcard, using: .main)
{ [weak self] task in
guard let cpt = task as? BGContinuedProcessingTask
else { task.setTaskCompleted(success: false); return }
self?.attach(cpt)
}
}
// activeCount fraction [0, 1]
func sync(activeCount: Int, fraction: Double)
{
latestFraction = fraction
if activeCount > 0
{
if let task { task.progress.completedUnitCount = Int64(fraction * 100) }
else { submitIfForeground() }
}
else
{
finish(success: true)
}
}
private func submitIfForeground()
{
// / / attach
guard registerOK, task == nil, pendingId == nil else { return }
//
guard UIApplication.shared.applicationState != .background else { return }
let id = "\(Self.prefix).\(UUID().uuidString.prefix(8))"
let request = BGContinuedProcessingTaskRequest(
identifier: id, title: t("ios.bg.title"), subtitle: t("ios.bg.subtitle"))
request.strategy = .queue
do
{
try BGTaskScheduler.shared.submit(request)
pendingId = id
}
catch
{
// /
}
}
private func attach(_ task: BGContinuedProcessingTask)
{
pendingId = nil
self.task = task
task.progress.totalUnitCount = 100
task.progress.completedUnitCount = Int64(latestFraction * 100)
task.expirationHandler = { [weak self] in
self?.task?.setTaskCompleted(success: false)
self?.task = nil
}
}
private func finish(success: Bool)
{
if let task
{
task.progress.completedUnitCount = task.progress.totalUnitCount
task.setTaskCompleted(success: success)
self.task = nil
}
if let pendingId
{
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: pendingId)
self.pendingId = nil
}
}
}
+8
View File
@@ -5,6 +5,7 @@ import SwiftUI
@main
struct CDropApp: App
{
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var auth = AuthManager()
@State private var engine = EngineController()
@@ -47,5 +48,12 @@ struct AppRoot: View
auth.debugSession()
}
}
// Share Extension cdrop://share RootView
// AppRoot 便 / onAppear
.onOpenURL
{ url in
guard url.scheme == "cdrop", url.host == "share" else { return }
engine.loadPendingShares()
}
}
}
+152 -4
View File
@@ -60,6 +60,10 @@ final class EngineController: NSObject
var presenceCount = 0
var lastEngineLog = ""
// JS "ready" APNs
// didRegister ready flushPushRegistration
private var engineReady = false
// / /
var clipboardStatus = ""
var deviceActionStatus = ""
@@ -89,6 +93,7 @@ final class EngineController: NSObject
override init()
{
super.init()
PushRegistry.shared.engine = self
}
// makeWebView WebView __CDROP_BOOT__device_type:"ios"
@@ -126,6 +131,8 @@ final class EngineController: NSObject
wv.load(request)
webView = wv
deviceName = currentDeviceName()
//
PushRegistry.shared.requestAuthorizationAndRegister()
return wv
}
@@ -135,11 +142,19 @@ final class EngineController: NSObject
{
sendCommand("shutdown", payload: [:])
webView = nil
engineReady = false
devices = []
transfers = []
history = []
status = t("ios.engine.disconnected")
deviceName = ""
PushRegistry.shared.reset()
WidgetSessionStore.clear()
// L1访 + outgoing访 /
for (_, url) in outgoing { url.stopAccessingSecurityScopedResource() }
outgoing.removeAll()
// L2 id
CDropAPI.clearWidgetDeviceID()
}
// Documents Files app /
@@ -185,6 +200,24 @@ final class EngineController: NSObject
sendCommand("revokeDevice", payload: [ "name": name ])
}
// APNs PushRegistry.didRegisterready
// + /api/push/apns/register token
func flushPushRegistration()
{
guard engineReady, let token = PushRegistry.shared.deviceTokenHex else { return }
sendCommand("registerPush", payload: [ "token": token, "locale": I18n.locale ])
}
// App Group device_id +
// <> ·
func provisionWidgetSessionIfNeeded()
{
guard engineReady, WidgetSessionStore.load() == nil else { return }
let id = CDropAPI.widgetDeviceID()
let label = t("ios.control.sessionLabel", [ "name": currentDeviceName() ])
sendCommand("provisionWidgetSession", payload: [ "device_id": id, "device_name": label ])
}
// JSwindow.__cdropEngineEvent
func sendCommand(_ name: String, payload: [String: Any])
{
@@ -207,6 +240,48 @@ final class EngineController: NSObject
])
}
// Share Extension App Group cdrop://share
//
var pendingShareFiles: [URL] = []
// pendingShareFiles
func loadPendingShares()
{
AppGroup.pruneStale()
pendingShareFiles = AppGroup.inboxFiles()
}
// App Group move app
// 便 loadPendingShares /
// M1
func sendShares(to device: String)
{
for url in pendingShareFiles
{
if let staged = moveShareToPrivate(url) { sendFile(to: device, fileURL: staged) }
AppGroup.removeStaged(url)
}
pendingShareFiles = []
}
// app
private func moveShareToPrivate(_ url: URL) -> URL?
{
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("ShareOutbox/\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let dest = dir.appendingPathComponent(url.lastPathComponent)
do { try FileManager.default.moveItem(at: url, to: dest); return dest }
catch { return nil }
}
// +
func dismissShares()
{
for url in pendingShareFiles { AppGroup.removeStaged(url) }
pendingShareFiles = []
}
// cdrop-file://<id> URL
// start...Access
private func stageOutgoingFile(_ url: URL) -> String
@@ -278,6 +353,9 @@ extension EngineController: WKScriptMessageHandler
{
case "ready":
status = t("ios.engine.ready")
engineReady = true
flushPushRegistration()
provisionWidgetSessionIfNeeded()
case "probe":
// WebRTC-in-WKWebView arch A R-iOS-3
if let p = payload as? [String: Any]
@@ -300,6 +378,7 @@ extension EngineController: WKScriptMessageHandler
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
{
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
syncBackgroundTask()
}
case "transferDone":
// sessionId
@@ -309,6 +388,7 @@ extension EngineController: WKScriptMessageHandler
history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0)
if history.count > 30 { history.removeLast(history.count - 30) }
syncBackgroundTask()
}
case "sendStarted":
// transfers
@@ -362,6 +442,15 @@ extension EngineController: WKScriptMessageHandler
{
auth?.updateSession(accessToken: access, refreshToken: refresh)
}
case "widgetSession":
// App Group /
if let p = payload as? [String: Any],
let access = p["access_token"] as? String,
let refresh = p["refresh_token"] as? String,
let userId = p["user_id"] as? String
{
WidgetSessionStore.save(WidgetSession(accessToken: access, refreshToken: refresh, userId: userId))
}
case "authExpired":
// refresh / Keychain
auth?.logout()
@@ -375,6 +464,16 @@ extension EngineController: WKScriptMessageHandler
{
webView?.evaluateJavaScript("window.__cdropEngineResolve(\(id), \(ok), \(jsonValue(value)));")
}
// transfers history
// BackgroundTaskManager / / R-iOS-1 / R-iOS-3
private func syncBackgroundTask()
{
let total = transfers.reduce(0) { $0 + $1.fileSize }
let done = transfers.reduce(0) { $0 + ($1.bytesTransferred ?? 0) }
let fraction = total > 0 ? Double(done) / Double(total) : 0
BackgroundTaskManager.shared.sync(activeCount: transfers.count, fraction: fraction)
}
}
// MARK: - schemecdrop-file
@@ -382,8 +481,9 @@ extension EngineController: WKScriptMessageHandler
extension EngineController: WKURLSchemeHandler
{
// cdrop-file://<id> JS fetch
// PLAN §2 / R-iOS-4v1 /
// didReceive jetsam R-iOS-4
// PLAN §2 / R-iOS-4rangeSource `Range` seek
// 206 Partial Content jetsam Range
// 退 Range
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
{
guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id]
@@ -392,16 +492,48 @@ extension EngineController: WKURLSchemeHandler
respond(urlSchemeTask, status: 404)
return
}
guard let data = try? Data(contentsOf: fileURL)
guard let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let total = attrs[.size] as? Int,
let handle = try? FileHandle(forReadingFrom: fileURL)
else
{
respond(urlSchemeTask, status: 404)
return
}
defer { try? handle.close() }
if let range = Self.parseByteRange(urlSchemeTask.request.value(forHTTPHeaderField: "Range"), total: total)
{
let data: Data
do
{
try handle.seek(toOffset: UInt64(range.lowerBound))
data = (try handle.read(upToCount: range.count)) ?? Data()
}
catch
{
respond(urlSchemeTask, status: 500)
return
}
let resp = HTTPURLResponse(url: url, statusCode: 206, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)",
"Content-Range": "bytes \(range.lowerBound)-\(range.upperBound - 1)/\(total)",
"Accept-Ranges": "bytes",
])!
urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
return
}
let data = (try? handle.readToEnd()) ?? Data()
let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)",
"Accept-Ranges": "bytes",
])!
urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data)
@@ -410,7 +542,23 @@ extension EngineController: WKURLSchemeHandler
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
{
// v1
// read
}
// HTTP `Range: bytes=start-end`end [lowerBound, upperBound)
// end "bytes=start-" EOF / nil
static func parseByteRange(_ header: String?, total: Int) -> Range<Int>?
{
guard let header, header.hasPrefix("bytes="), total > 0 else { return nil }
let spec = header.dropFirst("bytes=".count)
let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2, let start = Int(parts[0]), start >= 0, start < total else { return nil }
let endInclusive: Int
if parts[1].isEmpty { endInclusive = total - 1 }
else if let e = Int(parts[1]) { endInclusive = min(e, total - 1) }
else { return nil }
guard endInclusive >= start else { return nil }
return start ..< (endInclusive + 1)
}
private func respond(_ task: any WKURLSchemeTask, status: Int)
-66
View File
@@ -1,66 +0,0 @@
import Foundation
// i18n app bundle <locale>.json web web/scripts/emit-i18n.mjs
// ios/PLAN.md §5退 zh-CN JSON 退
// zh-CN loader locale
enum I18n
{
private static let dict: [String: String] = load()
static func t(_ key: String) -> String
{
return dict[key] ?? key
}
// web t(key, vars) {{name}}catalog
// settings.lastSeen {{time}} {{...}} UI
static func t(_ key: String, _ args: [String: String]) -> String
{
var s = dict[key] ?? key
for (name, value) in args
{
s = s.replacingOccurrences(of: "{{\(name)}}", with: value)
}
return s
}
private static func load() -> [String: String]
{
guard let url = resourceURL(preferredLocale()) ?? resourceURL("zh-CN"),
let data = try? Data(contentsOf: url),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String]
else { return [:] }
return obj
}
private static func resourceURL(_ loc: String) -> URL?
{
return Bundle.main.url(forResource: loc, withExtension: "json", subdirectory: "i18n")
?? Bundle.main.url(forResource: loc, withExtension: "json")
}
private static func preferredLocale() -> String
{
for lang in Locale.preferredLanguages
{
if lang.hasPrefix("zh-Hant") || lang.hasPrefix("zh-TW") || lang.hasPrefix("zh-HK")
{
return "zh-TW"
}
if lang.hasPrefix("zh") { return "zh-CN" }
if lang.hasPrefix("en") { return "en-US" }
}
return "zh-CN"
}
}
// web t()
func t(_ key: String) -> String
{
return I18n.t(key)
}
func t(_ key: String, _ args: [String: String]) -> String
{
return I18n.t(key, args)
}
+19
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>net.commilitia.Commilitia-Drop.transfer.*</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>zh-Hans</string>
<key>CFBundleDisplayName</key>
@@ -24,6 +28,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>net.commilitia.Commilitia-Drop</string>
<key>CFBundleURLSchemes</key>
<array>
<string>cdrop</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSSupportsOpeningDocumentsInPlace</key>
@@ -41,6 +56,10 @@
<string>Commilitia Drop 使用相机扫描二维码登录新设备。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchScreen</key>
+60
View File
@@ -0,0 +1,60 @@
import UIKit
import UserNotifications
// APNs AppDelegate UIKit EngineController
// token POST /api/push/apns/register
// / token
//
// / Push Notifications aps-environment entitlement
// ADP profile .p8iOS 16+ / Apple Silicon
// `simctl push`
@MainActor
final class PushRegistry: NSObject
{
static let shared = PushRegistry()
private override init() { super.init() }
weak var engine: EngineController?
private(set) var deviceTokenHex: String?
private var requested = false
// AppDelegate didRegister
//
func requestAuthorizationAndRegister()
{
guard !requested else { return }
requested = true
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
{ granted, _ in
guard granted else { return }
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
}
}
// AppDelegate 线APNs Data
func didRegister(tokenData: Data)
{
deviceTokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
engine?.flushPushRegistration()
}
// /
func reset()
{
requested = false
deviceTokenHex = nil
}
}
extension PushRegistry: UNUserNotificationCenterDelegate
{
// +
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions
{
return [ .banner, .sound ]
}
}
+7 -1
View File
@@ -354,5 +354,11 @@
"ios.files.empty": "No files received yet",
"ios.files.share": "Share",
"ios.files.done": "Done",
"ios.detail.openFile": "Open File"
"ios.detail.openFile": "Open File",
"ios.bg.title": "Transferring files",
"ios.bg.subtitle": "Continues in the background",
"ios.share.title": "Send {{n}} file(s) to a device",
"ios.control.upload": "Upload Clipboard",
"ios.control.pull": "Pull Clipboard",
"ios.control.sessionLabel": "{{name}} · Control"
}
+7 -1
View File
@@ -354,5 +354,11 @@
"ios.files.empty": "还没有收到文件",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "打开文件"
"ios.detail.openFile": "打开文件",
"ios.bg.title": "正在传输文件",
"ios.bg.subtitle": "切到后台仍继续传输",
"ios.share.title": "发送 {{n}} 个文件到设备",
"ios.control.upload": "上传剪贴板",
"ios.control.pull": "拉取剪贴板",
"ios.control.sessionLabel": "{{name}} · 控件"
}
+7 -1
View File
@@ -354,5 +354,11 @@
"ios.files.empty": "尚未收到檔案",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "開啟檔案"
"ios.detail.openFile": "開啟檔案",
"ios.bg.title": "正在傳輸檔案",
"ios.bg.subtitle": "切到背景仍繼續傳輸",
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
"ios.control.upload": "上傳剪貼簿",
"ios.control.pull": "拉取剪貼簿",
"ios.control.sessionLabel": "{{name}} · 控制項"
}
+17
View File
@@ -54,6 +54,23 @@ struct RootView: View
.opacity(0)
.allowsHitTesting(false)
}
// Share Extension cdrop://share
.onAppear { engine.loadPendingShares() }
.confirmationDialog(
t("ios.share.title", [ "n": String(engine.pendingShareFiles.count) ]),
isPresented: Binding(
get: { !engine.pendingShareFiles.isEmpty },
set: { if !$0 { engine.dismissShares() } }
),
titleVisibility: .visible
)
{
ForEach(engine.sendableDevices())
{ dev in
Button(dev.name) { engine.sendShares(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { engine.dismissShares() }
}
}
}