eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// πŸ€– The per-instance store for space-wide app installs (docs/space-apps.md,
4// docs/native-apps.md phase 1): fetches the hidden all-members space thread,
5// unwraps its key from open_key under the INSTANCE key (no per-member wrap
6// exists β€” membership is implicit), seeds the fold from the thread's
7// encrypted slot-"apps" snapshot, catches up on newer events, and folds live
8// spaceapps.* frames as they arrive. The folded list parks in the Keychain
9// per origin β€” decrypted app names are user content, the ThreadListSnapshot
10// posture β€” so a relaunch lists a space's installed apps before (or without)
11// any network. ThreadStore owns one and prods it once the instance key is
12// unwrapped; the platform socket handlers forward space_thread prods and the
13// space thread's own msg/delete frames.
14@MainActor
15public final class SpaceAppsStore: ObservableObject {
16 /// The folded install list, raw: every install in the log, unknown app
17 /// names included. Filtering to apps this client can actually surface is
18 /// a READ-time concern (the web's spaceAppsFor) and belongs to the
19 /// registry consumers β€” old and new clients converge on the same log.
20 @Published public private(set) var installs: [SpaceApps.Item] = []
21 /// The space thread's id once fetched β€” the socket handlers use it to
22 /// route the thread's live frames here. nil until fetched (or when the
23 /// space has no thread yet).
24 public private(set) var threadID: String?
25 /// The space thread's AES key (base64url), for seeding a space app's
26 /// webview surface β€” the same narrow-key handoff every app webview gets
27 /// (never the instance key, never the private key). nil until unwrapped.
28 public private(set) var threadKeyB64: String?
29
30 private let instance: SavedInstance
31 private var key: GroupKey?
32 private var seed: [SpaceApps.Item] = []
33 private var upTo: Int64 = 0
34 private var events: [Int64: SpaceApps.Event] = [:]
35 private var loaded = false // one full network pass has completed
36 private var hydrated = false
37 // reload() refires from refresh passes and socket prods β€” coalesced the
38 // same way ThreadStore.refresh is (see CoalescedRunner).
39 private let gate = CoalescedRunner()
40
41 public init(instance: SavedInstance) {
42 self.instance = instance
43 }
44
45 /// One full pass if none has happened yet this launch β€” called from every
46 /// ThreadStore refresh, so it must be a cheap no-op afterwards (the
47 /// space_thread prod and live frames carry changes from then on).
48 public func loadIfNeeded(instanceKeyB64: String?) async {
49 guard !loaded else { return }
50 await reload(instanceKeyB64: instanceKeyB64)
51 }
52
53 /// Fetch + unwrap + seed + catch-up, publishing and parking the result.
54 /// Network failure keeps what we hold (never blank a list on a blip);
55 /// a definitive "no space thread exists" folds to empty.
56 public func reload(instanceKeyB64: String?) async {
57 await gate.run { await self.pass(instanceKeyB64: instanceKeyB64) }
58 }
59
60 private func pass(instanceKeyB64 ikB64: String?) async {
61 await hydrateIfNeeded()
62 guard let base = instance.baseURL else { return }
63 let api = LChatAPI(baseURL: base, token: instance.deviceToken)
64 // nil = network error (keep held state); (nil, _) = no thread yet.
65 guard let st = await api.spaceThread() else { return }
66 guard let id = st.id else {
67 threadID = nil
68 key = nil
69 threadKeyB64 = nil
70 seed = []
71 upTo = 0
72 events = [:]
73 loaded = true
74 recompute()
75 return
76 }
77 threadID = id
78 if key == nil, let ikB64, !st.openKey.isEmpty,
79 let ik = try? GroupKey(base64url: ikB64) {
80 // open_key is the thread's AES key under the instance key, the
81 // open-group envelope ({k}) β€” mirrors loadSpaceThread in shell.js.
82 let openKey = st.openKey
83 let unwrapped: (GroupKey, String)? = await Task.detached(priority: .userInitiated) {
84 guard let payload = try? ik.decryptJSON(OpenKeyPayload.self, blob: openKey),
85 let raw = Data(base64urlEncoded: payload.k),
86 let k = try? GroupKey(rawKey: raw) else { return nil }
87 return (k, payload.k)
88 }.value
89 key = unwrapped?.0
90 threadKeyB64 = unwrapped?.1
91 }
92 // No instance key on this device (not granted yet): the thread exists
93 // but we can't read it β€” keep the parked list and try again next prod.
94 guard let key else { return }
95 // Seed from the slot-"apps" snapshot (what the web saves on every fold
96 // advance), then fold only events newer than its horizon β€” without
97 // this an app installed long ago would be invisible once its event
98 // fell out of any message window.
99 if let state = await api.threadState(thread: id, slot: "apps"), !state.blob.isEmpty {
100 let blob = state.blob
101 let parsed = await Task.detached(priority: .userInitiated) {
102 (try? key.decryptData(blob: blob)).flatMap(SpaceApps.parseSnapshot)
103 }.value
104 if let parsed {
105 seed = parsed.seed
106 upTo = parsed.upTo
107 events = events.filter { $0.key > parsed.upTo }
108 }
109 }
110 // Catch-up: page forward from the horizon (the web's seedAppState
111 // walk). Bounded β€” a page short of the server's limit is the end.
112 var cursor = upTo
113 for _ in 0..<20 {
114 let page = await api.messages(thread: id, after: cursor)
115 guard !page.isEmpty else { break }
116 for m in page where m.tombstone_of == nil {
117 if let e = SpaceApps.event(m.blob, key: key) { events[m.id] = e }
118 }
119 guard let last = page.map(\.id).max(), last > cursor else { break }
120 cursor = last
121 }
122 loaded = true
123 recompute()
124 }
125
126 /// Fold one live hub frame for the space thread β€” the socket handlers
127 /// route frames whose thread_id matches threadID here. msg folds the
128 /// event; delete unfolds it (a retracted install/remove refolds cleanly
129 /// because the fold is a pure recompute).
130 public func handleLive(_ frame: ChatSocket.Frame) {
131 guard let key, let id = frame.id else { return }
132 switch frame.type {
133 case "msg":
134 guard let blob = frame.blob,
135 let e = SpaceApps.event(blob, key: key) else { return }
136 guard events[id] != e else { return }
137 events[id] = e
138 recompute()
139 case "delete":
140 guard events.removeValue(forKey: id) != nil else { return }
141 recompute()
142 default:
143 break
144 }
145 }
146
147 private func recompute() {
148 let folded = SpaceApps.fold(seed: seed, upTo: upTo, events: events)
149 let changed = folded != installs
150 if changed { installs = folded }
151 // Park for next launch (hydrate reads it) β€” off-main, skipped when
152 // nothing changed (recompute refires on every live frame).
153 guard changed || !hydrated else { return }
154 hydrated = true
155 let snap = Snap(threadID: threadID, threadKeyB64: threadKeyB64, installs: folded)
156 let origin = instance.origin
157 Task.detached(priority: .utility) {
158 SpaceAppsSnapshot.save(origin: origin, snap: snap)
159 }
160 }
161
162 // Local-first: publish the last session's folded installs before any
163 // network β€” entering a space you've been to lists its apps immediately,
164 // and offline relaunch still lists them (the phase-1 acceptance).
165 private func hydrateIfNeeded() async {
166 guard !hydrated, !loaded else { return }
167 hydrated = true
168 let origin = instance.origin
169 guard let snap = await Task.detached(priority: .userInitiated, operation: {
170 SpaceAppsSnapshot.load(origin: origin)
171 }).value else { return }
172 guard !loaded, installs.isEmpty else { return } // a pass raced us β€” it wins
173 threadID = threadID ?? snap.threadID
174 if threadKeyB64 == nil, let parked = snap.threadKeyB64 {
175 threadKeyB64 = parked
176 key = try? GroupKey(base64url: parked)
177 }
178 installs = snap.installs
179 }
180
181 /// A synthetic ChatThread for a space app's webview surface β€” the space
182 /// thread + its key wearing the app's identity, shaped for the existing
183 /// app-pane machinery. Never listed in ThreadStore.threads; nil until
184 /// the space thread has been fetched and its key unwrapped.
185 public func surfaceThread(app: String, title: String, glyph: String) -> ChatThread? {
186 guard let id = threadID, let keyB64 = threadKeyB64 else { return nil }
187 return ChatThread(id: id, kind: "space", title: title, preview: "",
188 lastAt: 0, unread: false, pinned: false, hidden: false,
189 muted: nil, members: [], threadKeyB64: keyB64, avatar: nil,
190 isApp: true, appId: app, glyph: glyph, badge: "space")
191 }
192
193 /// The parked fold, one per origin. Keychain, not a file: installed app
194 /// names (and who installed them) are decrypted user content β€” the
195 /// ThreadListSnapshot posture.
196 public struct Snap: Codable, Sendable {
197 public var threadID: String?
198 /// Optional: snaps parked before phase 4 don't carry it. Keychain,
199 /// so parking the key matches the SharedThreadKeys posture.
200 public var threadKeyB64: String?
201 public var installs: [SpaceApps.Item]
202 }
203}
204
205public enum SpaceAppsSnapshot {
206 private static func account(_ origin: String) -> String { "space-apps|\(origin)" }
207
208 public static func load(origin: String) -> SpaceAppsStore.Snap? {
209 Keychain.load(SpaceAppsStore.Snap.self, account: account(origin))
210 }
211
212 /// Call off the main thread (recompute already does).
213 public static func save(origin: String, snap: SpaceAppsStore.Snap) {
214 Keychain.save(snap, account: account(origin))
215 }
216
217 public static func clear(origin: String) {
218 Keychain.clear(account: account(origin))
219 }
220}
221