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 one native source of truth for app metadata (docs/native-apps.md
4// Layer 1): every "what is this app called / where can it live / how does it
5// present" question is a registry read, replacing the ~10 hand-kept per-app
6// lookup tables that each restated a fragment of it (appTitles, appGlyphs,
7// appDescriptions, personalApps, GroupApps.catalog, companionApps, the
8// intros-as-companion-gateโ€ฆ).
9//
10// Three layers of records, later winning per app name:
11// 1. the BUNDLED SEED (Seed/app-catalog.json) โ€” appCatalogReport() for
12// every built-in app, generated by tools/gen-app-catalog.mjs and
13// drift-gated by test/appcatalog-seed.test.mjs, so first launch knows
14// the full built-in catalogue with zero per-app native code;
15// 2. the PERSISTED per-origin catalogue (Keychain, AppCatalog.load) โ€” what
16// the last live report said, surviving relaunch (custom + member apps
17// only exist per space);
18// 3. LIVE REPORTS โ€” any embed webview answers appCatalog(); the bridges
19// feed the JSON here on page-ready and on {catalogChanged}.
20//
21// An app the registry doesn't know still works: title falls back to its id,
22// glyph to "โ–ฆ", surface to a webview โ€” that is the first-class-citizen
23// guarantee. Registries are per ORIGIN (one space's member apps are not
24// another's), cached like NativeFeedModelCache.
25@MainActor
26public final class AppRegistry: ObservableObject {
27 @Published public private(set) var manifests: [String: AppManifest] = [:]
28 /// The space's connected third-party services (GET /api/connected) โ€” the
29 /// pickers hide service-gated apps (manifest.requiresService) until their
30 /// service is in this set, mirroring the web (newchat.js: "until it
31 /// arrives, gated apps stay hidden โ€” no flash of a not-yet-usable app").
32 @Published public private(set) var connectedServices: Set<String> = []
33
34 public let origin: String
35 private var reported = false // a live report landed this launch
36
37 private static var registries: [String: AppRegistry] = [:]
38
39 public static func shared(origin: String) -> AppRegistry {
40 if let r = registries[origin] { return r }
41 let r = AppRegistry(origin: origin)
42 registries[origin] = r
43 return r
44 }
45
46 private init(origin: String) {
47 self.origin = origin
48 var merged: [String: AppManifest] = [:]
49 for m in Self.seed { merged[m.name] = m }
50 for m in AppCatalog.load(origin: origin) ?? [] { merged[m.name] = m }
51 manifests = merged
52 Self.pushDecoration(merged)
53 }
54
55 /// The bundled seed, decoded once for every registry (it's per-build,
56 /// not per-origin). Missing/undecodable is an empty floor, not a crash โ€”
57 /// the fallbacks below still answer every read.
58 private static let seed: [AppManifest] = {
59 guard let url = Bundle.module.url(forResource: "app-catalog",
60 withExtension: "json", subdirectory: "Seed"),
61 let data = try? Data(contentsOf: url),
62 let apps = AppCatalog.decode(reportJSON: data) else { return [] }
63 return apps
64 }()
65
66 // ---- feeding ----------------------------------------------------------
67
68 /// Fold one live appCatalog() report (the JSON an evaluateJavaScript call
69 /// returned). The live report is authoritative for the whole app set of
70 /// this space, but the seed still floors every built-in โ€” an embed that
71 /// somehow reported a subset must not un-register todo. Persists per
72 /// origin so the next launch starts from it.
73 public func update(reportJSON: Data) {
74 guard let apps = AppCatalog.decode(reportJSON: reportJSON), !apps.isEmpty else {
75 catalogLog.notice("catalogue report didn't decode (\(reportJSON.count) bytes)")
76 return
77 }
78 catalogLog.notice("catalogue report: \(apps.count) apps")
79 var merged: [String: AppManifest] = [:]
80 for m in Self.seed { merged[m.name] = m }
81 for m in apps { merged[m.name] = m }
82 reported = true
83 if merged != manifests { manifests = merged }
84 Self.pushDecoration(merged)
85 let list = apps
86 let origin = origin
87 Task.detached(priority: .utility) {
88 AppCatalog.save(origin: origin, list)
89 }
90 }
91
92 /// Whether a live report has landed this launch โ€” the bridges use it to
93 /// avoid re-pulling from every webview that becomes ready.
94 public var hasLiveReport: Bool { reported }
95
96 // The apps' own declared native chrome (manifest symbol/mark โ€” each app's
97 // JS spec, docs/icons.md) feeds AppIdentity, the pure renderer: nothing
98 // hand-kept natively can drift from what the apps say about themselves.
99 private static func pushDecoration(_ manifests: [String: AppManifest]) {
100 var symbols: [String: String] = [:]
101 var marks: [String: String] = [:]
102 for (name, m) in manifests {
103 if let sym = m.symbol, !sym.isEmpty { symbols[name] = sym }
104 if let mark = m.mark, !mark.isEmpty { marks[name] = mark }
105 }
106 AppIdentity.updateDecoration(symbols: symbols, marks: marks)
107 }
108
109 /// The app set changed (an apps_changed frame reached a native socket):
110 /// drop the once-per-launch pull latch so the next ready page โ€” or the
111 /// forced pull the caller fires โ€” reports fresh.
112 public func invalidateLiveReport() {
113 reported = false
114 }
115
116 /// Fold the connected-services set (ThreadStore fetches it) โ€” gated apps
117 /// appear/disappear from the pickers with it.
118 public func updateConnected(_ services: Set<String>) {
119 if services != connectedServices { connectedServices = services }
120 }
121
122 // ---- reads ------------------------------------------------------------
123
124 public func manifest(_ id: String?) -> AppManifest? {
125 guard let id else { return nil }
126 return manifests[id]
127 }
128
129 /// Display title โ€” the manifest's, or the id capitalized (an unknown app
130 /// is never blank).
131 public func title(_ id: String) -> String {
132 manifests[id]?.title ?? id.capitalized
133 }
134
135 /// The web's emoji face โ€” the universal glyph fallback ("โ–ฆ" when even the
136 /// manifest is unknown, matching the web's defineApp default).
137 public func glyph(_ id: String) -> String {
138 manifests[id]?.glyph ?? "โ–ฆ"
139 }
140
141 /// One-line subtitle before first use ("Create a to-do list"), or nil.
142 public func blurb(_ id: String) -> String? {
143 let b = manifests[id]?.blurb ?? ""
144 return b.isEmpty ? nil : b
145 }
146
147 /// Whether the app presents as a COMPANION (a Chat view beside/instead of
148 /// its interface) โ€” the web's appWantsChatView, pre-answered.
149 public func isCompanion(_ id: String?) -> Bool {
150 guard let id else { return false }
151 return manifests[id]?.companion ?? false
152 }
153
154 // ---- pickers ----------------------------------------------------------
155
156 /// Personal apps (New chat โ–ธ Apps): places โˆ‹ "you" โ€” the web's
157 /// personalApps() exactly. Deliberately NOT gated on `listed`: that flag
158 /// feeds a different surface (the Apps directory), and member apps
159 /// (which rarely set it) must appear here like they do on the web.
160 public var personalApps: [AppManifest] {
161 placedApps(place: "you")
162 }
163
164 /// Group-capable apps (โ‹ฎ โ–ธ Apps, new-group seeding): places โˆ‹ "chat" โ€”
165 /// the web's groupCapableApps. Same deliberate no-`listed` rule.
166 public var groupApps: [AppManifest] {
167 placedApps(place: "chat")
168 }
169
170 /// Space-capable apps: places โˆ‹ "space" (the web's spaceCapableApps โ€”
171 /// deliberately NOT listed-gated, matching the web).
172 public var spaceCapableApps: [AppManifest] {
173 manifests.values.filter { $0.places.contains("space") }
174 .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
175 }
176
177 private func placedApps(place: String) -> [AppManifest] {
178 manifests.values.filter { $0.places.contains(place) && serviceAvailable($0) }
179 .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
180 }
181
182 // The web's matchingApps gate: an app naming a required service shows
183 // only once that service is connected. Missing/empty = ungated.
184 private func serviceAvailable(_ m: AppManifest) -> Bool {
185 guard let svc = m.requiresService, !svc.isEmpty else { return true }
186 return connectedServices.contains(svc)
187 }
188
189 /// The read-time install filter (the web's groupAppsFor/spaceAppsFor
190 /// rule): folded installs surface only when this client knows the app AND
191 /// the app can live in that place โ€” a hostile or stale add naming
192 /// anything else stays inert.
193 public func known(_ items: [GroupApps.Item], place: String = "chat") -> [GroupApps.Item] {
194 items.filter { manifests[$0.id]?.places.contains(place) ?? false }
195 }
196
197 public func knownSpace(_ items: [SpaceApps.Item]) -> [SpaceApps.Item] {
198 items.filter { manifests[$0.id]?.places.contains("space") ?? false }
199 }
200
201 // ---- surface routing ---------------------------------------------------
202
203 /// How an app-thread presents. One shared decision for both platforms'
204 /// routers (docs/native-apps.md Layer 3) โ€” native enhancements (the flow
205 /// interpreter, the schema engine) are selected here, and the webview is
206 /// the universal fallback, so an unknown app always opens.
207 public enum Presentation: Equatable {
208 /// The bundled conversation-flow interpreter (setup/welcome).
209 case flow
210 /// The native schema engine (a bundled Schemas/<id>.json exists).
211 /// `companion` mirrors the manifest โ€” schema surfaces offer the Chat
212 /// view exactly when the web would.
213 case schema(companion: Bool)
214 /// A Chat view beside (Mac: Chat|Split|Interface) or instead of the
215 /// interface webview. `defaultView` is "chat" | "split" | "interface";
216 /// chatOnly means there is no interface at all.
217 case companion(defaultView: String, chatOnly: Bool, chatReadOnly: Bool)
218 /// The app's render owns the whole surface: a webview seeded
219 /// view:"interface". The universal fallback.
220 case takeover
221 }
222
223 public func presentation(_ id: String) -> Presentation {
224 // The flow interpreter is a native enhancement keyed by id โ€” these
225 // two are the product's own onboarding conversations, not catalogue
226 // apps (places:[], never picked).
227 if id == "setup" || id == "welcome" { return .flow }
228 if AppSchemaRegistry.hasSchema(for: id) {
229 return .schema(companion: isCompanion(id))
230 }
231 guard let m = manifests[id] else { return .takeover }
232 if m.companion {
233 return .companion(defaultView: m.defaultView,
234 chatOnly: m.chatOnly,
235 chatReadOnly: m.chatReadOnly)
236 }
237 return .takeover
238 }
239}
240