ζ-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
|
|
@ -1,38 +1,169 @@
|
|||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
/// Phase ζ-1 Placeholder.
|
||||
/// Phase ζ-1: SwiftUI-Hülle um `WKWebView`. Eine `WebShellView`-Instanz
|
||||
/// gehört zu einem Tab und behält ihren Web-State (Scroll-Position,
|
||||
/// Browser-History) während der Tab aktiv bleibt.
|
||||
///
|
||||
/// Wird in ζ-1 zu einer echten `UIViewRepresentable`/
|
||||
/// `NSViewRepresentable` um `WKWebView`. Aufgabenliste in ζ-1:
|
||||
///
|
||||
/// - WebView-Konfiguration: `WKWebViewConfiguration` mit non-persistent
|
||||
/// DataStore in Debug-Builds; Persistent in Release.
|
||||
/// - Cookie-Bridge: nach ManaCore-Login JWT als `mana.access`-Cookie
|
||||
/// für `.mana.how` ins `WKHTTPCookieStore` schreiben.
|
||||
/// - Pull-to-Refresh via `UIRefreshControl` (iOS) /
|
||||
/// `NSScrollView` (macOS).
|
||||
/// - `WKNavigationDelegate` für Deep-Link-Catching: wenn der WebView
|
||||
/// eine Navigation auf `zitare://` oder eine andere mana-Domain
|
||||
/// versucht, abfangen und natively routen.
|
||||
/// - `WKUIDelegate` für `target=_blank`-Links (Safari öffnen, nicht
|
||||
/// im WebView).
|
||||
/// - Native-Toolbar overlay (ζ-5).
|
||||
///
|
||||
/// Heute nur die Signatur, damit `RootView` schon den finalen
|
||||
/// Import-Pfad nutzt.
|
||||
/// **Verhalten:**
|
||||
/// - Lädt `target` beim ersten Auftauchen.
|
||||
/// - Wechselt `target` während die View lebt → lädt neue URL.
|
||||
/// - Pull-to-Refresh über `UIRefreshControl` (iOS).
|
||||
/// - External-Links (anderer Host, `target=_blank`) öffnen im System-
|
||||
/// Browser via `openURL`-Environment, nicht im WebView.
|
||||
/// - Cookies werden über das default `WKWebsiteDataStore` geteilt,
|
||||
/// sodass `CookieBridge` einmal injizierte `mana.access`-Cookies
|
||||
/// sichtbar sind.
|
||||
struct WebShellView: View {
|
||||
let initialURL: URL
|
||||
let target: WebTarget
|
||||
|
||||
@State private var navState = WebNavState()
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("WebShellView")
|
||||
.font(.headline)
|
||||
Text("ζ-1 — TODO: WKWebView auf \(initialURL.absoluteString)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
VStack(spacing: 0) {
|
||||
if navState.isLoading {
|
||||
ProgressView(value: navState.estimatedProgress)
|
||||
.progressViewStyle(.linear)
|
||||
.frame(height: 2)
|
||||
}
|
||||
#if canImport(UIKit)
|
||||
WebViewRepresentable(
|
||||
target: target,
|
||||
navState: navState,
|
||||
openURL: openURL
|
||||
)
|
||||
.background(ZitareTheme.background)
|
||||
#elseif canImport(AppKit)
|
||||
MacWebViewRepresentable(
|
||||
target: target,
|
||||
navState: navState,
|
||||
openURL: openURL
|
||||
)
|
||||
.background(ZitareTheme.background)
|
||||
#endif
|
||||
if let error = navState.lastError {
|
||||
errorBar(error)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func errorBar(_ message: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ZitareTheme.warning)
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(ZitareTheme.muted)
|
||||
}
|
||||
}
|
||||
|
||||
/// URL + monoton wachsende `reloadToken`. Ein neuer Token zwingt den
|
||||
/// WebView, dieselbe URL nochmal zu laden — wird gebraucht wenn der
|
||||
/// User auf einen Universal-Link tappt, der zur aktuellen URL führt.
|
||||
struct WebTarget: Equatable {
|
||||
let url: URL
|
||||
let reloadToken: Int
|
||||
|
||||
init(url: URL, reloadToken: Int = 0) {
|
||||
self.url = url
|
||||
self.reloadToken = reloadToken
|
||||
}
|
||||
}
|
||||
|
||||
/// Reactive Navigation-State, geteilt zwischen SwiftUI und Coordinator.
|
||||
@Observable
|
||||
final class WebNavState {
|
||||
var isLoading: Bool = false
|
||||
var estimatedProgress: Double = 0
|
||||
var lastError: String?
|
||||
var currentURL: URL?
|
||||
var canGoBack: Bool = false
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
|
||||
private struct WebViewRepresentable: UIViewRepresentable {
|
||||
let target: WebTarget
|
||||
let navState: WebNavState
|
||||
let openURL: OpenURLAction
|
||||
|
||||
func makeCoordinator() -> WebShellCoordinator {
|
||||
WebShellCoordinator(navState: navState, openURL: openURL)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .default()
|
||||
config.applicationNameForUserAgent = "ZitareNative/0.1 (iOS)"
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.uiDelegate = context.coordinator
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.scrollView.refreshControl = makeRefreshControl(
|
||||
webView: webView,
|
||||
coordinator: context.coordinator
|
||||
)
|
||||
context.coordinator.observe(webView: webView)
|
||||
context.coordinator.load(target.url, into: webView)
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
let coord = context.coordinator
|
||||
if coord.lastTarget != target {
|
||||
coord.load(target.url, into: webView)
|
||||
coord.lastTarget = target
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRefreshControl(
|
||||
webView: WKWebView,
|
||||
coordinator: WebShellCoordinator
|
||||
) -> UIRefreshControl {
|
||||
let refresh = UIRefreshControl()
|
||||
coordinator.attachRefresh(refresh, webView: webView)
|
||||
return refresh
|
||||
}
|
||||
}
|
||||
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
|
||||
private struct MacWebViewRepresentable: NSViewRepresentable {
|
||||
let target: WebTarget
|
||||
let navState: WebNavState
|
||||
let openURL: OpenURLAction
|
||||
|
||||
func makeCoordinator() -> WebShellCoordinator {
|
||||
WebShellCoordinator(navState: navState, openURL: openURL)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .default()
|
||||
config.applicationNameForUserAgent = "ZitareNative/0.1 (macOS)"
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.uiDelegate = context.coordinator
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
context.coordinator.observe(webView: webView)
|
||||
context.coordinator.load(target.url, into: webView)
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
let coord = context.coordinator
|
||||
if coord.lastTarget != target {
|
||||
coord.load(target.url, into: webView)
|
||||
coord.lastTarget = target
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue