| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import WebKit |
| 4 | import 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. |
| 21 | final 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 | |