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 } } }