eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import WebKit
4
5// A mini-app thread's surface inside the native takeover: the app's own web
6// interface, embedded per-thread — "Apps stay WKWebView" honored at thread
7// granularity instead of flipping the whole app to the web client. Seeded the
8// same way iOS's FeedWebCache seeds its per-thread pages: window.__lchatEmbed
9// carries the device token + THIS thread's unwrapped key (never the user's
10// private key), so the page can render exactly one thread and nothing else.
11//
12// `app` names one of a GROUP's added apps: that pane renders the app's
13// interface (the group's shared widget) instead of a personal app-thread's.
14// One cached page serves all the group's apps — switching is a JS call
15// (MacWebBridge.setGroupApp), never a reload.
16struct AppThreadWebView: NSViewRepresentable {
17 let instance: SavedInstance
18 let thread: ChatThread
19 let myUserId: String
20 let instanceKeyB64: String?
21 var app: String? = nil
22 var space: Bool = false
23
24 // Hands out the CACHED page — a detached WKWebView keeps its page, so
25 // toggling Chat | Both | View reattaches the same live app instead of
26 // reloading it on every click.
27 func makeNSView(context: Context) -> WKWebView {
28 AppWebViewCache.shared.webView(for: instance, thread: thread,
29 myUserId: myUserId, instanceKeyB64: instanceKeyB64,
30 app: app, space: space)
31 }
32
33 func updateNSView(_ web: WKWebView, context: Context) {}
34
35 // Mirrors FeedWebCache.bootScript (iOS): one global that both flags embed
36 // mode and carries the seed.
37 fileprivate static func bootScript(instance: SavedInstance, thread: ChatThread,
38 myUserId: String, instanceKeyB64: String?,
39 app: String?, space: Bool = false) -> WKUserScript? {
40 var seed: [String: Any] = [
41 "token": instance.deviceToken,
42 "name": instance.screenName,
43 "myUserId": myUserId,
44 "threadId": thread.id,
45 "threadKey": thread.threadKeyB64,
46 ]
47 if let ik = instanceKeyB64 { seed["instanceKey"] = ik }
48 // Per-viewer names (#577): names only — never key material.
49 let names = NativeFeedModelCache.shared.names(instance: instance.id)
50 if !names.isEmpty { seed["names"] = names }
51 // The native split renders chat itself — this page is purely the
52 // app's INTERFACE (app threads otherwise default to their chat view).
53 seed["view"] = "interface"
54 // A group pane also names which added app to show (shell.js bootEmbed).
55 if let app { seed["app"] = app }
56 // A SPACE app's surface (docs/native-apps.md phase 4): the seed's
57 // thread IS the space thread — bootEmbed folds its installs and opens
58 // the app via the web's own openSpaceApp path (never a chat; without
59 // the flag the page would mount the conversation feed).
60 if space { seed["space"] = true }
61 guard let data = try? JSONSerialization.data(withJSONObject: seed),
62 let json = String(data: data, encoding: .utf8) else { return nil }
63 return WKUserScript(source: "window.__lchatEmbed = \(json);",
64 injectionTime: .atDocumentStart, forMainFrameOnly: true)
65 }
66}
67
68// An app webview surface with its loading state handled: the veil sits over
69// the pane until the page reports it has painted ({ready:true} from shell.js
70// bootEmbed, didFinish + a beat as the fallback) — a booting app reads as
71// "loading", never a blank white pane. Wraps every Mac app-pane use of
72// AppThreadWebView; also re-asserts a group pane's selected app on switches.
73struct AppWebSurface: View {
74 @ObservedObject private var bridge: MacWebBridge
75 let instance: SavedInstance
76 let thread: ChatThread
77 let myUserId: String
78 let instanceKeyB64: String?
79 var app: String? = nil
80 var space: Bool = false
81
82 @MainActor
83 init(instance: SavedInstance, thread: ChatThread, myUserId: String,
84 instanceKeyB64: String?, app: String? = nil, space: Bool = false) {
85 self.instance = instance
86 self.thread = thread
87 self.myUserId = myUserId
88 self.instanceKeyB64 = instanceKeyB64
89 self.app = app
90 self.space = space
91 bridge = AppWebViewCache.shared.bridge(for: instance, thread: thread,
92 myUserId: myUserId,
93 instanceKeyB64: instanceKeyB64, app: app,
94 space: space)
95 }
96
97 var body: some View {
98 VStack(spacing: 0) {
99 AppUpdateBar(bridge: bridge)
100 ZStack {
101 AppThreadWebView(instance: instance, thread: thread, myUserId: myUserId,
102 instanceKeyB64: instanceKeyB64, app: app, space: space)
103 if !bridge.pageReady {
104 WebLoadingVeil().transition(.opacity)
105 }
106 }
107 }
108 .animation(.easeOut(duration: 0.25), value: bridge.pageReady)
109 // The boot seed carries the app chosen at page-creation time; every
110 // appearance/switch (and the ready flip, for a pane that appeared
111 // mid-boot) re-asserts the current one.
112 .onAppear { if let app { bridge.setGroupApp(app) } }
113 .onChange(of: app) { _, newApp in if let newApp { bridge.setGroupApp(newApp) } }
114 .onChange(of: bridge.pageReady) { _, ready in
115 if ready, let app { bridge.setGroupApp(app) }
116 }
117 }
118}
119
120// 🤖 The in-place offer for an app whose code changed while its surface was
121// on screen (AppWebViewCache.handleAppsChanged): a page can't swap app code
122// in place, and reloading it out from under someone is worse than asking —
123// so the pane grows this bar inline, occupying layout space above the
124// webview (never a floating overlay), with the web sidebar banner's copy and
125// choices. Reload picks the new version up now; Later hides the bar, and the
126// page still reloads on its next return from parked. The Mac twin of iOS's
127// AppUpdateBar.
128struct AppUpdateBar: View {
129 @ObservedObject var bridge: MacWebBridge
130
131 var body: some View {
132 if let kind = bridge.appUpdateKind, !bridge.appUpdateDismissed {
133 HStack(spacing: 16) {
134 Text(kind == "updated" ? "App updated." : "Apps changed.")
135 .font(.subheadline)
136 Spacer(minLength: 0)
137 Button("Reload") { bridge.reloadForAppUpdate() }
138 .font(.subheadline.weight(.semibold))
139 Button("Later") { bridge.appUpdateDismissed = true }
140 .font(.subheadline)
141 }
142 .padding(.horizontal, 14)
143 .padding(.vertical, 8)
144 .background(.bar)
145 .overlay(Divider(), alignment: .bottom)
146 }
147 }
148}
149
150// The Mac cousin of iOS's WebBridge, scoped to what app panes need: the
151// page-ready flag for the loading veil, the setGroupApp switch, and the
152// target="_blank" fix (without a UI delegate those links fell on the floor —
153// Bookmarks lives on them). Not actor-isolated, like WebBridge: WebKit calls
154// every delegate method on the main thread already.
155final class MacWebBridge: NSObject, ObservableObject, WKNavigationDelegate,
156 WKScriptMessageHandler, WKUIDelegate {
157 weak var webView: WKWebView?
158 // The instance origin, for the app-catalogue pull (set where the entry is
159 // built) — a ready page reports the space's live app set into AppRegistry.
160 var origin: String?
161
162 // True once the page has real content. shell.js posts {ready:true} after
163 // its first render — the honest "painted" moment; didFinish is only the
164 // fallback (it fires when the HTML lands, before the app has drawn).
165 @Published var pageReady = false
166
167 // An apps_changed update/removal arrived while this page was on screen
168 // (AppWebViewCache.handleAppsChanged): the page is running stale app
169 // code, but yanking a surface someone is actively looking at is worse
170 // than telling them — the pane renders an inline offer (AppUpdateBar)
171 // instead. "Later" only hides the bar — the flag stays, so the cache
172 // still reloads the page the next time it comes back from parked.
173 @Published var appUpdateKind: String?
174 @Published var appUpdateDismissed = false
175
176 // The banner's Reload: pick up the new app code now.
177 func reloadForAppUpdate() {
178 appUpdateKind = nil
179 appUpdateDismissed = false
180 webView?.reload()
181 }
182
183 func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
184 pageReady = false
185 }
186
187 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
188 DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
189 self?.pageReady = true
190 }
191 }
192
193 func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
194 pageReady = true // show whatever the page has — a veil that never lifts is worse
195 }
196
197 func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
198 withError error: Error) {
199 pageReady = true
200 }
201
202 func userContentController(_ controller: WKUserContentController,
203 didReceive message: WKScriptMessage) {
204 guard message.name == "lchat", let body = message.body as? [String: Any] else { return }
205 if body["catalogChanged"] != nil, let web = webView, let origin {
206 // The space's app set changed (apps_changed) — the page already
207 // re-registered member apps live; re-pull the catalogue.
208 AppCatalogPull.pull(from: web, origin: origin, force: true)
209 }
210 // App Studio's Open, handed off from the embed (shell.js
211 // memberAdmin.openApp): open the app's thread as its own native
212 // surface — this pane must never navigate itself, or the target app
213 // renders inside App Studio's window. The active NativeChatView
214 // consumes it through the palette's "app:" open-or-create path.
215 if let req = body["openAppThread"] as? [String: Any], let app = req["app"] as? String {
216 NotificationCenter.default.post(name: .lchatOpenAppThread, object: nil,
217 userInfo: ["app": app])
218 return
219 }
220 guard body["ready"] as? Bool == true else { return }
221 pageReady = true
222 // A ready page has loaded all three app tiers — pull the live
223 // catalogue into the registry (one pull per launch; cheap after).
224 if let web = webView, let origin { AppCatalogPull.pull(from: web, origin: origin) }
225 }
226
227 // Message links render as target="_blank" anchors — route them to the
228 // default browser instead of spawning a child webview.
229 func webView(_ webView: WKWebView,
230 createWebViewWith configuration: WKWebViewConfiguration,
231 for navigationAction: WKNavigationAction,
232 windowFeatures: WKWindowFeatures) -> WKWebView? {
233 if let url = navigationAction.request.url {
234 NSWorkspace.shared.open(url)
235 }
236 return nil
237 }
238
239 // Swap which of a group's added apps the shared pane shows (shell.js
240 // setGroupApp) — `app` is a catalog id (ours, never user text). The group
241 // pane must NEVER sit on the chat view: against a server whose shell.js
242 // predates setGroupApp/embed.app, fall back to setAppView("interface")
243 // (the old bridge routes a group with apps to its app pane — just without
244 // per-app choice). The page's bridge installs a beat after didFinish, so
245 // retry until one of the calls lands.
246 func setGroupApp(_ app: String, attempt: Int = 0) {
247 let js = """
248 (function(){ var n = window.lchatNative;
249 if (!n) return false;
250 if (n.setGroupApp) { n.setGroupApp('\(app)'); return true; }
251 if (n.setAppView) { n.setAppView('interface'); return true; }
252 return false; })()
253 """
254 webView?.evaluateJavaScript(js) { [weak self] result, _ in
255 guard (result as? Bool) != true, attempt < 12 else { return }
256 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
257 self?.setGroupApp(app, attempt: attempt + 1)
258 }
259 }
260 }
261}
262
263// Keeps each app thread's webview alive across Chat | Both | View toggles and
264// thread switches — a detached WKWebView keeps its page, so reattaching is
265// instant with no reload. Small LRU; the Mac cousin of iOS's FeedWebCache.
266@MainActor
267final class AppWebViewCache {
268 static let shared = AppWebViewCache()
269
270 private struct Entry {
271 let web: WKWebView
272 let bridge: MacWebBridge
273 let seedKey: String
274 var lastUsed = Date()
275 }
276
277 private var entries: [String: Entry] = [:] // "instanceID|threadID[|groupapps]"
278 private let maxEntries = 6
279
280 func webView(for instance: SavedInstance, thread: ChatThread,
281 myUserId: String, instanceKeyB64: String?, app: String? = nil,
282 space: Bool = false) -> WKWebView {
283 entry(for: instance, thread: thread, myUserId: myUserId,
284 instanceKeyB64: instanceKeyB64, app: app, space: space).web
285 }
286
287 func bridge(for instance: SavedInstance, thread: ChatThread,
288 myUserId: String, instanceKeyB64: String?, app: String? = nil,
289 space: Bool = false) -> MacWebBridge {
290 entry(for: instance, thread: thread, myUserId: myUserId,
291 instanceKeyB64: instanceKeyB64, app: app, space: space).bridge
292 }
293
294 private func entry(for instance: SavedInstance, thread: ChatThread,
295 myUserId: String, instanceKeyB64: String?, app: String?,
296 space: Bool = false) -> Entry {
297 // One shared pane per group for ALL its apps (switching is a JS call,
298 // not a reload), so the key marks the surface, not the app — EXCEPT
299 // space surfaces: a space app is fixed at page boot (openSpaceApp),
300 // so each one keeps its own page.
301 let key = instance.id + "|" + thread.id
302 + (space ? "|space:" + (app ?? "") : (app != nil ? "|groupapps" : ""))
303 // Reuse only if seeded with the same thread key — a re-wrap = stale page.
304 if var e = entries[key], e.seedKey == thread.threadKeyB64 {
305 e.lastUsed = Date()
306 entries[key] = e
307 // A deferred app update (flagged while on screen, or "Later"):
308 // this access is (re)attaching a parked page — reload it now,
309 // before it's back under someone's eyes. A page that's already
310 // on screen (superview != nil, a SwiftUI re-render) keeps
311 // running and keeps its banner.
312 if e.bridge.appUpdateKind != nil, e.web.superview == nil {
313 e.bridge.appUpdateKind = nil
314 e.bridge.appUpdateDismissed = false
315 e.web.reload()
316 }
317 return e
318 }
319 if let old = entries.removeValue(forKey: key) { release(old) }
320 let bridge = MacWebBridge()
321 bridge.origin = instance.origin // the app-catalogue pull's registry key
322 let cfg = WKWebViewConfiguration()
323 cfg.websiteDataStore = .default()
324 if let script = AppThreadWebView.bootScript(instance: instance, thread: thread,
325 myUserId: myUserId,
326 instanceKeyB64: instanceKeyB64,
327 app: app, space: space) {
328 cfg.userContentController.addUserScript(script)
329 }
330 cfg.userContentController.add(bridge, name: "lchat")
331 let web = WKWebView(frame: .zero, configuration: cfg)
332 bridge.webView = web
333 web.navigationDelegate = bridge
334 web.uiDelegate = bridge
335 if let url = instance.baseURL { web.load(URLRequest(url: url)) }
336 let e = Entry(web: web, bridge: bridge, seedKey: thread.threadKeyB64)
337 entries[key] = e
338 while entries.count > maxEntries,
339 let oldest = entries.filter({ $0.value.web.superview == nil })
340 .min(by: { $0.value.lastUsed < $1.value.lastUsed }) {
341 if let old = entries.removeValue(forKey: oldest.key) { release(old) }
342 }
343 return e
344 }
345
346 // An app's code changed under a live page (apps_changed, kind updated /
347 // removed). A reload is the only way to swap — custom apps are ES modules
348 // imported at boot, and sandbox.js refuses to re-register a member app id
349 // a running page already loaded — but never reload a surface someone is
350 // actively looking at: parked pages reload invisibly now, while a page
351 // that's on screen gets flagged and its pane offers an inline reload
352 // (AppUpdateBar). The embed seed survives the reload (it's a WKUserScript
353 // on the configuration), so a page reboots straight back into its pinned
354 // surface.
355 func handleAppsChanged(instanceID: String, kind: String) {
356 for (key, entry) in entries where key.hasPrefix(instanceID + "|") {
357 if entry.web.superview != nil {
358 entry.bridge.appUpdateKind = kind
359 entry.bridge.appUpdateDismissed = false
360 } else {
361 entry.bridge.appUpdateKind = nil
362 entry.bridge.appUpdateDismissed = false
363 entry.web.reload()
364 }
365 }
366 }
367
368 private func release(_ e: Entry) {
369 e.web.stopLoading()
370 // The controller retains the bridge; break that so the whole page tears down.
371 e.web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat")
372 }
373}
374