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: "commilitia-drop://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
|
||
}
|
||
}
|
||
}
|