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:
@@ -76,22 +76,20 @@ struct RootView: View
|
||||
.opacity(0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Share Extension 交接:cdrop://share 唤起后导入收件箱待发文件,弹出选设备发送。
|
||||
// Share Extension 交接:cdrop://share 唤起 / 前台扫到收件箱待发文件 → 直入发送预览(与手动
|
||||
// 选文件同一表单 SendComposeSheet),预填分享的文件,选目标设备发送。onSend 走 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_seen=now.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" }
|
||||
|
||||
Reference in New Issue
Block a user