diff --git a/CHANGELOG.md b/CHANGELOG.md index ea52c58..6bb2766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,53 +6,6 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] -## [0.6.0] — 2026-05-17 - -Minor — **neues Library-Product `ManaWebShell`**. WKWebView-Hülle für -Hybrid-Apps (Web-Lese-Surfaces + native Submit/Widget/ShareExt). -Extrahiert aus den fast-byte-identischen `WebShell/`-Ordnern in -`seepuls-native` und `zitare-native` (~900 LOC, davon ~700 LOC -Duplikat). Audit 2026-05-17 Vorschlag V2. - -### Neu - -- `WebShellView` (public SwiftUI View) — `WKWebView`-Wrapper mit - Progress-Bar, Pull-to-Refresh (iOS), Fehler-Snackbar, External-Link- - Delegation in den System-Browser. Universal (iOS + macOS). -- `WebShellConfig` (public, Sendable) — Host-Whitelist mit Wildcard- - Support (`"*.mana.how"`), User-Agent, Theme-Hints (background, - progressTint, errorBackground/Foreground/Icon), User-Scripts. -- `WebTarget` (public, Equatable+Sendable) — URL + monoton wachsender - `reloadToken`. Forciert Reload bei Universal-Link auf aktuelle URL. -- `WebNavState` (public, @Observable, @MainActor) — reaktiver - Navigation-State (isLoading, estimatedProgress, lastError, - currentURL, canGoBack). -- `WebShellCoordinator` (public, @MainActor) — `WKNavigationDelegate` - + `WKUIDelegate`-Implementierung. KVO-Observations, Pull-to-Refresh- - Action. -- `WebShellScripts` (public Enum, @MainActor) — vor-gefertigte - `WKUserScript`-Helfer: `preferDarkScheme`, `syncDarkMode(localStorageKey:)`, - `hideElements(selectors:tagName:)`. Apps stapeln nach Bedarf. - -### Logging - -- ManaWebShell loggt unter Subsystem `ev.mana.webshell`, Kategorie - `web`. App-OSLog bleibt unverändert. - -### Tests - -- `ManaWebShellTests` mit 6 Tests gegen `WebShellConfig.isAllowed`. - Coverage für exakte Hosts, `*.root`-Wildcard, Root-selbst, - Negativ-Cases, leere Whitelist. 6/6 grün. - -### Migrations-Hinweis - -`seepuls-native` und `zitare-native` können ihre lokalen -`Sources/Features/WebShell/`-Dateien gegen `ManaWebShell` ersetzen. -Pattern in `mana/docs/playbooks/HYBRID_NATIVE_APP.md` (entsteht -parallel). App-spezifisches (CookieBridge, App-Theme als -`config.backgroundColor`) bleibt in der App. - ## [0.5.0] — 2026-05-14 Minor — `ManaTwoFactorAccountRow` + `ManaBackupCodeRegenerateView`. diff --git a/Package.swift b/Package.swift index 7b28ec9..482de59 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,6 @@ let package = Package( ], products: [ .library(name: "ManaAuthUI", targets: ["ManaAuthUI"]), - .library(name: "ManaWebShell", targets: ["ManaWebShell"]), ], dependencies: [ // Lokaler Dev-Pfad. Apps konsumieren beide Pakete parallel über @@ -30,22 +29,10 @@ let package = Package( .enableExperimentalFeature("StrictConcurrency"), ] ), - .target( - name: "ManaWebShell", - path: "Sources/ManaWebShell", - swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency"), - ] - ), .testTarget( name: "ManaAuthUITests", dependencies: ["ManaAuthUI"], path: "Tests/ManaAuthUITests" ), - .testTarget( - name: "ManaWebShellTests", - dependencies: ["ManaWebShell"], - path: "Tests/ManaWebShellTests" - ), ] ) diff --git a/Sources/ManaWebShell/WebNavState.swift b/Sources/ManaWebShell/WebNavState.swift deleted file mode 100644 index c467daf..0000000 --- a/Sources/ManaWebShell/WebNavState.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -/// Reactive Navigation-State, geteilt zwischen SwiftUI und Coordinator. -/// Auf `MainActor` — alle Mutationen passieren via WKWebView-Callbacks -/// (KVO + Delegate), die WebKit auf Main liefert. -@Observable -@MainActor -public final class WebNavState { - public var isLoading: Bool = false - public var estimatedProgress: Double = 0 - public var lastError: String? - public var currentURL: URL? - public var canGoBack: Bool = false - - public init() {} -} diff --git a/Sources/ManaWebShell/WebShellConfig.swift b/Sources/ManaWebShell/WebShellConfig.swift deleted file mode 100644 index 2570d0f..0000000 --- a/Sources/ManaWebShell/WebShellConfig.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import SwiftUI -import WebKit - -/// Konfiguration für ``WebShellView``. -/// -/// Beispiel: -/// -/// ```swift -/// WebShellView( -/// target: WebTarget(url: URL(string: "https://seepuls.mana.how")!), -/// config: WebShellConfig( -/// allowedHosts: ["seepuls.mana.how", "*.mana.how", "mana.how"], -/// userAgent: "SeepulsNative/0.1 (iOS)" -/// ) -/// ) -/// ``` -/// -/// Apps mit eigenem Theme injizieren `background` / `progressTint` / -/// `warning` etc. — default werden System-Farben benutzt. -public struct WebShellConfig: Sendable { - /// Liste erlaubter Hosts. Unterstützt: - /// - exakte Hosts: `"seepuls.mana.how"` - /// - Wildcard-Subdomains: `"*.mana.how"` - /// - /// Pfade auf nicht-gelisteten Hosts werden via `OpenURLAction` an - /// den System-Browser delegiert. Ein leeres Array bedeutet - /// **alles extern** — selten gewünscht, aber explizit erlaubt. - public let allowedHosts: [String] - - /// `applicationNameForUserAgent`. WKWebView hängt das an seinen - /// Standard-UA an, ersetzt ihn nicht. Konvention im mana-Ökosystem: - /// `"Native/ ()"`. - public let userAgent: String - - /// Hintergrund hinter dem WKWebView (verhindert Flash vor first - /// paint). Default: `.clear`. Caller setzt typischerweise auf - /// App-Theme-Background. - public let backgroundColor: Color - - /// Tint der Fortschritts-Linie oben (Linear-ProgressView). Default: - /// `.accentColor`. - public let progressTint: Color - - /// Hintergrund der Fehler-Snackbar. Default: `.gray.opacity(0.15)`. - public let errorBackgroundColor: Color - - /// Vordergrund der Fehler-Snackbar (Icon + Text). Default: `.primary`. - public let errorForegroundColor: Color - - /// Icon-Farbe (Warn-Dreieck) in der Fehler-Snackbar. Default: `.orange`. - public let errorIconColor: Color - - /// User-Scripts, die in `WKUserContentController` injiziert werden - /// (Reihenfolge bleibt erhalten). Häufig genutzt: Theme-Sync, - /// Web-Nav-Verstecken. Siehe ``WebShellScripts`` für Default-Helfer. - public let userScripts: [WKUserScript] - - public init( - allowedHosts: [String], - userAgent: String, - backgroundColor: Color = .clear, - progressTint: Color = .accentColor, - errorBackgroundColor: Color = Color.gray.opacity(0.15), - errorForegroundColor: Color = .primary, - errorIconColor: Color = .orange, - userScripts: [WKUserScript] = [] - ) { - self.allowedHosts = allowedHosts - self.userAgent = userAgent - self.backgroundColor = backgroundColor - self.progressTint = progressTint - self.errorBackgroundColor = errorBackgroundColor - self.errorForegroundColor = errorForegroundColor - self.errorIconColor = errorIconColor - self.userScripts = userScripts - } - - /// Prüft, ob ein Host in dieser Konfiguration erlaubt ist. - /// Unterstützt `*.`-Wildcards (subdomain-suffix + Root selbst). - public func isAllowed(host: String) -> Bool { - for pattern in allowedHosts { - if pattern.hasPrefix("*.") { - let suffix = String(pattern.dropFirst(1)) // ".mana.how" - if host.hasSuffix(suffix) { return true } - let root = String(suffix.dropFirst(1)) // "mana.how" - if host == root { return true } - } else if host == pattern { - return true - } - } - return false - } -} diff --git a/Sources/ManaWebShell/WebShellCoordinator.swift b/Sources/ManaWebShell/WebShellCoordinator.swift deleted file mode 100644 index ab349cb..0000000 --- a/Sources/ManaWebShell/WebShellCoordinator.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import OSLog -import SwiftUI -import WebKit - -#if canImport(UIKit) -import UIKit -#endif - -private let log = Logger(subsystem: "ev.mana.webshell", category: "web") - -/// `WKNavigationDelegate` + `WKUIDelegate` für ``WebShellView``. Hält -/// den reactive ``WebNavState`` aktuell, lenkt externe Links in den -/// System-Browser und treibt Pull-to-Refresh an. -/// -/// Lebt auf `MainActor` (Closures von WKWebView liefern auf Main). -/// KVO-Observations werden bei `deinit` entfernt. -@MainActor -public final class WebShellCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate { - let navState: WebNavState - let openURL: OpenURLAction - let config: WebShellConfig - 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, config: WebShellConfig) { - self.navState = navState - self.openURL = openURL - self.config = config - 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.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 - - public func webView( - _: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction - ) async -> WKNavigationActionPolicy { - guard let url = navigationAction.request.url else { return .allow } - if let host = url.host, config.isAllowed(host: host) { - return .allow - } - if url.scheme == "http" || url.scheme == "https" { - log.info("WebShell → openURL extern: \(url.absoluteString, privacy: .public)") - openURL(url) - return .cancel - } - return .cancel - } - - public func webView(_: WKWebView, didFail _: WKNavigation, withError error: Error) { - log.warning("didFail: \(String(describing: error), privacy: .public)") - navState.lastError = (error as NSError).localizedDescription - } - - public func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation, withError error: Error) { - log.warning("didFailProvisional: \(String(describing: error), privacy: .public)") - navState.lastError = (error as NSError).localizedDescription - } - - public func webView(_: WKWebView, didFinish _: WKNavigation) { - navState.lastError = nil - } - - // MARK: - WKUIDelegate - - public func webView( - _ webView: WKWebView, - createWebViewWith _: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, - windowFeatures _: WKWindowFeatures - ) -> WKWebView? { - // `target=_blank`-Links: kein neues Fenster, im aktuellen WebView - // laden bzw. extern öffnen. - if let url = navigationAction.request.url { - if let host = url.host, config.isAllowed(host: host) { - webView.load(navigationAction.request) - } else { - openURL(url) - } - } - return nil - } -} diff --git a/Sources/ManaWebShell/WebShellScripts.swift b/Sources/ManaWebShell/WebShellScripts.swift deleted file mode 100644 index 3291b89..0000000 --- a/Sources/ManaWebShell/WebShellScripts.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -import WebKit - -/// Vor-gefertigte `WKUserScript`-Helfer für ``WebShellView``. Apps -/// pickern, was sie brauchen, und reichen das Ergebnis als -/// `config.userScripts` durch. -/// -/// `WKUserScript` ist MainActor-isolated; deshalb sind die Factory- -/// Methoden hier ebenfalls MainActor. Aufrufer leben sowieso auf Main -/// (SwiftUI `makeUIView`/`makeNSView` sind MainActor). -@MainActor -public enum WebShellScripts { - /// Erzwingt Dark-Color-Scheme im WebView, indem ein `` injiziert und `.dark` an - /// `` gehängt wird. Sinnvoll für Web-Apps, die nur Dark- - /// Styles haben (Seepuls) oder bei denen die App das Light/Dark - /// hart festlegt. - public static let preferDarkScheme: WKUserScript = .init( - source: """ - (function() { - var meta = document.querySelector('meta[name="color-scheme"]'); - if (!meta) { - meta = document.createElement('meta'); - meta.setAttribute('name', 'color-scheme'); - (document.head || document.documentElement).appendChild(meta); - } - meta.setAttribute('content', 'dark'); - var html = document.documentElement; - if (html) html.classList.add('dark'); - })(); - """, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - - /// Synct den System-Dark-Mode in den WebView via - /// `matchMedia('(prefers-color-scheme: dark)')`. Setzt eine - /// `.dark`-Klasse auf `` und optional einen `localStorage`- - /// Key, an dem das Web-Theme hängt. Listener für Live-Switch - /// während die Page offen ist. - /// - /// - Parameter localStorageKey: Key, an dem das Web seinen Theme- - /// State liest. `nil` falls Web nur auf `.dark` reagiert. - public static func syncDarkMode(localStorageKey: String? = nil) -> WKUserScript { - let setStorage: String - if let key = localStorageKey { - let escaped = key.replacingOccurrences(of: "'", with: "\\'") - setStorage = """ - try { - if (isDark) localStorage.setItem('\(escaped)', 'dark'); - else localStorage.removeItem('\(escaped)'); - } catch (e) {} - """ - } else { - setStorage = "" - } - let source = """ - (function() { - function apply(isDark) { - \(setStorage) - var html = document.documentElement; - if (!html) return; - if (isDark) html.classList.add('dark'); - else html.classList.remove('dark'); - } - var mq = window.matchMedia('(prefers-color-scheme: dark)'); - apply(mq.matches); - if (mq.addEventListener) { - mq.addEventListener('change', function(e) { apply(e.matches); }); - } - })(); - """ - return WKUserScript( - source: source, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - } - - /// Versteckt eine Top-Nav-Komponente per CSS, damit eine native - /// TabBar nicht doppelt rendert. Mehrere Selektoren werden - /// gestapelt (mit `,`-Group), damit ein Markup-Refactor in - /// Web-Land das Hide nicht still bricht. - /// - /// Konvention für Selektor-Kaskaden: - /// 1. `nav[data-app-nav]` / `header[data-app-nav]` — explizites - /// Attribut, falls Web es markieren will (greift sofort) - /// 2. strukturell (`body header:has(a.brand)` o.ä.) — heutige - /// Realität - /// 3. positionell (`body > nav:first-of-type`) — Fallback - /// - /// - Parameter selectors: CSS-Selektoren, die `display: none - /// !important` bekommen. Werden mit `,` gejoint. - /// - Parameter tagName: Wert für das `data-mana-webshell`- - /// Attribut auf dem Style-Tag (debugging, source inspection). - public static func hideElements( - selectors: [String], - tagName: String = "hide" - ) -> WKUserScript { - let joined = selectors.joined(separator: ",\n") - let escapedTag = tagName.replacingOccurrences(of: "'", with: "\\'") - let source = """ - (function() { - var css = `\(joined) { - display: none !important; - }`; - var style = document.createElement('style'); - style.setAttribute('data-mana-webshell', '\(escapedTag)'); - style.textContent = css; - (document.head || document.documentElement).appendChild(style); - })(); - """ - return WKUserScript( - source: source, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - } -} diff --git a/Sources/ManaWebShell/WebShellView.swift b/Sources/ManaWebShell/WebShellView.swift deleted file mode 100644 index 98cc341..0000000 --- a/Sources/ManaWebShell/WebShellView.swift +++ /dev/null @@ -1,183 +0,0 @@ -import SwiftUI -import WebKit - -/// SwiftUI-Hülle um `WKWebView`. Eine Instanz gehört üblicherweise zu -/// einem Tab/Screen und behält ihren Web-State (Scroll-Position, -/// Browser-History) während die View lebt. -/// -/// **Verhalten:** -/// - Lädt `target` beim ersten Auftauchen. -/// - Wechselt `target` während die View lebt → lädt neue URL (oder -/// reloaded, wenn `target.reloadToken` sich erhöht). -/// - Pull-to-Refresh über `UIRefreshControl` (iOS / iPadOS). -/// - Links auf nicht-gelisteten Hosts (siehe ``WebShellConfig/allowedHosts``) -/// und `target=_blank` öffnen im System-Browser via -/// `OpenURLAction`, nicht im WebView. -/// - Cookies werden über `WKWebsiteDataStore.default()` geteilt. -/// -/// **Theme-Hint:** -/// `config.backgroundColor` ist der Hintergrund hinter dem WKWebView -/// — verhindert weißen Flash bis zum first paint. Apps mit Dark-Theme -/// setzen das auf ihren Theme-Background. -public struct WebShellView: View { - let target: WebTarget - let config: WebShellConfig - - @State private var navState = WebNavState() - @Environment(\.openURL) private var openURL - - public init(target: WebTarget, config: WebShellConfig) { - self.target = target - self.config = config - } - - public var body: some View { - VStack(spacing: 0) { - if navState.isLoading { - ProgressView(value: navState.estimatedProgress) - .progressViewStyle(.linear) - .tint(config.progressTint) - .frame(height: 2) - } - #if canImport(UIKit) - WebViewRepresentable( - target: target, - navState: navState, - openURL: openURL, - config: config - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(config.backgroundColor) - #elseif canImport(AppKit) - MacWebViewRepresentable( - target: target, - navState: navState, - openURL: openURL, - config: config - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(config.backgroundColor) - #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(config.errorIconColor) - Text(message) - .font(.caption) - .lineLimit(2) - .foregroundStyle(config.errorForegroundColor) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(config.errorBackgroundColor) - } -} - -#if canImport(UIKit) -import UIKit - -private struct WebViewRepresentable: UIViewRepresentable { - let target: WebTarget - let navState: WebNavState - let openURL: OpenURLAction - let config: WebShellConfig - - func makeCoordinator() -> WebShellCoordinator { - WebShellCoordinator(navState: navState, openURL: openURL, config: config) - } - - func makeUIView(context: Context) -> WKWebView { - let wkConfig = WKWebViewConfiguration() - wkConfig.websiteDataStore = .default() - wkConfig.applicationNameForUserAgent = config.userAgent - for script in config.userScripts { - wkConfig.userContentController.addUserScript(script) - } - let webView = WKWebView(frame: .zero, configuration: wkConfig) - webView.navigationDelegate = context.coordinator - webView.uiDelegate = context.coordinator - webView.allowsBackForwardNavigationGestures = true - // Ohne diese drei flackert WKWebView bis zum first paint weiß - // gegen das App-Theme — egal was der SwiftUI-Container als - // Background setzt. - webView.isOpaque = false - webView.backgroundColor = .clear - webView.scrollView.backgroundColor = .clear - webView.scrollView.refreshControl = makeRefreshControl( - webView: webView, - coordinator: context.coordinator - ) - context.coordinator.observe(webView: webView) - context.coordinator.load(target.url, into: webView) - context.coordinator.lastTarget = target - 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 - let config: WebShellConfig - - func makeCoordinator() -> WebShellCoordinator { - WebShellCoordinator(navState: navState, openURL: openURL, config: config) - } - - func makeNSView(context: Context) -> WKWebView { - let wkConfig = WKWebViewConfiguration() - wkConfig.websiteDataStore = .default() - wkConfig.applicationNameForUserAgent = config.userAgent - for script in config.userScripts { - wkConfig.userContentController.addUserScript(script) - } - let webView = WKWebView(frame: .zero, configuration: wkConfig) - webView.navigationDelegate = context.coordinator - webView.uiDelegate = context.coordinator - webView.allowsBackForwardNavigationGestures = true - // macOS-Pendant zu UIView.isOpaque=false — sonst weißer Flash - // vor first paint. - webView.setValue(false, forKey: "drawsBackground") - context.coordinator.observe(webView: webView) - context.coordinator.load(target.url, into: webView) - context.coordinator.lastTarget = target - 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 diff --git a/Sources/ManaWebShell/WebTarget.swift b/Sources/ManaWebShell/WebTarget.swift deleted file mode 100644 index 682525d..0000000 --- a/Sources/ManaWebShell/WebTarget.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -/// 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. -public struct WebTarget: Equatable, Sendable { - public let url: URL - public let reloadToken: Int - - public init(url: URL, reloadToken: Int = 0) { - self.url = url - self.reloadToken = reloadToken - } -} diff --git a/Tests/ManaWebShellTests/WebShellConfigTests.swift b/Tests/ManaWebShellTests/WebShellConfigTests.swift deleted file mode 100644 index 46bddcb..0000000 --- a/Tests/ManaWebShellTests/WebShellConfigTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Testing -@testable import ManaWebShell - -@Suite("WebShellConfig — Host-Whitelist") -struct WebShellConfigTests { - private func config(_ hosts: [String]) -> WebShellConfig { - WebShellConfig(allowedHosts: hosts, userAgent: "TestNative/0.1") - } - - @Test("Exakter Host matched") - func exactMatch() { - let c = config(["seepuls.mana.how"]) - #expect(c.isAllowed(host: "seepuls.mana.how")) - #expect(!c.isAllowed(host: "other.mana.how")) - #expect(!c.isAllowed(host: "mana.how")) - #expect(!c.isAllowed(host: "evil.com")) - } - - @Test("Wildcard *.root matched Subdomain") - func wildcardSubdomain() { - let c = config(["*.mana.how"]) - #expect(c.isAllowed(host: "seepuls.mana.how")) - #expect(c.isAllowed(host: "auth.mana.how")) - #expect(c.isAllowed(host: "deep.nested.mana.how")) - } - - @Test("Wildcard *.root matched Root selbst") - func wildcardCoversRoot() { - let c = config(["*.mana.how"]) - #expect(c.isAllowed(host: "mana.how")) - } - - @Test("Wildcard matched nicht andere TLDs") - func wildcardScoped() { - let c = config(["*.mana.how"]) - #expect(!c.isAllowed(host: "mana.com")) - #expect(!c.isAllowed(host: "fake-mana.how")) - #expect(!c.isAllowed(host: "evil.com")) - } - - @Test("Mehrere Patterns kombinieren") - func mixedPatterns() { - let c = config(["zitare.com", "www.zitare.com", "*.mana.how"]) - #expect(c.isAllowed(host: "zitare.com")) - #expect(c.isAllowed(host: "www.zitare.com")) - #expect(c.isAllowed(host: "auth.mana.how")) - #expect(c.isAllowed(host: "mana.how")) - #expect(!c.isAllowed(host: "other.zitare.com")) - } - - @Test("Leere Whitelist verbietet alles") - func emptyDenies() { - let c = config([]) - #expect(!c.isAllowed(host: "anything.com")) - } -} diff --git a/devlog/2026-05-13/data.json b/devlog/2026-05-13/data.json deleted file mode 100644 index 81727ad..0000000 --- a/devlog/2026-05-13/data.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "date": "2026-05-13", - "day_number": 1, - "weekday": "Mittwoch", - "commits": 5, - "authors": [ - { - "name": "Till JS", - "count": 5 - } - ], - "additions": 4247, - "deletions": 4, - "net_lines": 4243, - "files_changed": 39, - "new_files": 0, - "deleted_files": 0, - "session": { - "first_commit_at": "2026-05-13T17:22:42.000Z", - "last_commit_at": "2026-05-13T23:08:41.000Z", - "total_span_minutes": 346, - "active_minutes": 48, - "pauses": [ - { - "from": "19:22", - "to": "22:16", - "minutes": 174 - }, - { - "from": "22:16", - "to": "00:20", - "minutes": 124 - } - ], - "longest_focus_minutes": 48 - }, - "top_dirs": [ - { - "path": "CHANGELOG.md", - "pct": 11 - }, - { - "path": "Sources/ManaAuthUI/TwoFactor", - "pct": 11 - }, - { - "path": "Sources/ManaAuthUI/Login", - "pct": 9 - }, - { - "path": "Sources/ManaAuthUI/Reset", - "pct": 9 - }, - { - "path": "Sources/ManaAuthUI/Account", - "pct": 6 - } - ], - "top_extensions": [ - { - "ext": ".swift", - "count": 38 - }, - { - "ext": ".md", - "count": 7 - }, - { - "ext": ".gitignore", - "count": 2 - } - ], - "tags": [], - "commits_list": [ - { - "hash": "0a2cb34", - "short": "v0.1.0 — initialer Sprint, vollständige Auth-Reise als SwiftUI", - "type": null, - "scope": null, - "additions": 2614, - "deletions": 0, - "timestamp": "2026-05-13T19:22:42+02:00" - }, - { - "hash": "6417b4c", - "short": "v0.2.0 — ManaAuthGate für Action-Level-Login-Eskalation", - "type": null, - "scope": null, - "additions": 357, - "deletions": 0, - "timestamp": "2026-05-13T22:16:27+02:00" - }, - { - "hash": "c155556", - "short": "v0.3.0 — ManaTwoFactorChallengeView", - "type": null, - "scope": null, - "additions": 348, - "deletions": 4, - "timestamp": "2026-05-14T00:20:30+02:00" - }, - { - "hash": "dc8e5a4", - "short": "v0.4.0 — ManaTwoFactorEnrollView + ManaTwoFactorDisableView", - "type": null, - "scope": null, - "additions": 595, - "deletions": 0, - "timestamp": "2026-05-14T00:39:03+02:00" - }, - { - "hash": "117538f", - "short": "v0.5.0 — ManaTwoFactorAccountRow + ManaBackupCodeRegenerateView", - "type": null, - "scope": null, - "additions": 333, - "deletions": 0, - "timestamp": "2026-05-14T01:08:41+02:00" - } - ], - "review_state": "auto", - "llm": { - "model": null, - "generated_at": null - } -} diff --git a/devlog/2026-05-13/macher.md b/devlog/2026-05-13/macher.md deleted file mode 100644 index 9042a76..0000000 --- a/devlog/2026-05-13/macher.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -date: 2026-05-13 -day: 1 -view: macher -weekday: Mittwoch -commits: 5 -review: written ---- -# Mittwoch, 2026-05-13 — Tag 1 (Macher-Sicht) - -Initialer Sprint des Pakets. Aus drei fast-byte-identischen -`LoginView.swift`-Files in cards-native, manaspur-native und -memoro-native wird ein gemeinsames Swift-Package — plus das, was -bisher gar nicht da war: Sign-Up, E-Mail-Verifikation, Passwort-Reset, -Account-Management. Und 2FA, weil die Lücke beim Aufräumen sichtbar -wurde. - -## Stats - -5 Commits, +4 247 / −4 LoC, 39 Files. Sessionspanne 17:22 → 01:08, -~48 aktive Minuten in einem Durchstich. Bei +4 243 netto ist das -v0.1.0 mit allem Anhang, nicht „aktive Tipparbeit" — die Inhalte -flossen aus den drei App-Repos zusammen. - -## Versionsschritte des Tages - -- **v0.1.0** — vollständige Auth-Reise als SwiftUI: Login, Sign-Up, - Email-Verify-Gate, Forgot-/Reset-Password, Change-Email, - Change-Password, Delete-Account. ViewModels strikt getrennt von - Views, jeder Flow eigene `@Observable`-State-Maschine. -- **v0.2.0** — `ManaAuthGate`, der „bitte-erst-einloggen"-Wrapper - für Action-Level-Eskalation. Cards/Manaspur/Memoro brauchen das - pro Aktion, nicht pro Screen. -- **v0.3.0** — `ManaTwoFactorChallengeView` für den Login-Step, - wenn der Server 2FA verlangt. -- **v0.4.0** — `ManaTwoFactorEnrollView` (QR-Code + Verify) und - `ManaTwoFactorDisableView` (Passwort-Bestätigung). -- **v0.5.0** — `ManaTwoFactorAccountRow` für den Account-Tab und - `ManaBackupCodeRegenerateView`. - -Fünf Tags in einer Session ist viel — der Schnitt war pro -Feature-Komplex, damit Consumer-Apps gezielt minor-bumpen können -ohne 2FA mitzunehmen, das sie noch nicht zeigen. - -## Architektur-Entscheidungen - -- **Pure SwiftUI, keine UI-Lib** — gleiche Regel wie `ManaCore`. - Senkt Drift-Risiko zwischen Vereins-Apps. -- **App injiziert `ManaBrandConfig`** — Pakets-Sources kennen keinen - App-Namen, keine Farben. `forest`/`mana`/künftige Themes leben in - der konsumierenden App, bis Token-Theme-Variants kommen. -- **ViewModel-zuerst-Pattern.** Tests gehen gegen ViewModels via - URLProtocol-Mock, Views sind dünn und ungetestet — das passt zum - Swift-Test-Realismus auf macOS-CI. -- **Account-Löschung ist Pflicht** (App-Store-Guideline 5.1.1(v)); - `ManaDeleteAccountView` ist Bestandteil jedes Sign-Up-Anbieters. -- **2FA-Flow läuft komplett über `ManaCore` v1.2.0** (Guest-Mode + - Refresh-Resilience), keine eigenen API-Wrapper in UI. - -## Trade-offs - -- 4 243 netto Zeilen für „v0.1.0 + v0.5.0 in einer Session" — das - hat nur funktioniert, weil drei Source-Repos schon Login-Code in - ähnlicher Form hatten. Hätten wir Sign-Up parallel in drei Apps - gebaut, wäre der Tag dreimal so lang. -- Sprache deutsch im Public-API. Lokalisierungs-Refactor später, - wenn EN-Bedarf real wird; jetzt würde die Indirection bremsen. -- 2FA UI ist da, aber Server-seitig fehlt der Endpoint in - `mana-auth` noch in mancher Form — die Views laufen daher - zunächst gegen Stub-Antworten. - -## Offene Punkte - -- ManaTokens-Theme-Variants → erst dann `ManaBrandConfig` ersetzen. -- Snapshot-Tests für Views fehlen; ViewModel-Tests laufen. -- Localizable.xcstrings (EN) noch nicht angelegt — kommt mit der - ersten konkreten EN-Anforderung. -- Cross-App-Probelauf: 2FA-Enroll von cards-native gegen Production - noch nicht durchgespielt. diff --git a/devlog/2026-05-13/spieler.md b/devlog/2026-05-13/spieler.md deleted file mode 100644 index 10ed6e2..0000000 --- a/devlog/2026-05-13/spieler.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -date: 2026-05-13 -day: 1 -view: spieler -weekday: Mittwoch -commits: 5 -review: written ---- -# Mittwoch, 2026-05-13 — Tag 1 - -Drei Apps hatten bisher fast identische Login-Bildschirme, Sign-Up -fehlte komplett, und „Account löschen" konnte nirgendwo richtig -gezeigt werden. Heute ist daraus ein gemeinsamer Bausatz geworden — -**ManaAuthUI**. - -## Was sich für dich ändert (in Cards, Manaspur, Memoro) - -- Sign-In, Registrierung, „Passwort vergessen?" und E-Mail-Verifikation - sehen jetzt überall gleich aus und führen sauber von einem Schritt - zum nächsten. Wenn du eine App schon kennst, kennst du die andere. -- **Zwei-Faktor-Schutz für deinen Account** ist nun durchgängig - möglich. Du kannst ihn einschalten (über eine Authenticator-App), - Backup-Codes neu erzeugen und ihn wieder ausschalten — vorausgesetzt, - du bestätigst kurz mit Passwort. -- **Account-Löschung** ist überall erreichbar, mit klarem - Bestätigungs-Schritt. Kein Kleingedrucktes, kein versteckter Pfad. -- Wer mitten in einer Aktion plötzlich gefragt wird „bitte erst noch - einloggen", bekommt das jetzt als sanfte Eskalation, nicht als harten - Rauswurf. - -## Hintergrund - -Diese Sachen waren bisher pro App gebaut — mit Unterschieden, die -keinem Menschen helfen. Ab jetzt: eine gemeinsame Tür für alle -Vereins-Apps. Was du in einer App lernst, hilft dir in der nächsten.