eleven / messenger Public

๐Ÿค– Native apps: app-host protocol phases 0โ€“1 (web bridge + space installs survive natively) (#1256)

* docs+web: native apps as first-class app-platform clients โ€” design + app-host protocol phase 0

The design brief (docs/native-apps.md): the native apps must recognise and
use ANY app in a space โ€” built-in, admin custom, or member-built โ€” with zero
per-app native code. It maps the platform contract a client implements, the
honest state of native today (three parallel subsystems, ~10 drifting per-app
lookup tables, no space-thread support at all), traces each user-visible
breakage to its root cause, and lays out the target architecture (AppRegistry
/ app-host protocol / registry-driven surfaces) in six small phases. No
server changes anywhere โ€” the platform is already client-folded by design.

Phase 0 ships here, web-side (the part the native phases decode):
- appCatalogReport() in apps.js โ€” every registered app's manifest facts
  (identity, places, presentation contract, commands) as plain JSON
- window.lchatNative.appCatalog() / commandItems() on the embed bridge โ€” the
  catalogue and the per-thread commandCatalog rows, pull-based for
  evaluateJavaScript
- embeds now answer apps_changed with a {catalogChanged} post (native owns
  reloads) instead of falling through to the web reload banner
- test/appcatalog.test.mjs pins the shapes against the real framework

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWjr7oU3uxP1RYCtB1qU6Y

* native: space-wide installs survive on iOS/Mac โ€” SpaceApps fold + store (app-host phase 1)

Phase 1 of docs/native-apps.md, all in LChatCore + thin socket wiring:

- SpaceApps.swift โ€” the pure fold half of the web's spaceapps registry
  (installsApp), GroupApps' twin one level up: Item carries the installer's
  uid (the admins-only policy filters by it at read time), parseSnapshot
  reads the unified "installs" snapshot key with the legacy "spaceapps"
  fallback. SpaceAppsVectorTests transcribes all 6 vectors from
  test/event-vectors/spaceapps.json plus horizon/order/wire-decode pins.
- SpaceAppsStore.swift โ€” fetches GET /api/space-thread, unwraps open_key
  under the INSTANCE key (the existing OpenKeyPayload envelope), seeds from
  the encrypted slot-"apps" snapshot, pages the catch-up, folds live
  msg/delete frames, and parks the folded list in the Keychain per origin
  (the ThreadListSnapshot posture) โ€” so kill + relaunch still lists a
  space's installs, offline included.
- AppCatalog.swift โ€” AppManifest decodes appCatalogReport() (phase 0's
  bridge report) losslessly + per-origin Keychain persistence;
  AppCatalogTests is the Swift half of the contract test/appcatalog.test.mjs
  pins on the web side. Feed wiring (webview pull on {catalogChanged})
  lands with the registry consumers in phase 2.
- ChatSocket.Frame gains `kind` (apps_changed's payload); both platform
  sidebar sockets route space_thread prods and the space thread's own
  frames to the store (that thread never appears in /api/threads, so a
  refresh would learn nothing about it).
- GroupApps.parseSnapshot bug fix in passing: it read only the legacy
  "groupapps" key, so a slot-"apps" snapshot written by a current web
  client (unified "installs" key) seeded NOTHING natively โ€” group installs
  only survived while old-key snapshots lingered.

swift test: 349 pass (15 new); Mac + iOS app targets build.

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

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam eb116472f25f51c6939f46de9137588c6c696f48 parent a9c608f
17 files changed, +1164 −5
  • docs/native-apps.md +288 −0
  • ios/LChat/Views/ThreadListView.swift +13 −0
  • ios/LChatCore/Sources/LChatCore/AppCatalog.swift +80 −0
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +1 −0
  • ios/LChatCore/Sources/LChatCore/GroupApps.swift +9 −4
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +12 −0
  • ios/LChatCore/Sources/LChatCore/SpaceApps.swift +117 −0
  • ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift +193 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +17 −0
  • ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift +69 −0
  • ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift +12 −0
  • ios/LChatCore/Tests/LChatCoreTests/SpaceAppsVectorTests.swift +146 −0
  • ios/LChatMac/NativeChatView.swift +13 −0
  • package.json +1 −1
  • test/appcatalog.test.mjs +131 −0
  • web/static/apps.js +35 −0
  • web/static/shell.js +27 −0
diff --git a/docs/native-apps.md b/docs/native-apps.md
new file mode 100644
index 0000000..cc95caa
--- /dev/null
+++ b/docs/native-apps.md
@@ -0,0 +1,288 @@
+# ๐Ÿค– Native apps ร— the app platform: apps as first-class citizens
+
+> Status: **DESIGN + PHASES 0โ€“1 (2026-07-22)** โ€” 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`
+> unwrap, the `spaceapps` fold pinned by `SpaceAppsVectorTests`, live-frame
+> wiring on both platforms, per-origin Keychain persistence for installs and
+> the `AppManifest` catalogue). Phases 2+ land as their own small PRs, iOS
+> first per CLAUDE.md. This note is the handoff brief for all of them โ€” the
+> same role `docs/native-chat.md` played for the native feed.
+
+## The position (accepted, load-bearing)
+
+**Member apps โ€” every tier of them โ€” are first-class citizens in the native
+apps.** Concretely, the native apps (Mac and iOS, as one product) must:
+
+1. **Recognise and use any app available in a space** โ€” built-in, admin-installed
+ custom, or member-built โ€” with **zero per-app native code**. Native
+ special-casing (the schema engine, hand-native surfaces) is a progressive
+ enhancement selected per app, never a gate on availability.
+2. **Offer one consistent way to discover, add, and open apps** โ€” personal
+ ("you"), per-group ("chat"), and space-wide ("space") โ€” identical in
+ behaviour across Mac and iOS.
+3. **Support the full app capability set the web does** โ€” posting into chat,
+ commands and replies, panels, scheduling, snapshots, notices โ€” generally,
+ not as per-app special cases.
+4. **Expect the app set to differ per space and change over time** โ€” folding
+ the install registries and reacting to the server's change prods, with the
+ result surviving restart.
+5. **Expect the command/completion set to differ per chat and per server** โ€”
+ assembled dynamically, never hard-coded.
+
+"Make it work" means every app runs on native through the web implementation
+(embedded, bridged); "make it good" means native surfaces replace webviews
+per app **behind the same registry**, invisible to the rest of the system.
+
+## What the app platform actually is (the contract a client implements)
+
+Everything below already exists and is what the *web* client implements. A
+native client is simply **the second implementation of the same contract** โ€”
+none of it requires server changes (see "What the server provides" below).
+
+**Three tiers of apps, one registry.** Built-in apps are ES modules compiled
+into the binary (statically imported, `shell.js`). Admin-installed **custom
+apps** are plaintext JS: discovered via public `GET /api/apps` โ†’
+`{apps:[{name,url}]}`, served at `/static/apps-custom/<name>`
+(`customapps.go`). **Member apps** are user content: instance-key-encrypted
+blobs from `GET /api/member-apps`, executed only inside the opaque-origin
+sandbox (`/app-sandbox`, `sandbox.js` / `sandbox-frame.js`). All three tiers
+register through one `defineApp()` into one in-page registry (`apps.js`), and
+the server broadcasts a content-free `{type:"apps_changed", kind}` WS frame
+whenever the custom/member set changes.
+
+**Install state is fold state.** Which apps live where is end-to-end encrypted
+and client-derived โ€” the server never learns it:
+
+- *Personal* ("you"): implicit โ€” a `kind:"self"` thread whose `name_blob`
+ decrypts to `{app,title}`. Created on first use.
+- *Group* ("chat"): `groupapps.add/remove` events in that group's own log,
+ folded by the `installs` registry app (`apps.js`), pinned by
+ `test/event-vectors/groupapps.json`.
+- *Space-wide* ("space"): `spaceapps.add/remove` events in the hidden
+ all-members **space thread** (`kind:"space"`; fetched explicitly via
+ `GET /api/space-thread` โ€” it never appears in `/api/threads`; its AES key
+ rides `open_key`, wrapped under the *instance key*, so no private key is
+ needed to open it). Pinned by `test/event-vectors/spaceapps.json`; design
+ `docs/space-apps.md`. Creation is lazy + race-safe; the server prods online
+ clients with a content-free `{type:"space_thread"}` frame.
+
+Both registries filter **at read time** to apps the client actually has
+registered and that are capable of that place โ€” unknown installs fold but stay
+inert, so clients on different versions converge on the same log.
+
+**Commands are assembled per thread.** `commandCatalog(threadId)` (`apps.js`)
+builds the typeahead/`/help` list dynamically: the owning app's local verbs
+first, then every registered app's global commands stamped with *where the
+data lands* ("chat"/"space"/"you", ranked by nearness), alt rows for the
+`@me`/`@here`/`@everyone` flags, then floor commands, `/me`, and bot commands.
+Invocation is `runCommand(text, threadId, reply)` โ€” parse, route to the
+nearest instance (`instanceThread`), build a ctx, run. Replies are private
+in-place notices; a command may also open a **panel** (`ctx.panel`) โ€” a
+transient in-chat surface.
+
+**Presentation is an app's own declaration.** `appWantsChatView(name)` โ€” i.e.
+"does the app implement `chatMessage(event)`" โ€” decides companion
+(Chat | Split | Interface) vs takeover; `defaultView` / `chatOnly` /
+`chatReadOnly` refine it; `groupFeedMessage(event)` narrates a takeover app's
+events into a *group's* feed without growing a Chat view. These are facts of
+the app spec, not of the client.
+
+**The capability set (`ctx`)** an app runs against: `emit` (encrypt + post
+into the bound thread), `schedule`/`unschedule` (server-fired future events,
+`/api/schedule`), `emitEphemeral` (relay-only), `getState`, `id`, `me`,
+`meName`, `threadId`, `place`, `reply`, `panel`, `open`, `interval`,
+`onCleanup`. **Memory** is automatic: folded state snapshots to
+`GET/PUT /api/threads/{id}/state` (encrypted, optimistic-versioned, 409 โ†’
+refold + retry). **Voice** is `notice`/`notify` on events.
+
+### What the server provides โ€” and why this needs no server changes
+
+The initial hunch list ("server APIs to communicate which apps are available",
+"event folds doing rollup to a current state") turns out to already exist, in
+the E2EE-shaped form the one rule demands:
+
+| hunch | what exists |
+|---|---|
+| "APIs to say which apps are available" | `GET /api/apps` (custom-app code), `GET /api/member-apps` (member-app blobs), `apps_changed` prod. *Which apps are installed where* is deliberately not a server API โ€” it's encrypted fold state (registries above), because the server must never learn the app set. |
+| "rollup to a current state: recent messages + installed apps + app states" | Exactly the `thread_state` snapshot system: clients fold, encrypt, and PUT the rolled-up state per `(thread, slot)`; boot = snapshot + newer events. The server can't do the rollup (ciphertext), and doesn't need to. |
+| "app added / app removed events" | `groupapps.*` / `spaceapps.*` wire events, already vector-pinned for native. |
+| "a bridge like the web ctx" | Client-side by definition โ€” the app-host protocol below. |
+| "custom commands invoking across the bridge" | `commandCatalog` + `runCommand` exist; the gap is native consuming them. |
+
+The whole plan is client work. The server's part โ€” blobs, prods, schedules,
+state slots, the space thread โ€” is finished and in production.
+
+## Where native is today (the honest map)
+
+Three parallel app subsystems grew independently in `ios/`:
+
+1. **Schema apps** โ€” `SchemaEngine/SchemaAppStore/SchemaAppView` (LChatCore)
+ interpret `Schemas/{todo,remind}.json` natively. Generic and good โ€” but
+ fed only by two bundled files (`AppSchemaRegistry`).
+2. **Webview embeds** โ€” everything else renders the web client in a
+ `WKWebView` seeded with `window.__lchatEmbed` (one thread, one thread key).
+ iOS: `ChatView`+`WebBridge`+`FeedWebCache`; Mac: `AppThreadWebView`+
+ `MacWebBridge`+`AppWebViewCache`, plus a Mac-only per-thread **command
+ host** (`CommandPanelWebView`, `mode:"command"`) used for `/emoji`/`/gif`.
+3. **Flow apps** โ€” `FlowEngine` for `setup`/`welcome`.
+
+Overlaid: a native `groupapps` fold (`GroupApps.swift`) and **~10 hand-kept
+per-app lookup tables** that each re-state a fragment of app metadata:
+`AppIdentity.symbols/marks`, `GroupApps.catalog` (4 apps),
+`ThreadStore.appTitles/appGlyphs/appDescriptions/personalApps` (12/12/12/5),
+`AppChatProjection.intros/title` (2), `AppDefaultCommand.specs` (2),
+`NativeChatView.companionApps` (2), `ComposerTypeahead.botCommands`,
+`SlashCommands.WebPanelCommands.catalog`. The same fact ("is this app a
+companion?") lives in **two** independent sets (iOS keys off
+`AppChatProjection.intros`, Mac off `companionApps`); the app catalogue exists
+in **three** different-length lists.
+
+### Symptoms โ†’ root causes
+
+| symptom | root cause |
+|---|---|
+| Space restarts forget which apps are installed | Native has **zero** space-thread support: no `GET /api/space-thread` call, no `spaceapps` fold, no handling of the `space_thread`/`apps_changed` frames (`grep spaceapps ios/` โ†’ nothing). Group installs survive only because the *web* writes the `slot:"apps"` snapshot native re-seeds from. |
+| No app slash commands anywhere native | The native typeahead floor is hard-coded (`ComposerTypeahead`); apps' commands are never learned. The Mac command host *can already run every command* (`window.lchatNative.runCommand` โ†’ the real `runCommand`) but the Mac shell only forwards `/emoji`/`/gif` to it; iOS has no command host at all, and its native feed posts a dead literal "/emoji" message. |
+| Admin: no Chat\|View on Mac | `companionApps = {todo, remind}` hard-codes the companion set; Admin (a companion with `defaultView:"interface"`, whose Chat view now carries the changelog stream) isn't in it. |
+| Admin on iOS shows chat, not the app | `ThreadListView.chatSurface` routes non-schema apps to `ChatView`, which seeds `view:"chat"` โ€” wrong for interface-first and takeover apps. |
+| Whiteboard won't load / can't be added to a group | The board is `places:["chat"]` with an `openEvent` โ€” but native's group Apps sheet is the 4-app `GroupApps.catalog` (no board), and `GroupApps.known` filters unknown installs out. |
+| Group todo on iOS: no chat part, janky menu | Group-app selection replaces the feed entirely (`activeGroupAppID`); there's no companion projection for group instances and no consistent switcher. |
+| Vault passkey lock dead on native | WebAuthn inside an embedded `WKWebView` โ€” needs a host bridge (deliberately deferred; "make it good"). |
+
+## Target architecture
+
+Three layers, all platform-shared in LChatCore; iOS and Mac keep only thin UI.
+
+### Layer 1 โ€” `AppRegistry`: one source of truth (native)
+
+A LChatCore store that merges, and persists across launches:
+
+- **The catalogue** โ€” per-app manifest records reported by the app host
+ (below): `name, title, glyph, places, listed, quiet, companion, chatOnly,
+ chatReadOnly, defaultView, defaultCommand, composerPlaceholder,
+ commands[{name, desc, args, local}]`. Cached to disk; invalidated by
+ `apps_changed` โ†’ `catalogChanged`.
+- **Install state** โ€” personal app threads (`ThreadStore` already folds
+ these); per-group `groupapps` folds (`ChatFeedModel` already); and the new
+ **`SpaceAppsStore`**: fetch `/api/space-thread`, unwrap `open_key` with the
+ native instance key, seed from its `slot:"apps"` snapshot + fold
+ `spaceapps.*` per the existing vectors, subscribe to the
+ `space_thread`/`apps_changed` frames in `ChatSocket`, persist.
+- **Native enhancements** โ€” `AppSchemaRegistry.hasSchema`, SF-symbol/brand-mark
+ overrides (`AppIdentity` becomes a *decoration* keyed by registry id, with
+ the manifest `glyph` as the universal fallback โ€” unknown apps get their
+ glyph, never a blank).
+
+Every current lookup table collapses into a registry read. The two
+companion-sets, three catalogues, and per-app titles/glyphs/blurbs all become
+one record type with one source. **An app the registry doesn't know still
+works**: title/glyph/commands come from the manifest, surface is a webview โ€”
+that is the first-class-citizen guarantee.
+
+### Layer 2 โ€” the app host: the web engine, bridged (the generalized command host)
+
+The one component that can *execute* app JS is the web framework โ€” so native
+runs it, headless, and talks to it over a small protocol. This is not new
+machinery: it is the Mac command host (`mode:"command"`), generalized and made
+first-class on both platforms. The embed page already loads **all three app
+tiers** (`bootEmbed` runs `loadCustomApps()` + `loadMemberAppsNow()`), already
+runs any command, and already reports panels/replies/errors.
+
+**The app-host protocol** (native โ‡„ `window.lchatNative` / `"lchat"` posts):
+
+| direction | call / post | meaning | status |
+|---|---|---|---|
+| native โ†’ js | `appCatalog()` โ†’ `[manifestโ€ฆ]` | the full registered-app catalogue (all tiers) | **phase 0 (this branch)** |
+| native โ†’ js | `commandItems()` โ†’ `[rowโ€ฆ]` | `commandCatalog(threadId)` for the embed's thread โ€” the typeahead/`/help` source | **phase 0 (this branch)** |
+| native โ†’ js | `runCommand(text)` โ†’ handled? | run any slash command; replies/panels post back | exists |
+| native โ†’ js | `dismissPanel()` | close the open panel | exists |
+| js โ†’ native | `{catalogChanged: kind}` | the app set changed (`apps_changed`); re-pull `appCatalog`, recreate stale webviews | **phase 0 (this branch)** |
+| js โ†’ native | `{commandHostReady}`, `{panel}`, `{panelHeight}`, `{notice}`, `{composerInsert}`, `{error}` | command-host lifecycle | exists |
+| js โ†’ native | `{needKey: threadId}` โ†’ native calls `__lchatProvideKey(threadId, keyB64)` | cross-thread routing: a personal command typed in a group must emit into the personal app-thread, whose key the single-key embed doesn't hold. Native unwraps (it can only unwrap threads the user is in) and provides. | phase 3 |
+| native โ†’ js | `projectEvent(app, eventJSON, group)` โ†’ `{text, action?} \| null` | chat projection of an app event, for the native feed (companion Chat views, group narration) โ€” the web's `chatMessage`/`groupFeedMessage` answered by the engine that owns them | phase 5 |
+
+Trust posture: the app host is a privileged surface *by job description* โ€” it
+is the command engine, so its reach is the union of the member's app threads,
+group threads it's asked about, and the space thread. It still **never**
+holds the user's private key (native unwraps and hands narrow thread keys),
+and the server still only ever sees ciphertext. Member-app code inside the
+host stays inside the `/app-sandbox` iframe exactly as on the web. The
+`needKey` grant is native-policied: only keys native itself can unwrap, only
+on request, never the private key or admin token.
+
+Two host-shape decisions:
+
+- **Per-thread hosts first** (the existing Mac shape, LRU-cached): minimal
+ delta, and `appCatalog()` is thread-independent so *any* live embed can
+ serve it. A single per-instance host (`mode:"apphost"`, threadless boot) is
+ a later consolidation, not a prerequisite.
+- **Snapshots come for free**: a host that folds a thread with real keys runs
+ the framework's automatic snapshot writer โ€” which fixes "native households
+ never write snapshots" without new code. (Safe by construction: snapshot
+ writes are optimistic-versioned, and racing writers refold + retry.)
+
+The embed's space-thread bail (`loadSpaceThread`: `if (embed || !instanceKey)
+return`) lifts for hosts seeded with the instance key, so space-installed
+apps' commands route correctly inside the host too (phase 4).
+
+### Layer 3 โ€” registry-driven surfaces
+
+- **Surface routing**: iOS `ThreadListView.chatSurface` and Mac
+ `NativeChatView.feed` stop switching on hard-coded names. One shared
+ decision: `registry.presentation(appId)` โ†’ *native-schema* (schema exists) /
+ *flow* (`setup`/`welcome`) / *companion webview* (Chat|Split|Interface on
+ Mac, Chat|Interface on iOS, honouring `defaultView`, `chatOnly`,
+ `chatReadOnly`) / *takeover webview* (seed `view:"interface"` โ€” fixes
+ Admin-on-iOS) โ€” with *webview* as the universal fallback.
+- **Pickers**: New-chat โ–ธ Apps (personal = `places` โˆ‹ "you"), group โ‹ฎ โ–ธ Apps
+ (chat-capable, so the board appears), and the space band (space installs,
+ from `SpaceAppsStore`) all render from the registry โ€” custom and member
+ apps included, identically on both platforms.
+- **Typeahead**: `ComposerTypeahead` consumes registry commands (per-thread
+ rows via `commandItems()`, cached + refreshed on fold changes) instead of
+ its hard-coded floor; selection routes to the command host. The floor
+ (`/me`, `/help`, theme verbs) stays native.
+- **Group instances**: the added-apps switcher and the group feed's app-event
+ narration come from the registry + projection queries, one design on both
+ platforms (Mac keeps Split; iOS keeps toggle).
+
+## Phases (small PRs; each stands alone; iOS first where UI diverges)
+
+- **Phase 0 โ€” protocol groundwork (this branch, web-side, testable in Node).**
+ `appCatalogReport()` in `apps.js`; `appCatalog()` / `commandItems()` on the
+ embed bridge; `{catalogChanged}` post on `apps_changed` in embeds;
+ `test/appcatalog.test.mjs` pins the shapes.
+- **Phase 1 โ€” installs survive: `SpaceAppsStore` + registry persistence.**
+ LChatCore: space-thread fetch/unwrap, `spaceapps` fold (vectors exist:
+ `spaceapps.json`), `space_thread`/`apps_changed` in `ChatSocket`, disk
+ persistence for catalogue + installs. Acceptance: kill + relaunch โ†’ a
+ space's installed apps (vault included) are still listed.
+- **Phase 2 โ€” one registry, registry-driven routing.** Collapse the lookup
+ tables into `AppRegistry`; route surfaces and pickers from it. Acceptance:
+ Admin gets Chat|View on Mac and opens as the app on iOS; the board is
+ addable to a group and loads; unknown/custom apps appear with glyph+title.
+- **Phase 3 โ€” commands everywhere.** iOS gains the command host; both
+ platforms forward *every* non-floor slash command to it; typeahead rows
+ from `commandItems()`; `needKey` bridge for cross-thread routing.
+ Acceptance: `/todo buy milk` works from any chat on both platforms;
+ `/emoji`/`/gif` stop dead-posting on iOS; a custom app's command appears in
+ the typeahead with its desc/args.
+- **Phase 4 โ€” space routing in the host.** Lift the embed space-thread bail
+ when the instance key is present; space band UI. Acceptance: `/gif` (space
+ app) works natively where installed.
+- **Phase 5 โ€” projection queries.** Native feeds narrate app events for apps
+ without native projections (`projectEvent`); `AppChatProjection` becomes the
+ fast path for schema apps, same outputs, vector-pinned.
+- **Phase 6 โ€” make it good.** More schemas; native panel rendering; the vault
+ passkey/WebAuthn host bridge; per-instance host consolidation; native
+ snapshot writing direct from `SchemaAppStore` if the host path isn't enough.
+
+## Deliberately out of scope
+
+Bots and webhooks (already native-blind by design โ€” sidecar processes);
+changing any wire format or server behaviour; SDUI for the chat feed
+(`docs/native-chat.md` locked that out). The schema engine keeps growing
+*within* its archetype โ€” it is the "make it good" tier, never the
+availability gate.
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 3c3bfaa..aebf960 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -458,6 +458,19 @@ struct ThreadListView: View {
store.applyDMClosed(threadID: tid, closed: frame.type == "dm_closed")
return
}
+ // The space thread was just created somewhere (content-free prod,
+ // spacethread.go) โ€” fetch it and fold its installs.
+ if frame.type == "space_thread" {
+ Task { await store.reloadSpaceApps() }
+ return
+ }
+ // The space thread's own frames: fold installs in place โ€” the
+ // thread never appears in /api/threads, so a refresh would learn
+ // nothing about it.
+ if let tid = frame.thread_id, tid == store.spaceApps.threadID {
+ store.spaceApps.handleLive(frame)
+ return
+ }
// role_changed rides the same debounced refresh: an admin
// promotion (main.go adoptKeyInvite) is server-synthesized, not a
// stored-message mutation, but the roster it invalidates is the
diff --git a/ios/LChatCore/Sources/LChatCore/AppCatalog.swift b/ios/LChatCore/Sources/LChatCore/AppCatalog.swift
new file mode 100644
index 0000000..9b4fd9a
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/AppCatalog.swift
@@ -0,0 +1,80 @@
+import Foundation
+
+// ๐Ÿค– The native record of one registered app's manifest facts โ€” the decode of
+// appCatalogReport() (web/static/apps.js), the app-host protocol's catalogue
+// (docs/native-apps.md). The web page is the only thing that can execute app
+// JS across all three tiers (built-in, admin custom, member), so it reports
+// every app's identity, places, presentation contract, and commands as plain
+// JSON; native decodes that report here and drives its registry, pickers,
+// surface routing, and composer typeahead from it โ€” zero per-app native code.
+// AppCatalogTests pins this decode against the shapes test/appcatalog.test.mjs
+// pins on the web side: the two are the cross-platform contract.
+public struct AppManifest: Codable, Equatable, Sendable, Identifiable {
+ public struct Command: Codable, Equatable, Sendable {
+ public var name: String
+ public var desc: String
+ public var args: String
+ /// A local verb of the owning app's own thread (never offered
+ /// elsewhere), vs a global command available in any chat.
+ public var local: Bool
+ }
+
+ /// The app's registry name ("todo", "vault", a member app's name) โ€” the
+ /// identity every install event and app-thread name_blob carries.
+ public var name: String
+ public var title: String
+ /// 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
+ /// Where the app can live: any of "you" (personal), "chat" (per-group),
+ /// "space" (space-wide).
+ public var places: [String]
+ public var listed: Bool
+ public var quiet: Bool
+ /// A member-built app (user content, sandbox-executed) โ€” vs built-in or
+ /// admin-installed custom.
+ public var member: Bool
+ // 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
+ // Chat view; no companion = the app's render takes over the surface.
+ public var companion: Bool
+ public var chatOnly: Bool
+ public var chatReadOnly: Bool
+ /// "chat" | "interface" | "split" โ€” the companion view to open first.
+ public var defaultView: String
+ /// The command the app-thread composer runs bare input through ("" = none).
+ public var defaultCommand: String
+ public var commands: [Command]
+
+ public var id: String { name }
+}
+
+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.
+ public static func decode(reportJSON: Data) -> [AppManifest]? {
+ try? JSONDecoder().decode([AppManifest].self, from: reportJSON)
+ }
+
+ // Parked per origin so pickers and routing have a catalogue before any
+ // webview reports one this launch (and offline). Keychain, not a file:
+ // member-app names/titles/commands are decrypted user content โ€” the
+ // server never learns the app set, so neither should disk plaintext
+ // (the ThreadListSnapshot posture).
+ private static func account(_ origin: String) -> String { "app-catalog|\(origin)" }
+
+ public static func load(origin: String) -> [AppManifest]? {
+ Keychain.load([AppManifest].self, account: account(origin))
+ }
+
+ /// Call off the main thread (Keychain writes aren't free).
+ public static func save(origin: String, _ catalog: [AppManifest]) {
+ Keychain.save(catalog, account: account(origin))
+ }
+
+ public static func clear(origin: String) {
+ Keychain.clear(account: account(origin))
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
index be61ebc..bb337e3 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
@@ -22,6 +22,7 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
public let user_id: String? // "presence" / "label" frames: whose state changed
public let presence: String? // "presence" frames: "online" | "away" | "offline"
public let last_read_at: Int64? // "read" frames: our watermark, moved on another device
+ public let kind: String? // "apps_changed" frames: "created" | "updated" | "removed"
}
/// Delivered on the main thread.
diff --git a/ios/LChatCore/Sources/LChatCore/GroupApps.swift b/ios/LChatCore/Sources/LChatCore/GroupApps.swift
index 2b4c57d..8c13aab 100644
--- a/ios/LChatCore/Sources/LChatCore/GroupApps.swift
+++ b/ios/LChatCore/Sources/LChatCore/GroupApps.swift
@@ -89,16 +89,21 @@ public enum GroupApps {
return apps
}
- /// Decode the decrypted slot-"apps" snapshot ({states: {groupapps: {apps:
- /// [{id, by, ts}]}, โ€ฆ}, upTo}) into a seed. Tolerant by design โ€” other
- /// apps' states share the blob and any of it may be missing.
+ /// Decode the decrypted slot-"apps" snapshot into a seed. The registry is
+ /// one app on the web now ("installs" owns both the groupapps and
+ /// spaceapps families), so new snapshots hold its state under "installs";
+ /// snapshots written before the unification used the per-family
+ /// "groupapps" key โ€” read both, unified winning (apps.js seedThread's
+ /// shim, mirrored; without the new key a current web client's snapshot
+ /// seeds nothing here). Tolerant by design โ€” other apps' states share the
+ /// blob and any of it may be missing.
public static func parseSnapshot(_ json: Data) -> (seed: [Item], upTo: Int64)? {
guard let root = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any] else {
return nil
}
let upTo = (root["upTo"] as? NSNumber)?.int64Value ?? 0
let states = root["states"] as? [String: Any]
- let ga = states?["groupapps"] as? [String: Any]
+ let ga = (states?["installs"] ?? states?["groupapps"]) as? [String: Any]
let rows = ga?["apps"] as? [[String: Any]] ?? []
let seed: [Item] = rows.compactMap { row in
guard let id = row["id"] as? String, !id.isEmpty else { return nil }
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 237272d..1b20e49 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -157,6 +157,18 @@ public struct LChatAPI {
return (resp as? HTTPURLResponse)?.statusCode == 200
}
+ /// 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
+ /// yet ({} on the wire). nil on a network error, so callers keep held
+ /// state instead of reading a blip as "no installs".
+ public func spaceThread() async -> (id: String?, openKey: String)? {
+ struct Resp: Codable { let id: String?; let open_key: String? }
+ guard let r = try? await getJSON("/api/space-thread", Resp.self) else { return nil }
+ guard let id = r.id, !id.isEmpty else { return (nil, "") }
+ return (id, r.open_key ?? "")
+ }
+
/// Messages in a thread after a cursor id โ€” used to count unread (the feed
/// itself is rendered by the webview).
public func messages(thread: String, after: Int64) async -> [ServerMessage] {
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceApps.swift b/ios/LChatCore/Sources/LChatCore/SpaceApps.swift
new file mode 100644
index 0000000..906c838
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/SpaceApps.swift
@@ -0,0 +1,117 @@
+import Foundation
+
+// ๐Ÿค– The native side of the web's `spaceapps` registry (apps.js installsApp โ€”
+// the spaceapps event family): which mini-apps are installed FOR THE WHOLE
+// SPACE is E2E fold state โ€” encrypted spaceapps.add / spaceapps.remove events
+// in the hidden all-members space thread's log (docs/space-apps.md), seeded
+// from that thread's encrypted slot-"apps" snapshot โ€” so the server never
+// learns which app (or that one) was installed. GroupApps' twin one level up;
+// this file is the pure, testable half (SpaceAppsVectorTests transcribes
+// test/event-vectors/spaceapps.json). SpaceAppsStore feeds it.
+public enum SpaceApps {
+ /// One space-wide install, as the web folds it: the item id IS the app
+ /// name, so re-installing is idempotent and a remove needs no lookup.
+ /// Unlike a group item it carries the installer's `uid` โ€” the admins-only
+ /// install policy filters by it at read time. Codable so the store can
+ /// park the folded list across launches (installs must survive relaunch).
+ public struct Item: Equatable, Identifiable, Sendable, Codable {
+ public let id: String // the installed app's name ("bookmarks", โ€ฆ)
+ public var by: String
+ public var ts: Int64 // ms, as stamped by the installer
+ public var uid: String
+ public init(id: String, by: String, ts: Int64, uid: String) {
+ self.id = id
+ self.by = by
+ self.ts = ts
+ self.uid = uid
+ }
+ }
+
+ /// One spaceapps.* wire event, keyed by its server message id so pages can
+ /// fold in any arrival order and a recompute stays deterministic.
+ public struct Event: Equatable, Sendable {
+ public let type: String // "spaceapps.add" | "spaceapps.remove"
+ public let app: String
+ public let by: String
+ public let ts: Int64
+ public let uid: String
+ public init(type: String, app: String, by: String, ts: Int64, uid: String) {
+ self.type = type
+ self.app = app
+ self.by = by
+ self.ts = ts
+ self.uid = uid
+ }
+ }
+
+ // The spaceapps.* wire shape โ€” the event fields plus the transport
+ // envelope's name/ts/uid (ts is ms; tolerate a float, JS numbers are
+ // doubles). Mirrors ChatFeedModel.GroupAppsWire.
+ private struct Wire: Decodable {
+ let type: String?
+ let app: String?
+ let name: String?
+ let ts: Double?
+ let uid: String?
+ }
+
+ /// Decode one decrypted space-thread message into an event, or nil for
+ /// anything that isn't a well-formed spaceapps.* install/remove (a
+ /// malformed or hostile blob folds to a no-op, never a crash).
+ public static func event(_ blob: String, key: GroupKey) -> Event? {
+ guard let w = try? key.decryptJSON(Wire.self, blob: blob),
+ let type = w.type, type.hasPrefix("spaceapps."),
+ let app = w.app, !app.isEmpty else { return nil }
+ return Event(type: type, app: app, by: w.name ?? "",
+ ts: Int64(w.ts ?? 0), uid: w.uid ?? "")
+ }
+
+ /// Recompute the installed-apps list from the snapshot seed plus every
+ /// event NEWER than the snapshot's horizon, in log order โ€” the web's fold
+ /// (addItem dedupes by id and keeps the original attribution; remove
+ /// deletes). `events` is keyed by server message id; ids <= upTo are
+ /// already inside the seed, so they're skipped (apps.js foldInto's
+ /// `mid <= upTo` rule).
+ public static func fold(seed: [Item], upTo: Int64, events: [Int64: Event]) -> [Item] {
+ var apps = seed
+ for (id, e) in events.sorted(by: { $0.key < $1.key }) {
+ guard id > upTo, !e.app.isEmpty else { continue }
+ switch e.type {
+ case "spaceapps.add":
+ if !apps.contains(where: { $0.id == e.app }) {
+ apps.append(Item(id: e.app, by: e.by, ts: e.ts, uid: e.uid))
+ }
+ case "spaceapps.remove":
+ apps.removeAll { $0.id == e.app }
+ default:
+ break
+ }
+ }
+ return apps
+ }
+
+ /// Decode the decrypted slot-"apps" snapshot into a seed. The registry is
+ /// one app on the web ("installs" owns both the groupapps and spaceapps
+ /// families), so new snapshots hold its state under "installs"; snapshots
+ /// written before the unification used the per-family "spaceapps" key โ€”
+ /// read both, unified winning (apps.js seedThread's shim, mirrored).
+ /// Tolerant by design โ€” other apps' states share the blob and any of it
+ /// may be missing.
+ public static func parseSnapshot(_ json: Data) -> (seed: [Item], upTo: Int64)? {
+ guard let root = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any] else {
+ return nil
+ }
+ let upTo = (root["upTo"] as? NSNumber)?.int64Value ?? 0
+ let states = root["states"] as? [String: Any]
+ let reg = (states?["installs"] ?? states?["spaceapps"]) as? [String: Any]
+ let rows = reg?["apps"] as? [[String: Any]] ?? []
+ let seed: [Item] = rows.compactMap { row in
+ guard let id = row["id"] as? String, !id.isEmpty else { return nil }
+ return Item(id: id,
+ by: row["by"] as? String ?? "",
+ ts: (row["ts"] as? NSNumber)?.int64Value ?? 0,
+ uid: row["uid"] as? String ?? "")
+ }
+ return (seed, upTo)
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift b/ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift
new file mode 100644
index 0000000..f9e0121
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift
@@ -0,0 +1,193 @@
+import Foundation
+
+// ๐Ÿค– The per-instance store for space-wide app installs (docs/space-apps.md,
+// docs/native-apps.md phase 1): fetches the hidden all-members space thread,
+// unwraps its key from open_key under the INSTANCE key (no per-member wrap
+// exists โ€” membership is implicit), seeds the fold from the thread's
+// encrypted slot-"apps" snapshot, catches up on newer events, and folds live
+// spaceapps.* frames as they arrive. The folded list parks in the Keychain
+// per origin โ€” decrypted app names are user content, the ThreadListSnapshot
+// posture โ€” so a relaunch lists a space's installed apps before (or without)
+// any network. ThreadStore owns one and prods it once the instance key is
+// unwrapped; the platform socket handlers forward space_thread prods and the
+// space thread's own msg/delete frames.
+@MainActor
+public final class SpaceAppsStore: ObservableObject {
+ /// The folded install list, raw: every install in the log, unknown app
+ /// names included. Filtering to apps this client can actually surface is
+ /// a READ-time concern (the web's spaceAppsFor) and belongs to the
+ /// registry consumers โ€” old and new clients converge on the same log.
+ @Published public private(set) var installs: [SpaceApps.Item] = []
+ /// The space thread's id once fetched โ€” the socket handlers use it to
+ /// route the thread's live frames here. nil until fetched (or when the
+ /// space has no thread yet).
+ public private(set) var threadID: String?
+
+ private let instance: SavedInstance
+ private var key: GroupKey?
+ private var seed: [SpaceApps.Item] = []
+ private var upTo: Int64 = 0
+ private var events: [Int64: SpaceApps.Event] = [:]
+ private var loaded = false // one full network pass has completed
+ private var hydrated = false
+ // reload() refires from refresh passes and socket prods โ€” coalesced the
+ // same way ThreadStore.refresh is (see CoalescedRunner).
+ private let gate = CoalescedRunner()
+
+ public init(instance: SavedInstance) {
+ self.instance = instance
+ }
+
+ /// One full pass if none has happened yet this launch โ€” called from every
+ /// ThreadStore refresh, so it must be a cheap no-op afterwards (the
+ /// space_thread prod and live frames carry changes from then on).
+ public func loadIfNeeded(instanceKeyB64: String?) async {
+ guard !loaded else { return }
+ await reload(instanceKeyB64: instanceKeyB64)
+ }
+
+ /// Fetch + unwrap + seed + catch-up, publishing and parking the result.
+ /// Network failure keeps what we hold (never blank a list on a blip);
+ /// a definitive "no space thread exists" folds to empty.
+ public func reload(instanceKeyB64: String?) async {
+ await gate.run { await self.pass(instanceKeyB64: instanceKeyB64) }
+ }
+
+ private func pass(instanceKeyB64 ikB64: String?) async {
+ await hydrateIfNeeded()
+ guard let base = instance.baseURL else { return }
+ let api = LChatAPI(baseURL: base, token: instance.deviceToken)
+ // nil = network error (keep held state); (nil, _) = no thread yet.
+ guard let st = await api.spaceThread() else { return }
+ guard let id = st.id else {
+ threadID = nil
+ key = nil
+ seed = []
+ upTo = 0
+ events = [:]
+ loaded = true
+ recompute()
+ return
+ }
+ threadID = id
+ if key == nil, let ikB64, !st.openKey.isEmpty,
+ let ik = try? GroupKey(base64url: ikB64) {
+ // open_key is the thread's AES key under the instance key, the
+ // open-group envelope ({k}) โ€” mirrors loadSpaceThread in shell.js.
+ let openKey = st.openKey
+ key = await Task.detached(priority: .userInitiated) {
+ guard let payload = try? ik.decryptJSON(OpenKeyPayload.self, blob: openKey),
+ let raw = Data(base64urlEncoded: payload.k) else { return nil }
+ return try? GroupKey(rawKey: raw)
+ }.value
+ }
+ // No instance key on this device (not granted yet): the thread exists
+ // but we can't read it โ€” keep the parked list and try again next prod.
+ guard let key else { return }
+ // Seed from the slot-"apps" snapshot (what the web saves on every fold
+ // advance), then fold only events newer than its horizon โ€” without
+ // this an app installed long ago would be invisible once its event
+ // fell out of any message window.
+ if let state = await api.threadState(thread: id, slot: "apps"), !state.blob.isEmpty {
+ let blob = state.blob
+ let parsed = await Task.detached(priority: .userInitiated) {
+ (try? key.decryptData(blob: blob)).flatMap(SpaceApps.parseSnapshot)
+ }.value
+ if let parsed {
+ seed = parsed.seed
+ upTo = parsed.upTo
+ events = events.filter { $0.key > parsed.upTo }
+ }
+ }
+ // Catch-up: page forward from the horizon (the web's seedAppState
+ // walk). Bounded โ€” a page short of the server's limit is the end.
+ var cursor = upTo
+ for _ in 0..<20 {
+ let page = await api.messages(thread: id, after: cursor)
+ guard !page.isEmpty else { break }
+ for m in page where m.tombstone_of == nil {
+ if let e = SpaceApps.event(m.blob, key: key) { events[m.id] = e }
+ }
+ guard let last = page.map(\.id).max(), last > cursor else { break }
+ cursor = last
+ }
+ loaded = true
+ recompute()
+ }
+
+ /// Fold one live hub frame for the space thread โ€” the socket handlers
+ /// route frames whose thread_id matches threadID here. msg folds the
+ /// event; delete unfolds it (a retracted install/remove refolds cleanly
+ /// because the fold is a pure recompute).
+ public func handleLive(_ frame: ChatSocket.Frame) {
+ guard let key, let id = frame.id else { return }
+ switch frame.type {
+ case "msg":
+ guard let blob = frame.blob,
+ let e = SpaceApps.event(blob, key: key) else { return }
+ guard events[id] != e else { return }
+ events[id] = e
+ recompute()
+ case "delete":
+ guard events.removeValue(forKey: id) != nil else { return }
+ recompute()
+ default:
+ break
+ }
+ }
+
+ private func recompute() {
+ let folded = SpaceApps.fold(seed: seed, upTo: upTo, events: events)
+ let changed = folded != installs
+ if changed { installs = folded }
+ // Park for next launch (hydrate reads it) โ€” off-main, skipped when
+ // nothing changed (recompute refires on every live frame).
+ guard changed || !hydrated else { return }
+ hydrated = true
+ let snap = Snap(threadID: threadID, installs: folded)
+ let origin = instance.origin
+ Task.detached(priority: .utility) {
+ SpaceAppsSnapshot.save(origin: origin, snap: snap)
+ }
+ }
+
+ // Local-first: publish the last session's folded installs before any
+ // network โ€” entering a space you've been to lists its apps immediately,
+ // and offline relaunch still lists them (the phase-1 acceptance).
+ private func hydrateIfNeeded() async {
+ guard !hydrated, !loaded else { return }
+ hydrated = true
+ let origin = instance.origin
+ guard let snap = await Task.detached(priority: .userInitiated, operation: {
+ SpaceAppsSnapshot.load(origin: origin)
+ }).value else { return }
+ guard !loaded, installs.isEmpty else { return } // a pass raced us โ€” it wins
+ threadID = threadID ?? snap.threadID
+ installs = snap.installs
+ }
+
+ /// The parked fold, one per origin. Keychain, not a file: installed app
+ /// names (and who installed them) are decrypted user content โ€” the
+ /// ThreadListSnapshot posture.
+ public struct Snap: Codable, Sendable {
+ public var threadID: String?
+ public var installs: [SpaceApps.Item]
+ }
+}
+
+public enum SpaceAppsSnapshot {
+ private static func account(_ origin: String) -> String { "space-apps|\(origin)" }
+
+ public static func load(origin: String) -> SpaceAppsStore.Snap? {
+ Keychain.load(SpaceAppsStore.Snap.self, account: account(origin))
+ }
+
+ /// Call off the main thread (recompute already does).
+ public static func save(origin: String, snap: SpaceAppsStore.Snap) {
+ Keychain.save(snap, account: account(origin))
+ }
+
+ public static func clear(origin: String) {
+ Keychain.clear(account: account(origin))
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index b327296..de09663 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -23,6 +23,12 @@ public final class ThreadStore: ObservableObject {
public let instance: SavedInstance
public private(set) var myUserId: String
private var instanceKeyB64: String? // for member avatars (optional)
+
+ // Space-wide app installs (docs/space-apps.md): folded from the hidden
+ // space thread once the instance key is unwrapped. Owned here so its
+ // 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)
// 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?
@@ -304,6 +310,17 @@ public final class ThreadStore: ObservableObject {
ThreadListSnapshot.save(origin: origin, name: name, rows: rows)
}
}
+ // Space-wide installs: one full pass once the instance key is known โ€”
+ // a cheap no-op on every later refresh (space_thread prods and the
+ // space thread's live frames carry changes from then on).
+ let ik = instanceKeyB64
+ Task { await spaceApps.loadIfNeeded(instanceKeyB64: ik) }
+ }
+
+ /// A space_thread prod (the content-free "it was just created" frame) or
+ /// any other reason to re-pull: full refetch + refold.
+ public func reloadSpaceApps() async {
+ await spaceApps.reload(instanceKeyB64: instanceKeyB64)
}
// Everything refresh() publishes, computed off the main actor.
diff --git a/ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift b/ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift
new file mode 100644
index 0000000..5b6d608
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift
@@ -0,0 +1,69 @@
+import XCTest
+@testable import LChatCore
+
+// ๐Ÿค– The native half of the app-host catalogue contract (docs/native-apps.md):
+// AppManifest must decode exactly what appCatalogReport() (web/static/apps.js)
+// emits. The web side pins the report's shape in test/appcatalog.test.mjs;
+// this file pins the decode of those same shapes, so drift on either side
+// breaks a test. The literals here mirror that test's fixtures: a takeover
+// app with commands (vault-like) and a chatOnly companion.
+final class AppCatalogTests: XCTestCase {
+ func testDecodesReport() throws {
+ let report = """
+ [
+ {"name":"vault","title":"Vault","glyph":"๐Ÿ”’","places":["chat"],
+ "listed":true,"quiet":false,"member":false,
+ "companion":false,"chatOnly":false,"chatReadOnly":false,
+ "defaultView":"split","defaultCommand":"",
+ "commands":[{"name":"vault","desc":"Save something","args":"<text>","local":false}]},
+ {"name":"habits","title":"Habit tracker","glyph":"โœ…","places":["you"],
+ "listed":false,"quiet":false,"member":true,
+ "companion":true,"chatOnly":true,"chatReadOnly":false,
+ "defaultView":"chat","defaultCommand":"habit",
+ "commands":[{"name":"habit","desc":"Log a habit","args":"","local":true}]}
+ ]
+ """.data(using: .utf8)!
+ let catalog = try XCTUnwrap(AppCatalog.decode(reportJSON: report))
+ XCTAssertEqual(catalog.count, 2)
+
+ let vault = catalog[0]
+ XCTAssertEqual(vault.name, "vault")
+ XCTAssertEqual(vault.title, "Vault")
+ XCTAssertEqual(vault.glyph, "๐Ÿ”’")
+ XCTAssertEqual(vault.places, ["chat"])
+ XCTAssertTrue(vault.listed)
+ XCTAssertFalse(vault.member)
+ // No chatMessage โ†’ takeover: render owns the whole surface.
+ XCTAssertFalse(vault.companion)
+ XCTAssertEqual(vault.commands,
+ [AppManifest.Command(name: "vault", desc: "Save something",
+ args: "<text>", local: false)])
+
+ let habits = catalog[1]
+ XCTAssertTrue(habits.member)
+ XCTAssertTrue(habits.companion)
+ XCTAssertTrue(habits.chatOnly)
+ XCTAssertEqual(habits.defaultView, "chat")
+ XCTAssertEqual(habits.defaultCommand, "habit")
+ }
+
+ // Undecodable input is "no report", never an empty app set โ€” an empty
+ // catalogue would read as "this space has no apps" downstream.
+ func testUndecodableReportIsNil() {
+ XCTAssertNil(AppCatalog.decode(reportJSON: Data("not json".utf8)))
+ XCTAssertNil(AppCatalog.decode(reportJSON: Data(#"[{"name":"x"}]"#.utf8)))
+ }
+
+ // The record round-trips through Codable losslessly โ€” what the per-origin
+ // Keychain park depends on.
+ func testRoundTrip() throws {
+ let manifest = AppManifest(
+ name: "board", title: "Whiteboard", glyph: "๐Ÿ–Š๏ธ", places: ["chat"],
+ listed: true, quiet: false, member: false,
+ companion: false, chatOnly: false, chatReadOnly: false,
+ defaultView: "split", defaultCommand: "",
+ commands: [.init(name: "board", desc: "Open the board", args: "", local: false)])
+ let data = try JSONEncoder().encode([manifest])
+ XCTAssertEqual(AppCatalog.decode(reportJSON: data), [manifest])
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
index 94f5c6c..ea13f4d 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
@@ -122,4 +122,16 @@ final class GroupAppsVectorTests: XCTestCase {
XCTAssertEqual(parsed?.upTo, 7)
XCTAssertEqual(parsed?.seed, [])
}
+
+ // Snapshots written since the registry unification (apps.js installsApp)
+ // hold the state under "installs" โ€” and it wins over a stale legacy key
+ // left in the same blob.
+ func testSnapshotParseUnifiedInstallsKey() {
+ let json = """
+ {"states":{"installs":{"apps":[{"id":"vault","by":"Bob","ts":9}]},"groupapps":{"apps":[{"id":"todo","by":"Ana","ts":1}]}},"upTo":9}
+ """.data(using: .utf8)!
+ let parsed = GroupApps.parseSnapshot(json)
+ XCTAssertEqual(parsed?.upTo, 9)
+ XCTAssertEqual(parsed?.seed, [GroupApps.Item(id: "vault", by: "Bob", ts: 9)])
+ }
}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SpaceAppsVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SpaceAppsVectorTests.swift
new file mode 100644
index 0000000..88f47a5
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/SpaceAppsVectorTests.swift
@@ -0,0 +1,146 @@
+import XCTest
+@testable import LChatCore
+
+// ๐Ÿค– Cross-platform event contract tests for the space-apps registry โ€” the
+// native fold (SpaceApps.fold, fed by SpaceAppsStore) must match the web's
+// spaceapps reducers exactly. Pinned by test/event-vectors/spaceapps.json;
+// the web side runs the same vectors in test/event-vectors.test.mjs.
+final class SpaceAppsVectorTests: XCTestCase {
+ private func fold(seed: [SpaceApps.Item] = [], upTo: Int64 = 0,
+ _ events: [Int64: SpaceApps.Event]) -> [SpaceApps.Item] {
+ SpaceApps.fold(seed: seed, upTo: upTo, events: events)
+ }
+
+ // spaceapps_install: a member installs an app for everyone in the space.
+ func testInstall() {
+ let out = fold([1: .init(type: "spaceapps.add", app: "bookmarks", by: "Ana",
+ ts: 1_700_000_000_000, uid: "u-ana")])
+ XCTAssertEqual(out, [SpaceApps.Item(id: "bookmarks", by: "Ana",
+ ts: 1_700_000_000_000, uid: "u-ana")])
+ }
+
+ // spaceapps_install_duplicate: idempotent by app name โ€” the original
+ // install's attribution (by/ts/uid) is kept, a re-install never rewrites it.
+ func testDuplicateInstallKeepsOriginalAttribution() {
+ let out = fold([
+ 1: .init(type: "spaceapps.add", app: "bookmarks", by: "Ana",
+ ts: 1_700_000_000_000, uid: "u-ana"),
+ 2: .init(type: "spaceapps.add", app: "bookmarks", by: "Bob",
+ ts: 1_700_000_005_000, uid: "u-bob"),
+ ])
+ XCTAssertEqual(out, [SpaceApps.Item(id: "bookmarks", by: "Ana",
+ ts: 1_700_000_000_000, uid: "u-ana")])
+ }
+
+ // spaceapps_install_unknown_app: an install naming an app this client
+ // doesn't know still folds โ€” it stays inert at read time, so clients on
+ // different versions converge on the same log.
+ func testUnknownAppFoldsIn() {
+ let seed = [SpaceApps.Item(id: "bookmarks", by: "Ana",
+ ts: 1_700_000_000_000, uid: "u-ana")]
+ let out = fold(seed: seed, upTo: 1, [
+ 2: .init(type: "spaceapps.add", app: "someday-app", by: "Ana",
+ ts: 1_700_000_010_000, uid: "u-ana"),
+ ])
+ XCTAssertEqual(out, seed + [SpaceApps.Item(id: "someday-app", by: "Ana",
+ ts: 1_700_000_010_000, uid: "u-ana")])
+ }
+
+ // spaceapps_remove: any member removes a space-wide install.
+ func testRemove() {
+ let seed = [SpaceApps.Item(id: "bookmarks", by: "Ana",
+ ts: 1_700_000_000_000, uid: "u-ana")]
+ let out = fold(seed: seed, upTo: 1, [
+ 2: .init(type: "spaceapps.remove", app: "bookmarks", by: "Bob",
+ ts: 1_700_000_020_000, uid: ""),
+ ])
+ XCTAssertEqual(out, [])
+ }
+
+ // spaceapps_remove_absent: removing an app that isn't installed is a no-op
+ // (a remove can race another member's remove).
+ func testRemoveAbsentIsNoop() {
+ let out = fold([1: .init(type: "spaceapps.remove", app: "bookmarks", by: "Bob",
+ ts: 1_700_000_030_000, uid: "")])
+ XCTAssertEqual(out, [])
+ }
+
+ // spaceapps_add_malformed: a hostile or malformed install (missing app)
+ // folds to a no-op, never a crash. The wire decode enforces the same rule
+ // one layer down: no spaceapps.* prefix / no app / undecryptable โ†’ nil.
+ func testMalformedAddIsNoop() {
+ let out = fold([1: .init(type: "spaceapps.add", app: "", by: "Mallory",
+ ts: 1_700_000_040_000, uid: "")])
+ XCTAssertEqual(out, [])
+ }
+
+ // Fold order is server-message-id order, whatever order pages arrived in.
+ func testFoldsInMessageIDOrder() {
+ let out = fold([
+ 3: .init(type: "spaceapps.remove", app: "gif", by: "Ana", ts: 3, uid: "u-ana"),
+ 1: .init(type: "spaceapps.add", app: "gif", by: "Ana", ts: 1, uid: "u-ana"),
+ 2: .init(type: "spaceapps.add", app: "bookmarks", by: "Bob", ts: 2, uid: "u-bob"),
+ ])
+ XCTAssertEqual(out, [SpaceApps.Item(id: "bookmarks", by: "Bob", ts: 2, uid: "u-bob")])
+ }
+
+ // Events at or below the snapshot horizon are already inside the seed โ€”
+ // refolding them must not double-apply (apps.js foldInto's `mid <= upTo`).
+ func testSnapshotHorizonSkipsSeededEvents() {
+ let seed = [SpaceApps.Item(id: "gif", by: "Ana", ts: 1, uid: "u-ana")]
+ let out = fold(seed: seed, upTo: 10, [
+ 9: .init(type: "spaceapps.remove", app: "gif", by: "Ana", ts: 9, uid: "u-ana"),
+ 11: .init(type: "spaceapps.add", app: "vault", by: "Bob", ts: 11, uid: "u-bob"),
+ ])
+ XCTAssertEqual(out, [SpaceApps.Item(id: "gif", by: "Ana", ts: 1, uid: "u-ana"),
+ SpaceApps.Item(id: "vault", by: "Bob", ts: 11, uid: "u-bob")])
+ }
+
+ // The slot-"apps" snapshot decode. New snapshots hold the registry's state
+ // under the unified "installs" key (apps.js installsApp) โ€” tolerant of
+ // other apps' states sharing the blob.
+ func testSnapshotParseUnifiedKey() {
+ let json = """
+ {"states":{"gif":{"q":""},"installs":{"apps":[{"id":"gif","by":"Ana","ts":1700000000000,"uid":"u-ana"}]}},"upTo":42}
+ """.data(using: .utf8)!
+ let parsed = SpaceApps.parseSnapshot(json)
+ XCTAssertEqual(parsed?.upTo, 42)
+ XCTAssertEqual(parsed?.seed, [SpaceApps.Item(id: "gif", by: "Ana",
+ ts: 1_700_000_000_000, uid: "u-ana")])
+ }
+
+ // Snapshots written before the registry unification hold the state under
+ // the legacy per-family "spaceapps" key โ€” read as a fallback, and a pre-uid
+ // row's missing uid decodes to "".
+ func testSnapshotParseLegacyKey() {
+ let json = """
+ {"states":{"spaceapps":{"apps":[{"id":"bookmarks","by":"Ana","ts":7}]}},"upTo":7}
+ """.data(using: .utf8)!
+ let parsed = SpaceApps.parseSnapshot(json)
+ XCTAssertEqual(parsed?.upTo, 7)
+ XCTAssertEqual(parsed?.seed, [SpaceApps.Item(id: "bookmarks", by: "Ana", ts: 7, uid: "")])
+ }
+
+ func testSnapshotParseWithoutRegistryState() {
+ let json = #"{"states":{"gif":{"q":""}},"upTo":7}"#.data(using: .utf8)!
+ let parsed = SpaceApps.parseSnapshot(json)
+ XCTAssertEqual(parsed?.upTo, 7)
+ XCTAssertEqual(parsed?.seed, [])
+ }
+
+ // The wire decode: a real encrypted spaceapps.add round-trips into an
+ // Event; a non-spaceapps type and a missing app both decode to nil (fold
+ // input is only ever well-formed installs/removes).
+ func testWireDecode() throws {
+ let key = try GroupKey(rawKey: Data(repeating: 7, count: 32))
+ let add = #"{"type":"spaceapps.add","app":"bookmarks","name":"Ana","ts":1700000000000,"uid":"u-ana"}"#
+ let blob = try key.encryptData(Data(add.utf8))
+ XCTAssertEqual(SpaceApps.event(blob, key: key),
+ SpaceApps.Event(type: "spaceapps.add", app: "bookmarks", by: "Ana",
+ ts: 1_700_000_000_000, uid: "u-ana"))
+ let chat = try key.encryptData(Data(#"{"type":"chat","text":"hi"}"#.utf8))
+ XCTAssertNil(SpaceApps.event(chat, key: key))
+ let missing = try key.encryptData(Data(#"{"type":"spaceapps.add","name":"Mallory"}"#.utf8))
+ XCTAssertNil(SpaceApps.event(missing, key: key))
+ }
+}
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 361bd0f..4bcede1 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -1590,6 +1590,19 @@ struct NativeChatView: View {
store.applyDMClosed(threadID: tid, closed: frame.type == "dm_closed")
return
}
+ // The space thread was just created somewhere (content-free prod,
+ // spacethread.go) โ€” fetch it and fold its installs.
+ if frame.type == "space_thread" {
+ Task { await store.reloadSpaceApps() }
+ return
+ }
+ // The space thread's own frames: fold installs in place โ€” the
+ // thread never appears in /api/threads, so a refresh would learn
+ // nothing about it.
+ if let tid = frame.thread_id, tid == store.spaceApps.threadID {
+ store.spaceApps.handleLive(frame)
+ return
+ }
// Any stored-message mutation anywhere in the instance refreshes
// the sidebar (previews, ordering, unread) โ€” debounced so a burst
// is one refresh. role_changed rides the same path: an admin
diff --git a/package.json b/package.json
index 982f475..a4c20ed 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/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/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.test.mjs b/test/appcatalog.test.mjs
new file mode 100644
index 0000000..edf9da8
--- /dev/null
+++ b/test/appcatalog.test.mjs
@@ -0,0 +1,131 @@
+// ๐Ÿค– The app-host protocol's catalogue report (docs/native-apps.md): the
+// serialized shapes the NATIVE shells consume via window.lchatNative.
+// appCatalog() / commandItems(). These shapes are a cross-platform contract โ€”
+// Swift decodes them โ€” so this test pins them against the REAL framework
+// (defineApp normalization included), not a reconstruction. Widening the
+// report is additive; renaming or dropping a field here means updating the
+// native decoders in the same PR.
+import assert from "assert";
+import { defineApp, appCatalogReport, commandCatalog } from "../web/static/apps.js";
+// A real shipped app as a sanity row: the vault is the canonical TAKEOVER app
+// (groupFeedMessage, no chatMessage โ€” docs/building-apps.md), so its record
+// pins the companion gate against the real thing.
+import "../web/static/apps/vault.js";
+
+// A synthetic companion app exercising every reported field.
+defineApp({
+ name: "cataloguetest",
+ title: "Catalogue Test",
+ glyph: "๐Ÿงช",
+ places: ["you", "chat"],
+ quiet: true,
+ defaultView: "interface",
+ chatReadOnly: true,
+ initialState: () => ({ items: [] }),
+ events: { "cataloguetest.add": (s) => s },
+ chatMessage: () => null,
+ defaultCommand: "/ct-add",
+ commands: {
+ "/ct": { run: () => {}, desc: "Test the catalogue", args: "<thing>" },
+ "/ct-add": { run: () => {}, desc: "Add locally", args: "<thing>", local: true },
+ "/ct-bare": () => {}, // bare-handler form must normalize to empty desc/args
+ },
+});
+
+// A synthetic takeover app: no chatMessage, chat-place only.
+defineApp({
+ name: "takeovertest",
+ title: "Takeover Test",
+ glyph: "๐ŸŽจ",
+ places: ["chat"],
+ initialState: () => ({}),
+ events: {},
+ render: () => {},
+});
+
+const report = appCatalogReport();
+const byName = Object.fromEntries(report.map((a) => [a.name, a]));
+
+// The report is PLAIN JSON โ€” the whole point is that evaluateJavaScript can
+// hand it to Swift losslessly. A function or undefined leaking in breaks that.
+assert.deepStrictEqual(JSON.parse(JSON.stringify(report)), report, "report must round-trip through JSON unchanged");
+
+// Every record carries the full field set, correctly typed.
+for (const a of report) {
+ assert.strictEqual(typeof a.name, "string");
+ assert.ok(a.name, "name is never empty");
+ assert.strictEqual(typeof a.title, "string");
+ assert.strictEqual(typeof a.glyph, "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"]) {
+ assert.strictEqual(typeof a[k], "boolean", `${a.name}.${k} is a boolean`);
+ }
+ assert.ok(["chat", "split", "interface"].includes(a.defaultView), `${a.name}.defaultView normalized`);
+ assert.strictEqual(typeof a.defaultCommand, "string");
+ assert.ok(Array.isArray(a.commands));
+ for (const c of a.commands) {
+ assert.ok(c.name.startsWith("/"), `${a.name} command ${c.name} keeps its slash`);
+ assert.strictEqual(typeof c.desc, "string");
+ assert.strictEqual(typeof c.args, "string");
+ assert.strictEqual(typeof c.local, "boolean");
+ }
+}
+
+// The companion record, field by field.
+const ct = byName.cataloguetest;
+assert.ok(ct, "synthetic companion app reported");
+assert.strictEqual(ct.title, "Catalogue Test");
+assert.strictEqual(ct.glyph, "๐Ÿงช");
+assert.deepStrictEqual(ct.places, ["you", "chat"]);
+assert.strictEqual(ct.listed, true); // defaults on: places is non-empty
+assert.strictEqual(ct.quiet, true);
+assert.strictEqual(ct.member, false);
+assert.strictEqual(ct.companion, true);
+assert.strictEqual(ct.chatOnly, false);
+assert.strictEqual(ct.chatReadOnly, true);
+assert.strictEqual(ct.defaultView, "interface");
+assert.strictEqual(ct.defaultCommand, "/ct-add");
+assert.deepStrictEqual(
+ ct.commands.find((c) => c.name === "/ct"),
+ { name: "/ct", desc: "Test the catalogue", args: "<thing>", local: false },
+);
+assert.deepStrictEqual(
+ ct.commands.find((c) => c.name === "/ct-add"),
+ { name: "/ct-add", desc: "Add locally", args: "<thing>", local: true },
+);
+assert.deepStrictEqual(
+ ct.commands.find((c) => c.name === "/ct-bare"),
+ { name: "/ct-bare", desc: "", args: "", local: false },
+);
+
+// The takeover record: no chatMessage means no companion, and the unset
+// presentation fields land on their documented defaults.
+const to = byName.takeovertest;
+assert.ok(to, "synthetic takeover app reported");
+assert.strictEqual(to.companion, false);
+assert.strictEqual(to.chatOnly, false);
+assert.strictEqual(to.chatReadOnly, false);
+assert.strictEqual(to.defaultView, "split");
+assert.strictEqual(to.defaultCommand, "");
+
+// The real vault: a takeover app (groupFeedMessage only) โ€” the companion gate
+// answered from shipped code, not this file's synthetics.
+const vault = byName.vault;
+assert.ok(vault, "vault registered and reported");
+assert.strictEqual(vault.companion, false, "vault is a takeover app");
+assert.ok(vault.title && vault.glyph, "vault carries identity");
+
+// commandItems() serves commandCatalog(threadId); with no thread context the
+// rows still come back JSON-plain with the typeahead fields native ranks on.
+const rows = commandCatalog();
+assert.deepStrictEqual(JSON.parse(JSON.stringify(rows)), rows, "command rows must round-trip through JSON");
+const ctRow = rows.find((r) => r.name === "/ct");
+assert.ok(ctRow, "global command listed in the catalog");
+assert.strictEqual(typeof ctRow.desc, "string");
+assert.strictEqual(ctRow.args, "<thing>");
+assert.ok(!rows.some((r) => r.name === "/ct-add"), "local commands stay out of the global catalog");
+const me = rows.find((r) => r.name === "/me");
+assert.ok(me && me.bot === false, "/me rides the floor, client-answered");
+
+console.log("appcatalog: all assertions passed");
diff --git a/web/static/apps.js b/web/static/apps.js
index 9965bf9..ee8c9ae 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -529,6 +529,41 @@ export function appIsQuiet(name) {
return !!(app && app.quiet);
}
+// appCatalogReport serializes the registered-app catalogue for the NATIVE
+// shell โ€” the app-host protocol (docs/native-apps.md). The native apps can't
+// execute app JS, so this page (which just did, across all three tiers:
+// built-in, admin custom, member) reports every app's manifest facts as plain
+// JSON: identity, places, presentation, and commands. That one report is what
+// lets native drive its registry, pickers, surface routing, and composer
+// typeahead for ANY app with zero per-app native code. Plain data only โ€” a
+// WKWebView evaluateJavaScript call returns it directly.
+export function appCatalogReport() {
+ return apps.map((app) => ({
+ name: app.name,
+ title: app.title || app.name,
+ glyph: app.glyph || "โ–ฆ",
+ places: [...app.places],
+ listed: !!app.listed,
+ quiet: !!app.quiet,
+ member: !!app.memberApp,
+ // 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
+ // means the app's render takes over the whole surface.
+ companion: typeof app.chatMessage === "function",
+ chatOnly: !!(typeof app.chatMessage === "function" && app.chatOnly),
+ chatReadOnly: !!app.chatReadOnly,
+ defaultView: app.defaultView === "chat" || app.defaultView === "interface" ? app.defaultView : "split",
+ defaultCommand: app.defaultCommand && app.commands[app.defaultCommand] ? app.defaultCommand : "",
+ commands: Object.entries(app.commands).map(([name, c]) => ({
+ name,
+ desc: c.desc || "",
+ args: c.args || "",
+ local: !!c.local,
+ })),
+ }));
+}
+
// appWantsChatView decides how a personal app-thread presents. An app that
// implements chatMessage(event) โ€” the hook projecting its events into chat
// bubbles / action lines โ€” opts into the COMPANION view: a Chat conversation of
diff --git a/web/static/shell.js b/web/static/shell.js
index d88d5e0..7df6729 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -90,6 +90,7 @@ import {
notifyLive,
renderApp as renderMiniApp,
appByName,
+ appCatalogReport,
appIsQuiet,
appForType,
appWantsChatView,
@@ -1515,6 +1516,23 @@ function installNativeBridge() {
appThreadView[tid] = "interface";
refreshGroupAppsUI(tid);
},
+ // The app-host protocol (docs/native-apps.md): the native shell pulls the
+ // registered-app catalogue โ€” every app this page loaded across all three
+ // tiers (built-in, admin custom, member), with places / presentation /
+ // commands โ€” to drive its own registry, pickers, and surface routing.
+ // Pull-based on purpose: an evaluateJavaScript call returns the plain
+ // JSON directly, and native re-pulls on {catalogChanged} posts.
+ appCatalog() {
+ return appCatalogReport();
+ },
+ // This thread's command rows โ€” the same commandCatalog(threadId) that
+ // feeds the web "/" typeahead and /help, place-stamped and with the
+ // install-flag alt rows, so the native composer can list and rank exactly
+ // what the web would. Rows change as installs fold, so native re-pulls
+ // per composer session rather than caching long.
+ commandItems() {
+ return embed && embed.threadId !== undefined ? commandCatalog(embed.threadId) : commandCatalog();
+ },
// Composer typeahead for the native input: same token detection + ranking
// as the web menu (composerToken + typeaheadItems). caret is a UTF-16
// offset, matching JS string indexing. Returns {start, items} or null.
@@ -7289,6 +7307,15 @@ function connectWS() {
if (msg.type === "apps_changed") {
// Something in the app set changed: a member app (bot build / App Studio),
// or an admin-installed custom app (an ES module, which needs a reload).
+ // In an embed the NATIVE shell owns reloads (a web banner would render
+ // inside a pane the shell controls): register member apps live, then
+ // post {catalogChanged} so native re-pulls appCatalog() and recreates
+ // any webviews that need a fresh module load (docs/native-apps.md).
+ if (embed) {
+ loadMemberAppsNow().then(() => activeId && renderActiveSurfaces());
+ nativePost({ catalogChanged: msg.kind || "updated" });
+ return;
+ }
if (recentLocalDeploy()) {
// We deployed it ourselves โ€” App Studio already registered it live and
// suppressed its own reload; just refresh the surfaces.