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:
@@ -1,76 +1,173 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// Share Extension 主控制器:把 share sheet 传入的文件拷进 App Group 收件箱,再深链唤起主 app
|
||||
// 选设备发送(扩展只交接、不跑引擎,避 120MB jetsam,见 PLAN §I5)。无 compose UI——即起即办、
|
||||
// 处理完即返回。
|
||||
// Share Extension 主控制器:把 share sheet 传入的文件拷进 App Group 收件箱,再以**品牌引导界面**
|
||||
// (ShareGuideView:吉祥物 + 字标 + 已就绪文件数 + 「打开 Commilitia Drop」按钮)引导用户回到主 app
|
||||
// 选设备发送(扩展只交接、不跑引擎,避 120MB jetsam,见 PLAN §I5)。
|
||||
//
|
||||
// 为何是「引导」而非自动跳:iOS 不官方支持、也不可靠地允许 share 扩展自动拉起宿主 app(旧单参
|
||||
// openURL: 自 iOS 10 弃用、iOS 18+ 已成空壳)。故改为明确的用户操作:点「打开」尝试唤起(响应链经
|
||||
// IMP 调现代 open,最大努力)。无论唤起成败,文件已落 App Group 收件箱,用户回到 app 时
|
||||
// CDropApp.scenePhase(.active)→loadPendingShares 即弹发送预览,故分享始终能用、不丢文件。
|
||||
final class ShareViewController: UIViewController
|
||||
{
|
||||
private let model = ShareModel()
|
||||
private var started = false
|
||||
// 已暂存文件的目标 URL(取消时据此删掉收件箱里的它们,不残留)。
|
||||
private var stagedURLs: [URL] = []
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
let host = UIHostingController(
|
||||
rootView: ShareGuideView(
|
||||
model: model,
|
||||
onOpen: { [weak self] in self?.openAndFinish() },
|
||||
onCancel: { [weak self] in self?.cancelAndFinish() }))
|
||||
addChild(host)
|
||||
host.view.frame = view.bounds
|
||||
host.view.autoresizingMask = [ .flexibleWidth, .flexibleHeight ]
|
||||
view.addSubview(host.view)
|
||||
host.didMove(toParent: self)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewDidAppear(animated)
|
||||
Task { await processAndClose() }
|
||||
guard !started else { return } // viewDidAppear 可能多次;只处理一次
|
||||
started = true
|
||||
Task { await stageAll() }
|
||||
}
|
||||
|
||||
private func processAndClose() async
|
||||
private func stageAll() async
|
||||
{
|
||||
let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
|
||||
var staged = 0
|
||||
var urls: [URL] = []
|
||||
for item in items
|
||||
{
|
||||
for provider in item.attachments ?? []
|
||||
{
|
||||
if await stage(provider) { staged += 1 }
|
||||
if let dest = await stage(provider) { urls.append(dest) }
|
||||
}
|
||||
}
|
||||
let ctx = extensionContext
|
||||
guard staged > 0, let url = URL(string: "cdrop://share") else
|
||||
{
|
||||
ctx?.completeRequest(returningItems: nil)
|
||||
return
|
||||
}
|
||||
// 先唤起主 app,等 open 回调拿到结果再 completeRequest——否则立即 completeRequest 会拆掉
|
||||
// 扩展、self 被释放,open 失败时的 openViaResponder 兜底永不触发(审查 L8)。捕获 ctx 使
|
||||
// 扩展存活到回调。
|
||||
ctx?.open(url)
|
||||
{ [weak self] ok in
|
||||
if !ok { self?.openViaResponder(url) }
|
||||
ctx?.completeRequest(returningItems: nil)
|
||||
stagedURLs = urls
|
||||
await MainActor.run { model.fileCount = urls.count }
|
||||
}
|
||||
|
||||
// 「打开 Commilitia Drop」:尝试唤起主 app(最大努力),延后 completeRequest 给系统留出拉起窗口
|
||||
// (立即 completeRequest 会拆掉扩展进程、可能取消尚未真正开始的唤起)。文件留在收件箱供主 app 接手。
|
||||
private func openAndFinish()
|
||||
{
|
||||
if let url = URL(string: "cdrop://share") { openViaResponder(url) }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35)
|
||||
{ [weak self] in
|
||||
self?.extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 把单个附件落到 App Group 收件箱。用 loadFileRepresentation 取系统给的临时文件表示
|
||||
// (文件 / 图片 / 文档统一走此路,且大文件不整体进内存),在回调内同步拷到收件箱
|
||||
// (系统会在回调返回后删临时文件)。
|
||||
private func stage(_ provider: NSItemProvider) async -> Bool
|
||||
// 「取消」:删掉刚暂存进收件箱的文件(不残留、避免下次开 app 误弹预览),返回来源 app。
|
||||
private func cancelAndFinish()
|
||||
{
|
||||
for url in stagedURLs
|
||||
{
|
||||
try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
|
||||
}
|
||||
extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
|
||||
// 把单个附件落到 App Group 收件箱,返回落地 URL。优先文件表示(大文件不整体进内存),失败退数据表示
|
||||
// (仅提供数据、无文件表示的提供者)。两路都失败返回 nil。
|
||||
private func stage(_ provider: NSItemProvider) async -> URL?
|
||||
{
|
||||
if let u = await stageFile(provider) { return u }
|
||||
return await stageData(provider)
|
||||
}
|
||||
|
||||
private func stageFile(_ provider: NSItemProvider) async -> URL?
|
||||
{
|
||||
let typeId = provider.registeredTypeIdentifiers.first ?? UTType.item.identifier
|
||||
return await withCheckedContinuation
|
||||
{ cont in
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier)
|
||||
provider.loadFileRepresentation(forTypeIdentifier: typeId)
|
||||
{ url, _ in
|
||||
guard let url, let dest = AppGroup.stagedDestination(originalName: url.lastPathComponent)
|
||||
else { cont.resume(returning: false); return }
|
||||
else { cont.resume(returning: nil); return }
|
||||
do
|
||||
{
|
||||
try FileManager.default.copyItem(at: url, to: dest)
|
||||
cont.resume(returning: true)
|
||||
cont.resume(returning: dest)
|
||||
}
|
||||
catch
|
||||
{
|
||||
cont.resume(returning: false)
|
||||
cont.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// open 失败时的兜底:沿响应链找 UIApplication 唤起主 app(个别系统版本扩展直接 open 不生效)。
|
||||
private func stageData(_ provider: NSItemProvider) async -> URL?
|
||||
{
|
||||
let typeId = provider.registeredTypeIdentifiers.first ?? UTType.data.identifier
|
||||
let name = suggestedName(provider, typeId: typeId)
|
||||
return await withCheckedContinuation
|
||||
{ cont in
|
||||
provider.loadDataRepresentation(forTypeIdentifier: typeId)
|
||||
{ data, _ in
|
||||
guard let data, let dest = AppGroup.stagedDestination(originalName: name)
|
||||
else { cont.resume(returning: nil); return }
|
||||
do
|
||||
{
|
||||
try data.write(to: dest)
|
||||
cont.resume(returning: dest)
|
||||
}
|
||||
catch
|
||||
{
|
||||
cont.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 数据兜底路径的文件名:优先提供者建议名,否则按类型首选扩展名拼一个。
|
||||
private func suggestedName(_ provider: NSItemProvider, typeId: String) -> String
|
||||
{
|
||||
if let s = provider.suggestedName, !s.isEmpty
|
||||
{
|
||||
if (s as NSString).pathExtension.isEmpty,
|
||||
let ext = UTType(typeId)?.preferredFilenameExtension
|
||||
{
|
||||
return "\(s).\(ext)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
let ext = UTType(typeId)?.preferredFilenameExtension ?? "dat"
|
||||
return "shared-\(UUID().uuidString.prefix(8)).\(ext)"
|
||||
}
|
||||
|
||||
// 沿响应链找 UIApplication 实例,经 IMP 调现代 open(_:options:completionHandler:)(绕过扩展对
|
||||
// UIApplication 的编译期可用性限制)。找不到则退旧单参 openURL:(iOS 18+ 多为空壳,仅历史兜底)。
|
||||
private func openViaResponder(_ url: URL)
|
||||
{
|
||||
let modern = NSSelectorFromString("openURL:options:completionHandler:")
|
||||
var responder: UIResponder? = self
|
||||
let sel = NSSelectorFromString("openURL:")
|
||||
while let r = responder
|
||||
{
|
||||
if r.responds(to: sel) { _ = r.perform(sel, with: url); return }
|
||||
if r.responds(to: modern), let app = r as? NSObject,
|
||||
let method = class_getInstanceMethod(type(of: app), modern)
|
||||
{
|
||||
typealias OpenFn = @convention(c) (NSObject, Selector, NSURL, NSDictionary, Any?) -> Void
|
||||
let fn = unsafeBitCast(method_getImplementation(method), to: OpenFn.self)
|
||||
fn(app, modern, url as NSURL, NSDictionary(), nil)
|
||||
return
|
||||
}
|
||||
responder = r.next
|
||||
}
|
||||
let legacy = NSSelectorFromString("openURL:")
|
||||
responder = self
|
||||
while let r = responder
|
||||
{
|
||||
if r.responds(to: legacy) { _ = r.perform(legacy, with: url); return }
|
||||
responder = r.next
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user