mana-swift-ui/Sources/ManaWebShell/WebShellView.swift
Till JS 8f4d4b0c03 feat(webshell): neues Library-Product ManaWebShell (v0.6.0)
WKWebView-Huelle fuer Hybrid-Apps (Web-Lese-Surfaces + native
Submit/Widget/ShareExt). Extrahiert aus den fast-byte-identischen
`WebShell/`-Ordnern in seepuls-native und zitare-native (~900 LOC,
davon ~700 LOC Duplikat). Audit 2026-05-17 V2.

Neu (public API):
- `WebShellView` — WKWebView-Wrapper mit Progress-Bar, Pull-to-
  Refresh (iOS), Fehler-Snackbar, External-Link-Delegation. Universal
  (iOS + macOS)
- `WebShellConfig` — Host-Whitelist mit Wildcard-Support (`"*.mana.how"`),
  User-Agent, Theme-Hints, User-Scripts
- `WebTarget` — URL + monoton wachsender reloadToken
- `WebNavState` — @Observable, @MainActor, reaktiver Nav-State
- `WebShellCoordinator` — WKNavigationDelegate + WKUIDelegate
- `WebShellScripts` — Helfer fuer `preferDarkScheme`,
  `syncDarkMode(localStorageKey:)`, `hideElements(selectors:tagName:)`

Logging unter Subsystem `ev.mana.webshell` (App-OSLog bleibt eigen).

Tests: 6 neue Tests gegen `WebShellConfig.isAllowed` (Wildcards,
Negativ-Cases). 50/50 grün insgesamt (6 ManaWebShell + 44 ManaAuthUI).

Doku: `mana/docs/playbooks/HYBRID_NATIVE_APP.md` (Schwester-Repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:11:47 +02:00

183 lines
6.4 KiB
Swift

import SwiftUI
import WebKit
/// SwiftUI-Hülle um `WKWebView`. Eine Instanz gehört üblicherweise zu
/// einem Tab/Screen und behält ihren Web-State (Scroll-Position,
/// Browser-History) während die View lebt.
///
/// **Verhalten:**
/// - Lädt `target` beim ersten Auftauchen.
/// - Wechselt `target` während die View lebt lädt neue URL (oder
/// reloaded, wenn `target.reloadToken` sich erhöht).
/// - Pull-to-Refresh über `UIRefreshControl` (iOS / iPadOS).
/// - Links auf nicht-gelisteten Hosts (siehe ``WebShellConfig/allowedHosts``)
/// und `target=_blank` öffnen im System-Browser via
/// `OpenURLAction`, nicht im WebView.
/// - Cookies werden über `WKWebsiteDataStore.default()` geteilt.
///
/// **Theme-Hint:**
/// `config.backgroundColor` ist der Hintergrund hinter dem WKWebView
/// verhindert weißen Flash bis zum first paint. Apps mit Dark-Theme
/// setzen das auf ihren Theme-Background.
public struct WebShellView: View {
let target: WebTarget
let config: WebShellConfig
@State private var navState = WebNavState()
@Environment(\.openURL) private var openURL
public init(target: WebTarget, config: WebShellConfig) {
self.target = target
self.config = config
}
public var body: some View {
VStack(spacing: 0) {
if navState.isLoading {
ProgressView(value: navState.estimatedProgress)
.progressViewStyle(.linear)
.tint(config.progressTint)
.frame(height: 2)
}
#if canImport(UIKit)
WebViewRepresentable(
target: target,
navState: navState,
openURL: openURL,
config: config
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(config.backgroundColor)
#elseif canImport(AppKit)
MacWebViewRepresentable(
target: target,
navState: navState,
openURL: openURL,
config: config
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(config.backgroundColor)
#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(config.errorIconColor)
Text(message)
.font(.caption)
.lineLimit(2)
.foregroundStyle(config.errorForegroundColor)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(config.errorBackgroundColor)
}
}
#if canImport(UIKit)
import UIKit
private struct WebViewRepresentable: UIViewRepresentable {
let target: WebTarget
let navState: WebNavState
let openURL: OpenURLAction
let config: WebShellConfig
func makeCoordinator() -> WebShellCoordinator {
WebShellCoordinator(navState: navState, openURL: openURL, config: config)
}
func makeUIView(context: Context) -> WKWebView {
let wkConfig = WKWebViewConfiguration()
wkConfig.websiteDataStore = .default()
wkConfig.applicationNameForUserAgent = config.userAgent
for script in config.userScripts {
wkConfig.userContentController.addUserScript(script)
}
let webView = WKWebView(frame: .zero, configuration: wkConfig)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
// Ohne diese drei flackert WKWebView bis zum first paint weiß
// gegen das App-Theme egal was der SwiftUI-Container als
// Background setzt.
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.backgroundColor = .clear
webView.scrollView.refreshControl = makeRefreshControl(
webView: webView,
coordinator: context.coordinator
)
context.coordinator.observe(webView: webView)
context.coordinator.load(target.url, into: webView)
context.coordinator.lastTarget = target
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
let config: WebShellConfig
func makeCoordinator() -> WebShellCoordinator {
WebShellCoordinator(navState: navState, openURL: openURL, config: config)
}
func makeNSView(context: Context) -> WKWebView {
let wkConfig = WKWebViewConfiguration()
wkConfig.websiteDataStore = .default()
wkConfig.applicationNameForUserAgent = config.userAgent
for script in config.userScripts {
wkConfig.userContentController.addUserScript(script)
}
let webView = WKWebView(frame: .zero, configuration: wkConfig)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
// macOS-Pendant zu UIView.isOpaque=false sonst weißer Flash
// vor first paint.
webView.setValue(false, forKey: "drawsBackground")
context.coordinator.observe(webView: webView)
context.coordinator.load(target.url, into: webView)
context.coordinator.lastTarget = target
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