ζ-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 + "/") }
}
}