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:
@@ -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
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.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"
|
||||
}
|
||||
|
||||
@@ -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}} · 控件"
|
||||
}
|
||||
|
||||
@@ -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}} · 控制項"
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user