- WebShellView (UIViewRepresentable + NSViewRepresentable) wrapt WKWebView, KVO-Observation für Loading/Progress/canGoBack/URL, Pull-to-Refresh via UIRefreshControl - WebShellCoordinator (MainActor) hält WKNavigationDelegate + WKUIDelegate, externe Links via openURL aus dem Environment in System-Browser, Host-Whitelist auf zitare.com + .mana.how - RootView refactored: Lesen-Tab lädt webBaseURL/, Erkunden-Tab /explore. Universal-Links zitare.com/q|a|c/<slug>, /search, /region/*, /thema/* etc. routen in den passenden Tab, reloadToken zwingt Re-Navigation auch bei selber URL - AppConfig.webBaseURL = appBaseURL (zitare.mana.how) bis Cloudflare-Zone für zitare.com live ist; publicWebURL als Konstante schon eingetragen - CookieBridge-Skeleton für mana.access auf .mana.how — scharfgeschaltet erst in ζ-3 nach Live-Auth-Smoke - iPhone 16e Simulator: zitare.mana.how lädt, Carl-Spitteler-Quote rendert, Healthz weiter 200 - 16 Files swiftlint-grün, alle Tests grün Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
5.4 KiB
Swift
155 lines
5.4 KiB
Swift
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"
|
|
}
|
|
}
|