鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移
后端(委托 Auth Broker,路径 A): - 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest() - internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等 统一会话模型“委派设备会话”(Delegated Device Sessions): - 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表 - 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备 - 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定 - 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行 Web 前端: - 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh - 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环 - 去 OIDC PKCE / step-up(删 oauth.callback / stepUp) 桌面客户端(Wails): - loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置 iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥): - 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库) - 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId 实时 / 健壮性: - presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线) - Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热) 配置 / 删旧栈: - config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效) - 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传) - 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
|
||||
// 接收落盘管理器(对应桌面 desktop/platform/download.go):把接收到的字节写入 app 沙盒
|
||||
// Documents。小文件整文件写(saveWhole);大文件流式 begin / append / finalize;abort
|
||||
// 丢弃临时文件。线程:由 EngineController 在主线程调用,文件 IO 同步执行(首版求简单可
|
||||
// 验,后续可挪后台队列)。
|
||||
final class DownloadManager
|
||||
{
|
||||
private struct Active
|
||||
{
|
||||
let handle: FileHandle
|
||||
let tmpURL: URL
|
||||
}
|
||||
|
||||
private var active: [String: Active] = [:]
|
||||
|
||||
private func downloadDir() -> URL
|
||||
{
|
||||
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
// 列出已落盘的接收文件(Documents 顶层,排除流式临时 .part / 隐藏文件),最近的在前。
|
||||
// 供应用内「收到的文件」视图浏览 / 打开 / 转发,也是 Files app 可见的同一批文件。
|
||||
func receivedFiles() -> [URL]
|
||||
{
|
||||
let urls = (try? FileManager.default.contentsOfDirectory(
|
||||
at: downloadDir(),
|
||||
includingPropertiesForKeys: [ .contentModificationDateKey ],
|
||||
options: [ .skipsHiddenFiles ])) ?? []
|
||||
return urls
|
||||
.filter { $0.pathExtension != "part" && !$0.lastPathComponent.hasPrefix(".cdrop-") }
|
||||
.sorted { a, b in modDate(a) > modDate(b) }
|
||||
}
|
||||
|
||||
func deleteReceivedFile(_ url: URL)
|
||||
{
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
private func modDate(_ url: URL) -> Date
|
||||
{
|
||||
return (try? url.resourceValues(forKeys: [ .contentModificationDateKey ]))?
|
||||
.contentModificationDate ?? .distantPast
|
||||
}
|
||||
|
||||
// 整文件写入(接收端小文件路径,对应桥 saveDownload)。返回最终绝对路径。
|
||||
func saveWhole(name: String, data: Data) throws -> String
|
||||
{
|
||||
let url = uniqueURL(for: sanitize(name))
|
||||
try data.write(to: url)
|
||||
return url.path
|
||||
}
|
||||
|
||||
func begin(sessionId: String) throws
|
||||
{
|
||||
cleanup(sessionId) // re-begin 安全:先清旧条目再开新文件
|
||||
let tmp = downloadDir().appendingPathComponent(".cdrop-\(sanitize(sessionId)).part")
|
||||
FileManager.default.createFile(atPath: tmp.path, contents: nil)
|
||||
let handle = try FileHandle(forWritingTo: tmp)
|
||||
active[sessionId] = Active(handle: handle, tmpURL: tmp)
|
||||
}
|
||||
|
||||
func append(sessionId: String, data: Data) throws
|
||||
{
|
||||
guard let a = active[sessionId] else { throw Err.unknownSession }
|
||||
try a.handle.write(contentsOf: data)
|
||||
}
|
||||
|
||||
func finalize(sessionId: String, name: String) throws -> String
|
||||
{
|
||||
guard let a = active[sessionId] else { throw Err.unknownSession }
|
||||
try a.handle.close()
|
||||
active[sessionId] = nil
|
||||
let dest = uniqueURL(for: sanitize(name))
|
||||
try FileManager.default.moveItem(at: a.tmpURL, to: dest)
|
||||
return dest.path
|
||||
}
|
||||
|
||||
func abort(sessionId: String)
|
||||
{
|
||||
cleanup(sessionId)
|
||||
}
|
||||
|
||||
private func cleanup(_ sessionId: String)
|
||||
{
|
||||
guard let a = active[sessionId] else { return }
|
||||
try? a.handle.close()
|
||||
try? FileManager.default.removeItem(at: a.tmpURL)
|
||||
active[sessionId] = nil
|
||||
}
|
||||
|
||||
// 唯一化:若已存在则插入 (1) (2)…(对应桌面 uniquePath)。
|
||||
private func uniqueURL(for name: String) -> URL
|
||||
{
|
||||
let dir = downloadDir()
|
||||
let base = (name as NSString).deletingPathExtension
|
||||
let ext = (name as NSString).pathExtension
|
||||
var candidate = dir.appendingPathComponent(name)
|
||||
var i = 1
|
||||
while FileManager.default.fileExists(atPath: candidate.path)
|
||||
{
|
||||
let next = ext.isEmpty ? "\(base) (\(i))" : "\(base) (\(i)).\(ext)"
|
||||
candidate = dir.appendingPathComponent(next)
|
||||
i += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// 文件名 sanitize:只取末段、剥路径分隔符,防目录穿越(对应桌面 sanitizeFileName)。
|
||||
private func sanitize(_ name: String) -> String
|
||||
{
|
||||
let last = (name as NSString).lastPathComponent
|
||||
let cleaned = last.replacingOccurrences(of: "/", with: "_")
|
||||
return cleaned.isEmpty ? "download" : cleaned
|
||||
}
|
||||
|
||||
enum Err: Error
|
||||
{
|
||||
case unknownSession
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
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
|
||||
{
|
||||
let name: String
|
||||
let type: String
|
||||
let online: Bool
|
||||
let lastSeen: Double
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
// 引擎过桥推来的传输记录(对应 web engine/main.ts 的 toWire 精简视图)。ice* 为选中候选
|
||||
// 对诊断(揭示 P2P 是否实为 TURN 中继,解释慢速)。同样手动容忍解析。
|
||||
struct TransferItem: Identifiable, Equatable
|
||||
{
|
||||
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 }
|
||||
}
|
||||
|
||||
// 原生 ↔ 无头 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)
|
||||
|
||||
// 诊断信号(设置页展示):SSE 是否连上 / 是否在重连 + 累计收到的 presence 事件数 + 引擎
|
||||
// 最近一条 warn/error 日志。用来定位「设备空」卡在哪一环(未连接?401?静默挂起?)。
|
||||
var hubConnected = false
|
||||
var hubReconnecting = false
|
||||
var presenceCount = 0
|
||||
var lastEngineLog = ""
|
||||
|
||||
// 剪贴板 / 设备管理操作的瞬时反馈(设置页 / 设备页展示)。
|
||||
var clipboardStatus = ""
|
||||
var deviceActionStatus = ""
|
||||
|
||||
// 引擎页地址:须安全源(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)。由 app 在启动时接好(AppRoot.onAppear)。
|
||||
var auth: AuthManager?
|
||||
|
||||
private var webView: WKWebView?
|
||||
private let downloads = DownloadManager()
|
||||
|
||||
// 待发文件暂存:id → 安全作用域 URL(文件选择器选中的文件)。引擎经 cdrop-file://<id>
|
||||
// 回取字节(下方 WKURLSchemeHandler 供给)。
|
||||
private var outgoing: [String: URL] = [:]
|
||||
|
||||
override init()
|
||||
{
|
||||
super.init()
|
||||
}
|
||||
|
||||
// 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()
|
||||
return wv
|
||||
}
|
||||
|
||||
// 登出复位:通知引擎断开 SSE、丢弃 WebView 并清空 UI 态。下次登录时 makeWebView 以新
|
||||
// 会话重建(boot 注入读 auth.session 的最新值)。
|
||||
func reset()
|
||||
{
|
||||
sendCommand("shutdown", payload: [:])
|
||||
webView = nil
|
||||
devices = []
|
||||
transfers = []
|
||||
history = []
|
||||
status = t("ios.engine.disconnected")
|
||||
deviceName = ""
|
||||
}
|
||||
|
||||
// 收到的文件(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) }
|
||||
}
|
||||
|
||||
// 可发送目标:在线、且非本机(不发给自己)。发送 / 转发两处入口共用此过滤。
|
||||
func sendableDevices() -> [DeviceItem]
|
||||
{
|
||||
return devices.filter { $0.online && $0.name != deviceName }
|
||||
}
|
||||
|
||||
// 剪贴板上行:读本机 UIPasteboard 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。
|
||||
func uploadClipboard()
|
||||
{
|
||||
let content = UIPasteboard.general.string ?? ""
|
||||
guard !content.isEmpty else { clipboardStatus = t("ios.clipboard.empty"); return }
|
||||
clipboardStatus = t("ios.clipboard.uploading")
|
||||
sendCommand("clipboardUpload", payload: [ "content": content ])
|
||||
}
|
||||
|
||||
// 剪贴板下行:让引擎拉一次云剪贴板,回来写入 UIPasteboard(见 handleNotify "clipboard")。
|
||||
func pullClipboard()
|
||||
{
|
||||
clipboardStatus = t("ios.clipboard.pulling")
|
||||
sendCommand("clipboardPull", payload: [:])
|
||||
}
|
||||
|
||||
// 设备管理:移除 / 吊销一台设备。
|
||||
func revokeDevice(_ name: String)
|
||||
{
|
||||
deviceActionStatus = ""
|
||||
sendCommand("revokeDevice", payload: [ "name": name ])
|
||||
}
|
||||
|
||||
// 推命令给引擎(原生 → 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://<id> 引用 + 目标设备交给引擎发起传输。
|
||||
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,
|
||||
])
|
||||
}
|
||||
|
||||
// 暂存待发文件,返回引擎用的 cdrop-file://<id> 引用。文件选择器给的 URL 是安全作用域
|
||||
// 资源,需 start...Access 才能读。
|
||||
private func stageOutgoingFile(_ url: URL) -> String
|
||||
{
|
||||
let id = UUID().uuidString
|
||||
_ = url.startAccessingSecurityScopedResource()
|
||||
outgoing[id] = url
|
||||
return "cdrop-file://\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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")
|
||||
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) }
|
||||
}
|
||||
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) }
|
||||
}
|
||||
case "transferDone":
|
||||
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
|
||||
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
|
||||
{
|
||||
transfers.removeAll { $0.sessionId == item.sessionId }
|
||||
history.removeAll { $0.sessionId == item.sessionId }
|
||||
history.insert(item, at: 0)
|
||||
if history.count > 30 { history.removeLast(history.count - 30) }
|
||||
}
|
||||
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
|
||||
}
|
||||
case "clipboard":
|
||||
// 引擎拉回的云剪贴板内容 → 写入本机 UIPasteboard。
|
||||
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
|
||||
{
|
||||
UIPasteboard.general.string = content
|
||||
clipboardStatus = t("ios.clipboard.pulled")
|
||||
}
|
||||
else
|
||||
{
|
||||
clipboardStatus = t("ios.clipboard.empty")
|
||||
}
|
||||
case "clipboardUploaded":
|
||||
clipboardStatus = t("ios.clipboard.uploaded")
|
||||
case "deviceRevoked":
|
||||
deviceActionStatus = t("ios.devices.revoked")
|
||||
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" { clipboardStatus = t("ios.clipboard.failed") }
|
||||
else if stage == "revoke"
|
||||
{
|
||||
deviceActionStatus = msg == "step_up_required"
|
||||
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed")
|
||||
}
|
||||
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 "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)));")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 发送侧自定义 scheme(cdrop-file)
|
||||
|
||||
extension EngineController: WKURLSchemeHandler
|
||||
{
|
||||
// 发送侧:原生把暂存的待发文件经 cdrop-file://<id> 喂给引擎(JS fetch 它得到字节,
|
||||
// 见 PLAN §2 / R-iOS-4)。v1 整文件读入内存返回(小 / 中文件可行);大文件改分块
|
||||
// didReceive 流式以避 jetsam = R-iOS-4 优化点。
|
||||
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 data = try? Data(contentsOf: fileURL)
|
||||
else
|
||||
{
|
||||
respond(urlSchemeTask, status: 404)
|
||||
return
|
||||
}
|
||||
let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": "\(data.count)",
|
||||
])!
|
||||
urlSchemeTask.didReceive(resp)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
|
||||
{
|
||||
// 无长任务(v1 同步返回),无需处理。
|
||||
}
|
||||
|
||||
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 }
|
||||
return DeviceItem(name: name,
|
||||
type: type,
|
||||
online: boolOf(d["online"]),
|
||||
lastSeen: doubleOf(d["lastSeen"]))
|
||||
}
|
||||
|
||||
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"] ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
// 把 EngineController 持有的离屏 WKWebView 桥进 SwiftUI 视图树(保持存活)。无可见 UI
|
||||
// 用途——引擎由 controller 经 JS 桥驱动(见 ios/PLAN.md arch A)。
|
||||
struct EngineWebView: UIViewRepresentable
|
||||
{
|
||||
let controller: EngineController
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView
|
||||
{
|
||||
return controller.makeWebView()
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context)
|
||||
{
|
||||
// 无需更新。
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user