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 Security
3
4// Keychain-backed JSON storage. The saved instances live here (not UserDefaults)
5// because each carries its user *private* key — the "key is load-bearing" rule
6// from CLAUDE.md.
7//
8// Every query sets kSecUseDataProtectionKeychain so Mac Catalyst uses the same
9// iOS-style data-protection keychain instead of the legacy file-based macOS
10// keychain. Without it the sandboxed Mac app's writes fail silently and nothing
11// persists — the saved-chats list comes back empty on every launch. (On iOS the
12// flag is a no-op; the data-protection keychain is already the only one.)
13public enum Keychain {
14 private static let service = "com.lchat.client"
15 // The app-family keychain group (same as SharedThreadKeys): lets the
16 // share extension read identities to send directly. Writes try the group
17 // first and fall back without it (ad-hoc dev builds lack the entitlement).
18 private static let accessGroup = "J5NDF5PCD8.com.elevenmessenger.messenger.shared"
19
20 public static func load<T: Decodable>(_ type: T.Type, account: String) -> T? {
21 for withGroup in [true, false] {
22 var query: [String: Any] = [
23 kSecClass as String: kSecClassGenericPassword,
24 kSecAttrService as String: service,
25 kSecAttrAccount as String: account,
26 kSecUseDataProtectionKeychain as String: true,
27 kSecReturnData as String: true,
28 kSecMatchLimit as String: kSecMatchLimitOne,
29 ]
30 if withGroup { query[kSecAttrAccessGroup as String] = accessGroup }
31 var item: CFTypeRef?
32 if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
33 let data = item as? Data,
34 let value = try? JSONDecoder().decode(T.self, from: data) {
35 return value
36 }
37 }
38 return nil
39 }
40
41 /// Returns whether the item actually landed in the keychain — ad-hoc dev
42 /// builds can lack the entitlement the data-protection keychain requires,
43 /// in which case SecItemAdd fails and callers need a fallback.
44 @discardableResult
45 public static func save<T: Encodable>(_ value: T, account: String) -> Bool {
46 guard let data = try? JSONEncoder().encode(value) else { return false }
47 let base: [String: Any] = [
48 kSecClass as String: kSecClassGenericPassword,
49 kSecAttrService as String: service,
50 kSecAttrAccount as String: account,
51 kSecUseDataProtectionKeychain as String: true,
52 ]
53 var grouped = base
54 grouped[kSecAttrAccessGroup as String] = accessGroup
55 SecItemDelete(grouped as CFDictionary)
56 SecItemDelete(base as CFDictionary)
57 var add = grouped
58 add[kSecValueData as String] = data
59 add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
60 if SecItemAdd(add as CFDictionary, nil) == errSecSuccess { return true }
61 add = base
62 add[kSecValueData as String] = data
63 add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
64 return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
65 }
66
67 public static func clear(account: String) {
68 SecItemDelete([
69 kSecClass as String: kSecClassGenericPassword,
70 kSecAttrService as String: service,
71 kSecAttrAccount as String: account,
72 kSecUseDataProtectionKeychain as String: true,
73 ] as CFDictionary)
74 }
75}
76
77// The persisted list of instances (each with its keypair). Keychain-backed because
78// it holds the user private keys — and keychain-ONLY for writes: the old
79// fallback wrote the list (private keys included) as plaintext JSON to
80// Application Support when a dev build lacked the data-protection entitlement,
81// which violates "no key material on disk in the clear". A failed keychain
82// save now simply doesn't persist — the Mac re-extracts identity from the
83// webview session on next launch, the honest degradation. The legacy file
84// stays READABLE so existing installs migrate: the first successful keychain
85// save deletes it.
86public enum InstancesStore {
87 private static let account = "instances"
88
89 public static func load() -> [SavedInstance] {
90 Keychain.load([SavedInstance].self, account: account) ?? fileLoad() ?? []
91 }
92
93 public static func save(_ instances: [SavedInstance]) {
94 if Keychain.save(instances, account: account) {
95 try? FileManager.default.removeItem(at: fileURL) // migrate: no stale plaintext copy
96 }
97 // No file fallback — private keys never touch disk unencrypted.
98 }
99
100 private static var fileURL: URL {
101 (FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
102 ?? URL(fileURLWithPath: NSTemporaryDirectory()))
103 .appendingPathComponent("lchat/instances.json")
104 }
105
106 private static func fileLoad() -> [SavedInstance]? {
107 guard let data = try? Data(contentsOf: fileURL) else { return nil }
108 return try? JSONDecoder().decode([SavedInstance].self, from: data)
109 }
110}
111
112// 🤖 The last folded thread list per space, so re-entering one renders its
113// chats instantly (ThreadStore.hydrate) instead of waiting on the network —
114// loading is a first-visit-only state. Keychain, not a file: the rows carry
115// unwrapped thread keys and decrypted titles/previews — the SharedThreadKeys
116// posture. Presence dots are stripped on save (they're live-only state).
117public enum ThreadListSnapshot {
118 public struct Snap: Codable {
119 public var name: String // the space's group name (title)
120 public var rows: [ChatThread]
121 }
122
123 // Refresh refires on every socket frame; skip the (5-50ms) Keychain write
124 // when nothing changed — the SharedThreadKeys fingerprint trick.
125 private static var savedFingerprints: [String: Int] = [:]
126 private static let lock = NSLock()
127
128 private static func account(_ origin: String) -> String { "threads-snapshot|\(origin)" }
129
130 public static func load(origin: String) -> Snap? {
131 Keychain.load(Snap.self, account: account(origin))
132 }
133
134 /// Call off the main thread (hydrate/refresh already do).
135 public static func save(origin: String, name: String, rows: [ChatThread]) {
136 var snap = Snap(name: name, rows: rows)
137 for i in snap.rows.indices { snap.rows[i].presence = nil }
138 guard let data = try? JSONEncoder().encode(snap) else { return }
139 let fp = data.hashValue
140 lock.lock()
141 let unchanged = savedFingerprints[origin] == fp
142 if !unchanged { savedFingerprints[origin] = fp }
143 lock.unlock()
144 guard !unchanged else { return }
145 Keychain.save(snap, account: account(origin))
146 }
147
148 public static func clear(origin: String) {
149 Keychain.clear(account: account(origin))
150 lock.lock(); savedFingerprints[origin] = nil; lock.unlock()
151 }
152}
153
154// 🤖 The most recent chat the member had open — the share extension's
155// "Suggested" row. Kept in the shared-keychain group (NOT app-group defaults)
156// because the decrypted title is user content: same posture as
157// SharedThreadKeys, which already parks decrypted titles for the NSE.
158public enum LastOpenedChat {
159 public struct Entry: Codable, Equatable {
160 public let origin: String
161 public let thread: String
162 public let title: String
163 }
164
165 // A Keychain write costs 5-50ms and note() runs at the exact moment a chat
166 // opens — the one frame that must not stall. Skip re-noting the same chat
167 // and do the real write off the main thread (Keychain is thread-safe; the
168 // same treatment SharedThreadKeys got). #465.
169 private static let writeQueue = DispatchQueue(label: "last-opened-chat", qos: .utility)
170 private static var lastNoted: Entry? // touched only on writeQueue
171
172 public static func note(origin: String, thread: String, title: String) {
173 let entry = Entry(origin: origin, thread: thread, title: title)
174 writeQueue.async {
175 guard entry != lastNoted else { return }
176 lastNoted = entry
177 Keychain.save(entry, account: "last-opened-chat")
178 }
179 }
180
181 public static func load() -> Entry? {
182 Keychain.load(Entry.self, account: "last-opened-chat")
183 }
184}
185