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 native record of one registered app's manifest facts β€” the decode of
4// appCatalogReport() (web/static/apps.js), the app-host protocol's catalogue
5// (docs/native-apps.md). The web page is the only thing that can execute app
6// JS across all three tiers (built-in, admin custom, member), so it reports
7// every app's identity, places, presentation contract, and commands as plain
8// JSON; native decodes that report here and drives its registry, pickers,
9// surface routing, and composer typeahead from it β€” zero per-app native code.
10// AppCatalogTests pins this decode against the shapes test/appcatalog.test.mjs
11// pins on the web side: the two are the cross-platform contract.
12public struct AppManifest: Codable, Equatable, Sendable, Identifiable {
13 public struct Command: Codable, Equatable, Sendable {
14 public var name: String
15 public var desc: String
16 public var args: String
17 /// A local verb of the owning app's own thread (never offered
18 /// elsewhere), vs a global command available in any chat.
19 public var local: Bool
20 }
21
22 /// The app's registry name ("todo", "vault", a member app's name) β€” the
23 /// identity every install event and app-thread name_blob carries.
24 public var name: String
25 public var title: String
26 /// The web's emoji face β€” the universal fallback; native SF-symbol
27 /// decoration keys off `name` and falls back to this, never to a blank.
28 public var glyph: String
29 /// One-line subtitle before first use ("Create a to-do list"). Optional
30 /// on the wire: reports from instances still running the first bridge
31 /// version don't carry it, and a missing field must not fail the decode.
32 public var blurb: String?
33 /// Where the app can live: any of "you" (personal), "chat" (per-group),
34 /// "space" (space-wide).
35 public var places: [String]
36 public var listed: Bool
37 public var quiet: Bool
38 /// A member-built app (user content, sandbox-executed) β€” vs built-in or
39 /// admin-installed custom.
40 public var member: Bool
41 /// Native chrome (docs/icons.md), declared in the app's own JS beside its
42 /// web logo: the SF Symbols name this app's avatar renders ("" = none)…
43 public var symbol: String?
44 /// …or the vendored template asset (Marks/<name>.png) for brand apps.
45 public var mark: String?
46 /// The event that makes the app's content visible after an add (board's
47 /// "board.open") β€” "" / missing = none. The add flows fire it right after
48 /// groupapps.add lands, like the web's installGroupApp; without it the
49 /// app is "silently present but empty" (board.js's own words).
50 public var openEvent: String?
51 /// The connected-service gate ("" / missing = ungated): the pickers hide
52 /// an app whose service isn't in the space's connected set β€” DNSimple's
53 /// app only "installs" once DNSimple is linked (the web's matchingApps
54 /// rule). Optional on the wire: reports from earlier bridge versions
55 /// don't carry it, and missing must read as ungated (their behaviour).
56 public var requiresService: String?
57 /// Whether the app has an openable surface (a render) β€” command-only
58 /// apps (gif) get no sidebar/space-band row. Optional on the wire:
59 /// reports from earlier bridge versions don't carry it; treat missing
60 /// as "has one" (the safer default for rows).
61 public var surface: Bool?
62 // The presentation contract, pre-answered by the reporting page (the same
63 // rules as appWantsChatView / appChatOnly / appDefaultView) so native
64 // never re-derives it: companion = has a Chat view; chatOnly = ONLY a
65 // Chat view; no companion = the app's render takes over the surface.
66 public var companion: Bool
67 public var chatOnly: Bool
68 public var chatReadOnly: Bool
69 /// "chat" | "interface" | "split" β€” the companion view to open first.
70 public var defaultView: String
71 /// The command the app-thread composer runs bare input through ("" = none).
72 public var defaultCommand: String
73 public var commands: [Command]
74
75 public var id: String { name }
76}
77
78public enum AppCatalog {
79 /// Decode one appCatalog() report (the JSON an evaluateJavaScript call
80 /// returns). nil on undecodable input β€” a native shell must treat that as
81 /// "no report", never as an empty app set. Per-record tolerant: ONE
82 /// malformed manifest (a member app with an odd shape) must not poison
83 /// the whole catalogue.
84 public static func decode(reportJSON: Data) -> [AppManifest]? {
85 struct Row: Decodable { let manifest: AppManifest? }
86 guard let rows = try? JSONDecoder().decode([LenientManifest].self, from: reportJSON)
87 else { return nil }
88 return rows.compactMap(\.manifest)
89 }
90
91 // A wrapper whose init never throws: a record that doesn't decode becomes
92 // nil (and is dropped) instead of failing the array.
93 private struct LenientManifest: Decodable {
94 let manifest: AppManifest?
95 init(from decoder: Decoder) throws {
96 manifest = try? AppManifest(from: decoder)
97 }
98 }
99
100 // Parked per origin so pickers and routing have a catalogue before any
101 // webview reports one this launch (and offline). Keychain, not a file:
102 // member-app names/titles/commands are decrypted user content β€” the
103 // server never learns the app set, so neither should disk plaintext
104 // (the ThreadListSnapshot posture).
105 private static func account(_ origin: String) -> String { "app-catalog|\(origin)" }
106
107 public static func load(origin: String) -> [AppManifest]? {
108 Keychain.load([AppManifest].self, account: account(origin))
109 }
110
111 /// Call off the main thread (Keychain writes aren't free).
112 public static func save(origin: String, _ catalog: [AppManifest]) {
113 Keychain.save(catalog, account: account(origin))
114 }
115
116 public static func clear(origin: String) {
117 Keychain.clear(account: account(origin))
118 }
119}
120