iOS 原生客户端:libwebrtc 数据面 + UX 改造 + 品牌/字体 + 全平台平均速度
- 原生数据面 libwebrtc(PLAN 决策 F):新增 Engine/LibWebRtcEngine.swift(RTCPeerConnection,端口 engine.go/session.go 线协议 + 水位背压 + ack 完成 + 不冲突落盘);NativeTransfer.swift 的 P2PEventsBridge 改 conform P2PEngineDelegate(去 gomobile);EngineController 4 个 p2p RPC 改调新引擎(completion-based + resolveOnMain)。stasel/WebRTC M149 经 SPM(exactVersion 149.0.0)。环回端到端 20MB 逐字节测过(Tests/) - UX 改造(PARITY 残余项 1/2):新增 SendComposeSheet(发送预览=文件名 + 选设备,手动多选与分享共用);分享直入预览 + scenePhase .active 兜底(修“分享后必须重启 app”);重写 MessagesView(按设备会话 + 每设备未读红点 + 删单条 / 删整设备历史);EngineController 加 conversations / unread 模型 - 品牌 logo:新增 Shared/Brand.swift(BrandMark / Wordmark / BrandLockup / BrandHero)+ Brand.xcassets/LogoMark;登录页 / 传输空态 / 设置头 / 控制中心控件接 logo;Theme 加 cdropAmber - 字体(对齐 web,仅主 app):新增 BrandFonts.swift(Orbit Gothic + Maple Mono woff2 运行期 CTFontManager 注册 + UIKit 外观代理 + .oXxx / .maple 助手);Fredoka SemiBold 字标;7 视图 .font sweep。字体不入库——.gitignore 排除 + just ios-fonts 构建前从 CDN / Google Fonts 拉取,见 Sources/Fonts/README.md - 分享引导:新增 Share/ShareGuideView(吉祥物卡 + “打开 Commilitia Drop”);ShareViewController 健壮 staging(文件 / 数据双路)+ 响应链 IMP 唤起宿主 - 全平台平均速度:传输完成态显平均速度(总字节 / 总耗时)而非末次瞬时——web store/slices/transfer.ts + TransferRow.tsx + iOS RootView 卡片 / 详情;i18n 加 transfer.avgRate - 工程:project.yml 接 SPM WebRTC + CDropTests + Fredoka UIAppFonts + Share i18n;Justfile 加 ios-sim-build + ios-fonts / ios-fonts-fredoka 配方 - 文档:ios/PLAN.md(决策 F + §10 UX + §11 品牌/字体 + §12 平均速度)、ios/PARITY.md(字体分叉条目更新 + 2026-06-28 漂移行)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -39,5 +39,9 @@
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>Fredoka-SemiBold.ttf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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) ])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo-mark.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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> ..." 形式的诊断行解析出的传输会话 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<String> = []
|
||||
@@ -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://<id> 引用解析回暂存文件的沙盒绝对路径(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://<id>,解析回沙盒绝对路径交引擎直接读盘。引擎
|
||||
// 调用立即返回(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 去重避免重复事件叠加。
|
||||
|
||||
@@ -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 <id> ...",供日志界面按会话聚合。
|
||||
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://<id> 解析得到)。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:<foundation> <component> <proto> <priority> <ip> <port> typ <type> ...
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
@@ -58,6 +58,10 @@
|
||||
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Commilitia Drop 需要访问照片库,以便从相册选取图片或视频发送给其他设备。</string>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>Fredoka-SemiBold.ttf</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "对准另一台设备上的登录二维码",
|
||||
|
||||
@@ -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 碼",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user