import LChatCore import SwiftUI import WebKit import UIKit // 🤖 The native side of an app-interface webview (owned by FeedWebCache // alongside its page): load/ready state, the view/app switching calls into the // embed bridge, and the WebKit delegate glue (external links, JS dialogs, // interactive keyboard dismissal). // // This file used to be ChatView.swift — the whole-thread webview fallback that // rendered an app thread's CHAT in the web client. That surface is gone (chat // is native everywhere — AppThreadScreen / NativeChatScreen); a webview now // only ever carries an app's own interface, and this bridge is what remains. // // E2E is preserved and tightened: native unwraps this thread's AES key // (Keychain private key, never leaves the device) and seeds ONLY that key into // the page via window.__lchatEmbed. The webview never sees the user private // key or any other thread's key, so a single thread's page can't reach another // thread's content. final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate { weak var webView: WKWebView? // The instance origin, for the app-catalogue pull (set by FeedWebCache // when it builds the entry) — a ready page reports the space's live app // set into AppRegistry. var origin: String? // True once the page has real content to show. The page posts {ready:true} // after its first render (shell.js bootEmbed) — that's the honest "painted" // moment; didFinish is only a fallback (it fires when the HTML lands, well // before the app has drawn, and never fires again on a parked page). Hosts // overlay a loading veil until this flips so a booting page never shows as // a blank pane. @Published var pageReady = false // An apps_changed update/removal arrived while this page was on screen // (FeedWebCache.handleAppsChanged): the page is running stale app code, // but yanking a surface someone is actively looking at is worse than // telling them — the pane renders an inline offer (AppUpdateBar) instead. // `appUpdateKind` is the frame's kind (picks the copy); "Later" only // hides the bar — the flag stays, so the cache still reloads the page // the next time it comes back from parked. @Published var appUpdateKind: String? @Published var appUpdateDismissed = false // The banner's Reload: pick up the new app code now. func reloadForAppUpdate() { appUpdateKind = nil appUpdateDismissed = false webView?.reload() } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { pageReady = false } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { pageReady = true // show whatever the page has — a veil that never lifts is worse } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { pageReady = true } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { applyKeyboardDismiss() // Fallback for a page that never posts {ready:true} (an older server's // shell.js): give its boot a beat to paint, then lift the veil anyway. DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.pageReady = true } } // MARK: Interactive keyboard dismissal // // With the keyboard up (an app interface's own inputs), dragging the page // down until the finger meets the keyboard should carry it away with the // drag, not leave it stranded open. That's UIScrollView's `.interactive` // mode — but the page scrolls inside a nested overflow scroll view WebKit // builds (the page body is pinned at 100%, so the webview's own scrollView // never scrolls). WebKit creates that inner scroller lazily and can rebuild // it, so re-apply the mode whenever the page loads and — the moment that // counts — the keyboard is about to show. func enableInteractiveKeyboardDismiss() { NotificationCenter.default.addObserver( self, selector: #selector(applyKeyboardDismiss), name: UIResponder.keyboardWillShowNotification, object: nil) } @objc func applyKeyboardDismiss() { // Only the on-screen page owns the visible keyboard; skip parked webviews. guard let webView, webView.window != nil else { return } setInteractiveDismiss(in: webView.scrollView) } private func setInteractiveDismiss(in view: UIView) { (view as? UIScrollView)?.keyboardDismissMode = .interactive for sub in view.subviews { setInteractiveDismiss(in: sub) } } // Links inside an app's interface render as target="_blank" anchors, which // WebKit routes here as a new-window request, not a normal navigation — // with no WKUIDelegate the tap fell on the floor and nothing happened. // Hand the URL to the system browser instead of ever opening a child // webview. Mirrors the Mac app. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if let url = navigationAction.request.url { UIApplication.shared.open(url) } return nil } // Pin an app-thread's webview to its Interface view (shell.js // setAppThreadView, via the embed bridge) — AppInterfacePane asserts this // on attach and page-ready so the page can never sit on a chat view. // `view` is "chat" or "interface" (ours, never user text — safe to // interpolate). func setAppView(_ view: String) { webView?.evaluateJavaScript( "window.lchatNative && window.lchatNative.setAppView && window.lchatNative.setAppView('\(view)')") } // Swap which of a group's added apps the shared pane shows (shell.js // setGroupApp) — no reload, the pane re-renders from folded state. `app` // is a catalog id (ours, never user text — safe to interpolate). The pane // must NEVER sit on the chat view: against a server whose shell.js // predates setGroupApp/embed.app, fall back to setAppView("interface") // (the old bridge routes a group with apps to its app pane). The page's // bridge installs a beat after didFinish, so retry until a call lands. func setGroupApp(_ app: String, attempt: Int = 0) { let js = """ (function(){ var n = window.lchatNative; if (!n) return false; if (n.setGroupApp) { n.setGroupApp('\(app)'); return true; } if (n.setAppView) { n.setAppView('interface'); return true; } return false; })() """ webView?.evaluateJavaScript(js) { [weak self] result, _ in guard (result as? Bool) != true, attempt < 12 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self?.setGroupApp(app, attempt: attempt + 1) } } } // WKUIDelegate shows nothing for window.confirm()/alert() unless we implement // these — an app interface's confirms (embed mode shares the full web app's // code paths) silently no-op without them. func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async -> Bool { guard let vc = Self.topViewController() else { return false } let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) return await withCheckedContinuation { continuation in alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in continuation.resume(returning: false) }) alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume(returning: true) }) vc.present(alert, animated: true) } } func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async { guard let vc = Self.topViewController() else { return } let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) await withCheckedContinuation { (continuation: CheckedContinuation) in alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume() }) vc.present(alert, animated: true) } } private static func topViewController() -> UIViewController? { guard let scene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }), let root = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { return nil } var top = root while let presented = top.presentedViewController { top = presented } return top } // The page's reports arrive on the one "lchat" handler. The old chat-embed // traffic ({staged}, {replying}, {editing}, {error}) only ever fired from // the chat view, which no page shows anymore. func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "lchat", let body = message.body as? [String: Any] else { return } if body["ready"] as? Bool == true { pageReady = true // A ready page has loaded all three app tiers — pull the live // catalogue into the registry (one pull per launch; cheap no-op // after). {catalogChanged} below forces a refresh. if let web = webView, let origin { AppCatalogPull.pull(from: web, origin: origin) } } if body["catalogChanged"] != nil, let web = webView, let origin { // The space's app set changed (apps_changed): the page re-registered // member apps live and told us — re-pull so pickers/routing follow. AppCatalogPull.pull(from: web, origin: origin, force: true) } // App Studio's Open, handed off from the embed (shell.js // memberAdmin.openApp): open the app's thread as its own native // surface — this pane must never navigate itself, or the target app // renders inside App Studio's screen. LChatApp consumes it and // pushes the opened-or-created thread. if let req = body["openAppThread"] as? [String: Any], let app = req["app"] as? String { NotificationCenter.default.post(name: .lchatOpenAppThread, object: nil, userInfo: ["app": app, "title": req["title"] as? String ?? app]) } } }