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 SwiftUI
3import WebKit
4import UIKit
5
6// πŸ€– The native side of an app-interface webview (owned by FeedWebCache
7// alongside its page): load/ready state, the view/app switching calls into the
8// embed bridge, and the WebKit delegate glue (external links, JS dialogs,
9// interactive keyboard dismissal).
10//
11// This file used to be ChatView.swift β€” the whole-thread webview fallback that
12// rendered an app thread's CHAT in the web client. That surface is gone (chat
13// is native everywhere β€” AppThreadScreen / NativeChatScreen); a webview now
14// only ever carries an app's own interface, and this bridge is what remains.
15//
16// E2E is preserved and tightened: native unwraps this thread's AES key
17// (Keychain private key, never leaves the device) and seeds ONLY that key into
18// the page via window.__lchatEmbed. The webview never sees the user private
19// key or any other thread's key, so a single thread's page can't reach another
20// thread's content.
21final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate {
22 weak var webView: WKWebView?
23 // The instance origin, for the app-catalogue pull (set by FeedWebCache
24 // when it builds the entry) β€” a ready page reports the space's live app
25 // set into AppRegistry.
26 var origin: String?
27
28 // True once the page has real content to show. The page posts {ready:true}
29 // after its first render (shell.js bootEmbed) β€” that's the honest "painted"
30 // moment; didFinish is only a fallback (it fires when the HTML lands, well
31 // before the app has drawn, and never fires again on a parked page). Hosts
32 // overlay a loading veil until this flips so a booting page never shows as
33 // a blank pane.
34 @Published var pageReady = false
35
36 // An apps_changed update/removal arrived while this page was on screen
37 // (FeedWebCache.handleAppsChanged): the page is running stale app code,
38 // but yanking a surface someone is actively looking at is worse than
39 // telling them β€” the pane renders an inline offer (AppUpdateBar) instead.
40 // `appUpdateKind` is the frame's kind (picks the copy); "Later" only
41 // hides the bar β€” the flag stays, so the cache still reloads the page
42 // the next time it comes back from parked.
43 @Published var appUpdateKind: String?
44 @Published var appUpdateDismissed = false
45
46 // The banner's Reload: pick up the new app code now.
47 func reloadForAppUpdate() {
48 appUpdateKind = nil
49 appUpdateDismissed = false
50 webView?.reload()
51 }
52
53 func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
54 pageReady = false
55 }
56
57 func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
58 pageReady = true // show whatever the page has β€” a veil that never lifts is worse
59 }
60
61 func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
62 withError error: Error) {
63 pageReady = true
64 }
65
66 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
67 applyKeyboardDismiss()
68 // Fallback for a page that never posts {ready:true} (an older server's
69 // shell.js): give its boot a beat to paint, then lift the veil anyway.
70 DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
71 self?.pageReady = true
72 }
73 }
74
75 // MARK: Interactive keyboard dismissal
76 //
77 // With the keyboard up (an app interface's own inputs), dragging the page
78 // down until the finger meets the keyboard should carry it away with the
79 // drag, not leave it stranded open. That's UIScrollView's `.interactive`
80 // mode β€” but the page scrolls inside a nested overflow scroll view WebKit
81 // builds (the page body is pinned at 100%, so the webview's own scrollView
82 // never scrolls). WebKit creates that inner scroller lazily and can rebuild
83 // it, so re-apply the mode whenever the page loads and β€” the moment that
84 // counts β€” the keyboard is about to show.
85 func enableInteractiveKeyboardDismiss() {
86 NotificationCenter.default.addObserver(
87 self, selector: #selector(applyKeyboardDismiss),
88 name: UIResponder.keyboardWillShowNotification, object: nil)
89 }
90
91 @objc func applyKeyboardDismiss() {
92 // Only the on-screen page owns the visible keyboard; skip parked webviews.
93 guard let webView, webView.window != nil else { return }
94 setInteractiveDismiss(in: webView.scrollView)
95 }
96
97 private func setInteractiveDismiss(in view: UIView) {
98 (view as? UIScrollView)?.keyboardDismissMode = .interactive
99 for sub in view.subviews { setInteractiveDismiss(in: sub) }
100 }
101
102 // Links inside an app's interface render as target="_blank" anchors, which
103 // WebKit routes here as a new-window request, not a normal navigation β€”
104 // with no WKUIDelegate the tap fell on the floor and nothing happened.
105 // Hand the URL to the system browser instead of ever opening a child
106 // webview. Mirrors the Mac app.
107 func webView(_ webView: WKWebView,
108 createWebViewWith configuration: WKWebViewConfiguration,
109 for navigationAction: WKNavigationAction,
110 windowFeatures: WKWindowFeatures) -> WKWebView? {
111 if let url = navigationAction.request.url {
112 UIApplication.shared.open(url)
113 }
114 return nil
115 }
116
117 // Pin an app-thread's webview to its Interface view (shell.js
118 // setAppThreadView, via the embed bridge) β€” AppInterfacePane asserts this
119 // on attach and page-ready so the page can never sit on a chat view.
120 // `view` is "chat" or "interface" (ours, never user text β€” safe to
121 // interpolate).
122 func setAppView(_ view: String) {
123 webView?.evaluateJavaScript(
124 "window.lchatNative && window.lchatNative.setAppView && window.lchatNative.setAppView('\(view)')")
125 }
126
127 // Swap which of a group's added apps the shared pane shows (shell.js
128 // setGroupApp) β€” no reload, the pane re-renders from folded state. `app`
129 // is a catalog id (ours, never user text β€” safe to interpolate). The pane
130 // must NEVER sit on the chat view: against a server whose shell.js
131 // predates setGroupApp/embed.app, fall back to setAppView("interface")
132 // (the old bridge routes a group with apps to its app pane). The page's
133 // bridge installs a beat after didFinish, so retry until a call lands.
134 func setGroupApp(_ app: String, attempt: Int = 0) {
135 let js = """
136 (function(){ var n = window.lchatNative;
137 if (!n) return false;
138 if (n.setGroupApp) { n.setGroupApp('\(app)'); return true; }
139 if (n.setAppView) { n.setAppView('interface'); return true; }
140 return false; })()
141 """
142 webView?.evaluateJavaScript(js) { [weak self] result, _ in
143 guard (result as? Bool) != true, attempt < 12 else { return }
144 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
145 self?.setGroupApp(app, attempt: attempt + 1)
146 }
147 }
148 }
149
150 // WKUIDelegate shows nothing for window.confirm()/alert() unless we implement
151 // these β€” an app interface's confirms (embed mode shares the full web app's
152 // code paths) silently no-op without them.
153 func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
154 initiatedByFrame frame: WKFrameInfo) async -> Bool {
155 guard let vc = Self.topViewController() else { return false }
156 let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
157 return await withCheckedContinuation { continuation in
158 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in continuation.resume(returning: false) })
159 alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume(returning: true) })
160 vc.present(alert, animated: true)
161 }
162 }
163
164 func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
165 initiatedByFrame frame: WKFrameInfo) async {
166 guard let vc = Self.topViewController() else { return }
167 let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
168 await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
169 alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume() })
170 vc.present(alert, animated: true)
171 }
172 }
173
174 private static func topViewController() -> UIViewController? {
175 guard let scene = UIApplication.shared.connectedScenes
176 .compactMap({ $0 as? UIWindowScene })
177 .first(where: { $0.activationState == .foregroundActive }),
178 let root = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { return nil }
179 var top = root
180 while let presented = top.presentedViewController { top = presented }
181 return top
182 }
183
184 // The page's reports arrive on the one "lchat" handler. The old chat-embed
185 // traffic ({staged}, {replying}, {editing}, {error}) only ever fired from
186 // the chat view, which no page shows anymore.
187 func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
188 guard message.name == "lchat", let body = message.body as? [String: Any] else { return }
189 if body["ready"] as? Bool == true {
190 pageReady = true
191 // A ready page has loaded all three app tiers β€” pull the live
192 // catalogue into the registry (one pull per launch; cheap no-op
193 // after). {catalogChanged} below forces a refresh.
194 if let web = webView, let origin { AppCatalogPull.pull(from: web, origin: origin) }
195 }
196 if body["catalogChanged"] != nil, let web = webView, let origin {
197 // The space's app set changed (apps_changed): the page re-registered
198 // member apps live and told us β€” re-pull so pickers/routing follow.
199 AppCatalogPull.pull(from: web, origin: origin, force: true)
200 }
201 // App Studio's Open, handed off from the embed (shell.js
202 // memberAdmin.openApp): open the app's thread as its own native
203 // surface β€” this pane must never navigate itself, or the target app
204 // renders inside App Studio's screen. LChatApp consumes it and
205 // pushes the opened-or-created thread.
206 if let req = body["openAppThread"] as? [String: Any], let app = req["app"] as? String {
207 NotificationCenter.default.post(name: .lchatOpenAppThread, object: nil,
208 userInfo: ["app": app, "title": req["title"] as? String ?? app])
209 }
210 }
211}
212