| 1 | import Foundation |
| 2 | import ImageIO |
| 3 | import Observation |
| 4 | import RastrilloApple |
| 5 | #if os(macOS) |
| 6 | import AppKit |
| 7 | typealias DocsPlatformImage = NSImage |
| 8 | #else |
| 9 | import UIKit |
| 10 | typealias DocsPlatformImage = UIImage |
| 11 | #endif |
| 12 | |
| 13 | private final class ImageRedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable { |
| 14 | func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, |
| 15 | newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { |
| 16 | guard let url = request.url, ["http", "https"].contains(url.scheme ?? ""), url.user == nil, url.password == nil else { |
| 17 | completionHandler(nil); return |
| 18 | } |
| 19 | completionHandler(request) |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | /// Document image bytes live only in this screen's memory. External hosts receive |
| 24 | /// no app credentials; same-origin protected assets use the ordinary client. |
| 25 | @MainActor @Observable final class DocsImageStore { |
| 26 | private(set) var images: [String: DocsPlatformImage] = [:] |
| 27 | private var generation = UUID() |
| 28 | private var documentURL: URL? |
| 29 | private let session: URLSession = { |
| 30 | let config = URLSessionConfiguration.ephemeral |
| 31 | config.httpShouldSetCookies = false |
| 32 | config.urlCache = nil |
| 33 | config.timeoutIntervalForRequest = 20 |
| 34 | return URLSession(configuration: config, delegate: ImageRedirectGuard(), delegateQueue: nil) |
| 35 | }() |
| 36 | static func sources(in projection: CRDTProjection) -> [String] { |
| 37 | var found = Set<String>() |
| 38 | func visit(_ node: CRDTNode) { |
| 39 | if node.type == "image", let source = node.attrs?["src"] { found.insert(source) } |
| 40 | for child in node.children ?? [] { visit(child) } |
| 41 | } |
| 42 | for node in projection.children { visit(node) } |
| 43 | return found.sorted() |
| 44 | } |
| 45 | func images(forDocumentPath path: String, client: NativeHTTP) -> [String: DocsPlatformImage] { |
| 46 | documentURL == URL(string: path, relativeTo: client.origin)?.absoluteURL ? images : [:] |
| 47 | } |
| 48 | static func resolvedURL(source: String, documentPath: String, origin: URL) -> URL? { |
| 49 | guard let document = URL(string: documentPath, relativeTo: origin)?.absoluteURL else { return nil } |
| 50 | return URL(string: source, relativeTo: document)?.absoluteURL |
| 51 | } |
| 52 | func load(sources: [String], client: NativeHTTP, documentPath: String = "/") async { |
| 53 | let current = UUID(); generation = current |
| 54 | let document = URL(string: documentPath, relativeTo: client.origin)?.absoluteURL |
| 55 | if documentURL != document { images.removeAll(); documentURL = document } |
| 56 | images = images.filter { sources.contains($0.key) } |
| 57 | for source in sources where images[source] == nil { |
| 58 | guard !Task.isCancelled, generation == current else { return } |
| 59 | do { |
| 60 | let data: Data |
| 61 | if source.hasPrefix("data:image/"), let separator = source.firstIndex(of: ","), source[..<separator].hasSuffix(";base64") { |
| 62 | guard source.utf8.count <= 12 * 1024 * 1024, |
| 63 | let decoded = Data(base64Encoded: String(source[source.index(after: separator)...])) else { continue } |
| 64 | data = decoded |
| 65 | } else { |
| 66 | guard let url = Self.resolvedURL(source: source, documentPath: documentPath, origin: client.origin), |
| 67 | ["https", "http"].contains(url.scheme ?? ""), url.user == nil, url.password == nil else { continue } |
| 68 | if url.scheme == client.origin.scheme && url.host == client.origin.host && url.port == client.origin.port { |
| 69 | guard let parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { continue } |
| 70 | let path = parts.percentEncodedPath + (parts.percentEncodedQuery.map { "?" + $0 } ?? "") |
| 71 | let response = try await client.response(path, maxBytes: 8 * 1024 * 1024) |
| 72 | guard response.1 == 200 else { continue } |
| 73 | data = response.0 |
| 74 | } else { |
| 75 | let (bytes, response) = try await session.bytes(from: url) |
| 76 | defer { bytes.task.cancel() } |
| 77 | guard (response as? HTTPURLResponse)?.statusCode == 200 else { continue } |
| 78 | var received = Data() |
| 79 | for try await byte in bytes { |
| 80 | try Task.checkCancellation() |
| 81 | guard received.count < 8 * 1024 * 1024 else { throw CRDTReplica.Failure.rejected } |
| 82 | received.append(byte) |
| 83 | } |
| 84 | data = received |
| 85 | } |
| 86 | } |
| 87 | guard !Task.isCancelled, generation == current, let image = DocsAttachments.decode(data) else { continue } |
| 88 | images[source] = image |
| 89 | } catch { if Task.isCancelled { return } } |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | @MainActor enum DocsAttachments { |
| 95 | static func decode(_ data: Data) -> DocsPlatformImage? { |
| 96 | guard let source = CGImageSourceCreateWithData(data as CFData, nil), |
| 97 | let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], |
| 98 | let width = properties[kCGImagePropertyPixelWidth] as? Int, |
| 99 | let height = properties[kCGImagePropertyPixelHeight] as? Int, |
| 100 | width > 0, height > 0, width <= 16_384, height <= 16_384, |
| 101 | width * height <= 40_000_000, |
| 102 | let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ |
| 103 | kCGImageSourceCreateThumbnailFromImageAlways: true, |
| 104 | kCGImageSourceThumbnailMaxPixelSize: 2048, |
| 105 | kCGImageSourceCreateThumbnailWithTransform: true, |
| 106 | ] as CFDictionary) else { return nil } |
| 107 | #if os(macOS) |
| 108 | return NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) |
| 109 | #else |
| 110 | return UIImage(cgImage: image) |
| 111 | #endif |
| 112 | } |
| 113 | static func image(_ image: DocsPlatformImage?, alt: String?, maxWidth: CGFloat = 440) -> NSAttributedString { |
| 114 | let attachment = NSTextAttachment() |
| 115 | attachment.image = image |
| 116 | if let image { |
| 117 | let size = image.size |
| 118 | let scale = min(1, maxWidth / max(size.width, 1)) |
| 119 | attachment.bounds = CGRect(x: 0, y: 0, width: size.width * scale, height: size.height * scale) |
| 120 | } else { |
| 121 | #if os(macOS) |
| 122 | attachment.image = NSImage(systemSymbolName: "photo", accessibilityDescription: alt) |
| 123 | #else |
| 124 | attachment.image = UIImage(systemName: "photo") |
| 125 | #endif |
| 126 | attachment.bounds = CGRect(x: 0, y: 0, width: 24, height: 24) |
| 127 | } |
| 128 | #if os(macOS) |
| 129 | attachment.image?.accessibilityDescription = alt |
| 130 | #endif |
| 131 | return NSAttributedString(attachment: attachment) |
| 132 | } |
| 133 | static func rule(width: CGFloat = 440) -> NSAttributedString { |
| 134 | let attachment = NSTextAttachment() |
| 135 | #if os(macOS) |
| 136 | attachment.image = NSImage(size: NSSize(width: width, height: 1), flipped: false) { rect in |
| 137 | NSColor.separatorColor.setFill(); rect.fill(); return true |
| 138 | } |
| 139 | #else |
| 140 | attachment.image = UIGraphicsImageRenderer(size: CGSize(width: width, height: 1)).image { context in |
| 141 | UIColor.separator.setFill(); context.fill(CGRect(x: 0, y: 0, width: width, height: 1)) |
| 142 | } |
| 143 | #endif |
| 144 | attachment.bounds = CGRect(x: 0, y: 4, width: width, height: 1) |
| 145 | return NSAttributedString(attachment: attachment) |
| 146 | } |
| 147 | } |
| 148 | |