349d94aa8f
- 原生数据面 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 漂移行)
175 lines
7.0 KiB
Swift
175 lines
7.0 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
import UniformTypeIdentifiers
|
||
|
||
// 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)
|
||
guard !started else { return } // viewDidAppear 可能多次;只处理一次
|
||
started = true
|
||
Task { await stageAll() }
|
||
}
|
||
|
||
private func stageAll() async
|
||
{
|
||
let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
|
||
var urls: [URL] = []
|
||
for item in items
|
||
{
|
||
for provider in item.attachments ?? []
|
||
{
|
||
if let dest = await stage(provider) { urls.append(dest) }
|
||
}
|
||
}
|
||
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 误弹预览),返回来源 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: typeId)
|
||
{ url, _ in
|
||
guard let url, let dest = AppGroup.stagedDestination(originalName: url.lastPathComponent)
|
||
else { cont.resume(returning: nil); return }
|
||
do
|
||
{
|
||
try FileManager.default.copyItem(at: url, to: dest)
|
||
cont.resume(returning: dest)
|
||
}
|
||
catch
|
||
{
|
||
cont.resume(returning: nil)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
while let r = responder
|
||
{
|
||
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
|
||
}
|
||
}
|
||
}
|