| 1 | import 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 |
| 26 | public 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 | |