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:
@@ -77,13 +77,38 @@ desktop-doctor:
|
|||||||
ios-devices:
|
ios-devices:
|
||||||
xcrun devicectl list devices
|
xcrun devicectl list devices
|
||||||
|
|
||||||
|
# 拉取品牌字体(不入库——Orbit Gothic 专有;统一从 CDN / Google Fonts 拉,镜像 web 思路)。幂等:已存在
|
||||||
|
# 则跳过。ios-sim-build / ios-device 构建前已 chain 本步。前置:curl;造 Fredoka 静态实例需 python3 +
|
||||||
|
# fonttools(python3 -m pip install --user fonttools)。见 ios/CDrop/Sources/Fonts/README.md。
|
||||||
|
ios-fonts:
|
||||||
|
mkdir -p ios/CDrop/Sources/Fonts ios/CDrop/Shared/Fonts
|
||||||
|
[ -f ios/CDrop/Sources/Fonts/OrbitGothicCJKsc-Regular.woff2 ] || curl -fsSL "https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKsc/OrbitGothicCJKsc-Regular.woff2" -o ios/CDrop/Sources/Fonts/OrbitGothicCJKsc-Regular.woff2
|
||||||
|
[ -f ios/CDrop/Sources/Fonts/OrbitGothicCJKsc-SemiBold.woff2 ] || curl -fsSL "https://orbit-gothic.cdn.commilitia.net/WOFF2/CJKsc/OrbitGothicCJKsc-SemiBold.woff2" -o ios/CDrop/Sources/Fonts/OrbitGothicCJKsc-SemiBold.woff2
|
||||||
|
[ -f ios/CDrop/Sources/Fonts/MapleMono-Regular.woff2 ] || curl -fsSL "https://maple-mono.cdn.commilitia.net/MapleMono-Regular.ttf.woff2" -o ios/CDrop/Sources/Fonts/MapleMono-Regular.woff2
|
||||||
|
[ -f ios/CDrop/Shared/Fonts/Fredoka-SemiBold.ttf ] || just ios-fonts-fredoka
|
||||||
|
|
||||||
|
# Fredoka 字标:从 Google Fonts 变量字体实例化出静态 SemiBold(wght=600, wdth=100)。
|
||||||
|
ios-fonts-fredoka:
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/google/fonts/main/ofl/fredoka/Fredoka%5Bwdth%2Cwght%5D.ttf" -o /tmp/Fredoka-VF.ttf
|
||||||
|
python3 -m fontTools.varLib.instancer /tmp/Fredoka-VF.ttf wght=600 wdth=100 -o ios/CDrop/Shared/Fonts/Fredoka-SemiBold.ttf --update-name-table
|
||||||
|
rm -f /tmp/Fredoka-VF.ttf
|
||||||
|
|
||||||
|
# 模拟器编译验证(无需签名 / Apple 账号):拉字体 + xcodegen + xcodebuild iphonesimulator。原生数据面
|
||||||
|
# 引擎走 SPM 的 libwebrtc(首次构建自动拉取 WebRTC.xcframework),无需预构建步骤。用于改动自检。
|
||||||
|
ios-sim-build:
|
||||||
|
just ios-fonts
|
||||||
|
cd ios/CDrop && xcodegen generate
|
||||||
|
cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath build/DD build
|
||||||
|
|
||||||
# 真机构建 + 装机(自动 provisioning,经 ASC API Key)。前置:付费 ADP;App ID 的 App Groups /
|
# 真机构建 + 装机(自动 provisioning,经 ASC API Key)。前置:付费 ADP;App ID 的 App Groups /
|
||||||
# Push 能力(门户建好或自动建);根目录 gitignore 的 .env 配齐:CDROP_TEAM_ID / CDROP_ASC_KEY_PATH
|
# Push 能力(门户建好或自动建);根目录 gitignore 的 .env 配齐:CDROP_TEAM_ID / CDROP_ASC_KEY_PATH
|
||||||
# (.p8 路径)/ CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID。-allowProvisioningUpdates 自动登记连接的
|
# (.p8 路径)/ CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID。-allowProvisioningUpdates 自动登记连接的
|
||||||
# 设备 + 建 profile(绕过手动 profile 的设备选择坑)。模拟器构建不需这些(base ad-hoc)。
|
# 设备 + 建 profile(绕过手动 profile 的设备选择坑)。模拟器构建不需这些(base ad-hoc)。原生数据面
|
||||||
|
# 经 SPM 拉 libwebrtc,无 gomobile 预构建步骤(早先 ios-engine recipe 已随 pion-on-iOS 废弃移除)。
|
||||||
# 用法:just ios-device <设备UDID>(UDID 见 just ios-devices)。
|
# 用法:just ios-device <设备UDID>(UDID 见 just ios-devices)。
|
||||||
ios-device udid:
|
ios-device udid:
|
||||||
[ -n "$CDROP_TEAM_ID" ] && [ -n "$CDROP_ASC_KEY_PATH" ] && [ -n "$CDROP_ASC_KEY_ID" ] && [ -n "$CDROP_ASC_ISSUER_ID" ] || { echo "缺 .env(CDROP_TEAM_ID / CDROP_ASC_KEY_PATH / CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID)——见 ios/CDrop/REALDEVICE.md"; exit 1; }
|
[ -n "$CDROP_TEAM_ID" ] && [ -n "$CDROP_ASC_KEY_PATH" ] && [ -n "$CDROP_ASC_KEY_ID" ] && [ -n "$CDROP_ASC_ISSUER_ID" ] || { echo "缺 .env(CDROP_TEAM_ID / CDROP_ASC_KEY_PATH / CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID)——见 ios/CDrop/REALDEVICE.md"; exit 1; }
|
||||||
|
just ios-fonts
|
||||||
cd ios/CDrop && xcodegen generate
|
cd ios/CDrop && xcodegen generate
|
||||||
cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -configuration Debug -destination "platform=iOS,id={{udid}}" -derivedDataPath build/DD-device -allowProvisioningUpdates -authenticationKeyPath "$CDROP_ASC_KEY_PATH" -authenticationKeyID "$CDROP_ASC_KEY_ID" -authenticationKeyIssuerID "$CDROP_ASC_ISSUER_ID" DEVELOPMENT_TEAM="$CDROP_TEAM_ID" CODE_SIGN_STYLE=Automatic CODE_SIGN_IDENTITY="Apple Development" build
|
cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -configuration Debug -destination "platform=iOS,id={{udid}}" -derivedDataPath build/DD-device -allowProvisioningUpdates -authenticationKeyPath "$CDROP_ASC_KEY_PATH" -authenticationKeyID "$CDROP_ASC_KEY_ID" -authenticationKeyIssuerID "$CDROP_ASC_ISSUER_ID" DEVELOPMENT_TEAM="$CDROP_TEAM_ID" CODE_SIGN_STYLE=Automatic CODE_SIGN_IDENTITY="Apple Development" build
|
||||||
xcrun devicectl device install app --device {{udid}} ios/CDrop/build/DD-device/Build/Products/Debug-iphoneos/CDrop.app
|
xcrun devicectl device install app --device {{udid}} ios/CDrop/build/DD-device/Build/Products/Debug-iphoneos/CDrop.app
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ DerivedData/
|
|||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
.DS_Store
|
.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
|
# 真机签名的账号凭据——公开仓库不留账号标识。ASC API Key 的 .p8 + Team/Key/Issuer ID
|
||||||
# 走根目录 gitignore 的 .env(见 REALDEVICE.md);.p8 若落本目录一并忽略。
|
# 走根目录 gitignore 的 .env(见 REALDEVICE.md);.p8 若落本目录一并忽略。
|
||||||
*.p8
|
*.p8
|
||||||
|
|||||||
@@ -39,5 +39,9 @@
|
|||||||
<key>NSExtensionPrincipalClass</key>
|
<key>NSExtensionPrincipalClass</key>
|
||||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>UIAppFonts</key>
|
||||||
|
<array>
|
||||||
|
<string>Fredoka-SemiBold.ttf</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</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 UIKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
// Share Extension 主控制器:把 share sheet 传入的文件拷进 App Group 收件箱,再深链唤起主 app
|
// Share Extension 主控制器:把 share sheet 传入的文件拷进 App Group 收件箱,再以**品牌引导界面**
|
||||||
// 选设备发送(扩展只交接、不跑引擎,避 120MB jetsam,见 PLAN §I5)。无 compose UI——即起即办、
|
// (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
|
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)
|
override func viewDidAppear(_ animated: Bool)
|
||||||
{
|
{
|
||||||
super.viewDidAppear(animated)
|
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]) ?? []
|
let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
|
||||||
var staged = 0
|
var urls: [URL] = []
|
||||||
for item in items
|
for item in items
|
||||||
{
|
{
|
||||||
for provider in item.attachments ?? []
|
for provider in item.attachments ?? []
|
||||||
{
|
{
|
||||||
if await stage(provider) { staged += 1 }
|
if let dest = await stage(provider) { urls.append(dest) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ctx = extensionContext
|
stagedURLs = urls
|
||||||
guard staged > 0, let url = URL(string: "cdrop://share") else
|
await MainActor.run { model.fileCount = urls.count }
|
||||||
{
|
}
|
||||||
ctx?.completeRequest(returningItems: nil)
|
|
||||||
return
|
// 「打开 Commilitia Drop」:尝试唤起主 app(最大努力),延后 completeRequest 给系统留出拉起窗口
|
||||||
}
|
// (立即 completeRequest 会拆掉扩展进程、可能取消尚未真正开始的唤起)。文件留在收件箱供主 app 接手。
|
||||||
// 先唤起主 app,等 open 回调拿到结果再 completeRequest——否则立即 completeRequest 会拆掉
|
private func openAndFinish()
|
||||||
// 扩展、self 被释放,open 失败时的 openViaResponder 兜底永不触发(审查 L8)。捕获 ctx 使
|
{
|
||||||
// 扩展存活到回调。
|
if let url = URL(string: "cdrop://share") { openViaResponder(url) }
|
||||||
ctx?.open(url)
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35)
|
||||||
{ [weak self] ok in
|
{ [weak self] in
|
||||||
if !ok { self?.openViaResponder(url) }
|
self?.extensionContext?.completeRequest(returningItems: nil)
|
||||||
ctx?.completeRequest(returningItems: nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把单个附件落到 App Group 收件箱。用 loadFileRepresentation 取系统给的临时文件表示
|
// 「取消」:删掉刚暂存进收件箱的文件(不残留、避免下次开 app 误弹预览),返回来源 app。
|
||||||
// (文件 / 图片 / 文档统一走此路,且大文件不整体进内存),在回调内同步拷到收件箱
|
private func cancelAndFinish()
|
||||||
// (系统会在回调返回后删临时文件)。
|
|
||||||
private func stage(_ provider: NSItemProvider) async -> Bool
|
|
||||||
{
|
{
|
||||||
|
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
|
return await withCheckedContinuation
|
||||||
{ cont in
|
{ cont in
|
||||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier)
|
provider.loadFileRepresentation(forTypeIdentifier: typeId)
|
||||||
{ url, _ in
|
{ url, _ in
|
||||||
guard let url, let dest = AppGroup.stagedDestination(originalName: url.lastPathComponent)
|
guard let url, let dest = AppGroup.stagedDestination(originalName: url.lastPathComponent)
|
||||||
else { cont.resume(returning: false); return }
|
else { cont.resume(returning: nil); return }
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
try FileManager.default.copyItem(at: url, to: dest)
|
try FileManager.default.copyItem(at: url, to: dest)
|
||||||
cont.resume(returning: true)
|
cont.resume(returning: dest)
|
||||||
}
|
}
|
||||||
catch
|
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)
|
private func openViaResponder(_ url: URL)
|
||||||
{
|
{
|
||||||
|
let modern = NSSelectorFromString("openURL:options:completionHandler:")
|
||||||
var responder: UIResponder? = self
|
var responder: UIResponder? = self
|
||||||
let sel = NSSelectorFromString("openURL:")
|
|
||||||
while let r = responder
|
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
|
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.584, green: 0.502, blue: 1.0, alpha: 1)
|
||||||
: UIColor(red: 0.392, green: 0.290, blue: 0.788, 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
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
) -> Bool
|
) -> Bool
|
||||||
{
|
{
|
||||||
|
// 品牌字体须在任何 UI 渲染前注册 + 设外观代理(导航 / 标签栏标题换 Orbit Gothic)。
|
||||||
|
BrandFonts.register()
|
||||||
// BGContinuedProcessingTask 的 launch handler 须在 launch 完成前注册。
|
// BGContinuedProcessingTask 的 launch handler 须在 launch 完成前注册。
|
||||||
BackgroundTaskManager.shared.registerOnce()
|
BackgroundTaskManager.shared.registerOnce()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ struct LoginView: View
|
|||||||
VStack(spacing: 20)
|
VStack(spacing: 20)
|
||||||
{
|
{
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(t("app.brand"))
|
// 品牌(登录页):吉祥物 + 字标(Fredoka),取代原纯文字标题——首屏即见完整品牌。
|
||||||
.font(.largeTitle)
|
VStack(spacing: 14)
|
||||||
.bold()
|
{
|
||||||
|
BrandMark(size: 124)
|
||||||
|
Wordmark(size: 30)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
// 主登录:应用内 broker 账号登录。
|
// 主登录:应用内 broker 账号登录。
|
||||||
Button { Task { await auth.startBrokerLogin() } }
|
Button { Task { await auth.startBrokerLogin() } }
|
||||||
@@ -31,7 +35,7 @@ struct LoginView: View
|
|||||||
if !auth.statusText.isEmpty
|
if !auth.statusText.isEmpty
|
||||||
{
|
{
|
||||||
Text(auth.statusText)
|
Text(auth.statusText)
|
||||||
.font(.callout)
|
.font(.oCallout)
|
||||||
.foregroundStyle(auth.qrExpired ? Color.red : Color.secondary)
|
.foregroundStyle(auth.qrExpired ? Color.red : Color.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
@@ -45,7 +49,7 @@ struct LoginView: View
|
|||||||
qrArea
|
qrArea
|
||||||
refreshButton
|
refreshButton
|
||||||
Text(t("ios.login.guide"))
|
Text(t("ios.login.guide"))
|
||||||
.font(.footnote)
|
.font(.oFootnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
@@ -54,7 +58,7 @@ struct LoginView: View
|
|||||||
label:
|
label:
|
||||||
{
|
{
|
||||||
Text(t("ios.login.scanAlt"))
|
Text(t("ios.login.scanAlt"))
|
||||||
.font(.subheadline)
|
.font(.oSubheadline)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 40)
|
.padding(.horizontal, 40)
|
||||||
.onChange(of: showQR)
|
.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
|
WindowGroup
|
||||||
{
|
{
|
||||||
AppRoot(auth: auth, engine: engine)
|
AppRoot(auth: auth, engine: engine)
|
||||||
|
// 全局默认字体=Orbit Gothic 正文(无显式 .font 的文本随之走品牌 Sans)。显式语义样式
|
||||||
|
// 由各视图改用 .font(.oXxx)(见 BrandFonts)覆盖到品牌字。
|
||||||
|
.environment(\.font, .oBody)
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase)
|
.onChange(of: scenePhase)
|
||||||
{ _, phase in
|
{ _, phase in
|
||||||
|
guard phase == .active else { return }
|
||||||
|
// 回前台即重扫 App Group 收件箱:iOS 对 share 扩展唤起宿主 app 有限制、未必生效,故无论扩展
|
||||||
|
// 是否成功把 app 拉前台,只要回到前台(手动切回或被拉起)都重扫一次——有待发分享文件即弹
|
||||||
|
// 发送预览,免去「必须重启 app 分享才出现」(onAppear 仅冷启触发、回前台不触发,是要重启的根因)。
|
||||||
|
// 空收件箱无副作用。
|
||||||
|
engine.loadPendingShares()
|
||||||
// 回到前台即确保控件 / 剪贴板会话已铸(修「关闭态剪贴板不可用」:引擎冷启 ready 时铸一次,
|
// 回到前台即确保控件 / 剪贴板会话已铸(修「关闭态剪贴板不可用」:引擎冷启 ready 时铸一次,
|
||||||
// 这里补暖前台场景)。引擎持完整主会话铸独立控件会话(隔离于主会话),仅缺失时铸、不重复轮换。
|
// 这里补暖前台场景)。引擎持完整主会话铸独立控件会话(隔离于主会话),仅缺失时铸、不重复轮换。
|
||||||
if phase == .active, auth.session != nil
|
if auth.session != nil
|
||||||
{
|
{
|
||||||
engine.provisionWidgetSessionIfNeeded()
|
engine.provisionWidgetSessionIfNeeded()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ struct MessageItem: Identifiable, Equatable, Codable
|
|||||||
let sentAt: Double
|
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* 为选中候选
|
// 引擎过桥推来的传输记录(对应 web engine/main.ts 的 toWire 精简视图)。ice* 为选中候选
|
||||||
// 对诊断(揭示 P2P 是否实为 TURN 中继,解释慢速)。同样手动容忍解析。
|
// 对诊断(揭示 P2P 是否实为 TURN 中继,解释慢速)。同样手动容忍解析。
|
||||||
struct TransferItem: Identifiable, Equatable, Codable
|
struct TransferItem: Identifiable, Equatable, Codable
|
||||||
@@ -68,6 +85,24 @@ struct LogEntry: Identifiable, Equatable
|
|||||||
let level: String // warn | error | info
|
let level: String // warn | error | info
|
||||||
let message: String
|
let message: String
|
||||||
let at: Date
|
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=)后,经引擎
|
// 待批准的他端登录请求(#8):本机(已登录)扫到另一台设备显示的登录二维码(/link?r=&c=)后,经引擎
|
||||||
@@ -117,10 +152,13 @@ final class EngineController: NSObject
|
|||||||
// 待确认的他端登录请求(#8):扫码拿到信息后置位,驱动设备页弹批准确认;批准 / 拒绝 / 取消后清空。
|
// 待确认的他端登录请求(#8):扫码拿到信息后置位,驱动设备页弹批准确认;批准 / 拒绝 / 取消后清空。
|
||||||
var pendingLoginRequest: LoginRequestItem?
|
var pendingLoginRequest: LoginRequestItem?
|
||||||
|
|
||||||
// 未读红点(#5):消息 / 传输各自的未读计数,对应 tab 激活时清零。activeTab 记录当前所在
|
// 未读红点(#5):传输未读对应 tab 激活时清零(activeTab 记录当前页,使「正在该页时到达的事件」不
|
||||||
// 标签,使「正在该页时到达的事件」不计未读。seenIncomingTransfers 去重,避免同一传入传输在
|
// 计未读);seenIncomingTransfers 去重,避免同一传入传输在活跃→完成两阶段被重复计数。
|
||||||
// 活跃→完成两阶段被重复计数。
|
// 消息未读按设备分桶(unreadByPeer):每设备各自未读计数(会话列表逐行红点),消息 tab 徽标=总和
|
||||||
var unreadMessages = 0
|
// (unreadMessages 计算属性);activeConversation 记录当前打开的会话,使「正在该会话时到达的消息」不计未读。
|
||||||
|
var unreadByPeer: [String: Int] = [:]
|
||||||
|
var activeConversation: String?
|
||||||
|
var unreadMessages: Int { unreadByPeer.values.reduce(0, +) }
|
||||||
var unreadTransfers = 0
|
var unreadTransfers = 0
|
||||||
var activeTab = "transfer"
|
var activeTab = "transfer"
|
||||||
private var seenIncomingTransfers: Set<String> = []
|
private var seenIncomingTransfers: Set<String> = []
|
||||||
@@ -155,6 +193,14 @@ final class EngineController: NSObject
|
|||||||
// 回取字节(下方 WKURLSchemeHandler 供给)。
|
// 回取字节(下方 WKURLSchemeHandler 供给)。
|
||||||
private var outgoing: [String: URL] = [:]
|
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 historyCap = 50
|
||||||
private static let messagesCap = 200
|
private static let messagesCap = 200
|
||||||
@@ -276,7 +322,8 @@ final class EngineController: NSObject
|
|||||||
// 登出清空瞬时态:通知 / 日志 / 未读 / 已读传输集 / 待批准登录,不跨账号残留。
|
// 登出清空瞬时态:通知 / 日志 / 未读 / 已读传输集 / 待批准登录,不跨账号残留。
|
||||||
notices = []
|
notices = []
|
||||||
logs = []
|
logs = []
|
||||||
unreadMessages = 0
|
unreadByPeer = [:]
|
||||||
|
activeConversation = nil
|
||||||
unreadTransfers = 0
|
unreadTransfers = 0
|
||||||
seenIncomingTransfers = []
|
seenIncomingTransfers = []
|
||||||
activeTab = "transfer"
|
activeTab = "transfer"
|
||||||
@@ -307,7 +354,11 @@ final class EngineController: NSObject
|
|||||||
func clearHistory()
|
func clearHistory()
|
||||||
{
|
{
|
||||||
history.removeAll()
|
history.removeAll()
|
||||||
RecordsStore.clear(recordKey("history"))
|
// 落空数组到分桶键(而非删文件):activateRecords 在分桶键缺失时回退读 legacy 未分桶文件,
|
||||||
|
// 直接删文件会让旧记录重启复活(清空按钮失效的根因);写 [] 使 load 返回空数组、阻断回退。
|
||||||
|
// 顺手清掉 legacy 未分桶文件。
|
||||||
|
persistHistory()
|
||||||
|
RecordsStore.clear("history")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除一条消息(H)/ 清空全部(持久化同步)。
|
// 删除一条消息(H)/ 清空全部(持久化同步)。
|
||||||
@@ -320,7 +371,18 @@ final class EngineController: NSObject
|
|||||||
func clearMessages()
|
func clearMessages()
|
||||||
{
|
{
|
||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
RecordsStore.clear(recordKey("messages"))
|
// 同 clearHistory:落空数组阻断 legacy 回退(删文件会令清空后的消息重启复活),再清 legacy 文件。
|
||||||
|
persistMessages()
|
||||||
|
RecordsStore.clear("messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除与某设备的整段会话历史(按对端设备名):清掉该设备的全部消息,避免废会话 / 历史残留堆积。
|
||||||
|
// 仅删本地展示记录(持久化同步),不影响对方。删空后该会话从列表消失(除非该设备当前可达,
|
||||||
|
// 仍作「可发起新会话」项保留)。
|
||||||
|
func deleteConversation(_ peerName: String)
|
||||||
|
{
|
||||||
|
messages.removeAll { $0.peerName == peerName }
|
||||||
|
persistMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 瞬时通知(#2) ----
|
// ---- 瞬时通知(#2) ----
|
||||||
@@ -350,10 +412,28 @@ final class EngineController: NSObject
|
|||||||
func setActiveTab(_ tab: String)
|
func setActiveTab(_ tab: String)
|
||||||
{
|
{
|
||||||
activeTab = tab
|
activeTab = tab
|
||||||
if tab == "messages" { unreadMessages = 0 }
|
// 消息未读不在进入 tab 时清零(那只是看到会话列表、尚未读各设备消息)——改为打开某会话时清该
|
||||||
|
// 设备未读(见 markConversationRead)。传输未读仍按 tab 激活清零。
|
||||||
if tab == "transfer" { unreadTransfers = 0 }
|
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 去重,且仅当
|
// noteIncomingTransfer:传入传输首次出现(活跃或完成)时计一次未读,按 sessionId 去重,且仅当
|
||||||
// 不在传输页时计。本机发出的传输不计。
|
// 不在传输页时计。本机发出的传输不计。
|
||||||
private func noteIncomingTransfer(_ item: TransferItem)
|
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
|
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 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。
|
// 剪贴板上行:读本机 UIPasteboard 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。
|
||||||
func uploadClipboard()
|
func uploadClipboard()
|
||||||
{
|
{
|
||||||
@@ -617,6 +748,54 @@ final class EngineController: NSObject
|
|||||||
let data = (try handle.read(upToCount: count)) ?? Data()
|
let data = (try handle.read(upToCount: count)) ?? Data()
|
||||||
return data.base64EncodedString()
|
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 + 通知)
|
// MARK: - JS → 原生(RPC + 通知)
|
||||||
@@ -669,6 +848,40 @@ extension EngineController: WKScriptMessageHandler
|
|||||||
start: Self.intOf(payload["start"]),
|
start: Self.intOf(payload["start"]),
|
||||||
end: Self.intOf(payload["end"]))
|
end: Self.intOf(payload["end"]))
|
||||||
resolve(id: id, ok: true, value: b64)
|
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:
|
default:
|
||||||
resolve(id: id, ok: false, value: "unknown method: \(method)")
|
resolve(id: id, ok: false, value: "unknown method: \(method)")
|
||||||
}
|
}
|
||||||
@@ -729,8 +942,12 @@ extension EngineController: WKScriptMessageHandler
|
|||||||
messages.removeLast(messages.count - Self.messagesCap)
|
messages.removeLast(messages.count - Self.messagesCap)
|
||||||
}
|
}
|
||||||
persistMessages()
|
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":
|
case "transferDone":
|
||||||
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
|
// 完成项移出活跃、前插历史;按 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>
|
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Commilitia Drop 需要访问照片库,以便从相册选取图片或视频发送给其他设备。</string>
|
<string>Commilitia Drop 需要访问照片库,以便从相册选取图片或视频发送给其他设备。</string>
|
||||||
|
<key>UIAppFonts</key>
|
||||||
|
<array>
|
||||||
|
<string>Fredoka-SemiBold.ttf</string>
|
||||||
|
</array>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
// 引擎日志独立界面(#3):展示 EngineController.logs 环形缓冲里最近 N 条 console.warn/error,分级配色、
|
// 引擎日志独立界面(#3):展示 EngineController.logs 环形缓冲里最近 N 条诊断(含 pion 内部 ICE/mDNS
|
||||||
// 可选中复制、可一键清空。从设置页「查看日志」进入。便于在真机上(无 Web Inspector)定位 SSE / 鉴权 /
|
// 日志)。按传输会话(session id)聚合分组,每组可一键整体复制——便于真机调试时把某次传输的完整
|
||||||
// 传输失败原因——此前只能在设置页看到孤零零一行 lastEngineLog。
|
// 日志一并贴出(无 Web Inspector)。另有「复制全部」「清空」。
|
||||||
struct LogView: View
|
struct LogView: View
|
||||||
{
|
{
|
||||||
@Environment(EngineController.self) private var engine
|
@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
|
var body: some View
|
||||||
{
|
{
|
||||||
Group
|
Group
|
||||||
@@ -19,9 +44,23 @@ struct LogView: View
|
|||||||
{
|
{
|
||||||
List
|
List
|
||||||
{
|
{
|
||||||
ForEach(engine.logs)
|
ForEach(groups)
|
||||||
{ entry in
|
{ group in
|
||||||
logRow(entry)
|
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)
|
.listStyle(.plain)
|
||||||
@@ -33,15 +72,36 @@ struct LogView: View
|
|||||||
{
|
{
|
||||||
if !engine.logs.isEmpty
|
if !engine.logs.isEmpty
|
||||||
{
|
{
|
||||||
|
ToolbarItem(placement: .topBarTrailing)
|
||||||
|
{
|
||||||
|
Button { copy(engine.logs) }
|
||||||
|
label: { Label(t("ios.logs.copy"), systemImage: "doc.on.doc") }
|
||||||
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing)
|
ToolbarItem(placement: .topBarTrailing)
|
||||||
{
|
{
|
||||||
Button(role: .destructive) { engine.clearLogs() }
|
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
|
private func logRow(_ entry: LogEntry) -> some View
|
||||||
{
|
{
|
||||||
VStack(alignment: .leading, spacing: 4)
|
VStack(alignment: .leading, spacing: 4)
|
||||||
@@ -49,10 +109,10 @@ struct LogView: View
|
|||||||
HStack(spacing: 6)
|
HStack(spacing: 6)
|
||||||
{
|
{
|
||||||
Image(systemName: levelSymbol(entry.level))
|
Image(systemName: levelSymbol(entry.level))
|
||||||
.font(.caption)
|
.font(.oCaption)
|
||||||
.foregroundStyle(levelTint(entry.level))
|
.foregroundStyle(levelTint(entry.level))
|
||||||
Text(timeString(entry.at))
|
Text(timeString(entry.at))
|
||||||
.font(.caption2)
|
.font(.oCaption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,51 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// 设备间文本消息(#13):聊天式列表 + 底部撰写栏。消息由引擎经桥推来(in-memory,关 app 即清,
|
// 消息(按设备聚合,社交软件式):会话列表(每个对端设备一行)→ 点进 ConversationView 聊天线程。
|
||||||
// 对齐 web 语义),收发都经 POST /api/message(hub SendTo 实时转发;对端离线则后端发推送)。
|
// 会话由引擎 conversations() 合成——有消息历史的设备 ∪ 当前可达设备(在线 / 可唤醒,供发起新会话)。
|
||||||
// 可发给任意非本机设备(在线即时达、离线走推送),故目标取全部非本机设备而非仅在线。
|
// 可达设备可发(在线即时达、离线 iOS 走 APNs 唤醒);不可达的离线设备(离线桌面 / 浏览器)仅可查看
|
||||||
|
// 历史、撰写栏禁用。删除:单条消息(线程内长按)+ 整段会话(列表滑动 / 长按)——避免废会话堆积。
|
||||||
struct MessagesView: View
|
struct MessagesView: View
|
||||||
{
|
{
|
||||||
@Environment(EngineController.self) private var engine
|
@Environment(EngineController.self) private var engine
|
||||||
@State private var draft = ""
|
|
||||||
@State private var target = ""
|
|
||||||
@State private var showClearConfirm = false
|
@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
|
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")
|
ContentUnavailableView(t("ios.messages.empty"), systemImage: "message")
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ScrollViewReader
|
List
|
||||||
{ proxy in
|
{
|
||||||
ScrollView
|
ForEach(convos)
|
||||||
{
|
{ convo in
|
||||||
LazyVStack(alignment: .leading, spacing: 10)
|
NavigationLink(value: convo.peerName)
|
||||||
{
|
{
|
||||||
ForEach(ordered)
|
conversationRow(convo)
|
||||||
{ msg in
|
}
|
||||||
messageRow(msg)
|
// 长条行统一:滑动 + 长按都能删整段会话(清掉该设备全部消息记录)。
|
||||||
.id(msg.id)
|
.swipeActions(edge: .trailing)
|
||||||
.contextMenu
|
{
|
||||||
{
|
Button(role: .destructive) { engine.deleteConversation(convo.peerName) }
|
||||||
Button(role: .destructive) { engine.deleteMessage(msg.id) }
|
label: { Label(t("ios.messages.deleteConversation"), systemImage: "trash") }
|
||||||
label: { Label(t("transfer.action.delete"), 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"))
|
.navigationTitle(t("ios.tab.messages"))
|
||||||
|
.navigationDestination(for: String.self) { peer in ConversationView(peerName: peer) }
|
||||||
.toolbar
|
.toolbar
|
||||||
{
|
{
|
||||||
if !engine.messages.isEmpty
|
if !engine.messages.isEmpty
|
||||||
@@ -77,12 +62,124 @@ struct MessagesView: View
|
|||||||
Button(t("ios.records.clear"), role: .destructive) { engine.clearMessages() }
|
Button(t("ios.records.clear"), role: .destructive) { engine.clearMessages() }
|
||||||
Button(t("common.cancel"), role: .cancel) { }
|
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)
|
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) }
|
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,18 +189,12 @@ struct MessagesView: View
|
|||||||
return HStack
|
return HStack
|
||||||
{
|
{
|
||||||
if outgoing { Spacer(minLength: 48) }
|
if outgoing { Spacer(minLength: 48) }
|
||||||
VStack(alignment: outgoing ? .trailing : .leading, spacing: 2)
|
Text(msg.text)
|
||||||
{
|
.padding(.horizontal, 12)
|
||||||
Text(msg.peerName)
|
.padding(.vertical, 8)
|
||||||
.font(.caption2)
|
.background(outgoing ? Color.cdropAccent.opacity(0.18) : Color(.secondarySystemBackground),
|
||||||
.foregroundStyle(.secondary)
|
in: RoundedRectangle(cornerRadius: 14))
|
||||||
Text(msg.text)
|
.textSelection(.enabled)
|
||||||
.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) }
|
if !outgoing { Spacer(minLength: 48) }
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
|
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
|
||||||
@@ -112,53 +203,38 @@ struct MessagesView: View
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var composeBar: some View
|
private var composeBar: some View
|
||||||
{
|
{
|
||||||
VStack(spacing: 6)
|
if reachable
|
||||||
{
|
{
|
||||||
if targets.isEmpty
|
HStack(spacing: 10)
|
||||||
{
|
{
|
||||||
Text(t("ios.messages.noPeers"))
|
TextField(t("ios.messages.placeholder"), text: $draft, axis: .vertical)
|
||||||
.font(.caption)
|
.textFieldStyle(.roundedBorder)
|
||||||
.foregroundStyle(.secondary)
|
.lineLimit(1 ... 4)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.submitLabel(.send)
|
||||||
}
|
.onSubmit { send() }
|
||||||
else
|
Button { send() }
|
||||||
{
|
label: { Image(systemName: "paperplane.fill").font(.oTitle3) }
|
||||||
HStack(spacing: 10)
|
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
{
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.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()
|
private func send()
|
||||||
{
|
{
|
||||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !text.isEmpty, !target.isEmpty else { return }
|
guard !text.isEmpty else { return }
|
||||||
engine.sendMessage(to: target, text: text)
|
engine.sendMessage(to: peerName, text: text)
|
||||||
draft = ""
|
draft = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ private struct NoticeBanner: View
|
|||||||
HStack(spacing: 10)
|
HStack(spacing: 10)
|
||||||
{
|
{
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.font(.headline)
|
.font(.oHeadline)
|
||||||
.foregroundStyle(tint)
|
.foregroundStyle(tint)
|
||||||
Text(notice.text)
|
Text(notice.text)
|
||||||
.font(.subheadline)
|
.font(.oSubheadline)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|||||||
@@ -271,6 +271,7 @@
|
|||||||
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
|
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
|
||||||
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
||||||
"transfer.bytesRate": "{{rate}}/s",
|
"transfer.bytesRate": "{{rate}}/s",
|
||||||
|
"transfer.avgRate": "Avg {{rate}}/s",
|
||||||
"transfer.debug.toggle": "Debug",
|
"transfer.debug.toggle": "Debug",
|
||||||
"transfer.debug.iceGathering": "Gathering",
|
"transfer.debug.iceGathering": "Gathering",
|
||||||
"transfer.debug.iceConnection": "Connection",
|
"transfer.debug.iceConnection": "Connection",
|
||||||
@@ -354,13 +355,20 @@
|
|||||||
"ios.tab.messages": "Messages",
|
"ios.tab.messages": "Messages",
|
||||||
"ios.send.fromFiles": "From Files",
|
"ios.send.fromFiles": "From Files",
|
||||||
"ios.send.fromPhotos": "From Photos",
|
"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.empty": "No messages yet",
|
||||||
"ios.messages.noPeers": "No device to message",
|
"ios.messages.noPeers": "No device to message",
|
||||||
"ios.messages.placeholder": "Type a 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.clear": "Clear",
|
||||||
"ios.records.clearConfirm": "Clear all records? This cannot be undone.",
|
"ios.records.clearConfirm": "Clear all records? This cannot be undone.",
|
||||||
"ios.transfer.cancel": "Cancel transfer",
|
"ios.transfer.cancel": "Cancel transfer",
|
||||||
"ios.transfer.forceRelay": "Relay now",
|
"ios.transfer.forceRelay": "Relay",
|
||||||
"ios.settings.deviceCount": "Known Devices",
|
"ios.settings.deviceCount": "Known Devices",
|
||||||
"ios.settings.signaling": "Signaling",
|
"ios.settings.signaling": "Signaling",
|
||||||
"ios.settings.presenceEvents": "Presence Events",
|
"ios.settings.presenceEvents": "Presence Events",
|
||||||
@@ -375,6 +383,11 @@
|
|||||||
"ios.bg.title": "Transferring files",
|
"ios.bg.title": "Transferring files",
|
||||||
"ios.bg.subtitle": "Continues in the background",
|
"ios.bg.subtitle": "Continues in the background",
|
||||||
"ios.share.title": "Send {{n}} file(s) to a device",
|
"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.upload": "Upload Clipboard",
|
||||||
"ios.control.pull": "Pull Clipboard",
|
"ios.control.pull": "Pull Clipboard",
|
||||||
"ios.control.sessionLabel": "{{name}} · Control",
|
"ios.control.sessionLabel": "{{name}} · Control",
|
||||||
@@ -383,6 +396,8 @@
|
|||||||
"ios.logs.empty": "No logs yet",
|
"ios.logs.empty": "No logs yet",
|
||||||
"ios.logs.clear": "Clear Logs",
|
"ios.logs.clear": "Clear Logs",
|
||||||
"ios.logs.copy": "Copy All",
|
"ios.logs.copy": "Copy All",
|
||||||
|
"ios.logs.session": "Session",
|
||||||
|
"ios.logs.ungrouped": "Other",
|
||||||
"ios.scan.entry": "Scan to Sign In",
|
"ios.scan.entry": "Scan to Sign In",
|
||||||
"ios.scan.title": "Scan QR Code",
|
"ios.scan.title": "Scan QR Code",
|
||||||
"ios.scan.hint": "Point at the sign-in QR code on another device",
|
"ios.scan.hint": "Point at the sign-in QR code on another device",
|
||||||
|
|||||||
@@ -271,6 +271,7 @@
|
|||||||
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
|
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
|
||||||
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
||||||
"transfer.bytesRate": "{{rate}}/秒",
|
"transfer.bytesRate": "{{rate}}/秒",
|
||||||
|
"transfer.avgRate": "平均 {{rate}}/秒",
|
||||||
"transfer.debug.toggle": "调试信息",
|
"transfer.debug.toggle": "调试信息",
|
||||||
"transfer.debug.iceGathering": "候选收集",
|
"transfer.debug.iceGathering": "候选收集",
|
||||||
"transfer.debug.iceConnection": "连接状态",
|
"transfer.debug.iceConnection": "连接状态",
|
||||||
@@ -354,13 +355,20 @@
|
|||||||
"ios.tab.messages": "消息",
|
"ios.tab.messages": "消息",
|
||||||
"ios.send.fromFiles": "从文件",
|
"ios.send.fromFiles": "从文件",
|
||||||
"ios.send.fromPhotos": "从图库",
|
"ios.send.fromPhotos": "从图库",
|
||||||
|
"ios.send.previewTitle": "发送文件",
|
||||||
|
"ios.send.files": "文件",
|
||||||
|
"ios.send.send": "发送",
|
||||||
"ios.messages.empty": "暂无消息",
|
"ios.messages.empty": "暂无消息",
|
||||||
"ios.messages.noPeers": "没有可发送消息的设备",
|
"ios.messages.noPeers": "没有可发送消息的设备",
|
||||||
"ios.messages.placeholder": "输入消息…",
|
"ios.messages.placeholder": "输入消息…",
|
||||||
|
"ios.messages.viewOnly": "对方当前离线,仅可查看记录",
|
||||||
|
"ios.messages.deleteConversation": "删除会话",
|
||||||
|
"ios.messages.startHint": "点按开始会话",
|
||||||
|
"ios.messages.youPrefix": "你:",
|
||||||
"ios.records.clear": "清空",
|
"ios.records.clear": "清空",
|
||||||
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
|
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
|
||||||
"ios.transfer.cancel": "取消传输",
|
"ios.transfer.cancel": "取消传输",
|
||||||
"ios.transfer.forceRelay": "立即切到中继",
|
"ios.transfer.forceRelay": "中继传输",
|
||||||
"ios.settings.deviceCount": "已知设备",
|
"ios.settings.deviceCount": "已知设备",
|
||||||
"ios.settings.signaling": "信令连接",
|
"ios.settings.signaling": "信令连接",
|
||||||
"ios.settings.presenceEvents": "在线事件",
|
"ios.settings.presenceEvents": "在线事件",
|
||||||
@@ -375,6 +383,11 @@
|
|||||||
"ios.bg.title": "正在传输文件",
|
"ios.bg.title": "正在传输文件",
|
||||||
"ios.bg.subtitle": "切到后台仍继续传输",
|
"ios.bg.subtitle": "切到后台仍继续传输",
|
||||||
"ios.share.title": "发送 {{n}} 个文件到设备",
|
"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.upload": "上传剪贴板",
|
||||||
"ios.control.pull": "拉取剪贴板",
|
"ios.control.pull": "拉取剪贴板",
|
||||||
"ios.control.sessionLabel": "{{name}} · 控件",
|
"ios.control.sessionLabel": "{{name}} · 控件",
|
||||||
@@ -383,6 +396,8 @@
|
|||||||
"ios.logs.empty": "暂无日志",
|
"ios.logs.empty": "暂无日志",
|
||||||
"ios.logs.clear": "清空日志",
|
"ios.logs.clear": "清空日志",
|
||||||
"ios.logs.copy": "复制全部",
|
"ios.logs.copy": "复制全部",
|
||||||
|
"ios.logs.session": "会话",
|
||||||
|
"ios.logs.ungrouped": "其他",
|
||||||
"ios.scan.entry": "扫码登录设备",
|
"ios.scan.entry": "扫码登录设备",
|
||||||
"ios.scan.title": "扫码登录",
|
"ios.scan.title": "扫码登录",
|
||||||
"ios.scan.hint": "对准另一台设备上的登录二维码",
|
"ios.scan.hint": "对准另一台设备上的登录二维码",
|
||||||
|
|||||||
@@ -271,6 +271,7 @@
|
|||||||
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
|
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
|
||||||
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
||||||
"transfer.bytesRate": "{{rate}}/秒",
|
"transfer.bytesRate": "{{rate}}/秒",
|
||||||
|
"transfer.avgRate": "平均 {{rate}}/秒",
|
||||||
"transfer.debug.toggle": "偵錯資訊",
|
"transfer.debug.toggle": "偵錯資訊",
|
||||||
"transfer.debug.iceGathering": "候選收集",
|
"transfer.debug.iceGathering": "候選收集",
|
||||||
"transfer.debug.iceConnection": "連線狀態",
|
"transfer.debug.iceConnection": "連線狀態",
|
||||||
@@ -354,13 +355,20 @@
|
|||||||
"ios.tab.messages": "訊息",
|
"ios.tab.messages": "訊息",
|
||||||
"ios.send.fromFiles": "從檔案",
|
"ios.send.fromFiles": "從檔案",
|
||||||
"ios.send.fromPhotos": "從相簿",
|
"ios.send.fromPhotos": "從相簿",
|
||||||
|
"ios.send.previewTitle": "傳送檔案",
|
||||||
|
"ios.send.files": "檔案",
|
||||||
|
"ios.send.send": "傳送",
|
||||||
"ios.messages.empty": "尚無訊息",
|
"ios.messages.empty": "尚無訊息",
|
||||||
"ios.messages.noPeers": "沒有可傳送訊息的裝置",
|
"ios.messages.noPeers": "沒有可傳送訊息的裝置",
|
||||||
"ios.messages.placeholder": "輸入訊息…",
|
"ios.messages.placeholder": "輸入訊息…",
|
||||||
|
"ios.messages.viewOnly": "對方目前離線,僅可檢視記錄",
|
||||||
|
"ios.messages.deleteConversation": "刪除會話",
|
||||||
|
"ios.messages.startHint": "點按開始會話",
|
||||||
|
"ios.messages.youPrefix": "你:",
|
||||||
"ios.records.clear": "清空",
|
"ios.records.clear": "清空",
|
||||||
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
|
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
|
||||||
"ios.transfer.cancel": "取消傳輸",
|
"ios.transfer.cancel": "取消傳輸",
|
||||||
"ios.transfer.forceRelay": "立即切到中繼",
|
"ios.transfer.forceRelay": "中繼傳輸",
|
||||||
"ios.settings.deviceCount": "已知裝置",
|
"ios.settings.deviceCount": "已知裝置",
|
||||||
"ios.settings.signaling": "信令連線",
|
"ios.settings.signaling": "信令連線",
|
||||||
"ios.settings.presenceEvents": "上線事件",
|
"ios.settings.presenceEvents": "上線事件",
|
||||||
@@ -375,6 +383,11 @@
|
|||||||
"ios.bg.title": "正在傳輸檔案",
|
"ios.bg.title": "正在傳輸檔案",
|
||||||
"ios.bg.subtitle": "切到背景仍繼續傳輸",
|
"ios.bg.subtitle": "切到背景仍繼續傳輸",
|
||||||
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
|
"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.upload": "上傳剪貼簿",
|
||||||
"ios.control.pull": "拉取剪貼簿",
|
"ios.control.pull": "拉取剪貼簿",
|
||||||
"ios.control.sessionLabel": "{{name}} · 控制項",
|
"ios.control.sessionLabel": "{{name}} · 控制項",
|
||||||
@@ -383,6 +396,8 @@
|
|||||||
"ios.logs.empty": "尚無日誌",
|
"ios.logs.empty": "尚無日誌",
|
||||||
"ios.logs.clear": "清空日誌",
|
"ios.logs.clear": "清空日誌",
|
||||||
"ios.logs.copy": "複製全部",
|
"ios.logs.copy": "複製全部",
|
||||||
|
"ios.logs.session": "會話",
|
||||||
|
"ios.logs.ungrouped": "其他",
|
||||||
"ios.scan.entry": "掃碼登入裝置",
|
"ios.scan.entry": "掃碼登入裝置",
|
||||||
"ios.scan.title": "掃碼登入",
|
"ios.scan.title": "掃碼登入",
|
||||||
"ios.scan.hint": "對準另一台裝置上的登入 QR 碼",
|
"ios.scan.hint": "對準另一台裝置上的登入 QR 碼",
|
||||||
|
|||||||
@@ -76,22 +76,20 @@ struct RootView: View
|
|||||||
.opacity(0)
|
.opacity(0)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
// Share Extension 交接:cdrop://share 唤起后导入收件箱待发文件,弹出选设备发送。
|
// Share Extension 交接:cdrop://share 唤起 / 前台扫到收件箱待发文件 → 直入发送预览(与手动
|
||||||
|
// 选文件同一表单 SendComposeSheet),预填分享的文件,选目标设备发送。onSend 走 sendShares
|
||||||
|
// (把收件箱文件 move 到私有暂存再发),完成后 pendingShareFiles 清空 → 表单自动收起。
|
||||||
.onAppear { engine.loadPendingShares() }
|
.onAppear { engine.loadPendingShares() }
|
||||||
.confirmationDialog(
|
.sheet(isPresented: Binding(
|
||||||
t("ios.share.title", [ "n": String(engine.pendingShareFiles.count) ]),
|
get: { !engine.pendingShareFiles.isEmpty },
|
||||||
isPresented: Binding(
|
set: { if !$0 { engine.dismissShares() } }))
|
||||||
get: { !engine.pendingShareFiles.isEmpty },
|
|
||||||
set: { if !$0 { engine.dismissShares() } }
|
|
||||||
),
|
|
||||||
titleVisibility: .visible
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
ForEach(engine.sendableDevices())
|
SendComposeSheet(
|
||||||
{ dev in
|
files: engine.pendingShareFiles.map { PreviewFile(name: $0.lastPathComponent, size: fileByteSize($0)) },
|
||||||
Button(engine.sendLabel(dev)) { engine.sendShares(to: dev.name) }
|
devices: engine.sendableDevices(),
|
||||||
}
|
sendLabel: engine.sendLabel,
|
||||||
Button(t("common.cancel"), role: .cancel) { engine.dismissShares() }
|
onSend: { device in engine.sendShares(to: device) },
|
||||||
|
onCancel: { engine.dismissShares() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,9 +99,11 @@ struct TransferListView: View
|
|||||||
@Environment(EngineController.self) private var engine
|
@Environment(EngineController.self) private var engine
|
||||||
@State private var showImporter = false
|
@State private var showImporter = false
|
||||||
@State private var showPhotoPicker = false
|
@State private var showPhotoPicker = false
|
||||||
@State private var photoItem: PhotosPickerItem?
|
@State private var photoItems: [PhotosPickerItem] = []
|
||||||
@State private var pickedURL: URL?
|
// 选中的待发文件(手动选文件 / 图库临时文件统一收口,支持多文件):非空即弹发送预览
|
||||||
@State private var showDevicePicker = false
|
// (SendComposeSheet)。
|
||||||
|
@State private var pickedURLs: [URL] = []
|
||||||
|
@State private var showCompose = false
|
||||||
@State private var showNoDevices = false
|
@State private var showNoDevices = false
|
||||||
@State private var showClearConfirm = false
|
@State private var showClearConfirm = false
|
||||||
// 点按传输卡片进详情:不在 List 内用 NavigationLink(会出现 chevron 改变卡片观感),改经
|
// 点按传输卡片进详情:不在 List 内用 NavigationLink(会出现 chevron 改变卡片观感),改经
|
||||||
@@ -122,7 +122,8 @@ struct TransferListView: View
|
|||||||
{
|
{
|
||||||
if engine.transfers.isEmpty && engine.history.isEmpty
|
if engine.transfers.isEmpty && engine.history.isEmpty
|
||||||
{
|
{
|
||||||
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
|
// 主页空态=品牌 hero(吉祥物 + 字标 + 文案):首屏即见品牌,吉祥物抱纸恰呼应「等你 drop」。
|
||||||
|
BrandHero(tagline: t("ios.transfer.empty"))
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -161,31 +162,29 @@ struct TransferListView: View
|
|||||||
label:
|
label:
|
||||||
{
|
{
|
||||||
Image(systemName: "paperplane.fill")
|
Image(systemName: "paperplane.fill")
|
||||||
.font(.title2)
|
.font(.oTitle2)
|
||||||
.padding(18)
|
.padding(18)
|
||||||
}
|
}
|
||||||
.buttonStyle(.glassProminent)
|
.buttonStyle(.glassProminent)
|
||||||
.padding(24)
|
.padding(24)
|
||||||
}
|
}
|
||||||
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ])
|
// 文件 / 图库均支持一次多选;选完统一进发送预览(SendComposeSheet)选目标设备发送。
|
||||||
|
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ],
|
||||||
|
allowsMultipleSelection: true)
|
||||||
{ result in
|
{ result in
|
||||||
if case .success(let url) = result
|
if case .success(let urls) = result, !urls.isEmpty { present(urls) }
|
||||||
{
|
|
||||||
pickedURL = url
|
|
||||||
if sendableDevices.isEmpty { showNoDevices = true }
|
|
||||||
else { showDevicePicker = true }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.photosPicker(isPresented: $showPhotoPicker, selection: $photoItem,
|
.photosPicker(isPresented: $showPhotoPicker, selection: $photoItems,
|
||||||
matching: .any(of: [ .images, .videos ]))
|
matching: .any(of: [ .images, .videos ]))
|
||||||
.onChange(of: photoItem) { loadPhoto() }
|
.onChange(of: photoItems) { loadPhotos() }
|
||||||
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
|
.sheet(isPresented: $showCompose, onDismiss: clearPicked)
|
||||||
{
|
{
|
||||||
ForEach(sendableDevices)
|
SendComposeSheet(
|
||||||
{ dev in
|
files: pickedURLs.map { PreviewFile(name: $0.lastPathComponent, size: fileByteSize($0)) },
|
||||||
Button(engine.sendLabel(dev)) { startSend(to: dev.name) }
|
devices: sendableDevices,
|
||||||
}
|
sendLabel: engine.sendLabel,
|
||||||
Button(t("common.cancel"), role: .cancel) { }
|
onSend: { device in sendPicked(to: device) },
|
||||||
|
onCancel: { showCompose = false })
|
||||||
}
|
}
|
||||||
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
|
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
|
||||||
}
|
}
|
||||||
@@ -221,7 +220,7 @@ struct TransferListView: View
|
|||||||
HStack
|
HStack
|
||||||
{
|
{
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.subheadline)
|
.font(.oSubheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
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 }
|
pickedURLs = urls
|
||||||
engine.sendFile(to: device, fileURL: url)
|
if sendableDevices.isEmpty { showNoDevices = true }
|
||||||
pickedURL = nil
|
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
|
Task
|
||||||
{
|
{
|
||||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
var urls: [URL] = []
|
||||||
let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item))
|
for item in items
|
||||||
else { return }
|
|
||||||
await MainActor.run
|
|
||||||
{
|
{
|
||||||
pickedURL = url
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
photoItem = nil
|
let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item))
|
||||||
if sendableDevices.isEmpty { showNoDevices = true }
|
{
|
||||||
else { showDevicePicker = true }
|
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")
|
? "arrow.down.circle" : "arrow.up.circle")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text(item.fileName)
|
Text(item.fileName)
|
||||||
.font(.headline)
|
.font(.oHeadline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -335,7 +351,7 @@ struct TransferCardView: View
|
|||||||
ProgressView(value: progressOf(item))
|
ProgressView(value: progressOf(item))
|
||||||
}
|
}
|
||||||
Text(detailLine)
|
Text(detailLine)
|
||||||
.font(.caption)
|
.font(.oCaption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -360,6 +376,11 @@ struct TransferCardView: View
|
|||||||
{
|
{
|
||||||
parts.append(t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
|
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: " · ")
|
return parts.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,8 +448,10 @@ struct TransferDetailView: View
|
|||||||
}
|
}
|
||||||
if let rate = item.bytesPerSec, rate > 0
|
if let rate = item.bytesPerSec, rate > 0
|
||||||
{
|
{
|
||||||
|
// 完成显示平均速度、进行中显示瞬时速度(值文案不同;store 已据状态置 bytesPerSec)。
|
||||||
LabeledContent(t("ios.detail.speed"),
|
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))
|
ProgressView(value: progressOf(item))
|
||||||
}
|
}
|
||||||
@@ -602,14 +625,14 @@ struct DeviceListView: View
|
|||||||
if isSelf(dev)
|
if isSelf(dev)
|
||||||
{
|
{
|
||||||
Text(t("ios.devices.thisDevice"))
|
Text(t("ios.devices.thisDevice"))
|
||||||
.font(.caption2)
|
.font(.oCaption2)
|
||||||
.foregroundStyle(Color.cdropAccent)
|
.foregroundStyle(Color.cdropAccent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(dev.online
|
Text(dev.online
|
||||||
? t("settings.online")
|
? t("settings.online")
|
||||||
: t("settings.lastSeen", [ "time": relativeTime(dev.lastSeen) ]))
|
: t("settings.lastSeen", [ "time": relativeTime(dev.lastSeen) ]))
|
||||||
.font(.caption)
|
.font(.oCaption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,6 +655,23 @@ struct SettingsView: View
|
|||||||
{
|
{
|
||||||
List
|
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
|
if let user = auth.session?.user
|
||||||
{
|
{
|
||||||
Section
|
Section
|
||||||
@@ -678,7 +718,7 @@ struct SettingsView: View
|
|||||||
if !engine.lastEngineLog.isEmpty
|
if !engine.lastEngineLog.isEmpty
|
||||||
{
|
{
|
||||||
Text(engine.lastEngineLog)
|
Text(engine.lastEngineLog)
|
||||||
.font(.caption2)
|
.font(.oCaption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
@@ -712,6 +752,15 @@ struct SettingsView: View
|
|||||||
return t("ios.engine.disconnected")
|
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})。空 / 未变则忽略。
|
// 即时改名:提交时把草稿交引擎改名(PATCH /api/devices/{device_id})。空 / 未变则忽略。
|
||||||
// 成功后引擎经 renamed 事件落本地名 + Keychain 会话名 + 刷新 engine.deviceName。
|
// 成功后引擎经 renamed 事件落本地名 + Keychain 会话名 + 刷新 engine.deviceName。
|
||||||
private func commitRename()
|
private func commitRename()
|
||||||
@@ -812,7 +861,7 @@ struct ReceivedFilesView: View
|
|||||||
Text(url.lastPathComponent)
|
Text(url.lastPathComponent)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(fileSizeString(url))
|
Text(fileSizeString(url))
|
||||||
.font(.caption)
|
.font(.oCaption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -861,7 +910,7 @@ private func progressOf(_ item: TransferItem) -> Double
|
|||||||
return min(1.0, Double(sent) / Double(item.fileSize))
|
return min(1.0, Double(sent) / Double(item.fileSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatBytes(_ bytes: Int) -> String
|
func formatBytes(_ bytes: Int) -> String
|
||||||
{
|
{
|
||||||
let f = ByteCountFormatter()
|
let f = ByteCountFormatter()
|
||||||
f.countStyle = .file
|
f.countStyle = .file
|
||||||
@@ -876,7 +925,7 @@ private func fileSizeString(_ url: URL) -> String
|
|||||||
|
|
||||||
// lastSeen 是 epoch 秒(后端 devices.last_seen=now.Unix(),web formatRelative 也按秒);
|
// lastSeen 是 epoch 秒(后端 devices.last_seen=now.Unix(),web formatRelative 也按秒);
|
||||||
// 转相对时间填进共享 settings.lastSeen 文案的 {{time}}。
|
// 转相对时间填进共享 settings.lastSeen 文案的 {{time}}。
|
||||||
private func relativeTime(_ epochSec: Double) -> String
|
func relativeTime(_ epochSec: Double) -> String
|
||||||
{
|
{
|
||||||
let date = Date(timeIntervalSince1970: epochSec)
|
let date = Date(timeIntervalSince1970: epochSec)
|
||||||
let f = RelativeDateTimeFormatter()
|
let f = RelativeDateTimeFormatter()
|
||||||
@@ -885,7 +934,7 @@ private func relativeTime(_ epochSec: Double) -> String
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 设备类型 → SF Symbol(对齐 web mapKind 的语义)。
|
// 设备类型 → SF Symbol(对齐 web mapKind 的语义)。
|
||||||
private func deviceSymbol(_ type: String) -> String
|
func deviceSymbol(_ type: String) -> String
|
||||||
{
|
{
|
||||||
let s = type.lowercased()
|
let s = type.lowercased()
|
||||||
if s.contains("mac") || s == "darwin" { return "laptopcomputer" }
|
if s.contains("mac") || s == "darwin" { return "laptopcomputer" }
|
||||||
|
|||||||
@@ -59,14 +59,14 @@ struct ScanSheet: View
|
|||||||
if invalid
|
if invalid
|
||||||
{
|
{
|
||||||
Text(t("ios.scan.invalid"))
|
Text(t("ios.scan.invalid"))
|
||||||
.font(.callout)
|
.font(.oCallout)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.background(.red.opacity(0.85), in: Capsule())
|
.background(.red.opacity(0.85), in: Capsule())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
Text(t("ios.scan.hint"))
|
Text(t("ios.scan.hint"))
|
||||||
.font(.footnote)
|
.font(.oFootnote)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.padding(.horizontal, 14)
|
.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
|
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")
|
BrandMark(size: 30)
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text(t("ios.widget.clipboardName"))
|
Text(t("ios.widget.clipboardName"))
|
||||||
.font(.caption).bold()
|
.font(.subheadline).bold()
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
// 小尺寸纵向堆叠两按钮;中尺寸并排。
|
actionButton(t("ios.control.upload"), "square.and.arrow.up", UploadClipboardIntent())
|
||||||
if family == .systemSmall
|
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.upload"), "square.and.arrow.up", UploadClipboardIntent())
|
||||||
actionButton(t("ios.control.pull"), "square.and.arrow.down", PullClipboardIntent())
|
actionButton(t("ios.control.pull"), "square.and.arrow.down", PullClipboardIntent())
|
||||||
}
|
}
|
||||||
else
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
{
|
|
||||||
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, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding(16)
|
||||||
.containerBackground(.fill.tertiary, for: .widget)
|
}
|
||||||
|
|
||||||
|
// 品牌底:淡品牌色斜向渐变(取代原 .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
|
private func actionButton(_ title: String, _ symbol: String, _ intent: some AppIntent) -> some View
|
||||||
@@ -68,11 +93,12 @@ struct ClipboardWidgetView: View
|
|||||||
Button(intent: intent)
|
Button(intent: intent)
|
||||||
{
|
{
|
||||||
Label(title, systemImage: symbol)
|
Label(title, systemImage: symbol)
|
||||||
.font(.caption2)
|
.font(.caption).fontWeight(.semibold)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 3)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(.cdropAccent)
|
.tint(.cdropAccent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ options:
|
|||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "26.0"
|
iOS: "26.0"
|
||||||
createIntermediateGroups: true
|
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:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
@@ -26,10 +34,16 @@ targets:
|
|||||||
embed: true
|
embed: true
|
||||||
- target: CDropWidgets
|
- target: CDropWidgets
|
||||||
embed: true
|
embed: true
|
||||||
|
# 原生数据面引擎(libwebrtc):SPM 预编 xcframework,Xcode 自动嵌入 + 签名。仅主 app 链接——
|
||||||
|
# Share / 控件扩展不碰数据面,避免超扩展 120MB 内存限。见 LibWebRtcEngine.swift。
|
||||||
|
- package: WebRTC
|
||||||
info:
|
info:
|
||||||
path: Sources/Info.plist
|
path: Sources/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Commilitia Drop
|
CFBundleDisplayName: Commilitia Drop
|
||||||
|
# 品牌字标字体 Fredoka SemiBold(Shared/Fonts/,随 bundle 打包),用于 Wordmark(与 web 一致)。
|
||||||
|
UIAppFonts:
|
||||||
|
- Fredoka-SemiBold.ttf
|
||||||
# cdrop:// URL scheme:Share Extension 交接后经 cdrop://share 深链唤起主 app 选设备发送。
|
# cdrop:// URL scheme:Share Extension 交接后经 cdrop://share 深链唤起主 app 选设备发送。
|
||||||
CFBundleURLTypes:
|
CFBundleURLTypes:
|
||||||
- CFBundleURLName: net.commilitia.Commilitia-Drop
|
- CFBundleURLName: net.commilitia.Commilitia-Drop
|
||||||
@@ -79,10 +93,15 @@ targets:
|
|||||||
sources:
|
sources:
|
||||||
- path: Share
|
- path: Share
|
||||||
- path: Shared
|
- path: Shared
|
||||||
|
# i18n JSON 随扩展 bundle 一并打包,使分享引导界面的 t() 文案随设备语言(同控件扩展)。
|
||||||
|
- path: Sources/Resources/i18n
|
||||||
info:
|
info:
|
||||||
path: Share/Info.plist
|
path: Share/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Commilitia Drop
|
CFBundleDisplayName: Commilitia Drop
|
||||||
|
# 品牌字标字体(分享引导界面的 Wordmark 用),随扩展 bundle 打包 + 注册。
|
||||||
|
UIAppFonts:
|
||||||
|
- Fredoka-SemiBold.ttf
|
||||||
NSExtension:
|
NSExtension:
|
||||||
NSExtensionPointIdentifier: com.apple.share-services
|
NSExtensionPointIdentifier: com.apple.share-services
|
||||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
|
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
|
||||||
@@ -117,10 +136,32 @@ targets:
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets
|
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
CODE_SIGN_ENTITLEMENTS: Widgets/CDropWidgets.entitlements
|
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:
|
schemes:
|
||||||
CDrop:
|
CDrop:
|
||||||
build:
|
build:
|
||||||
targets:
|
targets:
|
||||||
CDrop: all
|
CDrop: all
|
||||||
|
test:
|
||||||
|
targets:
|
||||||
|
- CDropTests
|
||||||
|
gatherCoverageData: false
|
||||||
run:
|
run:
|
||||||
config: Debug
|
config: Debug
|
||||||
|
|||||||
+3
-1
@@ -31,7 +31,8 @@
|
|||||||
|
|
||||||
- **视觉材质**:液态玻璃 vs Theme B。
|
- **视觉材质**:液态玻璃 vs Theme B。
|
||||||
- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。
|
- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。
|
||||||
- **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto;间距 / 圆角 / 字阶各用平台母语。
|
- **字体 / 排版**(2026-06-28 更新,字族已收敛):两端现**同用** Orbit Gothic(Sans)+ Maple Mono(Mono)+ Fredoka(字标)——字族升为共享源(强约束③延伸)。**残余分叉**仅在落地机制(iOS woff2 运行期 `CTFontManagerRegisterFontsForURL` 注册 + UIKit appearance 代理 + Dynamic Type vs web CSS `@font-face`)与间距 / 圆角 / 字阶(各用平台母语);生僻字 iOS 回退苹方、web 回退 Noto。Orbit 为专有字体,两端均不入库、构建期从 CDN 拉。
|
||||||
|
- **分享引导界面**(仅 iOS):Share Extension 内的吉祥物引导卡(`ShareGuideView`)引用户回主程序完成发送——平台专属流程,web 无对应。
|
||||||
- **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。
|
- **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。
|
||||||
- **控制中心控件标签**:运行时控件标签(用户点按的那个)经 `t()` 跟随设备语言;但「添加控件」库名 + Shortcuts 标题是 `LocalizedStringResource` 静态值、`CDropWidgets` target 无 String Catalog,故对 en / zh-TW 用户回退简体字面量(审查 L7,平台专属功能可接受分叉;要彻底本地化须为 Widgets target 加 `.xcstrings`)。
|
- **控制中心控件标签**:运行时控件标签(用户点按的那个)经 `t()` 跟随设备语言;但「添加控件」库名 + Shortcuts 标题是 `LocalizedStringResource` 静态值、`CDropWidgets` target 无 String Catalog,故对 en / zh-TW 用户回退简体字面量(审查 L7,平台专属功能可接受分叉;要彻底本地化须为 Widgets target 加 `.xcstrings`)。
|
||||||
|
|
||||||
@@ -60,3 +61,4 @@
|
|||||||
| 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices` 推 `presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history;`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`(TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()` 加 `{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html` → `engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 |
|
| 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices` 推 `presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history;`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`(TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()` 加 `{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html` → `engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 |
|
||||||
| 2026-06-24 | engine/main.ts:transfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署**) | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreview;project.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`(Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`(UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/s(relay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`,ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html` → `engine-BC6mZ9zP.js`(含节流 + ice 摘要) |
|
| 2026-06-24 | engine/main.ts:transfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署**) | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreview;project.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`(Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`(UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/s(relay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`,ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html` → `engine-BC6mZ9zP.js`(含节流 + ice 摘要) |
|
||||||
| 2026-06-27 | source.ts(新 FileSource)+ p2p/relay/transfer 改 src + engine/main.ts 加 registerPush/provisionWidgetSession + i18n 加 `ios.bg.*`/`ios.share.*`/`ios.control.*`(本提交) | 新 BackgroundTaskManager / AppDelegate / PushRegistry / Shared(AppGroup,I18n,CDropAPI,WidgetSession) / Share(ShareViewController) / Widgets(控件+intents+client);EngineController 加 Range handler / 后台 sync / 分享导入 / 控件代铸;project.yml 加两扩展 target + 真机签名 scaffold;包名改 `net.commilitia.Commilitia-Drop`(本提交) | **计划完成(I3–I7)**:发送端流式(FileSource + 原生 Range,新增**分享发送 + 控件**两条非引擎流程,对应残余项 1/2)/ 后台续传 / APNs(后端 internal/apns + 原生注册,新后端字段 `push_subscriptions.platform` + 端点 `POST /api/push/apns/register` 对应残余项 3)/ Share Extension / 控制中心两控件(新允许分叉:控件标签见上)。经 ultracode 多 agent 审查(0 HIGH,修 2 MED + 7 LOW)。web tsc/build + go build/test + xcodebuild 全过;模拟器验登录 / 分享弹窗 / 控件嵌入 / App Group 容器。**真机 + 真发推送 + 分发账号门控**(见 REALDEVICE.md)。**待部署 prod**:engine.html(新桥)+ cdrop binary(APNs 端点 + platform 列) |
|
| 2026-06-27 | source.ts(新 FileSource)+ p2p/relay/transfer 改 src + engine/main.ts 加 registerPush/provisionWidgetSession + i18n 加 `ios.bg.*`/`ios.share.*`/`ios.control.*`(本提交) | 新 BackgroundTaskManager / AppDelegate / PushRegistry / Shared(AppGroup,I18n,CDropAPI,WidgetSession) / Share(ShareViewController) / Widgets(控件+intents+client);EngineController 加 Range handler / 后台 sync / 分享导入 / 控件代铸;project.yml 加两扩展 target + 真机签名 scaffold;包名改 `net.commilitia.Commilitia-Drop`(本提交) | **计划完成(I3–I7)**:发送端流式(FileSource + 原生 Range,新增**分享发送 + 控件**两条非引擎流程,对应残余项 1/2)/ 后台续传 / APNs(后端 internal/apns + 原生注册,新后端字段 `push_subscriptions.platform` + 端点 `POST /api/push/apns/register` 对应残余项 3)/ Share Extension / 控制中心两控件(新允许分叉:控件标签见上)。经 ultracode 多 agent 审查(0 HIGH,修 2 MED + 7 LOW)。web tsc/build + go build/test + xcodebuild 全过;模拟器验登录 / 分享弹窗 / 控件嵌入 / App Group 容器。**真机 + 真发推送 + 分发账号门控**(见 REALDEVICE.md)。**待部署 prod**:engine.html(新桥)+ cdrop binary(APNs 端点 + platform 列) |
|
||||||
|
| 2026-06-28 | `p2p.ts` 翻 `IOS_NATIVE=true` + 新 `p2pIos.ts` + `p2pNative.ts`/`net/ios.ts` 微调;平均速度 `store/slices/transfer.ts` + `TransferRow.tsx`;i18n 加 `transfer.avgRate`(native-transfer C1/C2) | 新 `Engine/LibWebRtcEngine.swift` + 改 `NativeTransfer.swift`/`EngineController.swift`(原生数据面);新 `SendComposeSheet.swift` + 重写 `MessagesView.swift`(发送预览 / 按设备会话 + 每设备未读);新 `Shared/Brand.swift`/`Sources/BrandFonts.swift`/`Share/ShareGuideView.swift` + 品牌资产;i18n 加 `ios.send.*`/`ios.messages.*`/`ios.share.guide.*`(native-transfer C1/C2) | **iOS 原生数据面 libwebrtc**(P2P 数据面下沉原生、线协议逐字节对齐桌面 pion,PLAN 决策 F;编排留无头 WebView)+ **UX 改造**(发送预览直入 / 分享直入 / 消息按设备会话 + 每设备未读红点 / 删除粒度——对应残余项 1/2)+ **品牌 + 字体**(logo + Orbit Gothic / Maple Mono / Fredoka,字族升共享源、字体分叉条目已更新)+ **全平台平均速度**(残余项 2,三端一致)。环回端到端 20MB 逐字节 + 模拟器全 app 编译 + web tsc/build 过;真机已装、用户反馈“传输总体良好”,真网吞吐互通为剩余闸。**待部署 prod**:engine.html(`IOS_NATIVE=true` 引擎);桌面平均速度待 `desktop-build-{mac,win}` 重建分发 |
|
||||||
|
|||||||
+66
@@ -53,6 +53,17 @@
|
|||||||
- **缺失 / 拒绝时按特性二选一**:① **有兜底则降级**——本地网络→回退中继并提示“仅中继、无法同内网直连”;通知→无后台推送但前台仍可用。② **无兜底则提示“无权限”并阻塞该特性**——相机→扫码不可用,提示 + 跳系统设置;阻塞的是**该特性**、非整个 app。
|
- **缺失 / 拒绝时按特性二选一**:① **有兜底则降级**——本地网络→回退中继并提示“仅中继、无法同内网直连”;通知→无后台推送但前台仍可用。② **无兜底则提示“无权限”并阻塞该特性**——相机→扫码不可用,提示 + 跳系统设置;阻塞的是**该特性**、非整个 app。
|
||||||
- 不拿任一权限当**整个 app** 的门槛(HIG 5.1.2)。详见 §9.2 逐权限映射。
|
- 不拿任一权限当**整个 app** 的门槛(HIG 5.1.2)。详见 §9.2 逐权限映射。
|
||||||
|
|
||||||
|
### 决策 F(新,2026-06-28):iOS P2P 数据面下沉原生 libwebrtc(与桌面 pion 对称)
|
||||||
|
|
||||||
|
修订决策 A 的“数据面亦跑在无头 WebView JS 里”。**P2P DataChannel 的字节收发(数据面)已从无头 WebView 的 JS 引擎移到原生 libwebrtc**(`Sources/Engine/LibWebRtcEngine.swift`,`RTCPeerConnection`),与桌面把数据面移到 Go/pion **完全对称**。
|
||||||
|
|
||||||
|
- **无头 WebView 此后只跑编排**:信令转发 / 会话 / presence / 消息 / 状态机 / relay(`hub.ts` + `transfer.ts`),**不再承载 P2P 数据面**。决策 A 的“原生壳 + 无头引擎”仍成立,只是引擎的职责收窄到编排层。
|
||||||
|
- **缘由(U1→U2 翻案)**:先试 gomobile+pion(与桌面同引擎,U1),真机 POC **证伪**——Go raw BSD socket 不与 iOS Network framework 集成(连接时好时坏 + 中途断流)。按闸退到 **libwebrtc.framework**(U2,stasel/WebRTC M149 经 SPM、pin `exactVersion 149.0.0`)。完整决策依据与实测证据见 `desktop/NATIVE-TRANSFER.md` §7/§8。
|
||||||
|
- **收益**:libwebrtc 原生集成 iOS 网络栈 + DcSCTP 默认 ~5MB rwnd——摆脱 WebKit 写死的 256KB 接收窗(R-iOS-3 的吞吐残差)对数据面的钳制,连通性与吞吐一并根治。
|
||||||
|
- **互通靠线协议逐字节对齐**:`LibWebRtcEngine` 是 `desktop/engine` 的 `engine.go`/`session.go` 线协议的忠实 Swift 端口——DataChannel `cdrop-file`(ordered)、`meta`/二进制分片(64KB)/`done`/`ack` 帧、16MB/4MB 水位背压、ack 追平完成、不冲突落盘。故 iOS↔桌面(pion)、iOS↔浏览器(JS)三端互通。
|
||||||
|
- **桥与 JS 路由零改动**:`IOS_NATIVE` 开关、`p2pIos.ts`/`net/ios.ts` 桥协议原样;只在 Swift 侧新增一条原生传输 RPC(`startOutgoing`/`startIncoming`/`handleSignal`/`cancel`,completion-based,经 `resolveOnMain` 跳主线程 resolve)。**iOS 定向的引擎配置绝不回流 `desktop/engine` 共享包**。
|
||||||
|
- **验证**:环回端到端(两个引擎同进程交叉连、20MB 经完整线协议传输后逐字节比对)✅;模拟器全 app 编译 ✅;web typecheck/build ✅。真机 / 真网吞吐互通为剩余闸(须 deploy prod 让设备拿到 `IOS_NATIVE=true` 引擎 + 真机装机,二者 Touch ID 门控)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 后端现状(决定 iOS 能做什么,2026-06-24 扫描)
|
## 1. 后端现状(决定 iOS 能做什么,2026-06-24 扫描)
|
||||||
@@ -72,6 +83,8 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
|
|||||||
|
|
||||||
## 2. 桥协议(arch A 的接口契约,账号无关、可先冻结)
|
## 2. 桥协议(arch A 的接口契约,账号无关、可先冻结)
|
||||||
|
|
||||||
|
> **状态(2026-06-28,决策 F 后)**:本节描述的桥是**编排与控制面**(信令 / 会话 / presence / 消息 / 接收回退字节通道)。**P2P 数据面(含其磁盘 I/O)现走原生 libwebrtc**(决策 F),不再经此桥的 `sendFile` / `Begin`-`Append`-`Finalize` 路径;那条接收字节通道仅保留给 relay / 非原生回退。下文桥语义对编排层仍准确。
|
||||||
|
|
||||||
原生 ↔ 无头 JS 引擎的 RPC:`WKScriptMessageHandler`(JS→Swift)+ `evaluateJavaScript`(Swift→JS),类比 Wails 的 `EventsEmit` / bound methods,但**方向相反**(原生在顶层)。
|
原生 ↔ 无头 JS 引擎的 RPC:`WKScriptMessageHandler`(JS→Swift)+ `evaluateJavaScript`(Swift→JS),类比 Wails 的 `EventsEmit` / bound methods,但**方向相反**(原生在顶层)。
|
||||||
|
|
||||||
- **平台判定**:`isIOSShell()`(`web/src/net/ios.ts`)查 `window.webkit.messageHandlers.cdropEngine` 在场(类比 `isDesktop()` 查 `window.go.main.App`)。无头入口(`web/src/engine/main.ts`)据此装配桥。
|
- **平台判定**:`isIOSShell()`(`web/src/net/ios.ts`)查 `window.webkit.messageHandlers.cdropEngine` 在场(类比 `isDesktop()` 查 `window.go.main.App`)。无头入口(`web/src/engine/main.ts`)据此装配桥。
|
||||||
@@ -113,6 +126,8 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
|
|||||||
|
|
||||||
> **实现状态(2026-06-27)**:I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4,FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share Extension(App Group 收件箱 + `cdrop://share` 深链);控制中心两控件(**专用 broker 设备会话**,纯原生 REST,不与引擎抢 refresh 轮换)。模拟器验 + ultracode 多 agent 审查修讫(0 HIGH,修 2 MED + 7 LOW)。**I0 账号 / 证书 + 真机签名 / 真发推送 / 旁加载分发**仍账号门控——手册 `ios/CDrop/REALDEVICE.md`(ASC API Key 自动 provisioning + `just ios-device` 全 CLI 装机,`-allowProvisioningUpdates` 自动登记设备 / 建 profile)。包名 `net.commilitia.Commilitia-Drop`,App Group `group.net.commilitia.Commilitia-Drop`。
|
> **实现状态(2026-06-27)**:I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4,FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share Extension(App Group 收件箱 + `cdrop://share` 深链);控制中心两控件(**专用 broker 设备会话**,纯原生 REST,不与引擎抢 refresh 轮换)。模拟器验 + ultracode 多 agent 审查修讫(0 HIGH,修 2 MED + 7 LOW)。**I0 账号 / 证书 + 真机签名 / 真发推送 / 旁加载分发**仍账号门控——手册 `ios/CDrop/REALDEVICE.md`(ASC API Key 自动 provisioning + `just ios-device` 全 CLI 装机,`-allowProvisioningUpdates` 自动登记设备 / 建 profile)。包名 `net.commilitia.Commilitia-Drop`,App Group `group.net.commilitia.Commilitia-Drop`。
|
||||||
|
|
||||||
|
> **更新(2026-06-28)**:① **P2P 数据面改原生 libwebrtc**(决策 F;完整依据 `desktop/NATIVE-TRANSFER.md` §7/§8)——已真机装机、用户反馈“传输总体良好”,真网吞吐互通为剩余闸;② **UX 改造**(发送预览直入 / 消息按设备会话 / 删除粒度,§10);③ **品牌与字体系统**(logo + Orbit Gothic / Maple Mono / Fredoka,§11)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开)
|
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开)
|
||||||
@@ -235,6 +250,57 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 10. UX 改造(2026-06-28,真机反馈驱动)
|
||||||
|
|
||||||
|
真机使用后落地的三块结构性改造,均属决策 D 的“非引擎驱动 UI 流程”(进 `ios/PARITY.md` 残余项 1/2,已登记)。
|
||||||
|
|
||||||
|
### 10.1 发送预览界面(取代即弹的设备选择框)
|
||||||
|
|
||||||
|
- 选文件 / 选图后**不再**直接弹设备选择,而是进入**发送预览**(`Sources/SendComposeSheet.swift`):列出待发文件名 + 字节大小,下方列表选目标设备。手动发送与分享发送**共用**此界面。
|
||||||
|
- 手动入口支持**多选**:`fileImporter(allowsMultipleSelection: true)` + `photosPicker(selection:)` 数组;`RootView` 的 `present`/`sendPicked`/`clearPicked`/`loadPhotos` 串起选取→预览→发送。
|
||||||
|
|
||||||
|
### 10.2 分享直入预览(修“必须重启 app”)
|
||||||
|
|
||||||
|
- Share Extension 抓取 → 暂存 App Group → 唤起主程序,主程序**直入发送预览**(同 `SendComposeSheet`)。
|
||||||
|
- 修复“分享后必须重启 app 才出现传输”:唤起宿主 app 经响应链 IMP 调现代 `openURL:options:completionHandler:`(旧单参 `openURL:` 在 iOS 18+ 已成空壳),**并以 `scenePhase` 回 `.active` 时 `loadPendingShares()` 兜底**——回前台即扫 App Group 暂存、补出预览,不依赖那条 iOS 灰区的扩展→宿主唤起是否可靠。分享引导界面见 §11.3。
|
||||||
|
|
||||||
|
### 10.3 消息按设备会话聚合 + 删除粒度
|
||||||
|
|
||||||
|
- 消息从扁平流改为**按设备聚合的会话列表**(社交 app 模型,`Sources/MessagesView.swift`):会话列表项 = 有历史**或**当前可达的设备;点进 `ConversationView` 看与该设备的线程。非 APNs 离线设备**不能**作为发送对象,但历史**可查看**(`canMessage()` 判定)。
|
||||||
|
- **每设备未读分桶**:`EngineController` 的 `unreadByPeer: [String: Int]` + `activeConversation`;每设备会话显示各自未读红点,“消息”标签显示总未读(`unreadMessages = unreadByPeer.values.reduce(0,+)`)。进会话即 `markConversationRead` + `setActiveConversation`。
|
||||||
|
- **任意删除**:可删**单条消息**与**整个设备的历史**(`deleteConversation()`),避免废 session 堆积。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 品牌与字体系统(2026-06-28,对齐 web)
|
||||||
|
|
||||||
|
iOS 界面引入与 web 一致的品牌资产与字体栈——这是强约束③(§5,品牌资产 + 主色单一源)在视觉层的延伸。
|
||||||
|
|
||||||
|
### 11.1 品牌 logo
|
||||||
|
|
||||||
|
- `Shared/Brand.swift`:`BrandMark`(吉祥物,资产 `Shared/Brand.xcassets/LogoMark`,源 `web/public/logo-mark.png`)、`Wordmark`(“Commilitia” 主色 + “Drop” `cdropAmber`,Fredoka SemiBold)、`BrandLockup`、`BrandHero(tagline:)`。
|
||||||
|
- 接入点:登录页(`BrandMark` + `Wordmark` 取代纯文字标题)、传输空态(`BrandHero`)、设置页顶部横向品牌头、控制中心控件重设计(吉祥物 + 品牌渐变 + 全宽按钮,最大化空间)。`Theme.swift` 加 `Color.cdropAmber`(#DB9410 / #F8C637)。
|
||||||
|
|
||||||
|
### 11.2 字体栈(Orbit Gothic + Maple Mono + Fredoka)
|
||||||
|
|
||||||
|
- 与 web `fonts.css` 同源:Sans = **Orbit Gothic CJK SC**(Regular + SemiBold)、Mono = **Maple Mono**、字标 = **Fredoka SemiBold**。
|
||||||
|
- **woff2 运行期注册**:Orbit / Maple 以 woff2 内嵌(约 TTF 的 1/3 体积),`UIAppFonts` 历史只认 ttf/otf,故经 `CTFontManagerRegisterFontsForURL` 运行期注册(iOS 13+ Core Text 原生支持 woff2,实测 register→true)——见 `Sources/BrandFonts.swift` 的 `BrandFonts.register()`(`AppDelegate` 启动调)。Fredoka 是 ttf,走 `UIAppFonts`。
|
||||||
|
- **助手 + sweep**:`BrandFonts` 暴露 `Font.oBody/.oHeadline/…`(Orbit,对位 SF 语义样式)、`Font.maple(…)`(Maple Mono);导航 / 标签栏标题经 UIKit appearance 代理换 Orbit;7 个视图的 `.font(.语义)` → `.font(.o语义)` sweep。
|
||||||
|
- **仅主 app**:CJK 字体大,不进 Share / 控件扩展(它们保持系统字 + 仅 Fredoka 字标);生僻字超 Orbit 字汇时 iOS 字体级联自动回退苹方,不出豆腐块。
|
||||||
|
- **字体不入库**:Orbit Gothic 为专有 / 定制字体(公开仓库不分发),Maple / Fredoka 虽开源也统一不入库保持精简;经 `.gitignore` 排除,构建前 `just ios-fonts`(已 chain 进 `ios-device` / `ios-sim-build`)从私有 CDN / Google Fonts 幂等拉取。详见 `Sources/Fonts/README.md`。
|
||||||
|
|
||||||
|
### 11.3 分享引导界面
|
||||||
|
|
||||||
|
- `Share/ShareGuideView.swift`(`ShareModel: ObservableObject`):扩展内呈现吉祥物 + Wordmark + “已就绪 N 个文件” + “打开 Commilitia Drop” 主按钮 + 取消,引导用户回到主程序完成发送(扩展内不跑传输引擎,§8.4)。取消则删暂存;`started` 标志防重入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 全平台平均速度(2026-06-28)
|
||||||
|
|
||||||
|
传输完成后,速度列**不再显示最后一次瞬时速率,而显示平均速度**(总字节 / 总耗时)。三端一致:web `store/slices/transfer.ts` 的 `completeTransfer` 在 DONE 时置 `bytesPerSec = total / (elapsedMs/1000)`、`TransferRow.tsx` 在 DONE 读 `avgRate`;iOS `RootView` 卡片 / 详情完成态显平均速度;i18n 加 `transfer.avgRate`(web 3 locale + ios 3 JSON)。桌面(Wails)共享 web store,逻辑自动生效,但需 `just desktop-build-{mac,win}` 重建 + 重分发方落到已装客户端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 附:与桌面端策略对照(修订)
|
## 附:与桌面端策略对照(修订)
|
||||||
|
|
||||||
| 维度 | 桌面(Wails) | iOS(arch A) |
|
| 维度 | 桌面(Wails) | iOS(arch A) |
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ export function TransferRow(props: TransferRowProps)
|
|||||||
|
|
||||||
// 终态记录可删除(从列表移除);非终态记录可取消(驱动后端 /cancel)。
|
// 终态记录可删除(从列表移除);非终态记录可取消(驱动后端 /cancel)。
|
||||||
const terminal = done || failed || cancelled;
|
const terminal = done || failed || cancelled;
|
||||||
// 发送方仍在试 P2P(已接受 / P2P 中、尚未切中继)时,给一个「立即中继」入口,
|
// 发送方仍在试 P2P(已接受 / P2P 中、尚未切中继)时,给一个「中继传输」入口手动切中继——
|
||||||
// 跳过 30s ICE 等待——只对 outgoing 有意义(驱动后端 /fallback)。
|
// 无自动回退后这是切中继的唯一途径,只对 outgoing 有意义(驱动后端 /fallback)。
|
||||||
const canRelayNow = tr.direction === "outgoing"
|
const canRelayNow = tr.direction === "outgoing"
|
||||||
&& (tr.state === "ACCEPTED" || tr.state === "P2P_ACTIVE")
|
&& (tr.state === "ACCEPTED" || tr.state === "P2P_ACTIVE")
|
||||||
&& tr.phase !== "fallback_pending"
|
&& tr.phase !== "fallback_pending"
|
||||||
@@ -192,10 +192,13 @@ function bytesText(tr: TransferRecord): string
|
|||||||
// 卡住了」状态已表达停滞,再显示一个陈旧的非零速度会误导。速率由 store 做 EMA 平滑。
|
// 卡住了」状态已表达停滞,再显示一个陈旧的非零速度会误导。速率由 store 做 EMA 平滑。
|
||||||
function speedText(tr: TransferRecord, stalled: boolean): string
|
function speedText(tr: TransferRecord, stalled: boolean): string
|
||||||
{
|
{
|
||||||
if (stalled) { return ""; }
|
|
||||||
if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
|
|
||||||
const rate = tr.bytesPerSec;
|
const rate = tr.bytesPerSec;
|
||||||
if (!rate || !Number.isFinite(rate) || rate <= 0) { return ""; }
|
if (!rate || !Number.isFinite(rate) || rate <= 0) { return ""; }
|
||||||
|
// 完成后显示平均速度(store completeTransfer 已把 bytesPerSec 置为总字节 / 总耗时的均速)。
|
||||||
|
if (tr.state === "DONE") { return t("transfer.avgRate", { rate: formatBytes(rate) }); }
|
||||||
|
// 进行中:瞬时速度(EMA 平滑)。卡死 / 非流动阶段不显示陈旧速度,避免误导。
|
||||||
|
if (stalled) { return ""; }
|
||||||
|
if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
|
||||||
return t("transfer.bytesRate", { rate: formatBytes(rate) });
|
return t("transfer.bytesRate", { rate: formatBytes(rate) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { relayReceiveFile, relaySendFile } from "./relay";
|
|||||||
import type { FileSource } from "./source";
|
import type { FileSource } from "./source";
|
||||||
import { useAppStore, type TransferRecord } from "../../store";
|
import { useAppStore, type TransferRecord } from "../../store";
|
||||||
|
|
||||||
// Orchestrator that picks P2P first, falls back to Relay on ICE timeout
|
// Orchestrator that starts P2P and leaves relay to a manual user action.
|
||||||
// (plan §M9: 30s after start, if iceConnectionState != connected → /fallback).
|
// 不再有 30s ICE 超时自动回退中继:P2P 无时限尝试,中继仅由用户手动触发(TransferRow 的
|
||||||
|
// 「中继传输」按钮 → skipWaitRelay)。自动回退会在弱网/慢 ICE 下抢先切走、且掩盖 P2P 是否
|
||||||
const ICE_TIMEOUT_MS = 30_000;
|
// 终将连通,故按产品要求移除——所有中继改手动。
|
||||||
|
|
||||||
interface OutgoingState
|
interface OutgoingState
|
||||||
{
|
{
|
||||||
@@ -15,7 +15,6 @@ interface OutgoingState
|
|||||||
peerName: string;
|
peerName: string;
|
||||||
src: FileSource;
|
src: FileSource;
|
||||||
p2p: P2PSession | null;
|
p2p: P2PSession | null;
|
||||||
fallbackTimer: number | null;
|
|
||||||
// waitTimer / waitUnsub back the "wait for an offline receiver to wake" path: the timeout that
|
// waitTimer / waitUnsub back the "wait for an offline receiver to wake" path: the timeout that
|
||||||
// fails the transfer if the peer never comes online, and the presence subscription that fires
|
// fails the transfer if the peer never comes online, and the presence subscription that fires
|
||||||
// P2P once it does.
|
// P2P once it does.
|
||||||
@@ -73,7 +72,6 @@ export async function startOutgoingTransfer(
|
|||||||
peerName: receiverName,
|
peerName: receiverName,
|
||||||
src,
|
src,
|
||||||
p2p: null,
|
p2p: null,
|
||||||
fallbackTimer: null,
|
|
||||||
waitTimer: null,
|
waitTimer: null,
|
||||||
waitUnsub: null,
|
waitUnsub: null,
|
||||||
abort,
|
abort,
|
||||||
@@ -86,8 +84,9 @@ export async function startOutgoingTransfer(
|
|||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// beginP2P arms the relay-fallback watchdog and starts the P2P offer. Extracted from
|
// beginP2P starts the P2P offer. Extracted from startOutgoingTransfer so the
|
||||||
// startOutgoingTransfer so the offline-receiver path can defer it until the peer wakes.
|
// offline-receiver path can defer it until the peer wakes. No auto-fallback watchdog:
|
||||||
|
// relay is manual only (skipWaitRelay).
|
||||||
function beginP2P(state: OutgoingState): void
|
function beginP2P(state: OutgoingState): void
|
||||||
{
|
{
|
||||||
const { sessionId, peerName, src } = state;
|
const { sessionId, peerName, src } = state;
|
||||||
@@ -95,35 +94,8 @@ function beginP2P(state: OutgoingState): void
|
|||||||
const r0 = store0.activeTransfers[sessionId];
|
const r0 = store0.activeTransfers[sessionId];
|
||||||
if (r0 && r0.phase === "waiting_peer") { store0.upsertTransfer({ ...r0, phase: "waiting_accept" }); }
|
if (r0 && r0.phase === "waiting_peer") { store0.upsertTransfer({ ...r0, phase: "waiting_accept" }); }
|
||||||
|
|
||||||
// Arm the fallback watchdog *before* any P2P work. p2pStartOutgoing is
|
|
||||||
// intentionally synchronous: if createOffer / setLocalDescription hang
|
|
||||||
// (e.g. injected stub during fallback testing) the timer must still fire.
|
|
||||||
state.fallbackTimer = window.setTimeout(() =>
|
|
||||||
{
|
|
||||||
const cur = outgoing.get(sessionId);
|
|
||||||
if (!cur) { return; }
|
|
||||||
const p2pState = cur.p2p?.state;
|
|
||||||
if (p2pState !== "connected" && p2pState !== "completed")
|
|
||||||
{
|
|
||||||
// 把"已经放弃 P2P,等服务端 RELAY_ACTIVE"这个状态显式落到 store,
|
|
||||||
// UI 据此告诉用户「还在通,正在切到中继」而不是干等。
|
|
||||||
const store = useAppStore.getState();
|
|
||||||
const r = store.activeTransfers[sessionId];
|
|
||||||
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
|
|
||||||
void triggerFallback(sessionId);
|
|
||||||
}
|
|
||||||
}, ICE_TIMEOUT_MS);
|
|
||||||
|
|
||||||
const p2p = p2pStartOutgoing(sessionId, peerName, src);
|
const p2p = p2pStartOutgoing(sessionId, peerName, src);
|
||||||
state.p2p = p2p;
|
state.p2p = p2p;
|
||||||
|
|
||||||
p2p.onState((s) =>
|
|
||||||
{
|
|
||||||
if (s === "completed" || s === "connected")
|
|
||||||
{
|
|
||||||
if (state.fallbackTimer) { clearTimeout(state.fallbackTimer); state.fallbackTimer = null; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PEER_WAIT_MS bounds how long the sender waits for an offline receiver to wake + reconnect.
|
// PEER_WAIT_MS bounds how long the sender waits for an offline receiver to wake + reconnect.
|
||||||
@@ -311,7 +283,6 @@ function cleanupOutgoing(sessionId: string): void
|
|||||||
{
|
{
|
||||||
const s = outgoing.get(sessionId);
|
const s = outgoing.get(sessionId);
|
||||||
if (!s) { return; }
|
if (!s) { return; }
|
||||||
if (s.fallbackTimer) { clearTimeout(s.fallbackTimer); }
|
|
||||||
if (s.waitTimer !== null) { clearTimeout(s.waitTimer); }
|
if (s.waitTimer !== null) { clearTimeout(s.waitTimer); }
|
||||||
if (s.waitUnsub) { s.waitUnsub(); }
|
if (s.waitUnsub) { s.waitUnsub(); }
|
||||||
s.abort.abort();
|
s.abort.abort();
|
||||||
@@ -325,17 +296,10 @@ export function cleanupTransfer(sessionId: string): void
|
|||||||
incomingFiles.delete(sessionId);
|
incomingFiles.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI-triggered (发送方「跳过等待、立即中继」):发送方不等 30s ICE 超时,直接切到
|
// UI-triggered (发送方「中继传输」):手动把传输切到中继(无自动回退后,这是切中继的唯一途径)。
|
||||||
// 中继。复用 watchdog 的逻辑——先停掉那个定时器以免重复 fire,把记录标成
|
// 把记录标成 fallback_pending 让 UI 显示「正在切到中继」,再请求服务端转 RELAY_ACTIVE。
|
||||||
// fallback_pending 让 UI 显示「正在切到中继」,再请求服务端转 RELAY_ACTIVE。
|
|
||||||
export async function skipWaitRelay(sessionId: string): Promise<void>
|
export async function skipWaitRelay(sessionId: string): Promise<void>
|
||||||
{
|
{
|
||||||
const cur = outgoing.get(sessionId);
|
|
||||||
if (cur?.fallbackTimer)
|
|
||||||
{
|
|
||||||
clearTimeout(cur.fallbackTimer);
|
|
||||||
cur.fallbackTimer = null;
|
|
||||||
}
|
|
||||||
const store = useAppStore.getState();
|
const store = useAppStore.getState();
|
||||||
const r = store.activeTransfers[sessionId];
|
const r = store.activeTransfers[sessionId];
|
||||||
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
|
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
"transfer.saveFailed": "Failed to save file: {{error}}",
|
"transfer.saveFailed": "Failed to save file: {{error}}",
|
||||||
"transfer.action.cancel": "Cancel",
|
"transfer.action.cancel": "Cancel",
|
||||||
"transfer.action.delete": "Delete",
|
"transfer.action.delete": "Delete",
|
||||||
"transfer.action.relayNow": "Relay now",
|
"transfer.action.relayNow": "Relay",
|
||||||
"transfer.action.details": "Details",
|
"transfer.action.details": "Details",
|
||||||
"transfer.state.PENDING": "Pending",
|
"transfer.state.PENDING": "Pending",
|
||||||
"transfer.state.ACCEPTED": "Accepted",
|
"transfer.state.ACCEPTED": "Accepted",
|
||||||
@@ -313,6 +313,7 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
|
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
|
||||||
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
||||||
"transfer.bytesRate": "{{rate}}/s",
|
"transfer.bytesRate": "{{rate}}/s",
|
||||||
|
"transfer.avgRate": "Avg {{rate}}/s",
|
||||||
|
|
||||||
// ---- debug expansion ------------------------------------------------
|
// ---- debug expansion ------------------------------------------------
|
||||||
"transfer.debug.toggle": "Debug",
|
"transfer.debug.toggle": "Debug",
|
||||||
@@ -414,7 +415,7 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
"ios.records.clear": "Clear",
|
"ios.records.clear": "Clear",
|
||||||
"ios.records.clearConfirm": "Clear all records? This cannot be undone.",
|
"ios.records.clearConfirm": "Clear all records? This cannot be undone.",
|
||||||
"ios.transfer.cancel": "Cancel transfer",
|
"ios.transfer.cancel": "Cancel transfer",
|
||||||
"ios.transfer.forceRelay": "Relay now",
|
"ios.transfer.forceRelay": "Relay",
|
||||||
"ios.settings.deviceCount": "Known Devices",
|
"ios.settings.deviceCount": "Known Devices",
|
||||||
"ios.settings.signaling": "Signaling",
|
"ios.settings.signaling": "Signaling",
|
||||||
"ios.settings.presenceEvents": "Presence Events",
|
"ios.settings.presenceEvents": "Presence Events",
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const zhCN = {
|
|||||||
"transfer.saveFailed": "保存文件失败:{{error}}",
|
"transfer.saveFailed": "保存文件失败:{{error}}",
|
||||||
"transfer.action.cancel": "取消",
|
"transfer.action.cancel": "取消",
|
||||||
"transfer.action.delete": "删除",
|
"transfer.action.delete": "删除",
|
||||||
"transfer.action.relayNow": "立即中继",
|
"transfer.action.relayNow": "中继传输",
|
||||||
"transfer.action.details": "详情",
|
"transfer.action.details": "详情",
|
||||||
"transfer.state.PENDING": "等待中",
|
"transfer.state.PENDING": "等待中",
|
||||||
"transfer.state.ACCEPTED": "已接受",
|
"transfer.state.ACCEPTED": "已接受",
|
||||||
@@ -309,6 +309,7 @@ export const zhCN = {
|
|||||||
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
|
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
|
||||||
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
||||||
"transfer.bytesRate": "{{rate}}/秒",
|
"transfer.bytesRate": "{{rate}}/秒",
|
||||||
|
"transfer.avgRate": "平均 {{rate}}/秒",
|
||||||
|
|
||||||
// ---- 调试展开区 -----------------------------------------------------
|
// ---- 调试展开区 -----------------------------------------------------
|
||||||
"transfer.debug.toggle": "调试信息",
|
"transfer.debug.toggle": "调试信息",
|
||||||
@@ -410,7 +411,7 @@ export const zhCN = {
|
|||||||
"ios.records.clear": "清空",
|
"ios.records.clear": "清空",
|
||||||
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
|
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
|
||||||
"ios.transfer.cancel": "取消传输",
|
"ios.transfer.cancel": "取消传输",
|
||||||
"ios.transfer.forceRelay": "立即切到中继",
|
"ios.transfer.forceRelay": "中继传输",
|
||||||
"ios.settings.deviceCount": "已知设备",
|
"ios.settings.deviceCount": "已知设备",
|
||||||
"ios.settings.signaling": "信令连接",
|
"ios.settings.signaling": "信令连接",
|
||||||
"ios.settings.presenceEvents": "在线事件",
|
"ios.settings.presenceEvents": "在线事件",
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
"transfer.saveFailed": "儲存檔案失敗:{{error}}",
|
"transfer.saveFailed": "儲存檔案失敗:{{error}}",
|
||||||
"transfer.action.cancel": "取消",
|
"transfer.action.cancel": "取消",
|
||||||
"transfer.action.delete": "刪除",
|
"transfer.action.delete": "刪除",
|
||||||
"transfer.action.relayNow": "立即中繼",
|
"transfer.action.relayNow": "中繼傳輸",
|
||||||
"transfer.action.details": "詳情",
|
"transfer.action.details": "詳情",
|
||||||
"transfer.state.PENDING": "等待中",
|
"transfer.state.PENDING": "等待中",
|
||||||
"transfer.state.ACCEPTED": "已接受",
|
"transfer.state.ACCEPTED": "已接受",
|
||||||
@@ -313,6 +313,7 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
|
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
|
||||||
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
"transfer.bytesProgress": "{{sent}} / {{total}}",
|
||||||
"transfer.bytesRate": "{{rate}}/秒",
|
"transfer.bytesRate": "{{rate}}/秒",
|
||||||
|
"transfer.avgRate": "平均 {{rate}}/秒",
|
||||||
|
|
||||||
// ---- 偵錯展開區 -----------------------------------------------------
|
// ---- 偵錯展開區 -----------------------------------------------------
|
||||||
"transfer.debug.toggle": "偵錯資訊",
|
"transfer.debug.toggle": "偵錯資訊",
|
||||||
@@ -414,7 +415,7 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
"ios.records.clear": "清空",
|
"ios.records.clear": "清空",
|
||||||
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
|
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
|
||||||
"ios.transfer.cancel": "取消傳輸",
|
"ios.transfer.cancel": "取消傳輸",
|
||||||
"ios.transfer.forceRelay": "立即切到中繼",
|
"ios.transfer.forceRelay": "中繼傳輸",
|
||||||
"ios.settings.deviceCount": "已知裝置",
|
"ios.settings.deviceCount": "已知裝置",
|
||||||
"ios.settings.signaling": "信令連線",
|
"ios.settings.signaling": "信令連線",
|
||||||
"ios.settings.presenceEvents": "上線事件",
|
"ios.settings.presenceEvents": "上線事件",
|
||||||
|
|||||||
@@ -46,7 +46,19 @@ export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice>
|
|||||||
{
|
{
|
||||||
const cur = s.activeTransfers[sessionId];
|
const cur = s.activeTransfers[sessionId];
|
||||||
if (!cur) { return s; }
|
if (!cur) { return s; }
|
||||||
const finalRec = normalizeTerminal(cur, finalState);
|
let finalRec = normalizeTerminal(cur, finalState);
|
||||||
|
// 完成后速度显示「平均速度」(总字节 / 总耗时),取代最后的瞬时 EMA 速率——后者只反映末段
|
||||||
|
// 抽样、对整体传输无代表性。仅 DONE 有意义(FAILED/CANCELLED 为部分传输,不覆写)。全平台
|
||||||
|
// 一处生效:web/desktop 读此记录、iOS 经 toWire→transferDone 拿到同一 bytesPerSec。
|
||||||
|
if (finalState === "DONE")
|
||||||
|
{
|
||||||
|
const elapsedMs = Date.now() - (cur.startedAt ?? Date.now());
|
||||||
|
const total = cur.fileSize > 0 ? cur.fileSize : (cur.bytesTransferred ?? 0);
|
||||||
|
if (elapsedMs > 0 && total > 0)
|
||||||
|
{
|
||||||
|
finalRec = { ...finalRec, bytesPerSec: total / (elapsedMs / 1000) };
|
||||||
|
}
|
||||||
|
}
|
||||||
const { [sessionId]: _, ...rest } = s.activeTransfers;
|
const { [sessionId]: _, ...rest } = s.activeTransfers;
|
||||||
return ({
|
return ({
|
||||||
activeTransfers: rest,
|
activeTransfers: rest,
|
||||||
|
|||||||
Reference in New Issue
Block a user