1import ImageIO
2import QuickLook
3import UIKit
4import UniformTypeIdentifiers
5
6private final class FilePreviewItem: NSObject, QLPreviewItem {
7 var previewItemURL: URL?
8 var previewItemTitle: String?
9}
10
11private 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.
21final 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}