eleven / messenger Public

πŸ€– Native apps phase 3: commands everywhere (lands #1271) (#1315)

* native+web: commands everywhere β€” iOS command host, full forwarding, needKey bridge (app-host phase 3)

Phase 3 of docs/native-apps.md. The command host (the headless embed page
that IS the web command engine) becomes platform-shared and first-class:

- CommandHost/CommandHostCache move to LChatCore (from the Mac-only
  CommandPanelWebView.swift); each platform keeps a thin representable.
  The host now also pulls the thread's commandItems() rows on ready (and
  re-pulls on {catalogChanged}), reporting them via a .commands sink event.
- iOS gains the host: NativeChatScreen mounts the panel webview above the
  composer (height-0 until a panel shows, the Mac shape), renders command
  replies in the existing inline routeNotice slot, pre-warms on "/", and
  forwards commands β€” /emoji and /gif stop dead-posting as literals.
- Forwarding broadens on BOTH platforms: the old two-command whitelist
  (WebPanelCommands.owns) becomes the pre-rows fallback only. The new
  CommandRouting consults the live rows β€” the native floor (/me /theme
  /themes /integrity) and the app-thread's own default-command verbs stay
  native, bot-answered commands ride to the conversation as messages, and
  every framework-owned command (and unknown /typos, which the web engine
  answers with an honest private notice) forwards to the host.
- The "/" typeahead consumes the per-thread rows when they've landed (the
  web's own ranked, place-stamped catalogue β€” custom/member app commands
  appear with desc/args); the hard-coded floor stands until then.
- needKey/needAppThread bridge (web + native): a command typed in one
  thread can emit into another (a personal /todo in a group). The embed
  posts {needKey: threadId} from the emit transport, or
  {needAppThread: {app, title}} when the app→thread resolution itself
  needs native (the embed can't decrypt other threads' name_blobs β€” its
  own resolver would mint a DUPLICATE app thread, so bootEmbed now routes
  setAppThreadResolver through the bridge). Native answers with one narrow
  thread key (SharedThreadKeys, origin-checked) or openOrCreateAppThread's
  id + key β€” never the private key. Missing answers time out to the same
  graceful failure as today; instances on pre-bridge deploys are unaffected.

swift test: 362 pass (8 new: CommandRouting + typeahead rows); npm test
green; node --check shell.js; Mac + iOS app targets build.

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

* native: the catalogue stays fresh without a webview open

Field feedback (James, 2026-07-23): with the native surfaces carrying the
whole session (native feeds, flow Eleven, schema todo/remind), no embed
page may ever exist β€” so nobody heard apps_changed, and a member app
deployed from another client never reached the registry (it appeared only
if a reload happened to boot a webview).

- Both sidebar sockets handle apps_changed: ThreadStore.refreshAppCatalog
  invalidates the live-report latch and force-pulls through a command
  host, MINTED for any keyed thread if no page is alive to answer.
- Once per launch, ThreadStore kicks that same host when no live report
  has landed β€” the doc's "appCatalog() is thread-independent; any live
  embed can serve it", made deterministic instead of incidental.

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

* native: the catalogue host RELOADS on apps_changed β€” custom apps need a fresh page

Field feedback (James, 2026-07-23): deploying a custom app made the web
offer its reload banner, but the native registry never learned it β€” the
apps_changed re-pull asked the same live page, and a custom app is an ES
module imported at page boot: no live page can ever report a new one.
The doc specified "re-pull appCatalog() and recreate stale webviews";
this is the recreate. reloadForCatalog drops the host's ready latch and
reloads its page β€” the fresh boot imports all three tiers and the
ready-pull reports the full set.

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

* native: the command host's ready-pull is forced β€” the stale-relay race

Live-debugged with James (2026-07-23, alt logs): on apps_changed the OLD
host page's {catalogChanged} relay force-pulls its stale catalogue just
before the native reload lands, re-setting the once-per-launch latch β€” so
the freshly booted page's ready-pull (the one that knows the new app) was
skipped, every time. Restart worked; live updates lost the race. A fresh
boot's report is authoritative: force it. Feed webviews stay latch-gated.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
James Adam f03c021deb838dd74d156fd81befb79de5ccb9b8 parent cf6375d
14 files changed, +816 −258
  • docs/native-apps.md +12 −2
  • ios/LChat/Views/CommandPanelWebView.swift +15 −0
  • ios/LChat/Views/NativeChatScreen.swift +115 −1
  • ios/LChat/Views/ThreadListView.swift +8 −0
  • ios/LChatCore/Sources/LChatCore/CommandHost.swift +335 −0
  • ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift +26 −1
  • ios/LChatCore/Sources/LChatCore/SlashCommands.swift +35 −8
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +29 −0
  • ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift +39 −0
  • ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift +42 −1
  • ios/LChatMac/CommandPanelWebView.swift +6 −226
  • ios/LChatMac/NativeChatView.swift +9 −0
  • ios/LChatMac/NativeFeedView.swift +54 −16
  • web/static/shell.js +91 −3
diff --git a/docs/native-apps.md b/docs/native-apps.md
index 4d0a329..66082aa 100644
--- a/docs/native-apps.md
+++ b/docs/native-apps.md
@@ -1,6 +1,15 @@
# πŸ€– Native apps Γ— the app platform: apps as first-class citizens
-> Status: **DESIGN + PHASES 0–2 (2026-07-22)** β€” phase 2 (branch
+> Status: **DESIGN + PHASES 0–3 (2026-07-22)** β€” phase 3 (branch
+> `claude/native-apps-commands`) ships commands everywhere: the command host
+> moves to LChatCore and iOS gains it (panel + notices in the native
+> composer), both platforms forward every non-floor slash command
+> (`CommandRouting` over live `commandItems()` rows β€” bot-answered rides to
+> the conversation, framework-owned forwards), the "/" typeahead consumes the
+> per-thread rows, and the `needKey`/`needAppThread` bridge lets a command
+> typed in one thread emit into another (native answers with one narrow
+> thread key / an open-or-created app thread β€” never the private key).
+> Phase 2 (branch
> `claude/native-apps-registry`) ships `AppRegistry`: the bundled seed
> catalogue generated from the web framework (`tools/gen-app-catalog.mjs`,
> drift-gated by `test/appcatalog-seed.test.mjs`), live `appCatalog()` pulls
@@ -207,7 +216,8 @@ runs any command, and already reports panels/replies/errors.
| 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 |
+| 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 (shipped)** |
+| 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. | **phase 3 (shipped)** |
| 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
diff --git a/ios/LChat/Views/CommandPanelWebView.swift b/ios/LChat/Views/CommandPanelWebView.swift
new file mode 100644
index 0000000..8f3f268
--- /dev/null
+++ b/ios/LChat/Views/CommandPanelWebView.swift
@@ -0,0 +1,15 @@
+import LChatCore
+import SwiftUI
+import WebKit
+
+// πŸ€– The iOS face of the platform-shared command host (LChatCore
+// CommandHost β€” the generalized command engine, docs/native-apps.md Layer 2):
+// just the UIViewRepresentable that frames the host's webview in the composer
+// stack. The host itself (page seeding, runCommand forwarding, panel/notice
+// posts, command rows, needKey/needAppThread answers) lives in LChatCore,
+// shared with the Mac.
+struct CommandPanelWebView: UIViewRepresentable {
+ let host: CommandHost
+ func makeUIView(context: Context) -> WKWebView { host.web }
+ func updateUIView(_ web: WKWebView, context: Context) {}
+}
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 015b419..db5299e 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -59,6 +59,17 @@ struct NativeChatScreen: View {
@State private var draft = ""
@State private var caret = 0
+ // The per-thread command host (LChatCore CommandHost β€” the web command
+ // engine, headless): slash commands the native floor doesn't own forward
+ // here; replies come back as inline notices, panels as the webview above
+ // the composer. Created lazily on the first "/" typed.
+ @State private var commandHost: CommandHost?
+ // This thread's commandItems() rows (the host reports them once ready) β€”
+ // they feed the "/" typeahead and the forward-or-send routing decision.
+ @State private var hostRows: [CommandRow] = []
+ @State private var panelVisible = false
+ @State private var panelHeight: CGFloat = 240
+ @State private var panelAnimates = true // the open post's animate flag, reused on close
@State private var composerFocused = false // ComposerField's own focus binding (UIKit-backed)
@State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])?
// A default command's push-back line, generation-tokened so a re-shown
@@ -939,6 +950,18 @@ struct NativeChatScreen: View {
if let ta = nativeTypeahead {
typeaheadStrip(ta.items)
}
+ // The command panel β€” a command's transient interactive surface
+ // (/emoji, /gif), the webview revealed above the composer while a
+ // panel is showing. Mounted permanently once a host exists (height
+ // 0 when closed) so the page loads and keeps its state β€” the Mac
+ // composer's exact shape.
+ if let commandHost {
+ CommandPanelWebView(host: commandHost)
+ .frame(height: panelVisible ? panelHeight : 0)
+ .clipShape(RoundedRectangle(cornerRadius: panelVisible ? 12 : 0))
+ .padding(.horizontal, panelVisible ? 10 : 0)
+ .accessibilityHidden(!panelVisible)
+ }
// A default command pushing back ("when should I remind you?") β€”
// inline above the composer, occupying layout, never floating.
if let routeNotice {
@@ -1012,6 +1035,10 @@ struct NativeChatScreen: View {
.onChange(of: draft) { _, _ in
refreshTypeahead()
model.typingBeat(active: !draft.isEmpty && editingID == nil)
+ // Pre-warm the command host the moment a command might
+ // be coming β€” its page load happens during typing, and
+ // its rows upgrade the "/" typeahead (Mac parity).
+ if draft.first == "/", commandHost == nil { ensureCommandHost() }
}
.onChange(of: caret) { _, _ in refreshTypeahead() }
@@ -1138,6 +1165,28 @@ struct NativeChatScreen: View {
?? "Integrity monitoring isn't active for this space."
return
case .help, .sendAsMessage:
+ // Not the native floor. The current app-thread's own verbs
+ // stay native (the default-command block below); everything
+ // else consults the thread's commandItems() rows: a
+ // bot-answered command rides to the conversation as a
+ // message, and every framework-owned command β€” /emoji, /gif,
+ // /todo, a custom app's verb, even a typo (the web engine
+ // answers "Unknown command β€” try /help" as a private notice
+ // instead of posting the literal) β€” forwards to the command
+ // host. Cleared immediately, like the web composer β€” a
+ // booting page queues the text.
+ let ownVerb = defaultCommand.map {
+ AppDefaultCommand.routing(draft, spec: $0) != .passthrough
+ } ?? false
+ if !ownVerb {
+ let host = ensureCommandHost()
+ if CommandRouting.route(draft, rows: hostRows.isEmpty ? host.commandRows : hostRows) == .host {
+ cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)")
+ host.run(draft)
+ draft = ""
+ return
+ }
+ }
break
}
}
@@ -1185,6 +1234,70 @@ struct NativeChatScreen: View {
@State private var showThemeSettings = false
@State private var integrityReport: String? // /integrity read-out (tamper evidence)
+ // The thread's command host (CommandHostCache keeps it across thread
+ // switches); every call re-points the sink at THIS on-screen pane β€” the
+ // model-cache re-point pattern, same as the Mac composer.
+ @discardableResult
+ private func ensureCommandHost() -> CommandHost {
+ let host = commandHost ?? CommandHostCache.shared.host(
+ for: instance, thread: thread, myUserId: store.myUserId,
+ instanceKeyB64: store.instanceKey())
+ if commandHost == nil { commandHost = host }
+ host.sink = { event in
+ switch event {
+ case .panel(_, let height, let animate): // the web panel draws its own header
+ panelAnimates = animate
+ withAnimation(animate ? .easeOut(duration: 0.22) : nil) {
+ panelHeight = min(height, 340)
+ panelVisible = true
+ }
+ case .panelClosed:
+ withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) {
+ panelVisible = false
+ }
+ case .panelHeight(let h):
+ if panelVisible {
+ withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) {
+ panelHeight = min(h, 340)
+ }
+ }
+ case .composerInsert(let text):
+ // A panel acting on the composer (an /emoji tap) β€” inserted at
+ // the caret, like the web's insertAtCaret.
+ composerController.insert(text, replacingUTF16: nil)
+ composerFocused = true
+ case .notice(let from, let text):
+ // A command's private "only visible to you" reply β€” the same
+ // inline slot the default-command push-back uses.
+ setRouteNotice(from.isEmpty ? text : "\(from) β€” \(text)")
+ case .commands(let rows):
+ // The thread's live command catalogue landed β€” the typeahead
+ // and the forwarding decision follow it from here.
+ hostRows = rows
+ refreshTypeahead()
+ }
+ }
+ // Cross-thread command routing (docs/native-apps.md phase 3): the page
+ // asks for one narrow thread key ({needKey}) or an app's personal
+ // thread ({needAppThread}); answer from what THIS member already
+ // holds β€” never the private key.
+ let origin = instance.origin
+ host.keyProvider = { tid in
+ guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil }
+ return e.key.base64urlEncodedString()
+ }
+ host.appThreadProvider = { [weak store] app, title in
+ guard let store, let id = await store.openOrCreateAppThread(app: app, title: title)
+ else { return nil }
+ if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty {
+ return (id, key)
+ }
+ return nil
+ }
+ if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows }
+ return host
+ }
+
private func startReply(_ m: ChatFeedModel.Message) {
editingID = nil
// Replying to a reply continues its thread: target the TOP message.
@@ -1249,7 +1362,8 @@ struct NativeChatScreen: View {
let items = typeaheadItems(token, roster: store.roster,
myUserId: store.myUserId, hasBot: hasBot,
appCommands: AppDefaultCommand.localCommands(appID: thread.appId),
- displayNames: store.displayLabelMap())
+ displayNames: store.displayLabelMap(),
+ rows: hostRows)
nativeTypeahead = items.isEmpty ? nil : (token, items)
}
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 5dcc554..fc3d9d8 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -464,6 +464,14 @@ struct ThreadListView: View {
Task { await store.reloadSpaceApps() }
return
}
+ // The app set changed (a custom/member app deployed or updated,
+ // anywhere): refresh the catalogue β€” through a command host,
+ // minted if no page is alive to answer (an all-native session
+ // may have none).
+ if frame.type == "apps_changed" {
+ store.refreshAppCatalog()
+ 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.
diff --git a/ios/LChatCore/Sources/LChatCore/CommandHost.swift b/ios/LChatCore/Sources/LChatCore/CommandHost.swift
new file mode 100644
index 0000000..af1c6ab
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/CommandHost.swift
@@ -0,0 +1,335 @@
+import WebKit
+import os
+
+// πŸ€– The generalized command host (docs/native-apps.md Layer 2), platform-
+// shared: the one component that can EXECUTE app JS is the web framework, so
+// native runs the embed page headless (mode:"command" β€” shell.js
+// installCommandHost collapses it to just a command's panel) and talks to it
+// over the app-host protocol. Typed slash commands forward to
+// window.lchatNative.runCommand; replies ({notice}), panels ({panel}), the
+// per-thread command rows (commandItems()), and cross-thread key requests
+// ({needKey}/{needAppThread}) ride the "lchat" post handler. Grown from the
+// Mac's /emoji//gif panel host β€” now first-class on both platforms.
+
+public let cmdLog = Logger(subsystem: "com.elevenmessenger.messenger", category: "commands")
+
+/// What the command-host page tells the shell.
+public enum CommandHostEvent {
+ case panel(title: String, height: CGFloat, animate: Bool) // reveal (or retitle) the panel
+ case panelClosed // collapse it (βœ•, Escape, app close)
+ case panelHeight(CGFloat) // late layout settled β€” resize
+ case composerInsert(String) // insert into the native composer
+ case notice(from: String, text: String) // a private reply β€” render inline
+ case commands([CommandRow]) // this thread's commandItems() rows landed
+}
+
+/// One row of commandCatalog(threadId) (apps.js) β€” the same rows that feed
+/// the web's "/" typeahead and /help, place-stamped. Decoded tolerantly: the
+/// web side owns the shape and may grow it.
+public struct CommandRow: Codable, Equatable, Sendable {
+ public let name: String
+ public var desc: String?
+ public var args: String?
+ /// Answered by a bot in the conversation (send as a message), vs run by
+ /// the app framework (forward to the host).
+ public var bot: Bool?
+ /// Where the data lands: "chat" | "space" | "you" | "" β€” display hint.
+ public var place: String?
+ /// An @here/@everyone/@me alternate of a default row.
+ public var alt: Bool?
+
+ public init(name: String, desc: String? = nil, args: String? = nil,
+ bot: Bool? = nil, place: String? = nil, alt: Bool? = nil) {
+ self.name = name
+ self.desc = desc
+ self.args = args
+ self.bot = bot
+ self.place = place
+ self.alt = alt
+ }
+}
+
+// One thread's command host: the webview, its readiness, and the queue of
+// commands typed before the page finished booting. Owned by CommandHostCache
+// (which outlives any one SwiftUI view); the feed view on screen points `sink`
+// at itself, exactly like the model-cache re-point pattern.
+@MainActor
+public final class CommandHost: NSObject, WKScriptMessageHandler {
+ public let web: WKWebView
+ public var sink: ((CommandHostEvent) -> Void)?
+ /// This thread's commandItems() rows, once the page reported them β€”
+ /// feeds the composer typeahead and the bot-or-host forwarding decision.
+ public private(set) var commandRows: [CommandRow] = []
+ /// Answers {needKey: threadId}: the thread's key (base64url) if THIS
+ /// member holds it β€” set by the platform view alongside `sink`. Only keys
+ /// native itself can unwrap, never the private key (the app-host trust
+ /// posture, docs/native-apps.md).
+ public var keyProvider: ((String) -> String?)?
+ /// Answers {needAppThread: {app, title}}: open-or-create the app's
+ /// personal thread and return (threadId, threadKeyB64) β€” the embed can't
+ /// resolve app→thread itself (other threads' name_blobs don't decrypt
+ /// there), and letting it create would mint duplicates.
+ public var appThreadProvider: ((String, String) async -> (id: String, keyB64: String)?)?
+ private var ready = false
+ private var queued: [String] = []
+ private let origin: String // the app-catalogue pull's registry key
+
+ public init(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?) {
+ origin = instance.origin
+ var seed: [String: Any] = [
+ "token": instance.deviceToken,
+ "name": instance.screenName,
+ "myUserId": myUserId,
+ "threadId": thread.id,
+ "threadKey": thread.threadKeyB64,
+ "mode": "command",
+ ]
+ if let ik = instanceKeyB64 { seed["instanceKey"] = ik }
+ // Per-viewer names (#577): names only β€” never key material.
+ let names = NativeFeedModelCache.shared.names(instance: instance.id)
+ if !names.isEmpty { seed["names"] = names }
+ let cfg = WKWebViewConfiguration()
+ cfg.websiteDataStore = .default()
+ if let data = try? JSONSerialization.data(withJSONObject: seed),
+ let json = String(data: data, encoding: .utf8) {
+ cfg.userContentController.addUserScript(
+ WKUserScript(source: "window.__lchatEmbed = \(json);",
+ injectionTime: .atDocumentStart, forMainFrameOnly: true))
+ }
+ web = WKWebView(frame: .zero, configuration: cfg)
+ super.init()
+ // A weak proxy breaks the controller→handler→self→web retain cycle, so
+ // an evicted host actually deallocates (teardown also unregisters).
+ cfg.userContentController.add(WeakMessageHandler(self), name: "lchat")
+ web.navigationDelegate = self
+ #if os(macOS)
+ web.setValue(false, forKey: "drawsBackground") // blend with the composer chrome
+ #else
+ web.isOpaque = false
+ web.backgroundColor = .clear
+ #endif
+ if let url = instance.baseURL {
+ cmdLog.notice("command host loading \(url.absoluteString, privacy: .public) for thread \(thread.id, privacy: .public)")
+ web.load(URLRequest(url: url))
+ } else {
+ cmdLog.error("command host has no base URL (origin \(instance.origin, privacy: .public))")
+ }
+ }
+
+ // Run a slash command through the page's engine. Booting queues the text β€”
+ // the page flushes on {commandHostReady} β€” so the first command typed in a
+ // thread never races the load.
+ public func run(_ text: String) {
+ guard ready else {
+ cmdLog.notice("command queued until the page is ready: \(text, privacy: .public)")
+ queued.append(text)
+ return
+ }
+ guard let data = try? JSONSerialization.data(withJSONObject: [text]),
+ let arg = String(data: data, encoding: .utf8) else { return }
+ web.evaluateJavaScript(
+ "window.lchatNative && window.lchatNative.runCommand ? window.lchatNative.runCommand(\(arg)[0]) : false"
+ ) { result, error in
+ if let error {
+ cmdLog.error("runCommand failed: \(error.localizedDescription, privacy: .public)")
+ } else if (result as? Bool) == false {
+ cmdLog.notice("command host page has no runCommand bridge (old server?)")
+ }
+ }
+ }
+
+ public func dismissPanel() {
+ web.evaluateJavaScript(
+ "window.lchatNative && window.lchatNative.dismissPanel && window.lchatNative.dismissPanel()",
+ completionHandler: nil)
+ }
+
+ /// The app set changed (apps_changed): a CUSTOM app is a plaintext ES
+ /// module the page imported at boot β€” no live re-registration can learn
+ /// a new or updated one (the web's own answer is its reload banner). So
+ /// the catalogue host reloads its page; the fresh boot re-imports all
+ /// three tiers and the ready-pull reports the new set.
+ public func reloadForCatalog() {
+ ready = false
+ commandRows = []
+ web.reload()
+ }
+
+ public func teardown() {
+ web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat")
+ web.stopLoading()
+ sink = nil
+ keyProvider = nil
+ appThreadProvider = nil
+ }
+
+ // This thread's typeahead/forwarding rows β€” pulled once the page is ready
+ // and re-pulled when the app set changes; installs folding in the page
+ // change what commandCatalog returns, so callers may also re-pull on
+ // demand (refreshCommandRows).
+ public func refreshCommandRows() {
+ let js = "window.lchatNative && window.lchatNative.commandItems"
+ + " ? JSON.stringify(window.lchatNative.commandItems()) : null"
+ web.evaluateJavaScript(js) { [weak self] result, _ in
+ guard let json = result as? String,
+ let rows = try? JSONDecoder().decode([CommandRow].self, from: Data(json.utf8))
+ else { return }
+ Task { @MainActor in
+ guard let self else { return }
+ self.commandRows = rows
+ self.sink?(.commands(rows))
+ }
+ }
+ }
+
+ // MARK: WKScriptMessageHandler
+ public func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let dict = message.body as? [String: Any] else { return }
+ if dict["commandHostReady"] as? Bool == true {
+ cmdLog.notice("command host ready (\(self.queued.count) queued)")
+ ready = true
+ let pending = queued
+ queued = []
+ for text in pending { run(text) }
+ // The host page loaded all three app tiers β€” pull the live
+ // catalogue into the registry, FORCED: a fresh boot's report is
+ // authoritative. The once-per-launch latch can't gate this β€” on
+ // apps_changed the old page's {catalogChanged} relay force-pulls
+ // its STALE set just before the reload, re-setting the latch, and
+ // the fresh page's report (the one that knows the new app) was
+ // the pull being skipped. Feed webviews stay latch-gated.
+ AppCatalogPull.pull(from: web, origin: origin, force: true)
+ refreshCommandRows()
+ return
+ }
+ if dict["catalogChanged"] != nil {
+ // The space's app set changed (apps_changed) β€” re-pull both.
+ AppCatalogPull.pull(from: web, origin: origin, force: true)
+ refreshCommandRows()
+ return
+ }
+ // Cross-thread routing (docs/native-apps.md phase 3): the page asks
+ // for one narrow thread key it wasn't seeded with. Always answer β€”
+ // an empty key resolves the page's wait to an honest failure fast
+ // instead of a timeout.
+ if let tid = dict["needKey"] as? String {
+ let key = keyProvider?(tid) ?? ""
+ cmdLog.notice("needKey for thread \(tid, privacy: .public): \(key.isEmpty ? "not held" : "provided", privacy: .public)")
+ callBack("__lchatProvideKey", [tid, key])
+ return
+ }
+ if let req = dict["needAppThread"] as? [String: Any], let app = req["app"] as? String {
+ let title = req["title"] as? String ?? app
+ guard let provider = appThreadProvider else {
+ callBack("__lchatProvideAppThread", [app, "", ""])
+ return
+ }
+ Task { @MainActor [weak self] in
+ let got = await provider(app, title)
+ cmdLog.notice("needAppThread \(app, privacy: .public): \(got == nil ? "unresolved" : "provided", privacy: .public)")
+ self?.callBack("__lchatProvideAppThread", [app, got?.id ?? "", got?.keyB64 ?? ""])
+ }
+ return
+ }
+ if dict.keys.contains("panel") {
+ if let p = dict["panel"] as? [String: Any] {
+ let h = (p["height"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 240
+ cmdLog.notice("panel open: \(p["title"] as? String ?? "", privacy: .public) height \(h)")
+ sink?(.panel(title: p["title"] as? String ?? "", height: h,
+ animate: (p["animate"] as? Bool) ?? true))
+ } else {
+ cmdLog.notice("panel closed")
+ sink?(.panelClosed)
+ }
+ return
+ }
+ if let h = dict["panelHeight"] as? NSNumber {
+ sink?(.panelHeight(CGFloat(truncating: h)))
+ return
+ }
+ if let t = dict["composerInsert"] as? String {
+ cmdLog.notice("composerInsert (\(t.count) chars)")
+ sink?(.composerInsert(t))
+ return
+ }
+ if let n = dict["notice"] as? [String: Any] {
+ cmdLog.notice("notice from \(n["from"] as? String ?? "", privacy: .public)")
+ sink?(.notice(from: n["from"] as? String ?? "", text: n["text"] as? String ?? ""))
+ return
+ }
+ // The page reporting a boot problem ({error} β€” bootEmbed's catch).
+ if let e = dict["error"] as? String {
+ cmdLog.error("command host page error: \(e, privacy: .public)")
+ }
+ }
+
+ // Call one of the page's __lchat* answer hooks with string arguments,
+ // JSON-encoded so ids/keys can't break out of the call.
+ private func callBack(_ fn: String, _ args: [String]) {
+ guard let data = try? JSONSerialization.data(withJSONObject: args),
+ let list = String(data: data, encoding: .utf8) else { return }
+ web.evaluateJavaScript(
+ "window.\(fn) && window.\(fn)(...\(list))", completionHandler: nil)
+ }
+}
+
+// Load diagnostics: a page that never reaches didFinish explains a silent
+// panel better than anything else can.
+extension CommandHost: WKNavigationDelegate {
+ public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ cmdLog.notice("command host page loaded")
+ }
+ public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ cmdLog.error("command host page failed: \(error.localizedDescription, privacy: .public)")
+ }
+ public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+ cmdLog.error("command host page failed to start: \(error.localizedDescription, privacy: .public)")
+ }
+}
+
+// WKUserContentController retains its handlers strongly; this proxy keeps the
+// host owning the controller without a cycle (the standard WKWebView pattern).
+private final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
+ weak var target: WKScriptMessageHandler?
+ init(_ target: WKScriptMessageHandler) { self.target = target }
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
+ target?.userContentController(controller, didReceive: message)
+ }
+}
+
+// Keeps each thread's command host alive across thread switches, so a second
+// slash command in a revisited chat is instant. Same LRU shape as
+// AppWebViewCache; hosts are cheap to rebuild, so the cap stays small.
+@MainActor
+public final class CommandHostCache {
+ public static let shared = CommandHostCache()
+
+ private struct Entry {
+ let host: CommandHost
+ let seedKey: String
+ var lastUsed = Date()
+ }
+
+ private var entries: [String: Entry] = [:] // "instanceID|threadID"
+ private let maxEntries = 4
+
+ public func host(for instance: SavedInstance, thread: ChatThread,
+ myUserId: String, instanceKeyB64: String?) -> CommandHost {
+ let key = instance.id + "|" + thread.id
+ if var e = entries[key], e.seedKey == thread.threadKeyB64 {
+ e.lastUsed = Date()
+ entries[key] = e
+ return e.host
+ }
+ entries.removeValue(forKey: key)?.host.teardown()
+ let host = CommandHost(instance: instance, thread: thread,
+ myUserId: myUserId, instanceKeyB64: instanceKeyB64)
+ entries[key] = Entry(host: host, seedKey: thread.threadKeyB64)
+ while entries.count > maxEntries,
+ let oldest = entries.filter({ $0.value.host.web.superview == nil })
+ .min(by: { $0.value.lastUsed < $1.value.lastUsed }) {
+ entries.removeValue(forKey: oldest.key)?.host.teardown()
+ }
+ return host
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift
index 72e084d..fcdf52c 100644
--- a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift
+++ b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift
@@ -89,10 +89,35 @@ public enum TypeaheadItem: Equatable, Identifiable {
public func typeaheadItems(_ token: ComposerToken, roster: [RosterEntry],
myUserId: String, hasBot: Bool,
appCommands: [(name: String, desc: String)] = [],
- displayNames: [String: String] = [:]) -> [TypeaheadItem] {
+ displayNames: [String: String] = [:],
+ rows: [CommandRow] = []) -> [TypeaheadItem] {
switch token.kind {
case .command:
let q = token.query.lowercased()
+ // With a thread's live commandItems() rows (the command host reported
+ // them β€” docs/native-apps.md phase 3), the web's own per-thread
+ // catalogue drives the menu: every registered app's commands,
+ // place-stamped and ranked by the framework. Bot-answered rows still
+ // gate on a bot being present; /integrity (native-only, absent from
+ // the web catalogue) rides along. Without rows β€” booting host, older
+ // server β€” the hard-coded floor below stands, exactly as before.
+ if !rows.isEmpty {
+ let live = rows
+ .filter { !($0.bot ?? false) || hasBot }
+ .map { (name: $0.name, desc: $0.desc ?? "") }
+ + [integrityCommand]
+ var seenLive = Set<String>()
+ return live
+ .filter { $0.name.dropFirst().lowercased().hasPrefix(q) }
+ .filter { cmd in
+ let key = cmd.name.lowercased()
+ guard !seenLive.contains(key) else { return false }
+ seenLive.insert(key)
+ return true
+ }
+ .prefix(8)
+ .map { .command(name: $0.name, desc: $0.desc) }
+ }
// The web's rule (shell.js typeaheadItems): bot-answered commands show
// only where a bot can answer them; client-answered ones show in every
// thread β€” a botless DM still offers what the client itself runs. The
diff --git a/ios/LChatCore/Sources/LChatCore/SlashCommands.swift b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift
index 0b715f2..f631857 100644
--- a/ios/LChatCore/Sources/LChatCore/SlashCommands.swift
+++ b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift
@@ -62,14 +62,12 @@ public enum SlashCommand: Equatable {
}
}
-// πŸ€– Commands the native shell forwards to its per-thread COMMAND-HOST webview
-// (the web command engine β€” shell.js installCommandHost) instead of handling
-// natively or sending as a message. These are the web's panel-opening commands;
-// only ones that act in the CURRENT thread (or need no thread at all) belong
-// here β€” the embed page holds a single thread's key, so a command that routes
-// to another thread (/todo) can't resolve there. Keep name + desc matching the
-// web app's declaration (apps/emoji.js), like the botCommands mirror in
-// ComposerTypeahead.swift.
+// πŸ€– The historical command-host whitelist (/emoji, /gif) β€” now only the
+// FALLBACK the router below uses before a thread's commandItems() rows have
+// landed (a booting host, or an instance still serving a pre-bridge deploy).
+// With live rows, every framework-owned command forwards (docs/native-apps.md
+// phase 3) β€” the needKey/needAppThread bridge lifted the "can't route to
+// another thread" constraint that used to cap this list at two.
public enum WebPanelCommands {
public static let catalog: [(name: String, desc: String)] = [
("/emoji", "browse emoji β€” tap one to add it to your message"),
@@ -85,3 +83,32 @@ public enum WebPanelCommands {
return catalog.contains { $0.name == name.lowercased() }
}
}
+
+// πŸ€– The forwarding decision for a typed slash command that ISN'T the native
+// floor (/me, /theme, /themes, /integrity β€” SlashCommand.parse answers those)
+// and isn't the current app-thread's own default-command verb (routed
+// natively by AppDefaultCommand): consult the thread's commandItems() rows.
+// Bot-answered rows ride to the conversation as ordinary messages (the bot
+// replies there, matching the web); framework rows β€” and unknown commands,
+// which the web engine answers with an honest "Unknown command β€” try /help"
+// private notice instead of posting the literal β€” forward to the command
+// host. Before rows land, the historical /emoji//gif whitelist stands.
+public enum CommandRouting {
+ public enum Decision: Equatable {
+ case host // run through the command host (the web app framework)
+ case message // send as a chat message (bot-answered, or pre-rows floor)
+ }
+
+ public static func route(_ text: String, rows: [CommandRow]) -> Decision {
+ let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard t.first == "/", t.count > 1 else { return .message }
+ let name = (t.split(separator: " ", maxSplits: 1).first.map(String.init) ?? t).lowercased()
+ if rows.isEmpty {
+ return WebPanelCommands.owns(t) ? .host : .message
+ }
+ if let row = rows.first(where: { $0.name.lowercased() == name }) {
+ return (row.bot ?? false) ? .message : .host
+ }
+ return .host
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 733195f..81d6983 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -34,6 +34,23 @@ public final class ThreadStore: ObservableObject {
/// source for app titles, glyphs, places, presentation, and pickers.
public var registry: AppRegistry { AppRegistry.shared(origin: instance.origin) }
private var connectedFetchedAt = Date.distantPast
+ private var catalogHostKicked = false
+
+ /// An apps_changed frame reached the sidebar socket (someone deployed or
+ /// changed a custom/member app): invalidate the catalogue and re-pull
+ /// through a command host β€” minting one if no page is alive to answer.
+ public func refreshAppCatalog() {
+ registry.invalidateLiveReport()
+ guard let t = threads.first(where: { !$0.threadKeyB64.isEmpty }) else { return }
+ let host = CommandHostCache.shared.host(for: instance, thread: t,
+ myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64)
+ // Reload, not just re-pull: a custom app is an ES module the page
+ // imported at boot, so a live page can never report a new one. The
+ // fresh boot's ready-pull reports the whole set (the latch above was
+ // just dropped).
+ host.reloadForCatalog()
+ }
// 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?
@@ -328,6 +345,18 @@ public final class ThreadStore: ObservableObject {
// space thread's live frames carry changes from then on).
let ik = instanceKeyB64
Task { await spaceApps.loadIfNeeded(instanceKeyB64: ik) }
+ // The catalogue's live report needs a page that runs the app framework
+ // (custom + member apps only exist there) β€” an all-native session may
+ // never open one, so mint the command host for any thread once per
+ // launch (the doc's "appCatalog() is thread-independent, any live
+ // embed can serve it"). It reports the catalogue when it boots.
+ if !registry.hasLiveReport, !catalogHostKicked,
+ let t = threads.first(where: { !$0.threadKeyB64.isEmpty }) {
+ catalogHostKicked = true
+ _ = CommandHostCache.shared.host(for: instance, thread: t,
+ myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64)
+ }
// Connected services (the pickers' service-app gate) β€” refresh fires
// on every socket frame, so throttle the fetch to once a minute.
if Date().timeIntervalSince(connectedFetchedAt) > 60 {
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
index 42b1658..8788108 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift
@@ -180,3 +180,42 @@ final class ComposerTypeaheadTests: XCTestCase {
"apps with no default-command spec offer no in-thread verbs")
}
}
+
+// πŸ€– Phase 3 (docs/native-apps.md): a thread's live commandItems() rows drive
+// the "/" menu β€” the web's own per-thread catalogue β€” with the hard-coded
+// floor standing only until they land.
+final class TypeaheadRowsTests: XCTestCase {
+ private let rows = [
+ CommandRow(name: "/todo", desc: "add a to-do β€” lands in your To-do", bot: false),
+ CommandRow(name: "/board", desc: "open the whiteboard", bot: false),
+ CommandRow(name: "/help", desc: "what I can do", bot: true),
+ ]
+ private let token = ComposerToken(kind: .command, query: "", start: 0)
+
+ func testRowsDriveTheMenu() {
+ let items = typeaheadItems(token, roster: [], myUserId: "u1", hasBot: true, rows: rows)
+ let names: [String] = items.compactMap {
+ if case .command(let name, _) = $0 { return name } else { return nil }
+ }
+ // Row order is the web's ranking; /integrity (native-only, absent
+ // from the web catalogue) rides along.
+ XCTAssertEqual(names, ["/todo", "/board", "/help", "/integrity"])
+ }
+
+ func testBotRowsGateOnABot() {
+ let items = typeaheadItems(token, roster: [], myUserId: "u1", hasBot: false, rows: rows)
+ let names: [String] = items.compactMap {
+ if case .command(let name, _) = $0 { return name } else { return nil }
+ }
+ XCTAssertFalse(names.contains("/help"))
+ XCTAssertTrue(names.contains("/todo"))
+ }
+
+ func testEmptyRowsKeepTheHardCodedFloor() {
+ let items = typeaheadItems(token, roster: [], myUserId: "u1", hasBot: false, rows: [])
+ let names: [String] = items.compactMap {
+ if case .command(let name, _) = $0 { return name } else { return nil }
+ }
+ XCTAssertEqual(names, ["/emoji", "/gif", "/me", "/integrity", "/theme", "/themes"])
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift
index ed75489..3d53e3d 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift
@@ -39,7 +39,7 @@ final class WebPanelCommandsTests: XCTestCase {
func testOwnsRejectsEverythingElse() {
XCTAssertFalse(WebPanelCommands.owns("/emojis cat")) // whole-token match only
XCTAssertFalse(WebPanelCommands.owns("/me waves")) // native-handled
- XCTAssertFalse(WebPanelCommands.owns("/todo buy milk"))// routes to another thread β€” never forwarded
+ XCTAssertFalse(WebPanelCommands.owns("/todo buy milk"))// not in the pre-rows fallback floor
XCTAssertFalse(WebPanelCommands.owns("emoji")) // no slash β†’ plain text
XCTAssertFalse(WebPanelCommands.owns(""))
}
@@ -47,3 +47,44 @@ final class WebPanelCommandsTests: XCTestCase {
XCTAssertTrue(WebPanelCommands.catalog.contains { $0.name == "/emoji" && !$0.desc.isEmpty })
}
}
+
+// πŸ€– The phase-3 forwarding decision (docs/native-apps.md): with a thread's
+// live commandItems() rows, every framework-owned command forwards to the
+// command host β€” the needKey/needAppThread bridge lifted the old "can't route
+// to another thread" cap β€” while bot-answered rows still ride to the
+// conversation. Before rows land, the historical /emoji//gif floor stands.
+final class CommandRoutingTests: XCTestCase {
+ private let rows = [
+ CommandRow(name: "/todo", desc: "add a to-do", bot: false),
+ CommandRow(name: "/emoji", desc: "browse emoji", bot: false),
+ CommandRow(name: "/help", desc: "what I can do", bot: true),
+ ]
+
+ func testFrameworkCommandsForwardWithRows() {
+ // The lifted constraint: /todo typed anywhere forwards now.
+ XCTAssertEqual(CommandRouting.route("/todo buy milk", rows: rows), .host)
+ XCTAssertEqual(CommandRouting.route("/emoji cat", rows: rows), .host)
+ XCTAssertEqual(CommandRouting.route(" /TODO x ", rows: rows), .host) // trim + case
+ }
+
+ func testBotAnsweredCommandsRideToTheConversation() {
+ XCTAssertEqual(CommandRouting.route("/help", rows: rows), .message)
+ }
+
+ func testUnknownCommandsForwardForTheHonestNotice() {
+ // The web engine answers "Unknown command β€” try /help" privately
+ // instead of posting the literal into the chat.
+ XCTAssertEqual(CommandRouting.route("/typo huh", rows: rows), .host)
+ }
+
+ func testPlainTextIsNeverACommand() {
+ XCTAssertEqual(CommandRouting.route("hello", rows: rows), .message)
+ XCTAssertEqual(CommandRouting.route("/", rows: rows), .message)
+ }
+
+ func testPreRowsFloorIsTheHistoricalWhitelist() {
+ XCTAssertEqual(CommandRouting.route("/emoji cat", rows: []), .host)
+ XCTAssertEqual(CommandRouting.route("/gif office", rows: []), .host)
+ XCTAssertEqual(CommandRouting.route("/todo buy milk", rows: []), .message)
+ }
+}
diff --git a/ios/LChatMac/CommandPanelWebView.swift b/ios/LChatMac/CommandPanelWebView.swift
index 5810be0..a5a51d9 100644
--- a/ios/LChatMac/CommandPanelWebView.swift
+++ b/ios/LChatMac/CommandPanelWebView.swift
@@ -1,233 +1,13 @@
import LChatCore
import SwiftUI
import WebKit
-import os
-// Diagnostics for the whole command→panel chain (page load, readiness, every
-// post, run/dismiss evals). Watch with:
-// log stream --predicate 'subsystem == "com.elevenmessenger.mac" && category == "commands"'
-let cmdLog = Logger(subsystem: "com.elevenmessenger.mac", category: "commands")
-
-// πŸ€– The command engine + panel surface for a NATIVE chat thread. The Mac feed
-// renders chat natively, so it has no JS to run mini-app slash commands β€” this
-// per-thread webview is that engine: the same embed page AppThreadWebView uses,
-// seeded with mode:"command" (shell.js installCommandHost), which collapses the
-// page to just a command's PANEL. The shell keeps it hidden, forwards a typed
-// command to window.lchatNative.runCommand, and reveals the webview above the
-// composer only while a panel is showing β€” the webview IS the panel. Posts back
-// on the "lchat" handler: {commandHostReady}, {panel: {title, height} | null},
-// {panelHeight}, {composerInsert} (a panel inserting into the native composer),
-// and {notice} (a command's private "only visible to you" reply).
-
-// What the command-host page tells the shell.
-enum CommandHostEvent {
- case panel(title: String, height: CGFloat, animate: Bool) // reveal (or retitle) the panel
- case panelClosed // collapse it (βœ•, Escape, app close)
- case panelHeight(CGFloat) // late layout settled β€” resize
- case composerInsert(String) // insert into the native composer
- case notice(from: String, text: String) // a private reply β€” render inline
-}
-
-// One thread's command host: the webview, its readiness, and the queue of
-// commands typed before the page finished booting. Owned by CommandHostCache
-// (which outlives any one SwiftUI view); the feed view on screen points `sink`
-// at itself, exactly like the model-cache re-point pattern.
-@MainActor
-final class CommandHost: NSObject, WKScriptMessageHandler {
- let web: WKWebView
- var sink: ((CommandHostEvent) -> Void)?
- private var ready = false
- private var queued: [String] = []
- private let origin: String // the app-catalogue pull's registry key
-
- init(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?) {
- origin = instance.origin
- var seed: [String: Any] = [
- "token": instance.deviceToken,
- "name": instance.screenName,
- "myUserId": myUserId,
- "threadId": thread.id,
- "threadKey": thread.threadKeyB64,
- "mode": "command",
- ]
- if let ik = instanceKeyB64 { seed["instanceKey"] = ik }
- // Per-viewer names (#577): names only β€” never key material.
- let names = NativeFeedModelCache.shared.names(instance: instance.id)
- if !names.isEmpty { seed["names"] = names }
- let cfg = WKWebViewConfiguration()
- cfg.websiteDataStore = .default()
- if let data = try? JSONSerialization.data(withJSONObject: seed),
- let json = String(data: data, encoding: .utf8) {
- cfg.userContentController.addUserScript(
- WKUserScript(source: "window.__lchatEmbed = \(json);",
- injectionTime: .atDocumentStart, forMainFrameOnly: true))
- }
- web = WKWebView(frame: .zero, configuration: cfg)
- super.init()
- // A weak proxy breaks the controller→handler→self→web retain cycle, so
- // an evicted host actually deallocates (teardown also unregisters).
- cfg.userContentController.add(WeakMessageHandler(self), name: "lchat")
- web.navigationDelegate = self
- web.setValue(false, forKey: "drawsBackground") // blend with the composer chrome
- if let url = instance.baseURL {
- cmdLog.notice("command host loading \(url.absoluteString, privacy: .public) for thread \(thread.id, privacy: .public)")
- web.load(URLRequest(url: url))
- } else {
- cmdLog.error("command host has no base URL (origin \(instance.origin, privacy: .public))")
- }
- }
-
- // Run a slash command through the page's engine. Booting queues the text β€”
- // the page flushes on {commandHostReady} β€” so the first command typed in a
- // thread never races the load.
- func run(_ text: String) {
- guard ready else {
- cmdLog.notice("command queued until the page is ready: \(text, privacy: .public)")
- queued.append(text)
- return
- }
- guard let data = try? JSONSerialization.data(withJSONObject: [text]),
- let arg = String(data: data, encoding: .utf8) else { return }
- web.evaluateJavaScript(
- "window.lchatNative && window.lchatNative.runCommand ? window.lchatNative.runCommand(\(arg)[0]) : false"
- ) { result, error in
- if let error {
- cmdLog.error("runCommand eval failed: \(error.localizedDescription, privacy: .public)")
- } else {
- cmdLog.notice("runCommand handled=\((result as? Bool) ?? false) for \(text, privacy: .public)")
- }
- }
- }
-
- // Ask the page to dismiss its panel (Escape in the native composer). The
- // actual collapse follows on the {panel: null} post, so the page's onClose
- // hook always runs β€” never hide the frame directly.
- func dismissPanel() {
- web.evaluateJavaScript(
- "window.lchatNative && window.lchatNative.dismissPanel && window.lchatNative.dismissPanel()",
- completionHandler: nil)
- }
-
- func teardown() {
- web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat")
- web.stopLoading()
- sink = nil
- }
-
- // MARK: WKScriptMessageHandler
- func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
- guard let dict = message.body as? [String: Any] else { return }
- if dict["commandHostReady"] as? Bool == true {
- cmdLog.notice("command host ready (\(self.queued.count) queued)")
- ready = true
- let pending = queued
- queued = []
- for text in pending { run(text) }
- // The host page loaded all three app tiers β€” pull the live
- // catalogue into the registry (cheap once one report landed).
- AppCatalogPull.pull(from: web, origin: origin)
- return
- }
- if dict["catalogChanged"] != nil {
- // The space's app set changed (apps_changed) β€” re-pull.
- AppCatalogPull.pull(from: web, origin: origin, force: true)
- return
- }
- if dict.keys.contains("panel") {
- if let p = dict["panel"] as? [String: Any] {
- let h = (p["height"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 240
- cmdLog.notice("panel open: \(p["title"] as? String ?? "", privacy: .public) height \(h)")
- sink?(.panel(title: p["title"] as? String ?? "", height: h,
- animate: (p["animate"] as? Bool) ?? true))
- } else {
- cmdLog.notice("panel closed")
- sink?(.panelClosed)
- }
- return
- }
- if let h = dict["panelHeight"] as? NSNumber {
- sink?(.panelHeight(CGFloat(truncating: h)))
- return
- }
- if let t = dict["composerInsert"] as? String {
- cmdLog.notice("composerInsert (\(t.count) chars)")
- sink?(.composerInsert(t))
- return
- }
- if let n = dict["notice"] as? [String: Any] {
- cmdLog.notice("notice from \(n["from"] as? String ?? "", privacy: .public)")
- sink?(.notice(from: n["from"] as? String ?? "", text: n["text"] as? String ?? ""))
- return
- }
- // The page reporting a boot problem ({error} β€” bootEmbed's catch).
- if let e = dict["error"] as? String {
- cmdLog.error("command host page error: \(e, privacy: .public)")
- }
- }
-}
-
-// Load diagnostics: a page that never reaches didFinish explains a silent
-// panel better than anything else can.
-extension CommandHost: WKNavigationDelegate {
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- cmdLog.notice("command host page loaded")
- }
- func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
- cmdLog.error("command host page failed: \(error.localizedDescription, privacy: .public)")
- }
- func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
- cmdLog.error("command host page failed to start: \(error.localizedDescription, privacy: .public)")
- }
-}
-
-// WKUserContentController retains its handlers strongly; this proxy keeps the
-// host owning the controller without a cycle (the standard WKWebView pattern).
-private final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
- weak var target: WKScriptMessageHandler?
- init(_ target: WKScriptMessageHandler) { self.target = target }
- func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
- target?.userContentController(controller, didReceive: message)
- }
-}
-
-// Keeps each thread's command host alive across thread switches, so a second
-// slash command in a revisited chat is instant. Same LRU shape as
-// AppWebViewCache; hosts are cheap to rebuild, so the cap stays small.
-@MainActor
-final class CommandHostCache {
- static let shared = CommandHostCache()
-
- private struct Entry {
- let host: CommandHost
- let seedKey: String
- var lastUsed = Date()
- }
-
- private var entries: [String: Entry] = [:] // "instanceID|threadID"
- private let maxEntries = 4
-
- func host(for instance: SavedInstance, thread: ChatThread,
- myUserId: String, instanceKeyB64: String?) -> CommandHost {
- let key = instance.id + "|" + thread.id
- if var e = entries[key], e.seedKey == thread.threadKeyB64 {
- e.lastUsed = Date()
- entries[key] = e
- return e.host
- }
- entries.removeValue(forKey: key)?.host.teardown()
- let host = CommandHost(instance: instance, thread: thread,
- myUserId: myUserId, instanceKeyB64: instanceKeyB64)
- entries[key] = Entry(host: host, seedKey: thread.threadKeyB64)
- while entries.count > maxEntries,
- let oldest = entries.filter({ $0.value.host.web.superview == nil })
- .min(by: { $0.value.lastUsed < $1.value.lastUsed }) {
- entries.removeValue(forKey: oldest.key)?.host.teardown()
- }
- return host
- }
-}
-
-// The visible panel: just the host's webview, framed by the composer stack.
+// πŸ€– The Mac face of the platform-shared command host (LChatCore
+// CommandHost β€” the generalized command engine, docs/native-apps.md Layer 2):
+// just the NSViewRepresentable that frames the host's webview in the composer
+// stack. The host itself (page seeding, runCommand forwarding, panel/notice
+// posts, command rows, needKey/needAppThread answers) lives in LChatCore,
+// shared with iOS.
struct CommandPanelWebView: NSViewRepresentable {
let host: CommandHost
func makeNSView(context: Context) -> WKWebView { host.web }
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 448e335..672edb5 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -637,6 +637,7 @@ struct NativeChatView: View {
isActive: isActive,
roster: store.roster,
instance: instance, instanceKeyB64: store.instanceKey(),
+ store: store,
mentionAvatar: { [weak store] uid in
store.flatMap { s in
s.roster.first { $0.user_id == uid }.flatMap(s.avatarSource(for:))
@@ -1624,6 +1625,14 @@ struct NativeChatView: View {
Task { await store.reloadSpaceApps() }
return
}
+ // The app set changed (a custom/member app deployed or updated,
+ // anywhere): refresh the catalogue β€” through a command host,
+ // minted if no page is alive to answer (an all-native session
+ // may have none).
+ if frame.type == "apps_changed" {
+ store.refreshAppCatalog()
+ 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.
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 7120617..e169d3e 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -120,6 +120,7 @@ struct NativeFeedView: View {
init(model: ChatFeedModel, thread: ChatThread, myUserId: String, isGroup: Bool,
isActive: Bool = true, roster: [RosterEntry],
instance: SavedInstance? = nil, instanceKeyB64: String? = nil,
+ store: ThreadStore? = nil,
mentionAvatar: @escaping (String) -> AvatarSource?,
onReopenDM: (() async -> LChatAPI.ReopenDM)? = nil,
onMutePerson: ((String, Bool) -> Void)? = nil,
@@ -158,6 +159,7 @@ struct NativeFeedView: View {
self.thread = thread
self.instance = instance
self.instanceKeyB64 = instanceKeyB64
+ self.store = store
self.onReopenDM = onReopenDM
self.onMutePerson = onMutePerson
self.onReport = onReport
@@ -171,6 +173,9 @@ struct NativeFeedView: View {
private let thread: ChatThread
private let instance: SavedInstance?
private let instanceKeyB64: String?
+ // For the command host's needAppThread answers (open-or-create a personal
+ // app thread) β€” nil just leaves cross-thread commands unresolved.
+ private let store: ThreadStore?
// The closed banner's Reopen (#578) β€” routed through the mounting view's
// ThreadStore so the sidebar row updates with the composer. nil (a mount
// without a store, e.g. the Debug pane) just hides the button's action.
@@ -209,6 +214,9 @@ struct NativeFeedView: View {
// mini-app slash commands (/emoji) and shows their panels; the panel
// reveals the webview above the composer (see CommandPanelWebView.swift).
@State private var commandHost: CommandHost?
+ // This thread's commandItems() rows (the host reports them once ready) β€”
+ // they feed the "/" typeahead and the forward-or-send routing decision.
+ @State private var hostRows: [CommandRow] = []
@State private var panelVisible = false
@State private var panelHeight: CGFloat = 240
@State private var panelAnimates = true // the open post's animate flag, reused on close
@@ -1316,7 +1324,8 @@ struct NativeFeedView: View {
taToken = composerToken(draft, caret: charCaret)
taItems = taToken.map {
typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
- appCommands: appCommands, displayNames: model.senderNames)
+ appCommands: appCommands, displayNames: model.senderNames,
+ rows: hostRows)
} ?? []
taIndex = 0
}
@@ -1651,8 +1660,31 @@ struct NativeFeedView: View {
// A command's private "only visible to you" reply β€” the same
// inline slot the default-command push-back uses.
setRouteNotice(from.isEmpty ? text : "\(from) β€” \(text)")
+ case .commands(let rows):
+ // The thread's live command catalogue landed β€” the typeahead
+ // and the forwarding decision follow it from here.
+ hostRows = rows
+ refreshTypeahead()
}
}
+ // Cross-thread command routing (docs/native-apps.md phase 3): the page
+ // asks for one narrow thread key ({needKey}) or an app's personal
+ // thread ({needAppThread}); answer from what THIS member already
+ // holds β€” never the private key.
+ let origin = instance.origin
+ host.keyProvider = { tid in
+ guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil }
+ return e.key.base64urlEncodedString()
+ }
+ host.appThreadProvider = { [weak store] app, title in
+ guard let store, let id = await store.openOrCreateAppThread(app: app, title: title)
+ else { return nil }
+ if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty {
+ return (id, key)
+ }
+ return nil
+ }
+ if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows }
return host
}
@@ -1681,21 +1713,6 @@ struct NativeFeedView: View {
// theme, everything else falls through to a normal send so a bot
// answers it (matching the web).
if staged.isEmpty, replyTo == nil, draft.first == "/" {
- // A command the web engine owns (/emoji β€” WebPanelCommands) runs in
- // the thread's command-host webview: its reply posts back as an
- // inline notice, a panel reveals the webview above the composer.
- // Cleared immediately, like the web composer β€” runCommand consumes
- // a command attempt synchronously (a booting page queues it).
- if WebPanelCommands.owns(draft) {
- if let host = ensureCommandHost() {
- cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)")
- host.run(draft)
- draft = "" // onChange(of: draft) ends the typing beat
- } else {
- cmdLog.error("no command host (no instance for this pane) β€” command dropped")
- }
- return
- }
switch SlashCommand.parse(draft, meName: model.myScreenName) {
case .me(let line):
if model.sendAction(line) { draft = "" }
@@ -1714,6 +1731,27 @@ struct NativeFeedView: View {
?? "Integrity monitoring isn't active for this space."
return
case .help, .sendAsMessage:
+ // Not the native floor. The current app-thread's own verbs
+ // stay native (the default-command block below); everything
+ // else consults the thread's commandItems() rows: a
+ // bot-answered command rides to the conversation as a
+ // message, and every framework-owned command β€” /emoji, /gif,
+ // /todo, a custom app's verb, even a typo (the web engine
+ // answers "Unknown command β€” try /help" as a private notice
+ // instead of posting the literal) β€” forwards to the command
+ // host. Cleared immediately, like the web composer β€”
+ // runCommand consumes a command attempt synchronously (a
+ // booting page queues it).
+ let ownVerb = defaultCommand.map {
+ AppDefaultCommand.routing(draft, spec: $0) != .passthrough
+ } ?? false
+ if !ownVerb, let host = ensureCommandHost(),
+ CommandRouting.route(draft, rows: hostRows.isEmpty ? host.commandRows : hostRows) == .host {
+ cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)")
+ host.run(draft)
+ draft = "" // onChange(of: draft) ends the typing beat
+ return
+ }
break // fall through β€” a bot answers /help and bot commands
}
}
diff --git a/web/static/shell.js b/web/static/shell.js
index 7df6729..6b9eb45 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1428,6 +1428,11 @@ async function bootEmbed() {
await openThread(tid);
connectWS();
installNativeBridge();
+ // Cross-thread command routing (docs/native-apps.md phase 3): resolving
+ // an app's personal thread needs decrypted name_blobs this page can't
+ // open β€” route the resolution through the native shell instead of the
+ // shell.js resolver (which would create a duplicate thread here).
+ setAppThreadResolver(embedResolveAppThread);
// Command-host mode (the Mac shell's panel webview): this page exists to
// RUN slash commands and show their panels β€” the native feed renders the
// chat itself. See installCommandHost.
@@ -1444,6 +1449,85 @@ async function bootEmbed() {
}
}
+// ---- app-host key requests (docs/native-apps.md phase 3) -------------------
+// A command typed in one thread can legitimately land in another β€” a personal
+// /todo typed in a group routes to the todo app-thread (instanceThread). The
+// embed page holds a single thread's key, so that emit would silently fail;
+// instead it asks the NATIVE shell, which can unwrap exactly the threads this
+// member is in and hands back one narrow thread key β€” never the private key.
+// Two requests: {needKey: threadId} when the id is known, and
+// {needAppThread: {app, title}} when the app→thread resolution itself needs
+// native (the embed can't decrypt other threads' name_blobs, so resolving
+// here would mint a DUPLICATE app thread). Native answers by calling
+// __lchatProvideKey / __lchatProvideAppThread; a missing answer times out to
+// the same graceful failure as today.
+const nativeKeyWaits = new Map(); // threadId -> [resolve(bool)]
+const nativeAppThreadWaits = new Map(); // appName -> [resolve(threadId|null)]
+window.__lchatProvideKey = async (threadId, keyB64) => {
+ try {
+ if (threadId && keyB64 && !threadKeys[threadId]) {
+ threadKeys[threadId] = await importKey(keyB64);
+ threadKeyB64[threadId] = keyB64;
+ }
+ } catch {}
+ const waits = nativeKeyWaits.get(threadId) || [];
+ nativeKeyWaits.delete(threadId);
+ waits.forEach((r) => r(!!threadKeys[threadId]));
+};
+window.__lchatProvideAppThread = async (appName, threadId, keyB64) => {
+ await window.__lchatProvideKey(threadId, keyB64);
+ if (threadId && threadKeys[threadId]) threadApp[threadId] = appName;
+ const waits = nativeAppThreadWaits.get(appName) || [];
+ nativeAppThreadWaits.delete(appName);
+ waits.forEach((r) => r(threadId && threadKeys[threadId] ? threadId : null));
+};
+function requestNativeKey(threadId) {
+ if (!embed || !threadId) return Promise.resolve(false);
+ if (threadKeys[threadId]) return Promise.resolve(true);
+ return new Promise((resolve) => {
+ const waits = nativeKeyWaits.get(threadId) || [];
+ if (!waits.length) nativePost({ needKey: threadId }); // first asker posts; the rest share the answer
+ let done = false;
+ const once = (ok) => {
+ if (!done) {
+ done = true;
+ resolve(ok);
+ }
+ };
+ waits.push(once);
+ nativeKeyWaits.set(threadId, waits);
+ setTimeout(() => once(false), 4000);
+ });
+}
+function requestNativeAppThread(appName, title) {
+ if (!embed || !appName) return Promise.resolve(null);
+ return new Promise((resolve) => {
+ const waits = nativeAppThreadWaits.get(appName) || [];
+ if (!waits.length) nativePost({ needAppThread: { app: appName, title: title || appName } });
+ let done = false;
+ const once = (id) => {
+ if (!done) {
+ done = true;
+ resolve(id);
+ }
+ };
+ waits.push(once);
+ nativeAppThreadWaits.set(appName, waits);
+ // Creation is server round-trips on the native side β€” wait longer than needKey.
+ setTimeout(() => once(null), 8000);
+ });
+}
+// The embed's app→thread resolver: the active thread if it IS the app, else
+// ask native (which opens-or-creates and answers with id + key). Never create
+// from here β€” a duplicate app thread is worse than an honest miss.
+async function embedResolveAppThread(appName) {
+ for (const id in threadApp) {
+ if (threadApp[id] === appName && threadKeys[id]) return id;
+ }
+ const title = (appByName(appName) || {}).title || appName;
+ return requestNativeAppThread(appName, title);
+}
+
// The bridge the native composer drives: forward typed text and staged photos into
// the one crypto/WebSocket path (shell.js). Mirrors the hooks ChatView's WebBridge
// calls; staged-attachment state + errors are posted back on the "lchat" handler.
@@ -4164,9 +4248,13 @@ registerFloorCommand("/integrity", {
// Mini-app events ride the exact same encrypted-message path as chat: the app
// only produces a typed payload, and the transport wraps it with the same
// envelope (name/uid/ts) chat uses, encrypts to the thread key, and sends it.
-setEmitTransport((threadId, event) => {
- const key = threadKeys[threadId];
- if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(false);
+setEmitTransport(async (threadId, event) => {
+ let key = threadKeys[threadId];
+ // An embed emitting into a thread it wasn't seeded with (a personal command
+ // typed in a group): ask the native shell for that one thread's key before
+ // giving up β€” the web app proper never hits this (it unwraps every thread).
+ if (!key && embed && (await requestNativeKey(threadId))) key = threadKeys[threadId];
+ if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false;
const payload = { ...event, name: session.name, ts: Date.now() };
if (myUserId) payload.uid = myUserId;
// Tuck our verified chain head into the encrypted payload (tamper-evidence