๐ค 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>
17 files changed,
+1164
−5
docs/native-apps.md+288 −0ios/LChat/Views/ThreadListView.swift+13 −0ios/LChatCore/Sources/LChatCore/AppCatalog.swift+80 −0ios/LChatCore/Sources/LChatCore/ChatSocket.swift+1 −0ios/LChatCore/Sources/LChatCore/GroupApps.swift+9 −4ios/LChatCore/Sources/LChatCore/LChatAPI.swift+12 −0ios/LChatCore/Sources/LChatCore/SpaceApps.swift+117 −0ios/LChatCore/Sources/LChatCore/SpaceAppsStore.swift+193 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+17 −0ios/LChatCore/Tests/LChatCoreTests/AppCatalogTests.swift+69 −0ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift+12 −0ios/LChatCore/Tests/LChatCoreTests/SpaceAppsVectorTests.swift+146 −0ios/LChatMac/NativeChatView.swift+13 −0package.json+1 −1test/appcatalog.test.mjs+131 −0web/static/apps.js+35 −0web/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. |