eleven / messenger Public

๐Ÿค– Native apps phase 2: one AppRegistry (lands #1268) (#1314)

* native: one AppRegistry โ€” the lookup tables collapse, surfaces route from it (app-host phase 2)

Phase 2 of docs/native-apps.md: every "what is this app / where can it
live / how does it present" question becomes a registry read.

- Seed catalogue: tools/gen-app-catalog.mjs emits appCatalogReport() for
  every built-in app as ios/.../Seed/app-catalog.json (bundled, decoded by
  AppRegistry); test/appcatalog-seed.test.mjs drift-gates the committed
  seed against the live framework, the schema-copy shape. appstudio needs
  a DOM at module scope so the seed omits it (live pulls carry it).
  appCatalogReport() gains `blurb` (the app's one-line subtitle โ€” the
  field ThreadStore.appDescriptions had been hand-mirroring).
- AppRegistry (LChatCore, per-origin): bundled seed โ† persisted Keychain
  catalogue โ† live reports, later winning per app. Answers titles/glyphs/
  blurbs, picker lists (personal/group/space), the install read-filter
  (known/knownSpace โ€” place-aware, so folded installs of apps a client
  can't surface stay inert), and presentation(id) โ†’ flow | schema |
  companion(defaultView, chatOnly, chatReadOnly) | takeover.
- Live pulls: iOS WebBridge, Mac MacWebBridge, and the Mac command host
  pull window.lchatNative.appCatalog() when a page reports ready and
  re-pull (forced) on {catalogChanged} โ€” so a space's custom and member
  apps register the first time any webview opens and stay current.
- Tables collapsed: ThreadStore.appTitles/appGlyphs/appDescriptions/
  personalApps, GroupApps.catalog+known, NativeChatView.companionApps,
  and the intros-as-companion-gate (iOS ChatView) are gone; GroupApps
  keeps only a nonisolated title map for chat projections until phase 5.
- Routing fixes that fall out: Admin gets Chat|View on the Mac and opens
  as its console on iOS (takeover/companion defaultView honoured in the
  FeedWebCache seed โ€” the hard-coded view:"chat" was the bug); the
  whiteboard is addable to groups and passes the install filter (the old
  4-app catalog filtered it inert); unknown/custom apps appear with
  glyph+title everywhere instead of blanks.
- Pickers from the registry: Mac NewChatPopover (personal + group), iOS
  NewGroupView, GroupAppsSheet โ€” and iOS gains the personal Apps section
  in New chat (Mac/web parity; iOS had no personal-apps picker at all).

swift test: 354 pass (6 new registry tests); npm test green (new seed
drift gate); Mac + iOS app targets build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native+web: pickers honour the connected-service gate (requiresService)

Field feedback (James, 2026-07-23): the native New-chat picker showed
GitHub/DNSimple/HubSpot/Linear/Stripe while the web correctly hides them
until their service is linked โ€” the web's matchingApps gates on the app
spec's `requiresService` against GET /api/connected, and the manifest
never carried the field, so the registry pickers over-showed.

- appCatalogReport() gains `requiresService` ("" = ungated); seed
  regenerated; field pinned in test/appcatalog.test.mjs.
- AppRegistry holds the space's connectedServices set (updateConnected)
  and the picker lists filter gated apps against it โ€” hidden until the
  set names the service, exactly the web's "no flash of a not-yet-usable
  app" rule. Missing field (a pre-bridge server's report) reads as
  ungated, preserving that server's behaviour.
- ThreadStore fetches /api/connected on refresh, throttled to once a
  minute (refresh refires on every socket frame).

swift test: 364 pass (1 new gate test); npm pins green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native: routing asks presentation(), not the bare companion flag

Field feedback (James, 2026-07-23): Eleven's welcome thread rendered as a
Chat|Split|View companion on the Mac โ€” two copies of the same conversation
side by side. setup/welcome are chatOnly companions in the manifest, and
the Mac feed's isCompanion checked the bare flag BEFORE the flow branch
(the old {todo, remind} set never matched them).

isCompanion now asks registry.presentation(), which already encodes the
precedence: flow wins, schema surfaces split per their companion flag, and
a chat-only companion has no interface to split with. iOS's ChatView
toggle gets the same chatOnly guard (its flow routing was already first).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native: the group-apps filter refolds when the catalogue learns an app

Field feedback (James, 2026-07-23): a member app deployed mid-session
stayed invisible in a group's app switcher even after the registry learned
it โ€” the read-time install filter (registry.known) only re-ran on fold
events, so a fresh install surfaced only when the next unrelated event
happened to arrive. ChatFeedModel now watches the registry's manifests and
refolds. Plus AppRegistry.invalidateLiveReport(), the hook the next phase
wires to apps_changed frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native: picker filters match the web exactly โ€” `listed` was never the rule

Field feedback (James, 2026-07-23): a member app with places:["chat"]
showed in the web's Manage Apps but never natively. The web's pickers
gate on places alone โ€” personalApps() is placesโˆ‹you, groupCapableApps()
is placesโˆ‹chat โ€” and `listed` feeds a DIFFERENT surface (the Apps
directory). Member apps rarely set it, so the registry's extra listed
gate hid them from every native picker.

Also: GroupAppsSheet observes the registry in its own right โ€” the
catalogue can learn an app while the sheet is open (the apps_changed
pull), and the model only publishes fold changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native+web: add flows fire an app's openEvent โ€” the board stops adding empty

Field feedback (James, 2026-07-23): Whiteboard added from the NATIVE Apps
sheet mounted its pane but rendered nothing, web and native alike. The
board gates its content behind board.open (state.open); the web's
installGroupApp fires that openEvent right after the add, and native's
add paths never did โ€” "silently present but empty", as board.js's own
comment warns.

openEvent joins the manifest report (+seed, +pin); sendGroupAppEvent and
the new-group seeding both fire it after groupapps.add, exactly like the
web.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native: catalogue decode is per-record tolerant, and the pull path logs

Field feedback (James, 2026-07-23): freshly deployed apps still not
reaching the native pickers even with the reload chain firing (verified
live โ€” the host reloads and reports ready on apps_changed). Two hardening
moves to pin down and survive the failure:
- decode(reportJSON:) drops a malformed manifest instead of failing the
  whole array โ€” one odd member-app record must not silently poison the
  entire catalogue.
- the pull and the registry update log their outcome (report size, app
  count, or why nothing landed), so the next field report can say which
  link broke instead of "never appeared".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native: pin the per-record-tolerant decode contract in AppCatalogTests

The tolerant decode (one malformed manifest drops alone) changed the
malformed-record case from nil to []; the test now pins exactly that โ€”
undecodable INPUT still reads as "no report".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native: AppIdentity is decoration only โ€” unknown apps wear their glyph

Field feedback (James, 2026-07-23): member apps in the Mac New-chat
picker (and every avatar surface) showed the generic grid symbol instead
of their emoji โ€” symbol(for:) defaulted to "square.grid.2x2" for any app
outside its hand-kept table, and avatars let the symbol win over the
glyph. Per the registry design (docs/native-apps.md Layer 1), AppIdentity
is decoration keyed by registry id with the MANIFEST GLYPH as the
universal fallback: symbol(for:) now returns nil for unknown apps, and
every avatar call site already passes the glyph. The iOS menu call sites
keep their explicit grid default (Labels drop non-image icons there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native+web: apps declare their own native chrome โ€” AppIdentity's tables die

Field direction (James, 2026-07-23): the chrome icons must be defined
WHOLLY in the apps' JS, never hard-coded natively. The web already worked
that way (each spec carries its Lucide `logo`); only native kept twin
tables (AppIdentity.symbols/marks) to drift.

- Each first-party app's spec declares its native chrome beside its web
  logo: `symbol:` (the SF Symbols name) or `mark:` (the vendored template
  asset) โ€” 17 specs updated; appCatalogReport carries both (+seed, +pin).
- AppIdentity becomes a pure RENDERER: a locked decoration snapshot the
  registry pushes on every catalogue load (seed at init, live reports
  after; merged so several spaces coexist). Only assets that actually
  ship in Marks/ render โ€” a hostile mark name falls back to the glyph.
- ThreadStore.hydrate touches the registry before the first rows publish,
  so cold-start rows wear their chrome instead of flashing emoji.

swift test: 365 pass (1 new decoration pin); npm green (seed regenerated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* native+web: any app may declare BOTH an emoji glyph and an SF symbol

Field direction (James, 2026-07-23): member apps must not be second-class
in native chrome โ€” an app can declare `symbol:` (SF Symbols name) beside
its emoji glyph, exactly like a built-in, and wear it in native rows and
menus.

- The sandbox manifest accepts an optional `symbol` (validated: dotted
  lowercase, bounded) and the proxy carries it into the registry/report.
  `mark` stays deliberately OFF the member-app surface โ€” naming a vendored
  brand asset would wear another product's logo.
- AppIdentity resolves the declared name against the OS (cached) and falls
  back to the glyph when it can't โ€” a typo'd symbol never renders blank.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
James Adam cf6375d937b43fa6bf874d2ebe60329eb09badcc parent eb11647
50 files changed, +1594 −188
  • .gitignore +1 −0
  • docs/native-apps.md +8 −1
  • ios/LChat/FeedWebCache.swift +17 −10
  • ios/LChat/Views/ChatView.swift +41 −6
  • ios/LChat/Views/NativeChatScreen.swift +2 −2
  • ios/LChat/Views/NewChatView.swift +56 −5
  • ios/LChat/Views/ThreadListView.swift +19 −13
  • ios/LChatCore/Package.resolved +0 −9
  • ios/LChatCore/Package.swift +1 −1
  • ios/LChatCore/Sources/LChatCore/AppCatalog.swift +36 −2
  • ios/LChatCore/Sources/LChatCore/AppCatalogPull.swift +34 −0
  • ios/LChatCore/Sources/LChatCore/AppIdentity.swift +69 −38
  • ios/LChatCore/Sources/LChatCore/AppRegistry.swift +239 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +43 −3
  • ios/LChatCore/Sources/LChatCore/GroupApps.swift +15 −27
  • ios/LChatCore/Sources/LChatCore/GroupAppsSheet.swift +12 −3
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +9 −0
  • ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json +586 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +55 −44
  • ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift +4 −2
  • ios/LChatCore/Tests/LChatCoreTests/AppRegistryTests.swift +130 −0
  • ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift +3 −2
  • ios/LChatMac/AppThreadWebView.swift +14 −2
  • ios/LChatMac/CommandPanelWebView.swift +10 −0
  • ios/LChatMac/NativeChatView.swift +39 −11
  • ios/LChatMac/NewChatPopover.swift +10 −6
  • package.json +1 −1
  • test/appcatalog-seed.test.mjs +40 −0
  • test/appcatalog.test.mjs +3 −0
  • tools/gen-app-catalog.mjs +52 −0
  • web/static/apps.js +16 −0
  • web/static/apps/admin.js +1 −0
  • web/static/apps/appstudio.js +1 −0
  • web/static/apps/board.js +1 −0
  • web/static/apps/bookmarks.js +1 −0
  • web/static/apps/connect.js +1 −0
  • web/static/apps/dnsimple.js +1 −0
  • web/static/apps/emoji.js +1 −0
  • web/static/apps/gif.js +1 −0
  • web/static/apps/github.js +1 −0
  • web/static/apps/hubspot.js +1 −0
  • web/static/apps/linear.js +1 −0
  • web/static/apps/memory.js +1 −0
  • web/static/apps/poll.js +1 −0
  • web/static/apps/remind.js +1 −0
  • web/static/apps/stripe.js +1 −0
  • web/static/apps/todo.js +1 −0
  • web/static/apps/vault.js +1 −0
  • web/static/sandbox-frame.js +6 −0
  • web/static/sandbox.js +6 −0
diff --git a/.gitignore b/.gitignore
index 4132d50..0b96b4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@
# the dev bot's keys/device token. (The db they point at is covered by *.db.)
.lchat-dev.json
.lchat-dev-bot.json
+.lchat-dev-hooks.json
.lchat-dev.db*
.lchat-dev.vapid
.lchat-dev-blobs/
diff --git a/docs/native-apps.md b/docs/native-apps.md
index cc95caa..4d0a329 100644
--- a/docs/native-apps.md
+++ b/docs/native-apps.md
@@ -1,6 +1,13 @@
# ๐Ÿค– Native apps ร— the app platform: apps as first-class citizens
-> Status: **DESIGN + PHASES 0โ€“1 (2026-07-22)** โ€” this branch ships the design,
+> Status: **DESIGN + PHASES 0โ€“2 (2026-07-22)** โ€” phase 2 (branch
+> `claude/native-apps-registry`) ships `AppRegistry`: the bundled seed
+> catalogue generated from the web framework (`tools/gen-app-catalog.mjs`,
+> drift-gated by `test/appcatalog-seed.test.mjs`), live `appCatalog()` pulls
+> from ready embeds (+`{catalogChanged}` re-pulls), and every lookup table
+> collapsed into registry reads: pickers (incl. a NEW iOS personal-apps
+> section), surface routing (`registry.presentation`), the install
+> read-filter, and per-app titles/glyphs/blurbs. This branch ships the design,
> the web side of the app-host protocol (`appCatalog()` / `commandItems()` /
> `catalogChanged`, pinned by `test/appcatalog.test.mjs`), and phase 1:
> `SpaceApps`/`SpaceAppsStore` in LChatCore (space-thread fetch + `open_key`
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift
index e46a89b..748f64c 100644
--- a/ios/LChat/FeedWebCache.swift
+++ b/ios/LChat/FeedWebCache.swift
@@ -82,9 +82,10 @@ final class FeedWebCache {
// The bridge that owns this thread's webview. ChatView observes this instead of
// creating its own, so composer state (staged reply/photo) survives with the page.
func bridge(for instance: SavedInstance, thread: ChatThread, myUserId: String,
- instanceKeyB64: String?, app: String? = nil) -> WebBridge {
+ instanceKeyB64: String?, app: String? = nil,
+ defaultView: String = "chat") -> WebBridge {
entry(for: instance, thread: thread, myUserId: myUserId,
- instanceKeyB64: instanceKeyB64, app: app).bridge
+ instanceKeyB64: instanceKeyB64, app: app, defaultView: defaultView).bridge
}
// Pre-warm feeds for the given threads (the top of the sidebar): create their
@@ -97,7 +98,8 @@ final class FeedWebCache {
}
private func entry(for instance: SavedInstance, thread: ChatThread, myUserId: String,
- instanceKeyB64: String?, app: String? = nil) -> Entry {
+ instanceKeyB64: String?, app: String? = nil,
+ defaultView: String = "chat") -> Entry {
// One shared pane per group for ALL its apps (switching apps is a JS
// call into the same page โ€” WebBridge.setGroupApp โ€” not a reload), so
// the key marks the surface, not the app.
@@ -107,7 +109,7 @@ final class FeedWebCache {
if let e = entries[key], e.seedKey == thread.threadKeyB64 { return e }
if let e = entries.removeValue(forKey: key) { release(e) }
let e = makeEntry(instance: instance, thread: thread, myUserId: myUserId,
- instanceKeyB64: instanceKeyB64, app: app)
+ instanceKeyB64: instanceKeyB64, app: app, defaultView: defaultView)
entries[key] = e
evictIfNeeded()
return e
@@ -115,13 +117,16 @@ final class FeedWebCache {
// The webview construction that used to live in ChatFeedWebView.makeUIView.
private func makeEntry(instance: SavedInstance, thread: ChatThread, myUserId: String,
- instanceKeyB64: String?, app: String? = nil) -> Entry {
+ instanceKeyB64: String?, app: String? = nil,
+ defaultView: String = "chat") -> Entry {
let bridge = WebBridge()
+ bridge.origin = instance.origin // the app-catalogue pull's registry key
let cfg = WKWebViewConfiguration()
cfg.websiteDataStore = .default()
cfg.allowsInlineMediaPlayback = true
if let script = Self.bootScript(instance: instance, thread: thread, myUserId: myUserId,
- instanceKeyB64: instanceKeyB64, app: app) {
+ instanceKeyB64: instanceKeyB64, app: app,
+ defaultView: defaultView) {
cfg.userContentController.addUserScript(script)
}
cfg.userContentController.add(bridge, name: "lchat")
@@ -150,18 +155,20 @@ final class FeedWebCache {
// the thread key crosses over โ€” never the user private key.
// (AppViewMemory lives below so the seed and ChatView's toggle share one truth.)
private static func bootScript(instance: SavedInstance, thread: ChatThread, myUserId: String,
- instanceKeyB64: String?, app: String? = nil) -> WKUserScript? {
+ instanceKeyB64: String?, app: String? = nil,
+ defaultView: String = "chat") -> WKUserScript? {
var seed: [String: Any] = [
"token": instance.deviceToken,
"name": instance.screenName,
"myUserId": myUserId,
"threadId": thread.id,
"threadKey": thread.threadKeyB64,
- // Companion app-threads open on the remembered view (default chat),
- // deterministically โ€” the native header's toggle (ChatView) drives
+ // App-threads open on the remembered view, falling back to the
+ // app's own default (registry presentation โ€” a takeover app is
+ // its interface). The native header's toggle (ChatView) drives
// any switch and shares this memory, so the button state and the
// page always start in agreement, parked OR freshly booted.
- "view": AppViewMemory.views[thread.id] ?? "chat",
+ "view": AppViewMemory.views[thread.id] ?? defaultView,
]
if let app {
// A group's shared app pane: this page is purely that app's
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index 775437a..0f91e07 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -67,19 +67,39 @@ struct ChatView: View {
self.store = store
self.myUserId = myUserId
self.instanceKeyB64 = instanceKeyB64
+ // First open honours the app's own declaration (the registry's
+ // presentation โ€” the web's appDefaultView): a TAKEOVER app (Admin) is
+ // its interface, so seeding view:"chat" showed an empty chat instead
+ // of the app; a companion opens per its defaultView (iOS has no
+ // split, so "split" reads as chat-first). The member's own last
+ // choice, when they've made one, still wins.
+ let initial: String
+ switch AppRegistry.shared(origin: instance.origin).presentation(thread.appId ?? "") {
+ case .takeover:
+ initial = "interface"
+ case .companion(let defaultView, let chatOnly, _):
+ initial = (defaultView == "interface" && !chatOnly) ? "interface" : "chat"
+ default:
+ initial = "chat"
+ }
bridge = FeedWebCache.shared.bridge(for: instance, thread: thread,
- myUserId: myUserId, instanceKeyB64: instanceKeyB64)
- _appView = State(initialValue: AppViewMemory.views[thread.id] ?? "chat")
+ myUserId: myUserId, instanceKeyB64: instanceKeyB64,
+ defaultView: initial)
+ _appView = State(initialValue: AppViewMemory.views[thread.id] ?? initial)
}
private var isMac: Bool { ProcessInfo.processInfo.isMacCatalystApp }
// Companion app-threads (a Chat view AND an Interface view โ€” To-do,
// Reminders) get the header toggle; takeover apps are their interface
- // already, and plain chats have nothing to toggle. The intros table is
- // LChatCore's existing "which apps are companion" fact.
+ // already, chat-only companions have no Interface to toggle to, and
+ // plain chats have nothing to toggle. The registry's presentation
+ // (appWantsChatView + chatOnly, pre-answered) is the fact.
private var isCompanionApp: Bool {
- thread.appId.flatMap { AppChatProjection.intros[$0] } != nil
+ guard thread.isApp,
+ case .companion(_, let chatOnly, _) =
+ store.registry.presentation(thread.appId ?? "") else { return false }
+ return !chatOnly
}
private func setAppView(_ view: String) {
@@ -587,6 +607,10 @@ struct ChatView: View {
// client's send() path, and receives staged-attachment state + errors back.
final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate {
weak var webView: WKWebView?
+ // The instance origin, for the app-catalogue pull (set by FeedWebCache
+ // when it builds the entry) โ€” a ready page reports the space's live app
+ // set into AppRegistry.
+ var origin: String?
// True once the page has real content to show. The page posts {ready:true}
// after its first render (shell.js bootEmbed) โ€” that's the honest "painted"
@@ -917,7 +941,18 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
// {error: String} when an upload/stage fails. Both arrive on the one "lchat" handler.
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == "lchat", let body = message.body as? [String: Any] else { return }
- if body["ready"] as? Bool == true { pageReady = true }
+ if body["ready"] as? Bool == true {
+ pageReady = true
+ // A ready page has loaded all three app tiers โ€” pull the live
+ // catalogue into the registry (one pull per launch; cheap no-op
+ // after). {catalogChanged} below forces a refresh.
+ if let web = webView, let origin { AppCatalogPull.pull(from: web, origin: origin) }
+ }
+ if body["catalogChanged"] != nil, let web = webView, let origin {
+ // The space's app set changed (apps_changed): the page re-registered
+ // member apps live and told us โ€” re-pull so pickers/routing follow.
+ AppCatalogPull.pull(from: web, origin: origin, force: true)
+ }
if let staged = body["staged"] as? Bool { attachmentStaged = staged }
if let err = body["error"] as? String { lastError = err }
if body.keys.contains("replying") {
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 6ffd2bc..015b419 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -401,8 +401,8 @@ struct NativeChatScreen: View {
// icon: iOS menus align Labels on one
// grid but silently DROP non-image icons
// (the emoji glyphs never rendered).
- Label(GroupApps.title(app.id),
- systemImage: GroupApps.symbol(app.id))
+ Label(model.registry.title(app.id),
+ systemImage: AppIdentity.symbol(for: app.id) ?? "square.grid.2x2")
.tag(app.id)
}
}
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift
index 0d33b72..fef1271 100644
--- a/ios/LChat/Views/NewChatView.swift
+++ b/ios/LChat/Views/NewChatView.swift
@@ -23,6 +23,15 @@ struct NewChatView: View {
private var helpers: [RosterEntry] {
store.botsEnabled ? store.rosterMatches(others.filter { $0.isAgent }, query) : []
}
+ // Personal apps from the registry (places โˆ‹ "you", listed) โ€” the web
+ // picker's Apps section, searchable by title like people are (Mac's
+ // NewChatPopover does the same). Hidden entirely when apps are off.
+ private var visibleApps: [AppManifest] {
+ guard store.appsEnabled else { return [] }
+ let apps = store.registry.personalApps
+ return query.isEmpty ? apps
+ : apps.filter { $0.title.localizedCaseInsensitiveContains(query) }
+ }
var body: some View {
NavigationStack {
@@ -96,7 +105,7 @@ struct NewChatView: View {
Text("No one else has joined this chat yet.")
.foregroundStyle(.secondary)
}
- } else if !query.isEmpty && people.isEmpty && helpers.isEmpty {
+ } else if !query.isEmpty && people.isEmpty && helpers.isEmpty && visibleApps.isEmpty {
Section {
Text("No matches.")
.foregroundStyle(.secondary)
@@ -112,6 +121,11 @@ struct NewChatView: View {
ForEach(helpers, id: \.user_id) { person in personRow(person) }
}
}
+ if !visibleApps.isEmpty {
+ Section("Apps") {
+ ForEach(visibleApps) { app in appRow(app) }
+ }
+ }
if let error {
Section { Text(error).foregroundStyle(.red) }
}
@@ -165,6 +179,40 @@ struct NewChatView: View {
}
}
+ // A personal app row: open (creating on first use) its app-thread and
+ // dismiss into it โ€” the same land-in-the-thread shape as picking a person.
+ private func appRow(_ app: AppManifest) -> some View {
+ Button {
+ busyWith = app.name
+ error = nil
+ Task {
+ let id = await store.openOrCreateAppThread(app: app.name, title: app.title)
+ busyWith = nil
+ if let id { finish(id) } else { error = store.loadError ?? "Couldn't open \(app.title)." }
+ }
+ } label: {
+ HStack(spacing: 12) {
+ Text(app.glyph)
+ .font(.system(size: 17))
+ .frame(width: 36, height: 36)
+ .background(Circle().fill(.primary.opacity(0.06)))
+ VStack(alignment: .leading, spacing: 1) {
+ Text(app.title).foregroundStyle(.primary)
+ if let blurb = app.blurb, !blurb.isEmpty {
+ Text(blurb).font(.footnote).foregroundStyle(.secondary).lineLimit(1)
+ }
+ }
+ Spacer()
+ if busyWith == app.name { ProgressView() }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(busyWith != nil)
+ .accessibilityLabel(app.title)
+ .accessibilityHint("Opens the \(app.title) app")
+ }
+
// Open (creating on first use) the Admin app-thread, then dismiss into it โ€”
// the same land-in-the-thread shape as picking a person.
private func openAdmin() {
@@ -253,17 +301,20 @@ struct NewGroupView: View {
// when the admin turned apps off.
if store.appsEnabled {
Section {
- ForEach(GroupApps.catalog, id: \.id) { app in
- Button { toggleApp(app.id) } label: {
+ // Every group-capable app from the registry (places โˆ‹
+ // "chat", listed) โ€” the same list the โ‹ฎ โ†’ Manage Apps
+ // sheet offers, custom and member apps included.
+ ForEach(store.registry.groupApps) { app in
+ Button { toggleApp(app.name) } label: {
HStack(spacing: 12) {
- Image(systemName: app.symbol)
+ Image(systemName: AppIdentity.symbol(for: app.name) ?? "square.grid.2x2")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.primary)
.frame(width: 32, height: 32)
.background(Circle().fill(.primary.opacity(0.06)))
Text(app.title).foregroundStyle(.primary)
Spacer()
- if selectedApps.contains(app.id) {
+ if selectedApps.contains(app.name) {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.tint)
} else {
Image(systemName: "circle").foregroundStyle(.tertiary)
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index aebf960..5dcc554 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -575,22 +575,28 @@ struct ThreadDestination: View {
}
@ViewBuilder private func chatSurface(_ t: ChatThread) -> some View {
- // Mini-app threads take the webview surface โ€” with two native
- // exceptions: Eleven's onboarding conversations (setup, welcome) run
- // on the bundled flow interpreter, and schema-described mini-apps
- // (To-do, Reminders) render the schema-driven Interface plus the
- // companion Chat view. Everything else keeps the webview, whatever
- // the native-feed flag says.
+ // One registry decision (docs/native-apps.md Layer 3) instead of
+ // hard-coded app-id checks: the flow interpreter and the schema
+ // engine are native enhancements; the webview is the universal
+ // fallback, seeded with the app's own default view โ€” a takeover app
+ // (Admin) opens on its interface, never an empty chat.
if !t.isApp {
NativeChatScreen(instance: store.instance, thread: t, store: store)
- } else if t.appId == "setup" || t.appId == "welcome" {
- ElevenChatScreen(instance: store.instance, thread: t, store: store, path: $path)
- } else if AppSchemaRegistry.hasSchema(for: t.appId), !t.threadKeyB64.isEmpty,
- let screen = SchemaAppScreen(instance: store.instance, thread: t, store: store) {
- screen
} else {
- ChatView(instance: store.instance, thread: t, store: store,
- myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
+ switch store.registry.presentation(t.appId ?? "") {
+ case .flow:
+ ElevenChatScreen(instance: store.instance, thread: t, store: store, path: $path)
+ case .schema where !t.threadKeyB64.isEmpty:
+ if let screen = SchemaAppScreen(instance: store.instance, thread: t, store: store) {
+ screen
+ } else {
+ ChatView(instance: store.instance, thread: t, store: store,
+ myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
+ }
+ default:
+ ChatView(instance: store.instance, thread: t, store: store,
+ myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
+ }
}
}
}
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved
index fe25d1c..d82f91f 100644
--- a/ios/LChatCore/Package.resolved
+++ b/ios/LChatCore/Package.resolved
@@ -1,14 +1,5 @@
{
"pins" : [
- {
- "identity" : "sparkle",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/sparkle-project/Sparkle",
- "state" : {
- "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
- "version" : "2.9.4"
- }
- },
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
diff --git a/ios/LChatCore/Package.swift b/ios/LChatCore/Package.swift
index fc8ce12..9ab7205 100644
--- a/ios/LChatCore/Package.swift
+++ b/ios/LChatCore/Package.swift
@@ -28,7 +28,7 @@ let package = Package(
.target(
name: "LChatCore",
dependencies: [.product(name: "Markdown", package: "swift-markdown")],
- resources: [.copy("Flows"), .copy("Icons"), .copy("Schemas"), .copy("Marks")]
+ resources: [.copy("Flows"), .copy("Icons"), .copy("Schemas"), .copy("Marks"), .copy("Seed")]
),
// The cross-implementation drift gates (fixtures generated by the web
// crypto โ€” see test/threadkey.test.mjs). `swift test` runs them natively
diff --git a/ios/LChatCore/Sources/LChatCore/AppCatalog.swift b/ios/LChatCore/Sources/LChatCore/AppCatalog.swift
index 9b4fd9a..f66527c 100644
--- a/ios/LChatCore/Sources/LChatCore/AppCatalog.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppCatalog.swift
@@ -26,6 +26,10 @@ public struct AppManifest: Codable, Equatable, Sendable, Identifiable {
/// The web's emoji face โ€” the universal fallback; native SF-symbol
/// decoration keys off `name` and falls back to this, never to a blank.
public var glyph: String
+ /// One-line subtitle before first use ("Create a to-do list"). Optional
+ /// on the wire: reports from instances still running the first bridge
+ /// version don't carry it, and a missing field must not fail the decode.
+ public var blurb: String?
/// Where the app can live: any of "you" (personal), "chat" (per-group),
/// "space" (space-wide).
public var places: [String]
@@ -34,6 +38,22 @@ public struct AppManifest: Codable, Equatable, Sendable, Identifiable {
/// A member-built app (user content, sandbox-executed) โ€” vs built-in or
/// admin-installed custom.
public var member: Bool
+ /// Native chrome (docs/icons.md), declared in the app's own JS beside its
+ /// web logo: the SF Symbols name this app's avatar renders ("" = none)โ€ฆ
+ public var symbol: String?
+ /// โ€ฆor the vendored template asset (Marks/<name>.png) for brand apps.
+ public var mark: String?
+ /// The event that makes the app's content visible after an add (board's
+ /// "board.open") โ€” "" / missing = none. The add flows fire it right after
+ /// groupapps.add lands, like the web's installGroupApp; without it the
+ /// app is "silently present but empty" (board.js's own words).
+ public var openEvent: String?
+ /// The connected-service gate ("" / missing = ungated): the pickers hide
+ /// an app whose service isn't in the space's connected set โ€” DNSimple's
+ /// app only "installs" once DNSimple is linked (the web's matchingApps
+ /// rule). Optional on the wire: reports from earlier bridge versions
+ /// don't carry it, and missing must read as ungated (their behaviour).
+ public var requiresService: String?
// The presentation contract, pre-answered by the reporting page (the same
// rules as appWantsChatView / appChatOnly / appDefaultView) so native
// never re-derives it: companion = has a Chat view; chatOnly = ONLY a
@@ -53,9 +73,23 @@ public struct AppManifest: Codable, Equatable, Sendable, Identifiable {
public enum AppCatalog {
/// Decode one appCatalog() report (the JSON an evaluateJavaScript call
/// returns). nil on undecodable input โ€” a native shell must treat that as
- /// "no report", never as an empty app set.
+ /// "no report", never as an empty app set. Per-record tolerant: ONE
+ /// malformed manifest (a member app with an odd shape) must not poison
+ /// the whole catalogue.
public static func decode(reportJSON: Data) -> [AppManifest]? {
- try? JSONDecoder().decode([AppManifest].self, from: reportJSON)
+ struct Row: Decodable { let manifest: AppManifest? }
+ guard let rows = try? JSONDecoder().decode([LenientManifest].self, from: reportJSON)
+ else { return nil }
+ return rows.compactMap(\.manifest)
+ }
+
+ // A wrapper whose init never throws: a record that doesn't decode becomes
+ // nil (and is dropped) instead of failing the array.
+ private struct LenientManifest: Decodable {
+ let manifest: AppManifest?
+ init(from decoder: Decoder) throws {
+ manifest = try? AppManifest(from: decoder)
+ }
}
// Parked per origin so pickers and routing have a catalogue before any
diff --git a/ios/LChatCore/Sources/LChatCore/AppCatalogPull.swift b/ios/LChatCore/Sources/LChatCore/AppCatalogPull.swift
new file mode 100644
index 0000000..4bbcc52
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/AppCatalogPull.swift
@@ -0,0 +1,34 @@
+import WebKit
+import os
+
+let catalogLog = Logger(subsystem: "com.elevenmessenger.messenger", category: "catalog")
+
+// ๐Ÿค– The live half of the registry's catalogue (docs/native-apps.md app-host
+// protocol): any embed page that booted the web app framework can answer
+// window.lchatNative.appCatalog() โ€” the full registered-app set across all
+// three tiers (built-in, admin custom, member). The platform web bridges call
+// pull() when a page reports ready, and again (forced) on a {catalogChanged}
+// post, so the registry learns this space's custom and member apps the first
+// time any webview opens and stays current as the set changes.
+public enum AppCatalogPull {
+ /// Pull the catalogue from a ready embed page into the origin's registry.
+ /// Cheap once a live report has landed (one pull per launch unless
+ /// forced). Tolerant of instances still serving a pre-bridge deploy โ€”
+ /// the eval answers null there and nothing changes.
+ @MainActor
+ public static func pull(from web: WKWebView, origin: String, force: Bool = false) {
+ guard force || !AppRegistry.shared(origin: origin).hasLiveReport else { return }
+ let js = "window.lchatNative && window.lchatNative.appCatalog"
+ + " ? JSON.stringify(window.lchatNative.appCatalog()) : null"
+ web.evaluateJavaScript(js) { result, err in
+ guard let json = result as? String, !json.isEmpty else {
+ let why = err.map(String.init(describing:)) ?? "bridge absent or page booting"
+ catalogLog.notice("catalogue pull: no report (\(why, privacy: .public))")
+ return
+ }
+ Task { @MainActor in
+ AppRegistry.shared(origin: origin).update(reportJSON: Data(json.utf8))
+ }
+ }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/AppIdentity.swift b/ios/LChatCore/Sources/LChatCore/AppIdentity.swift
index fd4a32a..0ffd22b 100644
--- a/ios/LChatCore/Sources/LChatCore/AppIdentity.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppIdentity.swift
@@ -1,62 +1,93 @@
import Foundation
+#if canImport(UIKit)
+import UIKit
+#else
+import AppKit
+#endif
// ๐Ÿค– AppIdentity โ€” the native half of the icon system (docs/icons.md): which
// mark an app wears in natively-rendered rows, headers, and pickers. Three
// kinds, mirroring the web exactly:
//
-// 1. First-party apps โ†’ an SF Symbol (the table in docs/icons.md keeps the
-// web's Lucide glyph and the SF name in step).
+// 1. First-party apps โ†’ an SF Symbol.
// 2. Integration apps โ†’ the real brand mark, vendored as a monochrome
// template PNG in Marks/ (brands have no SF Symbols; these are the same
// SVGs web/static/logos.js carries, rasterized black-on-transparent so
// the platform tints them like a symbol).
// 3. Everything else โ†’ emoji stays CONTENT, never chrome: a member-made
-// app or bot keeps its chosen glyph, and unmapped apps get the generic
-// grid symbol rather than the old "โ–ฆ" text placeholder.
+// app or bot keeps its chosen glyph (the avatar call sites all pass it).
+//
+// WHICH symbol or mark an app wears is not native knowledge anymore: each
+// app declares it in its own JS spec beside its web `logo` (`symbol:` /
+// `mark:`, carried by the manifest report โ€” docs/native-apps.md). AppRegistry
+// pushes the decoration snapshot here on every catalogue load, the bundled
+// seed included, so nothing hand-kept can drift; this file only RENDERS
+// (name โ†’ SF Symbol string, asset key โ†’ vendored template image).
public enum AppIdentity {
- // SF Symbol per first-party app id โ€” one row per docs/icons.md entry.
- // (welcome/setup are Eleven's own threads and wear ElevenBrand.icon; the
- // chat feed app never fronts a thread.)
- private static let symbols: [String: String] = [
- "todo": "checkmark.square",
- "remind": "alarm",
- "bookmarks": "bookmark",
- "poll": "chart.bar",
- "board": "rectangle.on.rectangle",
- "vault": "lock",
- "emoji": "face.smiling",
- "gif": "film",
- "admin": "wrench.adjustable",
- "appstudio": "puzzlepiece",
- "memory": "brain",
- ]
+ private static let lock = NSLock()
+ nonisolated(unsafe) private static var symbols: [String: String] = [:]
+ nonisolated(unsafe) private static var markKeys: [String: String] = [:]
- // Brand-marked apps โ€” the file in Marks/ (all six also exist on web in
- // logos.js; keep the two in step). The Integrations app ("connect") is a
- // capability, not a brand โ€” it wears the plug, like the web.
- private static let marks: [String: String] = [
- "github": "github",
- "dnsimple": "dnsimple",
- "stripe": "stripe",
- "hubspot": "hubspot",
- "linear": "linear",
- "connect": "plug",
- ]
+ /// The registry's feed (seed at init, live reports after): app id โ†’
+ /// declared SF Symbol / mark asset. Merged, new values winning โ€” several
+ /// spaces' registries share this one decoration surface.
+ static func updateDecoration(symbols s: [String: String], marks m: [String: String]) {
+ lock.lock()
+ defer { lock.unlock() }
+ symbols.merge(s) { _, new in new }
+ markKeys.merge(m) { _, new in new }
+ }
/// The SF Symbol an app-thread's avatar renders, or nil when the app
- /// wears a brand mark (see mark(for:)) or isn't an app at all. Unmapped
- /// app ids get the generic grid โ€” never the old "โ–ฆ" text placeholder.
+ /// wears a brand mark (see mark(for:)) or declared none. No generic
+ /// fallback: an app with no declared chrome renders its manifest glyph
+ /// (the avatar call sites all pass it), so a member app wears its own
+ /// emoji, never a generic grid. Call sites that NEED a symbol (iOS menus
+ /// drop non-image Label icons) keep their own ?? "square.grid.2x2".
public static func symbol(for appId: String?) -> String? {
guard let id = appId else { return nil }
- if let s = symbols[id] { return s }
- if marks[id] != nil { return nil } // brand mark wins
- return "square.grid.2x2"
+ lock.lock()
+ let name = symbols[id]
+ lock.unlock()
+ // Member apps can declare a symbol too (their manifest, like a
+ // built-in's) โ€” but a name this OS can't resolve must fall back to
+ // the glyph, never render a blank. Resolution is cached; the rows
+ // re-render constantly.
+ guard let name else { return nil }
+ return resolvable(name) ? name : nil
+ }
+
+ private static func resolvable(_ name: String) -> Bool {
+ lock.lock()
+ if let known = resolvableCache[name] {
+ lock.unlock()
+ return known
+ }
+ lock.unlock()
+ #if canImport(UIKit)
+ let ok = UIImage(systemName: name) != nil
+ #else
+ let ok = NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
+ #endif
+ lock.lock()
+ resolvableCache[name] = ok
+ lock.unlock()
+ return ok
}
+ nonisolated(unsafe) private static var resolvableCache: [String: Bool] = [:]
+
/// The vendored brand mark for an integration app, template-tinted by the
- /// caller. Cached โ€” the PNGs are tiny and the rows re-render constantly.
+ /// caller. The app names its asset (manifest `mark`); only assets that
+ /// actually ship in Marks/ render โ€” a custom app naming one it wishes it
+ /// had simply falls back to its glyph. Cached โ€” the PNGs are tiny and the
+ /// rows re-render constantly.
public static func mark(for appId: String?) -> PlatformImage? {
- guard let id = appId, let file = marks[id] else { return nil }
+ guard let id = appId else { return nil }
+ lock.lock()
+ let file = markKeys[id]
+ lock.unlock()
+ guard let file else { return nil }
if let cached = cache[id] { return cached }
guard let url = Bundle.module.url(forResource: file, withExtension: "png", subdirectory: "Marks"),
let data = try? Data(contentsOf: url),
diff --git a/ios/LChatCore/Sources/LChatCore/AppRegistry.swift b/ios/LChatCore/Sources/LChatCore/AppRegistry.swift
new file mode 100644
index 0000000..78ce573
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/AppRegistry.swift
@@ -0,0 +1,239 @@
+import Foundation
+
+// ๐Ÿค– The one native source of truth for app metadata (docs/native-apps.md
+// Layer 1): every "what is this app called / where can it live / how does it
+// present" question is a registry read, replacing the ~10 hand-kept per-app
+// lookup tables that each restated a fragment of it (appTitles, appGlyphs,
+// appDescriptions, personalApps, GroupApps.catalog, companionApps, the
+// intros-as-companion-gateโ€ฆ).
+//
+// Three layers of records, later winning per app name:
+// 1. the BUNDLED SEED (Seed/app-catalog.json) โ€” appCatalogReport() for
+// every built-in app, generated by tools/gen-app-catalog.mjs and
+// drift-gated by test/appcatalog-seed.test.mjs, so first launch knows
+// the full built-in catalogue with zero per-app native code;
+// 2. the PERSISTED per-origin catalogue (Keychain, AppCatalog.load) โ€” what
+// the last live report said, surviving relaunch (custom + member apps
+// only exist per space);
+// 3. LIVE REPORTS โ€” any embed webview answers appCatalog(); the bridges
+// feed the JSON here on page-ready and on {catalogChanged}.
+//
+// An app the registry doesn't know still works: title falls back to its id,
+// glyph to "โ–ฆ", surface to a webview โ€” that is the first-class-citizen
+// guarantee. Registries are per ORIGIN (one space's member apps are not
+// another's), cached like NativeFeedModelCache.
+@MainActor
+public final class AppRegistry: ObservableObject {
+ @Published public private(set) var manifests: [String: AppManifest] = [:]
+ /// The space's connected third-party services (GET /api/connected) โ€” the
+ /// pickers hide service-gated apps (manifest.requiresService) until their
+ /// service is in this set, mirroring the web (newchat.js: "until it
+ /// arrives, gated apps stay hidden โ€” no flash of a not-yet-usable app").
+ @Published public private(set) var connectedServices: Set<String> = []
+
+ public let origin: String
+ private var reported = false // a live report landed this launch
+
+ private static var registries: [String: AppRegistry] = [:]
+
+ public static func shared(origin: String) -> AppRegistry {
+ if let r = registries[origin] { return r }
+ let r = AppRegistry(origin: origin)
+ registries[origin] = r
+ return r
+ }
+
+ private init(origin: String) {
+ self.origin = origin
+ var merged: [String: AppManifest] = [:]
+ for m in Self.seed { merged[m.name] = m }
+ for m in AppCatalog.load(origin: origin) ?? [] { merged[m.name] = m }
+ manifests = merged
+ Self.pushDecoration(merged)
+ }
+
+ /// The bundled seed, decoded once for every registry (it's per-build,
+ /// not per-origin). Missing/undecodable is an empty floor, not a crash โ€”
+ /// the fallbacks below still answer every read.
+ private static let seed: [AppManifest] = {
+ guard let url = Bundle.module.url(forResource: "app-catalog",
+ withExtension: "json", subdirectory: "Seed"),
+ let data = try? Data(contentsOf: url),
+ let apps = AppCatalog.decode(reportJSON: data) else { return [] }
+ return apps
+ }()
+
+ // ---- feeding ----------------------------------------------------------
+
+ /// Fold one live appCatalog() report (the JSON an evaluateJavaScript call
+ /// returned). The live report is authoritative for the whole app set of
+ /// this space, but the seed still floors every built-in โ€” an embed that
+ /// somehow reported a subset must not un-register todo. Persists per
+ /// origin so the next launch starts from it.
+ public func update(reportJSON: Data) {
+ guard let apps = AppCatalog.decode(reportJSON: reportJSON), !apps.isEmpty else {
+ catalogLog.notice("catalogue report didn't decode (\(reportJSON.count) bytes)")
+ return
+ }
+ catalogLog.notice("catalogue report: \(apps.count) apps")
+ var merged: [String: AppManifest] = [:]
+ for m in Self.seed { merged[m.name] = m }
+ for m in apps { merged[m.name] = m }
+ reported = true
+ if merged != manifests { manifests = merged }
+ Self.pushDecoration(merged)
+ let list = apps
+ let origin = origin
+ Task.detached(priority: .utility) {
+ AppCatalog.save(origin: origin, list)
+ }
+ }
+
+ /// Whether a live report has landed this launch โ€” the bridges use it to
+ /// avoid re-pulling from every webview that becomes ready.
+ public var hasLiveReport: Bool { reported }
+
+ // The apps' own declared native chrome (manifest symbol/mark โ€” each app's
+ // JS spec, docs/icons.md) feeds AppIdentity, the pure renderer: nothing
+ // hand-kept natively can drift from what the apps say about themselves.
+ private static func pushDecoration(_ manifests: [String: AppManifest]) {
+ var symbols: [String: String] = [:]
+ var marks: [String: String] = [:]
+ for (name, m) in manifests {
+ if let sym = m.symbol, !sym.isEmpty { symbols[name] = sym }
+ if let mark = m.mark, !mark.isEmpty { marks[name] = mark }
+ }
+ AppIdentity.updateDecoration(symbols: symbols, marks: marks)
+ }
+
+ /// The app set changed (an apps_changed frame reached a native socket):
+ /// drop the once-per-launch pull latch so the next ready page โ€” or the
+ /// forced pull the caller fires โ€” reports fresh.
+ public func invalidateLiveReport() {
+ reported = false
+ }
+
+ /// Fold the connected-services set (ThreadStore fetches it) โ€” gated apps
+ /// appear/disappear from the pickers with it.
+ public func updateConnected(_ services: Set<String>) {
+ if services != connectedServices { connectedServices = services }
+ }
+
+ // ---- reads ------------------------------------------------------------
+
+ public func manifest(_ id: String?) -> AppManifest? {
+ guard let id else { return nil }
+ return manifests[id]
+ }
+
+ /// Display title โ€” the manifest's, or the id capitalized (an unknown app
+ /// is never blank).
+ public func title(_ id: String) -> String {
+ manifests[id]?.title ?? id.capitalized
+ }
+
+ /// The web's emoji face โ€” the universal glyph fallback ("โ–ฆ" when even the
+ /// manifest is unknown, matching the web's defineApp default).
+ public func glyph(_ id: String) -> String {
+ manifests[id]?.glyph ?? "โ–ฆ"
+ }
+
+ /// One-line subtitle before first use ("Create a to-do list"), or nil.
+ public func blurb(_ id: String) -> String? {
+ let b = manifests[id]?.blurb ?? ""
+ return b.isEmpty ? nil : b
+ }
+
+ /// Whether the app presents as a COMPANION (a Chat view beside/instead of
+ /// its interface) โ€” the web's appWantsChatView, pre-answered.
+ public func isCompanion(_ id: String?) -> Bool {
+ guard let id else { return false }
+ return manifests[id]?.companion ?? false
+ }
+
+ // ---- pickers ----------------------------------------------------------
+
+ /// Personal apps (New chat โ–ธ Apps): places โˆ‹ "you" โ€” the web's
+ /// personalApps() exactly. Deliberately NOT gated on `listed`: that flag
+ /// feeds a different surface (the Apps directory), and member apps
+ /// (which rarely set it) must appear here like they do on the web.
+ public var personalApps: [AppManifest] {
+ placedApps(place: "you")
+ }
+
+ /// Group-capable apps (โ‹ฎ โ–ธ Apps, new-group seeding): places โˆ‹ "chat" โ€”
+ /// the web's groupCapableApps. Same deliberate no-`listed` rule.
+ public var groupApps: [AppManifest] {
+ placedApps(place: "chat")
+ }
+
+ /// Space-capable apps: places โˆ‹ "space" (the web's spaceCapableApps โ€”
+ /// deliberately NOT listed-gated, matching the web).
+ public var spaceCapableApps: [AppManifest] {
+ manifests.values.filter { $0.places.contains("space") }
+ .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
+ }
+
+ private func placedApps(place: String) -> [AppManifest] {
+ manifests.values.filter { $0.places.contains(place) && serviceAvailable($0) }
+ .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
+ }
+
+ // The web's matchingApps gate: an app naming a required service shows
+ // only once that service is connected. Missing/empty = ungated.
+ private func serviceAvailable(_ m: AppManifest) -> Bool {
+ guard let svc = m.requiresService, !svc.isEmpty else { return true }
+ return connectedServices.contains(svc)
+ }
+
+ /// The read-time install filter (the web's groupAppsFor/spaceAppsFor
+ /// rule): folded installs surface only when this client knows the app AND
+ /// the app can live in that place โ€” a hostile or stale add naming
+ /// anything else stays inert.
+ public func known(_ items: [GroupApps.Item], place: String = "chat") -> [GroupApps.Item] {
+ items.filter { manifests[$0.id]?.places.contains(place) ?? false }
+ }
+
+ public func knownSpace(_ items: [SpaceApps.Item]) -> [SpaceApps.Item] {
+ items.filter { manifests[$0.id]?.places.contains("space") ?? false }
+ }
+
+ // ---- surface routing ---------------------------------------------------
+
+ /// How an app-thread presents. One shared decision for both platforms'
+ /// routers (docs/native-apps.md Layer 3) โ€” native enhancements (the flow
+ /// interpreter, the schema engine) are selected here, and the webview is
+ /// the universal fallback, so an unknown app always opens.
+ public enum Presentation: Equatable {
+ /// The bundled conversation-flow interpreter (setup/welcome).
+ case flow
+ /// The native schema engine (a bundled Schemas/<id>.json exists).
+ /// `companion` mirrors the manifest โ€” schema surfaces offer the Chat
+ /// view exactly when the web would.
+ case schema(companion: Bool)
+ /// A Chat view beside (Mac: Chat|Split|Interface) or instead of the
+ /// interface webview. `defaultView` is "chat" | "split" | "interface";
+ /// chatOnly means there is no interface at all.
+ case companion(defaultView: String, chatOnly: Bool, chatReadOnly: Bool)
+ /// The app's render owns the whole surface: a webview seeded
+ /// view:"interface". The universal fallback.
+ case takeover
+ }
+
+ public func presentation(_ id: String) -> Presentation {
+ // The flow interpreter is a native enhancement keyed by id โ€” these
+ // two are the product's own onboarding conversations, not catalogue
+ // apps (places:[], never picked).
+ if id == "setup" || id == "welcome" { return .flow }
+ if AppSchemaRegistry.hasSchema(for: id) {
+ return .schema(companion: isCompanion(id))
+ }
+ guard let m = manifests[id] else { return .takeover }
+ if m.companion {
+ return .companion(defaultView: m.defaultView,
+ chatOnly: m.chatOnly,
+ chatReadOnly: m.chatReadOnly)
+ }
+ return .takeover
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 804484e..b6dff93 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -1,3 +1,4 @@
+import Combine
import Foundation
import SwiftUI
import AVFoundation
@@ -684,10 +685,31 @@ public final class ChatFeedModel: ObservableObject {
recomputeGroupApps()
}
+ /// This space's app registry โ€” the group-apps read filter and the Apps
+ /// sheet both draw from it (docs/native-apps.md phase 2).
+ public var registry: AppRegistry { AppRegistry.shared(origin: instance.origin) }
+ private var registryWatch: AnyCancellable?
+
+ /// The read-time install filter depends on the catalogue, which can learn
+ /// an app AFTER the fold ran (a member app deployed mid-session): refold
+ /// when it changes, or a fresh install stays invisible until the next
+ /// event happens to arrive.
+ private func watchRegistry() {
+ guard registryWatch == nil else { return }
+ registryWatch = registry.$manifests
+ .dropFirst()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] _ in self?.recomputeGroupApps() }
+ }
+
private func recomputeGroupApps() {
- let folded = GroupApps.known(GroupApps.fold(seed: groupAppsSeed,
- upTo: groupAppsUpTo,
- events: groupAppsEvents))
+ watchRegistry()
+ // Read-time filter against the registry (the web's groupAppsFor):
+ // installs of apps this client knows AND that are chat-capable
+ // surface; anything else folds but stays inert.
+ let folded = registry.known(GroupApps.fold(seed: groupAppsSeed,
+ upTo: groupAppsUpTo,
+ events: groupAppsEvents))
if folded != groupApps { groupApps = folded }
}
@@ -702,9 +724,27 @@ public final class ChatFeedModel: ObservableObject {
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
encryptAndSend(payload, via: socket)
+ // Apps that gate their content behind an "open" event (board) fire it
+ // right after the add, exactly like the web's installGroupApp โ€”
+ // without it the app renders silently empty for every member.
+ if add, let ev = registry.manifest(app)?.openEvent, !ev.isEmpty {
+ var open = OpenEventOut(type: ev, name: instance.screenName,
+ ts: Int64(Date().timeIntervalSince1970 * 1000))
+ if !myUserId.isEmpty { open.uid = myUserId }
+ encryptAndSend(open, via: socket)
+ }
return true
}
+ // The bare emit envelope for an app's openEvent โ€” {type} stamped with
+ // name/ts/uid like every app event.
+ private struct OpenEventOut: Encodable {
+ let type: String
+ let name: String
+ let ts: Int64
+ var uid: String?
+ }
+
// The outgoing groupapps event โ€” shell.js's emit envelope around
// {type, app}: stamped with name, ts (ms) and uid like every app event.
private struct GroupAppsOut: Encodable {
diff --git a/ios/LChatCore/Sources/LChatCore/GroupApps.swift b/ios/LChatCore/Sources/LChatCore/GroupApps.swift
index 8c13aab..e11bdc0 100644
--- a/ios/LChatCore/Sources/LChatCore/GroupApps.swift
+++ b/ios/LChatCore/Sources/LChatCore/GroupApps.swift
@@ -35,35 +35,23 @@ public enum GroupApps {
}
}
- /// The apps a group can add โ€” the web's groupCapableApps() (apps whose
- /// `places` include "chat"). Keep in sync with web/static/apps/*.js like
- /// ThreadStore.appTitles.
- /// `glyph` is the web's emoji face (avatars, the Mac dropdown); `symbol`
- /// is the SF-symbol twin for iOS menus, which drop any Label icon that
- /// isn't a real image.
- public static let catalog: [(id: String, title: String, glyph: String, symbol: String)] = [
- ("todo", "To-do", "โœ“", "checkmark.square"),
- ("remind", "Reminders", "โฐ", "alarm"),
- ("bookmarks", "Bookmarks", "๐Ÿ”–", "bookmark"),
- ("vault", "Vault", "๐Ÿ”’", "lock"),
- ]
+ // The old 4-app `catalog` (and its `known` read-filter) collapsed into
+ // AppRegistry (docs/native-apps.md phase 2): pickers render the
+ // registry's groupApps, and the install read-filter is registry.known โ€”
+ // custom and member apps included, nothing hand-kept per app.
+ /// Display titles for the CHAT PROJECTION only ("Ana added To-do to this
+ /// chat") โ€” AppChatProjection runs on detached fold paths and can't read
+ /// the MainActor registry, so this nonisolated table stays until phase
+ /// 5's projection queries retire it. Everything UI-facing reads
+ /// AppRegistry instead.
public static func title(_ id: String) -> String {
- catalog.first { $0.id == id }?.title ?? id
- }
-
- public static func glyph(_ id: String) -> String {
- catalog.first { $0.id == id }?.glyph ?? "โ–ฆ"
- }
-
- public static func symbol(_ id: String) -> String {
- catalog.first { $0.id == id }?.symbol ?? "square.grid.2x2"
- }
-
- /// The web's groupAppsFor filter: resolve folded items against the catalog โ€”
- /// a hostile or stale add naming anything else stays inert.
- public static func known(_ items: [Item]) -> [Item] {
- items.filter { item in catalog.contains { $0.id == item.id } }
+ let titles = [
+ "todo": "To-do", "remind": "Reminders", "bookmarks": "Bookmarks",
+ "vault": "Vault", "board": "Whiteboard", "poll": "Poll",
+ "webhook-monitor": "Webhook Monitor",
+ ]
+ return titles[id] ?? id
}
/// Recompute the added-apps list from the snapshot seed plus every event
diff --git a/ios/LChatCore/Sources/LChatCore/GroupAppsSheet.swift b/ios/LChatCore/Sources/LChatCore/GroupAppsSheet.swift
index eb3bd94..e7bb91e 100644
--- a/ios/LChatCore/Sources/LChatCore/GroupAppsSheet.swift
+++ b/ios/LChatCore/Sources/LChatCore/GroupAppsSheet.swift
@@ -7,11 +7,17 @@ import SwiftUI
// every member's fold agrees on. Shared by iOS and the Mac.
public struct GroupAppsSheet: View {
@ObservedObject private var model: ChatFeedModel
+ // Observed in its own right: the catalogue can learn an app while the
+ // sheet is open (a member app deployed from another client โ€” the
+ // apps_changed pull), and the model only publishes when the FOLD
+ // changes, not when the registry does.
+ @ObservedObject private var registry: AppRegistry
@Environment(\.dismiss) private var dismiss
@State private var error: String?
public init(model: ChatFeedModel) {
self.model = model
+ self.registry = model.registry
}
public var body: some View {
@@ -27,7 +33,10 @@ public struct GroupAppsSheet: View {
Divider()
List {
Section {
- ForEach(GroupApps.catalog, id: \.id) { app in
+ // Every group-capable app from the registry (places โˆ‹
+ // "chat", listed) โ€” custom and member apps included, so
+ // the sheet matches the web's, not a hand-kept subset.
+ ForEach(registry.groupApps) { app in
row(app)
}
} footer: {
@@ -50,8 +59,8 @@ public struct GroupAppsSheet: View {
#endif
}
- private func row(_ app: (id: String, title: String, glyph: String, symbol: String)) -> some View {
- let added = model.groupApps.contains { $0.id == app.id }
+ private func row(_ app: AppManifest) -> some View {
+ let added = model.groupApps.contains { $0.id == app.name }
return HStack(spacing: 12) {
Text(app.glyph)
.font(.system(size: 17))
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 1b20e49..b57ec3f 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -157,6 +157,15 @@ public struct LChatAPI {
return (resp as? HTTPURLResponse)?.statusCode == 200
}
+ /// The space's connected third-party services (connect.go): the set the
+ /// pickers gate service apps on (AppRegistry.updateConnected). nil on a
+ /// network error so callers keep what they hold.
+ public func connectedServices() async -> Set<String>? {
+ struct Resp: Codable { let services: [String]? }
+ guard let r = try? await getJSON("/api/connected", Resp.self) else { return nil }
+ return Set(r.services ?? [])
+ }
+
/// The instance's hidden space thread backing space-wide app installs
/// (spacethread.go): (id, its open_key โ€” the thread key wrapped under the
/// INSTANCE key) once created, or (nil, "") when no space thread exists
diff --git a/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json b/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json
new file mode 100644
index 0000000..55d3905
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json
@@ -0,0 +1,586 @@
+[
+ {
+ "name": "admin",
+ "title": "Admin",
+ "glyph": "๐Ÿ› ๏ธ",
+ "blurb": "",
+ "places": [],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "wrench.adjustable",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": true,
+ "chatOnly": false,
+ "chatReadOnly": true,
+ "defaultView": "interface",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "board",
+ "title": "Whiteboard",
+ "glyph": "๐ŸŽจ",
+ "blurb": "Sketch on a whiteboard",
+ "places": [
+ "chat"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "rectangle.on.rectangle",
+ "mark": "",
+ "openEvent": "board.open",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/board",
+ "desc": "",
+ "args": "",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "bookmarks",
+ "title": "Bookmarks",
+ "glyph": "๐Ÿ”–",
+ "blurb": "Save a bookmark",
+ "places": [
+ "you",
+ "chat"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "bookmark",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/save",
+ "desc": "Save a bookmark",
+ "args": "<url> [| note]",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "chat",
+ "title": "Chat",
+ "glyph": "๐Ÿ’ฌ",
+ "blurb": "",
+ "places": [],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "connect",
+ "title": "Integrations",
+ "glyph": "๐Ÿ”Œ",
+ "blurb": "Connect third-party services",
+ "places": [
+ "you"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "plug",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/connect",
+ "desc": "Connect a third-party service",
+ "args": "",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "dnsimple",
+ "title": "DNSimple",
+ "glyph": "๐ŸŒ",
+ "blurb": "",
+ "places": [
+ "you"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "dnsimple",
+ "openEvent": "",
+ "requiresService": "dnsimple",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "emoji",
+ "title": "Emoji",
+ "glyph": "๐Ÿ˜Š",
+ "blurb": "",
+ "places": [],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "face.smiling",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/emoji",
+ "desc": "browse emoji โ€” tap one to add it to your message",
+ "args": "[search]",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "gif",
+ "title": "GIFs",
+ "glyph": "๐ŸŽž๏ธ",
+ "blurb": "",
+ "places": [
+ "space"
+ ],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "film",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/gif",
+ "desc": "find a GIF โ€” tap one to send it here",
+ "args": "[search]",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "github",
+ "title": "GitHub",
+ "glyph": "๐Ÿ™",
+ "blurb": "",
+ "places": [
+ "you"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "github",
+ "openEvent": "",
+ "requiresService": "github",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "hubspot",
+ "title": "HubSpot",
+ "glyph": "๐Ÿงฒ",
+ "blurb": "Contacts & deals",
+ "places": [
+ "you"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "hubspot",
+ "openEvent": "",
+ "requiresService": "hubspot",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "installs",
+ "title": "Apps",
+ "glyph": "โ–ฆ",
+ "blurb": "",
+ "places": [],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "linear",
+ "title": "Linear",
+ "glyph": "๐Ÿ“",
+ "blurb": "",
+ "places": [
+ "you"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "linear",
+ "openEvent": "",
+ "requiresService": "linear",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "memory",
+ "title": "Memory",
+ "glyph": "๐Ÿง ",
+ "blurb": "",
+ "places": [],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "brain",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/memory",
+ "desc": "See and edit what this AI remembers about you",
+ "args": "",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "poll",
+ "title": "Poll",
+ "glyph": "๐Ÿ“Š",
+ "blurb": "Start a poll",
+ "places": [
+ "chat"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "chart.bar",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/poll",
+ "desc": "",
+ "args": "",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "remind",
+ "title": "Reminders",
+ "glyph": "โฐ",
+ "blurb": "Set a reminder",
+ "places": [
+ "you",
+ "chat"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "alarm",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": true,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "/remind",
+ "commands": [
+ {
+ "name": "/remind",
+ "desc": "Set a reminder",
+ "args": "<what> <when>",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "setup",
+ "title": "Eleven",
+ "glyph": "๐Ÿ•š",
+ "blurb": "",
+ "places": [],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": true,
+ "chatOnly": true,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "stripe",
+ "title": "Stripe",
+ "glyph": "๐Ÿ’ณ",
+ "blurb": "Balance, charges & payouts",
+ "places": [
+ "you"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "stripe",
+ "openEvent": "",
+ "requiresService": "stripe",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "todo",
+ "title": "To-do",
+ "glyph": "โœ“",
+ "blurb": "Create a to-do list",
+ "places": [
+ "you",
+ "chat"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "checkmark.square",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": true,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "/add",
+ "commands": [
+ {
+ "name": "/todo",
+ "desc": "Add a to-do",
+ "args": "<task>",
+ "local": false
+ },
+ {
+ "name": "/add",
+ "desc": "Add a to-do",
+ "args": "<task>",
+ "local": true
+ }
+ ]
+ },
+ {
+ "name": "vault",
+ "title": "Vault",
+ "glyph": "๐Ÿ”’",
+ "blurb": "Keep secrets safe",
+ "places": [
+ "you",
+ "chat"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "lock",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/vault",
+ "desc": "Store a secret",
+ "args": "<name> = <value>",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "views",
+ "title": "Views",
+ "glyph": "โ–ค",
+ "blurb": "",
+ "places": [],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/views",
+ "desc": "See this chat's views",
+ "args": "",
+ "local": false
+ }
+ ]
+ },
+ {
+ "name": "webhook-monitor",
+ "title": "Webhook Monitor",
+ "glyph": "๐Ÿ“ก",
+ "blurb": "",
+ "places": [
+ "you",
+ "chat",
+ "space"
+ ],
+ "listed": true,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": true,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": [
+ {
+ "name": "/clear-webhooks",
+ "desc": "Clear all webhook events from this monitor",
+ "args": "",
+ "local": true
+ }
+ ]
+ },
+ {
+ "name": "webhooks",
+ "title": "Webhooks",
+ "glyph": "๐Ÿ“ฅ",
+ "blurb": "",
+ "places": [],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": false,
+ "chatOnly": false,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ },
+ {
+ "name": "welcome",
+ "title": "Eleven",
+ "glyph": "๐Ÿ•š",
+ "blurb": "",
+ "places": [],
+ "listed": false,
+ "quiet": false,
+ "member": false,
+ "symbol": "",
+ "mark": "",
+ "openEvent": "",
+ "requiresService": "",
+ "companion": true,
+ "chatOnly": true,
+ "chatReadOnly": false,
+ "defaultView": "split",
+ "defaultCommand": "",
+ "commands": []
+ }
+]
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index de09663..733195f 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -29,6 +29,11 @@ public final class ThreadStore: ObservableObject {
// lifecycle rides this store's; the platform socket handlers route the
// space thread's frames to it (reloadSpaceApps / spaceApps.handleLive).
public private(set) lazy var spaceApps = SpaceAppsStore(instance: instance)
+
+ /// This space's app registry (docs/native-apps.md Layer 1) โ€” the one
+ /// source for app titles, glyphs, places, presentation, and pickers.
+ public var registry: AppRegistry { AppRegistry.shared(origin: instance.origin) }
+ private var connectedFetchedAt = Date.distantPast
// Sealed-branding latch: the decrypted name, resolved at most once per
// store lifetime (see refresh โ€” the resolver is a 600k-iteration PBKDF2).
private var sealedInstanceName: String?
@@ -120,6 +125,10 @@ public final class ThreadStore: ObservableObject {
/// the loading scene is a truly-first-visit state.
public func hydrate() async {
guard threads.isEmpty else { return }
+ // Touch the registry before the first rows publish: its init pushes
+ // the seed's decoration (app symbols/marks) into AppIdentity, so the
+ // first frame's rows wear their chrome instead of flashing emoji.
+ _ = registry
let origin = instance.origin
let span = Perf.begin("threads.hydrate", slowMs: 500)
defer { span.end() }
@@ -236,13 +245,17 @@ public final class ThreadStore: ObservableObject {
? prefsFetch?.blob : nil
let heldMuted = mutedPeople
let pendingMutedOps = mutedOps
+ // Snapshot the app catalogue on the main actor (AppRegistry is
+ // MainActor state; the fold runs detached) โ€” titles, glyphs, and
+ // no-activity blurbs all come from it now.
+ let manifests = registry.manifests
let folded = await Task.detached(priority: .userInitiated) {
Self.fold(instance: inst, myUserId: uid, peopleIn: people,
wrappedInstanceKey: wrappedInstanceKey, serverThreads: serverThreads,
nicknamesBlob: nickBlob, heldNicknames: heldNicknames,
pendingOps: pendingOps, prefsBlob: prefsBlob,
heldMuted: heldMuted, pendingMutedOps: pendingMutedOps,
- tierRows: tierRows)
+ tierRows: tierRows, manifests: manifests)
}.value
instanceKeyB64 = folded.instanceKeyB64
tierKeysB64 = folded.tierKeysB64
@@ -315,6 +328,16 @@ public final class ThreadStore: ObservableObject {
// space thread's live frames carry changes from then on).
let ik = instanceKeyB64
Task { await spaceApps.loadIfNeeded(instanceKeyB64: ik) }
+ // Connected services (the pickers' service-app gate) โ€” refresh fires
+ // on every socket frame, so throttle the fetch to once a minute.
+ if Date().timeIntervalSince(connectedFetchedAt) > 60 {
+ connectedFetchedAt = Date()
+ Task { [weak self] in
+ guard let self, let api = self.api,
+ let services = await api.connectedServices() else { return }
+ self.registry.updateConnected(services)
+ }
+ }
}
/// A space_thread prod (the content-free "it was just created" frame) or
@@ -352,7 +375,8 @@ public final class ThreadStore: ObservableObject {
prefsBlob: String? = nil,
heldMuted: Set<String> = [],
pendingMutedOps: [String: Bool] = [:],
- tierRows: [TierRow] = []) -> Folded {
+ tierRows: [TierRow] = [],
+ manifests: [String: AppManifest] = [:]) -> Folded {
var people = peopleIn
var instanceKeyB64: String?
var instanceGroupKey: GroupKey?
@@ -481,12 +505,12 @@ public final class ThreadStore: ObservableObject {
let title: String
switch t.kind {
case "self":
- // Built-in apps take their name from the catalog, so renaming an
- // app (e.g. Connected services โ†’ Integrations) reaches existing
- // threads too โ€” matching the web, where displayName() also reads
- // the catalog title. Unknown/custom apps keep the stored title;
- // a bare self-thread is "Notes".
- if let app { title = appTitles[app] ?? appPayload?.title ?? app.capitalized }
+ // Apps take their name from the registry catalogue, so renaming
+ // an app (e.g. Connected services โ†’ Integrations) reaches
+ // existing threads too โ€” matching the web, where displayName()
+ // also reads the catalog title. Unknown apps keep the stored
+ // title; a bare self-thread is "Notes".
+ if let app { title = manifests[app]?.title ?? appPayload?.title ?? app.capitalized }
else { title = "Notes" }
case "dm":
// displayLabels: the DM titles by MY name for the person (#577).
@@ -520,7 +544,8 @@ public final class ThreadStore: ObservableObject {
// An app thread with no activity yet shows its blurb instead of a
// blank, vertically-centred title (the row layout top-aligns the
// moment there's a subtitle) โ€” same intent as the web sidebar.
- if preview.isEmpty, let app, let blurb = appDescriptions[app] {
+ if preview.isEmpty, let app,
+ let blurb = manifests[app]?.blurb, !blurb.isEmpty {
preview = blurb
}
@@ -601,7 +626,7 @@ public final class ThreadStore: ObservableObject {
avatar: avatar,
isApp: app != nil,
appId: app,
- glyph: app.map { appGlyphs[$0] ?? "โ–ฆ" },
+ glyph: app.map { manifests[$0]?.glyph ?? "โ–ฆ" },
badge: badge,
// The header's status line: yours on the Notes row, the other
// member's on a DM; groups/apps carry none (web parity).
@@ -1286,6 +1311,12 @@ public final class ThreadStore: ObservableObject {
let memberIDs = ids
let memberPubs = pubs
let seedApps = kind == "group" ? apps : []
+ // Apps gating content behind an openEvent (board) get it fired right
+ // after their add, like the web's installGroupApp โ€” captured on the
+ // main actor (the registry is MainActor state; the build is detached).
+ let openEvents = Dictionary(uniqueKeysWithValues: seedApps.compactMap { app in
+ (registry.manifest(app)?.openEvent).flatMap { $0.isEmpty ? nil : (app, $0) }
+ })
let screenName = instance.screenName
let uid = myUserId
let openIKB64 = (openGroup && kind == "group") ? instanceKeyB64 : nil
@@ -1328,6 +1359,14 @@ public final class ThreadStore: ObservableObject {
let blob = try? key.encryptBytes(data).base64urlEncodedString() {
appBlobs.append(blob)
}
+ if let ev = openEvents[app] {
+ var open: [String: Any] = ["type": ev, "name": screenName, "ts": ts]
+ if !uid.isEmpty { open["uid"] = uid }
+ if let data = try? JSONSerialization.data(withJSONObject: open),
+ let blob = try? key.encryptBytes(data).base64urlEncodedString() {
+ appBlobs.append(blob)
+ }
+ }
}
}
return (nameBlob, members, appBlobs, openKey)
@@ -1422,40 +1461,12 @@ public final class ThreadStore: ObservableObject {
threads[i].unread = false
}
- // Built-in mini-app display titles, for app-threads created before the title was
- // stored in name_blob (newer ones carry their own). Keep in sync with each app's
- // `title` in web/static/apps/*.js.
- nonisolated private static let appTitles: [String: String] = [
- "todo": "To-do", "remind": "Reminders", "poll": "Poll",
- "bookmarks": "Bookmarks", "board": "Whiteboard", "chat": "Chat",
- "connect": "Integrations", "dnsimple": "DNSimple", "github": "GitHub", "admin": "Admin",
- "welcome": "Eleven", "setup": "Eleven", // the onboarding conversations โ€” host-mounted, never in pickers
- ]
-
- // Each app's emoji face โ€” the sidebar/list avatar, mirroring the `glyph`
- // in web/static/apps/*.js (keep in sync like appTitles). The web and native
- // faces must match, so a service looks the same on every surface.
- nonisolated private static let appGlyphs: [String: String] = [
- "todo": "โœ“", "remind": "โฐ", "poll": "๐Ÿ“Š",
- "bookmarks": "๐Ÿ”–", "board": "๐ŸŽจ", "chat": "๐Ÿ’ฌ", "admin": "๐Ÿ› ๏ธ",
- "connect": "๐Ÿ”Œ", "dnsimple": "๐ŸŒ", "github": "๐Ÿ™", "welcome": "๐Ÿ•š", "setup": "๐Ÿ•š",
- ]
-
- // A one-line "what you'd do here" blurb, shown as the sidebar subtitle for an
- // app thread with no activity yet โ€” instead of a lonely, vertically-centred
- // title. Mirrors each app's `blurb` in web/static/apps/*.js (keep in sync).
- nonisolated private static let appDescriptions: [String: String] = [
- "todo": "Create a to-do list", "remind": "Set a reminder",
- "poll": "Start a poll", "bookmarks": "Save a bookmark",
- "board": "Sketch on a whiteboard", "connect": "Connect third-party services",
- ]
-
- /// The personal apps a picker can offer (New chat's APPS section) โ€”
- /// open-or-create via openOrCreateAppThread.
- public static let personalApps: [(id: String, title: String, glyph: String)] = [
- ("todo", "To-do", "โœ“"), ("remind", "Reminders", "โฐ"), ("poll", "Poll", "๐Ÿ“Š"),
- ("bookmarks", "Bookmarks", "๐Ÿ”–"), ("board", "Whiteboard", "๐ŸŽจ"),
- ]
+ // App titles, glyphs, and no-activity blurbs all come from the registry
+ // catalogue now (AppRegistry โ€” the seed generated from the web apps by
+ // tools/gen-app-catalog.mjs, superseded by live appCatalog() reports),
+ // so nothing here needs keeping in sync per app anymore. The former
+ // appTitles / appGlyphs / appDescriptions / personalApps tables collapsed
+ // into registry reads (docs/native-apps.md phase 2).
/// Add people to a group: wrap the EXISTING thread key to each newcomer's
/// public key and register them (mirrors the web's member sheet). Returns
diff --git a/ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift b/ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift
index 5b6d608..11a42c0 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift
@@ -48,10 +48,12 @@ final class AppCatalogTests: XCTestCase {
}
// Undecodable input is "no report", never an empty app set โ€” an empty
- // catalogue would read as "this space has no apps" downstream.
+ // catalogue would read as "this space has no apps" downstream. A malformed
+ // RECORD inside a valid report is dropped alone (per-record tolerance):
+ // one odd member-app manifest must not poison the whole catalogue.
func testUndecodableReportIsNil() {
XCTAssertNil(AppCatalog.decode(reportJSON: Data("not json".utf8)))
- XCTAssertNil(AppCatalog.decode(reportJSON: Data(#"[{"name":"x"}]"#.utf8)))
+ XCTAssertEqual(AppCatalog.decode(reportJSON: Data(#"[{"name":"x"}]"#.utf8)), [])
}
// The record round-trips through Codable losslessly โ€” what the per-origin
diff --git a/ios/LChatCore/Tests/LChatCoreTests/AppRegistryTests.swift b/ios/LChatCore/Tests/LChatCoreTests/AppRegistryTests.swift
new file mode 100644
index 0000000..9ddc7bf
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/AppRegistryTests.swift
@@ -0,0 +1,130 @@
+import XCTest
+@testable import LChatCore
+
+// ๐Ÿค– The registry (docs/native-apps.md Layer 1): the bundled seed must load
+// and answer every question the collapsed lookup tables used to โ€” titles,
+// glyphs, blurbs, picker lists, the install read-filter, and surface
+// presentation โ€” and live reports must merge over it. The seed itself is
+// drift-gated against the web framework by test/appcatalog-seed.test.mjs;
+// these tests pin the Swift reads of it.
+@MainActor
+final class AppRegistryTests: XCTestCase {
+ // Distinct origins per test: registries are per-origin process singletons.
+ private func fresh(_ tag: String) -> AppRegistry {
+ AppRegistry.shared(origin: "https://\(tag).registry-tests.example")
+ }
+
+ func testSeedLoadsBuiltins() {
+ let r = fresh("seed")
+ XCTAssertEqual(r.title("todo"), "To-do")
+ XCTAssertEqual(r.glyph("vault"), "๐Ÿ”’")
+ XCTAssertEqual(r.title("connect"), "Integrations")
+ XCTAssertEqual(r.blurb("board"), "Sketch on a whiteboard")
+ // The doc's first-class-citizen guarantee: unknown apps still answer.
+ XCTAssertEqual(r.title("mystery-app"), "Mystery-App")
+ XCTAssertEqual(r.glyph("mystery-app"), "โ–ฆ")
+ XCTAssertNil(r.blurb("mystery-app"))
+ }
+
+ func testPickerLists() {
+ let r = fresh("pickers")
+ let personal = r.personalApps.map(\.name)
+ let group = r.groupApps.map(\.name)
+ // The web's personal catalogue (places โˆ‹ "you", listed) โ€” todo yes,
+ // board no (board is chat-only on the web too).
+ XCTAssertTrue(personal.contains("todo"))
+ XCTAssertTrue(personal.contains("bookmarks"))
+ XCTAssertFalse(personal.contains("board"))
+ XCTAssertFalse(personal.contains("admin")) // places: [] โ€” offered by the admin row, not the catalogue
+ // The group list gains the apps the old 4-entry catalog was missing โ€”
+ // the "board can't be added to a group" fix.
+ XCTAssertTrue(group.contains("board"))
+ XCTAssertTrue(group.contains("poll"))
+ XCTAssertTrue(group.contains("vault"))
+ XCTAssertFalse(group.contains("connect")) // personal-only
+ }
+
+ func testKnownFiltersInstallsByPlace() {
+ let r = fresh("known")
+ let items = [
+ GroupApps.Item(id: "board", by: "Ana", ts: 1),
+ GroupApps.Item(id: "evil-app", by: "Mallory", ts: 2),
+ GroupApps.Item(id: "connect", by: "Ana", ts: 3), // real app, but not chat-capable
+ ]
+ XCTAssertEqual(r.known(items).map(\.id), ["board"])
+ let space = [
+ SpaceApps.Item(id: "gif", by: "Ana", ts: 1, uid: "u1"),
+ SpaceApps.Item(id: "todo", by: "Ana", ts: 2, uid: "u1"), // not space-capable
+ ]
+ XCTAssertEqual(r.knownSpace(space).map(\.id), ["gif"])
+ }
+
+ func testPresentation() {
+ let r = fresh("presentation")
+ XCTAssertEqual(r.presentation("setup"), .flow)
+ XCTAssertEqual(r.presentation("welcome"), .flow)
+ XCTAssertEqual(r.presentation("todo"), .schema(companion: true))
+ XCTAssertEqual(r.presentation("remind"), .schema(companion: true))
+ // Admin: a companion whose first view is its console (the Mac Chat|View
+ // / iOS opens-as-the-app fix); its Chat view is the read-only update
+ // stream, hence chatReadOnly.
+ XCTAssertEqual(r.presentation("admin"),
+ .companion(defaultView: "interface", chatOnly: false, chatReadOnly: true))
+ // Takeover is the universal fallback: vault, and any unknown app.
+ XCTAssertEqual(r.presentation("vault"), .takeover)
+ XCTAssertEqual(r.presentation("mystery-app"), .takeover)
+ }
+
+ func testLiveReportMergesOverSeed() {
+ let r = fresh("live-report")
+ XCTAssertFalse(r.hasLiveReport)
+ let report = """
+ [{"name":"habits","title":"Habit tracker","glyph":"โœ…","blurb":"Track a habit",
+ "places":["you"],"listed":true,"quiet":false,"member":true,
+ "companion":true,"chatOnly":false,"chatReadOnly":false,
+ "defaultView":"chat","defaultCommand":"","commands":[]},
+ {"name":"todo","title":"Tasks","glyph":"โœ“","blurb":"","places":["you","chat"],
+ "listed":true,"quiet":false,"member":false,"companion":true,"chatOnly":false,
+ "chatReadOnly":false,"defaultView":"split","defaultCommand":"/add","commands":[]}]
+ """.data(using: .utf8)!
+ r.update(reportJSON: report)
+ XCTAssertTrue(r.hasLiveReport)
+ // The member app registered and is personal-pickable.
+ XCTAssertEqual(r.title("habits"), "Habit tracker")
+ XCTAssertTrue(r.personalApps.map(\.name).contains("habits"))
+ XCTAssertEqual(r.presentation("habits"),
+ .companion(defaultView: "chat", chatOnly: false, chatReadOnly: false))
+ // The live report wins per app (a renamed todo renames here)โ€ฆ
+ XCTAssertEqual(r.title("todo"), "Tasks")
+ // โ€ฆbut the seed still floors every built-in the report didn't carry.
+ XCTAssertEqual(r.title("vault"), "Vault")
+ // An empty/undecodable report changes nothing.
+ r.update(reportJSON: Data("null".utf8))
+ XCTAssertEqual(r.title("habits"), "Habit tracker")
+ }
+
+ // Service-gated apps (manifest.requiresService โ€” GitHub, DNSimpleโ€ฆ) hide
+ // from the pickers until the space's connected set names their service:
+ // the web's matchingApps rule (newchat.js), so admin-native and member-web
+ // show the same list.
+ func testServiceGatedAppsHideUntilConnected() {
+ let r = fresh("services")
+ XCTAssertFalse(r.personalApps.map(\.name).contains("github"))
+ XCTAssertFalse(r.personalApps.map(\.name).contains("dnsimple"))
+ XCTAssertTrue(r.personalApps.map(\.name).contains("todo")) // ungated
+ r.updateConnected(["github"])
+ XCTAssertTrue(r.personalApps.map(\.name).contains("github"))
+ XCTAssertFalse(r.personalApps.map(\.name).contains("dnsimple"))
+ }
+
+ // Decoration flows from the apps' own manifests (symbol/mark in each JS
+ // spec โ†’ the report โ†’ the registry โ†’ AppIdentity): nothing hand-kept
+ // natively. The seed alone must dress the built-ins.
+ func testDecorationComesFromManifests() {
+ _ = fresh("decoration") // init pushes the seed's decoration
+ XCTAssertEqual(AppIdentity.symbol(for: "todo"), "checkmark.square")
+ XCTAssertEqual(AppIdentity.symbol(for: "admin"), "wrench.adjustable")
+ XCTAssertNil(AppIdentity.symbol(for: "github")) // brand mark, not a symbol
+ XCTAssertNil(AppIdentity.symbol(for: "some-member-app")) // emoji is content
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
index ea13f4d..b541763 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
@@ -74,11 +74,12 @@ final class GroupAppsVectorTests: XCTestCase {
}
// Unknown app names fold into state (log convergence) but are filtered at
- // read time โ€” the web's groupAppsFor rule.
+ // read time โ€” the web's groupAppsFor rule, now the registry's known().
+ @MainActor
func testUnknownAppsStayInert() {
let out = fold([1: .init(type: "groupapps.add", app: "evil-app", by: "Mallory", ts: 0)])
XCTAssertEqual(out.count, 1)
- XCTAssertEqual(GroupApps.known(out), [])
+ XCTAssertEqual(AppRegistry.shared(origin: "https://vector-tests.example").known(out), [])
}
// The chat projection โ€” apps.js groupAppsApp.groupFeedMessage, word for word
diff --git a/ios/LChatMac/AppThreadWebView.swift b/ios/LChatMac/AppThreadWebView.swift
index 4ac8155..fcb9a31 100644
--- a/ios/LChatMac/AppThreadWebView.swift
+++ b/ios/LChatMac/AppThreadWebView.swift
@@ -113,6 +113,9 @@ struct AppWebSurface: View {
final class MacWebBridge: NSObject, ObservableObject, WKNavigationDelegate,
WKScriptMessageHandler, WKUIDelegate {
weak var webView: WKWebView?
+ // The instance origin, for the app-catalogue pull (set where the entry is
+ // built) โ€” a ready page reports the space's live app set into AppRegistry.
+ var origin: String?
// True once the page has real content. shell.js posts {ready:true} after
// its first render โ€” the honest "painted" moment; didFinish is only the
@@ -140,9 +143,17 @@ final class MacWebBridge: NSObject, ObservableObject, WKNavigationDelegate,
func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
- guard message.name == "lchat", let body = message.body as? [String: Any],
- body["ready"] as? Bool == true else { return }
+ guard message.name == "lchat", let body = message.body as? [String: Any] else { return }
+ if body["catalogChanged"] != nil, let web = webView, let origin {
+ // The space's app set changed (apps_changed) โ€” the page already
+ // re-registered member apps live; re-pull the catalogue.
+ AppCatalogPull.pull(from: web, origin: origin, force: true)
+ }
+ guard body["ready"] as? Bool == true else { return }
pageReady = true
+ // A ready page has loaded all three app tiers โ€” pull the live
+ // catalogue into the registry (one pull per launch; cheap after).
+ if let web = webView, let origin { AppCatalogPull.pull(from: web, origin: origin) }
}
// Message links render as target="_blank" anchors โ€” route them to the
@@ -223,6 +234,7 @@ final class AppWebViewCache {
}
if let old = entries.removeValue(forKey: key) { release(old) }
let bridge = MacWebBridge()
+ bridge.origin = instance.origin // the app-catalogue pull's registry key
let cfg = WKWebViewConfiguration()
cfg.websiteDataStore = .default()
if let script = AppThreadWebView.bootScript(instance: instance, thread: thread,
diff --git a/ios/LChatMac/CommandPanelWebView.swift b/ios/LChatMac/CommandPanelWebView.swift
index eb6cd4f..5810be0 100644
--- a/ios/LChatMac/CommandPanelWebView.swift
+++ b/ios/LChatMac/CommandPanelWebView.swift
@@ -38,8 +38,10 @@ final class CommandHost: NSObject, WKScriptMessageHandler {
var sink: ((CommandHostEvent) -> Void)?
private var ready = false
private var queued: [String] = []
+ private let origin: String // the app-catalogue pull's registry key
init(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?) {
+ origin = instance.origin
var seed: [String: Any] = [
"token": instance.deviceToken,
"name": instance.screenName,
@@ -121,6 +123,14 @@ final class CommandHost: NSObject, WKScriptMessageHandler {
let pending = queued
queued = []
for text in pending { run(text) }
+ // The host page loaded all three app tiers โ€” pull the live
+ // catalogue into the registry (cheap once one report landed).
+ AppCatalogPull.pull(from: web, origin: origin)
+ return
+ }
+ if dict["catalogChanged"] != nil {
+ // The space's app set changed (apps_changed) โ€” re-pull.
+ AppCatalogPull.pull(from: web, origin: origin, force: true)
return
}
if dict.keys.contains("panel") {
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 4bcede1..448e335 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -294,7 +294,7 @@ struct NativeChatView: View {
if id.hasPrefix("app:") {
app.nativeOpenThread = nil
let appID = String(id.dropFirst(4))
- let title = ThreadStore.personalApps.first { $0.id == appID }?.title ?? appID
+ let title = store.registry.title(appID)
Task {
if let tid = await store.openOrCreateAppThread(app: appID, title: title) {
selectedID = tid
@@ -328,9 +328,9 @@ struct NativeChatView: View {
.map { .init(id: "user:" + $0.user_id, title: $0.label,
glyph: nil, avatar: store.avatarSource(for: $0)) }
let openApps = Set(store.threads.compactMap(\.appId))
- items += ThreadStore.personalApps
- .filter { !openApps.contains($0.id) }
- .map { .init(id: "app:" + $0.id, title: $0.title, glyph: $0.glyph, avatar: nil, appId: $0.id) }
+ items += store.registry.personalApps
+ .filter { !openApps.contains($0.name) }
+ .map { .init(id: "app:" + $0.name, title: $0.title, glyph: $0.glyph, avatar: nil, appId: $0.name) }
app.nativeChats = items
}
@@ -810,25 +810,53 @@ struct NativeChatView: View {
// The companion split (web: Chat | Split | Interface): the CHAT half is
// the native feed, the interface stays the app's webview in a resizable
- // sidebar. Which apps are companion mirrors appWantsChatView (apps.js) โ€”
- // the apps that implement chatMessage; keep in sync like appTitles.
+ // sidebar. Which apps are companion is the manifest's declaration
+ // (appWantsChatView, pre-answered in the registry catalogue) โ€” no
+ // hand-kept set to keep in sync anymore.
enum AppViewMode: String, CaseIterable {
case chat = "Chat", split = "Both", interface = "View"
}
- private static let companionApps: Set<String> = ["todo", "remind"]
@State private var appModes: [String: AppViewMode] = [:] // per-thread, session-only
@AppStorage("macAppPaneWidth") private var appPaneWidth = 380.0
@State private var appPaneDragStart: Double?
private func isCompanion(_ t: ChatThread) -> Bool {
- t.isApp && Self.companionApps.contains(t.appId ?? "")
+ // The full presentation decision, not the bare companion flag: Eleven's
+ // conversations (setup/welcome) are chatOnly companions on the web but
+ // run on the FLOW interpreter here โ€” presentation() puts flow first,
+ // and a chat-only companion has no interface to split with, so both
+ // fall through to the ladder's later branches (a bare isCompanion
+ // check split Eleven into two copies of the same chat).
+ guard t.isApp else { return false }
+ switch store.registry.presentation(t.appId ?? "") {
+ case .schema(let companion):
+ return companion
+ case .companion(_, let chatOnly, _):
+ return !chatOnly
+ default:
+ return false // flow and takeover
+ }
}
private func appModeBinding(_ t: ChatThread) -> Binding<AppViewMode> {
- Binding(get: { appModes[t.id] ?? .split },
+ // First open honours the app's own defaultView ("interface" is how
+ // Admin opens on its console; the web's appDefaultView) โ€” after that
+ // the member's per-session choice sticks.
+ Binding(get: { appModes[t.id] ?? initialMode(t) },
set: { appModes[t.id] = $0 })
}
+ private func initialMode(_ t: ChatThread) -> AppViewMode {
+ guard case .companion(let defaultView, let chatOnly, _) =
+ store.registry.presentation(t.appId ?? "") else { return .split }
+ if chatOnly { return .chat }
+ switch defaultView {
+ case "chat": return .chat
+ case "interface": return .interface
+ default: return .split
+ }
+ }
+
// ---- group apps: the shared widgets a group added (web's groupapps) ----
// Which added app a group's pane shows, per thread, session-only โ€” the
@@ -873,7 +901,7 @@ struct NativeChatView: View {
.font(.system(size: 12, weight: .medium))
}
appModeButton(.interface, binding) {
- Text(GroupApps.glyph(active ?? ""))
+ Text(store.registry.glyph(active ?? ""))
.font(.system(size: 11))
}
}
@@ -890,7 +918,7 @@ struct NativeChatView: View {
sel.wrappedValue = app.id
if binding.wrappedValue == .chat { binding.wrappedValue = .split }
} label: {
- Text("\(GroupApps.glyph(app.id)) \(GroupApps.title(app.id))")
+ Text("\(store.registry.glyph(app.id)) \(store.registry.title(app.id))")
}
}
Divider()
diff --git a/ios/LChatMac/NewChatPopover.swift b/ios/LChatMac/NewChatPopover.swift
index 8a1b947..9b171ee 100644
--- a/ios/LChatMac/NewChatPopover.swift
+++ b/ios/LChatMac/NewChatPopover.swift
@@ -43,10 +43,13 @@ struct NewChatPopover: View {
}
// The web picker searches apps by title too (matchingApps/adminMatches) โ€”
- // typing "to" should surface To-do just like it does there.
+ // typing "to" should surface To-do just like it does there. The list is
+ // the registry's (places โˆ‹ "you", listed) โ€” custom and member apps
+ // included, identically to the web's New chat โ–ธ Apps.
private var visibleApps: [(id: String, title: String, glyph: String)] {
- query.isEmpty ? ThreadStore.personalApps
- : ThreadStore.personalApps.filter { $0.title.localizedCaseInsensitiveContains(query) }
+ let apps = store.registry.personalApps.map { (id: $0.name, title: $0.title, glyph: $0.glyph) }
+ return query.isEmpty ? apps
+ : apps.filter { $0.title.localizedCaseInsensitiveContains(query) }
}
private var adminVisible: Bool {
store.isAdmin && (query.isEmpty || "Admin".localizedCaseInsensitiveContains(query))
@@ -171,8 +174,9 @@ struct NewChatPopover: View {
.kerning(0.5)
.foregroundStyle(.tertiary)
.padding(EdgeInsets(top: 10, leading: 8, bottom: 2, trailing: 0))
- ForEach(GroupApps.catalog, id: \.id) { appEntry in
- groupAppRow(appEntry)
+ ForEach(store.registry.groupApps) { app in
+ groupAppRow((id: app.name, title: app.title, glyph: app.glyph,
+ symbol: AppIdentity.symbol(for: app.name)))
}
}
}
@@ -398,7 +402,7 @@ struct NewChatPopover: View {
// A tickable row for a shared app in group mode โ€” same shape as personRow's
// multi-select, the app's glyph for a face.
- private func groupAppRow(_ entry: (id: String, title: String, glyph: String, symbol: String)) -> some View {
+ private func groupAppRow(_ entry: (id: String, title: String, glyph: String, symbol: String?)) -> some View {
Button {
if selectedApps.contains(entry.id) {
selectedApps.remove(entry.id)
diff --git a/package.json b/package.json
index a4c20ed..1934512 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs"
}
}
diff --git a/test/appcatalog-seed.test.mjs b/test/appcatalog-seed.test.mjs
new file mode 100644
index 0000000..913196e
--- /dev/null
+++ b/test/appcatalog-seed.test.mjs
@@ -0,0 +1,40 @@
+// ๐Ÿค– Drift gate for the native seed catalogue: the JSON bundled into
+// LChatCore (Seed/app-catalog.json, decoded by AppRegistry) must equal what
+// tools/gen-app-catalog.mjs generates from the live app framework. When an
+// app's manifest facts change (title, glyph, places, presentation, commands),
+// regenerate with `node tools/gen-app-catalog.mjs --write` and commit both
+// sides โ€” the same shape as the schema drift gate (schema-engine.test.mjs).
+import { readFileSync } from "node:fs";
+import { collect, seedPath } from "../tools/gen-app-catalog.mjs";
+
+const generated = JSON.stringify(await collect(), null, 2) + "\n";
+const committed = readFileSync(seedPath, "utf8");
+
+if (generated !== committed) {
+ console.error("FAIL: the bundled native seed catalogue has drifted from the app framework.");
+ console.error("Run: node tools/gen-app-catalog.mjs --write (then commit the seed)");
+ const a = JSON.parse(generated), b = JSON.parse(committed);
+ const an = new Set(a.map((x) => x.name)), bn = new Set(b.map((x) => x.name));
+ const missing = [...an].filter((n) => !bn.has(n));
+ const stale = [...bn].filter((n) => !an.has(n));
+ if (missing.length) console.error(" missing from seed:", missing.join(", "));
+ if (stale.length) console.error(" stale in seed:", stale.join(", "));
+ process.exit(1);
+}
+
+// A couple of load-bearing facts native routing depends on โ€” if these change
+// on the web side, the native routers' behaviour changes with them; fail
+// loudly so the change is a decision, not an accident.
+const seed = JSON.parse(committed);
+const byName = Object.fromEntries(seed.map((a) => [a.name, a]));
+function assert(cond, msg) {
+ if (!cond) { console.error("FAIL:", msg); process.exit(1); }
+}
+assert(byName.admin?.companion === true && byName.admin?.defaultView === "interface",
+ "admin must be a companion opening on its interface (the Mac Chat|View / iOS routing fix)");
+assert(byName.board?.places.includes("chat"),
+ "board must be group-capable (the board-in-a-group fix)");
+assert(byName.todo?.companion === true && byName.remind?.companion === true,
+ "todo/remind stay companions (the native schema surfaces key off this)");
+
+console.log(`appcatalog-seed: seed matches the framework (${seed.length} apps)`);
diff --git a/test/appcatalog.test.mjs b/test/appcatalog.test.mjs
index edf9da8..44f7cb3 100644
--- a/test/appcatalog.test.mjs
+++ b/test/appcatalog.test.mjs
@@ -56,6 +56,9 @@ for (const a of report) {
assert.ok(a.name, "name is never empty");
assert.strictEqual(typeof a.title, "string");
assert.strictEqual(typeof a.glyph, "string");
+ assert.strictEqual(typeof a.blurb, "string");
+ assert.strictEqual(typeof a.requiresService, "string");
+ assert.strictEqual(typeof a.openEvent, "string");
assert.ok(Array.isArray(a.places));
for (const p of a.places) assert.ok(["you", "chat", "space"].includes(p), `place ${p} unknown`);
for (const k of ["listed", "quiet", "member", "companion", "chatOnly", "chatReadOnly"]) {
diff --git a/tools/gen-app-catalog.mjs b/tools/gen-app-catalog.mjs
new file mode 100644
index 0000000..3662367
--- /dev/null
+++ b/tools/gen-app-catalog.mjs
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+// ๐Ÿค– Generates the NATIVE seed catalogue โ€” appCatalogReport() for every
+// built-in app, as the JSON the iOS/Mac apps bundle
+// (ios/LChatCore/Sources/LChatCore/Seed/app-catalog.json, decoded by
+// AppRegistry). The seed is what lets native know every built-in app's
+// manifest facts (title, glyph, places, presentation, commands) with ZERO
+// per-app native code before any webview has reported the live catalogue;
+// the runtime appCatalog() pull supersedes it per space (custom + member
+// apps only exist there). Run after adding or changing an app:
+//
+// node tools/gen-app-catalog.mjs --write # regenerate the bundled seed
+// node tools/gen-app-catalog.mjs # print to stdout
+//
+// test/appcatalog-seed.test.mjs pins the committed seed against this
+// generator's output, so a drifted seed fails `npm test` โ€” the same
+// drift-gate shape as the schema copies (test/schema-engine.test.mjs).
+import { readdirSync, writeFileSync, readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const appsDir = path.join(root, "web", "static", "apps");
+export const seedPath = path.join(
+ root, "ios", "LChatCore", "Sources", "LChatCore", "Seed", "app-catalog.json");
+
+// appstudio.js needs a real DOM at module scope (it builds its editor
+// eagerly), so it can't load headless. It registers no places and no
+// commands โ€” nothing a native picker or router reads โ€” so the seed simply
+// omits it; spaces that install it still report it via the live pull.
+const SKIP = new Set(["appstudio.js"]);
+
+export async function collect() {
+ const files = readdirSync(appsDir)
+ .filter((f) => f.endsWith(".js") && !SKIP.has(f))
+ .sort();
+ for (const f of files) await import(path.join(appsDir, f));
+ const { appCatalogReport } = await import(path.join(appsDir, "..", "apps.js"));
+ // Sorted by name so regenerating is a stable diff.
+ return appCatalogReport().sort((a, b) => a.name.localeCompare(b.name));
+}
+
+const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+if (isMain) {
+ const json = JSON.stringify(await collect(), null, 2) + "\n";
+ if (process.argv.includes("--write")) {
+ const before = (() => { try { return readFileSync(seedPath, "utf8"); } catch { return ""; } })();
+ writeFileSync(seedPath, json);
+ console.log(before === json ? `unchanged: ${seedPath}` : `wrote ${seedPath}`);
+ } else {
+ process.stdout.write(json);
+ }
+}
diff --git a/web/static/apps.js b/web/static/apps.js
index ee8c9ae..fd9db94 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -542,10 +542,26 @@ export function appCatalogReport() {
name: app.name,
title: app.title || app.name,
glyph: app.glyph || "โ–ฆ",
+ blurb: app.blurb || "",
places: [...app.places],
listed: !!app.listed,
quiet: !!app.quiet,
member: !!app.memberApp,
+ // Native chrome (docs/icons.md): the SF Symbols name / vendored template
+ // asset iOS and Mac render for this app โ€” declared HERE, in the app's own
+ // spec beside its web `logo`, so no native table has to be kept in step.
+ // "" = none: the app's emoji glyph is its face everywhere.
+ symbol: app.symbol || "",
+ mark: app.mark || "",
+ // Some apps (board) gate their visible content behind an "open" event
+ // rather than rendering the moment they're added โ€” the add flow fires it
+ // right after the install lands (installGroupApp), and native add paths
+ // must do the same or the app sits silently present but empty.
+ openEvent: app.openEvent || "",
+ // The connected-service gate ("" = ungated): DNSimple's app only
+ // "installs" once you've linked DNSimple โ€” the pickers hide gated apps
+ // until /api/connected lists the service (newchat.js matchingApps).
+ requiresService: app.requiresService || "",
// The presentation contract, pre-answered (same rules as appWantsChatView
// / appChatOnly / appDefaultView) so native never re-derives it: companion
// = has a Chat view; chatOnly = ONLY a Chat view; otherwise no companion
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index 82bef25..b09bde8 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -99,6 +99,7 @@ export const app = defineApp({
name: "admin",
title: "Admin",
glyph: "๐Ÿ› ๏ธ",
+ symbol: "wrench.adjustable", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_WRENCH,
places: [], // host-mounted (the admin thread) โ€” never user-instantiable
snapshot: false,
diff --git a/web/static/apps/appstudio.js b/web/static/apps/appstudio.js
index 91e6269..8ae45ac 100644
--- a/web/static/apps/appstudio.js
+++ b/web/static/apps/appstudio.js
@@ -54,6 +54,7 @@ export const app = defineApp({
name: "hello", // unique lowercase id โ€” rename for your app
title: "Hello", // shown in the Apps list
glyph: "๐Ÿ‘‹", // one emoji
+ symbol: "puzzlepiece", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
initialState: () => ({ count: 0 }),
diff --git a/web/static/apps/board.js b/web/static/apps/board.js
index 837238a..38187cb 100644
--- a/web/static/apps/board.js
+++ b/web/static/apps/board.js
@@ -55,6 +55,7 @@ export const app = defineApp({
title: "Whiteboard",
blurb: "Sketch on a whiteboard", // sidebar subtitle before first use (sync w/ native)
glyph: "๐ŸŽจ",
+ symbol: "rectangle.on.rectangle", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_PRESENTATION,
// Drawing together is the point โ€” the whiteboard lives in a chat (places
// model; the ambient panel retired with #405). /board in a group adopts it.
diff --git a/web/static/apps/bookmarks.js b/web/static/apps/bookmarks.js
index 97d544e..e12cc8e 100644
--- a/web/static/apps/bookmarks.js
+++ b/web/static/apps/bookmarks.js
@@ -54,6 +54,7 @@ export const app = defineApp({
title: "Bookmarks",
blurb: "Save a bookmark", // sidebar subtitle before first use (sync w/ native)
glyph: "๐Ÿ”–",
+ symbol: "bookmark", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_BOOKMARK,
// The reference places-first declaration (docs/space-apps.md, phase 3):
// your reading list or a group's shared link list โ€” the same reducer
diff --git a/web/static/apps/connect.js b/web/static/apps/connect.js
index 614ee06..91d06e7 100644
--- a/web/static/apps/connect.js
+++ b/web/static/apps/connect.js
@@ -63,6 +63,7 @@ export const app = defineApp({
// activity (mirrored natively in ThreadStore.appDescriptions โ€” keep in sync).
blurb: "Connect third-party services",
glyph: "๐Ÿ”Œ",
+ mark: "plug", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
logo: LOGO_PLUG,
places: ["you"],
placeNote: "Connections use your own account credentials โ€” each person links their own.",
diff --git a/web/static/apps/dnsimple.js b/web/static/apps/dnsimple.js
index e058614..c416ba4 100644
--- a/web/static/apps/dnsimple.js
+++ b/web/static/apps/dnsimple.js
@@ -50,6 +50,7 @@ export const app = defineApp({
name: "dnsimple",
title: "DNSimple",
glyph: "๐ŸŒ",
+ mark: "dnsimple", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
logo: LOGO_DNSIMPLE,
places: ["you"],
// Only listed in New chat once DNSimple is linked (shell.js gates on this).
diff --git a/web/static/apps/emoji.js b/web/static/apps/emoji.js
index 92b762b..e8f2945 100644
--- a/web/static/apps/emoji.js
+++ b/web/static/apps/emoji.js
@@ -28,6 +28,7 @@ export const app = defineApp({
name: "emoji",
title: "Emoji",
glyph: "๐Ÿ˜Š",
+ symbol: "face.smiling", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_SMILE,
// Command-only: no personal thread to route to (the command acts where you
// typed it) and nothing to instantiate from the Apps menu.
diff --git a/web/static/apps/gif.js b/web/static/apps/gif.js
index b48029e..3c87938 100644
--- a/web/static/apps/gif.js
+++ b/web/static/apps/gif.js
@@ -78,6 +78,7 @@ export const app = defineApp({
name: "gif",
title: "GIFs",
glyph: "๐ŸŽž๏ธ",
+ symbol: "film", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_FILM,
// The command acts in the chat you typed it in, but its one piece of data โ€”
// the space's Giphy key โ€” lives in the space. listed:false keeps it out of the
diff --git a/web/static/apps/github.js b/web/static/apps/github.js
index 1f883bf..25160d2 100644
--- a/web/static/apps/github.js
+++ b/web/static/apps/github.js
@@ -84,6 +84,7 @@ export const app = defineApp({
name: "github",
title: "GitHub",
glyph: "๐Ÿ™",
+ mark: "github", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
logo: LOGO_GITHUB,
places: ["you"],
// Only listed in New chat once GitHub is linked (shell.js gates on this).
diff --git a/web/static/apps/hubspot.js b/web/static/apps/hubspot.js
index b4b4f11..21799ea 100644
--- a/web/static/apps/hubspot.js
+++ b/web/static/apps/hubspot.js
@@ -60,6 +60,7 @@ export const app = defineApp({
title: "HubSpot",
blurb: "Contacts & deals",
glyph: "๐Ÿงฒ",
+ mark: "hubspot", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
logo: LOGO_HUBSPOT,
places: ["you"],
// Only listed in New chat once HubSpot is linked (shell.js gates on this).
diff --git a/web/static/apps/linear.js b/web/static/apps/linear.js
index 0bc7215..b58563f 100644
--- a/web/static/apps/linear.js
+++ b/web/static/apps/linear.js
@@ -76,6 +76,7 @@ export const app = defineApp({
name: "linear",
title: "Linear",
glyph: "๐Ÿ“",
+ mark: "linear", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
places: ["you"],
// Only listed in New chat once Linear is linked (shell.js gates on this).
requiresService: "linear",
diff --git a/web/static/apps/memory.js b/web/static/apps/memory.js
index aaf79c0..f2ce198 100644
--- a/web/static/apps/memory.js
+++ b/web/static/apps/memory.js
@@ -35,6 +35,7 @@ defineApp({
name: "memory",
title: "Memory",
glyph: "๐Ÿง ",
+ symbol: "brain", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_BRAIN,
places: [], // a scratchpad panel over the current DM โ€” never a listed app
commands: {
diff --git a/web/static/apps/poll.js b/web/static/apps/poll.js
index 3b4f94a..06adc6e 100644
--- a/web/static/apps/poll.js
+++ b/web/static/apps/poll.js
@@ -52,6 +52,7 @@ export const app = defineApp({
title: "Poll",
blurb: "Start a poll", // sidebar subtitle before first use (sync w/ native)
glyph: "๐Ÿ“Š",
+ symbol: "chart.bar", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_CHART_COLUMN,
// A poll needs an audience โ€” it lives in a chat (the places model; the old
// always-on panel above every feed retired with #405). Using /poll in a
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
index 501e3b1..93e0b15 100644
--- a/web/static/apps/remind.js
+++ b/web/static/apps/remind.js
@@ -165,6 +165,7 @@ export const app = defineApp({
title: schema.title,
blurb: schema.blurb, // sidebar subtitle before first use (sync w/ native)
glyph: schema.glyph,
+ symbol: "alarm", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_ALARM_CLOCK,
// Also addable to a group chat (โ‹ฎ โ†’ Add apps): shared reminders for the whole
// group โ€” the server delivers remind.fire into the GROUP thread, so every
diff --git a/web/static/apps/stripe.js b/web/static/apps/stripe.js
index 67b112b..791ebed 100644
--- a/web/static/apps/stripe.js
+++ b/web/static/apps/stripe.js
@@ -79,6 +79,7 @@ export const app = defineApp({
title: "Stripe",
blurb: "Balance, charges & payouts",
glyph: "๐Ÿ’ณ",
+ mark: "stripe", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
logo: LOGO_STRIPE,
places: ["you"],
// Only listed in New chat once Stripe is linked (shell.js gates on this).
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js
index d152004..6e4c27f 100644
--- a/web/static/apps/todo.js
+++ b/web/static/apps/todo.js
@@ -31,6 +31,7 @@ export const app = defineApp({
title: schema.title,
blurb: schema.blurb, // sidebar subtitle before first use (sync w/ native)
glyph: schema.glyph,
+ symbol: "checkmark.square", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_SQUARE_CHECK,
// Also addable to a group chat (โ‹ฎ โ†’ Add apps): one SHARED list for the whole
// group โ€” the same events and folds, applied to the group thread's log
diff --git a/web/static/apps/vault.js b/web/static/apps/vault.js
index 9b43dce..ae0aae2 100644
--- a/web/static/apps/vault.js
+++ b/web/static/apps/vault.js
@@ -158,6 +158,7 @@ export const app = defineApp({
title: "Vault",
blurb: "Keep secrets safe", // sidebar subtitle before first use (sync w/ native)
glyph: "๐Ÿ”’",
+ symbol: "lock", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
logo: ICON_LOCK,
// The SPACE place (a space vault as the team credential shelf โ€” one shared
// set of secrets for everyone the space already trusts) was a deliberate
diff --git a/web/static/sandbox-frame.js b/web/static/sandbox-frame.js
index 4f4c501..5784ccd 100644
--- a/web/static/sandbox-frame.js
+++ b/web/static/sandbox-frame.js
@@ -331,6 +331,12 @@ export function manifestOf(spec) {
name: spec.name,
title: spec.title,
glyph: spec.glyph,
+ // Optional native chrome: an SF Symbols name iOS/Mac render for the app
+ // (docs/icons.md) โ€” declared beside the emoji glyph, exactly like a
+ // built-in. The emoji stays the universal identity; a bad name just
+ // falls back to it. (Brand `mark`s are NOT a member-app field: naming a
+ // vendored asset would wear another product's logo.)
+ ...(typeof spec.symbol === "string" ? { symbol: spec.symbol } : {}),
types: spec.types || [],
commands: Object.entries(spec.commands || {}).map(([name, c]) => ({
name,
diff --git a/web/static/sandbox.js b/web/static/sandbox.js
index d01a49c..bef36a6 100644
--- a/web/static/sandbox.js
+++ b/web/static/sandbox.js
@@ -107,6 +107,11 @@ export function validateManifest(m) {
if (c.args !== undefined && (typeof c.args !== "string" || c.args.length > 40)) return "bad command args";
}
}
+ // Optional native chrome (docs/icons.md): an SF Symbols name โ€” dotted
+ // lowercase words, bounded. The emoji glyph stays the identity; a name
+ // native can't resolve falls back to it. `mark` is deliberately NOT a
+ // member-app field (vendored brand assets โ€” impersonation).
+ if (m.symbol !== undefined && (typeof m.symbol !== "string" || m.symbol.length > 60 || !/^[a-z0-9.]+$/.test(m.symbol))) return "bad symbol";
// Optional companion opt-in: the app projects each event into a Chat view.
if (m.chat !== undefined && typeof m.chat !== "boolean") return "bad chat flag";
// Optional group opt-in: the app may ALSO be added to a group chat, where
@@ -785,6 +790,7 @@ export function registerMemberApp({ id, manifest, source, author }) {
name: manifest.name,
title: manifest.title,
glyph: manifest.glyph,
+ symbol: manifest.symbol,
blurb: author ? `Built by ${author}` : "A member-built app",
incremental: true, // the iframe must survive re-renders; we pump instead
initialState: () => ({ v: null, vUpTo: 0, pend: [], chat: [] }),