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>
147 lines
5 KiB
Swift
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
|
|
}
|
|
}
|