diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index a3678f1..5e94371 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -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/` / `/a/` 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/`, `/a/`, `/c/` → 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/` wird auf + /// `https://zitare.com/q/` umgemappt. private func handle(url: URL) { Log.app.info("Deep-Link empfangen: \(url.absoluteString, privacy: .public)") - // ζ-1 TODO: parse zitare.com/q/, /a/, /c/ - // 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/` 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 + "/") } } } diff --git a/Sources/Core/Auth/AppConfig.swift b/Sources/Core/Auth/AppConfig.swift index 3bd6828..2118a7c 100644 --- a/Sources/Core/Auth/AppConfig.swift +++ b/Sources/Core/Auth/AppConfig.swift @@ -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" } diff --git a/Sources/Features/WebShell/CookieBridge.swift b/Sources/Features/WebShell/CookieBridge.swift new file mode 100644 index 0000000..380ea4e --- /dev/null +++ b/Sources/Features/WebShell/CookieBridge.swift @@ -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 + ]) + } +} diff --git a/Sources/Features/WebShell/WebShellCoordinator.swift b/Sources/Features/WebShell/WebShellCoordinator.swift new file mode 100644 index 0000000..48b51d7 --- /dev/null +++ b/Sources/Features/WebShell/WebShellCoordinator.swift @@ -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" + } +} diff --git a/Sources/Features/WebShell/WebShellView.swift b/Sources/Features/WebShell/WebShellView.swift index dc24664..5669534 100644 --- a/Sources/Features/WebShell/WebShellView.swift +++ b/Sources/Features/WebShell/WebShellView.swift @@ -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