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:
2026-06-28 19:33:08 +08:00
parent f1a00d128e
commit 349d94aa8f
39 changed files with 2535 additions and 295 deletions
+227 -10
View File
@@ -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] = [:]
// libwebrtcSafari RTCPeerConnection WKWebView 256KB SCTP
// + desktop/NATIVE-TRANSFER.md §7 gomobile/pionpion 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
// 线 JSlibwebrtc 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
// resolveevaluateJavaScript 线errornil 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":
// / 退 OnStatelibwebrtc 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()
// #5outgoing
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