eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import SwiftUI
3
4// Fetches + decrypts an encrypted image blob (an attachment photo, a profile or
5// group avatar) into a PlatformImage, caching by blob id so scrolling and
6// re-renders don't refetch. GET /api/blob/<id> returns opaque `iv||ct||tag`
7// bytes, opened on-device with the AES key carried in the AvatarSource — the
8// instance key for a member photo, the thread key for a group photo or a
9// message attachment. The server never sees a key or a pixel.
10//
11// Everything expensive — the fetch, the AES-GCM open, and crucially the
12// bitmap DECODE (decodeImageForDisplay) — runs detached, so a photo is
13// render-ready before the main thread ever touches it. `maxPixel` picks the
14// decode class (feedImageMaxPixel / avatarImageMaxPixel / nil for full) and
15// keys the cache, so a feed thumbnail and the lightbox's full-fidelity pass
16// coexist without evicting each other.
17@MainActor
18public final class BlobImageLoader {
19 public static let shared = BlobImageLoader()
20
21 private var cache: [String: PlatformImage] = [:] // key -> decoded image
22 private var order: [String] = [] // LRU, oldest first
23 private var totalCost = 0 // decoded bytes (px*4)
24 private let maxCost = 96 << 20 // ~96 MB of pixels
25 private var inFlight: [String: Task<PlatformImage?, Never>] = [:]
26 private let session = URLSession(configuration: .default)
27 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
28 private var pressureSource: DispatchSourceMemoryPressure?
29 #endif
30
31 private init() {
32 // Under memory pressure the cache is the first thing to give back —
33 // visible rows keep their own references, everything else re-decodes
34 // on demand. (In an app extension the UIKit notification never fires;
35 // the extension's own tight memory cap is the real limiter there.)
36 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
37 let source = DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical],
38 queue: .main)
39 source.setEventHandler { [weak self] in
40 MainActor.assumeIsolated { self?.purge() }
41 }
42 source.resume()
43 pressureSource = source
44 #else
45 // The raw name rather than UIApplication.didReceiveMemoryWarningNotification
46 // keeps LChatCore free of app-only UIKit API (the NSE and share
47 // extension link this package).
48 NotificationCenter.default.addObserver(
49 forName: Notification.Name("UIApplicationDidReceiveMemoryWarningNotification"),
50 object: nil, queue: .main
51 ) { [weak self] _ in
52 MainActor.assumeIsolated { self?.purge() }
53 }
54 #endif
55 }
56
57 private func purge() {
58 cache.removeAll()
59 order.removeAll()
60 totalCost = 0
61 }
62
63 private static func key(_ blobID: String, _ maxPixel: Int?) -> String {
64 maxPixel.map { "\(blobID)@\($0)" } ?? blobID
65 }
66
67 /// A blob already decoded in memory at this size class, if any — lets a
68 /// view render it with no placeholder flash on re-render.
69 public func cached(_ blobID: String, maxPixel: Int? = nil) -> PlatformImage? {
70 let key = Self.key(blobID, maxPixel)
71 if let img = cache[key] { touch(key); return img }
72 return nil
73 }
74
75 // Cost-capped LRU, like BlobDataCache next door: an uncapped dictionary is
76 // a slow-motion jetsam kill on a photo-heavy scroll-back.
77 private func touch(_ key: String) {
78 order.removeAll { $0 == key }
79 order.append(key)
80 }
81
82 private func store(_ img: PlatformImage, for key: String) {
83 cache[key] = img
84 touch(key)
85 totalCost += Self.cost(img)
86 while totalCost > maxCost, let oldest = order.first, oldest != key {
87 order.removeFirst()
88 if let evicted = cache.removeValue(forKey: oldest) {
89 totalCost -= Self.cost(evicted)
90 }
91 }
92 }
93
94 // Decoded bitmap cost in PIXELS — size is in points, and a 3x image
95 // counted in points under-counts 9-fold, which silently turned the 96 MB
96 // cap into ~860 MB of real pixels.
97 private static func cost(_ img: PlatformImage) -> Int {
98 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
99 let px = img.representations.first.map { $0.pixelsWide * $0.pixelsHigh }
100 ?? Int(img.size.width * img.size.height)
101 return px * 4
102 #else
103 return Int(img.size.width * img.scale * img.size.height * img.scale) * 4
104 #endif
105 }
106
107 /// The decrypted image for a source, fetched + decoded once and cached.
108 /// Concurrent callers for the same blob+class share a single fetch.
109 /// Returns nil on any failure (missing blob, wrong/absent key, undecodable
110 /// image); the caller keeps its placeholder.
111 public func image(_ source: AvatarSource, maxPixel: Int? = nil) async -> PlatformImage? {
112 let key = Self.key(source.blobID, maxPixel)
113 if let img = cache[key] { touch(key); return img }
114 if let task = inFlight[key] { return await task.value }
115
116 let session = self.session
117 let span = Perf.begin("image.load", slowMs: 1000)
118 // Detached so the fetch + AES-GCM open + bitmap decode stay off the
119 // main thread; only the cache bookkeeping below runs on the main actor.
120 let task = Task.detached { () -> PlatformImage? in
121 guard let url = source.url,
122 let (data, resp) = try? await session.data(from: url),
123 let http = resp as? HTTPURLResponse, http.statusCode == 200,
124 let groupKey = try? GroupKey(base64url: source.keyB64),
125 let plain = try? groupKey.decryptBytes(data),
126 let img = decodeImageForDisplay(plain, maxPixel: maxPixel) else { return nil }
127 return img
128 }
129 inFlight[key] = task
130 let img = await task.value
131 // A nil image is a failure, not a slow load — don't record it.
132 span.end(record: img != nil)
133 inFlight.removeValue(forKey: key)
134 if let img { store(img, for: key) }
135 return img
136 }
137}
138