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
+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.didRegister)或引擎刚就绪(ready)任一时机调用。
// 仅在引擎已就绪 + 有令牌时经桥上报,把令牌交后端 /api/push/apns/register(用新鲜会话 token)。
func flushPushRegistration()
{
guard engineReady, let token = PushRegistry.shared.deviceTokenHex else { return }
sendCommand("registerPush", payload: [ "token": token, "locale": I18n.locale ])
}
// 控件会话代铸:引擎就绪后,若 App Group 尚无控件会话则让引擎铸一条(独立 device_id +
// 「<设备> · 控件」标签)。已有则不重复铸——控件自刷拥有它,重复铸会轮换掉控件的新令牌。
func provisionWidgetSessionIfNeeded()
{
guard engineReady, WidgetSessionStore.load() == nil else { return }
let id = CDropAPI.widgetDeviceID()
let label = t("ios.control.sessionLabel", [ "name": currentDeviceName() ])
sendCommand("provisionWidgetSession", payload: [ "device_id": id, "device_name": label ])
}
// 推命令给引擎(原生 → JS,window.__cdropEngineEvent)。
func sendCommand(_ name: String, payload: [String: Any])
{
@@ -207,6 +240,48 @@ final class EngineController: NSObject
])
}
// Share Extension 经 App Group 收件箱交接来的待发文件(经 cdrop://share 唤起后导入)。
// 非空即触发主界面的「选设备发送」弹窗。
var pendingShareFiles: [URL] = []
// 导入分享:先回收陈旧暂存,再读收件箱当前文件填入 pendingShareFiles(驱动弹窗)。
func loadPendingShares()
{
AppGroup.pruneStale()
pendingShareFiles = AppGroup.inboxFiles()
}
// 把待发的分享文件发往选中设备。先把每个文件从 App Group 收件箱 move 到 app 私有暂存
// (同卷重命名,便宜),再发——收件箱当即清空,避免下次 loadPendingShares(再分享 / 重启)
// 重列已发文件而重复发送(审查 M1)。传输异步从私有暂存读取,不再依赖收件箱。
func sendShares(to device: String)
{
for url in pendingShareFiles
{
if let staged = moveShareToPrivate(url) { sendFile(to: device, fileURL: staged) }
AppGroup.removeStaged(url)
}
pendingShareFiles = []
}
// 把分享文件移到 app 私有暂存目录(保留原名),供传输异步读取;收件箱不再持有它。
private func moveShareToPrivate(_ url: URL) -> URL?
{
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("ShareOutbox/\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let dest = dir.appendingPathComponent(url.lastPathComponent)
do { try FileManager.default.moveItem(at: url, to: dest); return dest }
catch { return nil }
}
// 取消分享:删掉收件箱里这些待发文件 + 清空待发列表。
func dismissShares()
{
for url in pendingShareFiles { AppGroup.removeStaged(url) }
pendingShareFiles = []
}
// 暂存待发文件,返回引擎用的 cdrop-file://<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: - 发送侧自定义 scheme(cdrop-file)
@@ -382,8 +481,9 @@ extension EngineController: WKScriptMessageHandler
extension EngineController: WKURLSchemeHandler
{
// 发送侧:原生把暂存的待发文件经 cdrop-file://<id> 喂给引擎(JS fetch 它得到字节,
// 见 PLAN §2 / R-iOS-4)。v1 整文件读入内存返回(小 / 中文件可行);大文件改分块
// didReceive 流式以避 jetsam = R-iOS-4 优化点。
// 见 PLAN §2 / R-iOS-4)。引擎(rangeSource)按块带 `Range` 头拉取——这里 seek 文件
// 仅读该区间、回 206 Partial Content,整文件永不入内存,避大文件 jetsam。无 Range
// 头时回退整文件(防御路径;引擎正常总带 Range)。
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
{
guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id]
@@ -392,16 +492,48 @@ extension EngineController: WKURLSchemeHandler
respond(urlSchemeTask, status: 404)
return
}
guard let data = try? Data(contentsOf: fileURL)
guard let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let total = attrs[.size] as? Int,
let handle = try? FileHandle(forReadingFrom: fileURL)
else
{
respond(urlSchemeTask, status: 404)
return
}
defer { try? handle.close() }
if let range = Self.parseByteRange(urlSchemeTask.request.value(forHTTPHeaderField: "Range"), total: total)
{
let data: Data
do
{
try handle.seek(toOffset: UInt64(range.lowerBound))
data = (try handle.read(upToCount: range.count)) ?? Data()
}
catch
{
respond(urlSchemeTask, status: 500)
return
}
let resp = HTTPURLResponse(url: url, statusCode: 206, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)",
"Content-Range": "bytes \(range.lowerBound)-\(range.upperBound - 1)/\(total)",
"Accept-Ranges": "bytes",
])!
urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
return
}
let data = (try? handle.readToEnd()) ?? Data()
let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)",
"Accept-Ranges": "bytes",
])!
urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data)
@@ -410,7 +542,23 @@ extension EngineController: WKURLSchemeHandler
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
{
// 无长任务(v1 同步返回),无需处理。
// 每次请求同步返回(一块即一次 read),无长任务需取消。
}
// 解析 HTTP `Range: bytes=start-end`(end 含端)。返回半开区间 [lowerBound, upperBound)。
// end 缺省("bytes=start-")取到 EOF;越界裁剪到文件尾;非法 / 不可满足返回 nil。
static func parseByteRange(_ header: String?, total: Int) -> Range<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)