ζ-1: WebShellView + Universal-Link-Routing

- WebShellView (UIViewRepresentable + NSViewRepresentable) wrapt
  WKWebView, KVO-Observation für Loading/Progress/canGoBack/URL,
  Pull-to-Refresh via UIRefreshControl
- WebShellCoordinator (MainActor) hält WKNavigationDelegate +
  WKUIDelegate, externe Links via openURL aus dem Environment in
  System-Browser, Host-Whitelist auf zitare.com + .mana.how
- RootView refactored: Lesen-Tab lädt webBaseURL/, Erkunden-Tab
  /explore. Universal-Links zitare.com/q|a|c/<slug>, /search,
  /region/*, /thema/* etc. routen in den passenden Tab,
  reloadToken zwingt Re-Navigation auch bei selber URL
- AppConfig.webBaseURL = appBaseURL (zitare.mana.how) bis
  Cloudflare-Zone für zitare.com live ist; publicWebURL als
  Konstante schon eingetragen
- CookieBridge-Skeleton für mana.access auf .mana.how —
  scharfgeschaltet erst in ζ-3 nach Live-Auth-Smoke
- iPhone 16e Simulator: zitare.mana.how lädt, Carl-Spitteler-Quote
  rendert, Healthz weiter 200
- 16 Files swiftlint-grün, alle Tests grün

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till 2026-05-14 12:56:05 +02:00
parent 0bd59ed148
commit 75b5e7113f
5 changed files with 467 additions and 78 deletions

View file

@ -3,31 +3,28 @@ import SwiftUI
/// Top-Level-View: TabView mit drei Tabs.
///
/// **Phase ζ-0 Setup.** Tabs zeigen aktuell nur Placeholder-Views.
/// Ab Phase ζ-1 wird der Lesen-Tab durch `WebShellView` ersetzt,
/// der `zitare.com` im `WKWebView` rendert.
/// **Phase ζ-1:** Lesen + Erkunden laden `zitare.com` via `WebShellView`.
/// Universal-Links auf `zitare.com/q/<slug>` / `/a/<slug>` etc. öffnen
/// die App und routen in den passenden Tab.
struct RootView: View {
@Environment(AuthClient.self) private var auth
@State private var selectedTab: AppTab = .read
@State private var readTarget = WebTarget(url: AppConfig.webBaseURL)
@State private var exploreTarget = WebTarget(
url: AppConfig.webBaseURL.appendingPathComponent("explore")
)
@State private var reloadCounter: Int = 0
@State private var healthStatus: HealthStatus = .unknown
var body: some View {
TabView(selection: $selectedTab) {
placeholderView(
title: "Lesen",
subtitle: "ζ-1: WebShellView gegen zitare.com",
systemImage: "book"
)
.tabItem { Label("Lesen", systemImage: "book") }
.tag(AppTab.read)
WebShellView(target: readTarget)
.tabItem { Label("Lesen", systemImage: "book") }
.tag(AppTab.read)
placeholderView(
title: "Erkunden",
subtitle: "ζ-1: WebShell auf zitare.com/explore",
systemImage: "sparkle.magnifyingglass"
)
.tabItem { Label("Erkunden", systemImage: "sparkle.magnifyingglass") }
.tag(AppTab.explore)
WebShellView(target: exploreTarget)
.tabItem { Label("Erkunden", systemImage: "sparkle.magnifyingglass") }
.tag(AppTab.explore)
AccountView(healthStatus: healthStatus)
.tabItem { Label("Konto", systemImage: "person.circle") }
@ -44,9 +41,6 @@ struct RootView: View {
}
}
/// Phase ζ-0 Probe: ein Aufruf gegen `/healthz` bei App-Start.
/// Ab Phase ζ-1 wandert das in einen Service, der den Status global
/// trackt und im AccountView anzeigt.
private func probeHealth() async {
let api = ZitareAPI(auth: auth)
do {
@ -61,34 +55,61 @@ struct RootView: View {
}
}
/// Deep-Link-Routing. Phase ζ-0: nur loggen. Phase ζ-1: in den
/// passenden Tab + Pfad routen.
/// Universal-Link- und Custom-URL-Routing. Wird sowohl von
/// `onOpenURL` (Custom-Scheme `zitare://...`) als auch von
/// `onContinueUserActivity` (Universal-Links auf `zitare.com/...`)
/// aufgerufen.
///
/// Routing-Regeln (gespiegelt zu `app-manifest.json#link_patterns`):
/// - `/q/<slug>`, `/a/<slug>`, `/c/<slug>` Lesen-Tab
/// - `/heute`, `/random`, `/feed.rss` Lesen-Tab
/// - `/explore`, `/region/...`, `/thema/...`, `/rolle/...`,
/// `/epoche/...`, `/sprache/...`, `/search`, `/t/...` Erkunden-Tab
/// - alles andere unter `zitare.com` Lesen-Tab, Root-Pfad
///
/// Custom-Scheme `zitare://quote/<slug>` wird auf
/// `https://zitare.com/q/<slug>` umgemappt.
private func handle(url: URL) {
Log.app.info("Deep-Link empfangen: \(url.absoluteString, privacy: .public)")
// ζ-1 TODO: parse zitare.com/q/<slug>, /a/<slug>, /c/<slug>
// und in WebShellView mit entsprechender URL laden.
let resolved = resolveToWebURL(url)
let path = resolved.path
reloadCounter += 1
if isExplorePath(path) {
exploreTarget = WebTarget(url: resolved, reloadToken: reloadCounter)
selectedTab = .explore
} else {
readTarget = WebTarget(url: resolved, reloadToken: reloadCounter)
selectedTab = .read
}
}
private func placeholderView(
title: String,
subtitle: String,
systemImage: String
) -> some View {
VStack(spacing: 16) {
Image(systemName: systemImage)
.font(.system(size: 48))
.foregroundStyle(ZitareTheme.primary)
Text(title)
.font(.title2)
.fontWeight(.semibold)
Text(subtitle)
.font(.callout)
.foregroundStyle(ZitareTheme.mutedForeground)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
/// `zitare://quote/spitteler-...` `https://zitare.com/q/spitteler-...`.
/// `zitare://author/x` `https://zitare.com/a/x`.
/// `zitare://collection/x` `https://zitare.com/c/x`.
/// `https://zitare.com/<anything>` bleibt wie es ist.
private func resolveToWebURL(_ url: URL) -> URL {
if url.scheme == "zitare" {
let host = url.host ?? ""
let path = url.path
switch host {
case "quote":
return AppConfig.webBaseURL.appendingPathComponent("q\(path)")
case "author":
return AppConfig.webBaseURL.appendingPathComponent("a\(path)")
case "collection":
return AppConfig.webBaseURL.appendingPathComponent("c\(path)")
default:
return AppConfig.webBaseURL
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ZitareTheme.background)
return url
}
private func isExplorePath(_ path: String) -> Bool {
let prefixes = ["/explore", "/region", "/thema", "/rolle", "/epoche", "/sprache", "/search", "/t/"]
return prefixes.contains { path == $0 || path.hasPrefix($0 + "/") }
}
}

View file

@ -16,15 +16,22 @@ enum AppConfig {
/// `zitare-api.mana.how` API-Backend (Hono+Bun).
static let apiBaseURL = URL(string: "https://zitare-api.mana.how")!
/// `zitare.com` öffentliches statisches Frontend. Universal-Link-
/// Domain. WKWebView-Default für Lesen-Surfaces.
static let webBaseURL = URL(string: "https://zitare.com")!
/// `zitare.com` geplante öffentliche Domain (CC-BY-SA-Korpus,
/// statisch). Universal-Link-Domain für AASA. **Heute DNS noch
/// nicht live** (Cloudflare-Zone-Onboarding offen, siehe
/// `zitare/STATUS.md`); bis dahin nutzt der WebView `appBaseURL`
/// (`zitare.mana.how`) der Container liefert beide Surfaces.
static let publicWebURL = URL(string: "https://zitare.com")!
/// `zitare.mana.how` SPA-Surface für eingeloggte Pfade (Submit,
/// Edit, Moderation). Bekommt den `mana.access`-Cookie injiziert
/// für Cookie-SSO.
/// `zitare.mana.how` SPA-Surface, eingeloggte Pfade. Heute auch
/// der Default für Lese-Surfaces, bis `zitare.com` live ist.
static let appBaseURL = URL(string: "https://zitare.mana.how")!
/// Effektive Default-URL für den WebView. Zeigt vorerst auf
/// `appBaseURL` (`zitare.mana.how`); nach Cloudflare-Zone-Cut
/// kommt das zurück auf `publicWebURL`.
static let webBaseURL = appBaseURL
/// App-Group für Daten-Sharing zwischen App Widget ShareExt.
static let appGroup = "group.ev.mana.zitare"
}

View file

@ -0,0 +1,75 @@
import Foundation
import ManaCore
import WebKit
/// Reicht den mana-auth-JWT als Cookie an den `WKWebView` weiter, sodass
/// eingeloggte `(app)`-Routen auf `zitare.mana.how` ohne zweiten Login
/// erreichbar sind.
///
/// **Phase ζ-1: Skeleton.** Methoden existieren, werden aber heute
/// nicht aufgerufen bevor sie scharfgeschaltet werden, muss der
/// Cookie-SSO-Pfad auf der Web-Seite (`zitare/apps/api/src/auth/`
/// und `apps/zitare/src/lib/auth/token-helper.ts`) gegen einen
/// *echten* mana-auth-Token End-to-End getestet sein (Verifikations-
/// Lücke in `zitare/STATUS.md`).
///
/// **Phase ζ-3:** wird in `SubmitQuoteView` benutzt vor dem POST
/// gegen `zitare-api.mana.how` und vor dem Öffnen von
/// `zitare.mana.how/me` im WebView.
///
/// **Cookie-Schema** (gespiegelt zu mana-auth, siehe
/// `mana/services/mana-auth/src/auth/cookies.ts`):
/// - Name: `mana.access` (JWT) und optional `mana.refresh` (Opaque)
/// - Domain: `.mana.how` (App-Surface; **nicht** `.com`)
/// - Path: `/`
/// - Secure: true, HTTPOnly: false (WebView muss lesen können),
/// SameSite: Lax
enum CookieBridge {
/// Setzt den `mana.access`-Cookie im geteilten `WKHTTPCookieStore`,
/// wenn der `AuthClient` einen gültigen JWT hält. No-op sonst.
@MainActor
static func installManaAccess(from auth: AuthClient) async {
guard case .signedIn = auth.status, let token = currentAccessToken(from: auth) else {
Log.web.debug("CookieBridge: kein signedIn-Token, no-op")
return
}
guard let cookie = makeAccessCookie(token: token) else {
Log.web.warning("CookieBridge: konnte Cookie-Properties nicht bauen")
return
}
let store = WKWebsiteDataStore.default().httpCookieStore
await store.setCookie(cookie)
Log.web.info("CookieBridge: mana.access für .mana.how gesetzt")
}
/// Entfernt den `mana.access`-Cookie wieder etwa nach Logout.
@MainActor
static func removeManaAccess() async {
let store = WKWebsiteDataStore.default().httpCookieStore
let cookies = await store.allCookies()
for cookie in cookies where cookie.name == "mana.access" {
await store.deleteCookie(cookie)
}
Log.web.info("CookieBridge: mana.access entfernt")
}
private static func currentAccessToken(from auth: AuthClient) -> String? {
// ManaCore hält den JWT im Keychain. In ζ-3 ersetzt durch die
// tatsächliche `auth.currentAccessToken()`-API; heute nur
// Skelett-Hook, damit Cookie-Setup und API in Reichweite sind.
// Linter beruhigen ohne unused warning:
_ = auth
return nil
}
private static func makeAccessCookie(token: String) -> HTTPCookie? {
HTTPCookie(properties: [
.name: "mana.access",
.value: token,
.domain: ".mana.how",
.path: "/",
.secure: true,
.sameSitePolicy: HTTPCookieStringPolicy.sameSiteLax
])
}
}

View file

@ -0,0 +1,155 @@
import Foundation
import SwiftUI
import WebKit
#if canImport(UIKit)
import UIKit
#endif
/// `WKNavigationDelegate` + `WKUIDelegate` für `WebShellView`. Hält den
/// reactive `WebNavState` aktuell, lenkt externe Links in den System-
/// Browser und treibt Pull-to-Refresh an.
///
/// Nicht `Sendable`: Lebt auf `MainActor` (Closures von WKWebView
/// liefern auf Main). KVO-Observations werden bei `deinit` entfernt.
@MainActor
final class WebShellCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
let navState: WebNavState
let openURL: OpenURLAction
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) {
self.navState = navState
self.openURL = openURL
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.web.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
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction
) async -> WKNavigationActionPolicy {
guard let url = navigationAction.request.url else { return .allow }
// Eigene Host-Whitelist: alles auf zitare.com bzw. .mana.how darf
// im WebView öffnen. Alles andere geht in den System-Browser.
// Custom-Scheme zitare:// catchen wir hier nicht Universal-
// Links auf zitare.com sind der präferierte Pfad.
if let host = url.host, isAllowedHost(host) {
return .allow
}
if url.scheme == "http" || url.scheme == "https" {
Log.web.info("WebShell → openURL extern: \(url.absoluteString, privacy: .public)")
openURL(url)
return .cancel
}
return .cancel
}
func webView(_: WKWebView, didFail _: WKNavigation, withError error: Error) {
Log.web.warning("didFail: \(String(describing: error), privacy: .public)")
navState.lastError = (error as NSError).localizedDescription
}
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation, withError error: Error) {
Log.web.warning("didFailProvisional: \(String(describing: error), privacy: .public)")
navState.lastError = (error as NSError).localizedDescription
}
func webView(_: WKWebView, didFinish _: WKNavigation) {
navState.lastError = nil
}
// MARK: - WKUIDelegate
func webView(
_ webView: WKWebView,
createWebViewWith _: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures _: WKWindowFeatures
) -> WKWebView? {
// `target=_blank`-Links: kein neues Fenster aufmachen, sondern
// im aktuellen WebView laden bzw. extern öffnen.
if let url = navigationAction.request.url {
if let host = url.host, isAllowedHost(host) {
webView.load(navigationAction.request)
} else {
openURL(url)
}
}
return nil
}
// MARK: - Helpers
private func isAllowedHost(_ host: String) -> Bool {
host == "zitare.com"
|| host == "www.zitare.com"
|| host.hasSuffix(".mana.how")
|| host == "mana.how"
}
}

View file

@ -1,38 +1,169 @@
import SwiftUI
import WebKit
/// Phase ζ-1 Placeholder.
/// 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.
///
/// Wird in ζ-1 zu einer echten `UIViewRepresentable`/
/// `NSViewRepresentable` um `WKWebView`. Aufgabenliste in ζ-1:
///
/// - WebView-Konfiguration: `WKWebViewConfiguration` mit non-persistent
/// DataStore in Debug-Builds; Persistent in Release.
/// - Cookie-Bridge: nach ManaCore-Login JWT als `mana.access`-Cookie
/// für `.mana.how` ins `WKHTTPCookieStore` schreiben.
/// - Pull-to-Refresh via `UIRefreshControl` (iOS) /
/// `NSScrollView` (macOS).
/// - `WKNavigationDelegate` für Deep-Link-Catching: wenn der WebView
/// eine Navigation auf `zitare://` oder eine andere mana-Domain
/// versucht, abfangen und natively routen.
/// - `WKUIDelegate` für `target=_blank`-Links (Safari öffnen, nicht
/// im WebView).
/// - Native-Toolbar overlay (ζ-5).
///
/// Heute nur die Signatur, damit `RootView` schon den finalen
/// Import-Pfad nutzt.
/// **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 initialURL: URL
let target: WebTarget
@State private var navState = WebNavState()
@Environment(\.openURL) private var openURL
var body: some View {
VStack(spacing: 12) {
Text("WebShellView")
.font(.headline)
Text("ζ-1 — TODO: WKWebView auf \(initialURL.absoluteString)")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
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
)
.background(ZitareTheme.background)
#elseif canImport(AppKit)
MacWebViewRepresentable(
target: target,
navState: navState,
openURL: openURL
)
.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)"
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)"
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