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
+109 -60
View File
@@ -76,22 +76,20 @@ struct RootView: View
.opacity(0)
.allowsHitTesting(false)
}
// Share Extension cdrop://share
// Share Extension cdrop://share /
// SendComposeSheetonSend sendShares
// move pendingShareFiles
.onAppear { engine.loadPendingShares() }
.confirmationDialog(
t("ios.share.title", [ "n": String(engine.pendingShareFiles.count) ]),
isPresented: Binding(
get: { !engine.pendingShareFiles.isEmpty },
set: { if !$0 { engine.dismissShares() } }
),
titleVisibility: .visible
)
.sheet(isPresented: Binding(
get: { !engine.pendingShareFiles.isEmpty },
set: { if !$0 { engine.dismissShares() } }))
{
ForEach(engine.sendableDevices())
{ dev in
Button(engine.sendLabel(dev)) { engine.sendShares(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { engine.dismissShares() }
SendComposeSheet(
files: engine.pendingShareFiles.map { PreviewFile(name: $0.lastPathComponent, size: fileByteSize($0)) },
devices: engine.sendableDevices(),
sendLabel: engine.sendLabel,
onSend: { device in engine.sendShares(to: device) },
onCancel: { engine.dismissShares() })
}
}
}
@@ -101,9 +99,11 @@ struct TransferListView: View
@Environment(EngineController.self) private var engine
@State private var showImporter = false
@State private var showPhotoPicker = false
@State private var photoItem: PhotosPickerItem?
@State private var pickedURL: URL?
@State private var showDevicePicker = false
@State private var photoItems: [PhotosPickerItem] = []
// /
// SendComposeSheet
@State private var pickedURLs: [URL] = []
@State private var showCompose = false
@State private var showNoDevices = false
@State private var showClearConfirm = false
// List NavigationLink chevron
@@ -122,7 +122,8 @@ struct TransferListView: View
{
if engine.transfers.isEmpty && engine.history.isEmpty
{
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
// hero + + drop
BrandHero(tagline: t("ios.transfer.empty"))
}
else
{
@@ -161,31 +162,29 @@ struct TransferListView: View
label:
{
Image(systemName: "paperplane.fill")
.font(.title2)
.font(.oTitle2)
.padding(18)
}
.buttonStyle(.glassProminent)
.padding(24)
}
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ])
// / SendComposeSheet
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ],
allowsMultipleSelection: true)
{ result in
if case .success(let url) = result
{
pickedURL = url
if sendableDevices.isEmpty { showNoDevices = true }
else { showDevicePicker = true }
}
if case .success(let urls) = result, !urls.isEmpty { present(urls) }
}
.photosPicker(isPresented: $showPhotoPicker, selection: $photoItem,
.photosPicker(isPresented: $showPhotoPicker, selection: $photoItems,
matching: .any(of: [ .images, .videos ]))
.onChange(of: photoItem) { loadPhoto() }
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
.onChange(of: photoItems) { loadPhotos() }
.sheet(isPresented: $showCompose, onDismiss: clearPicked)
{
ForEach(sendableDevices)
{ dev in
Button(engine.sendLabel(dev)) { startSend(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { }
SendComposeSheet(
files: pickedURLs.map { PreviewFile(name: $0.lastPathComponent, size: fileByteSize($0)) },
devices: sendableDevices,
sendLabel: engine.sendLabel,
onSend: { device in sendPicked(to: device) },
onCancel: { showCompose = false })
}
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
@@ -221,7 +220,7 @@ struct TransferListView: View
HStack
{
Text(title)
.font(.subheadline)
.font(.oSubheadline)
.foregroundStyle(.secondary)
Spacer()
}
@@ -276,29 +275,46 @@ struct TransferListView: View
}
}
private func startSend(to device: String)
// /
private func present(_ urls: [URL])
{
guard let url = pickedURL else { return }
engine.sendFile(to: device, fileURL: url)
pickedURL = nil
pickedURLs = urls
if sendableDevices.isEmpty { showNoDevices = true }
else { showCompose = true }
}
// #14
private func loadPhoto()
//
private func sendPicked(to device: String)
{
guard let item = photoItem else { return }
for url in pickedURLs { engine.sendFile(to: device, fileURL: url) }
showCompose = false
}
// / 使
private func clearPicked()
{
pickedURLs = []
photoItems = []
}
// #14
private func loadPhotos()
{
let items = photoItems
guard !items.isEmpty else { return }
Task
{
guard let data = try? await item.loadTransferable(type: Data.self),
let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item))
else { return }
await MainActor.run
var urls: [URL] = []
for item in items
{
pickedURL = url
photoItem = nil
if sendableDevices.isEmpty { showNoDevices = true }
else { showDevicePicker = true }
if let data = try? await item.loadTransferable(type: Data.self),
let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item))
{
urls.append(url)
}
}
guard !urls.isEmpty else { return }
await MainActor.run { present(urls) }
}
}
@@ -326,7 +342,7 @@ struct TransferCardView: View
? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(.secondary)
Text(item.fileName)
.font(.headline)
.font(.oHeadline)
.lineLimit(1)
Spacer()
}
@@ -335,7 +351,7 @@ struct TransferCardView: View
ProgressView(value: progressOf(item))
}
Text(detailLine)
.font(.caption)
.font(.oCaption)
.foregroundStyle(.secondary)
}
.padding()
@@ -360,6 +376,11 @@ struct TransferCardView: View
{
parts.append(t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
}
else if item.state == "DONE", let rate = item.bytesPerSec, rate > 0
{
// / store completeTransfer bytesPerSec
parts.append(t("transfer.avgRate", [ "rate": formatBytes(Int(rate)) ]))
}
return parts.joined(separator: " · ")
}
}
@@ -427,8 +448,10 @@ struct TransferDetailView: View
}
if let rate = item.bytesPerSec, rate > 0
{
// store bytesPerSec
LabeledContent(t("ios.detail.speed"),
value: t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
value: t(item.state == "DONE" ? "transfer.avgRate" : "transfer.bytesRate",
[ "rate": formatBytes(Int(rate)) ]))
}
ProgressView(value: progressOf(item))
}
@@ -602,14 +625,14 @@ struct DeviceListView: View
if isSelf(dev)
{
Text(t("ios.devices.thisDevice"))
.font(.caption2)
.font(.oCaption2)
.foregroundStyle(Color.cdropAccent)
}
}
Text(dev.online
? t("settings.online")
: t("settings.lastSeen", [ "time": relativeTime(dev.lastSeen) ]))
.font(.caption)
.font(.oCaption)
.foregroundStyle(.secondary)
}
}
@@ -632,6 +655,23 @@ struct SettingsView: View
{
List
{
// + logo
Section
{
VStack(spacing: 6)
{
BrandLockup(markSize: 40, wordSize: 24)
if !appVersion.isEmpty
{
Text(appVersion)
.font(.oCaption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.listRowBackground(Color.clear)
}
if let user = auth.session?.user
{
Section
@@ -678,7 +718,7 @@ struct SettingsView: View
if !engine.lastEngineLog.isEmpty
{
Text(engine.lastEngineLog)
.font(.caption2)
.font(.oCaption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
@@ -712,6 +752,15 @@ struct SettingsView: View
return t("ios.engine.disconnected")
}
// vX.Y (build)
private var appVersion: String
{
let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
guard !v.isEmpty else { return "" }
let b = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
return b.isEmpty ? "v\(v)" : "v\(v) (\(b))"
}
// 稿PATCH /api/devices/{device_id} /
// renamed + Keychain + engine.deviceName
private func commitRename()
@@ -812,7 +861,7 @@ struct ReceivedFilesView: View
Text(url.lastPathComponent)
.lineLimit(1)
Text(fileSizeString(url))
.font(.caption)
.font(.oCaption)
.foregroundStyle(.secondary)
}
}
@@ -861,7 +910,7 @@ private func progressOf(_ item: TransferItem) -> Double
return min(1.0, Double(sent) / Double(item.fileSize))
}
private func formatBytes(_ bytes: Int) -> String
func formatBytes(_ bytes: Int) -> String
{
let f = ByteCountFormatter()
f.countStyle = .file
@@ -876,7 +925,7 @@ private func fileSizeString(_ url: URL) -> String
// lastSeen epoch devices.last_seennow.Unix()web formatRelative
// settings.lastSeen {{time}}
private func relativeTime(_ epochSec: Double) -> String
func relativeTime(_ epochSec: Double) -> String
{
let date = Date(timeIntervalSince1970: epochSec)
let f = RelativeDateTimeFormatter()
@@ -885,7 +934,7 @@ private func relativeTime(_ epochSec: Double) -> String
}
// SF Symbol web mapKind
private func deviceSymbol(_ type: String) -> String
func deviceSymbol(_ type: String) -> String
{
let s = type.lowercased()
if s.contains("mac") || s == "darwin" { return "laptopcomputer" }