diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift index a54b551..8092466 100644 --- a/ios/Share Extension/ShareViewController.swift +++ b/ios/Share Extension/ShareViewController.swift @@ -107,20 +107,58 @@ final class ShareViewController: UIViewController { (UTType.url.identifier, .url), (UTType.data.identifier, .file), ] - for (typeId, kind) in order { - if provider.hasItemConformingToTypeIdentifier(typeId) { - provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in - guard let self else { completion(nil); return } - if error != nil { completion(nil); return } - completion(self.toSharedFile(data: data, kind: kind)) + 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 } - 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 toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? { + 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 } @@ -139,6 +177,10 @@ final class ShareViewController: UIViewController { 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 } @@ -146,9 +188,15 @@ final class ShareViewController: UIViewController { thumbnail: nil, duration: videoDurationMs(url: u), message: nil, type: .video) case .file: - 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: nil, message: nil, type: .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 } } @@ -192,13 +240,11 @@ final class ShareViewController: UIViewController { } private func copyIntoShareDir(src: URL) -> URL? { - guard let dir = shareDir else { return nil } let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent - var dst = dir.appendingPathComponent(name) - // Mehrfachauswahl mit gleichem Namen innerhalb eines Shares. - if FileManager.default.fileExists(atPath: dst.path) { - dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)") - } + 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 @@ -207,6 +253,33 @@ final class ShareViewController: UIViewController { } } + // 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 }