import UIKit import UniformTypeIdentifiers import AVFoundation // Datenmodell muss byte-für-byte zu dem passen, was // ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert. private enum SharedMediaType: String, Codable { case image, video, text, file, url } private struct SharedMediaFile: Codable { let path: String let mimeType: String? let thumbnail: String? let duration: Double? let message: String? let type: SharedMediaType } final class ShareViewController: UIViewController { // Schlüssel sind die, die das Plugin liest. private let userDefaultsKey = "ShareKey" private let userDefaultsMessageKey = "ShareMessageKey" private let urlSchemePrefix = "ShareMedia" private var appGroupId = "" private var hostBundleId = "" override func viewDidLoad() { super.viewDidLoad() resolveIds() setupPlaceholderUI() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) processAttachments() } private func resolveIds() { let extBundleId = Bundle.main.bundleIdentifier ?? "" if let lastDot = extBundleId.lastIndex(of: ".") { hostBundleId = String(extBundleId[.. Void) { let order: [(String, SharedMediaType)] = [ (UTType.image.identifier, .image), (UTType.movie.identifier, .video), (UTType.text.identifier, .text), (UTType.fileURL.identifier, .file), (UTType.url.identifier, .url), (UTType.data.identifier, .file), ] for (typeId, kind) in order where provider.hasItemConformingToTypeIdentifier(typeId) { // Konkreter Typ (z. B. public.png statt public.image) für Dateiendung // und MIME-Type, falls die Quelle nur rohe Daten liefert. let concrete = concreteType(of: provider, conformingTo: typeId) provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in guard let self else { completion(nil); return } if error == nil, let file = self.toSharedFile(data: data, kind: kind, concrete: concrete, provider: provider) { completion(file) return } // Manche Quellen (z. B. der Screenshot-/Markup-Editor) liefern über // loadItem nichts Verwertbares oder eine URL außerhalb unserer // Sandbox. loadFileRepresentation stellt eine lesbare Kopie bereit. self.loadFileFallback(provider: provider, kind: kind, concrete: concrete, completion: completion) } return } completion(nil) } private func concreteType(of provider: NSItemProvider, conformingTo typeId: String) -> UTType? { guard let base = UTType(typeId) else { return nil } return provider.registeredTypeIdentifiers .compactMap { UTType($0) } .first { $0.conforms(to: base) } ?? base } private func loadFileFallback(provider: NSItemProvider, kind: SharedMediaType, concrete: UTType?, completion: @escaping (SharedMediaFile?) -> Void) { guard kind == .image || kind == .video || kind == .file, let concrete else { completion(nil) return } provider.loadFileRepresentation(forTypeIdentifier: concrete.identifier) { [weak self] url, _ in // Die URL ist nur innerhalb dieses Callbacks gültig — sofort kopieren. guard let self, let url, let dst = self.copyIntoAppGroup(src: url) else { completion(nil) return } let mime = self.mime(for: url) ?? concrete.preferredMIMEType let duration = kind == .video ? self.videoDurationMs(url: dst) : nil completion(SharedMediaFile(path: self.pathString(for: dst), mimeType: mime, thumbnail: nil, duration: duration, message: nil, type: kind)) } } private func toSharedFile(data: Any?, kind: SharedMediaType, concrete: UTType?, provider: NSItemProvider) -> SharedMediaFile? { switch kind { case .text: guard let s = data as? String else { return nil } return SharedMediaFile(path: s, mimeType: "text/plain", thumbnail: nil, duration: nil, message: nil, type: .text) case .url: guard let u = data as? URL else { return nil } return SharedMediaFile(path: u.absoluteString, mimeType: nil, thumbnail: nil, duration: nil, message: nil, type: .url) case .image: if let u = data as? URL, let dst = copyIntoAppGroup(src: u) { return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u), thumbnail: nil, duration: nil, message: nil, type: .image) } if let img = data as? UIImage, let dst = writePng(image: img) { return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png", thumbnail: nil, duration: nil, message: nil, type: .image) } if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) { return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType, thumbnail: nil, duration: nil, message: nil, type: .image) } return nil case .video: guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil } return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u), thumbnail: nil, duration: videoDurationMs(url: u), message: nil, type: .video) case .file: if let u = data as? URL, let dst = copyIntoAppGroup(src: u) { return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u), thumbnail: nil, duration: nil, message: nil, type: .file) } if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) { return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType, thumbnail: nil, duration: nil, message: nil, type: .file) } return nil } } // loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell. private let fileQueue = DispatchQueue(label: "share.files") // Ein Ordner pro Share: Gleichnamige Dateien (z. B. bearbeitete Fotos, alle // "FullSizeRender.jpg") überschrieben sich sonst gegenseitig, auch über // zwei Shares hinweg. Die App löscht den Ordner, sobald der Share erledigt ist. private lazy var shareDir: URL? = { guard let container = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { return nil } let root = container.appendingPathComponent("share_intent", isDirectory: true) sweepStaleShares(in: root) let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true) do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } catch { return nil } }() // Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.), // räumt sonst niemand weg — der App-Group-Container wird nie geleert. private func sweepStaleShares(in root: URL) { let fm = FileManager.default guard let entries = try? fm.contentsOfDirectory( at: root, includingPropertiesForKeys: [.creationDateKey]) else { return } let cutoff = Date().addingTimeInterval(-24 * 60 * 60) for entry in entries { let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate if let created, created < cutoff { try? fm.removeItem(at: entry) } } } private func copyIntoAppGroup(src: URL) -> URL? { fileQueue.sync { copyIntoShareDir(src: src) } } private func copyIntoShareDir(src: URL) -> URL? { let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent guard let dst = uniqueDestination(name: name) else { return nil } // Security-scoped URLs (z. B. aus "Dateien") sind sonst nicht lesbar. let scoped = src.startAccessingSecurityScopedResource() defer { if scoped { src.stopAccessingSecurityScopedResource() } } do { try FileManager.default.copyItem(at: src, to: dst) return dst } catch { return nil } } // Nur auf fileQueue aufrufen. private func uniqueDestination(name: String) -> URL? { guard let dir = shareDir else { return nil } let dst = dir.appendingPathComponent(name) // Mehrfachauswahl mit gleichem Namen innerhalb eines Shares. if FileManager.default.fileExists(atPath: dst.path) { return dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)") } return dst } private func writeData(_ data: Data, type: UTType?, provider: NSItemProvider) -> URL? { let ext = type?.preferredFilenameExtension ?? "bin" var base = (provider.suggestedName ?? UUID().uuidString) .replacingOccurrences(of: "/", with: "-") if (base as NSString).pathExtension.lowercased() == ext.lowercased() { base = (base as NSString).deletingPathExtension } // Namenswahl und Schreiben zusammen seriell, sonst können zwei Anhänge // mit gleichem suggestedName denselben Pfad bekommen. return fileQueue.sync { guard let dst = uniqueDestination(name: "\(base).\(ext)"), (try? data.write(to: dst)) != nil else { return nil } return dst } } private func writePng(image: UIImage) -> URL? { guard let data = image.pngData(), let dir = fileQueue.sync(execute: { shareDir }) else { return nil } let dst = dir.appendingPathComponent("\(UUID().uuidString).png") return (try? data.write(to: dst)) != nil ? dst : nil } // Das Plugin macht `path.replacingOccurrences(of: "file://", with: "")`, // also liefern wir absoluteString mit Prozent-Decoding — selbes Format wie // im Original-RSIShareViewController. private func pathString(for url: URL) -> String { url.absoluteString.removingPercentEncoding ?? url.absoluteString } private func mime(for url: URL) -> String? { UTType(filenameExtension: url.pathExtension)?.preferredMIMEType } private func videoDurationMs(url: URL) -> Double { (CMTimeGetSeconds(AVURLAsset(url: url).duration) * 1000).rounded() } private func saveAndRedirect(items: [SharedMediaFile]) { guard !items.isEmpty else { finish() return } let defaults = UserDefaults(suiteName: appGroupId) guard let encoded = try? JSONEncoder().encode(items) else { finish() return } defaults?.set(encoded, forKey: userDefaultsKey) defaults?.removeObject(forKey: userDefaultsMessageKey) let urlStr = "\(urlSchemePrefix)-\(hostBundleId):share" guard let url = URL(string: urlStr) else { finish() return } // Apple-DTS says Share Extensions are not officially allowed to open // URLs. Both `extensionContext.open` and the responder-chain trick // succeed in some iOS versions and fail silently in others, so we fire // both in parallel and let whichever Apple is honouring this release // win the race. extensionContext?.open(url, completionHandler: nil) var responder: UIResponder? = self while responder != nil { if let app = responder as? UIApplication { app.open(url, options: [:], completionHandler: nil) break } responder = responder?.next } // Brief window so the open request reaches the system before we tear // the extension down. DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.finish() } } private func finish() { extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } }