π€ 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>
14 files changed,
+816
−258
docs/native-apps.md+12 −2ios/LChat/Views/CommandPanelWebView.swift+15 −0ios/LChat/Views/NativeChatScreen.swift+115 −1ios/LChat/Views/ThreadListView.swift+8 −0ios/LChatCore/Sources/LChatCore/CommandHost.swift+335 −0ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift+26 −1ios/LChatCore/Sources/LChatCore/SlashCommands.swift+35 −8ios/LChatCore/Sources/LChatCore/ThreadStore.swift+29 −0ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift+39 −0ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift+42 −1ios/LChatMac/CommandPanelWebView.swift+6 −226ios/LChatMac/NativeChatView.swift+9 −0ios/LChatMac/NativeFeedView.swift+54 −16web/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 |