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
This commit is contained in:
Nimrod Gutman
2026-02-19 05:05:40 +08:00
committed by GitHub
parent e67da1538c
commit dd28a77df0
5 changed files with 276 additions and 145 deletions

View File

@@ -1,14 +1,12 @@
import OpenClawKit
import Observation
import SwiftUI
import UIKit
import WebKit
@MainActor
@Observable
final class ScreenController {
let webView: WKWebView
private let navigationDelegate: ScreenNavigationDelegate
private let a2uiActionHandler: CanvasA2UIActionMessageHandler
private weak var activeWebView: WKWebView?
var urlString: String = ""
var errorText: String?
@@ -24,29 +22,6 @@ final class ScreenController {
private var debugStatusSubtitle: String?
init() {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
let userContentController = WKUserContentController()
for name in CanvasA2UIActionMessageHandler.handlerNames {
userContentController.add(a2uiActionHandler, name: name)
}
config.userContentController = userContentController
self.navigationDelegate = ScreenNavigationDelegate()
self.a2uiActionHandler = a2uiActionHandler
self.webView = WKWebView(frame: .zero, configuration: config)
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
self.webView.isOpaque = true
self.webView.backgroundColor = .black
self.webView.scrollView.backgroundColor = .black
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
self.webView.scrollView.contentInset = .zero
self.webView.scrollView.scrollIndicatorInsets = .zero
self.webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
self.applyScrollBehavior()
self.webView.navigationDelegate = self.navigationDelegate
self.navigationDelegate.controller = self
a2uiActionHandler.controller = self
self.reload()
}
@@ -71,24 +46,26 @@ final class ScreenController {
}
func reload() {
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
self.applyScrollBehavior()
guard let webView = self.activeWebView else { return }
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
guard let url = Self.canvasScaffoldURL else { return }
self.errorText = nil
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
return
}
guard let url = URL(string: trimmed) else {
self.errorText = "Invalid URL: \(trimmed)"
return
}
self.errorText = nil
if url.isFileURL {
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
} else {
guard let url = URL(string: trimmed) else {
self.errorText = "Invalid URL: \(trimmed)"
return
}
self.errorText = nil
if url.isFileURL {
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
} else {
self.webView.load(URLRequest(url: url))
}
webView.load(URLRequest(url: url))
}
}
@@ -108,7 +85,8 @@ final class ScreenController {
self.applyDebugStatusIfNeeded()
}
fileprivate func applyDebugStatusIfNeeded() {
func applyDebugStatusIfNeeded() {
guard let webView = self.activeWebView else { return }
let enabled = self.debugStatusEnabled
let title = self.debugStatusTitle
let subtitle = self.debugStatusSubtitle
@@ -127,7 +105,7 @@ final class ScreenController {
} catch (_) {}
})()
"""
self.webView.evaluateJavaScript(js) { _, _ in }
webView.evaluateJavaScript(js) { _, _ in }
}
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
@@ -154,8 +132,13 @@ final class ScreenController {
}
func eval(javaScript: String) async throws -> String {
try await withCheckedThrowingContinuation { cont in
self.webView.evaluateJavaScript(javaScript) { result, error in
guard let webView = self.activeWebView else {
throw NSError(domain: "Screen", code: 3, userInfo: [
NSLocalizedDescriptionKey: "web view unavailable",
])
}
return try await withCheckedThrowingContinuation { cont in
webView.evaluateJavaScript(javaScript) { result, error in
if let error {
cont.resume(throwing: error)
return
@@ -174,8 +157,13 @@ final class ScreenController {
if let maxWidth {
config.snapshotWidth = NSNumber(value: Double(maxWidth))
}
guard let webView = self.activeWebView else {
throw NSError(domain: "Screen", code: 3, userInfo: [
NSLocalizedDescriptionKey: "web view unavailable",
])
}
let image: UIImage = try await withCheckedThrowingContinuation { cont in
self.webView.takeSnapshot(with: config) { image, error in
webView.takeSnapshot(with: config) { image, error in
if let error {
cont.resume(throwing: error)
return
@@ -206,8 +194,13 @@ final class ScreenController {
if let maxWidth {
config.snapshotWidth = NSNumber(value: Double(maxWidth))
}
guard let webView = self.activeWebView else {
throw NSError(domain: "Screen", code: 3, userInfo: [
NSLocalizedDescriptionKey: "web view unavailable",
])
}
let image: UIImage = try await withCheckedThrowingContinuation { cont in
self.webView.takeSnapshot(with: config) { image, error in
webView.takeSnapshot(with: config) { image, error in
if let error {
cont.resume(throwing: error)
return
@@ -238,6 +231,17 @@ final class ScreenController {
return data.base64EncodedString()
}
func attachWebView(_ webView: WKWebView) {
self.activeWebView = webView
self.reload()
self.applyDebugStatusIfNeeded()
}
func detachWebView(_ webView: WKWebView) {
guard self.activeWebView === webView else { return }
self.activeWebView = nil
}
private static func bundledResourceURL(
name: String,
ext: String,
@@ -277,9 +281,10 @@ final class ScreenController {
}
private func applyScrollBehavior() {
guard let webView = self.activeWebView else { return }
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
let allowScroll = !trimmed.isEmpty
let scrollView = self.webView.scrollView
let scrollView = webView.scrollView
// Default canvas needs raw touch events; external pages should scroll.
scrollView.isScrollEnabled = allowScroll
scrollView.bounces = allowScroll
@@ -366,72 +371,3 @@ extension Double {
return self
}
}
// 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(
_ 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)
}
}