| 1 | import 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 |
| 15 | public 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 | |
| 205 | public 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 | |