zitare-native/Sources/Features/WebShell/WebShellCoordinator.swift
Till 75b5e7113f ζ-1: WebShellView + Universal-Link-Routing
- 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>
2026-05-14 12:56:05 +02:00

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"
}
}