eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1# ๐Ÿค– Native apps ร— the app platform: apps as first-class citizens
2
3> Status: **SHIPPED โ€” phases 0โ€“5 + the voice surfaces merged to `main`
4> 2026-07-23** (#1256, #1314โ€“#1318; field fixes from live evaluation rode the
5> same PRs). What runs today: the app-host protocol below (all rows marked
6> shipped), `AppRegistry` (bundled seed from `tools/gen-app-catalog.mjs`,
7> drift-gated by `test/appcatalog-seed.test.mjs`, live `appCatalog()` pulls),
8> `SpaceApps`/`SpaceAppsStore` (installs survive relaunch), the platform-shared
9> command host on both platforms (full forwarding, per-thread typeahead rows,
10> the `needKey`/`needAppThread` bridge), space routing + the space band,
11> projection/preview queries (feeds and sidebars narrate any app's events),
12> and the voice contract (`web/static/apps/README.md ยง Voice`). Only phase 6
13> ("make it good", below) remains open. This note is the standing design
14> reference โ€” the same role `docs/native-chat.md` plays for the native feed.
15
16## The position (accepted, load-bearing)
17
18**Member apps โ€” every tier of them โ€” are first-class citizens in the native
19apps.** Concretely, the native apps (Mac and iOS, as one product) must:
20
211. **Recognise and use any app available in a space** โ€” built-in, admin-installed
22 custom, or member-built โ€” with **zero per-app native code**. Native
23 special-casing (the schema engine, hand-native surfaces) is a progressive
24 enhancement selected per app, never a gate on availability.
252. **Offer one consistent way to discover, add, and open apps** โ€” personal
26 ("you"), per-group ("chat"), and space-wide ("space") โ€” identical in
27 behaviour across Mac and iOS.
283. **Support the full app capability set the web does** โ€” posting into chat,
29 commands and replies, panels, scheduling, snapshots, notices โ€” generally,
30 not as per-app special cases.
314. **Expect the app set to differ per space and change over time** โ€” folding
32 the install registries and reacting to the server's change prods, with the
33 result surviving restart.
345. **Expect the command/completion set to differ per chat and per server** โ€”
35 assembled dynamically, never hard-coded.
36
37"Make it work" means every app runs on native through the web implementation
38(embedded, bridged); "make it good" means native surfaces replace webviews
39per app **behind the same registry**, invisible to the rest of the system.
40
41## What the app platform actually is (the contract a client implements)
42
43Everything below already exists and is what the *web* client implements. A
44native client is simply **the second implementation of the same contract** โ€”
45none of it requires server changes (see "What the server provides" below).
46
47**Three tiers of apps, one registry.** Built-in apps are ES modules compiled
48into the binary (statically imported, `shell.js`). Admin-installed **custom
49apps** are plaintext JS: discovered via public `GET /api/apps` โ†’
50`{apps:[{name,url}]}`, served at `/static/apps-custom/<name>`
51(`customapps.go`). **Member apps** are user content: instance-key-encrypted
52blobs from `GET /api/member-apps`, executed only inside the opaque-origin
53sandbox (`/app-sandbox`, `sandbox.js` / `sandbox-frame.js`). All three tiers
54register through one `defineApp()` into one in-page registry (`apps.js`), and
55the server broadcasts a content-free `{type:"apps_changed", kind}` WS frame
56whenever the custom/member set changes.
57
58**Install state is fold state.** Which apps live where is end-to-end encrypted
59and client-derived โ€” the server never learns it:
60
61- *Personal* ("you"): implicit โ€” a `kind:"self"` thread whose `name_blob`
62 decrypts to `{app,title}`. Created on first use.
63- *Group* ("chat"): `groupapps.add/remove` events in that group's own log,
64 folded by the `installs` registry app (`apps.js`), pinned by
65 `test/event-vectors/groupapps.json`.
66- *Space-wide* ("space"): `spaceapps.add/remove` events in the hidden
67 all-members **space thread** (`kind:"space"`; fetched explicitly via
68 `GET /api/space-thread` โ€” it never appears in `/api/threads`; its AES key
69 rides `open_key`, wrapped under the *instance key*, so no private key is
70 needed to open it). Pinned by `test/event-vectors/spaceapps.json`; design
71 `docs/space-apps.md`. Creation is lazy + race-safe; the server prods online
72 clients with a content-free `{type:"space_thread"}` frame.
73
74Both registries filter **at read time** to apps the client actually has
75registered and that are capable of that place โ€” unknown installs fold but stay
76inert, so clients on different versions converge on the same log.
77
78**Commands are assembled per thread.** `commandCatalog(threadId)` (`apps.js`)
79builds the typeahead/`/help` list dynamically: the owning app's local verbs
80first, then every registered app's global commands stamped with *where the
81data lands* ("chat"/"space"/"you", ranked by nearness), alt rows for the
82`@me`/`@here`/`@everyone` flags, then floor commands, `/me`, and bot commands.
83Invocation is `runCommand(text, threadId, reply)` โ€” parse, route to the
84nearest instance (`instanceThread`), build a ctx, run. Replies are private
85in-place notices; a command may also open a **panel** (`ctx.panel`) โ€” a
86transient in-chat surface.
87
88**Presentation is an app's own declaration.** `appWantsChatView(name)` โ€” i.e.
89"does the app implement `chatMessage(event)`" โ€” decides companion
90(Chat | Split | Interface) vs takeover; `defaultView` / `chatOnly` /
91`chatReadOnly` refine it; `groupFeedMessage(event)` narrates a takeover app's
92events into a *group's* feed without growing a Chat view. These are facts of
93the app spec, not of the client.
94
95**The capability set (`ctx`)** an app runs against: `emit` (encrypt + post
96into the bound thread), `schedule`/`unschedule` (server-fired future events,
97`/api/schedule`), `emitEphemeral` (relay-only), `getState`, `id`, `me`,
98`meName`, `threadId`, `place`, `reply`, `panel`, `open`, `interval`,
99`onCleanup`. **Memory** is automatic: folded state snapshots to
100`GET/PUT /api/threads/{id}/state` (encrypted, optimistic-versioned, 409 โ†’
101refold + retry). **Voice** is `notice`/`notify` on events.
102
103### What the server provides โ€” and why this needs no server changes
104
105The initial hunch list ("server APIs to communicate which apps are available",
106"event folds doing rollup to a current state") turns out to already exist, in
107the E2EE-shaped form the one rule demands:
108
109| hunch | what exists |
110|---|---|
111| "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. |
112| "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. |
113| "app added / app removed events" | `groupapps.*` / `spaceapps.*` wire events, already vector-pinned for native. |
114| "a bridge like the web ctx" | Client-side by definition โ€” the app-host protocol below. |
115| "custom commands invoking across the bridge" | `commandCatalog` + `runCommand` exist; the gap is native consuming them. |
116
117The whole plan is client work. The server's part โ€” blobs, prods, schedules,
118state slots, the space thread โ€” is finished and in production.
119
120## Where native was before this work (the honest map, kept as the record)
121
122> ๐Ÿค– Historical: this section described native as of 2026-07-22, and is what
123> the phases below were designed against. Every symptom in it is fixed on
124> `main` except the vault passkey row (phase 6). Kept because the root-cause
125> table is the best explanation of WHY the architecture looks the way it does.
126
127Three parallel app subsystems grew independently in `ios/`:
128
1291. **Schema apps** โ€” `SchemaEngine/SchemaAppStore/SchemaAppView` (LChatCore)
130 interpret `Schemas/{todo,remind}.json` natively. Generic and good โ€” but
131 fed only by two bundled files (`AppSchemaRegistry`).
1322. **Webview embeds** โ€” everything else renders the web client in a
133 `WKWebView` seeded with `window.__lchatEmbed` (one thread, one thread key).
134 iOS: `ChatView`+`WebBridge`+`FeedWebCache`; Mac: `AppThreadWebView`+
135 `MacWebBridge`+`AppWebViewCache`, plus a Mac-only per-thread **command
136 host** (`CommandPanelWebView`, `mode:"command"`) used for `/emoji`/`/gif`.
137 *(Since resolved on iOS: `ChatView` โ€” the embed that could carry a
138 thread's CHAT โ€” is deleted. `AppThreadScreen` renders app-thread chat
139 native and pins every surviving embed to `view:"interface"`; a webview
140 now only ever carries an app's own interface, on both platforms.)*
1413. **Flow apps** โ€” `FlowEngine` for `setup`/`welcome`.
142
143Overlaid: a native `groupapps` fold (`GroupApps.swift`) and **~10 hand-kept
144per-app lookup tables** that each re-state a fragment of app metadata:
145`AppIdentity.symbols/marks`, `GroupApps.catalog` (4 apps),
146`ThreadStore.appTitles/appGlyphs/appDescriptions/personalApps` (12/12/12/5),
147`AppChatProjection.intros/title` (2), `AppDefaultCommand.specs` (2),
148`NativeChatView.companionApps` (2), `ComposerTypeahead.botCommands`,
149`SlashCommands.WebPanelCommands.catalog`. The same fact ("is this app a
150companion?") lives in **two** independent sets (iOS keys off
151`AppChatProjection.intros`, Mac off `companionApps`); the app catalogue exists
152in **three** different-length lists.
153
154### Symptoms โ†’ root causes
155
156| symptom | root cause |
157|---|---|
158| 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. |
159| 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. |
160| 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. |
161| 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. |
162| 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. |
163| 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. |
164| Vault passkey lock dead on native | WebAuthn inside an embedded `WKWebView` โ€” needs a host bridge (deliberately deferred; "make it good"). |
165
166## Target architecture
167
168Three layers, all platform-shared in LChatCore; iOS and Mac keep only thin UI.
169
170### Layer 1 โ€” `AppRegistry`: one source of truth (native)
171
172A LChatCore store that merges, and persists across launches:
173
174- **The catalogue** โ€” per-app manifest records reported by the app host
175 (below): `name, title, glyph, places, listed, quiet, companion, chatOnly,
176 chatReadOnly, defaultView, defaultCommand, composerPlaceholder,
177 commands[{name, desc, args, local}]`. Cached to disk; invalidated by
178 `apps_changed` โ†’ `catalogChanged`.
179- **Install state** โ€” personal app threads (`ThreadStore` already folds
180 these); per-group `groupapps` folds (`ChatFeedModel` already); and the new
181 **`SpaceAppsStore`**: fetch `/api/space-thread`, unwrap `open_key` with the
182 native instance key, seed from its `slot:"apps"` snapshot + fold
183 `spaceapps.*` per the existing vectors, subscribe to the
184 `space_thread`/`apps_changed` frames in `ChatSocket`, persist.
185- **Native enhancements** โ€” `AppSchemaRegistry.hasSchema`, SF-symbol/brand-mark
186 overrides (`AppIdentity` becomes a *decoration* keyed by registry id, with
187 the manifest `glyph` as the universal fallback โ€” unknown apps get their
188 glyph, never a blank).
189
190Every current lookup table collapses into a registry read. The two
191companion-sets, three catalogues, and per-app titles/glyphs/blurbs all become
192one record type with one source. **An app the registry doesn't know still
193works**: title/glyph/commands come from the manifest, surface is a webview โ€”
194that is the first-class-citizen guarantee.
195
196### Layer 2 โ€” the app host: the web engine, bridged (the generalized command host)
197
198The one component that can *execute* app JS is the web framework โ€” so native
199runs it, headless, and talks to it over a small protocol. This is not new
200machinery: it is the Mac command host (`mode:"command"`), generalized and made
201first-class on both platforms. The embed page already loads **all three app
202tiers** (`bootEmbed` runs `loadCustomApps()` + `loadMemberAppsNow()`), already
203runs any command, and already reports panels/replies/errors.
204
205**The app-host protocol** (native โ‡„ `window.lchatNative` / `"lchat"` posts):
206
207| direction | call / post | meaning | status |
208|---|---|---|---|
209| native โ†’ js | `appCatalog()` โ†’ `[manifestโ€ฆ]` | the full registered-app catalogue (all tiers) | shipped |
210| native โ†’ js | `commandItems()` โ†’ `[rowโ€ฆ]` | `commandCatalog(threadId)` for the embed's thread โ€” the typeahead/`/help` source | shipped |
211| native โ†’ js | `runCommand(text)` โ†’ handled? | run any slash command; replies/panels post back | exists |
212| native โ†’ js | `dismissPanel()` | close the open panel | exists |
213| js โ†’ native | `{catalogChanged: kind}` | the app set changed (`apps_changed`); re-pull `appCatalog`, recreate stale webviews | shipped |
214| js โ†’ native | `{commandHostReady}`, `{panel}`, `{panelHeight}`, `{notice}`, `{composerInsert}`, `{error}` | command-host lifecycle | exists |
215| 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. | shipped |
216| js โ†’ native | `{needAppThread: {app, title}}` โ†’ native calls `__lchatProvideAppThread(app, threadId, keyB64)` | the appโ†’thread resolution itself: the embed can't decrypt other threads' name_blobs, so resolving there would mint a duplicate app thread. Native opens-or-creates (`openOrCreateAppThread`) and answers with the id + key. | shipped |
217| native โ†’ js | `previewEvent(event, you)` โ†’ `{text} \| null` | the sidebar line for one app event โ€” the app's own `preview(e, {you})` hook, defaulting in the engine to the actor-prefixed chat projection (the voice chain: notice โ†’ preview โ†’ chat projection โ†’ silent). `you` = the actor is the reader ("You addedโ€ฆ"). | shipped |
218| native โ†’ js | `projectEvent(event, group)` โ†’ `{text, action, title} \| 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. Ownership resolves in the engine via `appForType` (native never prefix-guesses โ€” `webhooks.*` belongs to webhook-monitor); `title` rides along so native names the bubble. | shipped |
219
220Trust posture: the app host is a privileged surface *by job description* โ€” it
221is the command engine, so its reach is the union of the member's app threads,
222group threads it's asked about, and the space thread. It still **never**
223holds the user's private key (native unwraps and hands narrow thread keys),
224and the server still only ever sees ciphertext. Member-app code inside the
225host stays inside the `/app-sandbox` iframe exactly as on the web. The
226`needKey` grant is native-policied: only keys native itself can unwrap, only
227on request, never the private key or admin token.
228
229Two host-shape decisions:
230
231- **Per-thread hosts first** (the existing Mac shape, LRU-cached): minimal
232 delta, and `appCatalog()` is thread-independent so *any* live embed can
233 serve it. A single per-instance host (`mode:"apphost"`, threadless boot) is
234 a later consolidation, not a prerequisite.
235- **Snapshots come for free**: a host that folds a thread with real keys runs
236 the framework's automatic snapshot writer โ€” which fixes "native households
237 never write snapshots" without new code. (Safe by construction: snapshot
238 writes are optimistic-versioned, and racing writers refold + retry.)
239
240The embed's space-thread bail (`loadSpaceThread`: `if (embed || !instanceKey)
241return`) lifts for hosts seeded with the instance key, so space-installed
242apps' commands route correctly inside the host too (phase 4).
243
244### Layer 3 โ€” registry-driven surfaces
245
246- **Surface routing**: iOS `ThreadListView.chatSurface` and Mac
247 `NativeChatView.feed` stop switching on hard-coded names. One shared
248 decision: `registry.presentation(appId)` โ†’ *native-schema* (schema exists) /
249 *flow* (`setup`/`welcome`) / *companion* (Chat|Split|Interface on Mac,
250 Chat|Interface on iOS โ€” the Chat pane is the NATIVE feed on both, honouring
251 `defaultView`, `chatOnly`, `chatReadOnly`) / *takeover* (the app's
252 interface, full pane) โ€” with the interface-only webview as the universal
253 fallback. Chat itself never renders in a webview: a webview may only carry
254 an app's own interface (iOS `AppThreadScreen`/`AppInterfacePane`, Mac
255 `companionSplit`/`appInterfacePane`).
256- **Pickers**: New-chat โ–ธ Apps (personal = `places` โˆ‹ "you"), group โ‹ฎ โ–ธ Apps
257 (chat-capable, so the board appears), and the space band (space installs,
258 from `SpaceAppsStore`) all render from the registry โ€” custom and member
259 apps included, identically on both platforms.
260- **Typeahead**: `ComposerTypeahead` consumes registry commands (per-thread
261 rows via `commandItems()`, cached + refreshed on fold changes) instead of
262 its hard-coded floor; selection routes to the command host. The floor
263 (`/me`, `/help`, theme verbs) stays native.
264- **Group instances**: the added-apps switcher and the group feed's app-event
265 narration come from the registry + projection queries, one design on both
266 platforms (Mac keeps Split; iOS keeps toggle).
267
268## Phases (all shipped 2026-07-23 except phase 6; each landed as its own PR)
269
270- **Phase 0 โ€” protocol groundwork (web-side, testable in Node). SHIPPED.**
271 `appCatalogReport()` in `apps.js`; `appCatalog()` / `commandItems()` on the
272 embed bridge; `{catalogChanged}` post on `apps_changed` in embeds;
273 `test/appcatalog.test.mjs` pins the shapes.
274- **Phase 1 โ€” installs survive: `SpaceAppsStore` + registry persistence. SHIPPED.**
275 LChatCore: space-thread fetch/unwrap, `spaceapps` fold (vectors exist:
276 `spaceapps.json`), `space_thread`/`apps_changed` in `ChatSocket`, disk
277 persistence for catalogue + installs. Acceptance: kill + relaunch โ†’ a
278 space's installed apps (vault included) are still listed.
279- **Phase 2 โ€” one registry, registry-driven routing. SHIPPED.** Collapse the lookup
280 tables into `AppRegistry`; route surfaces and pickers from it. Acceptance:
281 Admin gets Chat|View on Mac and opens as the app on iOS; the board is
282 addable to a group and loads; unknown/custom apps appear with glyph+title.
283- **Phase 3 โ€” commands everywhere. SHIPPED.** iOS gains the command host; both
284 platforms forward *every* non-floor slash command to it; typeahead rows
285 from `commandItems()`; `needKey` bridge for cross-thread routing.
286 Acceptance: `/todo buy milk` works from any chat on both platforms;
287 `/emoji`/`/gif` stop dead-posting on iOS; a custom app's command appears in
288 the typeahead with its desc/args.
289- **Phase 4 โ€” space routing in the host. SHIPPED.** Lift the embed space-thread bail
290 when the instance key is present; space band UI. Acceptance: `/gif` (space
291 app) works natively where installed.
292- **Phase 5 โ€” projection queries. SHIPPED.** Native feeds narrate app events for apps
293 without native projections (`projectEvent`); `AppChatProjection` becomes the
294 fast path for schema apps, same outputs, vector-pinned.
295- **Phase 6 โ€” make it good. OPEN.** More schemas; native panel rendering; the
296 vault passkey/WebAuthn host bridge; per-instance host consolidation; native
297 snapshot writing direct from `SchemaAppStore` if the host path isn't enough;
298 the sandbox-projection relay, so MEMBER apps' events narrate in native feeds
299 too (their `chatMessage` runs only inside the `/app-sandbox` iframe, so
300 `projectEvent` deliberately answers null for them today).
301
302## Deliberately out of scope
303
304Bots and webhooks (already native-blind by design โ€” sidecar processes);
305changing any wire format or server behaviour; SDUI for the chat feed
306(`docs/native-chat.md` locked that out). The schema engine keeps growing
307*within* its archetype โ€” it is the "make it good" tier, never the
308availability gate.
309