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
+169 -93
View File
@@ -1,66 +1,51 @@
import SwiftUI
// #13 + in-memory app
// web POST /api/messagehub SendTo 线
// 线线线
// ConversationView 线
// conversations() 线 /
// 线线 iOS APNs 线线 /
// 线+ /
struct MessagesView: View
{
@Environment(EngineController.self) private var engine
@State private var draft = ""
@State private var target = ""
@State private var showClearConfirm = false
// device_id
private var targets: [DeviceItem]
{
let selfID = engine.selfDeviceID
return engine.devices.filter
{ d in
if !selfID.isEmpty, !d.deviceID.isEmpty { return d.deviceID != selfID }
return d.name != engine.deviceName
}
}
// store
private var ordered: [MessageItem] { engine.messages.reversed() }
var body: some View
{
VStack(spacing: 0)
Group
{
if engine.messages.isEmpty
let convos = engine.conversations()
if convos.isEmpty
{
ContentUnavailableView(t("ios.messages.empty"), systemImage: "message")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
else
{
ScrollViewReader
{ proxy in
ScrollView
{
LazyVStack(alignment: .leading, spacing: 10)
List
{
ForEach(convos)
{ convo in
NavigationLink(value: convo.peerName)
{
ForEach(ordered)
{ msg in
messageRow(msg)
.id(msg.id)
.contextMenu
{
Button(role: .destructive) { engine.deleteMessage(msg.id) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
conversationRow(convo)
}
// +
.swipeActions(edge: .trailing)
{
Button(role: .destructive) { engine.deleteConversation(convo.peerName) }
label: { Label(t("ios.messages.deleteConversation"), systemImage: "trash") }
}
.contextMenu
{
Button(role: .destructive) { engine.deleteConversation(convo.peerName) }
label: { Label(t("ios.messages.deleteConversation"), systemImage: "trash") }
}
.padding()
}
.onChange(of: engine.messages.count) { scrollToLast(proxy) }
.onAppear { scrollToLast(proxy) }
}
.listStyle(.plain)
}
composeBar
}
.navigationTitle(t("ios.tab.messages"))
.navigationDestination(for: String.self) { peer in ConversationView(peerName: peer) }
.toolbar
{
if !engine.messages.isEmpty
@@ -77,12 +62,124 @@ struct MessagesView: View
Button(t("ios.records.clear"), role: .destructive) { engine.clearMessages() }
Button(t("common.cancel"), role: .cancel) { }
}
.onAppear { if target.isEmpty { target = targets.first?.name ?? "" } }
}
private func conversationRow(_ convo: ConversationItem) -> some View
{
HStack(spacing: 12)
{
Image(systemName: deviceSymbol(convo.deviceType))
.font(.oTitle3)
.foregroundStyle(convo.online ? Color.green : Color.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3)
{
Text(convo.peerName)
.font(.oBody)
.lineLimit(1)
Text(previewText(convo))
.font(.oCaption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
VStack(alignment: .trailing, spacing: 5)
{
if convo.lastAt > 0
{
Text(relativeTime(convo.lastAt))
.font(.oCaption2)
.foregroundStyle(.secondary)
}
// tab engine.unreadMessages
let unread = engine.unread(for: convo.peerName)
if unread > 0
{
Text("\(unread)")
.font(.oCaption2)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Color.cdropAccent, in: Capsule())
}
}
}
.padding(.vertical, 4)
}
//
private func previewText(_ convo: ConversationItem) -> String
{
if convo.lastText.isEmpty
{
return convo.reachable ? t("ios.messages.startHint") : t("ios.messages.viewOnly")
}
return convo.lastOutgoing ? "\(t("ios.messages.youPrefix"))\(convo.lastText)" : convo.lastText
}
}
// 线 + 线 /
// 线
struct ConversationView: View
{
@Environment(EngineController.self) private var engine
let peerName: String
@State private var draft = ""
private var thread: [MessageItem] { engine.thread(with: peerName) }
private var reachable: Bool { engine.canMessage(peerName) }
var body: some View
{
VStack(spacing: 0)
{
if thread.isEmpty
{
ContentUnavailableView(t("ios.messages.empty"), systemImage: "message")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
else
{
ScrollViewReader
{ proxy in
ScrollView
{
LazyVStack(alignment: .leading, spacing: 10)
{
ForEach(thread)
{ msg in
messageRow(msg)
.id(msg.id)
.contextMenu
{
Button(role: .destructive) { engine.deleteMessage(msg.id) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
}
.padding()
}
.onChange(of: thread.count) { scrollToLast(proxy) }
.onAppear { scrollToLast(proxy) }
}
}
composeBar
}
.navigationTitle(peerName)
.navigationBarTitleDisplayMode(.inline)
// +
.onAppear
{
engine.setActiveConversation(peerName)
engine.markConversationRead(peerName)
}
.onDisappear { engine.setActiveConversation(nil) }
}
private func scrollToLast(_ proxy: ScrollViewProxy)
{
guard let last = ordered.last else { return }
guard let last = thread.last else { return }
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
@@ -92,18 +189,12 @@ struct MessagesView: View
return HStack
{
if outgoing { Spacer(minLength: 48) }
VStack(alignment: outgoing ? .trailing : .leading, spacing: 2)
{
Text(msg.peerName)
.font(.caption2)
.foregroundStyle(.secondary)
Text(msg.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(outgoing ? Color.cdropAccent.opacity(0.18) : Color(.secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 14))
.textSelection(.enabled)
}
Text(msg.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(outgoing ? Color.cdropAccent.opacity(0.18) : Color(.secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 14))
.textSelection(.enabled)
if !outgoing { Spacer(minLength: 48) }
}
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
@@ -112,53 +203,38 @@ struct MessagesView: View
@ViewBuilder
private var composeBar: some View
{
VStack(spacing: 6)
if reachable
{
if targets.isEmpty
HStack(spacing: 10)
{
Text(t("ios.messages.noPeers"))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
else
{
HStack(spacing: 10)
{
Menu
{
ForEach(targets)
{ dev in
Button(dev.name) { target = dev.name }
}
}
label:
{
Label(target.isEmpty ? t("ios.send.pickDevice") : target,
systemImage: "chevron.up.chevron.down")
.font(.caption)
.lineLimit(1)
}
TextField(t("ios.messages.placeholder"), text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.submitLabel(.send)
.onSubmit { send() }
Button { send() }
label: { Image(systemName: "paperplane.fill").font(.title3) }
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || target.isEmpty)
}
TextField(t("ios.messages.placeholder"), text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1 ... 4)
.submitLabel(.send)
.onSubmit { send() }
Button { send() }
label: { Image(systemName: "paperplane.fill").font(.oTitle3) }
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding()
.background(.bar)
}
else
{
Text(t("ios.messages.viewOnly"))
.font(.oCaption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
.background(.bar)
}
.padding()
.background(.bar)
}
private func send()
{
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !target.isEmpty else { return }
engine.sendMessage(to: target, text: text)
guard !text.isEmpty else { return }
engine.sendMessage(to: peerName, text: text)
draft = ""
}
}