| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import 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. |
| 16 | struct 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. |
| 73 | struct 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. |
| 128 | struct 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. |
| 155 | final 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 |
| 267 | final 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 | |