import LChatCore import SwiftUI import WebKit // A mini-app thread's surface inside the native takeover: the app's own web // interface, embedded per-thread — "Apps stay WKWebView" honored at thread // granularity instead of flipping the whole app to the web client. Seeded the // same way iOS's FeedWebCache seeds its per-thread pages: window.__lchatEmbed // carries the device token + THIS thread's unwrapped key (never the user's // private key), so the page can render exactly one thread and nothing else. // // `app` names one of a GROUP's added apps: that pane renders the app's // interface (the group's shared widget) instead of a personal app-thread's. // One cached page serves all the group's apps — switching is a JS call // (MacWebBridge.setGroupApp), never a reload. struct AppThreadWebView: NSViewRepresentable { let instance: SavedInstance let thread: ChatThread let myUserId: String let instanceKeyB64: String? var app: String? = nil var space: Bool = false // Hands out the CACHED page — a detached WKWebView keeps its page, so // toggling Chat | Both | View reattaches the same live app instead of // reloading it on every click. func makeNSView(context: Context) -> WKWebView { AppWebViewCache.shared.webView(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64, app: app, space: space) } func updateNSView(_ web: WKWebView, context: Context) {} // Mirrors FeedWebCache.bootScript (iOS): one global that both flags embed // mode and carries the seed. fileprivate static func bootScript(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?, app: String?, space: Bool = false) -> WKUserScript? { var seed: [String: Any] = [ "token": instance.deviceToken, "name": instance.screenName, "myUserId": myUserId, "threadId": thread.id, "threadKey": thread.threadKeyB64, ] 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 } // The native split renders chat itself — this page is purely the // app's INTERFACE (app threads otherwise default to their chat view). seed["view"] = "interface" // A group pane also names which added app to show (shell.js bootEmbed). if let app { seed["app"] = app } // A SPACE app's surface (docs/native-apps.md phase 4): the seed's // thread IS the space thread — bootEmbed folds its installs and opens // the app via the web's own openSpaceApp path (never a chat; without // the flag the page would mount the conversation feed). if space { seed["space"] = true } guard let data = try? JSONSerialization.data(withJSONObject: seed), let json = String(data: data, encoding: .utf8) else { return nil } return WKUserScript(source: "window.__lchatEmbed = \(json);", injectionTime: .atDocumentStart, forMainFrameOnly: true) } } // An app webview surface with its loading state handled: the veil sits over // the pane until the page reports it has painted ({ready:true} from shell.js // bootEmbed, didFinish + a beat as the fallback) — a booting app reads as // "loading", never a blank white pane. Wraps every Mac app-pane use of // AppThreadWebView; also re-asserts a group pane's selected app on switches. struct AppWebSurface: View { @ObservedObject private var bridge: MacWebBridge let instance: SavedInstance let thread: ChatThread let myUserId: String let instanceKeyB64: String? var app: String? = nil var space: Bool = false @MainActor init(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?, app: String? = nil, space: Bool = false) { self.instance = instance self.thread = thread self.myUserId = myUserId self.instanceKeyB64 = instanceKeyB64 self.app = app self.space = space bridge = AppWebViewCache.shared.bridge(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64, app: app, space: space) } var body: some View { VStack(spacing: 0) { AppUpdateBar(bridge: bridge) ZStack { AppThreadWebView(instance: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64, app: app, space: space) if !bridge.pageReady { WebLoadingVeil().transition(.opacity) } } } .animation(.easeOut(duration: 0.25), value: bridge.pageReady) // The boot seed carries the app chosen at page-creation time; every // appearance/switch (and the ready flip, for a pane that appeared // mid-boot) re-asserts the current one. .onAppear { if let app { bridge.setGroupApp(app) } } .onChange(of: app) { _, newApp in if let newApp { bridge.setGroupApp(newApp) } } .onChange(of: bridge.pageReady) { _, ready in if ready, let app { bridge.setGroupApp(app) } } } } // 🤖 The in-place offer for an app whose code changed while its surface was // on screen (AppWebViewCache.handleAppsChanged): a page can't swap app code // in place, and reloading it out from under someone is worse than asking — // so the pane grows this bar inline, occupying layout space above the // webview (never a floating overlay), with the web sidebar banner's copy and // choices. Reload picks the new version up now; Later hides the bar, and the // page still reloads on its next return from parked. The Mac twin of iOS's // AppUpdateBar. struct AppUpdateBar: View { @ObservedObject var bridge: MacWebBridge var body: some View { if let kind = bridge.appUpdateKind, !bridge.appUpdateDismissed { HStack(spacing: 16) { Text(kind == "updated" ? "App updated." : "Apps changed.") .font(.subheadline) Spacer(minLength: 0) Button("Reload") { bridge.reloadForAppUpdate() } .font(.subheadline.weight(.semibold)) Button("Later") { bridge.appUpdateDismissed = true } .font(.subheadline) } .padding(.horizontal, 14) .padding(.vertical, 8) .background(.bar) .overlay(Divider(), alignment: .bottom) } } } // The Mac cousin of iOS's WebBridge, scoped to what app panes need: the // page-ready flag for the loading veil, the setGroupApp switch, and the // target="_blank" fix (without a UI delegate those links fell on the floor — // Bookmarks lives on them). Not actor-isolated, like WebBridge: WebKit calls // every delegate method on the main thread already. final class MacWebBridge: NSObject, ObservableObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate { weak var webView: WKWebView? // The instance origin, for the app-catalogue pull (set where the entry is // built) — a ready page reports the space's live app set into AppRegistry. var origin: String? // True once the page has real content. shell.js posts {ready:true} after // its first render — the honest "painted" moment; didFinish is only the // fallback (it fires when the HTML lands, before the app has drawn). @Published var pageReady = false // An apps_changed update/removal arrived while this page was on screen // (AppWebViewCache.handleAppsChanged): the page is running stale app // code, but yanking a surface someone is actively looking at is worse // than telling them — the pane renders an inline offer (AppUpdateBar) // instead. "Later" only hides the bar — the flag stays, so the cache // still reloads the page the next time it comes back from parked. @Published var appUpdateKind: String? @Published var appUpdateDismissed = false // The banner's Reload: pick up the new app code now. func reloadForAppUpdate() { appUpdateKind = nil appUpdateDismissed = false webView?.reload() } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { pageReady = false } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.pageReady = true } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { pageReady = true // show whatever the page has — a veil that never lifts is worse } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { pageReady = true } func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "lchat", let body = message.body as? [String: Any] else { return } if body["catalogChanged"] != nil, let web = webView, let origin { // The space's app set changed (apps_changed) — the page already // re-registered member apps live; re-pull the catalogue. AppCatalogPull.pull(from: web, origin: origin, force: true) } // App Studio's Open, handed off from the embed (shell.js // memberAdmin.openApp): open the app's thread as its own native // surface — this pane must never navigate itself, or the target app // renders inside App Studio's window. The active NativeChatView // consumes it through the palette's "app:" open-or-create path. if let req = body["openAppThread"] as? [String: Any], let app = req["app"] as? String { NotificationCenter.default.post(name: .lchatOpenAppThread, object: nil, userInfo: ["app": app]) return } guard body["ready"] as? Bool == true else { return } pageReady = true // A ready page has loaded all three app tiers — pull the live // catalogue into the registry (one pull per launch; cheap after). if let web = webView, let origin { AppCatalogPull.pull(from: web, origin: origin) } } // Message links render as target="_blank" anchors — route them to the // default browser instead of spawning a child webview. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if let url = navigationAction.request.url { NSWorkspace.shared.open(url) } return nil } // Swap which of a group's added apps the shared pane shows (shell.js // setGroupApp) — `app` is a catalog id (ours, never user text). The group // pane must NEVER sit on the chat view: against a server whose shell.js // predates setGroupApp/embed.app, fall back to setAppView("interface") // (the old bridge routes a group with apps to its app pane — just without // per-app choice). The page's bridge installs a beat after didFinish, so // retry until one of the calls lands. func setGroupApp(_ app: String, attempt: Int = 0) { let js = """ (function(){ var n = window.lchatNative; if (!n) return false; if (n.setGroupApp) { n.setGroupApp('\(app)'); return true; } if (n.setAppView) { n.setAppView('interface'); return true; } return false; })() """ webView?.evaluateJavaScript(js) { [weak self] result, _ in guard (result as? Bool) != true, attempt < 12 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self?.setGroupApp(app, attempt: attempt + 1) } } } } // Keeps each app thread's webview alive across Chat | Both | View toggles and // thread switches — a detached WKWebView keeps its page, so reattaching is // instant with no reload. Small LRU; the Mac cousin of iOS's FeedWebCache. @MainActor final class AppWebViewCache { static let shared = AppWebViewCache() private struct Entry { let web: WKWebView let bridge: MacWebBridge let seedKey: String var lastUsed = Date() } private var entries: [String: Entry] = [:] // "instanceID|threadID[|groupapps]" private let maxEntries = 6 func webView(for instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?, app: String? = nil, space: Bool = false) -> WKWebView { entry(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64, app: app, space: space).web } func bridge(for instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?, app: String? = nil, space: Bool = false) -> MacWebBridge { entry(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64, app: app, space: space).bridge } private func entry(for instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?, app: String?, space: Bool = false) -> Entry { // One shared pane per group for ALL its apps (switching is a JS call, // not a reload), so the key marks the surface, not the app — EXCEPT // space surfaces: a space app is fixed at page boot (openSpaceApp), // so each one keeps its own page. let key = instance.id + "|" + thread.id + (space ? "|space:" + (app ?? "") : (app != nil ? "|groupapps" : "")) // Reuse only if seeded with the same thread key — a re-wrap = stale page. if var e = entries[key], e.seedKey == thread.threadKeyB64 { e.lastUsed = Date() entries[key] = e // A deferred app update (flagged while on screen, or "Later"): // this access is (re)attaching a parked page — reload it now, // before it's back under someone's eyes. A page that's already // on screen (superview != nil, a SwiftUI re-render) keeps // running and keeps its banner. if e.bridge.appUpdateKind != nil, e.web.superview == nil { e.bridge.appUpdateKind = nil e.bridge.appUpdateDismissed = false e.web.reload() } return e } if let old = entries.removeValue(forKey: key) { release(old) } let bridge = MacWebBridge() bridge.origin = instance.origin // the app-catalogue pull's registry key let cfg = WKWebViewConfiguration() cfg.websiteDataStore = .default() if let script = AppThreadWebView.bootScript(instance: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64, app: app, space: space) { cfg.userContentController.addUserScript(script) } cfg.userContentController.add(bridge, name: "lchat") let web = WKWebView(frame: .zero, configuration: cfg) bridge.webView = web web.navigationDelegate = bridge web.uiDelegate = bridge if let url = instance.baseURL { web.load(URLRequest(url: url)) } let e = Entry(web: web, bridge: bridge, seedKey: thread.threadKeyB64) entries[key] = e while entries.count > maxEntries, let oldest = entries.filter({ $0.value.web.superview == nil }) .min(by: { $0.value.lastUsed < $1.value.lastUsed }) { if let old = entries.removeValue(forKey: oldest.key) { release(old) } } return e } // An app's code changed under a live page (apps_changed, kind updated / // removed). A reload is the only way to swap — custom apps are ES modules // imported at boot, and sandbox.js refuses to re-register a member app id // a running page already loaded — but never reload a surface someone is // actively looking at: parked pages reload invisibly now, while a page // that's on screen gets flagged and its pane offers an inline reload // (AppUpdateBar). The embed seed survives the reload (it's a WKUserScript // on the configuration), so a page reboots straight back into its pinned // surface. func handleAppsChanged(instanceID: String, kind: String) { for (key, entry) in entries where key.hasPrefix(instanceID + "|") { if entry.web.superview != nil { entry.bridge.appUpdateKind = kind entry.bridge.appUpdateDismissed = false } else { entry.bridge.appUpdateKind = nil entry.bridge.appUpdateDismissed = false entry.web.reload() } } } private func release(_ e: Entry) { e.web.stopLoading() // The controller retains the bridge; break that so the whole page tears down. e.web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat") } }