ζ-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>
This commit is contained in:
parent
0bd59ed148
commit
75b5e7113f
5 changed files with 467 additions and 78 deletions
155
Sources/Features/WebShell/WebShellCoordinator.swift
Normal file
155
Sources/Features/WebShell/WebShellCoordinator.swift
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue