import Foundation import SwiftUI import WebKit #if canImport(UIKit) import UIKit #endif /// `WKNavigationDelegate` + `WKUIDelegate` für `WebShellView`. Hält den /// reactive `WebNavState` aktuell, lenkt externe Links in den System- /// Browser und treibt Pull-to-Refresh an. /// /// Nicht `Sendable`: Lebt auf `MainActor` (Closures von WKWebView /// liefern auf Main). KVO-Observations werden bei `deinit` entfernt. @MainActor final class WebShellCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate { let navState: WebNavState let openURL: OpenURLAction var lastTarget: WebTarget? private var progressObservation: NSKeyValueObservation? private var loadingObservation: NSKeyValueObservation? private var canGoBackObservation: NSKeyValueObservation? private var urlObservation: NSKeyValueObservation? #if canImport(UIKit) private weak var refreshControl: UIRefreshControl? #endif init(navState: WebNavState, openURL: OpenURLAction) { self.navState = navState self.openURL = openURL super.init() } deinit { progressObservation?.invalidate() loadingObservation?.invalidate() canGoBackObservation?.invalidate() urlObservation?.invalidate() } func observe(webView: WKWebView) { progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, change in guard let value = change.newValue else { return } Task { @MainActor in self?.navState.estimatedProgress = value } } loadingObservation = webView.observe(\.isLoading, options: [.new]) { [weak self] _, change in guard let value = change.newValue else { return } Task { @MainActor in self?.navState.isLoading = value } } canGoBackObservation = webView.observe(\.canGoBack, options: [.new]) { [weak self] _, change in guard let value = change.newValue else { return } Task { @MainActor in self?.navState.canGoBack = value } } urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in let value = change.newValue ?? nil Task { @MainActor in self?.navState.currentURL = value } } } func load(_ url: URL, into webView: WKWebView) { Log.web.info("WebShell load: \(url.absoluteString, privacy: .public)") navState.lastError = nil let request = URLRequest(url: url) webView.load(request) } #if canImport(UIKit) func attachRefresh(_ control: UIRefreshControl, webView: WKWebView) { refreshControl = control control.addAction( UIAction { [weak self, weak webView] _ in webView?.reload() Task { @MainActor in self?.refreshControl?.endRefreshing() } }, for: .valueChanged ) } #endif // MARK: - WKNavigationDelegate func webView( _: WKWebView, decidePolicyFor navigationAction: WKNavigationAction ) async -> WKNavigationActionPolicy { guard let url = navigationAction.request.url else { return .allow } // Eigene Host-Whitelist: alles auf zitare.com bzw. .mana.how darf // im WebView öffnen. Alles andere geht in den System-Browser. // Custom-Scheme zitare:// catchen wir hier nicht — Universal- // Links auf zitare.com sind der präferierte Pfad. if let host = url.host, isAllowedHost(host) { return .allow } if url.scheme == "http" || url.scheme == "https" { Log.web.info("WebShell → openURL extern: \(url.absoluteString, privacy: .public)") openURL(url) return .cancel } return .cancel } func webView(_: WKWebView, didFail _: WKNavigation, withError error: Error) { Log.web.warning("didFail: \(String(describing: error), privacy: .public)") navState.lastError = (error as NSError).localizedDescription } func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation, withError error: Error) { Log.web.warning("didFailProvisional: \(String(describing: error), privacy: .public)") navState.lastError = (error as NSError).localizedDescription } func webView(_: WKWebView, didFinish _: WKNavigation) { navState.lastError = nil } // MARK: - WKUIDelegate func webView( _ webView: WKWebView, createWebViewWith _: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures _: WKWindowFeatures ) -> WKWebView? { // `target=_blank`-Links: kein neues Fenster aufmachen, sondern // im aktuellen WebView laden bzw. extern öffnen. if let url = navigationAction.request.url { if let host = url.host, isAllowedHost(host) { webView.load(navigationAction.request) } else { openURL(url) } } return nil } // MARK: - Helpers private func isAllowedHost(_ host: String) -> Bool { host == "zitare.com" || host == "www.zitare.com" || host.hasSuffix(".mana.how") || host == "mana.how" } }