| 1 | import ImageIO |
| 2 | import QuickLook |
| 3 | import UIKit |
| 4 | import UniformTypeIdentifiers |
| 5 | |
| 6 | private final class FilePreviewItem: NSObject, QLPreviewItem { |
| 7 | var previewItemURL: URL? |
| 8 | var previewItemTitle: String? |
| 9 | } |
| 10 | |
| 11 | private final class FilePreviewController: QLPreviewController { |
| 12 | var onAppear: (() -> Void)? |
| 13 | |
| 14 | override func viewDidAppear(_ animated: Bool) { |
| 15 | super.viewDidAppear(animated) |
| 16 | onAppear?() |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | /// Quick Look owns image and document controls, zooming, and source-view transitions. |
| 21 | final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, |
| 22 | QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate { |
| 23 | let identifier: String |
| 24 | private var controller: UIViewController? |
| 25 | private let completion: (Error?) -> Void |
| 26 | private weak var sources: T3PresentationSources? |
| 27 | private let sourceIdentifier: String |
| 28 | private var items: [FilePreviewItem] = [] |
| 29 | private var loading: Task<Void, Never>? |
| 30 | private var dismissRequested = false |
| 31 | private var finished = false |
| 32 | |
| 33 | init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) { |
| 34 | self.identifier = identifier |
| 35 | self.sources = sources |
| 36 | self.sourceIdentifier = sourceIdentifier |
| 37 | self.completion = completion |
| 38 | super.init() |
| 39 | } |
| 40 | |
| 41 | func present(url: URL, title: String, from presenter: UIViewController) { |
| 42 | present(urls: [url], titles: [title], selectedIndex: 0, from: presenter) |
| 43 | } |
| 44 | |
| 45 | func present(urls: [URL], titles: [String], selectedIndex: Int, from presenter: UIViewController) { |
| 46 | loading = Task { @MainActor [self] in |
| 47 | do { |
| 48 | for (index, url) in urls.enumerated() { |
| 49 | let title = titles[index] |
| 50 | let file = try await Self.prepareFile(url: url, title: title) |
| 51 | guard !finished, !Task.isCancelled else { |
| 52 | try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) |
| 53 | return |
| 54 | } |
| 55 | // Quick Look knows which formats it renders; refuse before presenting so the caller |
| 56 | // can fall back instead of showing an "unsupported format" page. |
| 57 | guard QLPreviewController.canPreview(file as NSURL) else { |
| 58 | try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) |
| 59 | throw NSError( |
| 60 | domain: "T3NativePresentation", |
| 61 | code: 3, |
| 62 | userInfo: [NSLocalizedDescriptionKey: "This file type cannot be previewed on this device."] |
| 63 | ) |
| 64 | } |
| 65 | let item = FilePreviewItem() |
| 66 | item.previewItemURL = file |
| 67 | item.previewItemTitle = title |
| 68 | items.append(item) |
| 69 | } |
| 70 | let preview = FilePreviewController() |
| 71 | preview.delegate = self |
| 72 | preview.dataSource = self |
| 73 | preview.currentPreviewItemIndex = selectedIndex |
| 74 | preview.onAppear = { [weak self] in self?.resumePendingDismissal() } |
| 75 | controller = preview |
| 76 | presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in |
| 77 | resumePendingDismissal() |
| 78 | } |
| 79 | preview.presentationController?.delegate = self |
| 80 | } catch { |
| 81 | finish(error: error) |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func dismiss() { |
| 87 | dismissRequested = true |
| 88 | loading?.cancel() |
| 89 | guard !finished else { return } |
| 90 | guard let controller else { finish(); return } |
| 91 | // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal. |
| 92 | // Starting a second modal transition while UIKit is settling the first can strand it. |
| 93 | guard !controller.isBeingPresented, !controller.isBeingDismissed else { return } |
| 94 | controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() } |
| 95 | } |
| 96 | |
| 97 | private func resumePendingDismissal() { |
| 98 | // Appearance callbacks run before UIKit has cleared the current transition. |
| 99 | DispatchQueue.main.async { [weak self] in |
| 100 | if self?.dismissRequested == true { self?.dismiss() } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func numberOfPreviewItems(in controller: QLPreviewController) -> Int { items.count } |
| 105 | |
| 106 | func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { |
| 107 | items[index] |
| 108 | } |
| 109 | |
| 110 | func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? { |
| 111 | guard !UIAccessibility.isReduceMotionEnabled else { return nil } |
| 112 | return sources?.view(for: sourceIdentifier) |
| 113 | } |
| 114 | |
| 115 | func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem, |
| 116 | inSourceView view: AutoreleasingUnsafeMutablePointer<UIView?>) -> CGRect { |
| 117 | guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero } |
| 118 | view.pointee = source |
| 119 | return source.bounds |
| 120 | } |
| 121 | |
| 122 | func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() } |
| 123 | |
| 124 | func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() } |
| 125 | |
| 126 | private func finish(error: Error? = nil) { |
| 127 | guard !finished else { return } |
| 128 | finished = true |
| 129 | loading?.cancel() |
| 130 | loading = nil |
| 131 | for item in items { |
| 132 | if let file = item.previewItemURL { |
| 133 | try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) |
| 134 | } |
| 135 | } |
| 136 | items.removeAll() |
| 137 | DispatchQueue.main.async { [completion] in completion(error) } |
| 138 | } |
| 139 | |
| 140 | /// Copy original bytes so preview and sharing do not mutate a draft or workspace file. |
| 141 | nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL { |
| 142 | try Task.checkCancellation() |
| 143 | let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)") |
| 144 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) |
| 145 | do { |
| 146 | let download = directory.appendingPathComponent("original") |
| 147 | if url.isFileURL { |
| 148 | try FileManager.default.copyItem(at: url, to: download) |
| 149 | } else if url.scheme == "data" { |
| 150 | try Data(contentsOf: url).write(to: download, options: .atomic) |
| 151 | } else { |
| 152 | guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else { |
| 153 | throw URLError(.unsupportedURL) |
| 154 | } |
| 155 | let (temporaryFile, response) = try await URLSession.shared.download(from: url) |
| 156 | guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { |
| 157 | throw URLError(.badServerResponse) |
| 158 | } |
| 159 | try FileManager.default.moveItem(at: temporaryFile, to: download) |
| 160 | } |
| 161 | try Task.checkCancellation() |
| 162 | // Pictures and PDFs are identified by content so a misnamed file still opens correctly. |
| 163 | // Anything else keeps its own extension: Quick Look decides from that whether it can |
| 164 | // render the document (Office, iWork, RTF and more) and refuses before we present. |
| 165 | let filename = URL(fileURLWithPath: title).lastPathComponent as NSString |
| 166 | let originalExtension = filename.pathExtension |
| 167 | var type: UTType? |
| 168 | if let image = CGImageSourceCreateWithURL(download as CFURL, nil), |
| 169 | CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image), |
| 170 | let detectedType = UTType(imageType as String) { |
| 171 | type = detectedType |
| 172 | } else if CGPDFDocument(download as CFURL) != nil { |
| 173 | type = .pdf |
| 174 | } else if originalExtension.lowercased() == "svg" { |
| 175 | type = .svg |
| 176 | } |
| 177 | let fileExtension: String |
| 178 | if let type { |
| 179 | fileExtension = UTType(filenameExtension: originalExtension) == type |
| 180 | ? originalExtension : type.preferredFilenameExtension ?? originalExtension |
| 181 | } else { |
| 182 | fileExtension = originalExtension |
| 183 | } |
| 184 | let stem = filename.deletingPathExtension |
| 185 | var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_") |
| 186 | while name.utf8.count > 200 { name.removeLast() } |
| 187 | let baseName = name.isEmpty ? "Preview" : name |
| 188 | let file = directory.appendingPathComponent( |
| 189 | fileExtension.isEmpty ? baseName : "\(baseName).\(fileExtension)") |
| 190 | try FileManager.default.moveItem(at: download, to: file) |
| 191 | return file |
| 192 | } catch { |
| 193 | try? FileManager.default.removeItem(at: directory) |
| 194 | throw error |
| 195 | } |
| 196 | } |
| 197 | } |