eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import WebKit
3import 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
17final 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.
247enum GroupAppMemory {
248 static var selection: [String: String] = [:] // thread id -> app id
249}
250