import Foundation import Observation import UIKit import WebKit // 引擎过桥推来的设备 presence(对应 web store 的 DeviceInfo)。lastSeen 是 ms epoch。 // 不用 Decodable:过桥的 JS 值经 WKWebView 桥后类型不稳(bool 可能成 NSNumber、 // 整数 / 浮点混用),严格 JSONDecoder 一处不匹配就整组失败、列表全空。改手动容忍解析 // (对齐 web hub.ts handlePresence 的防御式取值)。 struct DeviceItem: Identifiable, Equatable, Codable { // deviceID 是稳定标识(broker meta):设备名与令牌解耦后,去重 / 判本机 / 跨端吊销都按它, // 改名只换 name 而 deviceID 不变。后端 presence 总会带;偶发缺失(代铸前的连接)回落 name。 let deviceID: String let name: String let type: String let online: Bool let lastSeen: Double var id: String { deviceID.isEmpty ? name : deviceID } } // 引擎过桥推来的设备间文本消息(对应 web store 的 MessageRecord)。原生侧持久化(见 RecordsStore), // 故重启存活、可手动删除——不再「关 app 即清」。 struct MessageItem: Identifiable, Equatable, Codable { let id: String let direction: String // incoming | outgoing let peerName: String let text: String let sentAt: Double } // 引擎过桥推来的传输记录(对应 web engine/main.ts 的 toWire 精简视图)。ice* 为选中候选 // 对诊断(揭示 P2P 是否实为 TURN 中继,解释慢速)。同样手动容忍解析。 struct TransferItem: Identifiable, Equatable, Codable { let sessionId: String let direction: String let fileName: String let fileSize: Int let state: String let mode: String? let peerName: String let phase: String? let bytesTransferred: Int? let bytesPerSec: Double? let iceConn: String? let iceLocal: String? let iceRemote: String? var id: String { sessionId } } // 瞬时通知(#2):设备移除 / 改名 / 剪贴板等操作结果统一走横幅,醒目、状态分明、自动消失、 // 可手动清除——取代原先散落在设备页 / 设置页且永不清除的灰色小字 deviceActionStatus / clipboardStatus。 struct Notice: Identifiable, Equatable { enum Kind: Equatable { case success, error, info } let id = UUID() let kind: Kind let text: String } // 引擎日志条目(#3):installLogBridge 过桥来的 console.warn/error 逐条入环形缓冲,供独立日志界面 // 查看最近 N 条(而非仅设置页一行 lastEngineLog)。at 为本机收到时刻,用于时间戳显示。 struct LogEntry: Identifiable, Equatable { let id = UUID() let level: String // warn | error | info let message: String let at: Date } // 待批准的他端登录请求(#8):本机(已登录)扫到另一台设备显示的登录二维码(/link?r=&c=)后,经引擎 // 拉取该请求的设备信息,据此渲染安全确认(批准 / 拒绝)。requestId / code 来自二维码。 struct LoginRequestItem: Identifiable, Equatable { let requestId: String let code: String let deviceName: String let deviceType: String let requestIp: String var id: String { requestId } } // 原生 ↔ 无头 JS 引擎的桥(对侧契约见 web/src/net/ios.ts、ios/PLAN.md §2)。 // 注册名为 "cdropEngine" 的 WKScriptMessageHandler 收 { id, method, payload }(RPC)或 // { notify, payload }(单向通知);RPC 处理完经 evaluateJavaScript 调 // window.__cdropEngineResolve(id, ok, value) 回交;推命令经 window.__cdropEngineEvent。 @MainActor @Observable final class EngineController: NSObject { // SwiftUI 观察的引擎态。 var status: String = t("ios.engine.disconnected") var deviceName: String = "" // 引擎经桥推来的真实态,替换原生 UI 原先的 demo 数据。 var devices: [DeviceItem] = [] // 在线 / 离线设备 presence(每次 presence 事件整组替换) var transfers: [TransferItem] = [] // 活跃传输(每次 transfers 事件整组替换) var history: [TransferItem] = [] // 已完成传输(transferDone 逐条前插,限长 30) var messages: [MessageItem] = [] // 设备间文本消息(引擎每次 message 事件整组替换;最新在前) // 诊断信号(设置页展示):SSE 是否连上 / 是否在重连 + 累计收到的 presence 事件数 + 引擎 // 最近一条 warn/error 日志。用来定位「设备空」卡在哪一环(未连接?401?静默挂起?)。 var hubConnected = false var hubReconnecting = false var presenceCount = 0 var lastEngineLog = "" // 引擎日志环形缓冲(#3):log 事件逐条前插,限长 logsCap,供独立日志界面查看最近 N 条。 var logs: [LogEntry] = [] private static let logsCap = 300 // 瞬时通知队列(#2):操作结果横幅,前插展示、自动消失、可手动清除。 var notices: [Notice] = [] // 待确认的他端登录请求(#8):扫码拿到信息后置位,驱动设备页弹批准确认;批准 / 拒绝 / 取消后清空。 var pendingLoginRequest: LoginRequestItem? // 未读红点(#5):消息 / 传输各自的未读计数,对应 tab 激活时清零。activeTab 记录当前所在 // 标签,使「正在该页时到达的事件」不计未读。seenIncomingTransfers 去重,避免同一传入传输在 // 活跃→完成两阶段被重复计数。 var unreadMessages = 0 var unreadTransfers = 0 var activeTab = "transfer" private var seenIncomingTransfers: Set = [] // 引擎 JS 是否已就绪(收到 "ready" 通知)。门控 APNs 令牌登记:令牌可能先于引擎到达, // 故 didRegister 与 ready 双触发 flushPushRegistration,任一顺序都登记得上。 private var engineReady = false // 引擎页地址:须安全源(WebRTC 在非安全 origin 可能被禁,见 PLAN R-iOS-3)。默认线上 // https;可经环境变量 CDROP_ENGINE_URL 覆盖(本机测试指向 http://localhost——localhost // 是潜在可信源、算安全上下文,故 WebRTC 仍可用,同 Windows 桌面 loopback 思路)。 private var engineURL: URL { if let s = ProcessInfo.processInfo.environment["CDROP_ENGINE_URL"], let u = URL(string: s) { return u } return URL(string: "https://drop.commilitia.net/engine.html")! } // 会话来源(登录后注入引擎 boot)。在 EngineWebView.makeUIView 里 boot 注入前同步接好(#11)。 var auth: AuthManager? // 本机稳定设备 id(broker meta):判本机(presence 里哪台是自己)与即时改名(PATCH // /api/devices/{device_id})都用它,与设备名解耦——改名不变。 var selfDeviceID: String { auth?.session?.deviceId ?? "" } private var webView: WKWebView? private let downloads = DownloadManager() // 待发文件暂存:id → 安全作用域 URL(文件选择器选中的文件)。引擎经 cdrop-file:// // 回取字节(下方 WKURLSchemeHandler 供给)。 private var outgoing: [String: URL] = [:] // 历史 / 消息限长(原生持久化,跨重启存活)。 private static let historyCap = 50 private static let messagesCap = 200 override init() { super.init() PushRegistry.shared.engine = self // 加载上次持久化的传输历史 / 消息记录,修「重启清空」。新事件经引擎桥增量追加。 history = RecordsStore.load([TransferItem].self, "history") ?? [] messages = RecordsStore.load([MessageItem].self, "messages") ?? [] // 加载上次的设备 presence 快照:冷启 / 墓碑恢复时立即显示设备列表(而非空列表等 SSE 重连), // 缓解「长时间重连」的观感;SSE 一连上即被新鲜 presence 整组替换、自校正在线状态。 devices = RecordsStore.load([DeviceItem].self, "devices") ?? [] } // makeWebView:构建离屏 WebView——注入 __CDROP_BOOT__(device_type:"ios")、注册消息 // 处理器、注册 cdrop-file 自定义 scheme(发送侧给 JS 喂文件字节)。幂等。 func makeWebView() -> WKWebView { if let existing = webView { return existing } let ucc = WKUserContentController() ucc.add(self, name: "cdropEngine") // boot 注入:把登录后的真实会话注入引擎(无会话则 null)。device_type 标 ios 供后端 // 登记;api_base 默认空(引擎与 /api 同源,从 prod 加载时相对 URL 即可)。 let debug = ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1" let boot = "window.__CDROP_BOOT__ = { session: \(sessionJSON()), " + "device_name: \(jsString(currentDeviceName())), " + "api_base: \(jsString(apiBaseForBoot())), device_type: \"ios\", debug: \(debug) };" ucc.addUserScript(WKUserScript(source: boot, injectionTime: .atDocumentStart, forMainFrameOnly: true)) let cfg = WKWebViewConfiguration() cfg.userContentController = ucc cfg.setURLSchemeHandler(self, forURLScheme: "cdrop-file") // 迁移到 Auth Broker 后引擎自刷走注入的 refresh_token(POST /api/auth/refresh 代理 // broker),不再依赖 cdrop_session cookie,故无需把 URLSession 的 cookie 同步进 WebView。 let wv = WKWebView(frame: .zero, configuration: cfg) // 始终重新拉取 engine.html(忽略本地缓存),否则引擎更新后 WebView 可能仍跑旧包, // 新命令(剪贴板 / 设备管理等)在旧引擎里没有对应 handler → 静默失效。engine.html 很 // 小、且它引用的哈希化 bundle 仍按内容寻址正常缓存,开销可忽略。 var request = URLRequest(url: engineURL) request.cachePolicy = .reloadIgnoringLocalCacheData wv.load(request) webView = wv deviceName = currentDeviceName() // 已登录、引擎启动 → 请求通知授权并注册远程通知(幂等)。令牌回来后经引擎桥上报。 PushRegistry.shared.requestAuthorizationAndRegister() // 触发本地网络权限弹窗(#5):使 WKWebView 内的 WebRTC 能收集 host 候选、同内网直连。 LocalNetworkPermission.trigger() return wv } // 登出复位:通知引擎断开 SSE、丢弃 WebView 并清空 UI 态。下次登录时 makeWebView 以新 // 会话重建(boot 注入读 auth.session 的最新值)。 func reset() { sendCommand("shutdown", payload: [:]) webView = nil engineReady = false devices = [] transfers = [] history = [] messages = [] // 登出清空瞬时态:通知 / 日志 / 未读 / 已读传输集 / 待批准登录,不跨账号残留。 notices = [] logs = [] unreadMessages = 0 unreadTransfers = 0 seenIncomingTransfers = [] activeTab = "transfer" pendingLoginRequest = nil // 登出清空持久记录,不跨账号残留(重启保活仅针对同一登录态)。 RecordsStore.clear("history") RecordsStore.clear("messages") RecordsStore.clear("devices") 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() } // 删除一条传输历史记录(D)/ 清空全部(持久化同步)。仅删本地展示记录,不影响已落盘文件。 func deleteTransferRecord(_ sessionId: String) { history.removeAll { $0.sessionId == sessionId } RecordsStore.save(history, "history") } func clearHistory() { history.removeAll() RecordsStore.clear("history") } // 删除一条消息(H)/ 清空全部(持久化同步)。 func deleteMessage(_ id: String) { messages.removeAll { $0.id == id } RecordsStore.save(messages, "messages") } func clearMessages() { messages.removeAll() RecordsStore.clear("messages") } // ---- 瞬时通知(#2) ---- // notify:把一条操作结果作为横幅入队(前插,最新在上),并安排 4s 后自动消失。不去重——连续 // 同类操作各给一条反馈更符合预期。空文案忽略。 func notify(_ text: String, kind: Notice.Kind) { guard !text.isEmpty else { return } let notice = Notice(kind: kind, text: text) notices.insert(notice, at: 0) Task { try? await Task.sleep(for: .seconds(4)) dismissNotice(notice.id) } } func dismissNotice(_ id: UUID) { notices.removeAll { $0.id == id } } // ---- 未读红点(#5) ---- // setActiveTab:标签切换时调用。记录当前标签,并对刚进入的标签清未读(看到即已读)。 func setActiveTab(_ tab: String) { activeTab = tab if tab == "messages" { unreadMessages = 0 } if tab == "transfer" { unreadTransfers = 0 } } // noteIncomingTransfer:传入传输首次出现(活跃或完成)时计一次未读,按 sessionId 去重,且仅当 // 不在传输页时计。本机发出的传输不计。 private func noteIncomingTransfer(_ item: TransferItem) { guard item.direction == "incoming", !seenIncomingTransfers.contains(item.sessionId) else { return } seenIncomingTransfers.insert(item.sessionId) if activeTab != "transfer" { unreadTransfers += 1 } } // ---- 引擎日志(#3) ---- func clearLogs() { logs.removeAll() } // 收到的文件(Documents 沙盒,与 Files app 同一批)。供「收到的文件」视图浏览 / 转发。 func receivedFiles() -> [URL] { downloads.receivedFiles() } func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) } // 按文件名在已落盘文件里找对应项(传输详情「打开文件」直达用)。落盘时同名会被 // 唯一化为「name (1)」,故先精确匹配、再退回基名前缀匹配。 func receivedFile(matching name: String) -> URL? { let files = downloads.receivedFiles() if let exact = files.first(where: { $0.lastPathComponent == name }) { return exact } let base = (name as NSString).deletingPathExtension return files.first { $0.lastPathComponent.hasPrefix(base) } } // 可发送目标:在线设备 + 离线但可唤醒(iOS,有 APNs)的设备,且非本机。给离线可唤醒设备发送时, // 后端推送唤醒它、发送端等其回前台 / 上线后再发起实时传输(见 web transfer.ts waitForPeerThenBegin)。 func sendableDevices() -> [DeviceItem] { return devices.filter { $0.name != deviceName && ($0.online || Self.isWakeable($0)) } } // 离线但可经推送唤醒(目前=iOS App):可作为传输目标。 static func isWakeable(_ dev: DeviceItem) -> Bool { if dev.online { return false } let k = dev.type.lowercased() return k.contains("ios") || k.contains("iphone") || k.contains("ipad") } // 发送选择器里的设备标签:可唤醒(离线)设备附「可唤醒」提示,区别于在线设备。 func sendLabel(_ dev: DeviceItem) -> String { return Self.isWakeable(dev) ? "\(dev.name) · \(t("home.device.wakeable"))" : dev.name } // 剪贴板上行:读本机 UIPasteboard 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。 func uploadClipboard() { let content = UIPasteboard.general.string ?? "" guard !content.isEmpty else { notify(t("ios.clipboard.empty"), kind: .info); return } sendCommand("clipboardUpload", payload: [ "content": content ]) } // 剪贴板下行:让引擎拉一次云剪贴板,回来写入 UIPasteboard(见 handleNotify "clipboard")。 func pullClipboard() { sendCommand("clipboardPull", payload: [:]) } // 设备管理:移除 / 吊销一台设备。按稳定 device_id(DELETE /api/devices/{device_id})—— // 之前传设备名致后端找不到而「移除失败」(#2)。 func revokeDevice(_ deviceID: String) { sendCommand("revokeDevice", payload: [ "device_id": deviceID ]) } // ---- 扫码登录他端(#8) ---- // 本机扫到另一台设备的登录二维码(/link?r=&c=)后,先拉该请求的设备信息(qrRequest,需本机完整 // 会话),供确认页展示后再批准——不盲批。requestId / code 来自二维码。 func fetchLoginRequest(requestId: String, code: String) { sendCommand("fetchLoginRequest", payload: [ "request_id": requestId, "code": code ]) } // 批准当前待确认的他端登录:授予完整会话(原生只接受 full,受限访客是 Web/PWA 的权宜)。 func approveLogin() { guard let req = pendingLoginRequest else { return } pendingLoginRequest = nil sendCommand("approveLogin", payload: [ "request_id": req.requestId, "code": req.code ]) } func denyLogin() { guard let req = pendingLoginRequest else { return } pendingLoginRequest = nil sendCommand("denyLogin", payload: [ "request_id": req.requestId, "code": req.code ]) } func cancelLoginRequest() { pendingLoginRequest = nil } // 主动取消一个进行中的传输(O)。引擎置该会话为 CANCELLED,随后 transfers / transferDone 回推。 func cancelTransfer(_ sessionId: String) { sendCommand("cancelTransfer", payload: [ "sessionId": sessionId ]) } // 立即切到中继(O):跳过等待 ICE 直连的窗口,直接走 relay(同内网直连建不起来 / 不想等时用)。 func switchToRelay(_ sessionId: String) { sendCommand("switchToRelay", payload: [ "sessionId": sessionId ]) } // 设备间文本消息(发送):把 { to=对端设备名, text } 交引擎经 POST /api/message 转发。乐观回显 // 由引擎侧 store.addMessage 触发的 message 事件整组回推(含本条 outgoing),故这里不本地插。 func sendMessage(to peerName: String, text: String) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !peerName.isEmpty else { return } sendCommand("sendMessage", payload: [ "to": peerName, "text": trimmed ]) } // 本机即时改名(设备名与令牌解耦):把 { device_id=本机, name } 交引擎经 PATCH /api/devices/ // {device_id} 只改名字——不换 token、不重连、不产生重复项;成功后引擎回报 "renamed",本端据此 // 落本地名 + 更新 Keychain 会话名(见 handleNotify)。无 device_id(异常)则不发。 func renameSelf(to name: String) { let id = selfDeviceID let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !id.isEmpty, !trimmed.isEmpty else { return } sendCommand("renameSelf", payload: [ "device_id": id, "name": trimmed ]) } // 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 } // 子会话模型:控件会话铸在本机「主设备」之下(meta = 主 device_id)+ sub="widget" + 后端据此 // 给 tier=clipboard,故在用户视角=同一台设备一条会话(弃用此前独立 dev_widget device_id)。 // 令牌对仍隔离持有、独立刷新(ClipboardClient),不碰引擎主会话——#7 隔离不变。 let id = selfDeviceID guard !id.isEmpty else { return } sendCommand("provisionWidgetSession", payload: [ "device_id": id, "device_name": currentDeviceName(), "sub": "widget", ]) } // 推命令给引擎(原生 → JS,window.__cdropEngineEvent)。 func sendCommand(_ name: String, payload: [String: Any]) { guard let json = jsonString(payload) else { return } webView?.evaluateJavaScript("window.__cdropEngineEvent(\(jsString(name)), \(json));") } // sendFile:暂存选中文件、把 cdrop-file:// 引用 + 目标设备交给引擎发起传输。 func sendFile(to target: String, fileURL: URL) { let ref = stageOutgoingFile(fileURL) var size = 0 if let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path), let n = attrs[.size] as? Int { size = n } sendCommand("sendFile", payload: [ "target": target, "url": ref, "name": fileURL.lastPathComponent, "size": size, ]) } // 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 = [] } // 图库选择(#14):PhotosPicker 给的是 PhotosPickerItem,须先 loadTransferable 取字节,再落 // 一个临时文件,才能复用既有 cdrop-file scheme 发送链路(按 Range 读盘、整文件不入内存)。 // 返回临时文件 URL(在 temporaryDirectory,系统回收);写盘失败回 nil。 func stagePhotoData(_ data: Data, suggestedName: String) -> URL? { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("PhotoOutbox/\(UUID().uuidString)", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let name = suggestedName.isEmpty ? "photo.jpg" : suggestedName let dest = dir.appendingPathComponent(name) do { try data.write(to: dest); return dest } catch { return nil } } // 暂存待发文件,返回引擎用的 cdrop-file:// 引用。文件选择器给的 URL 是安全作用域 // 资源,需 start...Access 才能读。 private func stageOutgoingFile(_ url: URL) -> String { let id = UUID().uuidString _ = url.startAccessingSecurityScopedResource() outgoing[id] = url return "cdrop-file://\(id)" } // 发送侧按 [start,end) seek 读暂存的待发文件、回 base64(供 bridgeFileSource 经桥取片)。 // url 形如 cdrop-file://,id 即 stageOutgoingFile 的暂存键。无该文件 / 读失败抛错。 fileprivate func readOutgoingSlice(url: String, start: Int, end: Int) throws -> String { guard let id = URL(string: url)?.host, let fileURL = outgoing[id] else { throw NSError(domain: "cdrop.engine", code: 404, userInfo: [NSLocalizedDescriptionKey: "no staged file: \(url)"]) } let handle = try FileHandle(forReadingFrom: fileURL) defer { try? handle.close() } try handle.seek(toOffset: UInt64(max(0, start))) let count = max(0, end - start) let data = (try handle.read(upToCount: count)) ?? Data() return data.base64EncodedString() } } // MARK: - JS → 原生(RPC + 通知) extension EngineController: WKScriptMessageHandler { func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? [String: Any] else { return } if let id = body["id"] as? Int { handleRPC(id: id, method: body["method"] as? String ?? "", payload: body["payload"] as? [String: Any] ?? [:]) return } if let notify = body["notify"] as? String { handleNotify(notify, payload: body["payload"]) } } private func handleRPC(id: Int, method: String, payload: [String: Any]) { do { switch method { case "saveDownload": let name = payload["name"] as? String ?? "download" let path = try downloads.saveWhole(name: name, data: decodeBase64(payload["data"])) resolve(id: id, ok: true, value: path) case "beginDownload": try downloads.begin(sessionId: payload["sessionId"] as? String ?? "") resolve(id: id, ok: true, value: nil) case "appendDownload": try downloads.append(sessionId: payload["sessionId"] as? String ?? "", data: decodeBase64(payload["data"])) resolve(id: id, ok: true, value: nil) case "finalizeDownload": let path = try downloads.finalize(sessionId: payload["sessionId"] as? String ?? "", name: payload["name"] as? String ?? "download") resolve(id: id, ok: true, value: path) case "abortDownload": downloads.abort(sessionId: payload["sessionId"] as? String ?? "") resolve(id: id, ok: true, value: nil) case "readFileSlice": // 发送侧取片:原生 seek 暂存的待发文件读 [start,end)、回 base64(见 bridgeFileSource)。 let b64 = try readOutgoingSlice(url: payload["url"] as? String ?? "", start: Self.intOf(payload["start"]), end: Self.intOf(payload["end"])) resolve(id: id, ok: true, value: b64) default: resolve(id: id, ok: false, value: "unknown method: \(method)") } } catch { resolve(id: id, ok: false, value: "\(error)") } } private func handleNotify(_ name: String, payload: Any?) { switch name { 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] { let secure = (p["secure"] as? Bool) ?? false let rtc = (p["rtc"] as? String) ?? "?" let host = (p["host"] as? Int) ?? 0 let srflx = (p["srflx"] as? Int) ?? 0 status = "安全:\(secure) RTC:\(rtc) host:\(host) srflx:\(srflx)" } case "presence": // 注意:桥来的 JS 数组是 NSArray,`as? [[String: Any]]` 这种嵌套泛型条件转换 // 在 Foundation 桥接下常整体失败(即便数据合法)→ 列表全空。故先转 [Any] 再逐 // 元素 as? [String: Any],这是先前「健壮解析」仍漏掉的真正失败点。 if let p = payload as? [String: Any], let raw = p["devices"] as? [Any] { devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) } RecordsStore.save(devices, "devices") // 持久化 presence 快照,供下次冷启即时显示 } case "transfers": if let p = payload as? [String: Any], let raw = p["active"] as? [Any] { transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) } for item in transfers { noteIncomingTransfer(item) } // 传入传输首现计未读红点(#5) syncBackgroundTask() } case "message": // 引擎推来的单条新消息(增量):前插 + 去重 + 限长 + 落盘。原生为持久真源,故不被 // 引擎重启后的空列表覆盖(重启清空问题的根因)。 if let p = payload as? [String: Any], let m = p["message"] as? [String: Any], let item = Self.parseMessage(m) { let isNew = !messages.contains { $0.id == item.id } messages.removeAll { $0.id == item.id } messages.insert(item, at: 0) if messages.count > Self.messagesCap { messages.removeLast(messages.count - Self.messagesCap) } RecordsStore.save(messages, "messages") // 新到的对端消息:不在消息页时计未读红点(#5)。本机发出的乐观回显(outgoing)不计。 if isNew, item.direction == "incoming", activeTab != "messages" { unreadMessages += 1 } } case "transferDone": // 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。 if let p = payload as? [String: Any], let item = Self.parseTransfer(p) { noteIncomingTransfer(item) // 极快传输可能跳过活跃阶段,这里补计未读(#5,已 seen 则不重复) transfers.removeAll { $0.sessionId == item.sessionId } history.removeAll { $0.sessionId == item.sessionId } history.insert(item, at: 0) if history.count > Self.historyCap { history.removeLast(history.count - Self.historyCap) } syncBackgroundTask() RecordsStore.save(history, "history") // 持久化,跨重启存活 } case "sendStarted": // 随后的 transfers 事件会带出该活跃项,这里无需额外处理。 break case "hubState": if let p = payload as? [String: Any] { hubConnected = Self.boolOf(p["connected"]) hubReconnecting = Self.boolOf(p["reconnecting"]) presenceCount = Self.intOf(p["presenceCount"]) } case "log": if let p = payload as? [String: Any], let msg = p["msg"] as? String { lastEngineLog = msg logs.insert(LogEntry(level: p["level"] as? String ?? "info", message: msg, at: Date()), at: 0) if logs.count > Self.logsCap { logs.removeLast(logs.count - Self.logsCap) } } case "clipboard": // 引擎拉回的云剪贴板内容 → 写入本机 UIPasteboard。 if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty { UIPasteboard.general.string = content notify(t("ios.clipboard.pulled"), kind: .success) } else { notify(t("ios.clipboard.empty"), kind: .info) } case "clipboardUploaded": notify(t("ios.clipboard.uploaded"), kind: .success) case "deviceRevoked": notify(t("ios.devices.revoked"), kind: .success) case "loginRequest": // 扫码拿到的他端登录请求信息(#8)→ 置位驱动设备页弹批准确认。 if let p = payload as? [String: Any], let rid = p["request_id"] as? String, let code = p["code"] as? String { pendingLoginRequest = LoginRequestItem( requestId: rid, code: code, deviceName: p["device_name"] as? String ?? "", deviceType: p["device_type"] as? String ?? "", requestIp: p["request_ip"] as? String ?? "") } case "loginApproved": notify(t("ios.scan.approved"), kind: .success) case "loginDenied": notify(t("ios.scan.denied"), kind: .success) case "loginFailed": pendingLoginRequest = nil notify(t("ios.scan.failed"), kind: .error) case "identityUpdated": // 引擎据 /api/me 取到真实显示名(QR 登录注入的是 UUID 名)→ 更新会话身份 + Keychain, // 设置页据此显示真名而非 UUID(#12)。 if let p = payload as? [String: Any], let name = p["name"] as? String, !name.isEmpty { auth?.updateIdentity(name: name, avatar: p["avatar"] as? String) } case "renamed": // 本机即时改名成功(PATCH /api/devices/{device_id} 已落库 + 重广播 presence)→ 落本地名 // 并更新 Keychain 会话名,使重启后 boot 注入新名。 if let p = payload as? [String: Any], let name = p["name"] as? String, !name.isEmpty { DeviceNameStore.value = name auth?.updateDeviceName(name) deviceName = name notify(t("ios.settings.deviceName.success"), kind: .success) } case "error": // 复用现有 error 通知:剪贴板 / 吊销失败也走这里,分流到对应状态行。 if let p = payload as? [String: Any] { let stage = p["stage"] as? String ?? "" let msg = p["message"] as? String ?? "" if stage == "clipboard" { notify(t("ios.clipboard.failed"), kind: .error) } else if stage == "revoke" { notify(msg == "step_up_required" ? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed"), kind: .error) } else { status = "错误:\(msg)" } } case "sessionRotated": // 引擎自刷致 broker 轮换 refresh → 更新 Keychain,重启免用失效旧 refresh。 if let p = payload as? [String: Any], let access = p["access_token"] as? String, let refresh = p["refresh_token"] as? String { 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() reset() default: break } } private func resolve(id: Int, ok: Bool, value: Any?) { 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) extension EngineController: WKURLSchemeHandler { // 发送侧:原生把暂存的待发文件经 cdrop-file:// 喂给引擎(JS fetch 它得到字节, // 见 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] else { respond(urlSchemeTask, status: 404) return } 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) urlSchemeTask.didFinish() } func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { // 每次请求同步返回(一块即一次 read),无长任务需取消。 } // 解析 HTTP `Range: bytes=start-end`(end 含端)。返回半开区间 [lowerBound, upperBound)。 // end 缺省("bytes=start-")取到 EOF;越界裁剪到文件尾;非法 / 不可满足返回 nil。 static func parseByteRange(_ header: String?, total: Int) -> Range? { 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) { guard let url = task.request.url, let resp = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil) else { return } task.didReceive(resp) task.didFinish() } } // MARK: - 通知解析(手动容忍,见 DeviceItem 注释) extension EngineController { // 桥来的数值经 WKWebView 后可能是 NSNumber / Int / Double / Bool,任一字段类型不符都 // 不应让整组失败。下列取值器逐字段宽松转换,缺失给默认。 static func parseDevice(_ d: [String: Any]) -> DeviceItem? { guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil } // 引擎推来的是 web store 的 DeviceInfo(camelCase:deviceId / lastSeen)。 return DeviceItem(deviceID: d["deviceId"] as? String ?? "", name: name, type: type, online: boolOf(d["online"]), lastSeen: doubleOf(d["lastSeen"])) } static func parseMessage(_ m: [String: Any]) -> MessageItem? { guard let id = m["id"] as? String, let text = m["text"] as? String else { return nil } return MessageItem(id: id, direction: m["direction"] as? String ?? "incoming", peerName: m["peerName"] as? String ?? "", text: text, sentAt: doubleOf(m["sentAt"])) } static func parseTransfer(_ t: [String: Any]) -> TransferItem? { guard let sessionId = t["sessionId"] as? String else { return nil } let ice = t["ice"] as? [String: Any] return TransferItem(sessionId: sessionId, direction: t["direction"] as? String ?? "outgoing", fileName: t["fileName"] as? String ?? "", fileSize: intOf(t["fileSize"]), state: t["state"] as? String ?? "PENDING", mode: t["mode"] as? String, peerName: t["peerName"] as? String ?? "", phase: t["phase"] as? String, bytesTransferred: t["bytesTransferred"].map { intOf($0) }, bytesPerSec: t["bytesPerSec"].map { doubleOf($0) }, iceConn: ice?["conn"] as? String, iceLocal: ice?["local"] as? String, iceRemote: ice?["remote"] as? String) } static func boolOf(_ v: Any?) -> Bool { if let b = v as? Bool { return b } if let n = v as? NSNumber { return n.boolValue } return false } static func intOf(_ v: Any?) -> Int { if let i = v as? Int { return i } if let n = v as? NSNumber { return n.intValue } if let d = v as? Double { return Int(d) } return 0 } static func doubleOf(_ v: Any?) -> Double { if let d = v as? Double { return d } if let n = v as? NSNumber { return n.doubleValue } if let i = v as? Int { return Double(i) } return 0 } } // MARK: - 编码助手 private extension EngineController { func decodeBase64(_ v: Any?) -> Data { guard let s = v as? String, let d = Data(base64Encoded: s) else { return Data() } return d } func jsonString(_ obj: [String: Any]) -> String? { guard JSONSerialization.isValidJSONObject(obj), let d = try? JSONSerialization.data(withJSONObject: obj), let s = String(data: d, encoding: .utf8) else { return nil } return s } // 把任意值编码成可嵌入 JS 的字面量(字符串走 JSON 转义;nil → null)。借数组包裹再 // 剥括号,绕过 JSONSerialization 不接受顶层标量的限制。 func jsonValue(_ v: Any?) -> String { guard let v = v else { return "null" } guard let d = try? JSONSerialization.data(withJSONObject: [v]), let s = String(data: d, encoding: .utf8) else { return "null" } return String(s.dropFirst().dropLast()) } func jsString(_ s: String) -> String { return jsonValue(s) } // 引擎 boot 注入用:会话 JSON(无会话则 null)、设备名、api_base。 func sessionJSON() -> String { guard let s = auth?.session else { return "null" } return "{ access_token: \(jsString(s.accessToken)), " + "refresh_token: \(jsString(s.refreshToken)), " + "user: { id: \(jsString(s.user.id)), name: \(jsString(s.user.name)) } }" } func currentDeviceName() -> String { return auth?.session?.deviceName ?? "iPhone" } func apiBaseForBoot() -> String { return ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? "" } }