import LChatCore import WebKit import UIKit // Keeps each app-interface webview (plus its WebBridge) alive after you leave // the thread, so re-opening attaches an already-rendered page instead of // booting it from scratch behind its loading spinner. Chat never renders in // these pages — every surface here is an app's own interface (AppInterfacePane, // GroupAppPane, SpaceAppScreen); the chat feed is native everywhere. // // The E2E posture is unchanged: every webview is still seeded with exactly ONE // thread's unwrapped key (never the user private key), so a page can't reach // another thread's content — we just keep more of those single-thread pages // around. Parked pages keep their WebSocket, and the page reconnects + catches // up on its own after a drop, so a re-attached surface is already current. @MainActor final class FeedWebCache { static let shared = FeedWebCache() private let maxEntries = 8 private final class Entry { let web: WKWebView let bridge: WebBridge let seedKey: String // the thread key the page was seeded with var lastUsed: Date init(web: WKWebView, bridge: WebBridge, seedKey: String) { self.web = web self.bridge = bridge self.seedKey = seedKey self.lastUsed = Date() } } private var entries: [String: Entry] = [:] // "instanceID|threadID" -> entry private init() { // Parked feeds are pure cache — shed them under memory pressure. NotificationCenter.default.addObserver( forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main ) { _ in Task { @MainActor in FeedWebCache.shared.dropParked() } } // The boot seed's names map is baked once at webview creation, but // parked webviews outlive renames (and a pre-first-refresh open bakes // an empty map) — push every fresh fold's names into the live pages. NativeFeedModelCache.shared.onNamesUpdate = { [weak self] instanceID, names in self?.pushNames(instanceID: instanceID, names: names) } } // Hand a fresh resolved uid → name map to every cached webview of an // instance (shell.js __lchatSetNames). Names only — never key material. private func pushNames(instanceID: String, names: [String: String]) { guard !names.isEmpty, let data = try? JSONSerialization.data(withJSONObject: names), let json = String(data: data, encoding: .utf8) else { return } for (key, entry) in entries where key.hasPrefix(instanceID + "|") { entry.web.evaluateJavaScript( "window.__lchatSetNames && window.__lchatSetNames(\(json)); undefined;", completionHandler: nil) } } // 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.window != nil { entry.bridge.appUpdateKind = kind entry.bridge.appUpdateDismissed = false } else { entry.bridge.appUpdateKind = nil entry.bridge.appUpdateDismissed = false entry.web.reload() } } } // The live webview for this thread — reused if we have it, created (and left // loading) if not. Marks the entry most-recently-used so eviction spares it. // `app` names one of a GROUP's added apps: that pane renders the app's // interface instead of the chat feed, and caches separately from the feed // (same thread, different surface). func webView(for instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?, app: String? = nil, space: Bool = false) -> WKWebView { let e = entry(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64, app: app, space: space) e.lastUsed = Date() return e.web } // The bridge that owns this thread's webview. Panes observe this instead of // creating their own, so ready state survives with the page. func bridge(for instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?, app: String? = nil, space: Bool = false) -> WebBridge { 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? = nil, space: Bool = false) -> Entry { // One shared pane per group for ALL its apps (switching apps is a JS // call into the same page — WebBridge.setGroupApp — 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 keeps // its own page. let key = instance.id + "|" + thread.id + (space ? "|space:" + (app ?? "") : (app != nil ? "|groupapps" : "")) // Reuse only if the page was seeded with the same thread key — a re-invite / // key change makes the parked page stale, so rebuild it. if let e = entries[key], e.seedKey == thread.threadKeyB64 { // 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 (window != nil, a SwiftUI re-render) keeps running // and keeps its banner. if e.bridge.appUpdateKind != nil, e.web.window == nil { e.bridge.appUpdateKind = nil e.bridge.appUpdateDismissed = false e.web.reload() } return e } if let e = entries.removeValue(forKey: key) { release(e) } let e = makeEntry(instance: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64, app: app, space: space) entries[key] = e evictIfNeeded() return e } private func makeEntry(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?, app: String? = nil, space: Bool = false) -> Entry { let bridge = WebBridge() bridge.origin = instance.origin // the app-catalogue pull's registry key let cfg = WKWebViewConfiguration() cfg.websiteDataStore = .default() cfg.allowsInlineMediaPlayback = true if let script = Self.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 // opens target="_blank" links in the system browser // The pane sits inside the safe area already — don't let WebKit add its // own insets on top. web.scrollView.contentInsetAdjustmentBehavior = .never // A downward drag that meets the keyboard (an app interface's own // inputs) should carry it away with the finger. The page scrolls in a // nested WebKit overflow scroll view — the page body is pinned at 100% // so this outer scrollView never scrolls — so the bridge re-applies // .interactive to that inner scroller on load / keyboard-show // (see WebBridge). web.scrollView.keyboardDismissMode = .interactive bridge.enableInteractiveKeyboardDismiss() #if DEBUG if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true } #endif if let url = instance.baseURL { web.load(URLRequest(url: url)) } return Entry(web: web, bridge: bridge, seedKey: thread.threadKeyB64) } // Runs before any page script: hand the page the embed seed for this thread. // Only the thread key crosses over — never the user private key. private static func bootScript(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?, app: String? = nil, space: Bool = false) -> WKUserScript? { var seed: [String: Any] = [ "token": instance.deviceToken, "name": instance.screenName, "myUserId": myUserId, "threadId": thread.id, "threadKey": thread.threadKeyB64, // Every page here is an app's own face — the chat view is native, // so the seed pins the interface (AppInterfacePane re-asserts it // on attach for parked pages). "view": "interface", ] if let app { // A group's shared app pane: this page is purely that app's // interface — the native screen renders the chat itself. seed["app"] = app seed["view"] = "interface" } // 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). if space { seed["space"] = true } if let ik = instanceKeyB64 { seed["instanceKey"] = ik } // Per-viewer names (#577): the resolved uid → name map, so the page // shows my names for people. Names only — never key material (the // embed can't open the nicknames blob, by design). let names = NativeFeedModelCache.shared.names(instance: instance.id) if !names.isEmpty { seed["names"] = names } guard let data = try? JSONSerialization.data(withJSONObject: seed), let json = String(data: data, encoding: .utf8) else { return nil } // json is a JSON object literal (valid JS). Assigning it as the flag means // chat.js both detects embed mode and reads the seed from one global. let js = "window.__lchatEmbed = \(json);" return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true) } // Trim least-recently-opened parked feeds past the cap. A webview that's on // screen (in a window) is never evicted, whatever its age. private func evictIfNeeded() { while entries.count > maxEntries { let parked = entries.filter { $0.value.web.window == nil } guard let oldest = parked.min(by: { $0.value.lastUsed < $1.value.lastUsed }) else { return } if let e = entries.removeValue(forKey: oldest.key) { release(e) } } } private func dropParked() { for (key, e) in entries where e.web.window == nil { entries.removeValue(forKey: key) release(e) } } 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") } } // A GROUP chat's chosen view for this session: nil/absent = the conversation, // an app id = that added app's interface. The native mirror of the web's // groupAppView; session memory only, like the web's appThreadView, and // deliberately not persisted. enum GroupAppMemory { static var selection: [String: String] = [:] // thread id -> app id }