| 1 | import Foundation |
| 2 | |
| 3 | // π€ The native side of the web's `spaceapps` registry (apps.js installsApp β |
| 4 | // the spaceapps event family): which mini-apps are installed FOR THE WHOLE |
| 5 | // SPACE is E2E fold state β encrypted spaceapps.add / spaceapps.remove events |
| 6 | // in the hidden all-members space thread's log (docs/space-apps.md), seeded |
| 7 | // from that thread's encrypted slot-"apps" snapshot β so the server never |
| 8 | // learns which app (or that one) was installed. GroupApps' twin one level up; |
| 9 | // this file is the pure, testable half (SpaceAppsVectorTests transcribes |
| 10 | // test/event-vectors/spaceapps.json). SpaceAppsStore feeds it. |
| 11 | public enum SpaceApps { |
| 12 | /// One space-wide install, as the web folds it: the item id IS the app |
| 13 | /// name, so re-installing is idempotent and a remove needs no lookup. |
| 14 | /// Unlike a group item it carries the installer's `uid` β the admins-only |
| 15 | /// install policy filters by it at read time. Codable so the store can |
| 16 | /// park the folded list across launches (installs must survive relaunch). |
| 17 | public struct Item: Equatable, Identifiable, Sendable, Codable { |
| 18 | public let id: String // the installed app's name ("bookmarks", β¦) |
| 19 | public var by: String |
| 20 | public var ts: Int64 // ms, as stamped by the installer |
| 21 | public var uid: String |
| 22 | public init(id: String, by: String, ts: Int64, uid: String) { |
| 23 | self.id = id |
| 24 | self.by = by |
| 25 | self.ts = ts |
| 26 | self.uid = uid |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | /// One spaceapps.* wire event, keyed by its server message id so pages can |
| 31 | /// fold in any arrival order and a recompute stays deterministic. |
| 32 | public struct Event: Equatable, Sendable { |
| 33 | public let type: String // "spaceapps.add" | "spaceapps.remove" |
| 34 | public let app: String |
| 35 | public let by: String |
| 36 | public let ts: Int64 |
| 37 | public let uid: String |
| 38 | public init(type: String, app: String, by: String, ts: Int64, uid: String) { |
| 39 | self.type = type |
| 40 | self.app = app |
| 41 | self.by = by |
| 42 | self.ts = ts |
| 43 | self.uid = uid |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // The spaceapps.* wire shape β the event fields plus the transport |
| 48 | // envelope's name/ts/uid (ts is ms; tolerate a float, JS numbers are |
| 49 | // doubles). Mirrors ChatFeedModel.GroupAppsWire. |
| 50 | private struct Wire: Decodable { |
| 51 | let type: String? |
| 52 | let app: String? |
| 53 | let name: String? |
| 54 | let ts: Double? |
| 55 | let uid: String? |
| 56 | } |
| 57 | |
| 58 | /// Decode one decrypted space-thread message into an event, or nil for |
| 59 | /// anything that isn't a well-formed spaceapps.* install/remove (a |
| 60 | /// malformed or hostile blob folds to a no-op, never a crash). |
| 61 | public static func event(_ blob: String, key: GroupKey) -> Event? { |
| 62 | guard let w = try? key.decryptJSON(Wire.self, blob: blob), |
| 63 | let type = w.type, type.hasPrefix("spaceapps."), |
| 64 | let app = w.app, !app.isEmpty else { return nil } |
| 65 | return Event(type: type, app: app, by: w.name ?? "", |
| 66 | ts: Int64(w.ts ?? 0), uid: w.uid ?? "") |
| 67 | } |
| 68 | |
| 69 | /// Recompute the installed-apps list from the snapshot seed plus every |
| 70 | /// event NEWER than the snapshot's horizon, in log order β the web's fold |
| 71 | /// (addItem dedupes by id and keeps the original attribution; remove |
| 72 | /// deletes). `events` is keyed by server message id; ids <= upTo are |
| 73 | /// already inside the seed, so they're skipped (apps.js foldInto's |
| 74 | /// `mid <= upTo` rule). |
| 75 | public static func fold(seed: [Item], upTo: Int64, events: [Int64: Event]) -> [Item] { |
| 76 | var apps = seed |
| 77 | for (id, e) in events.sorted(by: { $0.key < $1.key }) { |
| 78 | guard id > upTo, !e.app.isEmpty else { continue } |
| 79 | switch e.type { |
| 80 | case "spaceapps.add": |
| 81 | if !apps.contains(where: { $0.id == e.app }) { |
| 82 | apps.append(Item(id: e.app, by: e.by, ts: e.ts, uid: e.uid)) |
| 83 | } |
| 84 | case "spaceapps.remove": |
| 85 | apps.removeAll { $0.id == e.app } |
| 86 | default: |
| 87 | break |
| 88 | } |
| 89 | } |
| 90 | return apps |
| 91 | } |
| 92 | |
| 93 | /// Decode the decrypted slot-"apps" snapshot into a seed. The registry is |
| 94 | /// one app on the web ("installs" owns both the groupapps and spaceapps |
| 95 | /// families), so new snapshots hold its state under "installs"; snapshots |
| 96 | /// written before the unification used the per-family "spaceapps" key β |
| 97 | /// read both, unified winning (apps.js seedThread's shim, mirrored). |
| 98 | /// Tolerant by design β other apps' states share the blob and any of it |
| 99 | /// may be missing. |
| 100 | public static func parseSnapshot(_ json: Data) -> (seed: [Item], upTo: Int64)? { |
| 101 | guard let root = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any] else { |
| 102 | return nil |
| 103 | } |
| 104 | let upTo = (root["upTo"] as? NSNumber)?.int64Value ?? 0 |
| 105 | let states = root["states"] as? [String: Any] |
| 106 | let reg = (states?["installs"] ?? states?["spaceapps"]) as? [String: Any] |
| 107 | let rows = reg?["apps"] as? [[String: Any]] ?? [] |
| 108 | let seed: [Item] = rows.compactMap { row in |
| 109 | guard let id = row["id"] as? String, !id.isEmpty else { return nil } |
| 110 | return Item(id: id, |
| 111 | by: row["by"] as? String ?? "", |
| 112 | ts: (row["ts"] as? NSNumber)?.int64Value ?? 0, |
| 113 | uid: row["uid"] as? String ?? "") |
| 114 | } |
| 115 | return (seed, upTo) |
| 116 | } |
| 117 | } |
| 118 | |