eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import WebKit
2import os
3
4// 🤖 The generalized command host (docs/native-apps.md Layer 2), platform-
5// shared: the one component that can EXECUTE app JS is the web framework, so
6// native runs the embed page headless (mode:"command" — shell.js
7// installCommandHost collapses it to just a command's panel) and talks to it
8// over the app-host protocol. Typed slash commands forward to
9// window.lchatNative.runCommand; replies ({notice}), panels ({panel}), the
10// per-thread command rows (commandItems()), and cross-thread key requests
11// ({needKey}/{needAppThread}) ride the "lchat" post handler. Grown from the
12// Mac's /emoji//gif panel host — now first-class on both platforms.
13
14public let cmdLog = Logger(subsystem: "com.elevenmessenger.messenger", category: "commands")
15
16/// What the command-host page tells the shell.
17public enum CommandHostEvent {
18 case panel(title: String, height: CGFloat, animate: Bool) // reveal (or retitle) the panel
19 case panelClosed // collapse it (✕, Escape, app close)
20 case panelHeight(CGFloat) // late layout settled — resize
21 case composerInsert(String) // insert into the native composer
22 case notice(from: String, text: String) // a private reply — render inline
23 case commands([CommandRow]) // this thread's commandItems() rows landed
24}
25
26/// One row of commandCatalog(threadId) (apps.js) — the same rows that feed
27/// the web's "/" typeahead and /help, place-stamped. Decoded tolerantly: the
28/// web side owns the shape and may grow it.
29public struct CommandRow: Codable, Equatable, Sendable {
30 public let name: String
31 public var desc: String?
32 public var args: String?
33 /// Answered by a bot in the conversation (send as a message), vs run by
34 /// the app framework (forward to the host).
35 public var bot: Bool?
36 /// Where the data lands: "chat" | "space" | "you" | "" — display hint.
37 public var place: String?
38 /// An @here/@everyone/@me alternate of a default row.
39 public var alt: Bool?
40
41 public init(name: String, desc: String? = nil, args: String? = nil,
42 bot: Bool? = nil, place: String? = nil, alt: Bool? = nil) {
43 self.name = name
44 self.desc = desc
45 self.args = args
46 self.bot = bot
47 self.place = place
48 self.alt = alt
49 }
50}
51
52// One thread's command host: the webview, its readiness, and the queue of
53// commands typed before the page finished booting. Owned by CommandHostCache
54// (which outlives any one SwiftUI view); the feed view on screen points `sink`
55// at itself, exactly like the model-cache re-point pattern.
56@MainActor
57public final class CommandHost: NSObject, WKScriptMessageHandler {
58 public let web: WKWebView
59 public var sink: ((CommandHostEvent) -> Void)?
60 /// This thread's commandItems() rows, once the page reported them —
61 /// feeds the composer typeahead and the bot-or-host forwarding decision.
62 public private(set) var commandRows: [CommandRow] = []
63 /// Answers {needKey: threadId}: the thread's key (base64url) if THIS
64 /// member holds it — set by the platform view alongside `sink`. Only keys
65 /// native itself can unwrap, never the private key (the app-host trust
66 /// posture, docs/native-apps.md).
67 public var keyProvider: ((String) -> String?)?
68 /// Answers {needAppThread: {app, title}}: open-or-create the app's
69 /// personal thread and return (threadId, threadKeyB64) — the embed can't
70 /// resolve app→thread itself (other threads' name_blobs don't decrypt
71 /// there), and letting it create would mint duplicates.
72 public var appThreadProvider: ((String, String) async -> (id: String, keyB64: String)?)?
73 private var ready = false
74 private var queued: [String] = []
75 private let origin: String // the app-catalogue pull's registry key
76
77 public init(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?) {
78 origin = instance.origin
79 var seed: [String: Any] = [
80 "token": instance.deviceToken,
81 "name": instance.screenName,
82 "myUserId": myUserId,
83 "threadId": thread.id,
84 "threadKey": thread.threadKeyB64,
85 "mode": "command",
86 ]
87 if let ik = instanceKeyB64 { seed["instanceKey"] = ik }
88 // Per-viewer names (#577): names only — never key material.
89 let names = NativeFeedModelCache.shared.names(instance: instance.id)
90 if !names.isEmpty { seed["names"] = names }
91 let cfg = WKWebViewConfiguration()
92 cfg.websiteDataStore = .default()
93 if let data = try? JSONSerialization.data(withJSONObject: seed),
94 let json = String(data: data, encoding: .utf8) {
95 cfg.userContentController.addUserScript(
96 WKUserScript(source: "window.__lchatEmbed = \(json);",
97 injectionTime: .atDocumentStart, forMainFrameOnly: true))
98 }
99 web = WKWebView(frame: .zero, configuration: cfg)
100 super.init()
101 // A weak proxy breaks the controller→handler→self→web retain cycle, so
102 // an evicted host actually deallocates (teardown also unregisters).
103 cfg.userContentController.add(WeakMessageHandler(self), name: "lchat")
104 web.navigationDelegate = self
105 #if os(macOS)
106 web.setValue(false, forKey: "drawsBackground") // blend with the composer chrome
107 #else
108 web.isOpaque = false
109 web.backgroundColor = .clear
110 #endif
111 if let url = instance.baseURL {
112 cmdLog.notice("command host loading \(url.absoluteString, privacy: .public) for thread \(thread.id, privacy: .public)")
113 web.load(URLRequest(url: url))
114 } else {
115 cmdLog.error("command host has no base URL (origin \(instance.origin, privacy: .public))")
116 }
117 }
118
119 // Run a slash command through the page's engine. Booting queues the text —
120 // the page flushes on {commandHostReady} — so the first command typed in a
121 // thread never races the load.
122 public func run(_ text: String) {
123 guard ready else {
124 cmdLog.notice("command queued until the page is ready: \(text, privacy: .public)")
125 queued.append(text)
126 return
127 }
128 guard let data = try? JSONSerialization.data(withJSONObject: [text]),
129 let arg = String(data: data, encoding: .utf8) else { return }
130 web.evaluateJavaScript(
131 "window.lchatNative && window.lchatNative.runCommand ? window.lchatNative.runCommand(\(arg)[0]) : false"
132 ) { result, error in
133 if let error {
134 cmdLog.error("runCommand failed: \(error.localizedDescription, privacy: .public)")
135 } else if (result as? Bool) == false {
136 cmdLog.notice("command host page has no runCommand bridge (old server?)")
137 }
138 }
139 }
140
141 /// Project one decrypted app event into its chat line (docs/native-apps.md
142 /// phase 5) — window.lchatNative.projectEvent, the web's chatMessage /
143 /// groupFeedMessage answered by the engine that owns them. The event JSON
144 /// is plaintext native already holds; no key material rides the query.
145 /// Waits briefly for a booting page; nil = silent (or an older server
146 /// without the bridge).
147 public func projectEvent(_ eventJSON: String, group: Bool) async -> AppChatProjection.ProjectedLine? {
148 for _ in 0..<20 where !ready {
149 try? await Task.sleep(nanoseconds: 400_000_000)
150 }
151 guard ready,
152 let data = try? JSONSerialization.data(withJSONObject: [eventJSON]),
153 let arg = String(data: data, encoding: .utf8) else { return nil }
154 let js = """
155 (function(a){ var n = window.lchatNative;
156 if (!n || !n.projectEvent) return null;
157 try { var r = n.projectEvent(JSON.parse(a[0]), \(group ? "true" : "false"));
158 return r ? JSON.stringify(r) : null; }
159 catch (e) { return null; } })(\(arg))
160 """
161 return await withCheckedContinuation { cont in
162 web.evaluateJavaScript(js) { result, _ in
163 guard let s = result as? String,
164 let obj = (try? JSONSerialization.jsonObject(with: Data(s.utf8))) as? [String: Any],
165 let text = obj["text"] as? String, !text.isEmpty else {
166 cont.resume(returning: nil)
167 return
168 }
169 cont.resume(returning: AppChatProjection.ProjectedLine(
170 text: text,
171 action: obj["action"] as? Bool ?? false,
172 title: obj["title"] as? String ?? ""))
173 }
174 }
175 }
176
177 /// The sidebar line for one decrypted app event (docs/native-apps.md
178 /// voice surfaces) — the app's own preview hook via
179 /// window.lchatNative.previewEvent, defaulting in the engine to the
180 /// actor-prefixed chat projection. nil = leave the fast-path line.
181 public func previewEvent(_ eventJSON: String, you: Bool) async -> String? {
182 for _ in 0..<20 where !ready {
183 try? await Task.sleep(nanoseconds: 400_000_000)
184 }
185 guard ready,
186 let data = try? JSONSerialization.data(withJSONObject: [eventJSON]),
187 let arg = String(data: data, encoding: .utf8) else { return nil }
188 let js = """
189 (function(a){ var n = window.lchatNative;
190 if (!n || !n.previewEvent) return null;
191 try { var r = n.previewEvent(JSON.parse(a[0]), \(you ? "true" : "false"));
192 return r && r.text ? r.text : null; }
193 catch (e) { return null; } })(\(arg))
194 """
195 return await withCheckedContinuation { cont in
196 web.evaluateJavaScript(js) { result, _ in
197 cont.resume(returning: (result as? String).flatMap { $0.isEmpty ? nil : $0 })
198 }
199 }
200 }
201
202 public func dismissPanel() {
203 web.evaluateJavaScript(
204 "window.lchatNative && window.lchatNative.dismissPanel && window.lchatNative.dismissPanel()",
205 completionHandler: nil)
206 }
207
208 /// The app set changed (apps_changed): a CUSTOM app is a plaintext ES
209 /// module the page imported at boot — no live re-registration can learn
210 /// a new or updated one (the web's own answer is its reload banner). So
211 /// the catalogue host reloads its page; the fresh boot re-imports all
212 /// three tiers and the ready-pull reports the new set.
213 public func reloadForCatalog() {
214 ready = false
215 commandRows = []
216 web.reload()
217 }
218
219 public func teardown() {
220 web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat")
221 web.stopLoading()
222 sink = nil
223 keyProvider = nil
224 appThreadProvider = nil
225 }
226
227 // This thread's typeahead/forwarding rows — pulled once the page is ready
228 // and re-pulled when the app set changes; installs folding in the page
229 // change what commandCatalog returns, so callers may also re-pull on
230 // demand (refreshCommandRows).
231 public func refreshCommandRows() {
232 let js = "window.lchatNative && window.lchatNative.commandItems"
233 + " ? JSON.stringify(window.lchatNative.commandItems()) : null"
234 web.evaluateJavaScript(js) { [weak self] result, _ in
235 guard let json = result as? String,
236 let rows = try? JSONDecoder().decode([CommandRow].self, from: Data(json.utf8))
237 else { return }
238 Task { @MainActor in
239 guard let self else { return }
240 self.commandRows = rows
241 self.sink?(.commands(rows))
242 }
243 }
244 }
245
246 // MARK: WKScriptMessageHandler
247 public func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
248 guard let dict = message.body as? [String: Any] else { return }
249 if dict["commandHostReady"] as? Bool == true {
250 cmdLog.notice("command host ready (\(self.queued.count) queued)")
251 ready = true
252 let pending = queued
253 queued = []
254 for text in pending { run(text) }
255 // The host page loaded all three app tiers — pull the live
256 // catalogue into the registry, FORCED: a fresh boot's report is
257 // authoritative. The once-per-launch latch can't gate this — on
258 // apps_changed the old page's {catalogChanged} relay force-pulls
259 // its STALE set just before the reload, re-setting the latch, and
260 // the fresh page's report (the one that knows the new app) was
261 // the pull being skipped. Feed webviews stay latch-gated.
262 AppCatalogPull.pull(from: web, origin: origin, force: true)
263 refreshCommandRows()
264 return
265 }
266 if dict["catalogChanged"] != nil {
267 // The space's app set changed (apps_changed) — re-pull both.
268 AppCatalogPull.pull(from: web, origin: origin, force: true)
269 refreshCommandRows()
270 return
271 }
272 // Cross-thread routing (docs/native-apps.md phase 3): the page asks
273 // for one narrow thread key it wasn't seeded with. Always answer —
274 // an empty key resolves the page's wait to an honest failure fast
275 // instead of a timeout.
276 if let tid = dict["needKey"] as? String {
277 let key = keyProvider?(tid) ?? ""
278 cmdLog.notice("needKey for thread \(tid, privacy: .public): \(key.isEmpty ? "not held" : "provided", privacy: .public)")
279 callBack("__lchatProvideKey", [tid, key])
280 return
281 }
282 if let req = dict["needAppThread"] as? [String: Any], let app = req["app"] as? String {
283 let title = req["title"] as? String ?? app
284 guard let provider = appThreadProvider else {
285 callBack("__lchatProvideAppThread", [app, "", ""])
286 return
287 }
288 Task { @MainActor [weak self] in
289 let got = await provider(app, title)
290 cmdLog.notice("needAppThread \(app, privacy: .public): \(got == nil ? "unresolved" : "provided", privacy: .public)")
291 self?.callBack("__lchatProvideAppThread", [app, got?.id ?? "", got?.keyB64 ?? ""])
292 }
293 return
294 }
295 if dict.keys.contains("panel") {
296 if let p = dict["panel"] as? [String: Any] {
297 let h = (p["height"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 240
298 cmdLog.notice("panel open: \(p["title"] as? String ?? "", privacy: .public) height \(h)")
299 sink?(.panel(title: p["title"] as? String ?? "", height: h,
300 animate: (p["animate"] as? Bool) ?? true))
301 } else {
302 cmdLog.notice("panel closed")
303 sink?(.panelClosed)
304 }
305 return
306 }
307 if let h = dict["panelHeight"] as? NSNumber {
308 sink?(.panelHeight(CGFloat(truncating: h)))
309 return
310 }
311 if let t = dict["composerInsert"] as? String {
312 cmdLog.notice("composerInsert (\(t.count) chars)")
313 sink?(.composerInsert(t))
314 return
315 }
316 if let n = dict["notice"] as? [String: Any] {
317 cmdLog.notice("notice from \(n["from"] as? String ?? "", privacy: .public)")
318 sink?(.notice(from: n["from"] as? String ?? "", text: n["text"] as? String ?? ""))
319 return
320 }
321 // The page reporting a boot problem ({error} — bootEmbed's catch).
322 if let e = dict["error"] as? String {
323 cmdLog.error("command host page error: \(e, privacy: .public)")
324 }
325 }
326
327 // Call one of the page's __lchat* answer hooks with string arguments,
328 // JSON-encoded so ids/keys can't break out of the call.
329 private func callBack(_ fn: String, _ args: [String]) {
330 guard let data = try? JSONSerialization.data(withJSONObject: args),
331 let list = String(data: data, encoding: .utf8) else { return }
332 web.evaluateJavaScript(
333 "window.\(fn) && window.\(fn)(...\(list))", completionHandler: nil)
334 }
335}
336
337// Load diagnostics: a page that never reaches didFinish explains a silent
338// panel better than anything else can.
339extension CommandHost: WKNavigationDelegate {
340 public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
341 cmdLog.notice("command host page loaded")
342 }
343 public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
344 cmdLog.error("command host page failed: \(error.localizedDescription, privacy: .public)")
345 }
346 public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
347 cmdLog.error("command host page failed to start: \(error.localizedDescription, privacy: .public)")
348 }
349}
350
351// WKUserContentController retains its handlers strongly; this proxy keeps the
352// host owning the controller without a cycle (the standard WKWebView pattern).
353private final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
354 weak var target: WKScriptMessageHandler?
355 init(_ target: WKScriptMessageHandler) { self.target = target }
356 func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
357 target?.userContentController(controller, didReceive: message)
358 }
359}
360
361// Keeps each thread's command host alive across thread switches, so a second
362// slash command in a revisited chat is instant. Same LRU shape as
363// AppWebViewCache; hosts are cheap to rebuild, so the cap stays small.
364@MainActor
365public final class CommandHostCache {
366 public static let shared = CommandHostCache()
367
368 private struct Entry {
369 let host: CommandHost
370 let seedKey: String
371 var lastUsed = Date()
372 }
373
374 private var entries: [String: Entry] = [:] // "instanceID|threadID"
375 private let maxEntries = 4
376
377 public func host(for instance: SavedInstance, thread: ChatThread,
378 myUserId: String, instanceKeyB64: String?) -> CommandHost {
379 let key = instance.id + "|" + thread.id
380 if var e = entries[key], e.seedKey == thread.threadKeyB64 {
381 e.lastUsed = Date()
382 entries[key] = e
383 return e.host
384 }
385 entries.removeValue(forKey: key)?.host.teardown()
386 let host = CommandHost(instance: instance, thread: thread,
387 myUserId: myUserId, instanceKeyB64: instanceKeyB64)
388 entries[key] = Entry(host: host, seedKey: thread.threadKeyB64)
389 while entries.count > maxEntries,
390 let oldest = entries.filter({ $0.value.host.web.superview == nil })
391 .min(by: { $0.value.lastUsed < $1.value.lastUsed }) {
392 entries.removeValue(forKey: oldest.key)?.host.teardown()
393 }
394 return host
395 }
396}
397