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:
2026-06-28 19:33:08 +08:00
parent f1a00d128e
commit 349d94aa8f
39 changed files with 2535 additions and 295 deletions
+26 -1
View File
@@ -77,13 +77,38 @@ desktop-doctor:
ios-devices:
xcrun devicectl list devices
# 拉取品牌字体(不入库——Orbit Gothic 专有;统一从 CDN / Google Fonts 拉,镜像 web 思路)。幂等:已存在
# 则跳过。ios-sim-build / ios-device 构建前已 chain 本步。前置:curl;造 Fredoka 静态实例需 python3 +
# fonttoolspython3 -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 变量字体实例化出静态 SemiBoldwght=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)。前置:付费 ADPApp ID 的 App Groups /
# Push 能力(门户建好或自动建);根目录 gitignore 的 .env 配齐:CDROP_TEAM_ID / CDROP_ASC_KEY_PATH
# .p8 路径)/ CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID。-allowProvisioningUpdates 自动登记连接的
# 设备 + 建 profile(绕过手动 profile 的设备选择坑)。模拟器构建不需这些(base ad-hoc)。
# 设备 + 建 profile(绕过手动 profile 的设备选择坑)。模拟器构建不需这些(base ad-hoc)。原生数据面
# 经 SPM 拉 libwebrtc,无 gomobile 预构建步骤(早先 ios-engine recipe 已随 pion-on-iOS 废弃移除)。
# 用法:just ios-device <设备UDID>UDID 见 just ios-devices)。
ios-device udid:
[ -n "$CDROP_TEAM_ID" ] && [ -n "$CDROP_ASC_KEY_PATH" ] && [ -n "$CDROP_ASC_KEY_ID" ] && [ -n "$CDROP_ASC_ISSUER_ID" ] || { echo "缺 .envCDROP_TEAM_ID / CDROP_ASC_KEY_PATH / CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID)——见 ios/CDrop/REALDEVICE.md"; exit 1; }
just ios-fonts
cd ios/CDrop && xcodegen generate
cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -configuration Debug -destination "platform=iOS,id={{udid}}" -derivedDataPath build/DD-device -allowProvisioningUpdates -authenticationKeyPath "$CDROP_ASC_KEY_PATH" -authenticationKeyID "$CDROP_ASC_KEY_ID" -authenticationKeyIssuerID "$CDROP_ASC_ISSUER_ID" DEVELOPMENT_TEAM="$CDROP_TEAM_ID" CODE_SIGN_STYLE=Automatic CODE_SIGN_IDENTITY="Apple Development" build
xcrun devicectl device install app --device {{udid}} ios/CDrop/build/DD-device/Build/Products/Debug-iphoneos/CDrop.app
+11
View File
@@ -7,6 +7,17 @@ DerivedData/
*.xcuserstate
.DS_Store
# 原生数据面引擎(曾是 gomobile/pion → xcframework;现已改 libwebrtc SPM,本目录废弃但留忽略防残留)。
Frameworks/
# 品牌字体不入库:Orbit Gothic 为专有 / 定制字体(公开仓库不应分发);Maple Mono / Fredoka 虽开源,
# 也统一不入库、改由 `just ios-fonts` 从 CDN / Google Fonts 拉取(镜像 web 的 CDN 思路),保持仓库精简。
# 见 Sources/Fonts/README.md。构建前 `just ios-fonts`(已 chain 进 ios-device / ios-sim-build)会自动备齐。
Sources/Fonts/*.woff2
Sources/Fonts/*.ttf
Shared/Fonts/*.ttf
Shared/Fonts/*.woff2
# 真机签名的账号凭据——公开仓库不留账号标识。ASC API Key 的 .p8 + Team/Key/Issuer ID
# 走根目录 gitignore 的 .env(见 REALDEVICE.md);.p8 若落本目录一并忽略。
*.p8
+4
View File
@@ -39,5 +39,9 @@
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
<key>UIAppFonts</key>
<array>
<string>Fredoka-SemiBold.ttf</string>
</array>
</dict>
</plist>
+70
View File
@@ -0,0 +1,70 @@
import SwiftUI
// nil@MainActorUI 线
@MainActor
final class ShareModel: ObservableObject
{
@Published var fileCount: Int?
}
// + + + Commilitia
// DropiOS 宿 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) ])
}
}
+127 -30
View File
@@ -1,76 +1,173 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers
// Share Extension share sheet App Group app
// 120MB jetsam PLAN §I5 compose UI
//
// Share Extension share sheet App Group ****
// ShareGuideView + + + Commilitia Drop app
// 120MB jetsam PLAN §I5
//
// iOS share 宿 app
// openURL: iOS 10 iOS 18+
// IMP open App Group app
// CDropApp.scenePhase(.active)loadPendingShares
final class ShareViewController: UIViewController
{
private let model = ShareModel()
private var started = false
// URL
private var stagedURLs: [URL] = []
override func viewDidLoad()
{
super.viewDidLoad()
let host = UIHostingController(
rootView: ShareGuideView(
model: model,
onOpen: { [weak self] in self?.openAndFinish() },
onCancel: { [weak self] in self?.cancelAndFinish() }))
addChild(host)
host.view.frame = view.bounds
host.view.autoresizingMask = [ .flexibleWidth, .flexibleHeight ]
view.addSubview(host.view)
host.didMove(toParent: self)
}
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
Task { await processAndClose() }
guard !started else { return } // viewDidAppear
started = true
Task { await stageAll() }
}
private func processAndClose() async
private func stageAll() async
{
let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
var staged = 0
var urls: [URL] = []
for item in items
{
for provider in item.attachments ?? []
{
if await stage(provider) { staged += 1 }
if let dest = await stage(provider) { urls.append(dest) }
}
}
let ctx = extensionContext
guard staged > 0, let url = URL(string: "cdrop://share") else
stagedURLs = urls
await MainActor.run { model.fileCount = urls.count }
}
// Commilitia Drop app completeRequest
// completeRequest app
private func openAndFinish()
{
ctx?.completeRequest(returningItems: nil)
return
}
// app open completeRequest completeRequest
// self open openViaResponder L8 ctx 使
//
ctx?.open(url)
{ [weak self] ok in
if !ok { self?.openViaResponder(url) }
ctx?.completeRequest(returningItems: nil)
if let url = URL(string: "cdrop://share") { openViaResponder(url) }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35)
{ [weak self] in
self?.extensionContext?.completeRequest(returningItems: nil)
}
}
// App Group loadFileRepresentation
// / /
//
private func stage(_ provider: NSItemProvider) async -> Bool
// app app
private func cancelAndFinish()
{
for url in stagedURLs
{
try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
}
extensionContext?.completeRequest(returningItems: nil)
}
// App Group URL退
// nil
private func stage(_ provider: NSItemProvider) async -> URL?
{
if let u = await stageFile(provider) { return u }
return await stageData(provider)
}
private func stageFile(_ provider: NSItemProvider) async -> URL?
{
let typeId = provider.registeredTypeIdentifiers.first ?? UTType.item.identifier
return await withCheckedContinuation
{ cont in
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier)
provider.loadFileRepresentation(forTypeIdentifier: typeId)
{ url, _ in
guard let url, let dest = AppGroup.stagedDestination(originalName: url.lastPathComponent)
else { cont.resume(returning: false); return }
else { cont.resume(returning: nil); return }
do
{
try FileManager.default.copyItem(at: url, to: dest)
cont.resume(returning: true)
cont.resume(returning: dest)
}
catch
{
cont.resume(returning: false)
cont.resume(returning: nil)
}
}
}
}
// open 沿 UIApplication app open
private func stageData(_ provider: NSItemProvider) async -> URL?
{
let typeId = provider.registeredTypeIdentifiers.first ?? UTType.data.identifier
let name = suggestedName(provider, typeId: typeId)
return await withCheckedContinuation
{ cont in
provider.loadDataRepresentation(forTypeIdentifier: typeId)
{ data, _ in
guard let data, let dest = AppGroup.stagedDestination(originalName: name)
else { cont.resume(returning: nil); return }
do
{
try data.write(to: dest)
cont.resume(returning: dest)
}
catch
{
cont.resume(returning: nil)
}
}
}
}
//
private func suggestedName(_ provider: NSItemProvider, typeId: String) -> String
{
if let s = provider.suggestedName, !s.isEmpty
{
if (s as NSString).pathExtension.isEmpty,
let ext = UTType(typeId)?.preferredFilenameExtension
{
return "\(s).\(ext)"
}
return s
}
let ext = UTType(typeId)?.preferredFilenameExtension ?? "dat"
return "shared-\(UUID().uuidString.prefix(8)).\(ext)"
}
// 沿 UIApplication IMP open(_:options:completionHandler:)
// UIApplication 退 openURL:iOS 18+
private func openViaResponder(_ url: URL)
{
let modern = NSSelectorFromString("openURL:options:completionHandler:")
var responder: UIResponder? = self
let sel = NSSelectorFromString("openURL:")
while let r = responder
{
if r.responds(to: sel) { _ = r.perform(sel, with: url); return }
if r.responds(to: modern), let app = r as? NSObject,
let method = class_getInstanceMethod(type(of: app), modern)
{
typealias OpenFn = @convention(c) (NSObject, Selector, NSURL, NSDictionary, Any?) -> Void
let fn = unsafeBitCast(method_getImplementation(method), to: OpenFn.self)
fn(app, modern, url as NSURL, NSDictionary(), nil)
return
}
responder = r.next
}
let legacy = NSSelectorFromString("openURL:")
responder = self
while let r = responder
{
if r.responds(to: legacy) { _ = r.perform(legacy, with: url); return }
responder = r.next
}
}
+76
View File
@@ -0,0 +1,76 @@
import SwiftUI
// mark + app / / sources
// Shared LogoMark Shared/Brand.xcassets target bundle Image("LogoMark")
// bundle web CommilitiaDropcdropAmber
// system rounded web Fredoka
// markdropsize
struct BrandMark: View
{
var size: CGFloat = 96
var body: some View
{
Image("LogoMark")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.accessibilityHidden(true)
}
}
// Commilitia DropDrop 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

+8
View File
@@ -11,4 +11,12 @@ extension Color
? UIColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1)
: UIColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1)
})
// Drop web #DB9410 / #F8C637
static let cdropAmber = Color(uiColor: UIColor
{ trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.973, green: 0.776, blue: 0.216, alpha: 1)
: UIColor(red: 0.859, green: 0.580, blue: 0.063, alpha: 1)
})
}
+2
View File
@@ -10,6 +10,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool
{
// UI + / Orbit Gothic
BrandFonts.register()
// BGContinuedProcessingTask launch handler launch
BackgroundTaskManager.shared.registerOnce()
return true
+10 -6
View File
@@ -14,9 +14,13 @@ struct LoginView: View
VStack(spacing: 20)
{
Spacer()
Text(t("app.brand"))
.font(.largeTitle)
.bold()
// + Fredoka
VStack(spacing: 14)
{
BrandMark(size: 124)
Wordmark(size: 30)
}
.padding(.bottom, 8)
// broker
Button { Task { await auth.startBrokerLogin() } }
@@ -31,7 +35,7 @@ struct LoginView: View
if !auth.statusText.isEmpty
{
Text(auth.statusText)
.font(.callout)
.font(.oCallout)
.foregroundStyle(auth.qrExpired ? Color.red : Color.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
@@ -45,7 +49,7 @@ struct LoginView: View
qrArea
refreshButton
Text(t("ios.login.guide"))
.font(.footnote)
.font(.oFootnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
@@ -54,7 +58,7 @@ struct LoginView: View
label:
{
Text(t("ios.login.scanAlt"))
.font(.subheadline)
.font(.oSubheadline)
}
.padding(.horizontal, 40)
.onChange(of: showQR)
+81
View File
@@ -0,0 +1,81 @@
import CoreText
import SwiftUI
import UIKit
// web --font-sans / --font-mono
// - Sans Orbit Gothic CJK SCRegular 400 + SemiBold 600
// - Mono Maple Mono /
// woff2 TTF 1/3iOS Core Text iOS 13 woff/woff2
// CTFontManagerRegisterFontsForURL UIAppFonts ttf/otfwoff2
// 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 GothicSans+ Maple MonoMonorelativeTo
// 访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)
}
+10 -1
View File
@@ -15,12 +15,21 @@ struct CDropApp: App
WindowGroup
{
AppRoot(auth: auth, engine: engine)
// Orbit Gothic .font Sans
// .font(.oXxx) BrandFonts
.environment(\.font, .oBody)
}
.onChange(of: scenePhase)
{ _, phase in
guard phase == .active else { return }
// App Group iOS share 宿 app
// app
// app onAppear
//
engine.loadPendingShares()
// / ready
//
if phase == .active, auth.session != nil
if auth.session != nil
{
engine.provisionWidgetSessionIfNeeded()
}
+227 -10
View File
@@ -30,6 +30,23 @@ struct MessageItem: Identifiable, Equatable, Codable
let sentAt: Double
}
// tab
// conversations() messages 线 /
// reachable线 APNs 线 iOS ConversationView 线
// 线 / sendableDevices
struct ConversationItem: Identifiable, Equatable
{
let peerName: String
let deviceID: String // id /
let deviceType: String
let online: Bool
let reachable: Bool // 线
let lastText: String
let lastAt: Double // epoch 0
let lastOutgoing: Bool
var id: String { peerName }
}
// web engine/main.ts toWire ice*
// P2P TURN
struct TransferItem: Identifiable, Equatable, Codable
@@ -68,6 +85,24 @@ struct LogEntry: Identifiable, Equatable
let level: String // warn | error | info
let message: String
let at: Date
// "session <id> ..." id + pion
// / SSE / nil
let sessionId: String?
init(level: String, message: String, at: Date)
{
self.level = level
self.message = message
self.at = at
self.sessionId = LogEntry.parseSession(message)
}
static func parseSession(_ msg: String) -> String?
{
guard msg.hasPrefix("session ") else { return nil }
let id = msg.dropFirst("session ".count).prefix { !$0.isWhitespace }
return id.isEmpty ? nil : String(id)
}
}
// #8/link?r=&c=
@@ -117,10 +152,13 @@ final class EngineController: NSObject
// #8 / /
var pendingLoginRequest: LoginRequestItem?
// #5 / tab activeTab
// 使seenIncomingTransfers
//
var unreadMessages = 0
// #5 tab activeTab 使
// seenIncomingTransfers
// unreadByPeer tab
// unreadMessages activeConversation 使
var unreadByPeer: [String: Int] = [:]
var activeConversation: String?
var unreadMessages: Int { unreadByPeer.values.reduce(0, +) }
var unreadTransfers = 0
var activeTab = "transfer"
private var seenIncomingTransfers: Set<String> = []
@@ -155,6 +193,14 @@ final class EngineController: NSObject
// WKURLSchemeHandler
private var outgoing: [String: URL] = [:]
// libwebrtcSafari RTCPeerConnection WKWebView 256KB SCTP
// + desktop/NATIVE-TRANSFER.md §7 gomobile/pionpion Go raw
// socket iOS Network framework P2P RPC
// Documents DownloadManager p2pEvents
// 宿libwebrtc 线 / 线宿
private var transferEngine: LibWebRtcEngine?
private var p2pEvents: P2PEventsBridge?
// /
private static let historyCap = 50
private static let messagesCap = 200
@@ -276,7 +322,8 @@ final class EngineController: NSObject
// / / / /
notices = []
logs = []
unreadMessages = 0
unreadByPeer = [:]
activeConversation = nil
unreadTransfers = 0
seenIncomingTransfers = []
activeTab = "transfer"
@@ -307,7 +354,11 @@ final class EngineController: NSObject
func clearHistory()
{
history.removeAll()
RecordsStore.clear(recordKey("history"))
// activateRecords 退 legacy
// [] 使 load 退
// legacy
persistHistory()
RecordsStore.clear("history")
}
// H/
@@ -320,7 +371,18 @@ final class EngineController: NSObject
func clearMessages()
{
messages.removeAll()
RecordsStore.clear(recordKey("messages"))
// clearHistory legacy 退 legacy
persistMessages()
RecordsStore.clear("messages")
}
// /
//
//
func deleteConversation(_ peerName: String)
{
messages.removeAll { $0.peerName == peerName }
persistMessages()
}
// ---- #2 ----
@@ -350,10 +412,28 @@ final class EngineController: NSObject
func setActiveTab(_ tab: String)
{
activeTab = tab
if tab == "messages" { unreadMessages = 0 }
// tab
// markConversationRead tab
if tab == "transfer" { unreadTransfers = 0 }
}
// ConversationView.onAppear + onDisappear nil
func setActiveConversation(_ peerName: String?)
{
activeConversation = peerName
}
func markConversationRead(_ peerName: String)
{
if unreadByPeer[peerName] != nil { unreadByPeer[peerName] = 0 }
}
//
func unread(for peerName: String) -> Int
{
return unreadByPeer[peerName] ?? 0
}
// noteIncomingTransfer sessionId
//
private func noteIncomingTransfer(_ item: TransferItem)
@@ -405,6 +485,57 @@ final class EngineController: NSObject
return Self.isWakeable(dev) ? "\(dev.name) · \(t("home.device.wakeable"))" : dev.name
}
// ---- ----
// 线 /
// presence / 线 lastAt=0
//
func conversations() -> [ConversationItem]
{
// messages
var latest: [String: MessageItem] = [:]
for m in messages where latest[m.peerName] == nil { latest[m.peerName] = m }
let reachable = sendableDevices()
let reachableNames = Set(reachable.map { $0.name })
//
var peers = Set(latest.keys)
peers.formUnion(reachableNames)
peers.remove(deviceName) //
var items: [ConversationItem] = []
for peer in peers
{
let dev = devices.first { $0.name == peer }
let last = latest[peer]
items.append(ConversationItem(
peerName: peer,
deviceID: dev?.deviceID ?? "",
deviceType: dev?.type ?? "",
online: dev?.online ?? false,
reachable: reachableNames.contains(peer),
lastText: last?.text ?? "",
lastAt: last?.sentAt ?? 0,
lastOutgoing: last?.direction == "outgoing"))
}
//
return items.sorted { a, b in a.lastAt != b.lastAt ? a.lastAt > b.lastAt : a.peerName < b.peerName }
}
// 线
func thread(with peerName: String) -> [MessageItem]
{
return messages.filter { $0.peerName == peerName }.sorted { $0.sentAt < $1.sentAt }
}
// 线 APNs sendableDevices 线
// 线 /
func canMessage(_ peerName: String) -> Bool
{
return sendableDevices().contains { $0.name == peerName }
}
// UIPasteboard
func uploadClipboard()
{
@@ -617,6 +748,54 @@ final class EngineController: NSObject
let data = (try handle.read(upToCount: count)) ?? Data()
return data.base64EncodedString()
}
// ---- gomobile/pion----
// Documents DownloadManager P2PEventsBridge
// 线 JSlibwebrtc 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
// resolveevaluateJavaScript 线errornil ok=false退 /
nonisolated private func resolveOnMain(id: Int, error: Error?)
{
DispatchQueue.main.async
{
MainActor.assumeIsolated
{
self.resolve(id: id, ok: error == nil, value: error?.localizedDescription)
}
}
}
// #3 P2PEventsBridge.onLog 线
func appendNativeLog(_ msg: String)
{
guard !msg.isEmpty else { return }
lastEngineLog = msg
logs.insert(LogEntry(level: "info", message: msg, at: Date()), at: 0)
if logs.count > Self.logsCap { logs.removeLast(logs.count - Self.logsCap) }
}
}
// MARK: - JS RPC +
@@ -669,6 +848,40 @@ extension EngineController: WKScriptMessageHandler
start: Self.intOf(payload["start"]),
end: Self.intOf(payload["end"]))
resolve(id: id, ok: true, value: b64)
case "p2pStartOutgoing":
// filePath cdrop-file://<id>
// libwebrtc completion offer + resolveOnMain
let engine = nativeTransferEngine()
let path = resolveOutgoingPath(payload["filePath"] as? String ?? "")
guard !path.isEmpty
else { resolve(id: id, ok: false, value: "no staged file"); return }
let sid = payload["sessionId"] as? String ?? ""
let peer = payload["peerName"] as? String ?? ""
let ice = payload["iceServersJSON"] as? String ?? ""
engine.startOutgoing(sessionId: sid, peerName: peer, filePath: path, iceServersJSON: ice)
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
case "p2pStartIncoming":
// PeerConnection offer Documents
let engine = nativeTransferEngine()
let sid = payload["sessionId"] as? String ?? ""
let peer = payload["peerName"] as? String ?? ""
let ice = payload["iceServersJSON"] as? String ?? ""
engine.startIncoming(sessionId: sid, peerName: peer, iceServersJSON: ice)
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
case "p2pHandleSignal":
// JS hub SSE completion
// JS offer/answer trickle p2pIos.ts chain
guard let engine = transferEngine
else { resolve(id: id, ok: false, value: "no native session"); return }
let from = payload["fromPeer"] as? String ?? ""
let pj = payload["payloadJSON"] as? String ?? ""
engine.handleSignal(fromPeer: from, payloadJSON: pj)
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
case "p2pCancel":
// / 退 OnStatelibwebrtc close resolve
let sid = payload["sessionId"] as? String ?? ""
transferEngine?.cancel(sessionId: sid)
resolve(id: id, ok: true, value: nil)
default:
resolve(id: id, ok: false, value: "unknown method: \(method)")
}
@@ -729,8 +942,12 @@ extension EngineController: WKScriptMessageHandler
messages.removeLast(messages.count - Self.messagesCap)
}
persistMessages()
// #5outgoing
if isNew, item.direction == "incoming", activeTab != "messages" { unreadMessages += 1 }
//
// outgoing
if isNew, item.direction == "incoming", item.peerName != activeConversation
{
unreadByPeer[item.peerName, default: 0] += 1
}
}
case "transferDone":
// sessionId
@@ -0,0 +1,905 @@
import Foundation
import WebRTC
// LibWebRtcEngine iOS libwebrtcSafari RTCPeerConnection
// P2P gomobile/pion pion Go raw socket iOS Network framework
// WebKit/libwebrtc desktop/NATIVE-TRANSFER.md §7
//
// 线 desktop/enginepion 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/icepayloadJSON 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.goweb 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
// IPv4libwebrtc iOS Network framework
// hosthost 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-candidatespion 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"
// RTCIceCandidateObjC usernameFragment candidate JSON candidate/sdpMid/
// sdpMLineIndexufrag 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()
}
// doneorderedreceivedBytes 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 namename (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
// P2PEventsBridgelibwebrtc 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 EventsEmitiOS cdropEngine
// LibWebRtcEngine.swift P2PEngineDelegateJS
// 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/icepayloadJSON 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) }
}
}
}
+40
View File
@@ -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/3iOS 13+ Core Text 原生支持 woff/woff2)。`UIAppFonts`Info.plist
历史上只认 ttf/otf,故 woff2 走**运行期注册** `CTFontManagerRegisterFontsForURL`——见 `Sources/BrandFonts.swift`
`BrandFonts.register()``AppDelegate` 启动调用)。Fredoka 字标是 ttf,经 `UIAppFonts` 注册。
仅**主 app** 接入 Orbit / MapleCJK 字体大,不进 Share / 控件扩展——它们保持系统字 + 仅 Fredoka 字标)。
生僻字超出 Orbit Gothic 字汇时由 iOS 字体级联自动回退系统苹方,不出豆腐块。
字体助手见 `Sources/BrandFonts.swift``Font.oBody/.oHeadline/...`Orbit,对位 SF 语义样式)、`Font.maple(...)`
Maple Mono);导航 / 标签栏标题经 UIKit appearance 代理换 Orbit。
+4
View File
@@ -58,6 +58,10 @@
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Commilitia Drop 需要访问照片库,以便从相册选取图片或视频发送给其他设备。</string>
<key>UIAppFonts</key>
<array>
<string>Fredoka-SemiBold.ttf</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
+68 -8
View File
@@ -1,12 +1,37 @@
import SwiftUI
import UIKit
// #3 EngineController.logs N console.warn/error
// 便 Web Inspector SSE / /
// lastEngineLog
// #3 EngineController.logs N pion ICE/mDNS
// session id便
// Web Inspector
struct LogView: View
{
@Environment(EngineController.self) private var engine
private struct LogGroup: Identifiable
{
let id: String
let session: String?
let entries: [LogEntry]
}
// sessionId
private var groups: [LogGroup]
{
var order: [String] = []
var map: [String: [LogEntry]] = [:]
for e in engine.logs
{
let key = e.sessionId ?? ""
if map[key] == nil { order.append(key) }
map[key, default: []].append(e)
}
return order.map
{
LogGroup(id: $0.isEmpty ? "" : $0, session: $0.isEmpty ? nil : $0, entries: map[$0] ?? [])
}
}
var body: some View
{
Group
@@ -19,9 +44,23 @@ struct LogView: View
{
List
{
ForEach(engine.logs)
{ entry in
logRow(entry)
ForEach(groups)
{ group in
Section
{
ForEach(group.entries) { entry in logRow(entry) }
}
header:
{
HStack
{
Text(sessionLabel(group))
Spacer()
Button { copy(group.entries) }
label: { Image(systemName: "doc.on.doc") }
.buttonStyle(.borderless)
}
}
}
}
.listStyle(.plain)
@@ -33,6 +72,11 @@ struct LogView: View
{
if !engine.logs.isEmpty
{
ToolbarItem(placement: .topBarTrailing)
{
Button { copy(engine.logs) }
label: { Label(t("ios.logs.copy"), systemImage: "doc.on.doc") }
}
ToolbarItem(placement: .topBarTrailing)
{
Button(role: .destructive) { engine.clearLogs() }
@@ -42,6 +86,22 @@ struct LogView: View
}
}
// 便
private func copy(_ entries: [LogEntry])
{
let text = entries.reversed()
.map { "\(timeString($0.at)) \($0.message)" }
.joined(separator: "\n")
UIPasteboard.general.string = text
}
private func sessionLabel(_ group: LogGroup) -> String
{
guard let s = group.session
else { return "\(t("ios.logs.ungrouped")) · \(group.entries.count)" }
return "\(t("ios.logs.session")) \(String(s.prefix(8))) · \(group.entries.count)"
}
private func logRow(_ entry: LogEntry) -> some View
{
VStack(alignment: .leading, spacing: 4)
@@ -49,10 +109,10 @@ struct LogView: View
HStack(spacing: 6)
{
Image(systemName: levelSymbol(entry.level))
.font(.caption)
.font(.oCaption)
.foregroundStyle(levelTint(entry.level))
Text(timeString(entry.at))
.font(.caption2)
.font(.oCaption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
+152 -76
View File
@@ -1,66 +1,51 @@
import SwiftUI
// #13 + in-memory app
// web POST /api/messagehub SendTo 线
// 线线线
// ConversationView 线
// conversations() 线 /
// 线线 iOS APNs 线线 /
// 线+ /
struct MessagesView: View
{
@Environment(EngineController.self) private var engine
@State private var draft = ""
@State private var target = ""
@State private var showClearConfirm = false
// device_id
private var targets: [DeviceItem]
{
let selfID = engine.selfDeviceID
return engine.devices.filter
{ d in
if !selfID.isEmpty, !d.deviceID.isEmpty { return d.deviceID != selfID }
return d.name != engine.deviceName
}
}
// store
private var ordered: [MessageItem] { engine.messages.reversed() }
var body: some View
{
VStack(spacing: 0)
Group
{
if engine.messages.isEmpty
let convos = engine.conversations()
if convos.isEmpty
{
ContentUnavailableView(t("ios.messages.empty"), systemImage: "message")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
else
{
ScrollViewReader
{ proxy in
ScrollView
List
{
LazyVStack(alignment: .leading, spacing: 10)
ForEach(convos)
{ convo in
NavigationLink(value: convo.peerName)
{
ForEach(ordered)
{ msg in
messageRow(msg)
.id(msg.id)
conversationRow(convo)
}
// +
.swipeActions(edge: .trailing)
{
Button(role: .destructive) { engine.deleteConversation(convo.peerName) }
label: { Label(t("ios.messages.deleteConversation"), systemImage: "trash") }
}
.contextMenu
{
Button(role: .destructive) { engine.deleteMessage(msg.id) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
Button(role: .destructive) { engine.deleteConversation(convo.peerName) }
label: { Label(t("ios.messages.deleteConversation"), systemImage: "trash") }
}
}
}
.padding()
.listStyle(.plain)
}
.onChange(of: engine.messages.count) { scrollToLast(proxy) }
.onAppear { scrollToLast(proxy) }
}
}
composeBar
}
.navigationTitle(t("ios.tab.messages"))
.navigationDestination(for: String.self) { peer in ConversationView(peerName: peer) }
.toolbar
{
if !engine.messages.isEmpty
@@ -77,12 +62,124 @@ struct MessagesView: View
Button(t("ios.records.clear"), role: .destructive) { engine.clearMessages() }
Button(t("common.cancel"), role: .cancel) { }
}
.onAppear { if target.isEmpty { target = targets.first?.name ?? "" } }
}
private func conversationRow(_ convo: ConversationItem) -> some View
{
HStack(spacing: 12)
{
Image(systemName: deviceSymbol(convo.deviceType))
.font(.oTitle3)
.foregroundStyle(convo.online ? Color.green : Color.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3)
{
Text(convo.peerName)
.font(.oBody)
.lineLimit(1)
Text(previewText(convo))
.font(.oCaption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
VStack(alignment: .trailing, spacing: 5)
{
if convo.lastAt > 0
{
Text(relativeTime(convo.lastAt))
.font(.oCaption2)
.foregroundStyle(.secondary)
}
// tab engine.unreadMessages
let unread = engine.unread(for: convo.peerName)
if unread > 0
{
Text("\(unread)")
.font(.oCaption2)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Color.cdropAccent, in: Capsule())
}
}
}
.padding(.vertical, 4)
}
//
private func previewText(_ convo: ConversationItem) -> String
{
if convo.lastText.isEmpty
{
return convo.reachable ? t("ios.messages.startHint") : t("ios.messages.viewOnly")
}
return convo.lastOutgoing ? "\(t("ios.messages.youPrefix"))\(convo.lastText)" : convo.lastText
}
}
// 线 + 线 /
// 线
struct ConversationView: View
{
@Environment(EngineController.self) private var engine
let peerName: String
@State private var draft = ""
private var thread: [MessageItem] { engine.thread(with: peerName) }
private var reachable: Bool { engine.canMessage(peerName) }
var body: some View
{
VStack(spacing: 0)
{
if thread.isEmpty
{
ContentUnavailableView(t("ios.messages.empty"), systemImage: "message")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
else
{
ScrollViewReader
{ proxy in
ScrollView
{
LazyVStack(alignment: .leading, spacing: 10)
{
ForEach(thread)
{ msg in
messageRow(msg)
.id(msg.id)
.contextMenu
{
Button(role: .destructive) { engine.deleteMessage(msg.id) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
}
.padding()
}
.onChange(of: thread.count) { scrollToLast(proxy) }
.onAppear { scrollToLast(proxy) }
}
}
composeBar
}
.navigationTitle(peerName)
.navigationBarTitleDisplayMode(.inline)
// +
.onAppear
{
engine.setActiveConversation(peerName)
engine.markConversationRead(peerName)
}
.onDisappear { engine.setActiveConversation(nil) }
}
private func scrollToLast(_ proxy: ScrollViewProxy)
{
guard let last = ordered.last else { return }
guard let last = thread.last else { return }
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
@@ -92,18 +189,12 @@ struct MessagesView: View
return HStack
{
if outgoing { Spacer(minLength: 48) }
VStack(alignment: outgoing ? .trailing : .leading, spacing: 2)
{
Text(msg.peerName)
.font(.caption2)
.foregroundStyle(.secondary)
Text(msg.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(outgoing ? Color.cdropAccent.opacity(0.18) : Color(.secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 14))
.textSelection(.enabled)
}
if !outgoing { Spacer(minLength: 48) }
}
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
@@ -112,53 +203,38 @@ struct MessagesView: View
@ViewBuilder
private var composeBar: some View
{
VStack(spacing: 6)
{
if targets.isEmpty
{
Text(t("ios.messages.noPeers"))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
else
if reachable
{
HStack(spacing: 10)
{
Menu
{
ForEach(targets)
{ dev in
Button(dev.name) { target = dev.name }
}
}
label:
{
Label(target.isEmpty ? t("ios.send.pickDevice") : target,
systemImage: "chevron.up.chevron.down")
.font(.caption)
.lineLimit(1)
}
TextField(t("ios.messages.placeholder"), text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1 ... 4)
.submitLabel(.send)
.onSubmit { send() }
Button { send() }
label: { Image(systemName: "paperplane.fill").font(.title3) }
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || target.isEmpty)
}
}
label: { Image(systemName: "paperplane.fill").font(.oTitle3) }
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding()
.background(.bar)
}
else
{
Text(t("ios.messages.viewOnly"))
.font(.oCaption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
.background(.bar)
}
}
private func send()
{
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !target.isEmpty else { return }
engine.sendMessage(to: target, text: text)
guard !text.isEmpty else { return }
engine.sendMessage(to: peerName, text: text)
draft = ""
}
}
+2 -2
View File
@@ -35,10 +35,10 @@ private struct NoticeBanner: View
HStack(spacing: 10)
{
Image(systemName: symbol)
.font(.headline)
.font(.oHeadline)
.foregroundStyle(tint)
Text(notice.text)
.font(.subheadline)
.font(.oSubheadline)
.foregroundStyle(.primary)
.lineLimit(2)
Spacer(minLength: 8)
+16 -1
View File
@@ -271,6 +271,7 @@
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/s",
"transfer.avgRate": "Avg {{rate}}/s",
"transfer.debug.toggle": "Debug",
"transfer.debug.iceGathering": "Gathering",
"transfer.debug.iceConnection": "Connection",
@@ -354,13 +355,20 @@
"ios.tab.messages": "Messages",
"ios.send.fromFiles": "From Files",
"ios.send.fromPhotos": "From Photos",
"ios.send.previewTitle": "Send Files",
"ios.send.files": "Files",
"ios.send.send": "Send",
"ios.messages.empty": "No messages yet",
"ios.messages.noPeers": "No device to message",
"ios.messages.placeholder": "Type a message…",
"ios.messages.viewOnly": "Offline — viewing only",
"ios.messages.deleteConversation": "Delete Conversation",
"ios.messages.startHint": "Tap to start a conversation",
"ios.messages.youPrefix": "You: ",
"ios.records.clear": "Clear",
"ios.records.clearConfirm": "Clear all records? This cannot be undone.",
"ios.transfer.cancel": "Cancel transfer",
"ios.transfer.forceRelay": "Relay now",
"ios.transfer.forceRelay": "Relay",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
@@ -375,6 +383,11 @@
"ios.bg.title": "Transferring files",
"ios.bg.subtitle": "Continues in the background",
"ios.share.title": "Send {{n}} file(s) to a device",
"ios.share.guide.preparing": "Preparing…",
"ios.share.guide.ready": "{{n}} file(s) ready",
"ios.share.guide.empty": "Couldn't add files",
"ios.share.guide.hint": "Open Commilitia Drop to pick a device",
"ios.share.guide.open": "Open Commilitia Drop",
"ios.control.upload": "Upload Clipboard",
"ios.control.pull": "Pull Clipboard",
"ios.control.sessionLabel": "{{name}} · Control",
@@ -383,6 +396,8 @@
"ios.logs.empty": "No logs yet",
"ios.logs.clear": "Clear Logs",
"ios.logs.copy": "Copy All",
"ios.logs.session": "Session",
"ios.logs.ungrouped": "Other",
"ios.scan.entry": "Scan to Sign In",
"ios.scan.title": "Scan QR Code",
"ios.scan.hint": "Point at the sign-in QR code on another device",
+16 -1
View File
@@ -271,6 +271,7 @@
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.avgRate": "平均 {{rate}}/秒",
"transfer.debug.toggle": "调试信息",
"transfer.debug.iceGathering": "候选收集",
"transfer.debug.iceConnection": "连接状态",
@@ -354,13 +355,20 @@
"ios.tab.messages": "消息",
"ios.send.fromFiles": "从文件",
"ios.send.fromPhotos": "从图库",
"ios.send.previewTitle": "发送文件",
"ios.send.files": "文件",
"ios.send.send": "发送",
"ios.messages.empty": "暂无消息",
"ios.messages.noPeers": "没有可发送消息的设备",
"ios.messages.placeholder": "输入消息…",
"ios.messages.viewOnly": "对方当前离线,仅可查看记录",
"ios.messages.deleteConversation": "删除会话",
"ios.messages.startHint": "点按开始会话",
"ios.messages.youPrefix": "你:",
"ios.records.clear": "清空",
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
"ios.transfer.cancel": "取消传输",
"ios.transfer.forceRelay": "立即切到中继",
"ios.transfer.forceRelay": "中继传输",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
@@ -375,6 +383,11 @@
"ios.bg.title": "正在传输文件",
"ios.bg.subtitle": "切到后台仍继续传输",
"ios.share.title": "发送 {{n}} 个文件到设备",
"ios.share.guide.preparing": "正在准备…",
"ios.share.guide.ready": "已就绪 {{n}} 个文件",
"ios.share.guide.empty": "未能添加文件",
"ios.share.guide.hint": "打开 Commilitia Drop,选择设备发送",
"ios.share.guide.open": "打开 Commilitia Drop",
"ios.control.upload": "上传剪贴板",
"ios.control.pull": "拉取剪贴板",
"ios.control.sessionLabel": "{{name}} · 控件",
@@ -383,6 +396,8 @@
"ios.logs.empty": "暂无日志",
"ios.logs.clear": "清空日志",
"ios.logs.copy": "复制全部",
"ios.logs.session": "会话",
"ios.logs.ungrouped": "其他",
"ios.scan.entry": "扫码登录设备",
"ios.scan.title": "扫码登录",
"ios.scan.hint": "对准另一台设备上的登录二维码",
+16 -1
View File
@@ -271,6 +271,7 @@
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.avgRate": "平均 {{rate}}/秒",
"transfer.debug.toggle": "偵錯資訊",
"transfer.debug.iceGathering": "候選收集",
"transfer.debug.iceConnection": "連線狀態",
@@ -354,13 +355,20 @@
"ios.tab.messages": "訊息",
"ios.send.fromFiles": "從檔案",
"ios.send.fromPhotos": "從相簿",
"ios.send.previewTitle": "傳送檔案",
"ios.send.files": "檔案",
"ios.send.send": "傳送",
"ios.messages.empty": "尚無訊息",
"ios.messages.noPeers": "沒有可傳送訊息的裝置",
"ios.messages.placeholder": "輸入訊息…",
"ios.messages.viewOnly": "對方目前離線,僅可檢視記錄",
"ios.messages.deleteConversation": "刪除會話",
"ios.messages.startHint": "點按開始會話",
"ios.messages.youPrefix": "你:",
"ios.records.clear": "清空",
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
"ios.transfer.cancel": "取消傳輸",
"ios.transfer.forceRelay": "立即切到中繼",
"ios.transfer.forceRelay": "中繼傳輸",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
@@ -375,6 +383,11 @@
"ios.bg.title": "正在傳輸檔案",
"ios.bg.subtitle": "切到背景仍繼續傳輸",
"ios.share.title": "傳送 {{n}} 個檔案到裝置",
"ios.share.guide.preparing": "正在準備…",
"ios.share.guide.ready": "已就緒 {{n}} 個檔案",
"ios.share.guide.empty": "未能加入檔案",
"ios.share.guide.hint": "開啟 Commilitia Drop,選擇裝置傳送",
"ios.share.guide.open": "開啟 Commilitia Drop",
"ios.control.upload": "上傳剪貼簿",
"ios.control.pull": "拉取剪貼簿",
"ios.control.sessionLabel": "{{name}} · 控制項",
@@ -383,6 +396,8 @@
"ios.logs.empty": "尚無日誌",
"ios.logs.clear": "清空日誌",
"ios.logs.copy": "複製全部",
"ios.logs.session": "會話",
"ios.logs.ungrouped": "其他",
"ios.scan.entry": "掃碼登入裝置",
"ios.scan.title": "掃碼登入",
"ios.scan.hint": "對準另一台裝置上的登入 QR 碼",
+108 -59
View File
@@ -76,22 +76,20 @@ struct RootView: View
.opacity(0)
.allowsHitTesting(false)
}
// Share Extension cdrop://share
// Share Extension cdrop://share /
// SendComposeSheetonSend sendShares
// move pendingShareFiles
.onAppear { engine.loadPendingShares() }
.confirmationDialog(
t("ios.share.title", [ "n": String(engine.pendingShareFiles.count) ]),
isPresented: Binding(
.sheet(isPresented: Binding(
get: { !engine.pendingShareFiles.isEmpty },
set: { if !$0 { engine.dismissShares() } }
),
titleVisibility: .visible
)
set: { if !$0 { engine.dismissShares() } }))
{
ForEach(engine.sendableDevices())
{ dev in
Button(engine.sendLabel(dev)) { engine.sendShares(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { engine.dismissShares() }
SendComposeSheet(
files: engine.pendingShareFiles.map { PreviewFile(name: $0.lastPathComponent, size: fileByteSize($0)) },
devices: engine.sendableDevices(),
sendLabel: engine.sendLabel,
onSend: { device in engine.sendShares(to: device) },
onCancel: { engine.dismissShares() })
}
}
}
@@ -101,9 +99,11 @@ struct TransferListView: View
@Environment(EngineController.self) private var engine
@State private var showImporter = false
@State private var showPhotoPicker = false
@State private var photoItem: PhotosPickerItem?
@State private var pickedURL: URL?
@State private var showDevicePicker = false
@State private var photoItems: [PhotosPickerItem] = []
// /
// SendComposeSheet
@State private var pickedURLs: [URL] = []
@State private var showCompose = false
@State private var showNoDevices = false
@State private var showClearConfirm = false
// List NavigationLink chevron
@@ -122,7 +122,8 @@ struct TransferListView: View
{
if engine.transfers.isEmpty && engine.history.isEmpty
{
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
// hero + + drop
BrandHero(tagline: t("ios.transfer.empty"))
}
else
{
@@ -161,31 +162,29 @@ struct TransferListView: View
label:
{
Image(systemName: "paperplane.fill")
.font(.title2)
.font(.oTitle2)
.padding(18)
}
.buttonStyle(.glassProminent)
.padding(24)
}
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ])
// / SendComposeSheet
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ],
allowsMultipleSelection: true)
{ result in
if case .success(let url) = result
{
pickedURL = url
if sendableDevices.isEmpty { showNoDevices = true }
else { showDevicePicker = true }
if case .success(let urls) = result, !urls.isEmpty { present(urls) }
}
}
.photosPicker(isPresented: $showPhotoPicker, selection: $photoItem,
.photosPicker(isPresented: $showPhotoPicker, selection: $photoItems,
matching: .any(of: [ .images, .videos ]))
.onChange(of: photoItem) { loadPhoto() }
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
.onChange(of: photoItems) { loadPhotos() }
.sheet(isPresented: $showCompose, onDismiss: clearPicked)
{
ForEach(sendableDevices)
{ dev in
Button(engine.sendLabel(dev)) { startSend(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { }
SendComposeSheet(
files: pickedURLs.map { PreviewFile(name: $0.lastPathComponent, size: fileByteSize($0)) },
devices: sendableDevices,
sendLabel: engine.sendLabel,
onSend: { device in sendPicked(to: device) },
onCancel: { showCompose = false })
}
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
@@ -221,7 +220,7 @@ struct TransferListView: View
HStack
{
Text(title)
.font(.subheadline)
.font(.oSubheadline)
.foregroundStyle(.secondary)
Spacer()
}
@@ -276,30 +275,47 @@ struct TransferListView: View
}
}
private func startSend(to device: String)
// /
private func present(_ urls: [URL])
{
guard let url = pickedURL else { return }
engine.sendFile(to: device, fileURL: url)
pickedURL = nil
pickedURLs = urls
if sendableDevices.isEmpty { showNoDevices = true }
else { showCompose = true }
}
// #14
private func loadPhoto()
//
private func sendPicked(to device: String)
{
guard let item = photoItem else { return }
for url in pickedURLs { engine.sendFile(to: device, fileURL: url) }
showCompose = false
}
// / 使
private func clearPicked()
{
pickedURLs = []
photoItems = []
}
// #14
private func loadPhotos()
{
let items = photoItems
guard !items.isEmpty else { return }
Task
{
guard let data = try? await item.loadTransferable(type: Data.self),
let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item))
else { return }
await MainActor.run
var urls: [URL] = []
for item in items
{
pickedURL = url
photoItem = nil
if sendableDevices.isEmpty { showNoDevices = true }
else { showDevicePicker = true }
if let data = try? await item.loadTransferable(type: Data.self),
let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item))
{
urls.append(url)
}
}
guard !urls.isEmpty else { return }
await MainActor.run { present(urls) }
}
}
// jpg mov
@@ -326,7 +342,7 @@ struct TransferCardView: View
? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(.secondary)
Text(item.fileName)
.font(.headline)
.font(.oHeadline)
.lineLimit(1)
Spacer()
}
@@ -335,7 +351,7 @@ struct TransferCardView: View
ProgressView(value: progressOf(item))
}
Text(detailLine)
.font(.caption)
.font(.oCaption)
.foregroundStyle(.secondary)
}
.padding()
@@ -360,6 +376,11 @@ struct TransferCardView: View
{
parts.append(t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
}
else if item.state == "DONE", let rate = item.bytesPerSec, rate > 0
{
// / store completeTransfer bytesPerSec
parts.append(t("transfer.avgRate", [ "rate": formatBytes(Int(rate)) ]))
}
return parts.joined(separator: " · ")
}
}
@@ -427,8 +448,10 @@ struct TransferDetailView: View
}
if let rate = item.bytesPerSec, rate > 0
{
// store bytesPerSec
LabeledContent(t("ios.detail.speed"),
value: t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
value: t(item.state == "DONE" ? "transfer.avgRate" : "transfer.bytesRate",
[ "rate": formatBytes(Int(rate)) ]))
}
ProgressView(value: progressOf(item))
}
@@ -602,14 +625,14 @@ struct DeviceListView: View
if isSelf(dev)
{
Text(t("ios.devices.thisDevice"))
.font(.caption2)
.font(.oCaption2)
.foregroundStyle(Color.cdropAccent)
}
}
Text(dev.online
? t("settings.online")
: t("settings.lastSeen", [ "time": relativeTime(dev.lastSeen) ]))
.font(.caption)
.font(.oCaption)
.foregroundStyle(.secondary)
}
}
@@ -632,6 +655,23 @@ struct SettingsView: View
{
List
{
// + logo
Section
{
VStack(spacing: 6)
{
BrandLockup(markSize: 40, wordSize: 24)
if !appVersion.isEmpty
{
Text(appVersion)
.font(.oCaption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.listRowBackground(Color.clear)
}
if let user = auth.session?.user
{
Section
@@ -678,7 +718,7 @@ struct SettingsView: View
if !engine.lastEngineLog.isEmpty
{
Text(engine.lastEngineLog)
.font(.caption2)
.font(.oCaption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
@@ -712,6 +752,15 @@ struct SettingsView: View
return t("ios.engine.disconnected")
}
// vX.Y (build)
private var appVersion: String
{
let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
guard !v.isEmpty else { return "" }
let b = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
return b.isEmpty ? "v\(v)" : "v\(v) (\(b))"
}
// 稿PATCH /api/devices/{device_id} /
// renamed + Keychain + engine.deviceName
private func commitRename()
@@ -812,7 +861,7 @@ struct ReceivedFilesView: View
Text(url.lastPathComponent)
.lineLimit(1)
Text(fileSizeString(url))
.font(.caption)
.font(.oCaption)
.foregroundStyle(.secondary)
}
}
@@ -861,7 +910,7 @@ private func progressOf(_ item: TransferItem) -> Double
return min(1.0, Double(sent) / Double(item.fileSize))
}
private func formatBytes(_ bytes: Int) -> String
func formatBytes(_ bytes: Int) -> String
{
let f = ByteCountFormatter()
f.countStyle = .file
@@ -876,7 +925,7 @@ private func fileSizeString(_ url: URL) -> String
// lastSeen epoch devices.last_seennow.Unix()web formatRelative
// settings.lastSeen {{time}}
private func relativeTime(_ epochSec: Double) -> String
func relativeTime(_ epochSec: Double) -> String
{
let date = Date(timeIntervalSince1970: epochSec)
let f = RelativeDateTimeFormatter()
@@ -885,7 +934,7 @@ private func relativeTime(_ epochSec: Double) -> String
}
// SF Symbol web mapKind
private func deviceSymbol(_ type: String) -> String
func deviceSymbol(_ type: String) -> String
{
let s = type.lowercased()
if s.contains("mac") || s == "darwin" { return "laptopcomputer" }
+2 -2
View File
@@ -59,14 +59,14 @@ struct ScanSheet: View
if invalid
{
Text(t("ios.scan.invalid"))
.font(.callout)
.font(.oCallout)
.padding(.vertical, 8)
.padding(.horizontal, 14)
.background(.red.opacity(0.85), in: Capsule())
.foregroundStyle(.white)
}
Text(t("ios.scan.hint"))
.font(.footnote)
.font(.oFootnote)
.multilineTextAlignment(.center)
.padding(.vertical, 8)
.padding(.horizontal, 14)
+108
View File
@@ -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
}
+140
View File
@@ -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")
}
}
+41 -15
View File
@@ -33,34 +33,59 @@ struct ClipboardWidgetView: View
var body: some View
{
VStack(alignment: .leading, spacing: 10)
Group
{
HStack(spacing: 6)
if family == .systemMedium { mediumLayout } else { smallLayout }
}
.containerBackground(for: .widget) { brandBackground }
}
// slim header + +
private var smallLayout: some View
{
Image(systemName: "doc.on.clipboard")
.foregroundStyle(.tint)
VStack(spacing: 10)
{
HStack(spacing: 8)
{
BrandMark(size: 30)
Text(t("ios.widget.clipboardName"))
.font(.caption).bold()
.font(.subheadline).bold()
.lineLimit(1)
Spacer(minLength: 0)
}
//
if family == .systemSmall
{
actionButton(t("ios.control.upload"), "square.and.arrow.up", UploadClipboardIntent())
actionButton(t("ios.control.pull"), "square.and.arrow.down", PullClipboardIntent())
}
else
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(14)
}
// +
private var mediumLayout: some View
{
HStack(spacing: 10)
HStack(spacing: 18)
{
BrandMark(size: 72)
VStack(alignment: .leading, spacing: 9)
{
Text(t("ios.widget.clipboardName"))
.font(.headline)
.lineLimit(1)
actionButton(t("ios.control.upload"), "square.and.arrow.up", UploadClipboardIntent())
actionButton(t("ios.control.pull"), "square.and.arrow.down", PullClipboardIntent())
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding()
.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
@@ -68,11 +93,12 @@ struct ClipboardWidgetView: View
Button(intent: intent)
{
Label(title, systemImage: symbol)
.font(.caption2)
.font(.caption).fontWeight(.semibold)
.lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.vertical, 3)
}
.buttonStyle(.bordered)
.buttonStyle(.borderedProminent)
.tint(.cdropAccent)
}
}
+41
View File
@@ -4,6 +4,14 @@ options:
deploymentTarget:
iOS: "26.0"
createIntermediateGroups: true
# 原生数据面:iOS 走 libwebrtcSafari 同款,与 iOS 网络栈原生集成)。预编译 xcframework 经
# stasel/WebRTC 以 SPM 分发,仅二进制 target,pin 精确版本避免上游 M 版跳变改 API。替换早先
# gomobile/pion 路径——pion 的 Go raw socket 不与 iOS Network framework 集成,真机确证不可靠
# (见 desktop/NATIVE-TRANSFER.md §7 / cdrop-native-data-plane 记忆)。
packages:
WebRTC:
url: https://github.com/stasel/WebRTC
exactVersion: 149.0.0
settings:
base:
SWIFT_VERSION: "5.0"
@@ -26,10 +34,16 @@ targets:
embed: true
- target: CDropWidgets
embed: true
# 原生数据面引擎(libwebrtc):SPM 预编 xcframeworkXcode 自动嵌入 + 签名。仅主 app 链接——
# Share / 控件扩展不碰数据面,避免超扩展 120MB 内存限。见 LibWebRtcEngine.swift。
- package: WebRTC
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
# 品牌字标字体 Fredoka SemiBoldShared/Fonts/,随 bundle 打包),用于 Wordmark(与 web 一致)。
UIAppFonts:
- Fredoka-SemiBold.ttf
# cdrop:// URL schemeShare Extension 交接后经 cdrop://share 深链唤起主 app 选设备发送。
CFBundleURLTypes:
- CFBundleURLName: net.commilitia.Commilitia-Drop
@@ -79,10 +93,15 @@ targets:
sources:
- path: Share
- path: Shared
# i18n JSON 随扩展 bundle 一并打包,使分享引导界面的 t() 文案随设备语言(同控件扩展)。
- path: Sources/Resources/i18n
info:
path: Share/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
# 品牌字标字体(分享引导界面的 Wordmark 用),随扩展 bundle 打包 + 注册。
UIAppFonts:
- Fredoka-SemiBold.ttf
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
@@ -117,10 +136,32 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_ENTITLEMENTS: Widgets/CDropWidgets.entitlements
# 单元 / 集成测试(模拟器,无需签名):原生数据面 libwebrtc 引擎的环回端到端测试——两个
# LibWebRtcEngine 在同进程内交叉连,跑完整线协议(offer/answer/ICE + meta/分片/done + ack +
# 收尾)验证文件字节完整性。宿主即 CDrop app@testable import 取内部类型 + 经 app 链接 WebRTC)。
CDropTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "26.0"
sources:
- path: Tests
dependencies:
- target: CDrop
- package: WebRTC
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.tests
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/CDrop.app/CDrop"
BUNDLE_LOADER: "$(TEST_HOST)"
GENERATE_INFOPLIST_FILE: "YES"
schemes:
CDrop:
build:
targets:
CDrop: all
test:
targets:
- CDropTests
gatherCoverageData: false
run:
config: Debug
+3 -1
View File
@@ -31,7 +31,8 @@
- **视觉材质**:液态玻璃 vs Theme B。
- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。
- **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto间距 / 圆角 / 字阶各用平台母语。
- **字体 / 排版**2026-06-28 更新,字族已收敛):两端现**同用** Orbit GothicSans+ Maple MonoMono+ Fredoka(字标)——字族升为共享源(强约束③延伸)。**残余分叉**仅在落地机制(iOS woff2 运行期 `CTFontManagerRegisterFontsForURL` 注册 + UIKit appearance 代理 + Dynamic Type vs web CSS `@font-face`)与间距 / 圆角 / 字阶各用平台母语);生僻字 iOS 回退苹方、web 回退 Noto。Orbit 为专有字体,两端均不入库、构建期从 CDN 拉
- **分享引导界面**(仅 iOS):Share Extension 内的吉祥物引导卡(`ShareGuideView`)引用户回主程序完成发送——平台专属流程,web 无对应。
- **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。
- **控制中心控件标签**:运行时控件标签(用户点按的那个)经 `t()` 跟随设备语言;但「添加控件」库名 + Shortcuts 标题是 `LocalizedStringResource` 静态值、`CDropWidgets` target 无 String Catalog,故对 en / zh-TW 用户回退简体字面量(审查 L7,平台专属功能可接受分叉;要彻底本地化须为 Widgets target 加 `.xcstrings`)。
@@ -60,3 +61,4 @@
| 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices``presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()``{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html``engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 |
| 2026-06-24 | engine/main.tstransfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署** | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreviewproject.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/srelay 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`(本提交) | **计划完成(I3I7**:发送端流式(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 binaryAPNs 端点 + 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
View File
@@ -53,6 +53,17 @@
- **缺失 / 拒绝时按特性二选一**:① **有兜底则降级**——本地网络→回退中继并提示“仅中继、无法同内网直连”;通知→无后台推送但前台仍可用。② **无兜底则提示“无权限”并阻塞该特性**——相机→扫码不可用,提示 + 跳系统设置;阻塞的是**该特性**、非整个 app。
- 不拿任一权限当**整个 app** 的门槛(HIG 5.1.2)。详见 §9.2 逐权限映射。
### 决策 F(新,2026-06-28):iOS P2P 数据面下沉原生 libwebrtc(与桌面 pion 对称)
修订决策 A 的“数据面亦跑在无头 WebView JS 里”。**P2P DataChannel 的字节收发(数据面)已从无头 WebView 的 JS 引擎移到原生 libwebrtc**`Sources/Engine/LibWebRtcEngine.swift``RTCPeerConnection`),与桌面把数据面移到 Go/pion **完全对称**
- **无头 WebView 此后只跑编排**:信令转发 / 会话 / presence / 消息 / 状态机 / relay`hub.ts` + `transfer.ts`),**不再承载 P2P 数据面**。决策 A 的“原生壳 + 无头引擎”仍成立,只是引擎的职责收窄到编排层。
- **缘由(U1→U2 翻案)**:先试 gomobile+pion(与桌面同引擎,U1),真机 POC **证伪**——Go raw BSD socket 不与 iOS Network framework 集成(连接时好时坏 + 中途断流)。按闸退到 **libwebrtc.framework**U2stasel/WebRTC M149 经 SPM、pin `exactVersion 149.0.0`)。完整决策依据与实测证据见 `desktop/NATIVE-TRANSFER.md` §7/§8。
- **收益**libwebrtc 原生集成 iOS 网络栈 + DcSCTP 默认 ~5MB rwnd——摆脱 WebKit 写死的 256KB 接收窗(R-iOS-3 的吞吐残差)对数据面的钳制,连通性与吞吐一并根治。
- **互通靠线协议逐字节对齐**`LibWebRtcEngine``desktop/engine``engine.go`/`session.go` 线协议的忠实 Swift 端口——DataChannel `cdrop-file`ordered)、`meta`/二进制分片(64KB)/`done`/`ack` 帧、16MB/4MB 水位背压、ack 追平完成、不冲突落盘。故 iOS↔桌面(pion)、iOS↔浏览器(JS)三端互通。
- **桥与 JS 路由零改动**`IOS_NATIVE` 开关、`p2pIos.ts`/`net/ios.ts` 桥协议原样;只在 Swift 侧新增一条原生传输 RPC(`startOutgoing`/`startIncoming`/`handleSignal`/`cancel`completion-based,经 `resolveOnMain` 跳主线程 resolve)。**iOS 定向的引擎配置绝不回流 `desktop/engine` 共享包**。
- **验证**:环回端到端(两个引擎同进程交叉连、20MB 经完整线协议传输后逐字节比对)✅;模拟器全 app 编译 ✅;web typecheck/build ✅。真机 / 真网吞吐互通为剩余闸(须 deploy prod 让设备拿到 `IOS_NATIVE=true` 引擎 + 真机装机,二者 Touch ID 门控)。
---
## 1. 后端现状(决定 iOS 能做什么,2026-06-24 扫描)
@@ -72,6 +83,8 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
## 2. 桥协议(arch A 的接口契约,账号无关、可先冻结)
> **状态(2026-06-28,决策 F 后)**:本节描述的桥是**编排与控制面**(信令 / 会话 / presence / 消息 / 接收回退字节通道)。**P2P 数据面(含其磁盘 I/O)现走原生 libwebrtc**(决策 F),不再经此桥的 `sendFile` / `Begin`-`Append`-`Finalize` 路径;那条接收字节通道仅保留给 relay / 非原生回退。下文桥语义对编排层仍准确。
原生 ↔ 无头 JS 引擎的 RPC`WKScriptMessageHandler`JS→Swift+ `evaluateJavaScript`Swift→JS),类比 Wails 的 `EventsEmit` / bound methods,但**方向相反**(原生在顶层)。
- **平台判定**`isIOSShell()``web/src/net/ios.ts`)查 `window.webkit.messageHandlers.cdropEngine` 在场(类比 `isDesktop()``window.go.main.App`)。无头入口(`web/src/engine/main.ts`)据此装配桥。
@@ -113,6 +126,8 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
> **实现状态(2026-06-27**I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share ExtensionApp Group 收件箱 + `cdrop://share` 深链);控制中心两控件(**专用 broker 设备会话**,纯原生 REST,不与引擎抢 refresh 轮换)。模拟器验 + ultracode 多 agent 审查修讫(0 HIGH,修 2 MED + 7 LOW)。**I0 账号 / 证书 + 真机签名 / 真发推送 / 旁加载分发**仍账号门控——手册 `ios/CDrop/REALDEVICE.md`ASC API Key 自动 provisioning + `just ios-device` 全 CLI 装机,`-allowProvisioningUpdates` 自动登记设备 / 建 profile)。包名 `net.commilitia.Commilitia-Drop`App Group `group.net.commilitia.Commilitia-Drop`。
> **更新(2026-06-28**:① **P2P 数据面改原生 libwebrtc**(决策 F;完整依据 `desktop/NATIVE-TRANSFER.md` §7/§8)——已真机装机、用户反馈“传输总体良好”,真网吞吐互通为剩余闸;② **UX 改造**(发送预览直入 / 消息按设备会话 / 删除粒度,§10);③ **品牌与字体系统**logo + Orbit Gothic / Maple Mono / Fredoka,§11)。
---
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开)
@@ -235,6 +250,57 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
---
## 10. UX 改造(2026-06-28,真机反馈驱动)
真机使用后落地的三块结构性改造,均属决策 D 的“非引擎驱动 UI 流程”(进 `ios/PARITY.md` 残余项 1/2,已登记)。
### 10.1 发送预览界面(取代即弹的设备选择框)
- 选文件 / 选图后**不再**直接弹设备选择,而是进入**发送预览**(`Sources/SendComposeSheet.swift`):列出待发文件名 + 字节大小,下方列表选目标设备。手动发送与分享发送**共用**此界面。
- 手动入口支持**多选**`fileImporter(allowsMultipleSelection: true)` + `photosPicker(selection:)` 数组;`RootView``present`/`sendPicked`/`clearPicked`/`loadPhotos` 串起选取→预览→发送。
### 10.2 分享直入预览(修“必须重启 app”)
- Share Extension 抓取 → 暂存 App Group → 唤起主程序,主程序**直入发送预览**(同 `SendComposeSheet`)。
- 修复“分享后必须重启 app 才出现传输”:唤起宿主 app 经响应链 IMP 调现代 `openURL:options:completionHandler:`(旧单参 `openURL:` 在 iOS 18+ 已成空壳),**并以 `scenePhase``.active``loadPendingShares()` 兜底**——回前台即扫 App Group 暂存、补出预览,不依赖那条 iOS 灰区的扩展→宿主唤起是否可靠。分享引导界面见 §11.3。
### 10.3 消息按设备会话聚合 + 删除粒度
- 消息从扁平流改为**按设备聚合的会话列表**(社交 app 模型,`Sources/MessagesView.swift`):会话列表项 = 有历史**或**当前可达的设备;点进 `ConversationView` 看与该设备的线程。非 APNs 离线设备**不能**作为发送对象,但历史**可查看**(`canMessage()` 判定)。
- **每设备未读分桶**`EngineController``unreadByPeer: [String: Int]` + `activeConversation`;每设备会话显示各自未读红点,“消息”标签显示总未读(`unreadMessages = unreadByPeer.values.reduce(0,+)`)。进会话即 `markConversationRead` + `setActiveConversation`
- **任意删除**:可删**单条消息**与**整个设备的历史**(`deleteConversation()`),避免废 session 堆积。
---
## 11. 品牌与字体系统(2026-06-28,对齐 web
iOS 界面引入与 web 一致的品牌资产与字体栈——这是强约束③(§5,品牌资产 + 主色单一源)在视觉层的延伸。
### 11.1 品牌 logo
- `Shared/Brand.swift``BrandMark`(吉祥物,资产 `Shared/Brand.xcassets/LogoMark`,源 `web/public/logo-mark.png`)、`Wordmark`(“Commilitia” 主色 + “Drop” `cdropAmber`Fredoka SemiBold)、`BrandLockup``BrandHero(tagline:)`
- 接入点:登录页(`BrandMark` + `Wordmark` 取代纯文字标题)、传输空态(`BrandHero`)、设置页顶部横向品牌头、控制中心控件重设计(吉祥物 + 品牌渐变 + 全宽按钮,最大化空间)。`Theme.swift``Color.cdropAmber`#DB9410 / #F8C637)。
### 11.2 字体栈(Orbit Gothic + Maple Mono + Fredoka
- 与 web `fonts.css` 同源:Sans = **Orbit Gothic CJK SC**Regular + SemiBold)、Mono = **Maple Mono**、字标 = **Fredoka SemiBold**
- **woff2 运行期注册**Orbit / Maple 以 woff2 内嵌(约 TTF 的 1/3 体积),`UIAppFonts` 历史只认 ttf/otf,故经 `CTFontManagerRegisterFontsForURL` 运行期注册(iOS 13+ Core Text 原生支持 woff2,实测 register→true)——见 `Sources/BrandFonts.swift``BrandFonts.register()``AppDelegate` 启动调)。Fredoka 是 ttf,走 `UIAppFonts`
- **助手 + sweep**`BrandFonts` 暴露 `Font.oBody/.oHeadline/…`Orbit,对位 SF 语义样式)、`Font.maple(…)`Maple Mono);导航 / 标签栏标题经 UIKit appearance 代理换 Orbit7 个视图的 `.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 | iOSarch A |
+7 -4
View File
@@ -71,8 +71,8 @@ export function TransferRow(props: TransferRowProps)
// 终态记录可删除(从列表移除);非终态记录可取消(驱动后端 /cancel)。
const terminal = done || failed || cancelled;
// 发送方仍在试 P2P(已接受 / P2P 中、尚未切中继)时,给一个「立即中继」入口,
// 跳过 30s ICE 等待——只对 outgoing 有意义(驱动后端 /fallback)。
// 发送方仍在试 P2P(已接受 / P2P 中、尚未切中继)时,给一个「中继传输」入口手动切中继——
// 无自动回退后这是切中继的唯一途径,只对 outgoing 有意义(驱动后端 /fallback)。
const canRelayNow = tr.direction === "outgoing"
&& (tr.state === "ACCEPTED" || tr.state === "P2P_ACTIVE")
&& tr.phase !== "fallback_pending"
@@ -192,10 +192,13 @@ function bytesText(tr: TransferRecord): string
// 卡住了」状态已表达停滞,再显示一个陈旧的非零速度会误导。速率由 store 做 EMA 平滑。
function speedText(tr: TransferRecord, stalled: boolean): string
{
if (stalled) { return ""; }
if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
const rate = tr.bytesPerSec;
if (!rate || !Number.isFinite(rate) || rate <= 0) { return ""; }
// 完成后显示平均速度(store completeTransfer 已把 bytesPerSec 置为总字节 / 总耗时的均速)。
if (tr.state === "DONE") { return t("transfer.avgRate", { rate: formatBytes(rate) }); }
// 进行中:瞬时速度(EMA 平滑)。卡死 / 非流动阶段不显示陈旧速度,避免误导。
if (stalled) { return ""; }
if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
return t("transfer.bytesRate", { rate: formatBytes(rate) });
}
+9 -45
View File
@@ -4,10 +4,10 @@ import { relayReceiveFile, relaySendFile } from "./relay";
import type { FileSource } from "./source";
import { useAppStore, type TransferRecord } from "../../store";
// Orchestrator that picks P2P first, falls back to Relay on ICE timeout
// (plan §M9: 30s after start, if iceConnectionState != connected → /fallback).
const ICE_TIMEOUT_MS = 30_000;
// Orchestrator that starts P2P and leaves relay to a manual user action.
// 不再有 30s ICE 超时自动回退中继:P2P 无时限尝试,中继仅由用户手动触发(TransferRow 的
// 「中继传输」按钮 → skipWaitRelay)。自动回退会在弱网/慢 ICE 下抢先切走、且掩盖 P2P 是否
// 终将连通,故按产品要求移除——所有中继改手动。
interface OutgoingState
{
@@ -15,7 +15,6 @@ interface OutgoingState
peerName: string;
src: FileSource;
p2p: P2PSession | null;
fallbackTimer: number | null;
// waitTimer / waitUnsub back the "wait for an offline receiver to wake" path: the timeout that
// fails the transfer if the peer never comes online, and the presence subscription that fires
// P2P once it does.
@@ -73,7 +72,6 @@ export async function startOutgoingTransfer(
peerName: receiverName,
src,
p2p: null,
fallbackTimer: null,
waitTimer: null,
waitUnsub: null,
abort,
@@ -86,8 +84,9 @@ export async function startOutgoingTransfer(
return sessionId;
}
// beginP2P arms the relay-fallback watchdog and starts the P2P offer. Extracted from
// startOutgoingTransfer so the offline-receiver path can defer it until the peer wakes.
// beginP2P starts the P2P offer. Extracted from startOutgoingTransfer so the
// offline-receiver path can defer it until the peer wakes. No auto-fallback watchdog:
// relay is manual only (skipWaitRelay).
function beginP2P(state: OutgoingState): void
{
const { sessionId, peerName, src } = state;
@@ -95,35 +94,8 @@ function beginP2P(state: OutgoingState): void
const r0 = store0.activeTransfers[sessionId];
if (r0 && r0.phase === "waiting_peer") { store0.upsertTransfer({ ...r0, phase: "waiting_accept" }); }
// Arm the fallback watchdog *before* any P2P work. p2pStartOutgoing is
// intentionally synchronous: if createOffer / setLocalDescription hang
// (e.g. injected stub during fallback testing) the timer must still fire.
state.fallbackTimer = window.setTimeout(() =>
{
const cur = outgoing.get(sessionId);
if (!cur) { return; }
const p2pState = cur.p2p?.state;
if (p2pState !== "connected" && p2pState !== "completed")
{
// 把"已经放弃 P2P,等服务端 RELAY_ACTIVE"这个状态显式落到 store
// UI 据此告诉用户「还在通,正在切到中继」而不是干等。
const store = useAppStore.getState();
const r = store.activeTransfers[sessionId];
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
void triggerFallback(sessionId);
}
}, ICE_TIMEOUT_MS);
const p2p = p2pStartOutgoing(sessionId, peerName, src);
state.p2p = p2p;
p2p.onState((s) =>
{
if (s === "completed" || s === "connected")
{
if (state.fallbackTimer) { clearTimeout(state.fallbackTimer); state.fallbackTimer = null; }
}
});
}
// PEER_WAIT_MS bounds how long the sender waits for an offline receiver to wake + reconnect.
@@ -311,7 +283,6 @@ function cleanupOutgoing(sessionId: string): void
{
const s = outgoing.get(sessionId);
if (!s) { return; }
if (s.fallbackTimer) { clearTimeout(s.fallbackTimer); }
if (s.waitTimer !== null) { clearTimeout(s.waitTimer); }
if (s.waitUnsub) { s.waitUnsub(); }
s.abort.abort();
@@ -325,17 +296,10 @@ export function cleanupTransfer(sessionId: string): void
incomingFiles.delete(sessionId);
}
// UI-triggered (发送方「跳过等待、立即中继」):发送方不等 30s ICE 超时,直接切到
// 中继。复用 watchdog 的逻辑——先停掉那个定时器以免重复 fire,把记录标成
// fallback_pending 让 UI 显示「正在切到中继」,再请求服务端转 RELAY_ACTIVE。
// UI-triggered (发送方「中继传输」):手动把传输切到中继(无自动回退后,这是切中继的唯一途径)。
// 把记录标成 fallback_pending 让 UI 显示「正在切到中继」,再请求服务端转 RELAY_ACTIVE。
export async function skipWaitRelay(sessionId: string): Promise<void>
{
const cur = outgoing.get(sessionId);
if (cur?.fallbackTimer)
{
clearTimeout(cur.fallbackTimer);
cur.fallbackTimer = null;
}
const store = useAppStore.getState();
const r = store.activeTransfers[sessionId];
if (r) { store.upsertTransfer({ ...r, phase: "fallback_pending" }); }
+3 -2
View File
@@ -284,7 +284,7 @@ export const enUS: Partial<TranslationDict> = {
"transfer.saveFailed": "Failed to save file: {{error}}",
"transfer.action.cancel": "Cancel",
"transfer.action.delete": "Delete",
"transfer.action.relayNow": "Relay now",
"transfer.action.relayNow": "Relay",
"transfer.action.details": "Details",
"transfer.state.PENDING": "Pending",
"transfer.state.ACCEPTED": "Accepted",
@@ -313,6 +313,7 @@ export const enUS: Partial<TranslationDict> = {
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/s",
"transfer.avgRate": "Avg {{rate}}/s",
// ---- debug expansion ------------------------------------------------
"transfer.debug.toggle": "Debug",
@@ -414,7 +415,7 @@ export const enUS: Partial<TranslationDict> = {
"ios.records.clear": "Clear",
"ios.records.clearConfirm": "Clear all records? This cannot be undone.",
"ios.transfer.cancel": "Cancel transfer",
"ios.transfer.forceRelay": "Relay now",
"ios.transfer.forceRelay": "Relay",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
+3 -2
View File
@@ -280,7 +280,7 @@ export const zhCN = {
"transfer.saveFailed": "保存文件失败:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "删除",
"transfer.action.relayNow": "立即中继",
"transfer.action.relayNow": "中继传输",
"transfer.action.details": "详情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
@@ -309,6 +309,7 @@ export const zhCN = {
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.avgRate": "平均 {{rate}}/秒",
// ---- 调试展开区 -----------------------------------------------------
"transfer.debug.toggle": "调试信息",
@@ -410,7 +411,7 @@ export const zhCN = {
"ios.records.clear": "清空",
"ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。",
"ios.transfer.cancel": "取消传输",
"ios.transfer.forceRelay": "立即切到中继",
"ios.transfer.forceRelay": "中继传输",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
+3 -2
View File
@@ -284,7 +284,7 @@ export const zhTW: Partial<TranslationDict> = {
"transfer.saveFailed": "儲存檔案失敗:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "刪除",
"transfer.action.relayNow": "立即中繼",
"transfer.action.relayNow": "中繼傳輸",
"transfer.action.details": "詳情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
@@ -313,6 +313,7 @@ export const zhTW: Partial<TranslationDict> = {
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.avgRate": "平均 {{rate}}/秒",
// ---- 偵錯展開區 -----------------------------------------------------
"transfer.debug.toggle": "偵錯資訊",
@@ -414,7 +415,7 @@ export const zhTW: Partial<TranslationDict> = {
"ios.records.clear": "清空",
"ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。",
"ios.transfer.cancel": "取消傳輸",
"ios.transfer.forceRelay": "立即切到中繼",
"ios.transfer.forceRelay": "中繼傳輸",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
+13 -1
View File
@@ -46,7 +46,19 @@ export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice>
{
const cur = s.activeTransfers[sessionId];
if (!cur) { return s; }
const finalRec = normalizeTerminal(cur, finalState);
let finalRec = normalizeTerminal(cur, finalState);
// 完成后速度显示「平均速度」(总字节 / 总耗时),取代最后的瞬时 EMA 速率——后者只反映末段
// 抽样、对整体传输无代表性。仅 DONE 有意义(FAILED/CANCELLED 为部分传输,不覆写)。全平台
// 一处生效:web/desktop 读此记录、iOS 经 toWire→transferDone 拿到同一 bytesPerSec。
if (finalState === "DONE")
{
const elapsedMs = Date.now() - (cur.startedAt ?? Date.now());
const total = cur.fileSize > 0 ? cur.fileSize : (cur.bytesTransferred ?? 0);
if (elapsedMs > 0 && total > 0)
{
finalRec = { ...finalRec, bytesPerSec: total / (elapsedMs / 1000) };
}
}
const { [sessionId]: _, ...rest } = s.activeTransfers;
return ({
activeTransfers: rest,