iOS 原生客户端:libwebrtc 数据面 + UX 改造 + 品牌/字体 + 全平台平均速度
- 原生数据面 libwebrtc(PLAN 决策 F):新增 Engine/LibWebRtcEngine.swift(RTCPeerConnection,端口 engine.go/session.go 线协议 + 水位背压 + ack 完成 + 不冲突落盘);NativeTransfer.swift 的 P2PEventsBridge 改 conform P2PEngineDelegate(去 gomobile);EngineController 4 个 p2p RPC 改调新引擎(completion-based + resolveOnMain)。stasel/WebRTC M149 经 SPM(exactVersion 149.0.0)。环回端到端 20MB 逐字节测过(Tests/) - UX 改造(PARITY 残余项 1/2):新增 SendComposeSheet(发送预览=文件名 + 选设备,手动多选与分享共用);分享直入预览 + scenePhase .active 兜底(修“分享后必须重启 app”);重写 MessagesView(按设备会话 + 每设备未读红点 + 删单条 / 删整设备历史);EngineController 加 conversations / unread 模型 - 品牌 logo:新增 Shared/Brand.swift(BrandMark / Wordmark / BrandLockup / BrandHero)+ Brand.xcassets/LogoMark;登录页 / 传输空态 / 设置头 / 控制中心控件接 logo;Theme 加 cdropAmber - 字体(对齐 web,仅主 app):新增 BrandFonts.swift(Orbit Gothic + Maple Mono woff2 运行期 CTFontManager 注册 + UIKit 外观代理 + .oXxx / .maple 助手);Fredoka SemiBold 字标;7 视图 .font sweep。字体不入库——.gitignore 排除 + just ios-fonts 构建前从 CDN / Google Fonts 拉取,见 Sources/Fonts/README.md - 分享引导:新增 Share/ShareGuideView(吉祥物卡 + “打开 Commilitia Drop”);ShareViewController 健壮 staging(文件 / 数据双路)+ 响应链 IMP 唤起宿主 - 全平台平均速度:传输完成态显平均速度(总字节 / 总耗时)而非末次瞬时——web store/slices/transfer.ts + TransferRow.tsx + iOS RootView 卡片 / 详情;i18n 加 transfer.avgRate - 工程:project.yml 接 SPM WebRTC + CDropTests + Fredoka UIAppFonts + Share i18n;Justfile 加 ios-sim-build + ios-fonts / ios-fonts-fredoka 配方 - 文档:ios/PLAN.md(决策 F + §10 UX + §11 品牌/字体 + §12 平均速度)、ios/PARITY.md(字体分叉条目更新 + 2026-06-28 漂移行)
This commit is contained in:
@@ -30,6 +30,23 @@ struct MessageItem: Identifiable, Equatable, Codable
|
||||
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
|
||||
@@ -68,6 +85,24 @@ struct LogEntry: Identifiable, Equatable
|
||||
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=)后,经引擎
|
||||
@@ -117,10 +152,13 @@ final class EngineController: NSObject
|
||||
// 待确认的他端登录请求(#8):扫码拿到信息后置位,驱动设备页弹批准确认;批准 / 拒绝 / 取消后清空。
|
||||
var pendingLoginRequest: LoginRequestItem?
|
||||
|
||||
// 未读红点(#5):消息 / 传输各自的未读计数,对应 tab 激活时清零。activeTab 记录当前所在
|
||||
// 标签,使「正在该页时到达的事件」不计未读。seenIncomingTransfers 去重,避免同一传入传输在
|
||||
// 活跃→完成两阶段被重复计数。
|
||||
var unreadMessages = 0
|
||||
// 未读红点(#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> = []
|
||||
@@ -155,6 +193,14 @@ final class EngineController: NSObject
|
||||
// 回取字节(下方 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
|
||||
@@ -276,7 +322,8 @@ final class EngineController: NSObject
|
||||
// 登出清空瞬时态:通知 / 日志 / 未读 / 已读传输集 / 待批准登录,不跨账号残留。
|
||||
notices = []
|
||||
logs = []
|
||||
unreadMessages = 0
|
||||
unreadByPeer = [:]
|
||||
activeConversation = nil
|
||||
unreadTransfers = 0
|
||||
seenIncomingTransfers = []
|
||||
activeTab = "transfer"
|
||||
@@ -307,7 +354,11 @@ final class EngineController: NSObject
|
||||
func clearHistory()
|
||||
{
|
||||
history.removeAll()
|
||||
RecordsStore.clear(recordKey("history"))
|
||||
// 落空数组到分桶键(而非删文件):activateRecords 在分桶键缺失时回退读 legacy 未分桶文件,
|
||||
// 直接删文件会让旧记录重启复活(清空按钮失效的根因);写 [] 使 load 返回空数组、阻断回退。
|
||||
// 顺手清掉 legacy 未分桶文件。
|
||||
persistHistory()
|
||||
RecordsStore.clear("history")
|
||||
}
|
||||
|
||||
// 删除一条消息(H)/ 清空全部(持久化同步)。
|
||||
@@ -320,7 +371,18 @@ final class EngineController: NSObject
|
||||
func clearMessages()
|
||||
{
|
||||
messages.removeAll()
|
||||
RecordsStore.clear(recordKey("messages"))
|
||||
// 同 clearHistory:落空数组阻断 legacy 回退(删文件会令清空后的消息重启复活),再清 legacy 文件。
|
||||
persistMessages()
|
||||
RecordsStore.clear("messages")
|
||||
}
|
||||
|
||||
// 删除与某设备的整段会话历史(按对端设备名):清掉该设备的全部消息,避免废会话 / 历史残留堆积。
|
||||
// 仅删本地展示记录(持久化同步),不影响对方。删空后该会话从列表消失(除非该设备当前可达,
|
||||
// 仍作「可发起新会话」项保留)。
|
||||
func deleteConversation(_ peerName: String)
|
||||
{
|
||||
messages.removeAll { $0.peerName == peerName }
|
||||
persistMessages()
|
||||
}
|
||||
|
||||
// ---- 瞬时通知(#2) ----
|
||||
@@ -350,10 +412,28 @@ final class EngineController: NSObject
|
||||
func setActiveTab(_ tab: String)
|
||||
{
|
||||
activeTab = tab
|
||||
if tab == "messages" { unreadMessages = 0 }
|
||||
// 消息未读不在进入 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)
|
||||
@@ -405,6 +485,57 @@ final class EngineController: NSObject
|
||||
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()
|
||||
{
|
||||
@@ -617,6 +748,54 @@ final class EngineController: NSObject
|
||||
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
|
||||
}
|
||||
|
||||
// 把 cdrop-file://<id> 引用解析回暂存文件的沙盒绝对路径(Go 据此直接读盘发送)。安全作用域
|
||||
// 访问已在 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 + 通知)
|
||||
@@ -669,6 +848,40 @@ extension EngineController: WKScriptMessageHandler
|
||||
start: Self.intOf(payload["start"]),
|
||||
end: Self.intOf(payload["end"]))
|
||||
resolve(id: id, ok: true, value: b64)
|
||||
case "p2pStartOutgoing":
|
||||
// 发起原生发送:filePath 为 cdrop-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)")
|
||||
}
|
||||
@@ -729,8 +942,12 @@ extension EngineController: WKScriptMessageHandler
|
||||
messages.removeLast(messages.count - Self.messagesCap)
|
||||
}
|
||||
persistMessages()
|
||||
// 新到的对端消息:不在消息页时计未读红点(#5)。本机发出的乐观回显(outgoing)不计。
|
||||
if isNew, item.direction == "incoming", activeTab != "messages" { unreadMessages += 1 }
|
||||
// 新到的对端消息:非「正在该设备会话中」即给该设备计未读(按设备分桶)。本机发出的
|
||||
// 乐观回显(outgoing)不计。
|
||||
if isNew, item.direction == "incoming", item.peerName != activeConversation
|
||||
{
|
||||
unreadByPeer[item.peerName, default: 0] += 1
|
||||
}
|
||||
}
|
||||
case "transferDone":
|
||||
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
|
||||
|
||||
Reference in New Issue
Block a user