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,