zitare-native/Sources/Features/WebShell/WebShellView.swift
Till 2616c4f440 fix(dark-mode): WebView folgt System statt eigener localStorage-Toggle
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>
2026-05-14 21:45:25 +02:00

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