mana-swift-ui/Sources/ManaWebShell/WebShellCoordinator.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

147 lines
5 KiB
Swift

import Foundation
import OSLog
import SwiftUI
import WebKit
#if canImport(UIKit)
import UIKit
#endif
private let log = Logger(subsystem: "ev.mana.webshell", category: "web")
/// `WKNavigationDelegate` + `WKUIDelegate` für ``WebShellView``. Hält
/// den reactive ``WebNavState`` aktuell, lenkt externe Links in den
/// System-Browser und treibt Pull-to-Refresh an.
///
/// Lebt auf `MainActor` (Closures von WKWebView liefern auf Main).
/// KVO-Observations werden bei `deinit` entfernt.
@MainActor
public final class WebShellCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
let navState: WebNavState
let openURL: OpenURLAction
let config: WebShellConfig
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, config: WebShellConfig) {
self.navState = navState
self.openURL = openURL
self.config = config
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.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
public func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction
) async -> WKNavigationActionPolicy {
guard let url = navigationAction.request.url else { return .allow }
if let host = url.host, config.isAllowed(host: host) {
return .allow
}
if url.scheme == "http" || url.scheme == "https" {
log.info("WebShell → openURL extern: \(url.absoluteString, privacy: .public)")
openURL(url)
return .cancel
}
return .cancel
}
public func webView(_: WKWebView, didFail _: WKNavigation, withError error: Error) {
log.warning("didFail: \(String(describing: error), privacy: .public)")
navState.lastError = (error as NSError).localizedDescription
}
public func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation, withError error: Error) {
log.warning("didFailProvisional: \(String(describing: error), privacy: .public)")
navState.lastError = (error as NSError).localizedDescription
}
public func webView(_: WKWebView, didFinish _: WKNavigation) {
navState.lastError = nil
}
// MARK: - WKUIDelegate
public func webView(
_ webView: WKWebView,
createWebViewWith _: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures _: WKWindowFeatures
) -> WKWebView? {
// `target=_blank`-Links: kein neues Fenster, im aktuellen WebView
// laden bzw. extern öffnen.
if let url = navigationAction.request.url {
if let host = url.host, config.isAllowed(host: host) {
webView.load(navigationAction.request)
} else {
openURL(url)
}
}
return nil
}
}