Files
openclaw/apps/ios/Sources/Screen/ScreenWebView.swift
Nimrod Gutman dd28a77df0 fix(ios): refactor screen webview lifecycle handling (#20366)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 7beb794a06
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-02-19 05:05:40 +08:00

194 lines
6.5 KiB
Swift

import OpenClawKit
import SwiftUI
import WebKit
struct ScreenWebView: UIViewRepresentable {
var controller: ScreenController
func makeCoordinator() -> ScreenWebViewCoordinator {
ScreenWebViewCoordinator(controller: self.controller)
}
func makeUIView(context: Context) -> UIView {
context.coordinator.makeContainerView()
}
func updateUIView(_: UIView, context: Context) {
context.coordinator.updateController(self.controller)
}
static func dismantleUIView(_: UIView, coordinator: ScreenWebViewCoordinator) {
coordinator.teardown()
}
}
@MainActor
final class ScreenWebViewCoordinator: NSObject {
private weak var controller: ScreenController?
private let navigationDelegate = ScreenNavigationDelegate()
private let a2uiActionHandler = CanvasA2UIActionMessageHandler()
private let userContentController = WKUserContentController()
private(set) var managedWebView: WKWebView?
private weak var containerView: UIView?
init(controller: ScreenController) {
self.controller = controller
super.init()
self.navigationDelegate.controller = controller
self.a2uiActionHandler.controller = controller
}
func makeContainerView() -> UIView {
if let containerView {
return containerView
}
let container = UIView(frame: .zero)
container.backgroundColor = .black
let webView = Self.makeWebView(userContentController: self.userContentController)
webView.navigationDelegate = self.navigationDelegate
self.installA2UIHandlers()
webView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(webView)
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
webView.topAnchor.constraint(equalTo: container.topAnchor),
webView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
self.managedWebView = webView
self.containerView = container
self.controller?.attachWebView(webView)
return container
}
func updateController(_ controller: ScreenController) {
let previousController = self.controller
let controllerChanged = self.controller !== controller
self.controller = controller
self.navigationDelegate.controller = controller
self.a2uiActionHandler.controller = controller
if controllerChanged, let managedWebView {
previousController?.detachWebView(managedWebView)
controller.attachWebView(managedWebView)
}
}
func teardown() {
if let managedWebView {
self.controller?.detachWebView(managedWebView)
managedWebView.navigationDelegate = nil
}
self.removeA2UIHandlers()
self.navigationDelegate.controller = nil
self.a2uiActionHandler.controller = nil
self.managedWebView = nil
self.containerView = nil
}
private static func makeWebView(userContentController: WKUserContentController) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
config.userContentController = userContentController
let webView = WKWebView(frame: .zero, configuration: config)
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
webView.isOpaque = true
webView.backgroundColor = .black
let scrollView = webView.scrollView
scrollView.backgroundColor = .black
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.contentInset = .zero
scrollView.scrollIndicatorInsets = .zero
scrollView.automaticallyAdjustsScrollIndicatorInsets = false
return webView
}
private func installA2UIHandlers() {
for name in CanvasA2UIActionMessageHandler.handlerNames {
self.userContentController.add(self.a2uiActionHandler, name: name)
}
}
private func removeA2UIHandlers() {
for name in CanvasA2UIActionMessageHandler.handlerNames {
self.userContentController.removeScriptMessageHandler(forName: name)
}
}
}
// MARK: - Navigation Delegate
/// Handles navigation policy to intercept openclaw:// deep links from canvas
@MainActor
private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
weak var controller: ScreenController?
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
// Intercept openclaw:// deep links.
if url.scheme?.lowercased() == "openclaw" {
decisionHandler(.cancel)
self.controller?.onDeepLink?(url)
return
}
decisionHandler(.allow)
}
func webView(
_: WKWebView,
didFailProvisionalNavigation _: WKNavigation?,
withError error: any Error)
{
self.controller?.errorText = error.localizedDescription
}
func webView(_: WKWebView, didFinish _: WKNavigation?) {
self.controller?.errorText = nil
self.controller?.applyDebugStatusIfNeeded()
}
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
self.controller?.errorText = error.localizedDescription
}
}
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "openclawCanvasA2UIAction"
static let handlerNames = [messageName]
weak var controller: ScreenController?
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard Self.handlerNames.contains(message.name) else { return }
guard let controller else { return }
guard let url = message.webView?.url else { return }
if url.isFileURL {
guard controller.isTrustedCanvasUIURL(url) else { return }
} else {
// For security, only accept actions from local-network pages (e.g. the canvas host).
guard controller.isLocalNetworkCanvasURL(url) else { return }
}
guard let body = ScreenController.parseA2UIActionBody(message.body) else { return }
controller.onA2UIAction?(body)
}
}