eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import 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.
11public 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