| 1 | import CryptoKit |
| 2 | import 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. |
| 12 | public 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 | |