- 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>
126 lines
4.4 KiB
Swift
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
|
|
}
|