diff --git a/Justfile b/Justfile
index e62d26b..4588fd2 100644
--- a/Justfile
+++ b/Justfile
@@ -77,13 +77,38 @@ desktop-doctor:
ios-devices:
xcrun devicectl list devices
+# 拉取品牌字体(不入库——Orbit Gothic 专有;统一从 CDN / Google Fonts 拉,镜像 web 思路)。幂等:已存在
+# 则跳过。ios-sim-build / ios-device 构建前已 chain 本步。前置:curl;造 Fredoka 静态实例需 python3 +
+# fonttools(python3 -m pip install --user fonttools)。见 ios/CDrop/Sources/Fonts/README.md。
+ios-fonts:
+ mkdir -p ios/CDrop/Sources/Fonts ios/CDrop/Shared/Fonts
+ [ -f ios/CDrop/Sources/Fonts/OrbitGothicCJKsc-Regular.woff2 ] || curl -fsSL "https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKsc/OrbitGothicCJKsc-Regular.woff2" -o ios/CDrop/Sources/Fonts/OrbitGothicCJKsc-Regular.woff2
+ [ -f ios/CDrop/Sources/Fonts/OrbitGothicCJKsc-SemiBold.woff2 ] || curl -fsSL "https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKsc/OrbitGothicCJKsc-SemiBold.woff2" -o ios/CDrop/Sources/Fonts/OrbitGothicCJKsc-SemiBold.woff2
+ [ -f ios/CDrop/Sources/Fonts/MapleMono-Regular.woff2 ] || curl -fsSL "https://maple-mono.cdn.commilitia.net/MapleMono-Regular.ttf.woff2" -o ios/CDrop/Sources/Fonts/MapleMono-Regular.woff2
+ [ -f ios/CDrop/Shared/Fonts/Fredoka-SemiBold.ttf ] || just ios-fonts-fredoka
+
+# Fredoka 字标:从 Google Fonts 变量字体实例化出静态 SemiBold(wght=600, wdth=100)。
+ios-fonts-fredoka:
+ curl -fsSL "https://raw.githubusercontent.com/google/fonts/main/ofl/fredoka/Fredoka%5Bwdth%2Cwght%5D.ttf" -o /tmp/Fredoka-VF.ttf
+ python3 -m fontTools.varLib.instancer /tmp/Fredoka-VF.ttf wght=600 wdth=100 -o ios/CDrop/Shared/Fonts/Fredoka-SemiBold.ttf --update-name-table
+ rm -f /tmp/Fredoka-VF.ttf
+
+# 模拟器编译验证(无需签名 / Apple 账号):拉字体 + xcodegen + xcodebuild iphonesimulator。原生数据面
+# 引擎走 SPM 的 libwebrtc(首次构建自动拉取 WebRTC.xcframework),无需预构建步骤。用于改动自检。
+ios-sim-build:
+ just ios-fonts
+ cd ios/CDrop && xcodegen generate
+ cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath build/DD build
+
# 真机构建 + 装机(自动 provisioning,经 ASC API Key)。前置:付费 ADP;App ID 的 App Groups /
# Push 能力(门户建好或自动建);根目录 gitignore 的 .env 配齐:CDROP_TEAM_ID / CDROP_ASC_KEY_PATH
# (.p8 路径)/ CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID。-allowProvisioningUpdates 自动登记连接的
-# 设备 + 建 profile(绕过手动 profile 的设备选择坑)。模拟器构建不需这些(base ad-hoc)。
+# 设备 + 建 profile(绕过手动 profile 的设备选择坑)。模拟器构建不需这些(base ad-hoc)。原生数据面
+# 经 SPM 拉 libwebrtc,无 gomobile 预构建步骤(早先 ios-engine recipe 已随 pion-on-iOS 废弃移除)。
# 用法:just ios-device <设备UDID>(UDID 见 just ios-devices)。
ios-device udid:
[ -n "$CDROP_TEAM_ID" ] && [ -n "$CDROP_ASC_KEY_PATH" ] && [ -n "$CDROP_ASC_KEY_ID" ] && [ -n "$CDROP_ASC_ISSUER_ID" ] || { echo "缺 .env(CDROP_TEAM_ID / CDROP_ASC_KEY_PATH / CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID)——见 ios/CDrop/REALDEVICE.md"; exit 1; }
+ just ios-fonts
cd ios/CDrop && xcodegen generate
cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -configuration Debug -destination "platform=iOS,id={{udid}}" -derivedDataPath build/DD-device -allowProvisioningUpdates -authenticationKeyPath "$CDROP_ASC_KEY_PATH" -authenticationKeyID "$CDROP_ASC_KEY_ID" -authenticationKeyIssuerID "$CDROP_ASC_ISSUER_ID" DEVELOPMENT_TEAM="$CDROP_TEAM_ID" CODE_SIGN_STYLE=Automatic CODE_SIGN_IDENTITY="Apple Development" build
xcrun devicectl device install app --device {{udid}} ios/CDrop/build/DD-device/Build/Products/Debug-iphoneos/CDrop.app
diff --git a/ios/CDrop/.gitignore b/ios/CDrop/.gitignore
index 13effee..0041bb3 100644
--- a/ios/CDrop/.gitignore
+++ b/ios/CDrop/.gitignore
@@ -7,6 +7,17 @@ DerivedData/
*.xcuserstate
.DS_Store
+# 原生数据面引擎(曾是 gomobile/pion → xcframework;现已改 libwebrtc SPM,本目录废弃但留忽略防残留)。
+Frameworks/
+
+# 品牌字体不入库:Orbit Gothic 为专有 / 定制字体(公开仓库不应分发);Maple Mono / Fredoka 虽开源,
+# 也统一不入库、改由 `just ios-fonts` 从 CDN / Google Fonts 拉取(镜像 web 的 CDN 思路),保持仓库精简。
+# 见 Sources/Fonts/README.md。构建前 `just ios-fonts`(已 chain 进 ios-device / ios-sim-build)会自动备齐。
+Sources/Fonts/*.woff2
+Sources/Fonts/*.ttf
+Shared/Fonts/*.ttf
+Shared/Fonts/*.woff2
+
# 真机签名的账号凭据——公开仓库不留账号标识。ASC API Key 的 .p8 + Team/Key/Issuer ID
# 走根目录 gitignore 的 .env(见 REALDEVICE.md);.p8 若落本目录一并忽略。
*.p8
diff --git a/ios/CDrop/Share/Info.plist b/ios/CDrop/Share/Info.plist
index 88e8e68..6e73c41 100644
--- a/ios/CDrop/Share/Info.plist
+++ b/ios/CDrop/Share/Info.plist
@@ -39,5 +39,9 @@
NSExtensionPrincipalClass
$(PRODUCT_MODULE_NAME).ShareViewController
+ UIAppFonts
+
+ Fredoka-SemiBold.ttf
+
diff --git a/ios/CDrop/Share/ShareGuideView.swift b/ios/CDrop/Share/ShareGuideView.swift
new file mode 100644
index 0000000..4290e17
--- /dev/null
+++ b/ios/CDrop/Share/ShareGuideView.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+
+// 分享引导界面的状态:文件数(nil=仍在准备)。@MainActor,UI 线程更新。
+@MainActor
+final class ShareModel: ObservableObject
+{
+ @Published var fileCount: Int?
+}
+
+// 分享扩展引导界面(取代「白窗一闪即关」):吉祥物 + 字标 + 已就绪文件数 + 醒目「打开 Commilitia
+// Drop」按钮——iOS 不可靠地允许扩展自动拉起宿主 app,故改为明确引导用户回到 app(点按尝试唤起;即便
+// 唤起失败,文件已落 App Group,回到 app 即弹发送预览)。吉祥物抱着飞散的纸恰好呼应「文件已接住、待 drop」。
+struct ShareGuideView: View
+{
+ @ObservedObject var model: ShareModel
+ let onOpen: () -> Void
+ let onCancel: () -> Void
+
+ var body: some View
+ {
+ VStack(spacing: 18)
+ {
+ Spacer()
+ ZStack
+ {
+ Circle()
+ .fill(Color.cdropAccent.opacity(0.10))
+ .frame(width: 168, height: 168)
+ BrandMark(size: 112)
+ }
+ Wordmark(size: 24)
+ VStack(spacing: 6)
+ {
+ Text(titleText)
+ .font(.headline)
+ Text(t("ios.share.guide.hint"))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .multilineTextAlignment(.center)
+ Spacer()
+ VStack(spacing: 12)
+ {
+ Button(action: onOpen)
+ {
+ Text(t("ios.share.guide.open"))
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .tint(.cdropAccent)
+ .disabled((model.fileCount ?? 0) == 0)
+
+ Button(t("common.cancel"), role: .cancel, action: onCancel)
+ .controlSize(.large)
+ }
+ }
+ .padding(28)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(.systemBackground))
+ }
+
+ private var titleText: String
+ {
+ guard let n = model.fileCount else { return t("ios.share.guide.preparing") }
+ if n == 0 { return t("ios.share.guide.empty") }
+ return t("ios.share.guide.ready", [ "n": String(n) ])
+ }
+}
diff --git a/ios/CDrop/Share/ShareViewController.swift b/ios/CDrop/Share/ShareViewController.swift
index 13a4b80..66fdd08 100644
--- a/ios/CDrop/Share/ShareViewController.swift
+++ b/ios/CDrop/Share/ShareViewController.swift
@@ -1,76 +1,173 @@
+import SwiftUI
import UIKit
import UniformTypeIdentifiers
-// Share Extension 主控制器:把 share sheet 传入的文件拷进 App Group 收件箱,再深链唤起主 app
-// 选设备发送(扩展只交接、不跑引擎,避 120MB jetsam,见 PLAN §I5)。无 compose UI——即起即办、
-// 处理完即返回。
+// Share Extension 主控制器:把 share sheet 传入的文件拷进 App Group 收件箱,再以**品牌引导界面**
+// (ShareGuideView:吉祥物 + 字标 + 已就绪文件数 + 「打开 Commilitia Drop」按钮)引导用户回到主 app
+// 选设备发送(扩展只交接、不跑引擎,避 120MB jetsam,见 PLAN §I5)。
+//
+// 为何是「引导」而非自动跳:iOS 不官方支持、也不可靠地允许 share 扩展自动拉起宿主 app(旧单参
+// openURL: 自 iOS 10 弃用、iOS 18+ 已成空壳)。故改为明确的用户操作:点「打开」尝试唤起(响应链经
+// IMP 调现代 open,最大努力)。无论唤起成败,文件已落 App Group 收件箱,用户回到 app 时
+// CDropApp.scenePhase(.active)→loadPendingShares 即弹发送预览,故分享始终能用、不丢文件。
final class ShareViewController: UIViewController
{
+ private let model = ShareModel()
+ private var started = false
+ // 已暂存文件的目标 URL(取消时据此删掉收件箱里的它们,不残留)。
+ private var stagedURLs: [URL] = []
+
+ override func viewDidLoad()
+ {
+ super.viewDidLoad()
+ let host = UIHostingController(
+ rootView: ShareGuideView(
+ model: model,
+ onOpen: { [weak self] in self?.openAndFinish() },
+ onCancel: { [weak self] in self?.cancelAndFinish() }))
+ addChild(host)
+ host.view.frame = view.bounds
+ host.view.autoresizingMask = [ .flexibleWidth, .flexibleHeight ]
+ view.addSubview(host.view)
+ host.didMove(toParent: self)
+ }
+
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
- Task { await processAndClose() }
+ guard !started else { return } // viewDidAppear 可能多次;只处理一次
+ started = true
+ Task { await stageAll() }
}
- private func processAndClose() async
+ private func stageAll() async
{
let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
- var staged = 0
+ var urls: [URL] = []
for item in items
{
for provider in item.attachments ?? []
{
- if await stage(provider) { staged += 1 }
+ if let dest = await stage(provider) { urls.append(dest) }
}
}
- let ctx = extensionContext
- guard staged > 0, let url = URL(string: "cdrop://share") else
- {
- ctx?.completeRequest(returningItems: nil)
- return
- }
- // 先唤起主 app,等 open 回调拿到结果再 completeRequest——否则立即 completeRequest 会拆掉
- // 扩展、self 被释放,open 失败时的 openViaResponder 兜底永不触发(审查 L8)。捕获 ctx 使
- // 扩展存活到回调。
- ctx?.open(url)
- { [weak self] ok in
- if !ok { self?.openViaResponder(url) }
- ctx?.completeRequest(returningItems: nil)
+ stagedURLs = urls
+ await MainActor.run { model.fileCount = urls.count }
+ }
+
+ // 「打开 Commilitia Drop」:尝试唤起主 app(最大努力),延后 completeRequest 给系统留出拉起窗口
+ // (立即 completeRequest 会拆掉扩展进程、可能取消尚未真正开始的唤起)。文件留在收件箱供主 app 接手。
+ private func openAndFinish()
+ {
+ if let url = URL(string: "cdrop://share") { openViaResponder(url) }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35)
+ { [weak self] in
+ self?.extensionContext?.completeRequest(returningItems: nil)
}
}
- // 把单个附件落到 App Group 收件箱。用 loadFileRepresentation 取系统给的临时文件表示
- // (文件 / 图片 / 文档统一走此路,且大文件不整体进内存),在回调内同步拷到收件箱
- // (系统会在回调返回后删临时文件)。
- private func stage(_ provider: NSItemProvider) async -> Bool
+ // 「取消」:删掉刚暂存进收件箱的文件(不残留、避免下次开 app 误弹预览),返回来源 app。
+ private func cancelAndFinish()
{
+ for url in stagedURLs
+ {
+ try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
+ }
+ extensionContext?.completeRequest(returningItems: nil)
+ }
+
+ // 把单个附件落到 App Group 收件箱,返回落地 URL。优先文件表示(大文件不整体进内存),失败退数据表示
+ // (仅提供数据、无文件表示的提供者)。两路都失败返回 nil。
+ private func stage(_ provider: NSItemProvider) async -> URL?
+ {
+ if let u = await stageFile(provider) { return u }
+ return await stageData(provider)
+ }
+
+ private func stageFile(_ provider: NSItemProvider) async -> URL?
+ {
+ let typeId = provider.registeredTypeIdentifiers.first ?? UTType.item.identifier
return await withCheckedContinuation
{ cont in
- provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier)
+ provider.loadFileRepresentation(forTypeIdentifier: typeId)
{ url, _ in
guard let url, let dest = AppGroup.stagedDestination(originalName: url.lastPathComponent)
- else { cont.resume(returning: false); return }
+ else { cont.resume(returning: nil); return }
do
{
try FileManager.default.copyItem(at: url, to: dest)
- cont.resume(returning: true)
+ cont.resume(returning: dest)
}
catch
{
- cont.resume(returning: false)
+ cont.resume(returning: nil)
}
}
}
}
- // open 失败时的兜底:沿响应链找 UIApplication 唤起主 app(个别系统版本扩展直接 open 不生效)。
+ private func stageData(_ provider: NSItemProvider) async -> URL?
+ {
+ let typeId = provider.registeredTypeIdentifiers.first ?? UTType.data.identifier
+ let name = suggestedName(provider, typeId: typeId)
+ return await withCheckedContinuation
+ { cont in
+ provider.loadDataRepresentation(forTypeIdentifier: typeId)
+ { data, _ in
+ guard let data, let dest = AppGroup.stagedDestination(originalName: name)
+ else { cont.resume(returning: nil); return }
+ do
+ {
+ try data.write(to: dest)
+ cont.resume(returning: dest)
+ }
+ catch
+ {
+ cont.resume(returning: nil)
+ }
+ }
+ }
+ }
+
+ // 数据兜底路径的文件名:优先提供者建议名,否则按类型首选扩展名拼一个。
+ private func suggestedName(_ provider: NSItemProvider, typeId: String) -> String
+ {
+ if let s = provider.suggestedName, !s.isEmpty
+ {
+ if (s as NSString).pathExtension.isEmpty,
+ let ext = UTType(typeId)?.preferredFilenameExtension
+ {
+ return "\(s).\(ext)"
+ }
+ return s
+ }
+ let ext = UTType(typeId)?.preferredFilenameExtension ?? "dat"
+ return "shared-\(UUID().uuidString.prefix(8)).\(ext)"
+ }
+
+ // 沿响应链找 UIApplication 实例,经 IMP 调现代 open(_:options:completionHandler:)(绕过扩展对
+ // UIApplication 的编译期可用性限制)。找不到则退旧单参 openURL:(iOS 18+ 多为空壳,仅历史兜底)。
private func openViaResponder(_ url: URL)
{
+ let modern = NSSelectorFromString("openURL:options:completionHandler:")
var responder: UIResponder? = self
- let sel = NSSelectorFromString("openURL:")
while let r = responder
{
- if r.responds(to: sel) { _ = r.perform(sel, with: url); return }
+ if r.responds(to: modern), let app = r as? NSObject,
+ let method = class_getInstanceMethod(type(of: app), modern)
+ {
+ typealias OpenFn = @convention(c) (NSObject, Selector, NSURL, NSDictionary, Any?) -> Void
+ let fn = unsafeBitCast(method_getImplementation(method), to: OpenFn.self)
+ fn(app, modern, url as NSURL, NSDictionary(), nil)
+ return
+ }
+ responder = r.next
+ }
+ let legacy = NSSelectorFromString("openURL:")
+ responder = self
+ while let r = responder
+ {
+ if r.responds(to: legacy) { _ = r.perform(legacy, with: url); return }
responder = r.next
}
}
diff --git a/ios/CDrop/Shared/Brand.swift b/ios/CDrop/Shared/Brand.swift
new file mode 100644
index 0000000..fe7154d
--- /dev/null
+++ b/ios/CDrop/Shared/Brand.swift
@@ -0,0 +1,76 @@
+import SwiftUI
+
+// 品牌视觉单一真源(吉祥物 mark + 文字字标),供主 app / 分享扩展 / 小组件共用(三者 sources 均含
+// Shared)。资产 LogoMark 在 Shared/Brand.xcassets,随各 target bundle 编译,故 Image("LogoMark") 在
+// 各自 bundle 内解析得到。字标双色对齐 web 品牌资产:Commilitia=主前景、Drop=品牌琥珀(cdropAmber);
+// 品牌名是专有名词、不本地化。圆体(system rounded)呼应 web 的 Fredoka。
+
+// 吉祥物 mark(透明背景,抱着飞散的纸=「drop」文件)。size 控制边长。
+struct BrandMark: View
+{
+ var size: CGFloat = 96
+
+ var body: some View
+ {
+ Image("LogoMark")
+ .resizable()
+ .scaledToFit()
+ .frame(width: size, height: size)
+ .accessibilityHidden(true)
+ }
+}
+
+// 文字字标「Commilitia Drop」:Drop 用品牌琥珀,与 mark 成对呈现(品牌规范:不以纯文字呈现品牌)。
+struct Wordmark: View
+{
+ var size: CGFloat = 22
+
+ var body: some View
+ {
+ (
+ Text("Commilitia ").foregroundColor(.primary)
+ + Text("Drop").foregroundColor(.cdropAmber)
+ )
+ // 品牌字标用 Fredoka SemiBold(与 web 品牌资产一致,font 经 UIAppFonts 注册;见 Shared/Fonts)。
+ .font(.custom("Fredoka-SemiBold", size: size))
+ .accessibilityLabel("Commilitia Drop")
+ }
+}
+
+// 横排锁版:mark + 字标(设置页头等)。
+struct BrandLockup: View
+{
+ var markSize: CGFloat = 30
+ var wordSize: CGFloat = 20
+
+ var body: some View
+ {
+ HStack(spacing: 10)
+ {
+ BrandMark(size: markSize)
+ Wordmark(size: wordSize)
+ }
+ }
+}
+
+// 品牌空态 hero(主页 / 列表空时):大 mark + 字标 + 一句副文案(i18n 由调用方传入)。取代单调的
+// SF Symbol 空态——首屏即见吉祥物,强化品牌;mark 抱纸的姿态恰好呼应「等你来 drop 文件」。
+struct BrandHero: View
+{
+ var tagline: String
+
+ var body: some View
+ {
+ VStack(spacing: 16)
+ {
+ BrandMark(size: 132)
+ Wordmark(size: 26)
+ Text(tagline)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding()
+ }
+}
diff --git a/ios/CDrop/Shared/Brand.xcassets/Contents.json b/ios/CDrop/Shared/Brand.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/CDrop/Shared/Brand.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/CDrop/Shared/Brand.xcassets/LogoMark.imageset/Contents.json b/ios/CDrop/Shared/Brand.xcassets/LogoMark.imageset/Contents.json
new file mode 100644
index 0000000..88c8e0f
--- /dev/null
+++ b/ios/CDrop/Shared/Brand.xcassets/LogoMark.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "logo-mark.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/CDrop/Shared/Brand.xcassets/LogoMark.imageset/logo-mark.png b/ios/CDrop/Shared/Brand.xcassets/LogoMark.imageset/logo-mark.png
new file mode 100644
index 0000000..c6093ce
Binary files /dev/null and b/ios/CDrop/Shared/Brand.xcassets/LogoMark.imageset/logo-mark.png differ
diff --git a/ios/CDrop/Shared/Theme.swift b/ios/CDrop/Shared/Theme.swift
index 7569585..415fd5d 100644
--- a/ios/CDrop/Shared/Theme.swift
+++ b/ios/CDrop/Shared/Theme.swift
@@ -11,4 +11,12 @@ extension Color
? UIColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1)
: UIColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1)
})
+
+ // 品牌琥珀色(字标里「Drop」用色,对齐 web 品牌资产):浅色 #DB9410 / 深色 #F8C637。
+ static let cdropAmber = Color(uiColor: UIColor
+ { trait in
+ trait.userInterfaceStyle == .dark
+ ? UIColor(red: 0.973, green: 0.776, blue: 0.216, alpha: 1)
+ : UIColor(red: 0.859, green: 0.580, blue: 0.063, alpha: 1)
+ })
}
diff --git a/ios/CDrop/Sources/AppDelegate.swift b/ios/CDrop/Sources/AppDelegate.swift
index f478c07..63698a0 100644
--- a/ios/CDrop/Sources/AppDelegate.swift
+++ b/ios/CDrop/Sources/AppDelegate.swift
@@ -10,6 +10,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool
{
+ // 品牌字体须在任何 UI 渲染前注册 + 设外观代理(导航 / 标签栏标题换 Orbit Gothic)。
+ BrandFonts.register()
// BGContinuedProcessingTask 的 launch handler 须在 launch 完成前注册。
BackgroundTaskManager.shared.registerOnce()
return true
diff --git a/ios/CDrop/Sources/Auth/LoginView.swift b/ios/CDrop/Sources/Auth/LoginView.swift
index cd5aeeb..c1ef588 100644
--- a/ios/CDrop/Sources/Auth/LoginView.swift
+++ b/ios/CDrop/Sources/Auth/LoginView.swift
@@ -14,9 +14,13 @@ struct LoginView: View
VStack(spacing: 20)
{
Spacer()
- Text(t("app.brand"))
- .font(.largeTitle)
- .bold()
+ // 品牌(登录页):吉祥物 + 字标(Fredoka),取代原纯文字标题——首屏即见完整品牌。
+ VStack(spacing: 14)
+ {
+ BrandMark(size: 124)
+ Wordmark(size: 30)
+ }
+ .padding(.bottom, 8)
// 主登录:应用内 broker 账号登录。
Button { Task { await auth.startBrokerLogin() } }
@@ -31,7 +35,7 @@ struct LoginView: View
if !auth.statusText.isEmpty
{
Text(auth.statusText)
- .font(.callout)
+ .font(.oCallout)
.foregroundStyle(auth.qrExpired ? Color.red : Color.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
@@ -45,7 +49,7 @@ struct LoginView: View
qrArea
refreshButton
Text(t("ios.login.guide"))
- .font(.footnote)
+ .font(.oFootnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
@@ -54,7 +58,7 @@ struct LoginView: View
label:
{
Text(t("ios.login.scanAlt"))
- .font(.subheadline)
+ .font(.oSubheadline)
}
.padding(.horizontal, 40)
.onChange(of: showQR)
diff --git a/ios/CDrop/Sources/BrandFonts.swift b/ios/CDrop/Sources/BrandFonts.swift
new file mode 100644
index 0000000..6216729
--- /dev/null
+++ b/ios/CDrop/Sources/BrandFonts.swift
@@ -0,0 +1,81 @@
+import CoreText
+import SwiftUI
+import UIKit
+
+// 品牌字体接入(对齐 web 字体栈 --font-sans / --font-mono):
+// - Sans 正文 = Orbit Gothic CJK SC(Regular 400 + SemiBold 600 两变体)
+// - Mono = Maple Mono(拉丁 / 符号)
+// 字体以 woff2 内嵌(体积约 TTF 的 1/3;iOS Core Text 自 iOS 13 起原生支持 woff/woff2),故经
+// CTFontManagerRegisterFontsForURL 运行期注册(UIAppFonts 历史上只认 ttf/otf,woff2 走运行期最稳)。
+// 仅主 app 接入——CJK 字体大(~10MB/字重),不进 Share / 控件扩展(它们保持系统字 + 仅 Fredoka 字标)。
+// 生僻字(超出 Orbit Gothic 字汇)由 iOS 字体级联自动回退系统苹方,不出豆腐块。
+enum BrandFonts
+{
+ static let sansRegular = "OrbitGothicCJKsc-Regular"
+ static let sansSemibold = "OrbitGothicCJKsc-SemiBold"
+ static let mono = "MapleMono-Regular"
+
+ // app 启动最早期(AppDelegate didFinishLaunching)调用一次:注册字体 + 设 UIKit 外观代理。
+ static func register()
+ {
+ for name in [ sansRegular, sansSemibold, mono ]
+ {
+ guard let url = Bundle.main.url(forResource: name, withExtension: "woff2") else { continue }
+ CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
+ }
+ applyUIKitAppearance()
+ }
+
+ // SwiftUI 的导航标题 / 标签栏标题经 UIKit 渲染,SwiftUI 无 API 直改,故经 appearance 代理统一换 Orbit。
+ private static func applyUIKitAppearance()
+ {
+ if let large = UIFont(name: sansSemibold, size: 32)
+ {
+ UINavigationBar.appearance().largeTitleTextAttributes = [ .font: large ]
+ }
+ if let inline = UIFont(name: sansSemibold, size: 17)
+ {
+ UINavigationBar.appearance().titleTextAttributes = [ .font: inline ]
+ }
+ if let tab = UIFont(name: sansRegular, size: 10)
+ {
+ let item = UITabBarItem.appearance()
+ item.setTitleTextAttributes([ .font: tab ], for: .normal)
+ item.setTitleTextAttributes([ .font: tab ], for: .selected)
+ }
+ }
+}
+
+// 字体助手:Orbit Gothic(Sans)+ Maple Mono(Mono)。relativeTo 让自定义字体随动态字号缩放
+// (不丢可访问性)。weight 仅二档——bold 走 SemiBold 变体,其余走 Regular(再粗由系统合成)。
+extension Font
+{
+ static func orbit(_ size: CGFloat, bold: Bool = false, relativeTo style: Font.TextStyle = .body) -> Font
+ {
+ .custom(bold ? BrandFonts.sansSemibold : BrandFonts.sansRegular, size: size, relativeTo: style)
+ }
+
+ static func maple(_ size: CGFloat, relativeTo style: Font.TextStyle = .body) -> Font
+ {
+ .custom(BrandFonts.mono, size: size, relativeTo: style)
+ }
+
+ // 与 SF 语义文本样式对位的 Orbit 版本(字号 / 字重对齐 SF 默认值),供全局替换 .font(.xxx)。
+ // headline / title* 默认偏粗 → SemiBold;正文 / 说明类 → Regular。
+ static let oLargeTitle = orbit(34, bold: true, relativeTo: .largeTitle)
+ static let oTitle = orbit(28, bold: true, relativeTo: .title)
+ static let oTitle2 = orbit(22, bold: true, relativeTo: .title2)
+ static let oTitle3 = orbit(20, bold: true, relativeTo: .title3)
+ static let oHeadline = orbit(17, bold: true, relativeTo: .headline)
+ static let oBody = orbit(17, relativeTo: .body)
+ static let oCallout = orbit(16, relativeTo: .callout)
+ static let oSubheadline = orbit(15, relativeTo: .subheadline)
+ static let oFootnote = orbit(13, relativeTo: .footnote)
+ static let oCaption = orbit(12, relativeTo: .caption)
+ static let oCaption2 = orbit(11, relativeTo: .caption2)
+
+ // Mono:等宽场景(日志行 / 设备 id / 字节计数)。
+ static let mCaption = maple(12, relativeTo: .caption)
+ static let mFootnote = maple(13, relativeTo: .footnote)
+ static let mBody = maple(15, relativeTo: .body)
+}
diff --git a/ios/CDrop/Sources/CDropApp.swift b/ios/CDrop/Sources/CDropApp.swift
index 647289a..6a4724c 100644
--- a/ios/CDrop/Sources/CDropApp.swift
+++ b/ios/CDrop/Sources/CDropApp.swift
@@ -15,12 +15,21 @@ struct CDropApp: App
WindowGroup
{
AppRoot(auth: auth, engine: engine)
+ // 全局默认字体=Orbit Gothic 正文(无显式 .font 的文本随之走品牌 Sans)。显式语义样式
+ // 由各视图改用 .font(.oXxx)(见 BrandFonts)覆盖到品牌字。
+ .environment(\.font, .oBody)
}
.onChange(of: scenePhase)
{ _, phase in
+ guard phase == .active else { return }
+ // 回前台即重扫 App Group 收件箱:iOS 对 share 扩展唤起宿主 app 有限制、未必生效,故无论扩展
+ // 是否成功把 app 拉前台,只要回到前台(手动切回或被拉起)都重扫一次——有待发分享文件即弹
+ // 发送预览,免去「必须重启 app 分享才出现」(onAppear 仅冷启触发、回前台不触发,是要重启的根因)。
+ // 空收件箱无副作用。
+ engine.loadPendingShares()
// 回到前台即确保控件 / 剪贴板会话已铸(修「关闭态剪贴板不可用」:引擎冷启 ready 时铸一次,
// 这里补暖前台场景)。引擎持完整主会话铸独立控件会话(隔离于主会话),仅缺失时铸、不重复轮换。
- if phase == .active, auth.session != nil
+ if auth.session != nil
{
engine.provisionWidgetSessionIfNeeded()
}
diff --git a/ios/CDrop/Sources/Engine/EngineController.swift b/ios/CDrop/Sources/Engine/EngineController.swift
index b2a2f10..d207e47 100644
--- a/ios/CDrop/Sources/Engine/EngineController.swift
+++ b/ios/CDrop/Sources/Engine/EngineController.swift
@@ -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(引擎诊断行 + 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 = []
@@ -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:// 引用解析回暂存文件的沙盒绝对路径(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://,解析回沙盒绝对路径交引擎直接读盘。引擎
+ // 调用立即返回(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 去重避免重复事件叠加。
diff --git a/ios/CDrop/Sources/Engine/LibWebRtcEngine.swift b/ios/CDrop/Sources/Engine/LibWebRtcEngine.swift
new file mode 100644
index 0000000..2cf8598
--- /dev/null
+++ b/ios/CDrop/Sources/Engine/LibWebRtcEngine.swift
@@ -0,0 +1,905 @@
+import Foundation
+import WebRTC
+
+// LibWebRtcEngine —— iOS 原生数据面引擎,用 libwebrtc(Safari 同款 RTCPeerConnection)在原生进程内
+// 完成 P2P 文件收发。取代早先 gomobile/pion 路径:pion 的 Go raw socket 不与 iOS Network framework
+// 集成(WebKit/libwebrtc 才集成),真机确证架构性不可靠(见 desktop/NATIVE-TRANSFER.md §7)。
+//
+// 线协议与 desktop/engine(pion)及 web/src/features/transfer/p2p.ts 逐字节一致,故可与桌面 pion
+// 引擎、浏览器 / 旧 iOS 的 JS 引擎互通:DataChannel "cdrop-file"(ordered),控制帧 meta/done/ack 走
+// 文本帧、文件分片走二进制帧(64KB),信令 payload 形如 {type, sdp?, candidate?}。本类是 session.go +
+// engine.go 的忠实 Swift 端口——逻辑、水位、ack 语义、收尾顺序一一对应。
+//
+// 分工同桌面:本引擎只跑纯 WebRTC + 文件 I/O,一切 HTTP(出站信令 POST / 状态机 POST)留 JS
+// (见 web/src/features/transfer/p2pIos.ts)。事件经 P2PEngineDelegate 回灌——宿主即 EngineController
+// 经 cdropEngine 桥(P2PEventsBridge)转给无头 JS 引擎。
+//
+// 宿主契约(EngineController 的 4 个 p2p RPC + net/ios.ts 的 nativeP2P*)一字不改;只把 Swift 侧引擎
+// 从 gomobile EngineEngine 换成本类。翻 web 侧 IOS_NATIVE=true 即启用。
+
+// MARK: - 事件回调(宿主实现,等价 desktop/engine 的 Events 接口)
+
+protocol P2PEngineDelegate: AnyObject
+{
+ // 已传字节:发送端=接收端 ack 追平值(无 ack 时回退本地已交付估计),接收端=已写盘字节。
+ func engineDidProgress(sessionId: String, bytes: Int64)
+ // 会话状态:connected / completed / failed / closed。
+ func engineDidChangeState(sessionId: String, state: String)
+ // 出站信令(offer/answer/ice):payloadJSON 为信令 JSON 串,宿主 POST /api/hub/signal。
+ func engineDidEmitSignal(sessionId: String, toPeer: String, payloadJSON: String)
+ // 接收端落盘完成的绝对路径(沙盒 Documents)。
+ func engineDidSave(sessionId: String, path: String)
+ // 选中候选对(形如 "host/udp"),供调试面板展示实际连通路径。
+ func engineDidSelectIcePair(sessionId: String, local: String, remote: String)
+ // 诊断日志(候选 / ICE 态 / 选中对 / 发送停滞),形如 "session ...",供日志界面按会话聚合。
+ func engineDidLog(_ line: String)
+}
+
+// MARK: - 线协议常量(与 desktop/engine wire.go、web p2p.ts 一致)
+
+private enum Wire
+{
+ static let channelName = "cdrop-file"
+ static let chunkSize = 64 * 1024
+ static let highWatermark: UInt64 = 16 * 1024 * 1024
+ static let lowWatermark: UInt64 = 4 * 1024 * 1024
+ static let ackIntervalMs = 200
+ static let progressThrottleNs: Int64 = 100 * 1_000_000 // ~10Hz,护宿主主线程(每片过桥 evaluateJavaScript)
+ static let ackCompleteTimeout: TimeInterval = 30
+ // ICE 候选预热池,与 web p2p.ts ICE_CANDIDATE_POOL 对齐。
+ static let iceCandidatePool: Int32 = 4
+}
+
+struct EngineError: LocalizedError
+{
+ let msg: String
+ init(_ m: String) { msg = m }
+ var errorDescription: String? { msg }
+}
+
+// MARK: - 引擎(持有所有活跃会话,按对端设备名索引,等价 engine.go 的 Engine)
+
+final class LibWebRtcEngine
+{
+ // 单次 SSL 初始化(libwebrtc 要求)。全局 let 惰性、线程安全地执行一次。
+ private static let sslReady: Bool = RTCInitializeSSL()
+
+ private let factory: RTCPeerConnectionFactory
+ // 内部可设:生产由 init 注入(EngineController 的 P2PEventsBridge);测试可重定向到环回宿主。
+ weak var delegate: P2PEngineDelegate?
+
+ private let lock = NSLock()
+ private var byPeer: [String: P2PTransferSession] = [:]
+ private var downloadDirValue: String
+
+ init(downloadDir: String, delegate: P2PEngineDelegate)
+ {
+ _ = LibWebRtcEngine.sslReady
+ factory = RTCPeerConnectionFactory()
+ downloadDirValue = downloadDir
+ self.delegate = delegate
+ }
+
+ var events: P2PEngineDelegate? { delegate }
+
+ func downloadDir() -> String
+ {
+ lock.lock(); defer { lock.unlock() }
+ return downloadDirValue
+ }
+
+ func setDownloadDir(_ dir: String)
+ {
+ lock.lock(); downloadDirValue = dir; lock.unlock()
+ }
+
+ // StartOutgoing:建 PeerConnection + DataChannel,发 offer,通道就绪后流式发出 filePath。
+ // filePath 为本机绝对路径(原生从 cdrop-file:// 解析得到)。completion 在 offer 已铸并
+ // setLocalDescription 完成后回(或出错时回错误),对齐 JS 侧信令链 seed 语义(见 p2pIos.ts chain)。
+ func startOutgoing(sessionId: String, peerName: String, filePath: String,
+ iceServersJSON: String, completion: @escaping (Error?) -> Void)
+ {
+ let fm = FileManager.default
+ var isDir: ObjCBool = false
+ guard fm.fileExists(atPath: filePath, isDirectory: &isDir), !isDir.boolValue
+ else { completion(EngineError("not a file: \(filePath)")); return }
+ let size = ((try? fm.attributesOfItem(atPath: filePath))?[.size] as? NSNumber)?.int64Value ?? 0
+
+ let s = P2PTransferSession(engine: self, factory: factory,
+ sessionId: sessionId, peerName: peerName, role: .sender)
+ s.filePath = filePath
+ s.fileSize = size
+ do { try s.createPeerConnection(iceServersJSON) }
+ catch { completion(error); return }
+ put(peerName, s)
+ do { try s.createSenderDataChannel() }
+ catch { s.fail("create datachannel: \(error.localizedDescription)"); completion(error); return }
+ s.createOfferAndEmit(completion: completion)
+ }
+
+ // StartIncoming:建 PeerConnection 等对端 offer,收到 DataChannel 后按线协议落盘。须在对端
+ // offer 到达前调用。completion 立即回(会话已登记,后续入站 offer/ice 可路由)。
+ func startIncoming(sessionId: String, peerName: String,
+ iceServersJSON: String, completion: @escaping (Error?) -> Void)
+ {
+ let s = P2PTransferSession(engine: self, factory: factory,
+ sessionId: sessionId, peerName: peerName, role: .receiver)
+ do { try s.createPeerConnection(iceServersJSON) }
+ catch { completion(error); return }
+ put(peerName, s)
+ completion(nil)
+ }
+
+ // HandleSignal:把宿主从 SSE 收到的入站信令喂给对应会话(按对端设备名路由)。completion 在信令
+ // 应用完成后回——对 offer 是 answer 已铸并发出后,对 answer 是 setRemoteDescription 完成后,对
+ // ice 是 addIceCandidate 完成后。这保证 JS 侧信令链按序投递(offer/answer 先于其后 trickle 候选)。
+ func handleSignal(fromPeer: String, payloadJSON: String, completion: @escaping (Error?) -> Void)
+ {
+ lock.lock(); let s = byPeer[fromPeer]; lock.unlock()
+ guard let s
+ else { completion(EngineError("no active session for peer \(fromPeer)")); return }
+ s.handleSignal(payloadJSON, completion: completion)
+ }
+
+ // Cancel:拆除指定会话(取消 / 中继回退时由宿主调用)。静默拆除,不回 OnState。
+ func cancel(sessionId: String)
+ {
+ lock.lock()
+ var target: P2PTransferSession?
+ for (_, s) in byPeer where s.sessionId == sessionId { target = s; break }
+ lock.unlock()
+ target?.cleanup()
+ }
+
+ fileprivate func put(_ peer: String, _ s: P2PTransferSession)
+ {
+ lock.lock()
+ let old = byPeer[peer]
+ byPeer[peer] = s
+ lock.unlock()
+ // 同一对端若已有会话,先拆旧的(沿用 web / 桌面按对端覆盖的语义)。异步拆,避免在 RPC / 主线程
+ // 上同步 close(对齐 engine.go 的 `go old.cleanup()`)。
+ if let old, old !== s { DispatchQueue.global(qos: .utility).async { old.cleanup() } }
+ }
+
+ fileprivate func remove(_ peer: String, _ s: P2PTransferSession)
+ {
+ lock.lock()
+ if byPeer[peer] === s { byPeer.removeValue(forKey: peer) }
+ lock.unlock()
+ }
+}
+
+// MARK: - 单条传输会话(pc + dc + 角色相关收发循环,等价 session.go 的 session)
+
+final class P2PTransferSession: NSObject
+{
+ enum Role { case sender, receiver }
+
+ let sessionId: String
+ let peerName: String
+ let role: Role
+ private weak var engine: LibWebRtcEngine?
+ private let factory: RTCPeerConnectionFactory
+
+ private var pc: RTCPeerConnection?
+ private var dc: RTCDataChannel?
+
+ // 发送端
+ var filePath = ""
+ var fileSize: Int64 = 0
+
+ // 计数器(跨线程:发送循环 / pion 回调 / dc 委托)。cLock 守护。
+ private let cLock = NSLock()
+ private var bytesSent: Int64 = 0 // 已塞进本地 SCTP 缓冲的字节
+ private var receivedBytes: Int64 = 0 // 接收端已写盘字节
+ private var ackedBytes: Int64 = 0 // 接收端经 ack 回传的已收字节
+ private var lastEmitNs: Int64 = 0 // 进度节流时间戳
+
+ // 接收端落盘。outLock 守护 outHandle 的读/写/关——writeChunk 在 libwebrtc 信令线程跑,cleanup 可能
+ // 在主线程(JS p2pCancel / 中继回退 / SSE 终态)并发关句柄;无锁则 ARC retain/release 与 close/write
+ // 同体竞争 → 可能崩溃(Go 的 *os.File 有内建 fdMutex 故安全,Swift FileHandle 无此保证)。
+ private var outHandle: FileHandle?
+ private var outName = ""
+ private var tmpPath = ""
+ private let outLock = NSLock()
+
+ // 发送流 / 背压
+ private let streamQueue: DispatchQueue
+ private let bufSem = DispatchSemaphore(value: 0) // didChangeBufferedAmount / ack 唤醒等待循环
+ private var ackTimer: DispatchSourceTimer?
+ private var senderStarted = false
+
+ // 生命周期
+ private let stateLock = NSLock()
+ private var state = ""
+ private var doneFlag = false
+ private var cleanedUp = false
+
+ init(engine: LibWebRtcEngine, factory: RTCPeerConnectionFactory,
+ sessionId: String, peerName: String, role: Role)
+ {
+ self.engine = engine
+ self.factory = factory
+ self.sessionId = sessionId
+ self.peerName = peerName
+ self.role = role
+ streamQueue = DispatchQueue(label: "net.commilitia.cdrop.p2p.\(sessionId)")
+ super.init()
+ }
+
+ // MARK: 建连
+
+ func createPeerConnection(_ iceServersJSON: String) throws
+ {
+ let config = RTCConfiguration()
+ config.iceServers = Self.parseICEServers(iceServersJSON)
+ config.sdpSemantics = .unifiedPlan
+ config.continualGatheringPolicy = .gatherOnce
+ config.iceCandidatePoolSize = Wire.iceCandidatePool
+ // 默认网络处理(不禁链路本地、不强 IPv4、不限接口):libwebrtc 与 iOS Network framework 原生
+ // 集成,同内网经 host↔host 直连。绝不在此加 iOS 定向过滤——曾把 IPv4-only 漏进桌面共享引擎致
+ // 桌面↔iOS-WebKit 的 IPv6 host 对被裁、退中继(重大回归)。
+ let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
+ guard let pc = factory.peerConnection(with: config, constraints: constraints, delegate: self)
+ else { throw EngineError("failed to create peer connection") }
+ self.pc = pc
+ }
+
+ func createSenderDataChannel() throws
+ {
+ guard let pc else { throw EngineError("no pc") }
+ let dcConfig = RTCDataChannelConfiguration()
+ dcConfig.isOrdered = true
+ guard let dc = pc.dataChannel(forLabel: Wire.channelName, configuration: dcConfig)
+ else { throw EngineError("failed to create data channel") }
+ dc.delegate = self
+ self.dc = dc
+ }
+
+ func createOfferAndEmit(completion: @escaping (Error?) -> Void)
+ {
+ guard let pc else { completion(EngineError("no pc")); return }
+ let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
+ pc.offer(for: constraints)
+ { [weak self] sdp, err in
+ guard let self else { completion(EngineError("session gone")); return }
+ if let err { self.fail("create offer: \(err.localizedDescription)"); completion(err); return }
+ guard let sdp else { self.fail("create offer: nil"); completion(EngineError("nil offer")); return }
+ pc.setLocalDescription(sdp)
+ { [weak self] err2 in
+ guard let self else { completion(EngineError("session gone")); return }
+ if let err2
+ {
+ self.fail("set local (offer): \(err2.localizedDescription)"); completion(err2); return
+ }
+ self.emitSignal(sdp: sdp, candidate: nil)
+ completion(nil)
+ }
+ }
+ }
+
+ // MARK: 入站信令
+
+ func handleSignal(_ payloadJSON: String, completion: @escaping (Error?) -> Void)
+ {
+ guard let pc else { completion(EngineError("no pc")); return }
+ guard let data = payloadJSON.data(using: .utf8),
+ let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
+ let type = obj["type"] as? String
+ else { completion(EngineError("decode signal")); return }
+ log("signal in: \(type)")
+
+ switch type
+ {
+ case "offer":
+ guard let sdpObj = obj["sdp"] as? [String: Any], let sdpStr = sdpObj["sdp"] as? String
+ else { completion(EngineError("offer missing sdp")); return }
+ let desc = RTCSessionDescription(type: .offer, sdp: sdpStr)
+ pc.setRemoteDescription(desc)
+ { [weak self] err in
+ guard let self else { completion(EngineError("gone")); return }
+ if let err { completion(err); return }
+ let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
+ pc.answer(for: constraints)
+ { [weak self] ans, aerr in
+ guard let self else { completion(EngineError("gone")); return }
+ if let aerr { completion(aerr); return }
+ guard let ans else { completion(EngineError("nil answer")); return }
+ pc.setLocalDescription(ans)
+ { [weak self] lerr in
+ guard let self else { completion(EngineError("gone")); return }
+ if let lerr { completion(lerr); return }
+ self.emitSignal(sdp: ans, candidate: nil)
+ completion(nil)
+ }
+ }
+ }
+ case "answer":
+ guard let sdpObj = obj["sdp"] as? [String: Any], let sdpStr = sdpObj["sdp"] as? String
+ else { completion(EngineError("answer missing sdp")); return }
+ let desc = RTCSessionDescription(type: .answer, sdp: sdpStr)
+ pc.setRemoteDescription(desc) { err in completion(err) }
+ case "ice":
+ // 空候选 / end-of-candidates:忽略(pion 同样跳过 nil candidate)。
+ guard let candObj = obj["candidate"] as? [String: Any],
+ let candStr = candObj["candidate"] as? String, !candStr.isEmpty
+ else { completion(nil); return }
+ let mid = candObj["sdpMid"] as? String
+ let mline = (candObj["sdpMLineIndex"] as? NSNumber)?.int32Value ?? 0
+ let cand = RTCIceCandidate(sdp: candStr, sdpMLineIndex: mline, sdpMid: mid)
+ pc.add(cand) { err in completion(err) }
+ default:
+ completion(nil)
+ }
+ }
+
+ // MARK: 出站信令
+
+ // 与 session.go emitSignal 同形:有 candidate 即 ice,否则取 sdp 自身类型(offer/answer)。
+ // JSON 形状与 web p2p.ts 的 SignalPayload / pion 的 signalPayload 逐字段对齐(键序无关,两侧皆 parse)。
+ private func emitSignal(sdp: RTCSessionDescription?, candidate: RTCIceCandidate?)
+ {
+ var payload: [String: Any] = [:]
+ if let candidate
+ {
+ payload["type"] = "ice"
+ // RTCIceCandidate(ObjC)不暴露 usernameFragment,故 candidate JSON 仅含 candidate/sdpMid/
+ // sdpMLineIndex。ufrag 由 m-line 的 ice-ufrag 决定(offer/answer 已带),pion 与浏览器的
+ // addIceCandidate 对缺省 usernameFragment 皆容忍,互通不受影响。
+ var c: [String: Any] = [
+ "candidate": candidate.sdp,
+ "sdpMLineIndex": Int(candidate.sdpMLineIndex),
+ ]
+ c["sdpMid"] = candidate.sdpMid ?? NSNull()
+ payload["candidate"] = c
+ }
+ else if let sdp
+ {
+ let t = Self.sdpTypeString(sdp.type)
+ payload["type"] = t
+ payload["sdp"] = [ "type": t, "sdp": sdp.sdp ]
+ }
+ else { return }
+
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
+ let json = String(data: data, encoding: .utf8)
+ else { return }
+ engine?.events?.engineDidEmitSignal(sessionId: sessionId, toPeer: peerName, payloadJSON: json)
+ }
+
+ // MARK: 发送端
+
+ private func startSenderStream()
+ {
+ cLock.lock()
+ if senderStarted { cLock.unlock(); return }
+ senderStarted = true
+ cLock.unlock()
+ streamQueue.async { [weak self] in self?.streamFile() }
+ }
+
+ // streamFile 把文件按 64KB 分片经数据通道发出,水位回压与桌面 pion / web 引擎一致:缓冲将越
+ // HIGH 前等抽干到 LOW。发完 done 帧后先抽干本地缓冲、再等接收端 ack 追平总量才宣告 completed。
+ private func streamFile()
+ {
+ guard let dc else { fail("no data channel"); return }
+ let fh: FileHandle
+ do { fh = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath)) }
+ catch { fail("open \(filePath): \(error.localizedDescription)"); return }
+ defer { try? fh.close() }
+
+ if !sendText([ "type": "meta", "name": (filePath as NSString).lastPathComponent, "size": fileSize ])
+ {
+ fail("send meta"); return
+ }
+
+ while true
+ {
+ if isDone() { return }
+ let chunk: Data?
+ do { chunk = try fh.read(upToCount: Wire.chunkSize) }
+ catch { fail("read \(filePath): \(error.localizedDescription)"); return }
+ guard let chunk, !chunk.isEmpty else { break } // EOF
+
+ if dc.bufferedAmount + UInt64(chunk.count) > Wire.highWatermark
+ {
+ waitBufferLow(Wire.lowWatermark)
+ }
+ if isDone() { return }
+ if !dc.sendData(RTCDataBuffer(data: chunk, isBinary: true))
+ {
+ fail("send chunk failed (channel closed?)"); return
+ }
+ addSent(Int64(chunk.count))
+ emitProgress()
+ }
+
+ _ = sendText([ "type": "done" ])
+ waitDrained()
+ waitAck(fileSize)
+ emitProgressNow()
+ setState("completed")
+ }
+
+ @discardableResult
+ private func sendText(_ dict: [String: Any]) -> Bool
+ {
+ guard let dc, let data = try? JSONSerialization.data(withJSONObject: dict) else { return false }
+ return dc.sendData(RTCDataBuffer(data: data, isBinary: false))
+ }
+
+ // 等本地缓冲落到 threshold 以下:靠 didChangeBufferedAmount 唤醒 bufSem,兼 50ms 轮询兜底。
+ private func waitBufferLow(_ threshold: UInt64)
+ {
+ let start = Date()
+ var warned = false
+ while true
+ {
+ guard let dc else { return }
+ if dc.bufferedAmount <= threshold { return }
+ if isDone() { return }
+ _ = bufSem.wait(timeout: .now() + 0.05)
+ if !warned, Date().timeIntervalSince(start) > 5
+ {
+ warned = true
+ log("send stalled >5s: buffered=\(dc.bufferedAmount) sent=\(getSent()) acked=\(getAcked())")
+ }
+ }
+ }
+
+ // 等本地缓冲彻底抽干(done 帧也已离开本端 SCTP)。
+ private func waitDrained()
+ {
+ while true
+ {
+ guard let dc else { return }
+ if dc.bufferedAmount == 0 { return }
+ if isDone() { return }
+ _ = bufSem.wait(timeout: .now() + 0.02)
+ }
+ }
+
+ // 等接收端 ack 追平 total(确认已全收)。超时兜底;无任何 ack 则不空等(对齐 session.go waitAck)。
+ private func waitAck(_ total: Int64)
+ {
+ if getAcked() == 0 { return }
+ let deadline = Date().addingTimeInterval(Wire.ackCompleteTimeout)
+ while getAcked() < total
+ {
+ if Date() > deadline { return }
+ if isDone() { return }
+ _ = bufSem.wait(timeout: .now() + 0.05)
+ }
+ }
+
+ // MARK: 接收端
+
+ private func bindReceiverDataChannel(_ channel: RTCDataChannel)
+ {
+ dc = channel
+ channel.delegate = self
+ startAckLoop()
+ }
+
+ // 每 200ms 把最新已收字节回传发送端(节流,避免每片都回与下行争用通道)。
+ private func startAckLoop()
+ {
+ let timer = DispatchSource.makeTimerSource(queue: streamQueue)
+ timer.schedule(deadline: .now() + .milliseconds(Wire.ackIntervalMs),
+ repeating: .milliseconds(Wire.ackIntervalMs))
+ var lastSent: Int64 = -1
+ timer.setEventHandler
+ { [weak self] in
+ guard let self else { return }
+ let r = self.getReceived()
+ if r != lastSent { self.sendAck(r); lastSent = r }
+ }
+ // 若 cleanup 已先发生(极少见:didOpen 与 cancel 同刻),不再挂表,避免泄漏一个活计时器。
+ stateLock.lock()
+ if cleanedUp { stateLock.unlock(); timer.cancel(); return }
+ ackTimer = timer
+ stateLock.unlock()
+ timer.resume()
+ }
+
+ private func sendAck(_ bytes: Int64)
+ {
+ guard let dc, dc.readyState == .open,
+ let data = try? JSONSerialization.data(withJSONObject: [ "type": "ack", "bytes": bytes ])
+ else { return }
+ _ = dc.sendData(RTCDataBuffer(data: data, isBinary: false))
+ }
+
+ // 文本控制帧:发送端处理 ack,接收端处理 meta / done。角色守护避免误处理对端不该发的帧。
+ private func handleControl(_ data: Data)
+ {
+ guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
+ let type = obj["type"] as? String
+ else { return }
+ switch type
+ {
+ case "meta":
+ if role == .receiver { openOutput(name: obj["name"] as? String ?? "download") }
+ case "done":
+ if role == .receiver { finalizeReceive() }
+ case "ack":
+ if role == .sender, let b = (obj["bytes"] as? NSNumber)?.int64Value
+ {
+ setAcked(b)
+ bufSem.signal() // 唤醒 waitAck / waitDrained
+ emitProgress()
+ }
+ default:
+ break
+ }
+ }
+
+ private func openOutput(name: String)
+ {
+ outName = name
+ let dir = engine?.downloadDir() ?? NSTemporaryDirectory()
+ let tmp = (dir as NSString).appendingPathComponent(".\(sessionId).part")
+ tmpPath = tmp
+ FileManager.default.createFile(atPath: tmp, contents: nil)
+ let handle = FileHandle(forWritingAtPath: tmp)
+ outLock.lock(); outHandle = handle; outLock.unlock()
+ if handle == nil { fail("create \(tmp)") }
+ }
+
+ // 直接写盘——无 base64 桥、无 OPFS、无 WebView 内存。dc 委托串行投递,写入天然有序。
+ private func writeChunk(_ data: Data)
+ {
+ // 持 outLock 整段写:与 cleanup 的关句柄互斥,杜绝写到已关闭 / 已释放的句柄(见 outHandle 注释)。
+ outLock.lock()
+ if let h = outHandle
+ {
+ do { try h.write(contentsOf: data) }
+ catch { outLock.unlock(); fail("write chunk: \(error.localizedDescription)"); return }
+ }
+ outLock.unlock()
+ _ = addReceived(Int64(data.count))
+ emitProgress()
+ }
+
+ // 收到 done=所有分片已到(ordered),receivedBytes 已等于总量。回最终 ack、关文件、改名落到
+ // 不冲突路径,报告路径与 completed。命名避开 NSObject 已弃用的 finalize()(否则被判为 override)。
+ private func finalizeReceive()
+ {
+ sendAck(getReceived())
+ outLock.lock(); let h = outHandle; outHandle = nil; outLock.unlock()
+ if let h
+ {
+ try? h.close()
+ let dir = engine?.downloadDir() ?? NSTemporaryDirectory()
+ let final = Self.nonCollidingPath(dir: dir, name: outName)
+ do { try FileManager.default.moveItem(atPath: tmpPath, toPath: final) }
+ catch { fail("rename to \(final): \(error.localizedDescription)"); return }
+ engine?.events?.engineDidSave(sessionId: sessionId, path: final)
+ }
+ emitProgressNow()
+ setState("completed")
+ }
+
+ // MARK: 进度 / 状态 / 收尾
+
+ // 当前应上报的已传字节:发送端优先用接收端 ack 追平值(无 ack 时回退本地已交付估计),
+ // 接收端用已写盘字节(对齐 session.go progressBytes)。
+ private func progressBytes() -> Int64
+ {
+ if role == .sender
+ {
+ let a = getAcked()
+ if a > 0 { return min(fileSize, a) }
+ let buffered = Int64(dc?.bufferedAmount ?? 0)
+ let b = getSent() - buffered
+ return b < 0 ? 0 : b
+ }
+ return getReceived()
+ }
+
+ // 节流上报(~10Hz):高吞吐下每片一回调把宿主主线程打满(每片过桥 evaluateJavaScript)。
+ private func emitProgress()
+ {
+ let now = Int64(DispatchTime.now().uptimeNanoseconds)
+ cLock.lock()
+ if now - lastEmitNs < Wire.progressThrottleNs { cLock.unlock(); return }
+ lastEmitNs = now
+ cLock.unlock()
+ engine?.events?.engineDidProgress(sessionId: sessionId, bytes: progressBytes())
+ }
+
+ // 无视节流强发一次:收尾确保最终字节数到达宿主(否则末次进度被节流吞掉、文案停在 99%)。
+ private func emitProgressNow()
+ {
+ cLock.lock(); lastEmitNs = Int64(DispatchTime.now().uptimeNanoseconds); cLock.unlock()
+ engine?.events?.engineDidProgress(sessionId: sessionId, bytes: progressBytes())
+ }
+
+ private func setState(_ st: String)
+ {
+ stateLock.lock()
+ // 终态不可被覆盖:completed/failed 之后即便连接随后 closed/failed 也不回退。
+ if state == st || state == "completed" || state == "failed"
+ {
+ stateLock.unlock(); return
+ }
+ state = st
+ stateLock.unlock()
+
+ engine?.events?.engineDidChangeState(sessionId: sessionId, state: st)
+ switch st
+ {
+ case "failed", "closed":
+ cleanup()
+ case "completed":
+ // 不立即拆连接:接收端刚发最终 ack;高吞吐下反向 ack 可能未达发送端,此刻关通道会让发送端
+ // 误判失败。留连接,ackLoop 续发;60s 兜底防泄漏。真正拆除交宿主 SSE 终态驱动的 cancel。
+ streamQueue.asyncAfter(deadline: .now() + 60) { [weak self] in self?.cleanup() }
+ default:
+ break
+ }
+ }
+
+ func fail(_ msg: String)
+ {
+ log("fail: \(msg)")
+ setState("failed")
+ }
+
+ func cleanup()
+ {
+ stateLock.lock()
+ if cleanedUp { stateLock.unlock(); return }
+ cleanedUp = true
+ doneFlag = true
+ let timer = ackTimer // 与 startAckLoop 的赋值经 stateLock 互斥,避免 ackTimer ARC 竞争
+ ackTimer = nil
+ stateLock.unlock()
+
+ bufSem.signal() // 唤醒任何在等缓冲 / ack 的发送循环
+ timer?.cancel()
+ // outHandle 经 outLock 取出再关:与 writeChunk 互斥(writeChunk 持锁期间 cleanup 在此阻塞,
+ // 待其写完再关),杜绝并发 close/write 与 ARC 竞争。
+ outLock.lock(); let h = outHandle; outHandle = nil; outLock.unlock()
+ if let h { try? h.close() }
+ dc?.close()
+ pc?.close()
+ engine?.remove(peerName, self)
+ }
+
+ // MARK: 计数器(cLock 守护)
+
+ private func addSent(_ n: Int64) { cLock.lock(); bytesSent += n; cLock.unlock() }
+ private func getSent() -> Int64 { cLock.lock(); defer { cLock.unlock() }; return bytesSent }
+ private func addReceived(_ n: Int64) -> Int64
+ {
+ cLock.lock(); receivedBytes += n; let r = receivedBytes; cLock.unlock(); return r
+ }
+ private func getReceived() -> Int64 { cLock.lock(); defer { cLock.unlock() }; return receivedBytes }
+ private func setAcked(_ v: Int64) { cLock.lock(); if v > ackedBytes { ackedBytes = v }; cLock.unlock() }
+ private func getAcked() -> Int64 { cLock.lock(); defer { cLock.unlock() }; return ackedBytes }
+ private func isDone() -> Bool { stateLock.lock(); defer { stateLock.unlock() }; return doneFlag }
+
+ // MARK: 选中候选对(连接建立后异步取,best-effort,仅供调试面板)
+
+ private func logSelectedPair()
+ {
+ // 不用 streamQueue:发送端的 streamFile 会长期占用它(背压等待中),用全局队列才能在传输期间
+ // 实时取到选中候选对(调试面板用),而非等传输结束。
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3)
+ { [weak self] in self?.querySelectedPair(attempt: 0) }
+ }
+
+ private func querySelectedPair(attempt: Int)
+ {
+ guard attempt < 12, !isDone(), let pc else { return }
+ pc.statistics
+ { [weak self] report in
+ guard let self else { return }
+ if let pair = Self.extractSelectedPair(report)
+ {
+ self.log("ice pair: local=\(pair.0) remote=\(pair.1)")
+ self.engine?.events?.engineDidSelectIcePair(sessionId: self.sessionId,
+ local: pair.0, remote: pair.1)
+ }
+ else
+ {
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3)
+ { [weak self] in self?.querySelectedPair(attempt: attempt + 1) }
+ }
+ }
+ }
+
+ // MARK: 静态助手
+
+ private static func parseICEServers(_ raw: String) -> [RTCIceServer]
+ {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8),
+ let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]]
+ else { return [] }
+ var out: [RTCIceServer] = []
+ for s in arr
+ {
+ var urls: [String] = []
+ if let one = s["urls"] as? String { urls = [one] }
+ else if let many = s["urls"] as? [String] { urls = many }
+ if urls.isEmpty { continue }
+ let username = s["username"] as? String
+ let credential = s["credential"] as? String
+ if let username, let credential, !username.isEmpty
+ {
+ out.append(RTCIceServer(urlStrings: urls, username: username, credential: credential))
+ }
+ else
+ {
+ out.append(RTCIceServer(urlStrings: urls))
+ }
+ }
+ return out
+ }
+
+ // 返回 dir 下不与现有文件冲突的目标路径:name、name (1)、name (2)……(对齐 wire.go nonCollidingPath)。
+ private static func nonCollidingPath(dir: String, name: String) -> String
+ {
+ let fm = FileManager.default
+ let base = (dir as NSString).appendingPathComponent(name)
+ if !fm.fileExists(atPath: base) { return base }
+ let ext = (name as NSString).pathExtension
+ let stem = (name as NSString).deletingPathExtension
+ var i = 1
+ while true
+ {
+ let candidate = ext.isEmpty ? "\(stem) (\(i))" : "\(stem) (\(i)).\(ext)"
+ let p = (dir as NSString).appendingPathComponent(candidate)
+ if !fm.fileExists(atPath: p) { return p }
+ i += 1
+ }
+ }
+
+ private static func extractSelectedPair(_ report: RTCStatisticsReport) -> (String, String)?
+ {
+ let stats = report.statistics
+ var localId: String?
+ var remoteId: String?
+ for (_, s) in stats where s.type == "candidate-pair"
+ {
+ let v = s.values
+ let succeeded = (v["state"] as? String) == "succeeded"
+ let nominated = (v["nominated"] as? NSNumber)?.boolValue ?? false
+ if succeeded, nominated
+ {
+ localId = v["localCandidateId"] as? String
+ remoteId = v["remoteCandidateId"] as? String
+ break
+ }
+ }
+ guard let localId, let remoteId else { return nil }
+ func label(_ id: String) -> String
+ {
+ guard let c = stats[id] else { return "?" }
+ let t = (c.values["candidateType"] as? String) ?? "?"
+ let proto = (c.values["protocol"] as? String) ?? "?"
+ return "\(t)/\(proto)"
+ }
+ return (label(localId), label(remoteId))
+ }
+
+ private static func sdpTypeString(_ t: RTCSdpType) -> String
+ {
+ switch t
+ {
+ case .offer: return "offer"
+ case .answer: return "answer"
+ case .prAnswer: return "pranswer"
+ case .rollback: return "rollback"
+ @unknown default: return "offer"
+ }
+ }
+
+ private static func iceConnString(_ s: RTCIceConnectionState) -> String
+ {
+ switch s
+ {
+ case .new: return "new"
+ case .checking: return "checking"
+ case .connected: return "connected"
+ case .completed: return "completed"
+ case .failed: return "failed"
+ case .disconnected: return "disconnected"
+ case .closed: return "closed"
+ case .count: return "count"
+ @unknown default: return "?"
+ }
+ }
+
+ // 从候选 SDP 串提取 "type/proto"(仅日志,best-effort):
+ // candidate: typ ...
+ private static func candTypeString(_ sdp: String) -> String
+ {
+ let parts = sdp.split(separator: " ").map(String.init)
+ var typ = "?"
+ var proto = "?"
+ if parts.count > 2 { proto = parts[2].lowercased() }
+ if let i = parts.firstIndex(of: "typ"), i + 1 < parts.count { typ = parts[i + 1] }
+ return "\(typ)/\(proto)"
+ }
+
+ private func log(_ msg: String)
+ {
+ engine?.events?.engineDidLog("session \(sessionId) \(msg)")
+ }
+}
+
+// MARK: - RTCPeerConnectionDelegate(回调在 libwebrtc 信令线程,非主线程)
+
+extension P2PTransferSession: RTCPeerConnectionDelegate
+{
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {}
+ func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {}
+ func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
+ func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState)
+ {
+ log("ice: \(Self.iceConnString(newState))")
+ }
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState)
+ {
+ if newState == .complete { log("local gathering done") }
+ }
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate)
+ {
+ log("cand local: \(Self.candTypeString(candidate.sdp))")
+ emitSignal(sdp: nil, candidate: candidate)
+ }
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
+
+ func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel)
+ {
+ if role == .receiver { bindReceiverDataChannel(dataChannel) }
+ }
+
+ // 组合 ice+dtls 连接态(等价 session.go OnConnectionStateChange):驱动 connected/failed/closed。
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState)
+ {
+ switch newState
+ {
+ case .connected:
+ setState("connected")
+ logSelectedPair()
+ case .failed:
+ fail("peerconnection failed")
+ case .closed:
+ setState("closed")
+ default:
+ break
+ }
+ }
+}
+
+// MARK: - RTCDataChannelDelegate(回调在 libwebrtc 信令线程)
+
+extension P2PTransferSession: RTCDataChannelDelegate
+{
+ func dataChannelDidChangeState(_ dataChannel: RTCDataChannel)
+ {
+ // 发送端:通道就绪即开始 streamFile(等价 session.go dc.OnOpen → go streamFile)。
+ if role == .sender, dataChannel.readyState == .open { startSenderStream() }
+ }
+
+ func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer)
+ {
+ if buffer.isBinary { writeChunk(buffer.data) }
+ else { handleControl(buffer.data) }
+ }
+
+ func dataChannel(_ dataChannel: RTCDataChannel, didChangeBufferedAmount amount: UInt64)
+ {
+ if amount <= Wire.lowWatermark { bufSem.signal() }
+ }
+}
diff --git a/ios/CDrop/Sources/Engine/NativeTransfer.swift b/ios/CDrop/Sources/Engine/NativeTransfer.swift
new file mode 100644
index 0000000..fdbf69e
--- /dev/null
+++ b/ios/CDrop/Sources/Engine/NativeTransfer.swift
@@ -0,0 +1,74 @@
+import Foundation
+
+// P2PEventsBridge:libwebrtc 原生数据面引擎(LibWebRtcEngine)的事件宿主。引擎在 libwebrtc 信令
+// 线程(非主线程)触发这些回调,每条都用 DispatchQueue.main.async 跳回主线程再交 EngineController
+// 过桥给无头 JS 引擎(evaluateJavaScript 仅限主线程)。
+//
+// 用 DispatchQueue.main.async 而非 Task { @MainActor }:前者对同源提交保持 FIFO,使
+// connected → completed 等有序状态不被乱序处理(Task 无顺序保证,completed 抢在 connected 前
+// 处理会把终态回退)。assumeIsolated 把主队列闭包桥进 EngineController 的 @MainActor 隔离域。
+//
+// 与桌面 desktop/app.go 的 transferEvents 等价——桌面经 Wails EventsEmit,iOS 经 cdropEngine 桥。
+// 事件契约见 LibWebRtcEngine.swift 的 P2PEngineDelegate;JS 侧消费见
+// web/src/features/transfer/p2pIos.ts(事件名 / payload 形状与桌面 NativeP2PHandlers 同形)。
+final class P2PEventsBridge: P2PEngineDelegate
+{
+ weak var controller: EngineController?
+
+ // 已传字节(发送端=接收端 ack 追平、接收端=已写盘)。
+ func engineDidProgress(sessionId: String, bytes: Int64)
+ {
+ emit("p2pProgress", [ "sessionId": sessionId, "bytes": bytes ])
+ }
+
+ // 会话状态:connected / completed / failed / closed。
+ func engineDidChangeState(sessionId: String, state: String)
+ {
+ emit("p2pState", [ "sessionId": sessionId, "state": state ])
+ }
+
+ // 出站信令(offer/answer/ice):payloadJSON 为信令 JSON 串,JS 侧 POST /api/hub/signal 前
+ // 解析(payload 透传字符串再 JSON.parse,与桌面 p2pNative onSignal 同形)。
+ func engineDidEmitSignal(sessionId: String, toPeer: String, payloadJSON: String)
+ {
+ emit("p2pSignal", [
+ "sessionId": sessionId,
+ "to": toPeer,
+ "payload": payloadJSON,
+ ])
+ }
+
+ // 接收端落盘完成的绝对路径(沙盒 Documents)。落盘提示由原生 transferDone 链路负责,JS 侧
+ // p2pIos onSaved 当前为 no-op;事件仍过桥以备用。
+ func engineDidSave(sessionId: String, path: String)
+ {
+ emit("p2pSaved", [ "sessionId": sessionId, "path": path ])
+ }
+
+ // 选中候选对(形如 "host/udp"):填进传输记录的 iceStats 供调试面板展示实际连通路径。
+ func engineDidSelectIcePair(sessionId: String, local: String, remote: String)
+ {
+ emit("p2pIcePair", [
+ "sessionId": sessionId,
+ "local": local,
+ "remote": remote,
+ ])
+ }
+
+ // 引擎诊断日志(候选 / ICE 态 / 选中对 / 发送停滞):直接进原生日志环形缓冲(#3 日志界面),
+ // 不过 JS——诊断由原生侧呈现,便于真机现场排查同内网 mDNS/ICE 时间线。
+ func engineDidLog(_ line: String)
+ {
+ let msg = line
+ DispatchQueue.main.async { [weak controller] in
+ MainActor.assumeIsolated { controller?.appendNativeLog(msg) }
+ }
+ }
+
+ private func emit(_ name: String, _ payload: [String: Any])
+ {
+ DispatchQueue.main.async { [weak controller] in
+ MainActor.assumeIsolated { controller?.sendCommand(name, payload: payload) }
+ }
+ }
+}
diff --git a/ios/CDrop/Sources/Fonts/README.md b/ios/CDrop/Sources/Fonts/README.md
new file mode 100644
index 0000000..97bbe8d
--- /dev/null
+++ b/ios/CDrop/Sources/Fonts/README.md
@@ -0,0 +1,40 @@
+# 品牌字体(不入库,构建前拉取)
+
+iOS App 的字体栈对齐 web(见 `web/src/styles/fonts.css`):
+
+| 角色 | 字体 | 落地 | 来源 |
+|---|---|---|---|
+| Sans(正文 / 界面) | **Orbit Gothic CJK SC**(Regular 400 + SemiBold 600) | `Sources/Fonts/OrbitGothicCJKsc-*.woff2` | 私有 CDN `orbit-gothic.cdn.commilitia.net` |
+| Mono(等宽) | **Maple Mono**(Regular) | `Sources/Fonts/MapleMono-Regular.woff2` | 私有 CDN `maple-mono.cdn.commilitia.net`(OFL) |
+| 字标(Wordmark) | **Fredoka SemiBold** | `Shared/Fonts/Fredoka-SemiBold.ttf` | Google Fonts 变量字体实例化(OFL) |
+
+## 为什么不提交进仓库
+
+- **Orbit Gothic 是专有 / 定制字体**——公开仓库(见根 README 的双 remote 发布)不应分发它。web 端同样不入库、
+ 改从私有 CDN 拉。
+- Maple Mono / Fredoka 虽开源(OFL),也统一走拉取,保持仓库精简(Orbit 两变体 woff2 合计 ~20MB)。
+
+字体文件经 `.gitignore` 排除(`Sources/Fonts/*.woff2,*.ttf`、`Shared/Fonts/*`);本 README 入库作说明。
+
+## 如何备齐
+
+```sh
+just ios-fonts
+```
+
+幂等(已存在则跳过)。`just ios-device` / `just ios-sim-build` 构建前已自动 chain 本步,正常无需手动跑。
+
+**前置**:`curl`;造 Fredoka 静态实例需 `python3` + `fonttools`(`python3 -m pip install --user fonttools`)。
+CDN 不可达时构建会失败——离线则手动把上述文件放到对应目录即可。
+
+## 接入方式(代码)
+
+字体以 **woff2** 内嵌(体积约 TTF 的 1/3;iOS 13+ Core Text 原生支持 woff/woff2)。`UIAppFonts`(Info.plist)
+历史上只认 ttf/otf,故 woff2 走**运行期注册** `CTFontManagerRegisterFontsForURL`——见 `Sources/BrandFonts.swift`
+的 `BrandFonts.register()`(`AppDelegate` 启动调用)。Fredoka 字标是 ttf,经 `UIAppFonts` 注册。
+
+仅**主 app** 接入 Orbit / Maple(CJK 字体大,不进 Share / 控件扩展——它们保持系统字 + 仅 Fredoka 字标)。
+生僻字超出 Orbit Gothic 字汇时由 iOS 字体级联自动回退系统苹方,不出豆腐块。
+
+字体助手见 `Sources/BrandFonts.swift`:`Font.oBody/.oHeadline/...`(Orbit,对位 SF 语义样式)、`Font.maple(...)`
+(Maple Mono);导航 / 标签栏标题经 UIKit appearance 代理换 Orbit。
diff --git a/ios/CDrop/Sources/Info.plist b/ios/CDrop/Sources/Info.plist
index 97c4327..887a9f3 100644
--- a/ios/CDrop/Sources/Info.plist
+++ b/ios/CDrop/Sources/Info.plist
@@ -58,6 +58,10 @@
Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。
NSPhotoLibraryUsageDescription
Commilitia Drop 需要访问照片库,以便从相册选取图片或视频发送给其他设备。
+ UIAppFonts
+
+ Fredoka-SemiBold.ttf
+
UIBackgroundModes
remote-notification
diff --git a/ios/CDrop/Sources/LogView.swift b/ios/CDrop/Sources/LogView.swift
index 5e7ff23..92c852f 100644
--- a/ios/CDrop/Sources/LogView.swift
+++ b/ios/CDrop/Sources/LogView.swift
@@ -1,12 +1,37 @@
import SwiftUI
+import UIKit
-// 引擎日志独立界面(#3):展示 EngineController.logs 环形缓冲里最近 N 条 console.warn/error,分级配色、
-// 可选中复制、可一键清空。从设置页「查看日志」进入。便于在真机上(无 Web Inspector)定位 SSE / 鉴权 /
-// 传输失败原因——此前只能在设置页看到孤零零一行 lastEngineLog。
+// 引擎日志独立界面(#3):展示 EngineController.logs 环形缓冲里最近 N 条诊断(含 pion 内部 ICE/mDNS
+// 日志)。按传输会话(session id)聚合分组,每组可一键整体复制——便于真机调试时把某次传输的完整
+// 日志一并贴出(无 Web Inspector)。另有「复制全部」「清空」。
struct LogView: View
{
@Environment(EngineController.self) private var engine
+ private struct LogGroup: Identifiable
+ {
+ let id: String
+ let session: String?
+ let entries: [LogEntry]
+ }
+
+ // 按 sessionId 聚合:保持组内最新在前;组按首次出现(即最新条目)顺序。非会话日志归入「其他」组。
+ private var groups: [LogGroup]
+ {
+ var order: [String] = []
+ var map: [String: [LogEntry]] = [:]
+ for e in engine.logs
+ {
+ let key = e.sessionId ?? ""
+ if map[key] == nil { order.append(key) }
+ map[key, default: []].append(e)
+ }
+ return order.map
+ {
+ LogGroup(id: $0.isEmpty ? "—" : $0, session: $0.isEmpty ? nil : $0, entries: map[$0] ?? [])
+ }
+ }
+
var body: some View
{
Group
@@ -19,9 +44,23 @@ struct LogView: View
{
List
{
- ForEach(engine.logs)
- { entry in
- logRow(entry)
+ ForEach(groups)
+ { group in
+ Section
+ {
+ ForEach(group.entries) { entry in logRow(entry) }
+ }
+ header:
+ {
+ HStack
+ {
+ Text(sessionLabel(group))
+ Spacer()
+ Button { copy(group.entries) }
+ label: { Image(systemName: "doc.on.doc") }
+ .buttonStyle(.borderless)
+ }
+ }
}
}
.listStyle(.plain)
@@ -33,15 +72,36 @@ struct LogView: View
{
if !engine.logs.isEmpty
{
+ ToolbarItem(placement: .topBarTrailing)
+ {
+ Button { copy(engine.logs) }
+ label: { Label(t("ios.logs.copy"), systemImage: "doc.on.doc") }
+ }
ToolbarItem(placement: .topBarTrailing)
{
Button(role: .destructive) { engine.clearLogs() }
- label: { Label(t("ios.logs.clear"), systemImage: "trash") }
+ label: { Label(t("ios.logs.clear"), systemImage: "trash") }
}
}
}
}
+ // 复制为纯文本进剪贴板:存储为最新在前,复制时反转成时间正序便于阅读。
+ private func copy(_ entries: [LogEntry])
+ {
+ let text = entries.reversed()
+ .map { "\(timeString($0.at)) \($0.message)" }
+ .joined(separator: "\n")
+ UIPasteboard.general.string = text
+ }
+
+ private func sessionLabel(_ group: LogGroup) -> String
+ {
+ guard let s = group.session
+ else { return "\(t("ios.logs.ungrouped")) · \(group.entries.count)" }
+ return "\(t("ios.logs.session")) \(String(s.prefix(8))) · \(group.entries.count)"
+ }
+
private func logRow(_ entry: LogEntry) -> some View
{
VStack(alignment: .leading, spacing: 4)
@@ -49,10 +109,10 @@ struct LogView: View
HStack(spacing: 6)
{
Image(systemName: levelSymbol(entry.level))
- .font(.caption)
+ .font(.oCaption)
.foregroundStyle(levelTint(entry.level))
Text(timeString(entry.at))
- .font(.caption2)
+ .font(.oCaption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
diff --git a/ios/CDrop/Sources/MessagesView.swift b/ios/CDrop/Sources/MessagesView.swift
index 6db42e6..b7e022c 100644
--- a/ios/CDrop/Sources/MessagesView.swift
+++ b/ios/CDrop/Sources/MessagesView.swift
@@ -1,66 +1,51 @@
import SwiftUI
-// 设备间文本消息(#13):聊天式列表 + 底部撰写栏。消息由引擎经桥推来(in-memory,关 app 即清,
-// 对齐 web 语义),收发都经 POST /api/message(hub 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 = ""
}
}
diff --git a/ios/CDrop/Sources/NotificationBanner.swift b/ios/CDrop/Sources/NotificationBanner.swift
index ddd975b..5bc775c 100644
--- a/ios/CDrop/Sources/NotificationBanner.swift
+++ b/ios/CDrop/Sources/NotificationBanner.swift
@@ -35,10 +35,10 @@ private struct NoticeBanner: View
HStack(spacing: 10)
{
Image(systemName: symbol)
- .font(.headline)
+ .font(.oHeadline)
.foregroundStyle(tint)
Text(notice.text)
- .font(.subheadline)
+ .font(.oSubheadline)
.foregroundStyle(.primary)
.lineLimit(2)
Spacer(minLength: 8)
diff --git a/ios/CDrop/Sources/Resources/i18n/en-US.json b/ios/CDrop/Sources/Resources/i18n/en-US.json
index dc08aac..4e09af4 100644
--- a/ios/CDrop/Sources/Resources/i18n/en-US.json
+++ b/ios/CDrop/Sources/Resources/i18n/en-US.json
@@ -271,6 +271,7 @@
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/s",
+ "transfer.avgRate": "Avg {{rate}}/s",
"transfer.debug.toggle": "Debug",
"transfer.debug.iceGathering": "Gathering",
"transfer.debug.iceConnection": "Connection",
@@ -354,13 +355,20 @@
"ios.tab.messages": "Messages",
"ios.send.fromFiles": "From Files",
"ios.send.fromPhotos": "From Photos",
+ "ios.send.previewTitle": "Send Files",
+ "ios.send.files": "Files",
+ "ios.send.send": "Send",
"ios.messages.empty": "No messages yet",
"ios.messages.noPeers": "No device to message",
"ios.messages.placeholder": "Type a message…",
+ "ios.messages.viewOnly": "Offline — viewing only",
+ "ios.messages.deleteConversation": "Delete Conversation",
+ "ios.messages.startHint": "Tap to start a conversation",
+ "ios.messages.youPrefix": "You: ",
"ios.records.clear": "Clear",
"ios.records.clearConfirm": "Clear all records? This cannot be undone.",
"ios.transfer.cancel": "Cancel transfer",
- "ios.transfer.forceRelay": "Relay now",
+ "ios.transfer.forceRelay": "Relay",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
@@ -375,6 +383,11 @@
"ios.bg.title": "Transferring files",
"ios.bg.subtitle": "Continues in the background",
"ios.share.title": "Send {{n}} file(s) to a device",
+ "ios.share.guide.preparing": "Preparing…",
+ "ios.share.guide.ready": "{{n}} file(s) ready",
+ "ios.share.guide.empty": "Couldn't add files",
+ "ios.share.guide.hint": "Open Commilitia Drop to pick a device",
+ "ios.share.guide.open": "Open Commilitia Drop",
"ios.control.upload": "Upload Clipboard",
"ios.control.pull": "Pull Clipboard",
"ios.control.sessionLabel": "{{name}} · Control",
@@ -383,6 +396,8 @@
"ios.logs.empty": "No logs yet",
"ios.logs.clear": "Clear Logs",
"ios.logs.copy": "Copy All",
+ "ios.logs.session": "Session",
+ "ios.logs.ungrouped": "Other",
"ios.scan.entry": "Scan to Sign In",
"ios.scan.title": "Scan QR Code",
"ios.scan.hint": "Point at the sign-in QR code on another device",
diff --git a/ios/CDrop/Sources/Resources/i18n/zh-CN.json b/ios/CDrop/Sources/Resources/i18n/zh-CN.json
index a631759..a28c048 100644
--- a/ios/CDrop/Sources/Resources/i18n/zh-CN.json
+++ b/ios/CDrop/Sources/Resources/i18n/zh-CN.json
@@ -271,6 +271,7 @@
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
+ "transfer.avgRate": "平均 {{rate}}/秒",
"transfer.debug.toggle": "调试信息",
"transfer.debug.iceGathering": "候选收集",
"transfer.debug.iceConnection": "连接状态",
@@ -354,13 +355,20 @@
"ios.tab.messages": "消息",
"ios.send.fromFiles": "从文件",
"ios.send.fromPhotos": "从图库",
+ "ios.send.previewTitle": "发送文件",
+ "ios.send.files": "文件",
+ "ios.send.send": "发送",
"ios.messages.empty": "暂无消息",
"ios.messages.noPeers": "没有可发送消息的设备",
"ios.messages.placeholder": "输入消息…",
+ "ios.messages.viewOnly": "对方当前离线,仅可查看记录",
+ "ios.messages.deleteConversation": "删除会话",
+ "ios.messages.startHint": "点按开始会话",
+ "ios.messages.youPrefix": "你:",
"ios.records.clear": "清空",
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
"ios.transfer.cancel": "取消传输",
- "ios.transfer.forceRelay": "立即切到中继",
+ "ios.transfer.forceRelay": "中继传输",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
@@ -375,6 +383,11 @@
"ios.bg.title": "正在传输文件",
"ios.bg.subtitle": "切到后台仍继续传输",
"ios.share.title": "发送 {{n}} 个文件到设备",
+ "ios.share.guide.preparing": "正在准备…",
+ "ios.share.guide.ready": "已就绪 {{n}} 个文件",
+ "ios.share.guide.empty": "未能添加文件",
+ "ios.share.guide.hint": "打开 Commilitia Drop,选择设备发送",
+ "ios.share.guide.open": "打开 Commilitia Drop",
"ios.control.upload": "上传剪贴板",
"ios.control.pull": "拉取剪贴板",
"ios.control.sessionLabel": "{{name}} · 控件",
@@ -383,6 +396,8 @@
"ios.logs.empty": "暂无日志",
"ios.logs.clear": "清空日志",
"ios.logs.copy": "复制全部",
+ "ios.logs.session": "会话",
+ "ios.logs.ungrouped": "其他",
"ios.scan.entry": "扫码登录设备",
"ios.scan.title": "扫码登录",
"ios.scan.hint": "对准另一台设备上的登录二维码",
diff --git a/ios/CDrop/Sources/Resources/i18n/zh-TW.json b/ios/CDrop/Sources/Resources/i18n/zh-TW.json
index a29a323..9a82128 100644
--- a/ios/CDrop/Sources/Resources/i18n/zh-TW.json
+++ b/ios/CDrop/Sources/Resources/i18n/zh-TW.json
@@ -271,6 +271,7 @@
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
+ "transfer.avgRate": "平均 {{rate}}/秒",
"transfer.debug.toggle": "偵錯資訊",
"transfer.debug.iceGathering": "候選收集",
"transfer.debug.iceConnection": "連線狀態",
@@ -354,13 +355,20 @@
"ios.tab.messages": "訊息",
"ios.send.fromFiles": "從檔案",
"ios.send.fromPhotos": "從相簿",
+ "ios.send.previewTitle": "傳送檔案",
+ "ios.send.files": "檔案",
+ "ios.send.send": "傳送",
"ios.messages.empty": "尚無訊息",
"ios.messages.noPeers": "沒有可傳送訊息的裝置",
"ios.messages.placeholder": "輸入訊息…",
+ "ios.messages.viewOnly": "對方目前離線,僅可檢視記錄",
+ "ios.messages.deleteConversation": "刪除會話",
+ "ios.messages.startHint": "點按開始會話",
+ "ios.messages.youPrefix": "你:",
"ios.records.clear": "清空",
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
"ios.transfer.cancel": "取消傳輸",
- "ios.transfer.forceRelay": "立即切到中繼",
+ "ios.transfer.forceRelay": "中繼傳輸",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
@@ -375,6 +383,11 @@
"ios.bg.title": "正在傳輸檔案",
"ios.bg.subtitle": "切到背景仍繼續傳輸",
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
+ "ios.share.guide.preparing": "正在準備…",
+ "ios.share.guide.ready": "已就緒 {{n}} 個檔案",
+ "ios.share.guide.empty": "未能加入檔案",
+ "ios.share.guide.hint": "開啟 Commilitia Drop,選擇裝置傳送",
+ "ios.share.guide.open": "開啟 Commilitia Drop",
"ios.control.upload": "上傳剪貼簿",
"ios.control.pull": "拉取剪貼簿",
"ios.control.sessionLabel": "{{name}} · 控制項",
@@ -383,6 +396,8 @@
"ios.logs.empty": "尚無日誌",
"ios.logs.clear": "清空日誌",
"ios.logs.copy": "複製全部",
+ "ios.logs.session": "會話",
+ "ios.logs.ungrouped": "其他",
"ios.scan.entry": "掃碼登入裝置",
"ios.scan.title": "掃碼登入",
"ios.scan.hint": "對準另一台裝置上的登入 QR 碼",
diff --git a/ios/CDrop/Sources/RootView.swift b/ios/CDrop/Sources/RootView.swift
index 086c1b2..fa82e24 100644
--- a/ios/CDrop/Sources/RootView.swift
+++ b/ios/CDrop/Sources/RootView.swift
@@ -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" }
diff --git a/ios/CDrop/Sources/ScannerView.swift b/ios/CDrop/Sources/ScannerView.swift
index 8f16852..ccbd7a1 100644
--- a/ios/CDrop/Sources/ScannerView.swift
+++ b/ios/CDrop/Sources/ScannerView.swift
@@ -59,14 +59,14 @@ struct ScanSheet: View
if invalid
{
Text(t("ios.scan.invalid"))
- .font(.callout)
+ .font(.oCallout)
.padding(.vertical, 8)
.padding(.horizontal, 14)
.background(.red.opacity(0.85), in: Capsule())
.foregroundStyle(.white)
}
Text(t("ios.scan.hint"))
- .font(.footnote)
+ .font(.oFootnote)
.multilineTextAlignment(.center)
.padding(.vertical, 8)
.padding(.horizontal, 14)
diff --git a/ios/CDrop/Sources/SendComposeSheet.swift b/ios/CDrop/Sources/SendComposeSheet.swift
new file mode 100644
index 0000000..4e28eba
--- /dev/null
+++ b/ios/CDrop/Sources/SendComposeSheet.swift
@@ -0,0 +1,108 @@
+import SwiftUI
+
+// 发送预览表单:列出待发文件(名 + 大小)+ 选择目标设备 + 发送。手动选文件(TransferListView)与
+// 分享交接(RootView)共用此一表单——即「选文件 → 预览 → 选设备 → 发送」。纯 UI:不感知文件来源
+// (手动安全作用域 URL / 图库临时文件 / 分享 App Group),实际发送由调用方经 onSend(设备名) 实现
+// (手动 loop engine.sendFile;分享 engine.sendShares),故同一表单复用零分叉。
+struct PreviewFile: Identifiable
+{
+ let id = UUID()
+ let name: String
+ let size: Int
+}
+
+struct SendComposeSheet: View
+{
+ let files: [PreviewFile]
+ let devices: [DeviceItem]
+ let sendLabel: (DeviceItem) -> String
+ let onSend: (String) -> Void // 目标设备名
+ let onCancel: () -> Void
+
+ // 选中的目标设备名。onAppear 默认选第一个可达设备,减少一次点击。
+ @State private var selected = ""
+
+ var body: some View
+ {
+ NavigationStack
+ {
+ Form
+ {
+ Section(t("ios.send.files"))
+ {
+ ForEach(files)
+ { file in
+ HStack(spacing: 10)
+ {
+ Image(systemName: "doc")
+ .foregroundStyle(.secondary)
+ Text(file.name)
+ .lineLimit(1)
+ Spacer()
+ Text(formatBytes(file.size))
+ .font(.oCaption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ Section(t("ios.send.pickDevice"))
+ {
+ if devices.isEmpty
+ {
+ Text(t("ios.send.noDevices"))
+ .foregroundStyle(.secondary)
+ }
+ else
+ {
+ ForEach(devices)
+ { dev in
+ Button { selected = dev.name }
+ label:
+ {
+ HStack(spacing: 10)
+ {
+ Image(systemName: deviceSymbol(dev.type))
+ .foregroundStyle(dev.online ? Color.green : Color.secondary)
+ Text(sendLabel(dev))
+ .foregroundStyle(.primary)
+ Spacer()
+ if selected == dev.name
+ {
+ Image(systemName: "checkmark")
+ .foregroundStyle(Color.cdropAccent)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .navigationTitle(t("ios.send.previewTitle"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar
+ {
+ ToolbarItem(placement: .topBarLeading)
+ {
+ Button(t("common.cancel")) { onCancel() }
+ }
+ ToolbarItem(placement: .topBarTrailing)
+ {
+ Button(t("ios.send.send")) { onSend(selected) }
+ .fontWeight(.semibold)
+ .disabled(selected.isEmpty || files.isEmpty)
+ }
+ }
+ .onAppear { if selected.isEmpty { selected = devices.first?.name ?? "" } }
+ }
+ }
+}
+
+// 读文件字节大小(供发送预览展示)。安全作用域 URL(文件选择器)须临时声明访问才能读属性;
+// 临时文件 / App Group 文件无须声明(startAccessing 返回 false,仍可正常读),故统一走此函数。
+func fileByteSize(_ url: URL) -> Int
+{
+ let access = url.startAccessingSecurityScopedResource()
+ defer { if access { url.stopAccessingSecurityScopedResource() } }
+ return (try? url.resourceValues(forKeys: [ .fileSizeKey ]))?.fileSize ?? 0
+}
diff --git a/ios/CDrop/Tests/LibWebRtcEngineTests.swift b/ios/CDrop/Tests/LibWebRtcEngineTests.swift
new file mode 100644
index 0000000..a9d53dc
--- /dev/null
+++ b/ios/CDrop/Tests/LibWebRtcEngineTests.swift
@@ -0,0 +1,140 @@
+import XCTest
+@testable import CDrop
+
+// LibWebRtcEngine 环回端到端测试:两个引擎在同进程内交叉连,跑完整线协议
+// (offer/answer/ICE + meta/分片/done + ack + 收尾),验证收到的文件字节与源逐字节相等。
+// 这是真机部署前最强的非设备验证——证实线协议端口(分片 / 背压 / ack / finalize)在 libwebrtc 上
+// 正确闭环;不覆盖真实网络 / 吞吐(须真机)。host 候选环回直连,无需 STUN/TURN / 本地网络权限。
+final class LibWebRtcEngineTests: XCTestCase
+{
+ // 占位 delegate:仅供 LibWebRtcEngine 构造(随后重定向到 Peer)。
+ final class NullDelegate: P2PEngineDelegate
+ {
+ static let shared = NullDelegate()
+ func engineDidProgress(sessionId: String, bytes: Int64) {}
+ func engineDidChangeState(sessionId: String, state: String) {}
+ func engineDidEmitSignal(sessionId: String, toPeer: String, payloadJSON: String) {}
+ func engineDidSave(sessionId: String, path: String) {}
+ func engineDidSelectIcePair(sessionId: String, local: String, remote: String) {}
+ func engineDidLog(_ line: String) {}
+ }
+
+ // 环回事件宿主:捕获状态 / 落盘,并把出站信令「串行」转发给对端引擎——每条等对端 handleSignal
+ // 完成再投下一条,复刻 p2pIos.ts 的信令链语义(offer/answer 必先于其后 trickle 的 ICE 候选,
+ // 否则 addIceCandidate 在 remote description 未设时报错)。
+ final class Peer: P2PEngineDelegate
+ {
+ let name: String // 对端索引「我」用的设备名(转发 handleSignal 时填 fromPeer)
+ let engine: LibWebRtcEngine
+ weak var other: Peer?
+ private let signalQueue: DispatchQueue
+
+ var onState: ((String) -> Void)?
+ var onSaved: ((String) -> Void)?
+
+ init(name: String, downloadDir: String)
+ {
+ self.name = name
+ signalQueue = DispatchQueue(label: "test.signal.\(name)")
+ engine = LibWebRtcEngine(downloadDir: downloadDir, delegate: NullDelegate.shared)
+ engine.delegate = self // 重定向到自己(self 此刻全属性已初始化,可用)
+ }
+
+ func engineDidProgress(sessionId: String, bytes: Int64) {}
+
+ func engineDidChangeState(sessionId: String, state: String)
+ {
+ onState?(state)
+ }
+
+ // 出站信令 → 对端引擎,按对端串行队列逐条投递(等对端处理完再投下一条)。
+ func engineDidEmitSignal(sessionId: String, toPeer: String, payloadJSON: String)
+ {
+ guard let other else { return }
+ let fromPeer = name
+ other.signalQueue.async { [weak other] in
+ guard let other else { return }
+ let sem = DispatchSemaphore(value: 0)
+ other.engine.handleSignal(fromPeer: fromPeer, payloadJSON: payloadJSON) { _ in sem.signal() }
+ sem.wait()
+ }
+ }
+
+ func engineDidSave(sessionId: String, path: String)
+ {
+ onSaved?(path)
+ }
+
+ func engineDidSelectIcePair(sessionId: String, local: String, remote: String) {}
+ func engineDidLog(_ line: String) {}
+ }
+
+ // 端到端:20 MB 伪随机源(跨数百个 64KB 分片 + 触发 HIGH/LOW 背压),环回传输后逐字节比对。
+ func testLoopbackTransferIntegrity() throws
+ {
+ let fm = FileManager.default
+ let dirA = fm.temporaryDirectory.appendingPathComponent("cdropTestA-\(UUID().uuidString)")
+ let dirB = fm.temporaryDirectory.appendingPathComponent("cdropTestB-\(UUID().uuidString)")
+ try fm.createDirectory(at: dirA, withIntermediateDirectories: true)
+ try fm.createDirectory(at: dirB, withIntermediateDirectories: true)
+ defer { try? fm.removeItem(at: dirA); try? fm.removeItem(at: dirB) }
+
+ // 确定可重现的伪随机字节(线性同余),便于失败诊断。
+ let size = 20 * 1024 * 1024
+ var payload = Data(count: size)
+ payload.withUnsafeMutableBytes
+ { raw in
+ let p = raw.bindMemory(to: UInt8.self)
+ var s: UInt64 = 0x9E37_79B9_7F4A_7C15
+ for i in 0 ..< size
+ {
+ s = s &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
+ p[i] = UInt8(truncatingIfNeeded: s >> 33)
+ }
+ }
+ let srcURL = dirA.appendingPathComponent("payload.bin")
+ try payload.write(to: srcURL)
+
+ let senderName = "DeviceA"
+ let receiverName = "DeviceB"
+ let peerA = Peer(name: senderName, downloadDir: dirA.path) // 发送端(engineB 据 senderName 索引)
+ let peerB = Peer(name: receiverName, downloadDir: dirB.path) // 接收端(engineA 据 receiverName 索引)
+ peerA.other = peerB
+ peerB.other = peerA
+
+ let senderDone = expectation(description: "sender completed")
+ let receiverSaved = expectation(description: "receiver saved")
+ var savedPath: String?
+
+ peerA.onState =
+ { state in
+ if state == "completed" { senderDone.fulfill() }
+ else if state == "failed" { XCTFail("sender entered failed state") }
+ }
+ peerB.onSaved =
+ { path in
+ savedPath = path
+ receiverSaved.fulfill()
+ }
+ peerB.onState =
+ { state in
+ if state == "failed" { XCTFail("receiver entered failed state") }
+ }
+
+ let sid = "loopback-session-1"
+ let ice = "[]" // host 候选环回直连,无 STUN/TURN
+ // 接收端先建 PeerConnection 等 offer(生产中 transfer:incoming 先于 offer 到达)。
+ peerB.engine.startIncoming(sessionId: sid, peerName: senderName, iceServersJSON: ice)
+ { err in XCTAssertNil(err, "startIncoming error: \(String(describing: err))") }
+ peerA.engine.startOutgoing(sessionId: sid, peerName: receiverName,
+ filePath: srcURL.path, iceServersJSON: ice)
+ { err in XCTAssertNil(err, "startOutgoing error: \(String(describing: err))") }
+
+ wait(for: [senderDone, receiverSaved], timeout: 120)
+
+ let saved = try XCTUnwrap(savedPath, "receiver never reported a saved path")
+ let got = try Data(contentsOf: URL(fileURLWithPath: saved))
+ XCTAssertEqual(got.count, size, "received size mismatch")
+ XCTAssertEqual(got, payload, "received bytes differ from source")
+ }
+}
diff --git a/ios/CDrop/Widgets/CloudClipboardWidget.swift b/ios/CDrop/Widgets/CloudClipboardWidget.swift
index 1f5c32d..be53d92 100644
--- a/ios/CDrop/Widgets/CloudClipboardWidget.swift
+++ b/ios/CDrop/Widgets/CloudClipboardWidget.swift
@@ -33,34 +33,59 @@ struct ClipboardWidgetView: View
var body: some View
{
- VStack(alignment: .leading, spacing: 10)
+ Group
{
- HStack(spacing: 6)
+ if family == .systemMedium { mediumLayout } else { smallLayout }
+ }
+ .containerBackground(for: .widget) { brandBackground }
+ }
+
+ // 小尺寸:slim 品牌 header(吉祥物 + 名)+ 两个全宽按钮填满余下高度(最大化利用空间、大点按区)。
+ private var smallLayout: some View
+ {
+ VStack(spacing: 10)
+ {
+ HStack(spacing: 8)
{
- Image(systemName: "doc.on.clipboard")
- .foregroundStyle(.tint)
+ BrandMark(size: 30)
Text(t("ios.widget.clipboardName"))
- .font(.caption).bold()
+ .font(.subheadline).bold()
.lineLimit(1)
+ Spacer(minLength: 0)
}
- // 小尺寸纵向堆叠两按钮;中尺寸并排。
- if family == .systemSmall
+ actionButton(t("ios.control.upload"), "square.and.arrow.up", UploadClipboardIntent())
+ actionButton(t("ios.control.pull"), "square.and.arrow.down", PullClipboardIntent())
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(14)
+ }
+
+ // 中尺寸:左侧大吉祥物作视觉锚,右侧名 + 两按钮竖排,吃满横向空间。
+ private var mediumLayout: some View
+ {
+ HStack(spacing: 18)
+ {
+ BrandMark(size: 72)
+ VStack(alignment: .leading, spacing: 9)
{
+ Text(t("ios.widget.clipboardName"))
+ .font(.headline)
+ .lineLimit(1)
actionButton(t("ios.control.upload"), "square.and.arrow.up", UploadClipboardIntent())
actionButton(t("ios.control.pull"), "square.and.arrow.down", PullClipboardIntent())
}
- else
- {
- HStack(spacing: 10)
- {
- actionButton(t("ios.control.upload"), "square.and.arrow.up", UploadClipboardIntent())
- actionButton(t("ios.control.pull"), "square.and.arrow.down", PullClipboardIntent())
- }
- }
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
- .padding()
- .containerBackground(.fill.tertiary, for: .widget)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(16)
+ }
+
+ // 品牌底:淡品牌色斜向渐变(取代原 .fill.tertiary 灰底),与吉祥物 / 按钮同色系。
+ private var brandBackground: some View
+ {
+ LinearGradient(
+ colors: [ Color.cdropAccent.opacity(0.18), Color.cdropAccent.opacity(0.05) ],
+ startPoint: .topLeading, endPoint: .bottomTrailing)
}
private func actionButton(_ title: String, _ symbol: String, _ intent: some AppIntent) -> some View
@@ -68,11 +93,12 @@ struct ClipboardWidgetView: View
Button(intent: intent)
{
Label(title, systemImage: symbol)
- .font(.caption2)
+ .font(.caption).fontWeight(.semibold)
.lineLimit(1)
.frame(maxWidth: .infinity)
+ .padding(.vertical, 3)
}
- .buttonStyle(.bordered)
+ .buttonStyle(.borderedProminent)
.tint(.cdropAccent)
}
}
diff --git a/ios/CDrop/project.yml b/ios/CDrop/project.yml
index d9475e6..a425197 100644
--- a/ios/CDrop/project.yml
+++ b/ios/CDrop/project.yml
@@ -4,6 +4,14 @@ options:
deploymentTarget:
iOS: "26.0"
createIntermediateGroups: true
+# 原生数据面:iOS 走 libwebrtc(Safari 同款,与 iOS 网络栈原生集成)。预编译 xcframework 经
+# stasel/WebRTC 以 SPM 分发,仅二进制 target,pin 精确版本避免上游 M 版跳变改 API。替换早先
+# gomobile/pion 路径——pion 的 Go raw socket 不与 iOS Network framework 集成,真机确证不可靠
+# (见 desktop/NATIVE-TRANSFER.md §7 / cdrop-native-data-plane 记忆)。
+packages:
+ WebRTC:
+ url: https://github.com/stasel/WebRTC
+ exactVersion: 149.0.0
settings:
base:
SWIFT_VERSION: "5.0"
@@ -26,10 +34,16 @@ targets:
embed: true
- target: CDropWidgets
embed: true
+ # 原生数据面引擎(libwebrtc):SPM 预编 xcframework,Xcode 自动嵌入 + 签名。仅主 app 链接——
+ # Share / 控件扩展不碰数据面,避免超扩展 120MB 内存限。见 LibWebRtcEngine.swift。
+ - package: WebRTC
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
+ # 品牌字标字体 Fredoka SemiBold(Shared/Fonts/,随 bundle 打包),用于 Wordmark(与 web 一致)。
+ UIAppFonts:
+ - Fredoka-SemiBold.ttf
# cdrop:// URL scheme:Share Extension 交接后经 cdrop://share 深链唤起主 app 选设备发送。
CFBundleURLTypes:
- CFBundleURLName: net.commilitia.Commilitia-Drop
@@ -79,10 +93,15 @@ targets:
sources:
- path: Share
- path: Shared
+ # i18n JSON 随扩展 bundle 一并打包,使分享引导界面的 t() 文案随设备语言(同控件扩展)。
+ - path: Sources/Resources/i18n
info:
path: Share/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
+ # 品牌字标字体(分享引导界面的 Wordmark 用),随扩展 bundle 打包 + 注册。
+ UIAppFonts:
+ - Fredoka-SemiBold.ttf
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
@@ -117,10 +136,32 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_ENTITLEMENTS: Widgets/CDropWidgets.entitlements
+ # 单元 / 集成测试(模拟器,无需签名):原生数据面 libwebrtc 引擎的环回端到端测试——两个
+ # LibWebRtcEngine 在同进程内交叉连,跑完整线协议(offer/answer/ICE + meta/分片/done + ack +
+ # 收尾)验证文件字节完整性。宿主即 CDrop app(@testable import 取内部类型 + 经 app 链接 WebRTC)。
+ CDropTests:
+ type: bundle.unit-test
+ platform: iOS
+ deploymentTarget: "26.0"
+ sources:
+ - path: Tests
+ dependencies:
+ - target: CDrop
+ - package: WebRTC
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.tests
+ TEST_HOST: "$(BUILT_PRODUCTS_DIR)/CDrop.app/CDrop"
+ BUNDLE_LOADER: "$(TEST_HOST)"
+ GENERATE_INFOPLIST_FILE: "YES"
schemes:
CDrop:
build:
targets:
CDrop: all
+ test:
+ targets:
+ - CDropTests
+ gatherCoverageData: false
run:
config: Debug
diff --git a/ios/PARITY.md b/ios/PARITY.md
index 2fccf45..13fb6a1 100644
--- a/ios/PARITY.md
+++ b/ios/PARITY.md
@@ -31,7 +31,8 @@
- **视觉材质**:液态玻璃 vs Theme B。
- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。
-- **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto;间距 / 圆角 / 字阶各用平台母语。
+- **字体 / 排版**(2026-06-28 更新,字族已收敛):两端现**同用** Orbit Gothic(Sans)+ Maple Mono(Mono)+ Fredoka(字标)——字族升为共享源(强约束③延伸)。**残余分叉**仅在落地机制(iOS woff2 运行期 `CTFontManagerRegisterFontsForURL` 注册 + UIKit appearance 代理 + Dynamic Type vs web CSS `@font-face`)与间距 / 圆角 / 字阶(各用平台母语);生僻字 iOS 回退苹方、web 回退 Noto。Orbit 为专有字体,两端均不入库、构建期从 CDN 拉。
+- **分享引导界面**(仅 iOS):Share Extension 内的吉祥物引导卡(`ShareGuideView`)引用户回主程序完成发送——平台专属流程,web 无对应。
- **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。
- **控制中心控件标签**:运行时控件标签(用户点按的那个)经 `t()` 跟随设备语言;但「添加控件」库名 + Shortcuts 标题是 `LocalizedStringResource` 静态值、`CDropWidgets` target 无 String Catalog,故对 en / zh-TW 用户回退简体字面量(审查 L7,平台专属功能可接受分叉;要彻底本地化须为 Widgets target 加 `.xcstrings`)。
@@ -60,3 +61,4 @@
| 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices` 推 `presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history;`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`(TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()` 加 `{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html` → `engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 |
| 2026-06-24 | engine/main.ts:transfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署**) | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreview;project.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`(Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`(UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/s(relay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`,ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html` → `engine-BC6mZ9zP.js`(含节流 + ice 摘要) |
| 2026-06-27 | source.ts(新 FileSource)+ p2p/relay/transfer 改 src + engine/main.ts 加 registerPush/provisionWidgetSession + i18n 加 `ios.bg.*`/`ios.share.*`/`ios.control.*`(本提交) | 新 BackgroundTaskManager / AppDelegate / PushRegistry / Shared(AppGroup,I18n,CDropAPI,WidgetSession) / Share(ShareViewController) / Widgets(控件+intents+client);EngineController 加 Range handler / 后台 sync / 分享导入 / 控件代铸;project.yml 加两扩展 target + 真机签名 scaffold;包名改 `net.commilitia.Commilitia-Drop`(本提交) | **计划完成(I3–I7)**:发送端流式(FileSource + 原生 Range,新增**分享发送 + 控件**两条非引擎流程,对应残余项 1/2)/ 后台续传 / APNs(后端 internal/apns + 原生注册,新后端字段 `push_subscriptions.platform` + 端点 `POST /api/push/apns/register` 对应残余项 3)/ Share Extension / 控制中心两控件(新允许分叉:控件标签见上)。经 ultracode 多 agent 审查(0 HIGH,修 2 MED + 7 LOW)。web tsc/build + go build/test + xcodebuild 全过;模拟器验登录 / 分享弹窗 / 控件嵌入 / App Group 容器。**真机 + 真发推送 + 分发账号门控**(见 REALDEVICE.md)。**待部署 prod**:engine.html(新桥)+ cdrop binary(APNs 端点 + platform 列) |
+| 2026-06-28 | `p2p.ts` 翻 `IOS_NATIVE=true` + 新 `p2pIos.ts` + `p2pNative.ts`/`net/ios.ts` 微调;平均速度 `store/slices/transfer.ts` + `TransferRow.tsx`;i18n 加 `transfer.avgRate`(native-transfer C1/C2) | 新 `Engine/LibWebRtcEngine.swift` + 改 `NativeTransfer.swift`/`EngineController.swift`(原生数据面);新 `SendComposeSheet.swift` + 重写 `MessagesView.swift`(发送预览 / 按设备会话 + 每设备未读);新 `Shared/Brand.swift`/`Sources/BrandFonts.swift`/`Share/ShareGuideView.swift` + 品牌资产;i18n 加 `ios.send.*`/`ios.messages.*`/`ios.share.guide.*`(native-transfer C1/C2) | **iOS 原生数据面 libwebrtc**(P2P 数据面下沉原生、线协议逐字节对齐桌面 pion,PLAN 决策 F;编排留无头 WebView)+ **UX 改造**(发送预览直入 / 分享直入 / 消息按设备会话 + 每设备未读红点 / 删除粒度——对应残余项 1/2)+ **品牌 + 字体**(logo + Orbit Gothic / Maple Mono / Fredoka,字族升共享源、字体分叉条目已更新)+ **全平台平均速度**(残余项 2,三端一致)。环回端到端 20MB 逐字节 + 模拟器全 app 编译 + web tsc/build 过;真机已装、用户反馈“传输总体良好”,真网吞吐互通为剩余闸。**待部署 prod**:engine.html(`IOS_NATIVE=true` 引擎);桌面平均速度待 `desktop-build-{mac,win}` 重建分发 |
diff --git a/ios/PLAN.md b/ios/PLAN.md
index 79d4d94..10d6267 100644
--- a/ios/PLAN.md
+++ b/ios/PLAN.md
@@ -53,6 +53,17 @@
- **缺失 / 拒绝时按特性二选一**:① **有兜底则降级**——本地网络→回退中继并提示“仅中继、无法同内网直连”;通知→无后台推送但前台仍可用。② **无兜底则提示“无权限”并阻塞该特性**——相机→扫码不可用,提示 + 跳系统设置;阻塞的是**该特性**、非整个 app。
- 不拿任一权限当**整个 app** 的门槛(HIG 5.1.2)。详见 §9.2 逐权限映射。
+### 决策 F(新,2026-06-28):iOS P2P 数据面下沉原生 libwebrtc(与桌面 pion 对称)
+
+修订决策 A 的“数据面亦跑在无头 WebView JS 里”。**P2P DataChannel 的字节收发(数据面)已从无头 WebView 的 JS 引擎移到原生 libwebrtc**(`Sources/Engine/LibWebRtcEngine.swift`,`RTCPeerConnection`),与桌面把数据面移到 Go/pion **完全对称**。
+
+- **无头 WebView 此后只跑编排**:信令转发 / 会话 / presence / 消息 / 状态机 / relay(`hub.ts` + `transfer.ts`),**不再承载 P2P 数据面**。决策 A 的“原生壳 + 无头引擎”仍成立,只是引擎的职责收窄到编排层。
+- **缘由(U1→U2 翻案)**:先试 gomobile+pion(与桌面同引擎,U1),真机 POC **证伪**——Go raw BSD socket 不与 iOS Network framework 集成(连接时好时坏 + 中途断流)。按闸退到 **libwebrtc.framework**(U2,stasel/WebRTC M149 经 SPM、pin `exactVersion 149.0.0`)。完整决策依据与实测证据见 `desktop/NATIVE-TRANSFER.md` §7/§8。
+- **收益**:libwebrtc 原生集成 iOS 网络栈 + DcSCTP 默认 ~5MB rwnd——摆脱 WebKit 写死的 256KB 接收窗(R-iOS-3 的吞吐残差)对数据面的钳制,连通性与吞吐一并根治。
+- **互通靠线协议逐字节对齐**:`LibWebRtcEngine` 是 `desktop/engine` 的 `engine.go`/`session.go` 线协议的忠实 Swift 端口——DataChannel `cdrop-file`(ordered)、`meta`/二进制分片(64KB)/`done`/`ack` 帧、16MB/4MB 水位背压、ack 追平完成、不冲突落盘。故 iOS↔桌面(pion)、iOS↔浏览器(JS)三端互通。
+- **桥与 JS 路由零改动**:`IOS_NATIVE` 开关、`p2pIos.ts`/`net/ios.ts` 桥协议原样;只在 Swift 侧新增一条原生传输 RPC(`startOutgoing`/`startIncoming`/`handleSignal`/`cancel`,completion-based,经 `resolveOnMain` 跳主线程 resolve)。**iOS 定向的引擎配置绝不回流 `desktop/engine` 共享包**。
+- **验证**:环回端到端(两个引擎同进程交叉连、20MB 经完整线协议传输后逐字节比对)✅;模拟器全 app 编译 ✅;web typecheck/build ✅。真机 / 真网吞吐互通为剩余闸(须 deploy prod 让设备拿到 `IOS_NATIVE=true` 引擎 + 真机装机,二者 Touch ID 门控)。
+
---
## 1. 后端现状(决定 iOS 能做什么,2026-06-24 扫描)
@@ -72,6 +83,8 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
## 2. 桥协议(arch A 的接口契约,账号无关、可先冻结)
+> **状态(2026-06-28,决策 F 后)**:本节描述的桥是**编排与控制面**(信令 / 会话 / presence / 消息 / 接收回退字节通道)。**P2P 数据面(含其磁盘 I/O)现走原生 libwebrtc**(决策 F),不再经此桥的 `sendFile` / `Begin`-`Append`-`Finalize` 路径;那条接收字节通道仅保留给 relay / 非原生回退。下文桥语义对编排层仍准确。
+
原生 ↔ 无头 JS 引擎的 RPC:`WKScriptMessageHandler`(JS→Swift)+ `evaluateJavaScript`(Swift→JS),类比 Wails 的 `EventsEmit` / bound methods,但**方向相反**(原生在顶层)。
- **平台判定**:`isIOSShell()`(`web/src/net/ios.ts`)查 `window.webkit.messageHandlers.cdropEngine` 在场(类比 `isDesktop()` 查 `window.go.main.App`)。无头入口(`web/src/engine/main.ts`)据此装配桥。
@@ -113,6 +126,8 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
> **实现状态(2026-06-27)**:I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4,FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share Extension(App Group 收件箱 + `cdrop://share` 深链);控制中心两控件(**专用 broker 设备会话**,纯原生 REST,不与引擎抢 refresh 轮换)。模拟器验 + ultracode 多 agent 审查修讫(0 HIGH,修 2 MED + 7 LOW)。**I0 账号 / 证书 + 真机签名 / 真发推送 / 旁加载分发**仍账号门控——手册 `ios/CDrop/REALDEVICE.md`(ASC API Key 自动 provisioning + `just ios-device` 全 CLI 装机,`-allowProvisioningUpdates` 自动登记设备 / 建 profile)。包名 `net.commilitia.Commilitia-Drop`,App Group `group.net.commilitia.Commilitia-Drop`。
+> **更新(2026-06-28)**:① **P2P 数据面改原生 libwebrtc**(决策 F;完整依据 `desktop/NATIVE-TRANSFER.md` §7/§8)——已真机装机、用户反馈“传输总体良好”,真网吞吐互通为剩余闸;② **UX 改造**(发送预览直入 / 消息按设备会话 / 删除粒度,§10);③ **品牌与字体系统**(logo + Orbit Gothic / Maple Mono / Fredoka,§11)。
+
---
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开)
@@ -235,6 +250,57 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
---
+## 10. UX 改造(2026-06-28,真机反馈驱动)
+
+真机使用后落地的三块结构性改造,均属决策 D 的“非引擎驱动 UI 流程”(进 `ios/PARITY.md` 残余项 1/2,已登记)。
+
+### 10.1 发送预览界面(取代即弹的设备选择框)
+
+- 选文件 / 选图后**不再**直接弹设备选择,而是进入**发送预览**(`Sources/SendComposeSheet.swift`):列出待发文件名 + 字节大小,下方列表选目标设备。手动发送与分享发送**共用**此界面。
+- 手动入口支持**多选**:`fileImporter(allowsMultipleSelection: true)` + `photosPicker(selection:)` 数组;`RootView` 的 `present`/`sendPicked`/`clearPicked`/`loadPhotos` 串起选取→预览→发送。
+
+### 10.2 分享直入预览(修“必须重启 app”)
+
+- Share Extension 抓取 → 暂存 App Group → 唤起主程序,主程序**直入发送预览**(同 `SendComposeSheet`)。
+- 修复“分享后必须重启 app 才出现传输”:唤起宿主 app 经响应链 IMP 调现代 `openURL:options:completionHandler:`(旧单参 `openURL:` 在 iOS 18+ 已成空壳),**并以 `scenePhase` 回 `.active` 时 `loadPendingShares()` 兜底**——回前台即扫 App Group 暂存、补出预览,不依赖那条 iOS 灰区的扩展→宿主唤起是否可靠。分享引导界面见 §11.3。
+
+### 10.3 消息按设备会话聚合 + 删除粒度
+
+- 消息从扁平流改为**按设备聚合的会话列表**(社交 app 模型,`Sources/MessagesView.swift`):会话列表项 = 有历史**或**当前可达的设备;点进 `ConversationView` 看与该设备的线程。非 APNs 离线设备**不能**作为发送对象,但历史**可查看**(`canMessage()` 判定)。
+- **每设备未读分桶**:`EngineController` 的 `unreadByPeer: [String: Int]` + `activeConversation`;每设备会话显示各自未读红点,“消息”标签显示总未读(`unreadMessages = unreadByPeer.values.reduce(0,+)`)。进会话即 `markConversationRead` + `setActiveConversation`。
+- **任意删除**:可删**单条消息**与**整个设备的历史**(`deleteConversation()`),避免废 session 堆积。
+
+---
+
+## 11. 品牌与字体系统(2026-06-28,对齐 web)
+
+iOS 界面引入与 web 一致的品牌资产与字体栈——这是强约束③(§5,品牌资产 + 主色单一源)在视觉层的延伸。
+
+### 11.1 品牌 logo
+
+- `Shared/Brand.swift`:`BrandMark`(吉祥物,资产 `Shared/Brand.xcassets/LogoMark`,源 `web/public/logo-mark.png`)、`Wordmark`(“Commilitia” 主色 + “Drop” `cdropAmber`,Fredoka SemiBold)、`BrandLockup`、`BrandHero(tagline:)`。
+- 接入点:登录页(`BrandMark` + `Wordmark` 取代纯文字标题)、传输空态(`BrandHero`)、设置页顶部横向品牌头、控制中心控件重设计(吉祥物 + 品牌渐变 + 全宽按钮,最大化空间)。`Theme.swift` 加 `Color.cdropAmber`(#DB9410 / #F8C637)。
+
+### 11.2 字体栈(Orbit Gothic + Maple Mono + Fredoka)
+
+- 与 web `fonts.css` 同源:Sans = **Orbit Gothic CJK SC**(Regular + SemiBold)、Mono = **Maple Mono**、字标 = **Fredoka SemiBold**。
+- **woff2 运行期注册**:Orbit / Maple 以 woff2 内嵌(约 TTF 的 1/3 体积),`UIAppFonts` 历史只认 ttf/otf,故经 `CTFontManagerRegisterFontsForURL` 运行期注册(iOS 13+ Core Text 原生支持 woff2,实测 register→true)——见 `Sources/BrandFonts.swift` 的 `BrandFonts.register()`(`AppDelegate` 启动调)。Fredoka 是 ttf,走 `UIAppFonts`。
+- **助手 + sweep**:`BrandFonts` 暴露 `Font.oBody/.oHeadline/…`(Orbit,对位 SF 语义样式)、`Font.maple(…)`(Maple Mono);导航 / 标签栏标题经 UIKit appearance 代理换 Orbit;7 个视图的 `.font(.语义)` → `.font(.o语义)` sweep。
+- **仅主 app**:CJK 字体大,不进 Share / 控件扩展(它们保持系统字 + 仅 Fredoka 字标);生僻字超 Orbit 字汇时 iOS 字体级联自动回退苹方,不出豆腐块。
+- **字体不入库**:Orbit Gothic 为专有 / 定制字体(公开仓库不分发),Maple / Fredoka 虽开源也统一不入库保持精简;经 `.gitignore` 排除,构建前 `just ios-fonts`(已 chain 进 `ios-device` / `ios-sim-build`)从私有 CDN / Google Fonts 幂等拉取。详见 `Sources/Fonts/README.md`。
+
+### 11.3 分享引导界面
+
+- `Share/ShareGuideView.swift`(`ShareModel: ObservableObject`):扩展内呈现吉祥物 + Wordmark + “已就绪 N 个文件” + “打开 Commilitia Drop” 主按钮 + 取消,引导用户回到主程序完成发送(扩展内不跑传输引擎,§8.4)。取消则删暂存;`started` 标志防重入。
+
+---
+
+## 12. 全平台平均速度(2026-06-28)
+
+传输完成后,速度列**不再显示最后一次瞬时速率,而显示平均速度**(总字节 / 总耗时)。三端一致:web `store/slices/transfer.ts` 的 `completeTransfer` 在 DONE 时置 `bytesPerSec = total / (elapsedMs/1000)`、`TransferRow.tsx` 在 DONE 读 `avgRate`;iOS `RootView` 卡片 / 详情完成态显平均速度;i18n 加 `transfer.avgRate`(web 3 locale + ios 3 JSON)。桌面(Wails)共享 web store,逻辑自动生效,但需 `just desktop-build-{mac,win}` 重建 + 重分发方落到已装客户端。
+
+---
+
## 附:与桌面端策略对照(修订)
| 维度 | 桌面(Wails) | iOS(arch A) |
diff --git a/web/src/features/transfer/TransferRow.tsx b/web/src/features/transfer/TransferRow.tsx
index 928ce76..58d047e 100644
--- a/web/src/features/transfer/TransferRow.tsx
+++ b/web/src/features/transfer/TransferRow.tsx
@@ -71,8 +71,8 @@ export function TransferRow(props: TransferRowProps)
// 终态记录可删除(从列表移除);非终态记录可取消(驱动后端 /cancel)。
const terminal = done || failed || cancelled;
- // 发送方仍在试 P2P(已接受 / P2P 中、尚未切中继)时,给一个「立即中继」入口,
- // 跳过 30s ICE 等待——只对 outgoing 有意义(驱动后端 /fallback)。
+ // 发送方仍在试 P2P(已接受 / P2P 中、尚未切中继)时,给一个「中继传输」入口手动切中继——
+ // 无自动回退后这是切中继的唯一途径,只对 outgoing 有意义(驱动后端 /fallback)。
const canRelayNow = tr.direction === "outgoing"
&& (tr.state === "ACCEPTED" || tr.state === "P2P_ACTIVE")
&& tr.phase !== "fallback_pending"
@@ -192,10 +192,13 @@ function bytesText(tr: TransferRecord): string
// 卡住了」状态已表达停滞,再显示一个陈旧的非零速度会误导。速率由 store 做 EMA 平滑。
function speedText(tr: TransferRecord, stalled: boolean): string
{
- if (stalled) { return ""; }
- if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
const rate = tr.bytesPerSec;
if (!rate || !Number.isFinite(rate) || rate <= 0) { return ""; }
+ // 完成后显示平均速度(store completeTransfer 已把 bytesPerSec 置为总字节 / 总耗时的均速)。
+ if (tr.state === "DONE") { return t("transfer.avgRate", { rate: formatBytes(rate) }); }
+ // 进行中:瞬时速度(EMA 平滑)。卡死 / 非流动阶段不显示陈旧速度,避免误导。
+ if (stalled) { return ""; }
+ if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
return t("transfer.bytesRate", { rate: formatBytes(rate) });
}
diff --git a/web/src/features/transfer/transfer.ts b/web/src/features/transfer/transfer.ts
index c40f291..5a51390 100644
--- a/web/src/features/transfer/transfer.ts
+++ b/web/src/features/transfer/transfer.ts
@@ -4,10 +4,10 @@ import { relayReceiveFile, relaySendFile } from "./relay";
import type { FileSource } from "./source";
import { useAppStore, type TransferRecord } from "../../store";
-// Orchestrator that picks P2P first, falls back to Relay on ICE timeout
-// (plan §M9: 30s after start, if iceConnectionState != connected → /fallback).
-
-const ICE_TIMEOUT_MS = 30_000;
+// Orchestrator that starts P2P and leaves relay to a manual user action.
+// 不再有 30s ICE 超时自动回退中继:P2P 无时限尝试,中继仅由用户手动触发(TransferRow 的
+// 「中继传输」按钮 → skipWaitRelay)。自动回退会在弱网/慢 ICE 下抢先切走、且掩盖 P2P 是否
+// 终将连通,故按产品要求移除——所有中继改手动。
interface OutgoingState
{
@@ -15,7 +15,6 @@ interface OutgoingState
peerName: string;
src: FileSource;
p2p: P2PSession | null;
- fallbackTimer: number | null;
// waitTimer / waitUnsub back the "wait for an offline receiver to wake" path: the timeout that
// fails the transfer if the peer never comes online, and the presence subscription that fires
// P2P once it does.
@@ -73,7 +72,6 @@ export async function startOutgoingTransfer(
peerName: receiverName,
src,
p2p: null,
- fallbackTimer: null,
waitTimer: null,
waitUnsub: null,
abort,
@@ -86,8 +84,9 @@ export async function startOutgoingTransfer(
return sessionId;
}
-// beginP2P arms the relay-fallback watchdog and starts the P2P offer. Extracted from
-// startOutgoingTransfer so the offline-receiver path can defer it until the peer wakes.
+// beginP2P starts the P2P offer. Extracted from startOutgoingTransfer so the
+// offline-receiver path can defer it until the peer wakes. No auto-fallback watchdog:
+// relay is manual only (skipWaitRelay).
function beginP2P(state: OutgoingState): void
{
const { sessionId, peerName, src } = state;
@@ -95,35 +94,8 @@ function beginP2P(state: OutgoingState): void
const r0 = store0.activeTransfers[sessionId];
if (r0 && r0.phase === "waiting_peer") { store0.upsertTransfer({ ...r0, phase: "waiting_accept" }); }
- // Arm the fallback watchdog *before* any P2P work. p2pStartOutgoing is
- // intentionally synchronous: if createOffer / setLocalDescription hang
- // (e.g. injected stub during fallback testing) the timer must still fire.
- state.fallbackTimer = window.setTimeout(() =>
- {
- const cur = outgoing.get(sessionId);
- if (!cur) { return; }
- const p2pState = cur.p2p?.state;
- if (p2pState !== "connected" && p2pState !== "completed")
- {
- // 把"已经放弃 P2P,等服务端 RELAY_ACTIVE"这个状态显式落到 store,
- // UI 据此告诉用户「还在通,正在切到中继」而不是干等。
- const store = useAppStore.getState();
- const r = store.activeTransfers[sessionId];
- if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
- void triggerFallback(sessionId);
- }
- }, ICE_TIMEOUT_MS);
-
const p2p = p2pStartOutgoing(sessionId, peerName, src);
state.p2p = p2p;
-
- p2p.onState((s) =>
- {
- if (s === "completed" || s === "connected")
- {
- if (state.fallbackTimer) { clearTimeout(state.fallbackTimer); state.fallbackTimer = null; }
- }
- });
}
// PEER_WAIT_MS bounds how long the sender waits for an offline receiver to wake + reconnect.
@@ -311,7 +283,6 @@ function cleanupOutgoing(sessionId: string): void
{
const s = outgoing.get(sessionId);
if (!s) { return; }
- if (s.fallbackTimer) { clearTimeout(s.fallbackTimer); }
if (s.waitTimer !== null) { clearTimeout(s.waitTimer); }
if (s.waitUnsub) { s.waitUnsub(); }
s.abort.abort();
@@ -325,17 +296,10 @@ export function cleanupTransfer(sessionId: string): void
incomingFiles.delete(sessionId);
}
-// UI-triggered (发送方「跳过等待、立即中继」):发送方不等 30s ICE 超时,直接切到
-// 中继。复用 watchdog 的逻辑——先停掉那个定时器以免重复 fire,把记录标成
-// fallback_pending 让 UI 显示「正在切到中继」,再请求服务端转 RELAY_ACTIVE。
+// UI-triggered (发送方「中继传输」):手动把传输切到中继(无自动回退后,这是切中继的唯一途径)。
+// 把记录标成 fallback_pending 让 UI 显示「正在切到中继」,再请求服务端转 RELAY_ACTIVE。
export async function skipWaitRelay(sessionId: string): Promise
{
- const cur = outgoing.get(sessionId);
- if (cur?.fallbackTimer)
- {
- clearTimeout(cur.fallbackTimer);
- cur.fallbackTimer = null;
- }
const store = useAppStore.getState();
const r = store.activeTransfers[sessionId];
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts
index e8e36d5..1790a61 100644
--- a/web/src/i18n/locales/en-US.ts
+++ b/web/src/i18n/locales/en-US.ts
@@ -284,7 +284,7 @@ export const enUS: Partial = {
"transfer.saveFailed": "Failed to save file: {{error}}",
"transfer.action.cancel": "Cancel",
"transfer.action.delete": "Delete",
- "transfer.action.relayNow": "Relay now",
+ "transfer.action.relayNow": "Relay",
"transfer.action.details": "Details",
"transfer.state.PENDING": "Pending",
"transfer.state.ACCEPTED": "Accepted",
@@ -313,6 +313,7 @@ export const enUS: Partial = {
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/s",
+ "transfer.avgRate": "Avg {{rate}}/s",
// ---- debug expansion ------------------------------------------------
"transfer.debug.toggle": "Debug",
@@ -414,7 +415,7 @@ export const enUS: Partial = {
"ios.records.clear": "Clear",
"ios.records.clearConfirm": "Clear all records? This cannot be undone.",
"ios.transfer.cancel": "Cancel transfer",
- "ios.transfer.forceRelay": "Relay now",
+ "ios.transfer.forceRelay": "Relay",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts
index 456776e..8540905 100644
--- a/web/src/i18n/locales/zh-CN.ts
+++ b/web/src/i18n/locales/zh-CN.ts
@@ -280,7 +280,7 @@ export const zhCN = {
"transfer.saveFailed": "保存文件失败:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "删除",
- "transfer.action.relayNow": "立即中继",
+ "transfer.action.relayNow": "中继传输",
"transfer.action.details": "详情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
@@ -309,6 +309,7 @@ export const zhCN = {
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
+ "transfer.avgRate": "平均 {{rate}}/秒",
// ---- 调试展开区 -----------------------------------------------------
"transfer.debug.toggle": "调试信息",
@@ -410,7 +411,7 @@ export const zhCN = {
"ios.records.clear": "清空",
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
"ios.transfer.cancel": "取消传输",
- "ios.transfer.forceRelay": "立即切到中继",
+ "ios.transfer.forceRelay": "中继传输",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
diff --git a/web/src/i18n/locales/zh-TW.ts b/web/src/i18n/locales/zh-TW.ts
index c6304d6..089f5dd 100644
--- a/web/src/i18n/locales/zh-TW.ts
+++ b/web/src/i18n/locales/zh-TW.ts
@@ -284,7 +284,7 @@ export const zhTW: Partial = {
"transfer.saveFailed": "儲存檔案失敗:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "刪除",
- "transfer.action.relayNow": "立即中繼",
+ "transfer.action.relayNow": "中繼傳輸",
"transfer.action.details": "詳情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
@@ -313,6 +313,7 @@ export const zhTW: Partial = {
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
+ "transfer.avgRate": "平均 {{rate}}/秒",
// ---- 偵錯展開區 -----------------------------------------------------
"transfer.debug.toggle": "偵錯資訊",
@@ -414,7 +415,7 @@ export const zhTW: Partial = {
"ios.records.clear": "清空",
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
"ios.transfer.cancel": "取消傳輸",
- "ios.transfer.forceRelay": "立即切到中繼",
+ "ios.transfer.forceRelay": "中繼傳輸",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
diff --git a/web/src/store/slices/transfer.ts b/web/src/store/slices/transfer.ts
index 4053e99..74e334c 100644
--- a/web/src/store/slices/transfer.ts
+++ b/web/src/store/slices/transfer.ts
@@ -46,7 +46,19 @@ export const createTransferSlice: StateCreator
{
const cur = s.activeTransfers[sessionId];
if (!cur) { return s; }
- const finalRec = normalizeTerminal(cur, finalState);
+ let finalRec = normalizeTerminal(cur, finalState);
+ // 完成后速度显示「平均速度」(总字节 / 总耗时),取代最后的瞬时 EMA 速率——后者只反映末段
+ // 抽样、对整体传输无代表性。仅 DONE 有意义(FAILED/CANCELLED 为部分传输,不覆写)。全平台
+ // 一处生效:web/desktop 读此记录、iOS 经 toWire→transferDone 拿到同一 bytesPerSec。
+ if (finalState === "DONE")
+ {
+ const elapsedMs = Date.now() - (cur.startedAt ?? Date.now());
+ const total = cur.fileSize > 0 ? cur.fileSize : (cur.bytesTransferred ?? 0);
+ if (elapsedMs > 0 && total > 0)
+ {
+ finalRec = { ...finalRec, bytesPerSec: total / (elapsedMs / 1000) };
+ }
+ }
const { [sessionId]: _, ...rest } = s.activeTransfers;
return ({
activeTransfers: rest,