1355 lines
63 KiB
Swift
1355 lines
63 KiB
Swift
import Foundation
|
||
import Observation
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#elseif canImport(AppKit)
|
||
import AppKit
|
||
#endif
|
||
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
|
||
}
|
||
|
||
// 会话摘要(消息按设备聚合,社交软件式):每个对端设备一条,驱动消息 tab 的会话列表。由
|
||
// conversations() 从 messages(取每设备最新一条)∪ 当前可达设备(在线 / 可唤醒,供发起新会话)合成。
|
||
// reachable=在线或可经 APNs 唤醒(离线 iOS)——决定 ConversationView 是否可发:不可达的离线设备
|
||
// (离线桌面 / 浏览器)仅可查看历史、不可发(对齐 sendableDevices 语义)。
|
||
struct ConversationItem: Identifiable, Equatable
|
||
{
|
||
let peerName: String
|
||
let deviceID: String // 匹配到的当前设备 id(用于图标 / 判型);无匹配(历史残留设备)为空
|
||
let deviceType: String
|
||
let online: Bool
|
||
let reachable: Bool // 在线或可唤醒 → 可发;否则仅查看
|
||
let lastText: String
|
||
let lastAt: Double // 最近一条消息时间(epoch 秒);无历史(仅可达)为 0,排在有历史之后
|
||
let lastOutgoing: Bool
|
||
var id: String { peerName }
|
||
}
|
||
|
||
// 引擎过桥推来的传输记录(对应 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
|
||
// 从 "session <id> ..." 形式的诊断行解析出的传输会话 id(引擎诊断行 + pion 内部日志统一此格式),
|
||
// 供日志界面按会话聚合 / 整体复制;非会话日志(SSE / 鉴权等)为 nil。
|
||
let sessionId: String?
|
||
|
||
init(level: String, message: String, at: Date)
|
||
{
|
||
self.level = level
|
||
self.message = message
|
||
self.at = at
|
||
self.sessionId = LogEntry.parseSession(message)
|
||
}
|
||
|
||
static func parseSession(_ msg: String) -> String?
|
||
{
|
||
guard msg.hasPrefix("session ") else { return nil }
|
||
let id = msg.dropFirst("session ".count).prefix { !$0.isWhitespace }
|
||
return id.isEmpty ? nil : String(id)
|
||
}
|
||
}
|
||
|
||
// 待批准的他端登录请求(#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 去重,避免同一传入传输在活跃→完成两阶段被重复计数。
|
||
// 消息未读按设备分桶(unreadByPeer):每设备各自未读计数(会话列表逐行红点),消息 tab 徽标=总和
|
||
// (unreadMessages 计算属性);activeConversation 记录当前打开的会话,使「正在该会话时到达的消息」不计未读。
|
||
var unreadByPeer: [String: Int] = [:]
|
||
var activeConversation: String?
|
||
var unreadMessages: Int { unreadByPeer.values.reduce(0, +) }
|
||
var unreadTransfers = 0
|
||
var activeTab = "transfer"
|
||
private var seenIncomingTransfers: Set<String> = []
|
||
|
||
// 引擎 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。引擎经 commilitia-drop-file://<id>
|
||
// 回取字节(下方 WKURLSchemeHandler 供给)。
|
||
private var outgoing: [String: URL] = [:]
|
||
|
||
// 原生数据面引擎(libwebrtc,Safari 同款 RTCPeerConnection):逃离 WKWebView 写死的 256KB SCTP
|
||
// 接收窗 + 渲染器节流(见 desktop/NATIVE-TRANSFER.md §7)。取代早先 gomobile/pion——pion 的 Go raw
|
||
// socket 不与 iOS Network framework 集成,真机确证不可靠。懒构造(首次 P2P RPC 时建),落盘=
|
||
// Documents(与 DownloadManager 同源,故收到的文件经「收到的文件」视图可见)。p2pEvents 强持引用
|
||
// (引擎只弱引它),避免回调宿主被释放。libwebrtc 自管线程(信令 / 网络线程),无需宿主串行队列。
|
||
private var transferEngine: LibWebRtcEngine?
|
||
private var p2pEvents: P2PEventsBridge?
|
||
|
||
// 历史 / 消息限长(原生持久化,跨重启存活)。
|
||
private static let historyCap = 50
|
||
private static let messagesCap = 200
|
||
|
||
// 当前记录归属用户:传输历史 / 消息 / 设备快照按登录用户分桶持久化(文件名带 userId,见 recordKey)。
|
||
// 这样会话失效(authExpired / 被吊销)只清内存、不毁盘,同一用户重登即恢复;换账号登录各读各的桶,
|
||
// 天然防跨账号泄露。空串=尚未激活会话(事件先于激活到达时跳过落盘,见 persist*)。
|
||
private var currentRecordUser = ""
|
||
|
||
override init()
|
||
{
|
||
super.init()
|
||
PushRegistry.shared.engine = self
|
||
// 记录加载推迟到 activateRecords(会话激活时,userId 已知才知道读哪个桶)。init 阶段
|
||
// auth 尚未注入、无从分桶,故此处不再加载——activateRecords 由 AppRoot 在登录 / 冷启已有
|
||
// 会话时即时调用,加载延迟可忽略。
|
||
}
|
||
|
||
// 会话激活:登录完成 / 冷启已有会话时按 userId 加载该用户的持久记录到内存。reset() 只清内存不动
|
||
// 磁盘,故隔夜被 authExpired 清空视图后,同一用户重登经此重新读盘即恢复历史 / 消息。幂等:重复激活
|
||
// 仅重读(内存与磁盘一致,无副作用)。legacy 回退:旧版本无分桶的 "history"/"messages"/"devices"
|
||
// 文件首次激活时一并采纳(随后落盘即写入分桶键),避免升级丢失既有记录。
|
||
func activateRecords(userId: String)
|
||
{
|
||
guard !userId.isEmpty else { return }
|
||
currentRecordUser = userId
|
||
history = RecordsStore.load([TransferItem].self, recordKey("history"))
|
||
?? RecordsStore.load([TransferItem].self, "history") ?? []
|
||
messages = RecordsStore.load([MessageItem].self, recordKey("messages"))
|
||
?? RecordsStore.load([MessageItem].self, "messages") ?? []
|
||
devices = RecordsStore.load([DeviceItem].self, recordKey("devices"))
|
||
?? RecordsStore.load([DeviceItem].self, "devices") ?? []
|
||
}
|
||
|
||
// 分桶文件键:userId(broker subject,通常即 UUID 式安全字符)拼到记录名前。保险起见把非
|
||
// 字母数字 / - / _ 的字符替换为 _,确保落地为合法文件名。
|
||
private func recordKey(_ name: String) -> String
|
||
{
|
||
let safe = String(currentRecordUser.map
|
||
{ ch in
|
||
(ch.isLetter || ch.isNumber || ch == "-" || ch == "_") ? ch : "_"
|
||
})
|
||
return "\(safe)-\(name)"
|
||
}
|
||
|
||
// 落盘助手:仅在已激活会话(currentRecordUser 非空)时写当前用户的桶。未激活时跳过,避免把
|
||
// 增量事件写进 "-name" 的无主桶。
|
||
private func persistHistory()
|
||
{
|
||
guard !currentRecordUser.isEmpty else { return }
|
||
RecordsStore.save(history, recordKey("history"))
|
||
}
|
||
|
||
private func persistMessages()
|
||
{
|
||
guard !currentRecordUser.isEmpty else { return }
|
||
RecordsStore.save(messages, recordKey("messages"))
|
||
}
|
||
|
||
private func persistDevices()
|
||
{
|
||
guard !currentRecordUser.isEmpty else { return }
|
||
RecordsStore.save(devices, recordKey("devices"))
|
||
}
|
||
|
||
// makeWebView:构建离屏 WebView——注入 __CDROP_BOOT__(device_type:"ios")、注册消息
|
||
// 处理器、注册 commilitia-drop-file 自定义 scheme(发送侧给 JS 喂文件字节)。幂等。
|
||
func makeWebView() -> WKWebView
|
||
{
|
||
if let existing = webView { return existing }
|
||
|
||
let ucc = WKUserContentController()
|
||
ucc.add(self, name: "cdropEngine")
|
||
|
||
// boot 注入:把登录后的真实会话注入引擎(无会话则 null)。device_type 标平台供后端登记 /
|
||
// 其他端设备图标 + isWakeable 推送判定;api_base 默认空(引擎与 /api 同源,从 prod 加载时相对
|
||
// URL 即可)。iOS 为 "ios",原生 Mac 为 "macos"(与桌面 Wails 客户端一致,后端 / 各端已识别)。
|
||
let debug = ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1"
|
||
#if os(macOS)
|
||
let deviceType = "macos"
|
||
#else
|
||
let deviceType = "ios"
|
||
#endif
|
||
let boot = "window.__CDROP_BOOT__ = { session: \(sessionJSON()), "
|
||
+ "device_name: \(jsString(currentDeviceName())), "
|
||
+ "api_base: \(jsString(apiBaseForBoot())), device_type: \(jsString(deviceType)), debug: \(debug) };"
|
||
ucc.addUserScript(WKUserScript(source: boot,
|
||
injectionTime: .atDocumentStart,
|
||
forMainFrameOnly: true))
|
||
|
||
let cfg = WKWebViewConfiguration()
|
||
cfg.userContentController = ucc
|
||
cfg.setURLSchemeHandler(self, forURLScheme: "commilitia-drop-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 = []
|
||
unreadByPeer = [:]
|
||
activeConversation = nil
|
||
unreadTransfers = 0
|
||
seenIncomingTransfers = []
|
||
activeTab = "transfer"
|
||
pendingLoginRequest = nil
|
||
// 不再清磁盘记录:传输历史 / 消息是用户数据,会话失效(authExpired / 被吊销)属凭证层事件,
|
||
// 不应连用户数据一起销毁——否则隔夜令牌过期一次就抹掉全部历史(#3 根因)。这里只断开会话归属
|
||
// (置空 currentRecordUser),磁盘按 userId 分桶留存:同一用户重登经 activateRecords 即恢复,
|
||
// 换账号登录各读各的桶不会串。要彻底清除走设置页登出后用记录页 / 消息页的「清空」(用户主动)。
|
||
currentRecordUser = ""
|
||
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 }
|
||
persistHistory()
|
||
}
|
||
|
||
func clearHistory()
|
||
{
|
||
history.removeAll()
|
||
// 落空数组到分桶键(而非删文件):activateRecords 在分桶键缺失时回退读 legacy 未分桶文件,
|
||
// 直接删文件会让旧记录重启复活(清空按钮失效的根因);写 [] 使 load 返回空数组、阻断回退。
|
||
// 顺手清掉 legacy 未分桶文件。
|
||
persistHistory()
|
||
RecordsStore.clear("history")
|
||
}
|
||
|
||
// 删除一条消息(H)/ 清空全部(持久化同步)。
|
||
func deleteMessage(_ id: String)
|
||
{
|
||
messages.removeAll { $0.id == id }
|
||
persistMessages()
|
||
}
|
||
|
||
func clearMessages()
|
||
{
|
||
messages.removeAll()
|
||
// 同 clearHistory:落空数组阻断 legacy 回退(删文件会令清空后的消息重启复活),再清 legacy 文件。
|
||
persistMessages()
|
||
RecordsStore.clear("messages")
|
||
}
|
||
|
||
// 删除与某设备的整段会话历史(按对端设备名):清掉该设备的全部消息,避免废会话 / 历史残留堆积。
|
||
// 仅删本地展示记录(持久化同步),不影响对方。删空后该会话从列表消失(除非该设备当前可达,
|
||
// 仍作「可发起新会话」项保留)。
|
||
func deleteConversation(_ peerName: String)
|
||
{
|
||
messages.removeAll { $0.peerName == peerName }
|
||
persistMessages()
|
||
}
|
||
|
||
// ---- 瞬时通知(#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
|
||
// 消息未读不在进入 tab 时清零(那只是看到会话列表、尚未读各设备消息)——改为打开某会话时清该
|
||
// 设备未读(见 markConversationRead)。传输未读仍按 tab 激活清零。
|
||
if tab == "transfer" { unreadTransfers = 0 }
|
||
}
|
||
|
||
// 打开某设备会话(ConversationView.onAppear):记录当前会话 + 清该设备未读;关闭(onDisappear)置 nil。
|
||
func setActiveConversation(_ peerName: String?)
|
||
{
|
||
activeConversation = peerName
|
||
}
|
||
|
||
func markConversationRead(_ peerName: String)
|
||
{
|
||
if unreadByPeer[peerName] != nil { unreadByPeer[peerName] = 0 }
|
||
}
|
||
|
||
// 某设备的未读消息数(会话列表行红点用)。
|
||
func unread(for peerName: String) -> Int
|
||
{
|
||
return unreadByPeer[peerName] ?? 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
|
||
}
|
||
|
||
// ---- 消息按设备聚合(社交软件式会话) ----
|
||
|
||
// 会话列表:每个对端设备一条。取「有消息历史的设备」∪「当前可达设备(在线 / 可唤醒,供发起新
|
||
// 会话)」,匹配当前 presence 取设备型号 / 在线态。按最近消息时间降序(无历史的可达设备 lastAt=0
|
||
// 排在最后),最近活跃的会话在最前。
|
||
func conversations() -> [ConversationItem]
|
||
{
|
||
// 每个对端的最新一条消息(messages 最新在前,故首次见到即最新)。
|
||
var latest: [String: MessageItem] = [:]
|
||
for m in messages where latest[m.peerName] == nil { latest[m.peerName] = m }
|
||
|
||
let reachable = sendableDevices()
|
||
let reachableNames = Set(reachable.map { $0.name })
|
||
|
||
// 候选对端:有历史的 ∪ 当前可达的(去重)。
|
||
var peers = Set(latest.keys)
|
||
peers.formUnion(reachableNames)
|
||
peers.remove(deviceName) // 不与本机会话
|
||
|
||
var items: [ConversationItem] = []
|
||
for peer in peers
|
||
{
|
||
let dev = devices.first { $0.name == peer }
|
||
let last = latest[peer]
|
||
items.append(ConversationItem(
|
||
peerName: peer,
|
||
deviceID: dev?.deviceID ?? "",
|
||
deviceType: dev?.type ?? "",
|
||
online: dev?.online ?? false,
|
||
reachable: reachableNames.contains(peer),
|
||
lastText: last?.text ?? "",
|
||
lastAt: last?.sentAt ?? 0,
|
||
lastOutgoing: last?.direction == "outgoing"))
|
||
}
|
||
// 最近消息在前;时间相同(多为均无历史)按名稳定排序。
|
||
return items.sorted { a, b in a.lastAt != b.lastAt ? a.lastAt > b.lastAt : a.peerName < b.peerName }
|
||
}
|
||
|
||
// 与某设备的消息线程(最旧→最新,供聊天视图自上而下渲染)。
|
||
func thread(with peerName: String) -> [MessageItem]
|
||
{
|
||
return messages.filter { $0.peerName == peerName }.sorted { $0.sentAt < $1.sentAt }
|
||
}
|
||
|
||
// 某对端当前是否可发消息:在线或可经 APNs 唤醒(=在 sendableDevices 中)。不可达的离线设备
|
||
// (离线桌面 / 浏览器)会话仅可查看历史、撰写栏禁用。
|
||
func canMessage(_ peerName: String) -> Bool
|
||
{
|
||
return sendableDevices().contains { $0.name == peerName }
|
||
}
|
||
|
||
// 剪贴板上行:读本机 UIPasteboard 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。
|
||
func uploadClipboard()
|
||
{
|
||
#if os(macOS)
|
||
let content = NSPasteboard.general.string(forType: .string) ?? ""
|
||
#else
|
||
let content = UIPasteboard.general.string ?? ""
|
||
#endif
|
||
guard !content.isEmpty else { notify(t("ios.clipboard.empty"), kind: .info); return }
|
||
sendCommand("clipboardUpload", payload: [ "content": content ])
|
||
}
|
||
|
||
// 指定文本上传云剪贴板(供 Mac 剪贴板自动同步 monitor 直传已解析文本,绕过再读 pasteboard——
|
||
// 避免富文本 Handoff 暂存路径等边角;静默,不弹空提示)。
|
||
func uploadClipboardText(_ content: String)
|
||
{
|
||
guard !content.isEmpty else { 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:暂存选中文件、把 commilitia-drop-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,
|
||
])
|
||
}
|
||
|
||
// Share Extension 经 App Group 收件箱交接来的待发文件(经 commilitia-drop://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 取字节,再落
|
||
// 一个临时文件,才能复用既有 commilitia-drop-file scheme 发送链路。
|
||
// 返回临时文件 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 }
|
||
}
|
||
|
||
// 暂存待发文件,返回引擎用的 commilitia-drop-file://<id> 引用。
|
||
// 资源,需 start...Access 才能读。
|
||
private func stageOutgoingFile(_ url: URL) -> String
|
||
{
|
||
let id = UUID().uuidString
|
||
_ = url.startAccessingSecurityScopedResource()
|
||
outgoing[id] = url
|
||
return "commilitia-drop-file://\(id)"
|
||
}
|
||
|
||
// 发送侧按 [start,end) seek 读暂存的待发文件、回 base64(供 bridgeFileSource 经桥取片)。
|
||
// url 形如 commilitia-drop-file://<id>,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()
|
||
}
|
||
|
||
// ---- 原生数据面(gomobile/pion)----
|
||
|
||
// 懒构造原生引擎:落盘目录=Documents(与 DownloadManager 同源),事件经 P2PEventsBridge
|
||
// 跳回主线程过桥给 JS。libwebrtc 工厂构造不会失败(连接级失败按会话经 RPC error 上报)。
|
||
private func nativeTransferEngine() -> LibWebRtcEngine
|
||
{
|
||
if let e = transferEngine { return e }
|
||
let events = P2PEventsBridge()
|
||
events.controller = self
|
||
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].path
|
||
let e = LibWebRtcEngine(downloadDir: dir, delegate: events)
|
||
p2pEvents = events
|
||
transferEngine = e
|
||
return e
|
||
}
|
||
|
||
// 把 commilitia-drop-file://<id> 引用解析回暂存文件的沙盒绝对路径。
|
||
// 访问已在 stageOutgoingFile 开启、持续到 reset,故同进程的 Go 可直接 os.Open 该路径。无对应
|
||
// 暂存项返回空串。
|
||
private func resolveOutgoingPath(_ ref: String) -> String
|
||
{
|
||
guard let id = URL(string: ref)?.host, let url = outgoing[id] else { return "" }
|
||
return url.path
|
||
}
|
||
|
||
// resolveOnMain 把引擎完成回调(在 libwebrtc 信令线程触发,非主线程)跳回主线程 resolve 该 RPC。
|
||
// nonisolated 使其可从任意线程调用;内部经 DispatchQueue.main + assumeIsolated 进 @MainActor 调
|
||
// resolve(evaluateJavaScript 仅限主线程)。error≠nil → ok=false(该会话回退中继 / 报错)。
|
||
nonisolated private func resolveOnMain(id: Int, error: Error?)
|
||
{
|
||
DispatchQueue.main.async
|
||
{
|
||
MainActor.assumeIsolated
|
||
{
|
||
self.resolve(id: id, ok: error == nil, value: error?.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 原生引擎诊断日志入环形缓冲(#3 日志界面)。由 P2PEventsBridge.onLog 在主线程调用。
|
||
func appendNativeLog(_ msg: String)
|
||
{
|
||
guard !msg.isEmpty else { return }
|
||
lastEngineLog = msg
|
||
logs.insert(LogEntry(level: "info", message: msg, at: Date()), at: 0)
|
||
if logs.count > Self.logsCap { logs.removeLast(logs.count - Self.logsCap) }
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
case "p2pStartOutgoing":
|
||
// 发起原生发送:filePath 为 commilitia-drop-file://<id>,解析回沙盒绝对路径。
|
||
// 调用立即返回(libwebrtc 异步),completion 在 offer 已铸+发出后回,经 resolveOnMain 回交。
|
||
let engine = nativeTransferEngine()
|
||
let path = resolveOutgoingPath(payload["filePath"] as? String ?? "")
|
||
guard !path.isEmpty
|
||
else { resolve(id: id, ok: false, value: "no staged file"); return }
|
||
let sid = payload["sessionId"] as? String ?? ""
|
||
let peer = payload["peerName"] as? String ?? ""
|
||
let ice = payload["iceServersJSON"] as? String ?? ""
|
||
engine.startOutgoing(sessionId: sid, peerName: peer, filePath: path, iceServersJSON: ice)
|
||
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
|
||
case "p2pStartIncoming":
|
||
// 预备原生接收:建 PeerConnection 等对端 offer,收到即直接写盘到 Documents。
|
||
let engine = nativeTransferEngine()
|
||
let sid = payload["sessionId"] as? String ?? ""
|
||
let peer = payload["peerName"] as? String ?? ""
|
||
let ice = payload["iceServersJSON"] as? String ?? ""
|
||
engine.startIncoming(sessionId: sid, peerName: peer, iceServersJSON: ice)
|
||
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
|
||
case "p2pHandleSignal":
|
||
// 把 JS hub 从 SSE 收到的入站信令喂给原生引擎(按对端设备名路由)。completion 在信令应用
|
||
// 完成后回——JS 侧信令链据此按序投递(offer/answer 先于其后 trickle 候选,见 p2pIos.ts chain)。
|
||
guard let engine = transferEngine
|
||
else { resolve(id: id, ok: false, value: "no native session"); return }
|
||
let from = payload["fromPeer"] as? String ?? ""
|
||
let pj = payload["payloadJSON"] as? String ?? ""
|
||
engine.handleSignal(fromPeer: from, payloadJSON: pj)
|
||
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
|
||
case "p2pCancel":
|
||
// 取消 / 中继回退:拆除原生会话(静默,不回 OnState)。libwebrtc close 快,直接调,立即 resolve。
|
||
let sid = payload["sessionId"] as? String ?? ""
|
||
transferEngine?.cancel(sessionId: sid)
|
||
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")
|
||
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) }
|
||
persistDevices() // 持久化 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)
|
||
}
|
||
persistMessages()
|
||
// 新到的对端消息:非「正在该设备会话中」即给该设备计未读(按设备分桶)。本机发出的
|
||
// 乐观回显(outgoing)不计。
|
||
if isNew, item.direction == "incoming", item.peerName != activeConversation
|
||
{
|
||
unreadByPeer[item.peerName, default: 0] += 1
|
||
#if os(macOS)
|
||
// 窗口非前台时发系统通知(对齐桌面);前台由应用内未读红点负责。
|
||
MacNotifier.notifyIfInactive(title: item.peerName, body: item.text)
|
||
#endif
|
||
}
|
||
}
|
||
case "transferDone":
|
||
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
|
||
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
|
||
{
|
||
noteIncomingTransfer(item) // 极快传输可能跳过活跃阶段,这里补计未读(#5,已 seen 则不重复)
|
||
#if os(macOS)
|
||
// 窗口非前台时,收到文件发系统通知(对齐桌面)。
|
||
if item.direction == "incoming", item.state == "completed"
|
||
{
|
||
MacNotifier.notifyIfInactive(title: "收到文件", body: item.fileName)
|
||
}
|
||
#endif
|
||
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()
|
||
persistHistory() // 持久化(按用户分桶),跨重启存活
|
||
}
|
||
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":
|
||
// 云剪贴板内容 → 写入本机剪贴板。auto=true 为远端自动下行(JS 在 isIOSShell 时对 clipboard:update
|
||
// 附加):iOS 不自动写系统剪贴板(后台受限 + 隐私,仍走控制中心手动拉取),故忽略 auto;macOS 写入
|
||
// 且不弹提示(避免每次远端变化刷屏)。auto=false 为用户手动拉取:两端都写并提示。
|
||
let clip = payload as? [String: Any]
|
||
let clipContent = clip?["content"] as? String ?? ""
|
||
let clipAuto = (clip?["auto"] as? Bool) ?? false
|
||
if !clipContent.isEmpty
|
||
{
|
||
#if os(macOS)
|
||
// 先武装自写守卫(令自动同步 monitor 认出刚写入的内容为自身回声、不再上传),再写。
|
||
MacClipboardMonitor.shared.armSelfWrite(clipContent)
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(clipContent, forType: .string)
|
||
if !clipAuto { notify(t("ios.clipboard.pulled"), kind: .success) }
|
||
#else
|
||
if !clipAuto
|
||
{
|
||
UIPasteboard.general.string = clipContent
|
||
notify(t("ios.clipboard.pulled"), kind: .success)
|
||
}
|
||
#endif
|
||
}
|
||
else if !clipAuto
|
||
{
|
||
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(commilitia-drop-file)
|
||
|
||
extension EngineController: WKURLSchemeHandler
|
||
{
|
||
// 发送侧:原生把暂存文件经 commilitia-drop-file://<id> 喂给引擎。
|
||
// 见 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<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)
|
||
{
|
||
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"] ?? ""
|
||
}
|
||
}
|