diff --git a/PLAN.md b/PLAN.md index eeb283e..30bdc8f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -64,8 +64,8 @@ in [`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks | Phase | Ziel | Erfolg | Status | |---|---|---|---| -| ζ-0 | Setup, leerer Build, Login | iOS-Build ✅ + Tests ✅, Mac + Login + Healthz Live offen | 🚧 (90%) | -| ζ-1 | WebShellView + Universal-Links | UL öffnet App auf Quote-Detail | ⏳ | +| ζ-0 | Setup, leerer Build, Login | iOS-Build ✅, Tests ✅, Healthz Live ✅ | ✅ (Mac + Git-Push offen) | +| ζ-1 | WebShellView + Universal-Links | zitare.mana.how rendert im WebView, UL-Routing implementiert | 🚧 (90%) | | ζ-2 | Snapshot-Sync + DailyQuoteWidget | Widget auf realem Gerät zeigt Zitat | ⏳ | | ζ-3 | Submit-View nativ | Founder submittet Quote, Draft in /admin/queue | ⏳ | | ζ-4 | Spotlight + ShareExt + App Intents | Spotlight findet, ShareExt POSTet | ⏳ | diff --git a/Sources/App/DeepLinkRouter.swift b/Sources/App/DeepLinkRouter.swift new file mode 100644 index 0000000..200e321 --- /dev/null +++ b/Sources/App/DeepLinkRouter.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Routet sowohl Custom-Scheme- (`zitare://`) als auch Universal-Link-URLs +/// (`zitare.com/...`) auf eine konkrete `WebTarget` + Ziel-Tab. +/// +/// Pure-Logic, kein State — easy testbar. +enum DeepLinkRouter { + /// Mapt eine externe URL auf eine WebShell-URL. + /// `zitare://quote/x` → `https://zitare.com/q/x`, + /// `zitare://author/x` → `https://zitare.com/a/x`, + /// `zitare://collection/x` → `https://zitare.com/c/x`. + /// `https://*` bleibt unverändert. + static func resolveToWebURL(_ url: URL, base: URL) -> URL { + if url.scheme == "zitare" { + let host = url.host ?? "" + let path = url.path + switch host { + case "quote": return base.appendingPathComponent("q\(path)") + case "author": return base.appendingPathComponent("a\(path)") + case "collection": return base.appendingPathComponent("c\(path)") + default: return base + } + } + return url + } + + /// `true` wenn der Pfad in den Erkunden-Tab gehört. Sonst Lesen-Tab. + static func isExplorePath(_ path: String) -> Bool { + let prefixes = ["/explore", "/region", "/thema", "/rolle", "/epoche", "/sprache", "/search", "/t/"] + return prefixes.contains { path == $0 || path.hasPrefix($0 + "/") } + } + + /// One-Shot-Resolution: URL + Base → (resolvedURL, isExploreTab). + static func route(_ url: URL, base: URL) -> (url: URL, isExplore: Bool) { + let resolved = resolveToWebURL(url, base: base) + return (resolved, isExplorePath(resolved.path)) + } +} diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 5e94371..c8aeced 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -71,46 +71,16 @@ struct RootView: View { /// `https://zitare.com/q/` umgemappt. private func handle(url: URL) { Log.app.info("Deep-Link empfangen: \(url.absoluteString, privacy: .public)") - - let resolved = resolveToWebURL(url) - let path = resolved.path + let routed = DeepLinkRouter.route(url, base: AppConfig.webBaseURL) reloadCounter += 1 - - if isExplorePath(path) { - exploreTarget = WebTarget(url: resolved, reloadToken: reloadCounter) + if routed.isExplore { + exploreTarget = WebTarget(url: routed.url, reloadToken: reloadCounter) selectedTab = .explore } else { - readTarget = WebTarget(url: resolved, reloadToken: reloadCounter) + readTarget = WebTarget(url: routed.url, 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/` 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 { diff --git a/Sources/Features/WebShell/WebShellScripts.swift b/Sources/Features/WebShell/WebShellScripts.swift new file mode 100644 index 0000000..f6182bd --- /dev/null +++ b/Sources/Features/WebShell/WebShellScripts.swift @@ -0,0 +1,30 @@ +import Foundation +import WebKit + +/// User-Scripts, die in jeden `WKWebView` injiziert werden. +/// +/// `WKUserScript` ist MainActor-isolated; deshalb sind die Factory- +/// Methoden hier ebenfalls MainActor. Aufrufer leben sowieso auf Main +/// (SwiftUI `makeUIView`/`makeNSView` sind MainActor). +@MainActor +enum WebShellScripts { + /// Versteckt den zitare-Web-Header (`
` mit ``), + /// weil die native TabBar bereits global navigiert. Footer und + /// Hauptinhalt bleiben sichtbar. + /// + /// CSS wird at document.start als `