oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import ImageIO
3import Observation
4import RastrilloApple
5#if os(macOS)
6import AppKit
7typealias DocsPlatformImage = NSImage
8#else
9import UIKit
10typealias DocsPlatformImage = UIImage
11#endif
12
13private 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