zitare-native/Sources/App/RootView.swift
Till 75b5e7113f ζ-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>
2026-05-14 12:56:05 +02:00

126 lines
4.4 KiB
Swift

import ManaCore
import SwiftUI
/// Top-Level-View: TabView mit drei Tabs.
///
/// **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) {
WebShellView(target: readTarget)
.tabItem { Label("Lesen", systemImage: "book") }
.tag(AppTab.read)
WebShellView(target: exploreTarget)
.tabItem { Label("Erkunden", systemImage: "sparkle.magnifyingglass") }
.tag(AppTab.explore)
AccountView(healthStatus: healthStatus)
.tabItem { Label("Konto", systemImage: "person.circle") }
.tag(AppTab.account)
}
.task {
await probeHealth()
}
.onOpenURL { url in
handle(url: url)
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) }
}
}
private func probeHealth() async {
let api = ZitareAPI(auth: auth)
do {
let ok = try await api.healthCheck()
healthStatus = ok ? .ok : .down
Log.api.info("Healthz: \(ok ? "OK" : "DOWN")")
} catch {
healthStatus = .down
Log.api.warning(
"Healthz fehlgeschlagen: \(String(describing: error), privacy: .public)"
)
}
}
/// 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)")
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
}
}
/// `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
}
}
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 + "/") }
}
}
enum AppTab: Hashable {
case read
case explore
case account
}
enum HealthStatus {
case unknown
case ok
case down
}