Symptom: in TestFlight-Build wirkte Account-Tab dunkel, aber Lesen + Erkunden hell (oder umgekehrt, je nachdem was im Web- localStorage stand). Inkonsistent, weil: - AccountView (SwiftUI) nutzt ZitareTheme.dynamic() — folgt System - WebView las localStorage['zitare-mode'], das nur über den Theme-Toggle-Button im Web-Header gesetzt wurde — den wir aber nativ ausgeblendet haben → kein User-Steuerpfad Fix: neuer User-Script `syncDarkMode` injiziert at document.start: - liest prefers-color-scheme via matchMedia - schreibt localStorage['zitare-mode'] = 'dark' / removes - togglet die `.dark`-Class auf <html> - bleibt aktiv via matchMedia-change-Listener für Live-Switches Reihenfolge in WebView-Config: syncDarkMode VOR hideWebHeader, damit das Theme richtig ist bevor Header-CSS rendert. Build 3 für nächsten TestFlight-Upload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
6.4 KiB
Swift
178 lines
6.4 KiB
Swift
import SwiftUI
|
|
import WebKit
|
|
|
|
/// 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.
|
|
///
|
|
/// **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 target: WebTarget
|
|
|
|
@State private var navState = WebNavState()
|
|
@Environment(\.openURL) private var openURL
|
|
|
|
var body: some View {
|
|
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
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(ZitareTheme.background)
|
|
#elseif canImport(AppKit)
|
|
MacWebViewRepresentable(
|
|
target: target,
|
|
navState: navState,
|
|
openURL: openURL
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.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)"
|
|
// Reihenfolge: erst Theme syncen, dann Header verstecken.
|
|
config.userContentController.addUserScript(WebShellScripts.syncDarkMode)
|
|
config.userContentController.addUserScript(WebShellScripts.hideWebHeader)
|
|
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)"
|
|
// Reihenfolge: erst Theme syncen, dann Header verstecken.
|
|
config.userContentController.addUserScript(WebShellScripts.syncDarkMode)
|
|
config.userContentController.addUserScript(WebShellScripts.hideWebHeader)
|
|
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
|