| 1 | import LChatCore |
| 2 | import WebKit |
| 3 | import UIKit |
| 4 | |
| 5 | // Keeps each app-interface webview (plus its WebBridge) alive after you leave |
| 6 | // the thread, so re-opening attaches an already-rendered page instead of |
| 7 | // booting it from scratch behind its loading spinner. Chat never renders in |
| 8 | // these pages — every surface here is an app's own interface (AppInterfacePane, |
| 9 | // GroupAppPane, SpaceAppScreen); the chat feed is native everywhere. |
| 10 | // |
| 11 | // The E2E posture is unchanged: every webview is still seeded with exactly ONE |
| 12 | // thread's unwrapped key (never the user private key), so a page can't reach |
| 13 | // another thread's content — we just keep more of those single-thread pages |
| 14 | // around. Parked pages keep their WebSocket, and the page reconnects + catches |
| 15 | // up on its own after a drop, so a re-attached surface is already current. |
| 16 | @MainActor |
| 17 | final class FeedWebCache { |
| 18 | static let shared = FeedWebCache() |
| 19 | |
| 20 | private let maxEntries = 8 |
| 21 | |
| 22 | private final class Entry { |
| 23 | let web: WKWebView |
| 24 | let bridge: WebBridge |
| 25 | let seedKey: String // the thread key the page was seeded with |
| 26 | var lastUsed: Date |
| 27 | init(web: WKWebView, bridge: WebBridge, seedKey: String) { |
| 28 | self.web = web |
| 29 | self.bridge = bridge |
| 30 | self.seedKey = seedKey |
| 31 | self.lastUsed = Date() |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | private var entries: [String: Entry] = [:] // "instanceID|threadID" -> entry |
| 36 | |
| 37 | private init() { |
| 38 | // Parked feeds are pure cache — shed them under memory pressure. |
| 39 | NotificationCenter.default.addObserver( |
| 40 | forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main |
| 41 | ) { _ in |
| 42 | Task { @MainActor in FeedWebCache.shared.dropParked() } |
| 43 | } |
| 44 | // The boot seed's names map is baked once at webview creation, but |
| 45 | // parked webviews outlive renames (and a pre-first-refresh open bakes |
| 46 | // an empty map) — push every fresh fold's names into the live pages. |
| 47 | NativeFeedModelCache.shared.onNamesUpdate = { [weak self] instanceID, names in |
| 48 | self?.pushNames(instanceID: instanceID, names: names) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // Hand a fresh resolved uid → name map to every cached webview of an |
| 53 | // instance (shell.js __lchatSetNames). Names only — never key material. |
| 54 | private func pushNames(instanceID: String, names: [String: String]) { |
| 55 | guard !names.isEmpty, |
| 56 | let data = try? JSONSerialization.data(withJSONObject: names), |
| 57 | let json = String(data: data, encoding: .utf8) else { return } |
| 58 | for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| 59 | entry.web.evaluateJavaScript( |
| 60 | "window.__lchatSetNames && window.__lchatSetNames(\(json)); undefined;", |
| 61 | completionHandler: nil) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // An app's code changed under a live page (apps_changed, kind updated / |
| 66 | // removed). A reload is the only way to swap — custom apps are ES modules |
| 67 | // imported at boot, and sandbox.js refuses to re-register a member app id |
| 68 | // a running page already loaded — but never reload a surface someone is |
| 69 | // actively looking at: parked pages reload invisibly now, while a page |
| 70 | // that's on screen gets flagged and its pane offers an inline reload |
| 71 | // (AppUpdateBar). The embed seed survives the reload (it's a WKUserScript |
| 72 | // on the configuration), so a page reboots straight back into its pinned |
| 73 | // surface. |
| 74 | func handleAppsChanged(instanceID: String, kind: String) { |
| 75 | for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| 76 | if entry.web.window != nil { |
| 77 | entry.bridge.appUpdateKind = kind |
| 78 | entry.bridge.appUpdateDismissed = false |
| 79 | } else { |
| 80 | entry.bridge.appUpdateKind = nil |
| 81 | entry.bridge.appUpdateDismissed = false |
| 82 | entry.web.reload() |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // The live webview for this thread — reused if we have it, created (and left |
| 88 | // loading) if not. Marks the entry most-recently-used so eviction spares it. |
| 89 | // `app` names one of a GROUP's added apps: that pane renders the app's |
| 90 | // interface instead of the chat feed, and caches separately from the feed |
| 91 | // (same thread, different surface). |
| 92 | func webView(for instance: SavedInstance, thread: ChatThread, myUserId: String, |
| 93 | instanceKeyB64: String?, app: String? = nil, |
| 94 | space: Bool = false) -> WKWebView { |
| 95 | let e = entry(for: instance, thread: thread, myUserId: myUserId, |
| 96 | instanceKeyB64: instanceKeyB64, app: app, space: space) |
| 97 | e.lastUsed = Date() |
| 98 | return e.web |
| 99 | } |
| 100 | |
| 101 | // The bridge that owns this thread's webview. Panes observe this instead of |
| 102 | // creating their own, so ready state survives with the page. |
| 103 | func bridge(for instance: SavedInstance, thread: ChatThread, myUserId: String, |
| 104 | instanceKeyB64: String?, app: String? = nil, |
| 105 | space: Bool = false) -> WebBridge { |
| 106 | entry(for: instance, thread: thread, myUserId: myUserId, |
| 107 | instanceKeyB64: instanceKeyB64, app: app, space: space).bridge |
| 108 | } |
| 109 | |
| 110 | private func entry(for instance: SavedInstance, thread: ChatThread, myUserId: String, |
| 111 | instanceKeyB64: String?, app: String? = nil, |
| 112 | space: Bool = false) -> Entry { |
| 113 | // One shared pane per group for ALL its apps (switching apps is a JS |
| 114 | // call into the same page — WebBridge.setGroupApp — not a reload), so |
| 115 | // the key marks the surface, not the app — EXCEPT space surfaces: |
| 116 | // a space app is fixed at page boot (openSpaceApp), so each keeps |
| 117 | // its own page. |
| 118 | let key = instance.id + "|" + thread.id |
| 119 | + (space ? "|space:" + (app ?? "") : (app != nil ? "|groupapps" : "")) |
| 120 | // Reuse only if the page was seeded with the same thread key — a re-invite / |
| 121 | // key change makes the parked page stale, so rebuild it. |
| 122 | if let e = entries[key], e.seedKey == thread.threadKeyB64 { |
| 123 | // A deferred app update (flagged while on screen, or "Later"): |
| 124 | // this access is (re)attaching a parked page — reload it now, |
| 125 | // before it's back under someone's eyes. A page that's already |
| 126 | // on screen (window != nil, a SwiftUI re-render) keeps running |
| 127 | // and keeps its banner. |
| 128 | if e.bridge.appUpdateKind != nil, e.web.window == nil { |
| 129 | e.bridge.appUpdateKind = nil |
| 130 | e.bridge.appUpdateDismissed = false |
| 131 | e.web.reload() |
| 132 | } |
| 133 | return e |
| 134 | } |
| 135 | if let e = entries.removeValue(forKey: key) { release(e) } |
| 136 | let e = makeEntry(instance: instance, thread: thread, myUserId: myUserId, |
| 137 | instanceKeyB64: instanceKeyB64, app: app, space: space) |
| 138 | entries[key] = e |
| 139 | evictIfNeeded() |
| 140 | return e |
| 141 | } |
| 142 | |
| 143 | private func makeEntry(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| 144 | instanceKeyB64: String?, app: String? = nil, |
| 145 | space: Bool = false) -> Entry { |
| 146 | let bridge = WebBridge() |
| 147 | bridge.origin = instance.origin // the app-catalogue pull's registry key |
| 148 | let cfg = WKWebViewConfiguration() |
| 149 | cfg.websiteDataStore = .default() |
| 150 | cfg.allowsInlineMediaPlayback = true |
| 151 | if let script = Self.bootScript(instance: instance, thread: thread, myUserId: myUserId, |
| 152 | instanceKeyB64: instanceKeyB64, app: app, |
| 153 | space: space) { |
| 154 | cfg.userContentController.addUserScript(script) |
| 155 | } |
| 156 | cfg.userContentController.add(bridge, name: "lchat") |
| 157 | let web = WKWebView(frame: .zero, configuration: cfg) |
| 158 | bridge.webView = web |
| 159 | web.navigationDelegate = bridge |
| 160 | web.uiDelegate = bridge // opens target="_blank" links in the system browser |
| 161 | // The pane sits inside the safe area already — don't let WebKit add its |
| 162 | // own insets on top. |
| 163 | web.scrollView.contentInsetAdjustmentBehavior = .never |
| 164 | // A downward drag that meets the keyboard (an app interface's own |
| 165 | // inputs) should carry it away with the finger. The page scrolls in a |
| 166 | // nested WebKit overflow scroll view — the page body is pinned at 100% |
| 167 | // so this outer scrollView never scrolls — so the bridge re-applies |
| 168 | // .interactive to that inner scroller on load / keyboard-show |
| 169 | // (see WebBridge). |
| 170 | web.scrollView.keyboardDismissMode = .interactive |
| 171 | bridge.enableInteractiveKeyboardDismiss() |
| 172 | #if DEBUG |
| 173 | if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true } |
| 174 | #endif |
| 175 | if let url = instance.baseURL { web.load(URLRequest(url: url)) } |
| 176 | return Entry(web: web, bridge: bridge, seedKey: thread.threadKeyB64) |
| 177 | } |
| 178 | |
| 179 | // Runs before any page script: hand the page the embed seed for this thread. |
| 180 | // Only the thread key crosses over — never the user private key. |
| 181 | private static func bootScript(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| 182 | instanceKeyB64: String?, app: String? = nil, |
| 183 | space: Bool = false) -> WKUserScript? { |
| 184 | var seed: [String: Any] = [ |
| 185 | "token": instance.deviceToken, |
| 186 | "name": instance.screenName, |
| 187 | "myUserId": myUserId, |
| 188 | "threadId": thread.id, |
| 189 | "threadKey": thread.threadKeyB64, |
| 190 | // Every page here is an app's own face — the chat view is native, |
| 191 | // so the seed pins the interface (AppInterfacePane re-asserts it |
| 192 | // on attach for parked pages). |
| 193 | "view": "interface", |
| 194 | ] |
| 195 | if let app { |
| 196 | // A group's shared app pane: this page is purely that app's |
| 197 | // interface — the native screen renders the chat itself. |
| 198 | seed["app"] = app |
| 199 | seed["view"] = "interface" |
| 200 | } |
| 201 | // A SPACE app's surface (docs/native-apps.md phase 4): the seed's |
| 202 | // thread IS the space thread — bootEmbed folds its installs and opens |
| 203 | // the app via the web's own openSpaceApp path (never a chat). |
| 204 | if space { seed["space"] = true } |
| 205 | if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| 206 | // Per-viewer names (#577): the resolved uid → name map, so the page |
| 207 | // shows my names for people. Names only — never key material (the |
| 208 | // embed can't open the nicknames blob, by design). |
| 209 | let names = NativeFeedModelCache.shared.names(instance: instance.id) |
| 210 | if !names.isEmpty { seed["names"] = names } |
| 211 | guard let data = try? JSONSerialization.data(withJSONObject: seed), |
| 212 | let json = String(data: data, encoding: .utf8) else { return nil } |
| 213 | // json is a JSON object literal (valid JS). Assigning it as the flag means |
| 214 | // chat.js both detects embed mode and reads the seed from one global. |
| 215 | let js = "window.__lchatEmbed = \(json);" |
| 216 | return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true) |
| 217 | } |
| 218 | |
| 219 | // Trim least-recently-opened parked feeds past the cap. A webview that's on |
| 220 | // screen (in a window) is never evicted, whatever its age. |
| 221 | private func evictIfNeeded() { |
| 222 | while entries.count > maxEntries { |
| 223 | let parked = entries.filter { $0.value.web.window == nil } |
| 224 | guard let oldest = parked.min(by: { $0.value.lastUsed < $1.value.lastUsed }) else { return } |
| 225 | if let e = entries.removeValue(forKey: oldest.key) { release(e) } |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | private func dropParked() { |
| 230 | for (key, e) in entries where e.web.window == nil { |
| 231 | entries.removeValue(forKey: key) |
| 232 | release(e) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | private func release(_ e: Entry) { |
| 237 | e.web.stopLoading() |
| 238 | // The controller retains the bridge; break that so the whole page tears down. |
| 239 | e.web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat") |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | // A GROUP chat's chosen view for this session: nil/absent = the conversation, |
| 244 | // an app id = that added app's interface. The native mirror of the web's |
| 245 | // groupAppView; session memory only, like the web's appThreadView, and |
| 246 | // deliberately not persisted. |
| 247 | enum GroupAppMemory { |
| 248 | static var selection: [String: String] = [:] // thread id -> app id |
| 249 | } |
| 250 | |