eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import CryptoKit
2import Foundation
3
4// Decrypted, downscaled profile photos shared with the notification extension:
5// the NSE has no member roster, no instance key and a ~30s budget, so it can't
6// fetch-and-decrypt a sender's photo itself. Instead the app parks a small
7// plaintext thumbnail here (App Group container, on-device, OS-protected —
8// exactly like the messages MessageStore files next door) whenever a
9// ThreadStore refreshes, and the extension just reads bytes to hand
10// INSendMessageIntent a sender image. The companion of SharedThreadKeys: keys
11// in the keychain, faces in the group container.
12public enum SharedAvatarCache {
13 // Thumbnails only need to fill the ~55pt notification circle; 256px covers
14 // 3x displays and keeps every file a few KB.
15 static let thumbMaxDim = 256.0
16
17 private static let appGroupID = "group.com.elevenmessenger.messenger"
18
19 /// Test seam: points the cache at a temp directory instead of the group
20 /// container.
21 static var directoryOverride: URL?
22
23 static func directory() -> URL {
24 if let directoryOverride { return directoryOverride }
25 let fm = FileManager.default
26 // Same fallback shape as MessageStore: ad-hoc dev signing can't mint
27 // the group entitlement, so each process keeps a private cache (the
28 // extension then just shows the initials-free banner it always has).
29 let base = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
30 ?? (fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
31 ?? URL(fileURLWithPath: NSTemporaryDirectory()))
32 .appendingPathComponent("lchat", isDirectory: true)
33 return base.appendingPathComponent("avatars", isDirectory: true)
34 }
35
36 // One member = one thumbnail + one sidecar recording which encrypted blob
37 // it came from (so a refresh only refetches when the photo changed). The
38 // filename hashes origin|uid — filesystem-safe, and two instances' members
39 // can never collide.
40 static func fileStem(origin: String, uid: String) -> String {
41 let digest = SHA256.hash(data: Data("\(origin)|\(uid)".utf8))
42 return digest.map { String(format: "%02x", $0) }.joined().prefix(32).description
43 }
44
45 private static func thumbURL(_ dir: URL, origin: String, uid: String) -> URL {
46 dir.appendingPathComponent(fileStem(origin: origin, uid: uid) + ".jpg")
47 }
48 private static func sourceURL(_ dir: URL, origin: String, uid: String) -> URL {
49 dir.appendingPathComponent(fileStem(origin: origin, uid: uid) + ".src")
50 }
51
52 /// The cached thumbnail's JPEG bytes for a member, or nil — the extension's
53 /// read side. Never fetches.
54 public static func imageData(origin: String, uid: String) -> Data? {
55 try? Data(contentsOf: thumbURL(directory(), origin: origin, uid: uid))
56 }
57
58 /// Bring the cache in line with a roster: fetch + decrypt + downscale any
59 /// member photo that's new or changed, drop entries for members whose photo
60 /// is gone. Runs off-main (network + AES + JPEG per changed photo); a
61 /// failed fetch just leaves that member's old thumbnail (or nothing) in
62 /// place — the banner falls back to a monogram, never an error.
63 public static func sync(origin: String, avatars: [(uid: String, source: AvatarSource?)]) async {
64 let fm = FileManager.default
65 let dir = directory()
66 try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
67 for (uid, source) in avatars {
68 let thumb = thumbURL(dir, origin: origin, uid: uid)
69 let src = sourceURL(dir, origin: origin, uid: uid)
70 guard let source else {
71 try? fm.removeItem(at: thumb)
72 try? fm.removeItem(at: src)
73 continue
74 }
75 // Unchanged blob + file still present → nothing to do (the
76 // overwhelmingly common case on every refresh).
77 if (try? String(contentsOf: src, encoding: .utf8)) == source.blobID,
78 fm.fileExists(atPath: thumb.path) { continue }
79 guard let url = source.url,
80 let (cipher, resp) = try? await URLSession.shared.data(from: url),
81 (resp as? HTTPURLResponse)?.statusCode == 200,
82 let key = try? GroupKey(base64url: source.keyB64),
83 let plain = try? key.decryptBytes(cipher),
84 let jpeg = processImage(plain, maxDim: thumbMaxDim)?.bytes
85 else { continue }
86 store(jpeg: jpeg, blobID: source.blobID, origin: origin, uid: uid)
87 }
88 }
89
90 /// Write one thumbnail + its source marker (split out so tests can seed the
91 /// cache without a network).
92 static func store(jpeg: Data, blobID: String, origin: String, uid: String) {
93 let dir = directory()
94 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
95 try? jpeg.write(to: thumbURL(dir, origin: origin, uid: uid), options: .atomic)
96 try? Data(blobID.utf8).write(to: sourceURL(dir, origin: origin, uid: uid), options: .atomic)
97 }
98}
99