η-0: De-Hybrid — WKWebView raus, native Tabs mit Platzhaltern

Lift von Hybrid (WKWebView für Lesen/Erkunden) auf fully-native ist
beschlossen. Diese Phase entfernt die WebShell-Infrastruktur; das volle
native Read-Surface folgt in η-2..η-5 nach docs/NATIVE_LIFT_PLAN.md.

- ManaWebShell-Dep raus aus project.yml
- Sources/Core/WebShell/CookieBridge.swift gelöscht
- RootView auf vier native Tabs (Lesen + Erkunden = Platzhalter,
  Submit + Konto unverändert nativ)
- DocComments in DeepLinkRouter / AppConfig / Account / Settings von
  WebView-Verweisen befreit
- CLAUDE.md Invarianten von Hybrid auf η umgestellt (13 Invarianten,
  pure SwiftUI + Offline-first + SafariView-Ausnahme für Legal)
- PLAN.md auf η-0 + Phasenübersicht η-0..η-10
- AppConfigTests.test_keychainService_matchesSharedGroup auf
  ManaSharedKeychainGroup aktualisiert (war drift seit Cross-App-SSO)

Verifikation:
- xcodebuild iOS-Simulator iPhone 16e: BUILD SUCCEEDED
- nm ZitareNative | grep WKWebView: 0 Referenzen
- otool -L: kein WebKit-Framework-Link
- 20/20 Tests grün

Cross-Repo-Follow-up (η-1 Blocker):
- zitare/apps/zitare/ muss index-full.json + 7 Stammdaten-JSONs liefern
- zitare/apps/api/ Volltext-Search-Endpoint bestätigen/ergänzen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-22 12:32:05 +02:00
parent 58eb2807c7
commit 1d770123f5
11 changed files with 619 additions and 365 deletions

View file

@ -1,11 +1,16 @@
import Foundation
/// Routet sowohl Custom-Scheme- (`zitare://`) als auch Universal-Link-URLs
/// (`zitare.com/...`) auf eine konkrete `WebTarget` + Ziel-Tab.
/// (`zitare.com/...`) auf eine normalisierte URL + Ziel-Tab.
///
/// **η-0 (de-Hybrid):** Die URL ist nicht mehr WebView-Target, sondern
/// Eingabe für das native `NavigationPath`-Routing in η-2. Path-Struktur
/// (`/q/<slug>`, `/a/<slug>`, `/c/<slug>`, `/thema/<slug>` etc.) bleibt
/// 1:1 wie im Web-Repo die Native-Views matchen die gleichen Slugs.
///
/// Pure-Logic, kein State easy testbar.
enum DeepLinkRouter {
/// Mapt eine externe URL auf eine WebShell-URL.
/// Normalisiert eine externe URL auf den kanonischen `https://`-Pfad.
/// `zitare://quote/x` `https://zitare.com/q/x`,
/// `zitare://author/x` `https://zitare.com/a/x`,
/// `zitare://collection/x` `https://zitare.com/c/x`.

View file

@ -1,67 +1,29 @@
import ManaAuthUI
import ManaCore
import ManaWebShell
import SwiftUI
@MainActor
private func makeWebShellConfig() -> WebShellConfig {
WebShellConfig(
allowedHosts: [
"zitare.com",
"www.zitare.com",
"*.mana.how",
],
userAgent: AppConfig.userAgent,
backgroundColor: ZitareTheme.background,
progressTint: ZitareTheme.primary,
errorBackgroundColor: ZitareTheme.muted,
errorForegroundColor: ZitareTheme.foreground,
errorIconColor: ZitareTheme.warning,
userScripts: [
// Syncs System-Dark-Mode in den WebView; zitare-web liest
// `localStorage['zitare-mode']` beim First Paint und toggelt
// dann `.dark` auf <html>.
WebShellScripts.syncDarkMode(localStorageKey: "zitare-mode"),
// Versteckt den zitare-web-Header (Brand-Logo + Nav), weil
// die native TabBar bereits global navigiert.
WebShellScripts.hideElements(
selectors: [
"header[data-app-nav]",
"body header:has(a.brand)",
"body > header:first-of-type",
"body > div > header:first-of-type",
],
tagName: "hide-web-header"
),
]
)
}
/// Top-Level-View: TabView mit drei Tabs.
/// Top-Level-View: TabView mit vier nativen 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.
/// **η-0 (de-Hybrid 2026-05-22):** `WebShellView` ist raus. Lesen +
/// Erkunden zeigen jetzt Platzhalter-Views; das volle native Read-
/// Surface kommt in η-2 (Heute / Quote-Detail / Author-Detail) bzw.
/// η-4 (Explore + Filter). Universal-Links werden hier vorerst nur
/// geloggt und in den Lesen-Tab gepinnt, bis `NavigationStack`-Routing
/// in η-2 steht. Submit + Konto sind schon nativ.
struct RootView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@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 pendingDeepLink: URL?
@State private var healthStatus: HealthStatus = .unknown
private var webShellConfig: WebShellConfig { makeWebShellConfig() }
var body: some View {
TabView(selection: $selectedTab) {
WebShellView(target: readTarget, config: webShellConfig)
ReadPlaceholderView(pendingDeepLink: pendingDeepLink)
.tabItem { Label("Lesen", systemImage: "book") }
.tag(AppTab.read)
WebShellView(target: exploreTarget, config: webShellConfig)
ExplorePlaceholderView()
.tabItem { Label("Erkunden", systemImage: "sparkle.magnifyingglass") }
.tag(AppTab.explore)
@ -73,35 +35,31 @@ struct RootView: View {
.tabItem { Label("Konto", systemImage: "person.circle") }
.tag(AppTab.account)
}
// Mac-Window-Hintergrund auf Paper-Theme setzen, damit der
// TabBar-/Title-Bar-Bereich oben nicht mit dem System-Grau
// gegen das Paper-Theme ausreißt. `windowToolbar`-Placement
// ist macOS-only.
.background(ZitareTheme.background.ignoresSafeArea())
#if os(macOS)
.toolbarBackground(ZitareTheme.background, for: .windowToolbar)
.toolbarBackground(.visible, for: .windowToolbar)
#endif
.manaBrand(ZitareBrand.manaBrand)
.manaAuthGate(authGate) {
NavigationStack {
ManaLoginView(
auth: auth,
onSignUpTapped: {},
onForgotTapped: {}
)
.manaBrand(ZitareBrand.manaBrand)
.manaBrand(ZitareBrand.manaBrand)
.manaAuthGate(authGate) {
NavigationStack {
ManaLoginView(
auth: auth,
onSignUpTapped: {},
onForgotTapped: {}
)
.manaBrand(ZitareBrand.manaBrand)
}
}
.task {
await probeHealth()
}
.onOpenURL { url in
handle(url: url)
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { handle(url: url) }
}
}
.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 {
@ -118,31 +76,18 @@ struct RootView: View {
}
}
/// Universal-Link- und Custom-URL-Routing. Wird sowohl von
/// `onOpenURL` (Custom-Scheme `zitare://...`) als auch von
/// `onContinueUserActivity` (Universal-Links auf `zitare.com/...`)
/// aufgerufen.
/// Deep-Link- + Universal-Link-Routing.
///
/// 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.
/// η-0: Routing-Logik (URL-Normalisierung + Tab-Auswahl) bleibt
/// erhalten, das eigentliche Ansteuern einer Quote/Author/Source-
/// Detail-View kommt in η-2 mit `NavigationPath`. Solange parken
/// wir die URL in `pendingDeepLink`, die Placeholder-View zeigt
/// sie zur Diagnose an.
private func handle(url: URL) {
Log.app.info("Deep-Link empfangen: \(url.absoluteString, privacy: .public)")
let routed = DeepLinkRouter.route(url, base: AppConfig.webBaseURL)
reloadCounter += 1
if routed.isExplore {
exploreTarget = WebTarget(url: routed.url, reloadToken: reloadCounter)
selectedTab = .explore
} else {
readTarget = WebTarget(url: routed.url, reloadToken: reloadCounter)
selectedTab = .read
}
pendingDeepLink = routed.url
selectedTab = routed.isExplore ? .explore : .read
}
}
@ -158,3 +103,55 @@ enum HealthStatus {
case ok
case down
}
/// η-0 Platzhalter wird in η-2 durch HeuteView + QuoteFeedView ersetzt.
private struct ReadPlaceholderView: View {
let pendingDeepLink: URL?
var body: some View {
VStack(spacing: 16) {
Image(systemName: "book")
.font(.system(size: 56))
.foregroundStyle(ZitareTheme.mutedForeground)
Text("Lesen")
.font(.title2)
.fontWeight(.semibold)
Text("Native Read-Surface kommt in η-2 (Heute, Quote-Detail, Author-Detail).")
.font(.callout)
.foregroundStyle(ZitareTheme.mutedForeground)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
if let url = pendingDeepLink {
Text("Deep-Link wartet auf η-2:\n\(url.absoluteString)")
.font(.caption.monospaced())
.foregroundStyle(ZitareTheme.mutedForeground)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.top, 8)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ZitareTheme.background)
}
}
/// η-0 Platzhalter wird in η-4 durch ExploreView (Facet-Filter) ersetzt.
private struct ExplorePlaceholderView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "sparkle.magnifyingglass")
.font(.system(size: 56))
.foregroundStyle(ZitareTheme.mutedForeground)
Text("Erkunden")
.font(.title2)
.fontWeight(.semibold)
Text("Filter (Sprache, Länge, Thema, Region, Epoche, Rolle) + lokale Suche kommen in η-4 und η-5.")
.font(.callout)
.foregroundStyle(ZitareTheme.mutedForeground)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ZitareTheme.background)
}
}