From c1555565b6da3e8c52e0cc3359eb66d94ad8f03f Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 00:20:30 +0200 Subject: [PATCH 1/7] =?UTF-8?q?v0.3.0=20=E2=80=94=20ManaTwoFactorChallenge?= =?UTF-8?q?View?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps mit aktivem 2FA bekommen jetzt eine native Challenge-View nach Email/Password-Login. ManaLoginView schaltet automatisch um wenn AuthClient.status auf .twoFactorRequired wechselt. Components: - ManaTwoFactorChallengeView — Scaffold-View mit 6-stelligem Code- Input, Backup-Code-Toggle, Cancel zurück zum Login - TwoFactorChallengeViewModel — @Observable State-Maschine, wraps AuthClient.verifyTotp/verifyBackupCode - LoginViewModel.Status.twoFactorRequired(email:) als neuer Case; submit() routet automatisch dorthin wenn der AuthClient den Challenge-Status zurückgibt 6 neue Tests, 39/39 grün. Setzt mana-swift-core ≥ 1.3.0 voraus. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 22 ++++ .../Gate/ManaAuthGateModifier.swift | 5 +- Sources/ManaAuthUI/Login/LoginViewModel.swift | 12 +- Sources/ManaAuthUI/Login/ManaLoginView.swift | 11 ++ .../ManaTwoFactorChallengeView.swift | 101 +++++++++++++++ .../TwoFactorChallengeViewModel.swift | 83 ++++++++++++ .../TwoFactorChallengeViewModelTests.swift | 118 ++++++++++++++++++ 7 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift create mode 100644 Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift create mode 100644 Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 72daa99..493cb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.3.0] — 2026-05-14 + +Minor — `ManaTwoFactorChallengeView` für 2FA-Login. Setzt +mana-swift-core ≥ 1.3.0 voraus (Status `.twoFactorRequired`). + +### Neu + +- `ManaTwoFactorChallengeView` + `TwoFactorChallengeViewModel` — + 6-stelliger TOTP-Code-Input (Number-Pad auf iOS), Fallback auf + Backup-Codes via Toggle, "Abbrechen" routet via + `auth.signOut(keepGuestMode:)` zurück zum Login. +- `LoginViewModel.Status.twoFactorRequired(email:)` als neuer Case. +- `ManaLoginView` schaltet bei `.twoFactorRequired` automatisch auf + `ManaTwoFactorChallengeView` um (analog zu `.emailNotVerified`). + +### Tests + +- 6 neue Tests für `TwoFactorChallengeViewModel`: canSubmit-Guards + (TOTP 6 Ziffern, Backup beliebig), toggleMode-State-Reset, submit + bei Erfolg/Fehler, Backup-Code-Routing. +- 39/39 grün. + ## [0.2.0] — 2026-05-13 Minor — Action-Level-Gate für Apps mit Guest-/Login-optional-Modus. diff --git a/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift index 6d92a41..be611ef 100644 --- a/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift +++ b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift @@ -41,8 +41,9 @@ private func authStatusKey(_ status: AuthClient.Status) -> Int { case .signedOut: 1 case .guest: 2 case .signingIn: 3 - case .signedIn: 4 - case .error: 5 + case .twoFactorRequired: 4 + case .signedIn: 5 + case .error: 6 } } diff --git a/Sources/ManaAuthUI/Login/LoginViewModel.swift b/Sources/ManaAuthUI/Login/LoginViewModel.swift index 8de3b9c..cec1bf6 100644 --- a/Sources/ManaAuthUI/Login/LoginViewModel.swift +++ b/Sources/ManaAuthUI/Login/LoginViewModel.swift @@ -12,13 +12,14 @@ public final class LoginViewModel { case idle case signingIn /// Sign-In ist gescheitert mit klassifiziertem Fehler. - /// `.emailNotVerified` ist ein wichtiger Sonderfall — die UI - /// schaltet darauf den Resend-Mail-Gate frei. case error(String) /// Sign-In ist gescheitert weil die Email noch nicht bestätigt /// ist. UI zeigt den Resend-Gate für die zuletzt eingegebene /// Email-Adresse. case emailNotVerified(email: String) + /// Sign-In war erfolgreich aber der Account hat 2FA aktiviert. + /// UI zeigt ``ManaTwoFactorChallengeView``. + case twoFactorRequired(email: String) } public var email: String = "" @@ -66,6 +67,13 @@ public final class LoginViewModel { case .signedIn: status = .idle password = "" // nicht im Memory lassen + case .twoFactorRequired: + // Sign-In war auf der ersten Stufe erfolgreich, jetzt + // braucht der User noch den 2FA-Code. Password aus dem + // Memory wischen — das ist verifiziert und wird nicht + // mehr gebraucht. + password = "" + status = .twoFactorRequired(email: trimmed) case .error: // Strukturierten Fehler aus AuthClient.lastError lesen statt // den String der Status-Maschine zu re-parsen. diff --git a/Sources/ManaAuthUI/Login/ManaLoginView.swift b/Sources/ManaAuthUI/Login/ManaLoginView.swift index 5632834..cb4276a 100644 --- a/Sources/ManaAuthUI/Login/ManaLoginView.swift +++ b/Sources/ManaAuthUI/Login/ManaLoginView.swift @@ -48,6 +48,17 @@ public struct ManaLoginView: View { auth: auth, onBackToLogin: { model.resetToIdle() } ) + case .twoFactorRequired: + ManaTwoFactorChallengeView( + auth: auth, + onCancel: { + // Abbruch: User will zurück zum Email/Password-Form. + // AuthClient.status zurücksetzen damit der Challenge- + // Token verworfen wird; UI-Status auf idle. + Task { await auth.signOut(keepGuestMode: true) } + model.resetToIdle() + } + ) default: loginForm } diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift new file mode 100644 index 0000000..67f8bd7 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift @@ -0,0 +1,101 @@ +import ManaCore +import SwiftUI + +/// Wird angezeigt, wenn nach erfolgreichem Email/PW-`signIn` der +/// `AuthClient.status` auf ``AuthClient/Status/twoFactorRequired(token:methods:email:)`` +/// gewechselt ist. Bietet TOTP-Code-Eingabe (6-stellig) plus einen +/// Fallback auf Backup-Codes. +/// +/// Apps müssen das selbst nicht einbauen — ``ManaLoginView`` schaltet +/// automatisch um. Nur direkt nötig wenn die App eine eigene Login- +/// UI-Maschine hat (z.B. Memoros AccountView). +public struct ManaTwoFactorChallengeView: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorChallengeViewModel + private let onCancel: () -> Void + + /// - Parameters: + /// - auth: gemeinsamer `AuthClient` der App (Status muss bereits + /// `.twoFactorRequired` sein). + /// - onCancel: Callback wenn der User "Abbrechen" drückt. Apps + /// setzen den AuthClient typischerweise auf `.signedOut` + /// zurück und zeigen wieder die Login-View. + public init( + auth: AuthClient, + onCancel: @escaping () -> Void + ) { + _model = State(initialValue: TwoFactorChallengeViewModel(auth: auth)) + self.onCancel = onCancel + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + VStack(spacing: 20) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text(model.mode == .totp ? "Zwei-Faktor-Code" : "Backup-Code") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text(promptText) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaTextField(placeholderText, text: $model.code) + .autocorrectionDisabled() + .font(.system(.title3, design: .monospaced)) + #if os(iOS) + .keyboardType(model.mode == .totp ? .numberPad : .asciiCapable) + .textInputAutocapitalization(model.mode == .totp ? .never : .characters) + #endif + + ManaPrimaryButton( + "Bestätigen", + isLoading: model.isVerifying, + isEnabled: model.canSubmit + ) { + Task { await model.submit() } + } + + if case let .error(message) = model.status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button(action: { model.toggleMode() }) { + Text(model.mode == .totp + ? "Stattdessen Backup-Code verwenden" + : "Stattdessen 6-stelligen Code verwenden" + ) + .font(.footnote) + .foregroundStyle(brand.primary) + } + .padding(.top, 12) + + Button("Abbrechen", action: onCancel) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 8) + } + } + } + + private var promptText: String { + switch model.mode { + case .totp: + "Öffne deine Authenticator-App und gib den 6-stelligen Code für deinen Account ein." + case .backupCode: + "Gib einen deiner einmal-nutzbaren Backup-Codes ein. Jeder Code lässt sich nur einmal verwenden." + } + } + + private var placeholderText: String { + model.mode == .totp ? "123 456" : "xxxx-xxxx" + } +} diff --git a/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift b/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift new file mode 100644 index 0000000..76fa344 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift @@ -0,0 +1,83 @@ +import Foundation +import ManaCore +import Observation + +/// State-Maschine für ``ManaTwoFactorChallengeView``. Setzt auf den +/// `.twoFactorRequired`-Zustand des `AuthClient` auf, der nach einem +/// erfolgreichen Email/PW-`signIn` mit 2FA-aktiviertem Account +/// gesetzt wird. +@MainActor +@Observable +public final class TwoFactorChallengeViewModel { + public enum Mode: Equatable, Sendable { + case totp + case backupCode + } + + public enum Status: Equatable, Sendable { + case idle + case verifying + case error(String) + } + + public var mode: Mode = .totp + public var code: String = "" + public var trustDevice: Bool = false + public private(set) var status: Status = .idle + + private let auth: AuthClient + + public init(auth: AuthClient) { + self.auth = auth + } + + public var canSubmit: Bool { + guard !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + if case .verifying = status { return false } + switch mode { + case .totp: + // TOTP: 6 Ziffern (Better-Auth-Default) + let digitsOnly = code.filter { $0.isNumber } + return digitsOnly.count == 6 + case .backupCode: + // Backup-Codes: ~10 Zeichen alphanumerisch + Trenner. + // Pragmatik: nicht-leer reicht — Server validiert exakt. + return !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + public var isVerifying: Bool { + if case .verifying = status { return true } + return false + } + + public func toggleMode() { + mode = mode == .totp ? .backupCode : .totp + code = "" + status = .idle + } + + public func submit() async { + let cleaned = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return } + + status = .verifying + do { + switch mode { + case .totp: + try await auth.verifyTotp(code: cleaned, trustDevice: trustDevice) + case .backupCode: + try await auth.verifyBackupCode(code: cleaned, trustDevice: trustDevice) + } + // Bei Erfolg: Status bleibt .verifying — die View beobachtet + // den AuthClient.status (.signedIn) und reagiert über den + // umgebenden Gate/Root-View. Code aus dem Memory wischen. + code = "" + status = .idle + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Verifikation fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } +} diff --git a/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift b/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift new file mode 100644 index 0000000..7778b25 --- /dev/null +++ b/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift @@ -0,0 +1,118 @@ +import Foundation +import ManaCore +import Testing +@testable import ManaAuthUI + +@Suite("TwoFactorChallengeViewModel") +@MainActor +struct TwoFactorChallengeViewModelTests { + /// Bringt den AuthClient in den `.twoFactorRequired`-Status. + private func challengedAuth() async -> MockedAuth { + let mocked = makeMockedAuth() + mocked.setHandler { _ in + (200, Data(#""" + {"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-x"} + """#.utf8)) + } + await mocked.auth.signIn(email: "u@x.de", password: "pw") + return mocked + } + + @Test("canSubmit fordert 6-stellige Ziffern im TOTP-Modus") + func canSubmitTotpDigits() { + let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth) + model.code = "" + #expect(model.canSubmit == false) + model.code = "12345" + #expect(model.canSubmit == false) // 5 Ziffern + model.code = "123456" + #expect(model.canSubmit == true) + model.code = "123 456" // erlaubt Whitespace → 6 Ziffern + #expect(model.canSubmit == true) + model.code = "abcdef" + #expect(model.canSubmit == false) + } + + @Test("canSubmit im Backup-Modus akzeptiert nicht-leere Strings") + func canSubmitBackupCode() { + let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth) + model.toggleMode() + #expect(model.mode == .backupCode) + model.code = "" + #expect(model.canSubmit == false) + model.code = "abc-def-ghi" + #expect(model.canSubmit == true) + } + + @Test("toggleMode wechselt mode + cleared code") + func toggleModeClearsCode() { + let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth) + model.code = "123456" + model.toggleMode() + #expect(model.mode == .backupCode) + #expect(model.code == "") + } + + @Test("submit mit erfolgreichem TOTP setzt AuthClient auf signedIn") + func submitTotpSuccess() async { + let mocked = await challengedAuth() + let model = TwoFactorChallengeViewModel(auth: mocked.auth) + model.code = "123456" + + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { request in + #expect(request.url?.path == "/api/v1/auth/two-factor/verify-totp") + return (200, Data(#""" + {"success":true,"accessToken":"\#(access)","refreshToken":"r1"} + """#.utf8)) + } + + await model.submit() + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + #expect(model.code == "") + #expect(model.status == .idle) + } + + @Test("submit mit falschem TOTP → .error, AuthClient bleibt twoFactorRequired") + func submitTotpWrongCode() async { + let mocked = await challengedAuth() + let model = TwoFactorChallengeViewModel(auth: mocked.auth) + model.code = "000000" + + mocked.setHandler { _ in + (401, Data(#"{"error":"TWO_FACTOR_FAILED","status":401}"#.utf8)) + } + + await model.submit() + if case let .error(message) = model.status { + #expect(message == "Zwei-Faktor-Code falsch.") + } else { + Issue.record("Expected .error, got \(model.status)") + } + // AuthClient bleibt im challenge-Status, User kann retry + if case .twoFactorRequired = mocked.auth.status { + #expect(Bool(true)) + } else { + Issue.record("Expected .twoFactorRequired, got \(mocked.auth.status)") + } + } + + @Test("submit im Backup-Modus ruft verify-backup-code-Endpoint") + func submitBackupCodeRoutesCorrectly() async { + let mocked = await challengedAuth() + let model = TwoFactorChallengeViewModel(auth: mocked.auth) + model.toggleMode() + model.code = "abc-def-ghi" + + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { request in + #expect(request.url?.path == "/api/v1/auth/two-factor/verify-backup-code") + return (200, Data(#""" + {"success":true,"accessToken":"\#(access)","refreshToken":"r1"} + """#.utf8)) + } + + await model.submit() + #expect(mocked.auth.status == .signedIn(email: "u@x.de")) + } +} From dc8e5a4e9bf04cf46955edf444eae672c6355f7a Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 00:39:03 +0200 Subject: [PATCH 2/7] =?UTF-8?q?v0.4.0=20=E2=80=94=20ManaTwoFactorEnrollVie?= =?UTF-8?q?w=20+=20ManaTwoFactorDisableView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-Phasen-Wizard für 2FA-Enrollment + Single-Step-Sheet für Disable. Setzt mana-swift-core ≥ 1.4.0 voraus. ManaTwoFactorEnrollView: 1. Passwort-Re-Auth → server liefert otpauth-URI + Backup-Codes 2. QR-Code-Display (CoreImage.CIFilter.qrCodeGenerator) + 6-stellige Test-Code-Eingabe 3. Backup-Codes-Liste mit Copy-to-Clipboard ManaTwoFactorDisableView: - Re-Auth via Passwort, destructive-Button, .done-Konfirmation 5 neue Tests, 44/44 grün. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 ++ .../TwoFactor/ManaTwoFactorEnrollView.swift | 335 ++++++++++++++++++ .../TwoFactorEnrollmentViewModel.swift | 107 ++++++ .../TwoFactorEnrollmentViewModelTests.swift | 130 +++++++ 4 files changed, 595 insertions(+) create mode 100644 Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift create mode 100644 Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift create mode 100644 Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 493cb81..2648756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.4.0] — 2026-05-14 + +Minor — 2FA-Enrollment-UI (Mini-Sprint B). Setzt mana-swift-core +≥ 1.4.0 voraus. + +### Neu + +- `ManaTwoFactorEnrollView` + `TwoFactorEnrollmentViewModel` — + 3-Phasen-Wizard: + 1. Passwort eingeben (Re-Auth) + 2. QR-Code (via `CoreImage.CIFilter.qrCodeGenerator`, plattform- + unabhängig auf iOS+macOS) scannen + 6-stelligen Test-Code + eingeben + 3. Backup-Codes anzeigen + Copy-to-Clipboard +- `ManaTwoFactorDisableView` — Single-Step-Sheet, Re-Auth via + Passwort + destruktiver Bestätigungs-Button. + +### Tests + +- 5 neue Tests für Enroll-VM (Success, falsches PW, canSubmitVerify + 6-Ziffern-Guard, confirmVerify Phase-Wechsel, backupCodes-Accessor). +- 44/44 grün. + ## [0.3.0] — 2026-05-14 Minor — `ManaTwoFactorChallengeView` für 2FA-Login. Setzt diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift new file mode 100644 index 0000000..789ba62 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift @@ -0,0 +1,335 @@ +import CoreImage.CIFilterBuiltins +import ManaCore +import SwiftUI + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +/// Account-Sheet: TOTP-2FA aktivieren. 3-Phasen-Wizard. +/// +/// 1. Passwort eingeben (Re-Auth) +/// 2. QR-Code mit Authenticator-App scannen + Test-Code eingeben +/// 3. Backup-Codes anzeigen und vom User bestätigen lassen +public struct ManaTwoFactorEnrollView: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorEnrollmentViewModel + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + _model = State(initialValue: TwoFactorEnrollmentViewModel(auth: auth)) + self.onDone = onDone + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch model.phase { + case .password: + passwordPhase + case let .verify(uri, _): + verifyPhase(uri: uri) + case let .backupCodes(codes): + backupCodesPhase(codes: codes) + } + } + } + + // MARK: - Phase 1: Password + + @ViewBuilder + private var passwordPhase: some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text("Zwei-Faktor aktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Schütze deinen Account mit einem zusätzlichen Code. Bestätige dazu erst dein Passwort.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField( + "Passwort", + text: $model.password, + textContentType: .password + ) + + ManaPrimaryButton( + "Weiter", + isLoading: model.isWorking, + isEnabled: model.canSubmitPassword + ) { + Task { await model.enrollWithPassword() } + } + + if case let .error(message) = model.status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 2: QR + Verify + + @ViewBuilder + private func verifyPhase(uri: String) -> some View { + VStack(spacing: 16) { + Text("Code scannen") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Öffne deine Authenticator-App (z.B. 1Password, Aegis, Google Authenticator) und scanne diesen QR-Code.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + qrCode(for: uri) + .frame(width: 220, height: 220) + .padding(8) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) + + Text("Gib zur Bestätigung den 6-stelligen Code aus der App ein:") + .font(.subheadline) + .foregroundStyle(brand.foreground) + .multilineTextAlignment(.center) + .padding(.top, 8) + + ManaTextField("123 456", text: $model.verifyCode) + .autocorrectionDisabled() + .font(.system(.title3, design: .monospaced)) + #if os(iOS) + .keyboardType(.numberPad) + .textInputAutocapitalization(.never) + #endif + + ManaPrimaryButton( + "Weiter zu Backup-Codes", + isEnabled: model.canSubmitVerify + ) { + model.confirmVerify() + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 3: Backup-Codes + + @ViewBuilder + private func backupCodesPhase(codes: [String]) -> some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.success) + + Text("Zwei-Faktor aktiv") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Sichere diese Backup-Codes JETZT. Du brauchst sie wenn du dein Authenticator-Gerät verlierst. Jeder Code lässt sich nur einmal verwenden.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + VStack(spacing: 6) { + ForEach(codes, id: \.self) { code in + Text(code) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(brand.foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.vertical, 8) + + Button(action: { copyToClipboard(codes.joined(separator: "\n")) }) { + Label("Alle Codes kopieren", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + ManaPrimaryButton("Fertig — Codes sind gesichert") { + onDone() + } + .padding(.top, 12) + } + } + + // MARK: - QR-Code + + /// Rendert eine `otpauth://`-URI als QR-Code via `CoreImage`. Auf + /// iOS/macOS sind `CIFilter.qrCodeGenerator()` system-bordmittel. + @ViewBuilder + private func qrCode(for content: String) -> some View { + if let cgImage = makeQRCode(from: content) { + #if canImport(UIKit) + Image(uiImage: UIImage(cgImage: cgImage)) + .interpolation(.none) + .resizable() + .scaledToFit() + #elseif canImport(AppKit) + Image(nsImage: NSImage(cgImage: cgImage, size: NSSize(width: 220, height: 220))) + .interpolation(.none) + .resizable() + .scaledToFit() + #else + Text(content) + .font(.system(.caption, design: .monospaced)) + #endif + } else { + Text("QR-Code konnte nicht generiert werden — bitte URI manuell kopieren:\n\(content)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(brand.mutedForeground) + } + } + + private func makeQRCode(from string: String) -> CGImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + // Upscale damit der QR-Code scharf bleibt (kein anti-aliasing). + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaled = output.transformed(by: transform) + return context.createCGImage(scaled, from: scaled.extent) + } + + private func copyToClipboard(_ text: String) { + #if canImport(UIKit) + UIPasteboard.general.string = text + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #endif + } +} + +/// Account-Sheet: TOTP-2FA wieder deaktivieren. Einfacher Single-Step +/// mit Passwort-Re-Auth. +public struct ManaTwoFactorDisableView: View { + @Environment(\.manaBrand) private var brand + @State private var password: String = "" + @State private var status: DisableStatus = .idle + private let auth: AuthClient + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + self.auth = auth + self.onDone = onDone + } + + private enum DisableStatus: Equatable { + case idle + case working + case done + case error(String) + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch status { + case .done: + doneView + default: + formView + } + } + } + + @ViewBuilder + private var formView: some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Dein Account wird wieder nur mit Email + Passwort geschützt. Backup-Codes verlieren ihre Gültigkeit.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField("Passwort", text: $password, textContentType: .password) + + ManaPrimaryButton( + "2FA deaktivieren", + role: .destructive, + isLoading: status == .working, + isEnabled: !password.isEmpty && status != .working + ) { + Task { await submit() } + } + + if case let .error(message) = status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + @ViewBuilder + private var doneView: some View { + VStack(spacing: 16) { + Image(systemName: "lock.open") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktiviert") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + ManaPrimaryButton("Fertig") { onDone() } + .padding(.top, 16) + } + } + + private func submit() async { + guard !password.isEmpty else { return } + status = .working + do { + try await auth.disableTotp(password: password) + password = "" + status = .done + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Deaktivieren fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } +} diff --git a/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift new file mode 100644 index 0000000..00809e0 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift @@ -0,0 +1,107 @@ +import Foundation +import ManaCore +import Observation + +/// State-Maschine für ``ManaTwoFactorEnrollView``. 3-Phasen-Wizard: +/// +/// 1. **Re-Auth** — User gibt aktuelles Passwort ein +/// 2. **QR + Verify** — App zeigt QR-Code, User scannt mit Authenticator +/// und gibt zur Bestätigung einen 6-stelligen Code ein +/// 3. **Backup-Codes** — App zeigt die generierten Codes, User sichert +/// sie (Kopieren in die Zwischenablage) +/// +/// Schritte 1+2 sind atomar gegen den Server: `enrollTotp(password:)` +/// liefert URI **und** Backup-Codes in einem Call. Der Verify-Step +/// in der UI ist defensiv — der User muss zeigen können dass er den +/// QR-Code wirklich gescannt hat, bevor wir ihm die Backup-Codes +/// zeigen. Wenn er den Code nicht hat, kann er den Enroll-Vorgang +/// abbrechen und der Server-Side ist die TOTP-Konfiguration trotzdem +/// als aktiv markiert — er muss dann disableTotp(password:) aufrufen. +@MainActor +@Observable +public final class TwoFactorEnrollmentViewModel { + public enum Phase: Equatable, Sendable { + case password + case verify(uri: String, backupCodes: [String]) + case backupCodes([String]) + } + + public enum Status: Equatable, Sendable { + case idle + case working + case error(String) + } + + public var password: String = "" + public var verifyCode: String = "" + public private(set) var phase: Phase = .password + public private(set) var status: Status = .idle + + private let auth: AuthClient + + public init(auth: AuthClient) { + self.auth = auth + } + + // MARK: - Phase 1: Password + + public var canSubmitPassword: Bool { + guard !password.isEmpty else { return false } + if case .working = status { return false } + return true + } + + public var isWorking: Bool { + if case .working = status { return true } + return false + } + + public func enrollWithPassword() async { + guard canSubmitPassword else { return } + + status = .working + do { + let enrollment = try await auth.enrollTotp(password: password) + password = "" + phase = .verify(uri: enrollment.totpURI, backupCodes: enrollment.backupCodes) + status = .idle + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Aktivierung fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } + + // MARK: - Phase 2: Verify + + public var canSubmitVerify: Bool { + let digits = verifyCode.filter { $0.isNumber } + return digits.count == 6 && !isWorking + } + + /// Server-seitig ist die 2FA-Konfiguration nach `enrollTotp` schon + /// aktiv — wir nutzen `verifyTotp` nicht zur Bestätigung des Setups, + /// sondern verlassen uns auf den User dass er den QR-Code richtig + /// gescannt hat. Better-Auth-API hat keinen "verify-setup-Endpoint" + /// (verify-totp ist nur im Login-Challenge-Flow gültig). Der + /// Bestätigungs-Schritt ist also rein UI-defensiv: zeigt einen + /// Code-Input, der erstmal nur lokal die Eingabe sammelt und dann + /// in den Backup-Codes-Schritt umschaltet. + public func confirmVerify() { + if case let .verify(_, codes) = phase { + verifyCode = "" + phase = .backupCodes(codes) + status = .idle + } + } + + // MARK: - Phase 3: Backup-Codes + + /// Die generierten Backup-Codes (8-stellige Strings, üblich 10 + /// Stück). UI zeigt sie zum Kopieren/Sichern. + public var backupCodes: [String] { + if case let .backupCodes(codes) = phase { return codes } + if case let .verify(_, codes) = phase { return codes } + return [] + } +} diff --git a/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift new file mode 100644 index 0000000..1c22ac6 --- /dev/null +++ b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift @@ -0,0 +1,130 @@ +import Foundation +import ManaCore +import Testing +@testable import ManaAuthUI + +@Suite("TwoFactorEnrollmentViewModel") +@MainActor +struct TwoFactorEnrollmentViewModelTests { + private func signedInAuth() async -> MockedAuth { + let mocked = makeMockedAuth() + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { _ in + (200, Data(#"{"accessToken":"\#(access)","refreshToken":"session-tok"}"#.utf8)) + } + await mocked.auth.signIn(email: "u@x.de", password: "pw") + return mocked + } + + @Test("enrollWithPassword erfolgreich → phase wechselt auf verify") + func enrollSuccess() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/Mana:u@x.de?secret=ABC","backupCodes":["a","b","c"]} + """#.utf8)) + } + + await model.enrollWithPassword() + if case let .verify(uri, codes) = model.phase { + #expect(uri.hasPrefix("otpauth://totp/")) + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .verify, got \(model.phase)") + } + #expect(model.password == "") // out of memory + } + + @Test("enrollWithPassword falsches PW → .error, phase bleibt password") + func enrollWrongPassword() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "wrong" + + mocked.setHandler { _ in + (401, Data(#"{"error":"INVALID_CREDENTIALS","status":401}"#.utf8)) + } + + await model.enrollWithPassword() + if case .password = model.phase { + #expect(Bool(true)) + } else { + Issue.record("Expected .password, got \(model.phase)") + } + if case let .error(message) = model.status { + #expect(message == "Email oder Passwort falsch") + } else { + Issue.record("Expected .error, got \(model.status)") + } + } + + @Test("canSubmitVerify fordert 6 Ziffern") + func canSubmitVerify() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a"]} + """#.utf8)) + } + await model.enrollWithPassword() + + model.verifyCode = "" + #expect(model.canSubmitVerify == false) + model.verifyCode = "12345" + #expect(model.canSubmitVerify == false) + model.verifyCode = "123456" + #expect(model.canSubmitVerify == true) + model.verifyCode = "abcdef" + #expect(model.canSubmitVerify == false) + } + + @Test("confirmVerify wechselt von verify auf backupCodes") + func confirmVerifySwitchesPhase() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a","b","c"]} + """#.utf8)) + } + await model.enrollWithPassword() + model.verifyCode = "123456" + + model.confirmVerify() + if case let .backupCodes(codes) = model.phase { + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .backupCodes, got \(model.phase)") + } + #expect(model.verifyCode == "") + } + + @Test("backupCodes computed property returnt Codes aus verify- und backupCodes-Phase") + func backupCodesAccessor() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + // Phase .password → keine Codes + #expect(model.backupCodes == []) + + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["c1","c2"]} + """#.utf8)) + } + await model.enrollWithPassword() + // Phase .verify → Codes verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + + model.verifyCode = "123456" + model.confirmVerify() + // Phase .backupCodes → Codes weiter verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + } +} From 117538f77a2e95b24010d995c07d93efe1a27708 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 01:08:41 +0200 Subject: [PATCH 3/7] =?UTF-8?q?v0.5.0=20=E2=80=94=20ManaTwoFactorAccountRo?= =?UTF-8?q?w=20+=20ManaBackupCodeRegenerateView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macht den 2FA-Vollausbau in der AccountView nutzbar. Setzt mana-swift-core ≥ 1.5.0 voraus. ManaTwoFactorAccountRow — Drop-in für AccountView: - Holt 2FA-Status via AuthClient.getProfile() - Off → "Zwei-Faktor aktivieren" → ManaTwoFactorEnrollView - An → "Zwei-Faktor aktiv" + "Backup-Codes erneuern" + "Deaktivieren" ManaBackupCodeRegenerateView — Re-Auth via Passwort, zeigt neue Backup-Codes mit Copy-to-Clipboard. TwoFactorAccountRowModel — internes @Observable-VM, reloaded Status nach Enroll/Disable/Regenerate. Plus: .gitignore um build/ erweitert (Xcode-build/ war vorher nicht abgedeckt, nur Swift-Package-.build/). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + CHANGELOG.md | 22 ++ .../TwoFactor/ManaTwoFactorAccountRow.swift | 310 ++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift diff --git a/.gitignore b/.gitignore index 89631fb..1821213 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.xcodeproj Package.resolved .DS_Store +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2648756..6bb2766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.5.0] — 2026-05-14 + +Minor — `ManaTwoFactorAccountRow` + `ManaBackupCodeRegenerateView`. +Macht den 2FA-Vollausbau in der AccountView nutzbar. Setzt +mana-swift-core ≥ 1.5.0 voraus (`getProfile()`). + +### Neu + +- `ManaTwoFactorAccountRow` — Drop-in für AccountView. Holt den + 2FA-Status via `AuthClient.getProfile()` und zeigt: + - **Off:** "Zwei-Faktor aktivieren" → öffnet `ManaTwoFactorEnrollView` + - **An:** "Zwei-Faktor aktiv" + "Backup-Codes erneuern" + + "Zwei-Faktor deaktivieren" +- `ManaBackupCodeRegenerateView` — Re-Auth via Passwort, zeigt neue + Backup-Codes + Copy-to-Clipboard. +- `TwoFactorAccountRowModel` — internes `@Observable`-VM, reloaded + Status nach Enroll/Disable/Regenerate. + +Damit ist 2FA in den Apps end-to-end nutzbar — User kann aktivieren, +Backup-Codes verwalten, deaktivieren. Der Login-Flow ist seit v0.3.0 +durchgängig. + ## [0.4.0] — 2026-05-14 Minor — 2FA-Enrollment-UI (Mini-Sprint B). Setzt mana-swift-core diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift new file mode 100644 index 0000000..e925cb6 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift @@ -0,0 +1,310 @@ +import ManaCore +import Observation +import SwiftUI + +/// Account-Section-Block für die 2FA-Verwaltung. Apps bauen den +/// einfach in ihre AccountView ein: +/// +/// ```swift +/// ManaTwoFactorAccountRow(auth: auth) +/// .manaBrand(brand) +/// ``` +/// +/// Die Row holt den 2FA-Status beim ersten Erscheinen via +/// `AuthClient.getProfile()` und zeigt dann entweder: +/// - "Zwei-Faktor aktivieren" (Enroll-Sheet) bei `twoFactorEnabled == false` +/// - "Zwei-Faktor deaktivieren" + "Backup-Codes erneuern" bei `true` +/// +/// Nach Enroll/Disable wird der Status automatisch neu geladen, +/// damit die Row sich konsistent updated. +@MainActor +@Observable +final class TwoFactorAccountRowModel { + enum LoadState: Equatable { + case loading + case loaded(twoFactorEnabled: Bool) + case error(String) + } + + private(set) var state: LoadState = .loading + private let auth: AuthClient + + init(auth: AuthClient) { + self.auth = auth + } + + func reload() async { + state = .loading + do { + let profile = try await auth.getProfile() + state = .loaded(twoFactorEnabled: profile.twoFactorEnabled) + } catch let error as AuthError { + if case .notSignedIn = error { + state = .error("Nicht angemeldet") + } else { + state = .error(error.errorDescription ?? "Status konnte nicht geladen werden") + } + } catch { + state = .error(String(describing: error)) + } + } +} + +public struct ManaTwoFactorAccountRow: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorAccountRowModel + @State private var showEnroll = false + @State private var showDisable = false + @State private var showRegenerate = false + private let auth: AuthClient + + public init(auth: AuthClient) { + self.auth = auth + _model = State(initialValue: TwoFactorAccountRowModel(auth: auth)) + } + + public var body: some View { + Group { + switch model.state { + case .loading: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("2FA-Status lädt…") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + case let .loaded(enabled): + if enabled { + enabledRow + } else { + disabledRow + } + case let .error(message): + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + } + } + .task { + await model.reload() + } + .sheet(isPresented: $showEnroll, onDismiss: { + Task { await model.reload() } + }) { + ManaTwoFactorEnrollView(auth: auth, onDone: { showEnroll = false }) + .manaBrand(brand) + } + .sheet(isPresented: $showDisable, onDismiss: { + Task { await model.reload() } + }) { + ManaTwoFactorDisableView(auth: auth, onDone: { showDisable = false }) + .manaBrand(brand) + } + .sheet(isPresented: $showRegenerate, onDismiss: { + Task { await model.reload() } + }) { + ManaBackupCodeRegenerateView(auth: auth, onDone: { showRegenerate = false }) + .manaBrand(brand) + } + } + + @ViewBuilder + private var disabledRow: some View { + Button(action: { showEnroll = true }) { + HStack { + Image(systemName: "lock.shield") + .foregroundStyle(brand.mutedForeground) + VStack(alignment: .leading, spacing: 2) { + Text("Zwei-Faktor aktivieren") + .foregroundStyle(brand.foreground) + Text("TOTP-App mit Backup-Codes") + .font(.caption) + .foregroundStyle(brand.mutedForeground) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(brand.mutedForeground) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var enabledRow: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "checkmark.shield.fill") + .foregroundStyle(brand.success) + Text("Zwei-Faktor aktiv") + .foregroundStyle(brand.foreground) + Spacer() + } + + Button(action: { showRegenerate = true }) { + Text("Backup-Codes erneuern") + .font(.subheadline) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + Button(role: .destructive, action: { showDisable = true }) { + Text("Zwei-Faktor deaktivieren") + .font(.subheadline) + .foregroundStyle(brand.error) + } + .buttonStyle(.plain) + } + } + +} + +/// Sheet zum Erneuern der Backup-Codes. Re-Auth via Passwort, +/// zeigt danach die neuen Codes. +public struct ManaBackupCodeRegenerateView: View { + @Environment(\.manaBrand) private var brand + @State private var password: String = "" + @State private var newCodes: [String] = [] + @State private var status: Status = .idle + private let auth: AuthClient + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + self.auth = auth + self.onDone = onDone + } + + private enum Status: Equatable { + case idle + case working + case done + case error(String) + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch status { + case .done: + doneView + default: + formView + } + } + } + + @ViewBuilder + private var formView: some View { + VStack(spacing: 16) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text("Backup-Codes erneuern") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Die alten Codes werden ungültig. Bestätige mit deinem Passwort.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField("Passwort", text: $password, textContentType: .password) + + ManaPrimaryButton( + "Neue Codes generieren", + isLoading: status == .working, + isEnabled: !password.isEmpty && status != .working + ) { + Task { await submit() } + } + + if case let .error(message) = status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + @ViewBuilder + private var doneView: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.success) + + Text("Neue Codes generiert") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Sichere diese Codes JETZT. Alte Codes sind ungültig.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + VStack(spacing: 6) { + ForEach(newCodes, id: \.self) { code in + Text(code) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(brand.foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.vertical, 8) + + Button(action: { copyToClipboard(newCodes.joined(separator: "\n")) }) { + Label("Alle Codes kopieren", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + ManaPrimaryButton("Fertig — Codes sind gesichert") { + onDone() + } + .padding(.top, 12) + } + } + + private func submit() async { + status = .working + do { + newCodes = try await auth.regenerateBackupCodes(password: password) + password = "" + status = .done + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Erneuerung fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } + + private func copyToClipboard(_ text: String) { + #if canImport(UIKit) + UIPasteboard.general.string = text + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #endif + } +} + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif From e284240f3c56f74add42728b379a63f4c99723a2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 15 May 2026 22:21:39 +0200 Subject: [PATCH 4/7] =?UTF-8?q?devlog:=201=20Tag=20geschrieben=20(v0.1.0?= =?UTF-8?q?=E2=80=93v0.5.0=20Sprint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManaAuthUI-Initialsprint mit 5 Versions-Schritten in einer Session. spieler.md + macher.md hand-curated. Co-Authored-By: Claude Opus 4.7 (1M context) --- devlog/2026-05-13/data.json | 126 +++++++++++++++++++++++++++++++++++ devlog/2026-05-13/macher.md | 79 ++++++++++++++++++++++ devlog/2026-05-13/spieler.md | 35 ++++++++++ 3 files changed, 240 insertions(+) create mode 100644 devlog/2026-05-13/data.json create mode 100644 devlog/2026-05-13/macher.md create mode 100644 devlog/2026-05-13/spieler.md diff --git a/devlog/2026-05-13/data.json b/devlog/2026-05-13/data.json new file mode 100644 index 0000000..81727ad --- /dev/null +++ b/devlog/2026-05-13/data.json @@ -0,0 +1,126 @@ +{ + "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 new file mode 100644 index 0000000..9042a76 --- /dev/null +++ b/devlog/2026-05-13/macher.md @@ -0,0 +1,79 @@ +--- +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 new file mode 100644 index 0000000..10ed6e2 --- /dev/null +++ b/devlog/2026-05-13/spieler.md @@ -0,0 +1,35 @@ +--- +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. From 8f4d4b0c03a7fd6625d1c2492bb9474a19e3a41d Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 17 May 2026 21:11:47 +0200 Subject: [PATCH 5/7] feat(webshell): neues Library-Product ManaWebShell (v0.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WKWebView-Huelle fuer 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 V2. Neu (public API): - `WebShellView` — WKWebView-Wrapper mit Progress-Bar, Pull-to- Refresh (iOS), Fehler-Snackbar, External-Link-Delegation. Universal (iOS + macOS) - `WebShellConfig` — Host-Whitelist mit Wildcard-Support (`"*.mana.how"`), User-Agent, Theme-Hints, User-Scripts - `WebTarget` — URL + monoton wachsender reloadToken - `WebNavState` — @Observable, @MainActor, reaktiver Nav-State - `WebShellCoordinator` — WKNavigationDelegate + WKUIDelegate - `WebShellScripts` — Helfer fuer `preferDarkScheme`, `syncDarkMode(localStorageKey:)`, `hideElements(selectors:tagName:)` Logging unter Subsystem `ev.mana.webshell` (App-OSLog bleibt eigen). Tests: 6 neue Tests gegen `WebShellConfig.isAllowed` (Wildcards, Negativ-Cases). 50/50 grün insgesamt (6 ManaWebShell + 44 ManaAuthUI). Doku: `mana/docs/playbooks/HYBRID_NATIVE_APP.md` (Schwester-Repo). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 47 +++++ Package.swift | 13 ++ Sources/ManaWebShell/WebNavState.swift | 16 ++ Sources/ManaWebShell/WebShellConfig.swift | 94 +++++++++ .../ManaWebShell/WebShellCoordinator.swift | 147 ++++++++++++++ Sources/ManaWebShell/WebShellScripts.swift | 119 ++++++++++++ Sources/ManaWebShell/WebShellView.swift | 183 ++++++++++++++++++ Sources/ManaWebShell/WebTarget.swift | 14 ++ .../WebShellConfigTests.swift | 56 ++++++ 9 files changed, 689 insertions(+) create mode 100644 Sources/ManaWebShell/WebNavState.swift create mode 100644 Sources/ManaWebShell/WebShellConfig.swift create mode 100644 Sources/ManaWebShell/WebShellCoordinator.swift create mode 100644 Sources/ManaWebShell/WebShellScripts.swift create mode 100644 Sources/ManaWebShell/WebShellView.swift create mode 100644 Sources/ManaWebShell/WebTarget.swift create mode 100644 Tests/ManaWebShellTests/WebShellConfigTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb2766..ea52c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ 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 482de59..7b28ec9 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ let package = Package( ], products: [ .library(name: "ManaAuthUI", targets: ["ManaAuthUI"]), + .library(name: "ManaWebShell", targets: ["ManaWebShell"]), ], dependencies: [ // Lokaler Dev-Pfad. Apps konsumieren beide Pakete parallel über @@ -29,10 +30,22 @@ 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 new file mode 100644 index 0000000..c467daf --- /dev/null +++ b/Sources/ManaWebShell/WebNavState.swift @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..2570d0f --- /dev/null +++ b/Sources/ManaWebShell/WebShellConfig.swift @@ -0,0 +1,94 @@ +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 new file mode 100644 index 0000000..ab349cb --- /dev/null +++ b/Sources/ManaWebShell/WebShellCoordinator.swift @@ -0,0 +1,147 @@ +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 new file mode 100644 index 0000000..3291b89 --- /dev/null +++ b/Sources/ManaWebShell/WebShellScripts.swift @@ -0,0 +1,119 @@ +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 new file mode 100644 index 0000000..98cc341 --- /dev/null +++ b/Sources/ManaWebShell/WebShellView.swift @@ -0,0 +1,183 @@ +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 new file mode 100644 index 0000000..682525d --- /dev/null +++ b/Sources/ManaWebShell/WebTarget.swift @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..46bddcb --- /dev/null +++ b/Tests/ManaWebShellTests/WebShellConfigTests.swift @@ -0,0 +1,56 @@ +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")) + } +} From d621cb83728cd50bf2085f1c32d19ee2007b108a Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 22 May 2026 12:29:19 +0200 Subject: [PATCH 6/7] =?UTF-8?q?feat(brand):=20logoAssetName=20f=C3=BCr=20C?= =?UTF-8?q?ustom-Logos=20(v0.7.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManaBrandConfig.logoAssetName ergänzt — Apps liefern einen Asset- Catalog-Namen, ManaAuthScaffold rendert das Bundle-Asset 64×64pt ohne Tint statt eines getinteten SF-Symbols. logoSymbol bleibt Fallback. Hintergrund: Pageta hat ein eigenes Apple-Icon-Composer-SVG; SF- Symbol "book.pages" sah neben dem polierten App-Icon unecht aus. Additive Änderung, alle bestehenden Apps quellkompatibel. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 47 +++++++++++++++++++ .../ManaAuthUI/Brand/ManaBrandConfig.swift | 13 +++++ .../Components/ManaAuthScaffold.swift | 11 ++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea52c58..38ce2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.7.0] — 2026-05-22 + +Minor — **`logoAssetName`** in `ManaBrandConfig`. Apps können jetzt +ein eigenes Logo-Asset (Asset-Catalog-Name) statt eines SF-Symbols +für den Login-/Sign-Up-/Forgot-Password-Header liefern. + +### Hintergrund + +Pageta hat ein eigenes Apple-Icon-Composer-SVG; das SF-Symbol +`book.pages` (vorher) sah neben dem polierten App-Icon unecht aus. +Andere Apps mit echten Logo-Assets (kommt) werden den gleichen +Migrationspfad gehen können. + +### Neu + +- `ManaBrandConfig.logoAssetName: String?` — Name eines Image-Assets + im Bundle der konsumierenden App. Hat Vorrang vor `logoSymbol`. +- `ManaAuthScaffold` rendert `logoAssetName` 64×64pt, `aspectRatio(.fit)`, + ohne Tint (Asset behält Originalfarben — typisch Apple-Icon- + Composer-Output mit Gradient). Fallback bleibt SF-Symbol mit Tint. + +### Geändert + +- `ManaBrandConfig.init` hat einen zusätzlichen optionalen Parameter + `logoAssetName: String? = nil`. Quellkompatibel — bestehende Apps + brauchen nichts ändern. +- `systemDefault`-Config setzt `logoAssetName: nil` explizit (kein + Verhaltenswechsel). + +### Tests + +- 50/50 grün (keine neuen Tests — die `ManaBrandConfig`-Änderung ist + rein additiv, gerendertes Asset hängt am Bundle der App). + +### Adoption + +Apps mit eigenem Logo: + +```swift +ManaBrandConfig( + appName: "Pageta", + logoSymbol: "book.pages", // SF-Fallback bleibt + logoAssetName: "PagetaLogo", // Asset-Catalog-Name, hat Vorrang + ... +) +``` + ## [0.6.0] — 2026-05-17 Minor — **neues Library-Product `ManaWebShell`**. WKWebView-Hülle für diff --git a/Sources/ManaAuthUI/Brand/ManaBrandConfig.swift b/Sources/ManaAuthUI/Brand/ManaBrandConfig.swift index d313285..2879f0b 100644 --- a/Sources/ManaAuthUI/Brand/ManaBrandConfig.swift +++ b/Sources/ManaAuthUI/Brand/ManaBrandConfig.swift @@ -23,8 +23,18 @@ public struct ManaBrandConfig: Sendable { /// Optionales SF-Symbol, das zentral über dem App-Namen erscheint. /// Z.B. `"rectangle.stack.fill"` für Cardecky, `"map.fill"` für /// Manaspur. Wenn nil, wird kein Icon gerendert. + /// + /// Wenn ``logoAssetName`` gesetzt ist, hat das Vorrang — das + /// SF-Symbol dient als Fallback. public let logoSymbol: String? + /// Optionaler Asset-Catalog-Name eines App-spezifischen Logos + /// (z.B. SVG aus dem App Icon Composer). Hat Vorrang vor + /// ``logoSymbol``. Das Asset muss im Bundle der konsumierenden App + /// liegen und Template-fähig sein, wenn es brand.primary annehmen + /// soll — sonst wird's in Originalfarben gerendert. + public let logoAssetName: String? + // MARK: - Theme-Farben /// Seiten-Hintergrund. @@ -58,6 +68,7 @@ public struct ManaBrandConfig: Sendable { appName: String, tagline: String? = nil, logoSymbol: String? = nil, + logoAssetName: String? = nil, background: Color, foreground: Color, surface: Color, @@ -71,6 +82,7 @@ public struct ManaBrandConfig: Sendable { self.appName = appName self.tagline = tagline self.logoSymbol = logoSymbol + self.logoAssetName = logoAssetName self.background = background self.foreground = foreground self.surface = surface @@ -91,6 +103,7 @@ public extension ManaBrandConfig { appName: "mana", tagline: nil, logoSymbol: nil, + logoAssetName: nil, background: PlatformPalette.background, foreground: .primary, surface: PlatformPalette.surface, diff --git a/Sources/ManaAuthUI/Components/ManaAuthScaffold.swift b/Sources/ManaAuthUI/Components/ManaAuthScaffold.swift index e31610f..589bd81 100644 --- a/Sources/ManaAuthUI/Components/ManaAuthScaffold.swift +++ b/Sources/ManaAuthUI/Components/ManaAuthScaffold.swift @@ -47,7 +47,16 @@ public struct ManaAuthScaffold: View { @ViewBuilder private var header: some View { VStack(spacing: 12) { - if let symbol = brand.logoSymbol { + if let assetName = brand.logoAssetName { + // App-spezifisches Logo aus dem Bundle der konsumierenden + // App. Größere 64pt-Variante, damit ein detailliertes + // Custom-Logo seinen Charakter zeigt (statt nur als + // SF-Symbol-Stand-In zu wirken). + Image(assetName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + } else if let symbol = brand.logoSymbol { Image(systemName: symbol) .font(.system(size: 44, weight: .medium)) .foregroundStyle(brand.primary) From ad9dc1abba5c9be88965295ca98df7584222b156 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 22 May 2026 14:19:58 +0200 Subject: [PATCH 7/7] =?UTF-8?q?v0.8.0=20=E2=80=94=20feat(llm-ui):=20neues?= =?UTF-8?q?=20Library-Product=20ManaLLMUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop-in-Settings-UI für die lokalen LLM-Backends aus mana-swift-llm. Pendant zu ManaAuthUI — vorher hatte nur Memoro die UI handgeschrieben, die drei anderen Konsumenten (pageta, comicello, herbatrium) gar nichts. Komponenten: - ManaLLMSettingsView(context:) — Convenience-Wrapper, drei Sections - ManaLLMBackendPickerSection — Picker + Availability + Empfohlen-Badge - ManaLLMPrepareSection — Download/Init-Card mit Progress, gated für Gemma - ManaLLMDownloadPolicySection — WiFi-only-Toggle - ManaLLMSettingsState (@Observable, @MainActor) — geteilter State, delegiert an Stores aus mana-swift-llm 0.2.0 - ManaLLMContext(useCaseShort:useCaseLong:) — app-spezifischer Section-Text; .generic als Fallback Test-Target ManaLLMUITests bewusst noch nicht angelegt (Linter hat es aus Package.swift entfernt, Comment markiert TODO). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 48 +++++ Package.swift | 21 ++ Sources/ManaLLMUI/ByteFormatter.swift | 14 ++ .../ManaLLMBackendPickerSection.swift | 144 +++++++++++++ Sources/ManaLLMUI/ManaLLMContext.swift | 33 +++ .../ManaLLMDownloadPolicySection.swift | 35 +++ Sources/ManaLLMUI/ManaLLMPrepareSection.swift | 200 ++++++++++++++++++ Sources/ManaLLMUI/ManaLLMSettingsState.swift | 141 ++++++++++++ Sources/ManaLLMUI/ManaLLMSettingsView.swift | 43 ++++ 9 files changed, 679 insertions(+) create mode 100644 Sources/ManaLLMUI/ByteFormatter.swift create mode 100644 Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift create mode 100644 Sources/ManaLLMUI/ManaLLMContext.swift create mode 100644 Sources/ManaLLMUI/ManaLLMDownloadPolicySection.swift create mode 100644 Sources/ManaLLMUI/ManaLLMPrepareSection.swift create mode 100644 Sources/ManaLLMUI/ManaLLMSettingsState.swift create mode 100644 Sources/ManaLLMUI/ManaLLMSettingsView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ce2c9..8edfcf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,54 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.8.0] — 2026-05-22 + +Minor — **neues Library-Product `ManaLLMUI`**. Drop-in-Settings-UI +für die lokalen LLM-Backends aus `mana-swift-llm`. Pendant zu +`ManaAuthUI` — vorher hatte nur Memoro die UI handgeschrieben, die +drei anderen Konsumenten (pageta, comicello, herbatrium) hatten +gar nichts. + +### Hintergrund + +Vier Apps konsumieren `ManaLLM` (Memoro, Pageta, Comicello, Herbatrium). +Memoros 250-Zeilen-LLM-UI (Picker + Prepare + Cellular-Toggle) war +strukturell shared-fähig — wurde rausgehoben, generalisiert und steht +jetzt allen Apps zur Verfügung. + +### Neu + +- `ManaLLMUI`-Product (deps: `ManaLLM`, `ManaLLMShared`) +- `ManaLLMSettingsView` — Convenience-Wrapper, drei Sections in + einem Schwung +- `ManaLLMBackendPickerSection` — Picker mit Availability-Status + + Empfohlen-Badge +- `ManaLLMPrepareSection` — Download/Init-Card mit Progress, nur + sichtbar für Gemma-Backends (`shouldShow(for:)`-Gate) +- `ManaLLMDownloadPolicySection` — WiFi-only-Toggle +- `ManaLLMSettingsState` — `@Observable`-State-Klasse, hält + Backend-Wahl + Availability + Prepare-Progress + delegiert an die + Stores aus `mana-swift-llm` +- `ManaLLMContext` — App-spezifischer Kontext (`useCaseShort` + + `useCaseLong`) für Section-Texte. `.generic` als Fallback + +### Migration + +Apps die heute eigene LLM-Settings-UI haben (Memoro): +- `import ManaLLM` für die Settings-View durch `import ManaLLMUI` ersetzen +- Lokale `llmSection`/`llmPrepareSection`/`llmDownloadPolicySection`- + Bodies entfernen +- `ManaLLMSettingsView(context: ...)` einhängen +- `LLMBackendPreferenceStore` + `LLMDownloadOverCellularStore` bleiben + funktionsgleich — wandern aber nach `ManaLLM` (siehe + `mana-swift-llm` CHANGELOG) + +Apps die heute keine LLM-Settings-UI haben (Pageta, Comicello, +Herbatrium): +- `ManaLLMUI`-Product in `project.yml` adden +- `ManaLLMSettingsView(context: ...)` an passender Stelle einhängen + (eigene Settings-View oder NavigationLink im Profile-Tab) + ## [0.7.0] — 2026-05-22 Minor — **`logoAssetName`** in `ManaBrandConfig`. Apps können jetzt diff --git a/Package.swift b/Package.swift index 7b28ec9..9602174 100644 --- a/Package.swift +++ b/Package.swift @@ -11,12 +11,18 @@ let package = Package( products: [ .library(name: "ManaAuthUI", targets: ["ManaAuthUI"]), .library(name: "ManaWebShell", targets: ["ManaWebShell"]), + .library(name: "ManaLLMUI", targets: ["ManaLLMUI"]), ], dependencies: [ // Lokaler Dev-Pfad. Apps konsumieren beide Pakete parallel über // `path: ../mana-swift-core` bzw. `path: ../mana-swift-ui`. // Release-Wechsel auf `from: "1.1.0"` kommt mit Phase 4. .package(path: "../mana-swift-core"), + // ManaLLMUI baut auf den Backend-Schicht aus mana-swift-llm. + // Apps die nur die Backends headless brauchen, importieren + // weiter direkt `ManaLLM` — ManaLLMUI ist additiv für die + // Settings-Schicht (Picker, Prepare, Cellular-Toggle). + .package(path: "../mana-swift-llm"), ], targets: [ .target( @@ -37,6 +43,17 @@ let package = Package( .enableExperimentalFeature("StrictConcurrency"), ] ), + .target( + name: "ManaLLMUI", + dependencies: [ + .product(name: "ManaLLM", package: "mana-swift-llm"), + .product(name: "ManaLLMShared", package: "mana-swift-llm"), + ], + path: "Sources/ManaLLMUI", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] + ), .testTarget( name: "ManaAuthUITests", dependencies: ["ManaAuthUI"], @@ -47,5 +64,9 @@ let package = Package( dependencies: ["ManaWebShell"], path: "Tests/ManaWebShellTests" ), + // ManaLLMUITests: deklariert, aber `Tests/ManaLLMUITests/` + // wurde nie angelegt — SPM verweigert Resolve. Entfernt + // 2026-05-22, wieder einfügen sobald die ersten ViewModel- + // Tests stehen. ] ) diff --git a/Sources/ManaLLMUI/ByteFormatter.swift b/Sources/ManaLLMUI/ByteFormatter.swift new file mode 100644 index 0000000..73f9929 --- /dev/null +++ b/Sources/ManaLLMUI/ByteFormatter.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Schmaler interner Helper für Bytes-Anzeige in der UI. Bewusst kein +/// `public`, weil das nicht Teil der ManaLLMUI-API ist — Apps sollen +/// ihren eigenen `ByteCountFormatter` haben, wenn sie Bytes +/// formatieren müssen. +enum ByteFormatter { + static func string(fromByteCount bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} diff --git a/Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift b/Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift new file mode 100644 index 0000000..d4a9d0c --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMBackendPickerSection.swift @@ -0,0 +1,144 @@ +import ManaLLM +import SwiftUI + +/// Picker über alle `LLMBackendID.allCases` mit Verfügbarkeits-Status, +/// "Empfohlen"-Badge, Icon-Mapping und Backend-spezifischem Footer. +/// +/// Schreibt bei Auswahl in `LLMBackendPreferenceStore` (via State). +/// Sortiert nicht — `LLMBackendID.allCases`-Reihenfolge ist die UX- +/// Reihenfolge (noOp → appleFM → gemmaE2B → gemmaE4B). +public struct ManaLLMBackendPickerSection: View { + @Bindable private var state: ManaLLMSettingsState + private let context: ManaLLMContext + + public init( + state: ManaLLMSettingsState, + context: ManaLLMContext = .generic + ) { + self.state = state + self.context = context + } + + public var body: some View { + Section { + Picker(context.useCaseShort, selection: backendBinding) { + ForEach(LLMBackendID.allCases, id: \.self) { id in + row(for: id).tag(id) + } + } + .pickerStyle(.inline) + .labelsHidden() + } header: { + Text(context.useCaseShort) + } footer: { + Text(footerText) + .font(.caption2) + } + } + + // MARK: - Binding (Picker schreibt durch state.setBackend) + + private var backendBinding: Binding { + Binding( + get: { state.backend }, + set: { state.setBackend($0) } + ) + } + + // MARK: - Row + + @ViewBuilder + private func row(for id: LLMBackendID) -> some View { + let availability = state.availability[id] ?? .unknown("checking") + let isSelectable = availability.isSelectable || id == .noOp + HStack(spacing: 10) { + Image(systemName: icon(for: id)) + .frame(width: 22) + .foregroundStyle(isSelectable ? .primary : .secondary) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(id.displayName) + .foregroundStyle(isSelectable ? .primary : .secondary) + if isRecommended(id) { + Text("Empfohlen") + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.15), in: Capsule()) + .foregroundStyle(Color.accentColor) + } + } + Text(availabilityShortText(for: id, status: availability)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Empfehlung + + /// `gemmaE2B` ist "Empfohlen", wenn Apple FM auf diesem Gerät nicht + /// verfügbar ist — der natürliche On-Device-Fallback für User ohne + /// Apple Intelligence. + private func isRecommended(_ id: LLMBackendID) -> Bool { + guard id == .gemmaE2B else { return false } + let appleStatus = state.availability[.appleFM] ?? .unknown("") + return appleStatus != .available + } + + private func icon(for id: LLMBackendID) -> String { + switch id { + case .noOp: "text.alignleft" + case .appleFM: "apple.logo" + case .gemmaE2B, .gemmaE4B: "g.circle" + } + } + + private func availabilityShortText(for id: LLMBackendID, status: LLMAvailability) -> String { + switch status { + case .available: + id == .noOp ? "Immer verfügbar" : "Bereit" + case let .requiresDownload(bytes): + "Lädt \(ByteFormatter.string(fromByteCount: bytes)) beim ersten Use" + case let .downloading(fraction): + "Lädt … \(Int(fraction * 100)) %" + case .unavailableDeviceNotEligible: + "Gerät zu alt" + case .unavailableAppleIntelligenceNotEnabled: + "Apple Intelligence in iOS-Settings aktivieren" + case .unavailableModelNotReady: + "Modell lädt im Hintergrund …" + case .unavailableOSTooOld: + "iOS 26+ erforderlich" + case let .unavailableMissingDependency(name): + "Fehlt: \(name)" + case let .unknown(detail): + "Status: \(detail)" + } + } + + // MARK: - Footer + + private var footerText: String { + // App-Kontext + Modell-Fakten getrennt formuliert. So bleibt der + // Modell-Beschreibungs-Block backend-agnostisch und der + // App-Kontext eine Zeile davor. + let intro = "Die App nutzt das gewählte Modell für: \(context.useCaseLong)." + let modelFacts = switch state.backend { + case .noOp: + "Aktuell gewählt: Kein LLM (Fallback). Der erste Satz des Eingabe-Texts wird " + + "als Überschrift verwendet. Schnell, keine KI, kein Download." + case .appleFM: + "Aktuell gewählt: Apple Foundation Models (~3 B). Kein Download, ANE-beschleunigt. " + + "Funktioniert nur auf Geräten mit Apple Intelligence (iPhone 15 Pro+, M-iPads). " + + "4096 Token Window." + case .gemmaE2B: + "Aktuell gewählt: Gemma 4 E2B (Apache 2.0). Lädt einmalig ~1.3 GB von Hugging Face. " + + "Läuft auf iPhone 14 Pro+ und allen M-iPads. 256 K Token Window." + case .gemmaE4B: + "Aktuell gewählt: Gemma 4 E4B (Apache 2.0). Lädt einmalig ~2.5 GB. Bessere Qualität " + + "als E2B, braucht iPhone 15 Pro+ oder M-iPad. 256 K Token Window." + } + return intro + "\n\n" + modelFacts + } +} diff --git a/Sources/ManaLLMUI/ManaLLMContext.swift b/Sources/ManaLLMUI/ManaLLMContext.swift new file mode 100644 index 0000000..60e1c4d --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMContext.swift @@ -0,0 +1,33 @@ +import Foundation + +/// App-spezifischer Kontext für die Section-Texte. Apps reichen ein +/// `ManaLLMContext` rein, und die Sections erweitern ihre Footer- +/// und Subtitle-Texte um diesen Kontext. +/// +/// **Beispiele:** +/// - Memoro: `ManaLLMContext(useCaseShort: "Headline + Intro", useCaseLong: "destilliert lange Audio-Transkripte in kurze Texte")` +/// - Pageta: `ManaLLMContext(useCaseShort: "Artikel-Zusammenfassung", useCaseLong: "fasst Artikel in zwei Sätze zusammen")` +/// - Comicello: `ManaLLMContext(useCaseShort: "Story-Synopsis", useCaseLong: "generiert eine kurze Synopsis zu einer Story")` +/// - Herbatrium: `ManaLLMContext(useCaseShort: "Pflanzen-Notizen", useCaseLong: "verdichtet Pflegenotizen in eine kurze Zusammenfassung")` +/// +/// Wenn nicht gesetzt (`nil`), nutzen die Sections generische Texte. +public struct ManaLLMContext: Equatable, Sendable { + /// Kurzer Label-Text, taucht z.B. als Section-Header auf + /// ("KI-Modell für Headline + Intro"). + public let useCaseShort: String + + /// Längerer Erklärtext, taucht im Section-Footer auf + /// ("Die App nutzt das Modell für: ."). + public let useCaseLong: String + + public init(useCaseShort: String, useCaseLong: String) { + self.useCaseShort = useCaseShort + self.useCaseLong = useCaseLong + } + + /// Generischer Fallback wenn die App keinen Kontext mitgibt. + public static let generic = ManaLLMContext( + useCaseShort: "Lokale KI", + useCaseLong: "Texte lokal zusammenfasst, klassifiziert oder generiert" + ) +} diff --git a/Sources/ManaLLMUI/ManaLLMDownloadPolicySection.swift b/Sources/ManaLLMUI/ManaLLMDownloadPolicySection.swift new file mode 100644 index 0000000..20b27b6 --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMDownloadPolicySection.swift @@ -0,0 +1,35 @@ +import ManaLLM +import SwiftUI + +/// WiFi-only-Default-Toggle für Modell-Downloads. Apps die nur Apple FM +/// und NoOp anbieten brauchen die Section nicht — der Toggle hat dann +/// keine Wirkung. `ManaLLMSettingsView` zeigt die Section trotzdem, +/// weil der Default-Pool aller Backends Gemma enthält. +public struct ManaLLMDownloadPolicySection: View { + @Bindable private var state: ManaLLMSettingsState + + public init(state: ManaLLMSettingsState) { + self.state = state + } + + public var body: some View { + Section { + Toggle("Modelle auch über Mobilfunk laden", isOn: cellularBinding) + } header: { + Text("Modell-Download") + } footer: { + Text( + "Standard: nur über WLAN. Gemma-Modelle sind 1.3–2.5 GB groß — " + + "über Mobilfunk verbraucht das spürbar Datenvolumen." + ) + .font(.caption2) + } + } + + private var cellularBinding: Binding { + Binding( + get: { state.allowCellular }, + set: { state.setAllowCellular($0) } + ) + } +} diff --git a/Sources/ManaLLMUI/ManaLLMPrepareSection.swift b/Sources/ManaLLMUI/ManaLLMPrepareSection.swift new file mode 100644 index 0000000..fe6d063 --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMPrepareSection.swift @@ -0,0 +1,200 @@ +import ManaLLM +import SwiftUI + +/// Status-Card mit Spinner/Check/Fail, "Modell laden" / "Modell +/// entfernen" und linearer Progress-Bar mit Byte-Anzeige. Nur sinnvoll +/// für Backends die einen Caller-Cache haben — heute Gemma. Apple FM +/// und NoOp brauchen die Section nicht (`shouldShow` ist `false`). +/// +/// Apps die `ManaLLMSettingsView` benutzen kriegen die Section +/// automatisch (mit Sichtbarkeits-Gate). Wer die Section solo +/// einhängt, sollte den Gate selber abfragen: +/// +/// ```swift +/// if ManaLLMPrepareSection.shouldShow(for: state) { +/// ManaLLMPrepareSection(state: state) +/// } +/// ``` +public struct ManaLLMPrepareSection: View { + @Bindable private var state: ManaLLMSettingsState + + public init(state: ManaLLMSettingsState) { + self.state = state + } + + /// Sichtbarkeits-Gate. Heute: nur für Gemma-Backends Section + /// rendern. Apple FM hat kein Caller-Cache, NoOp trivialerweise + /// auch nicht. + public static func shouldShow(for state: ManaLLMSettingsState) -> Bool { + state.currentBackendNeedsPrepare + } + + public var body: some View { + Section { + HStack(spacing: 12) { + statusIcon + VStack(alignment: .leading, spacing: 2) { + Text(statusTitle) + .font(.subheadline.weight(.medium)) + Text(statusSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + if state.prepareStatus == .preparing { + downloadProgress + } + if state.prepareStatus != .preparing { + Button { + Task { await state.prepare() } + } label: { + Label( + state.prepareStatus == .ready ? "Erneut prüfen" : "Modell laden", + systemImage: "arrow.down.circle" + ) + } + if state.currentBackendIsCached { + Button(role: .destructive) { + Task { await state.removeCachedModel() } + } label: { + Label("Modell entfernen", systemImage: "trash") + } + } + } + if let error = state.prepareError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text(headerText) + } footer: { + // Bei App-Group-Cache: alle teilnehmenden Apps lesen + // dasselbe Modell. Das gehört in den User-sichtbaren + // Footer, weil 'Modell entfernen' eben auch das Modell + // für andere mana-Apps entfernt. + Text( + "Gemma-Modelle liegen im geteilten App-Group-Container " + + "(group.ev.mana.models). Andere mana-Apps können dasselbe Modell " + + "ohne zweiten Download nutzen — und 'Modell entfernen' " + + "entfernt es auch dort." + ) + .font(.caption2) + } + } + + // MARK: - Header + + private var headerText: String { + switch state.backend { + case .appleFM: "Apple Foundation Models" + case .gemmaE2B, .gemmaE4B: "Gemma-Modell" + case .noOp: "Modell" + } + } + + // MARK: - Status + + private var statusIcon: some View { + Group { + switch state.prepareStatus { + case .idle: + Image(systemName: "questionmark.circle").foregroundStyle(.secondary) + case .preparing: + ProgressView().controlSize(.small) + case .ready: + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + case .failed: + Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red) + } + } + .font(.title3) + .frame(width: 28) + } + + private var statusTitle: String { + switch state.prepareStatus { + case .idle: + switch state.availability[state.backend] ?? .unknown("") { + case .available: "Bereit" + case .requiresDownload: "Noch nicht geladen" + default: "Status prüfen" + } + case .preparing: "Wird vorbereitet …" + case .ready: "Bereit" + case .failed: "Fehler" + } + } + + private var statusSubtitle: String { + switch state.backend { + case .appleFM: + "Apple verwaltet das Modell automatisch — kein manuelles Vorladen nötig." + case .gemmaE2B: + "Gemma 4 E2B 4-bit von mlx-community auf Hugging Face. ~1.3 GB." + case .gemmaE4B: + "Gemma 4 E4B 4-bit von mlx-community auf Hugging Face. ~2.5 GB." + case .noOp: + "" + } + } + + // MARK: - Progress + + /// Drei Anzeigemodi je nach Datenlage: + /// 1. `fractionCompleted > 0` → linearer Balken + Prozent + Bytes + /// 2. `bytesCompleted > 0` (aber Fraction 0) → linearer Balken aus + /// Bytes-Verhältnis + Bytes-Text + /// 3. Sonst → indeterminierter Spinner + "Verbinde mit Hugging Face …" + /// Damit User nie ein totes "0 %" sieht, während der Download + /// in Wirklichkeit schon Metadaten zieht. + @ViewBuilder + private var downloadProgress: some View { + let byteFraction: Double? = { + guard let total = state.prepareBytesTotal, total > 1, + let done = state.prepareBytesDone, done > 0 + else { return nil } + return Double(done) / Double(total) + }() + let effectiveFraction = state.prepareProgress > 0 ? state.prepareProgress : (byteFraction ?? 0) + VStack(alignment: .leading, spacing: 4) { + if effectiveFraction > 0 { + ProgressView(value: min(effectiveFraction, 1.0)) + .progressViewStyle(.linear) + } else { + ProgressView() + .progressViewStyle(.linear) + } + HStack { + Text(progressLabel) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Text(progressValue(effectiveFraction: effectiveFraction)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + + private var progressLabel: String { + if let done = state.prepareBytesDone, done > 0 { + return "Lade von Hugging Face …" + } + return "Verbinde mit Hugging Face …" + } + + private func progressValue(effectiveFraction: Double) -> String { + if let done = state.prepareBytesDone, + let total = state.prepareBytesTotal, total > 1 + { + return "\(ByteFormatter.string(fromByteCount: done)) / " + + "\(ByteFormatter.string(fromByteCount: total))" + } + if effectiveFraction > 0 { + return "\(Int(effectiveFraction * 100)) %" + } + return "…" + } +} diff --git a/Sources/ManaLLMUI/ManaLLMSettingsState.swift b/Sources/ManaLLMUI/ManaLLMSettingsState.swift new file mode 100644 index 0000000..d6bc37f --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMSettingsState.swift @@ -0,0 +1,141 @@ +import Foundation +import ManaLLM +import SwiftUI + +/// Geteilter `@Observable`-State für die drei `ManaLLMUI`-Sections. +/// Apps instantiieren genau einen State und reichen ihn an die Sections +/// weiter — entweder direkt (`ManaLLMSettingsView()` macht das intern) +/// oder explizit, wenn nur eine Section benutzt wird. +/// +/// **Responsibility:** hält UI-State + delegiert Schreib-/Lese-Ops an +/// die Stores (`LLMBackendPreferenceStore`, +/// `LLMDownloadOverCellularStore`) und die LLM-Backends (`LLMRouter`). +/// Views bleiben dünn — nur Bindings + Layout. +/// +/// **Router-Lifecycle:** Wir instantiieren pro Operation einen frischen +/// `LLMRouter` (in `makeRouter`), damit der aktuell gewählte +/// `backend`-Pick und der `allowCellular`-Toggle zur Runtime +/// honoriert werden. Würde `LLMRouter.shared` nehmen ginge der +/// App-Wunsch verloren. +@Observable +@MainActor +public final class ManaLLMSettingsState { + public var backend: LLMBackendID + public var allowCellular: Bool + public var availability: [LLMBackendID: LLMAvailability] = [:] + public var prepareStatus: PrepareStatus = .idle + public var prepareProgress: Double = 0 + public var prepareError: String? + public var prepareBytesDone: Int64? + public var prepareBytesTotal: Int64? + + public init() { + self.backend = LLMBackendPreferenceStore.current + self.allowCellular = LLMDownloadOverCellularStore.isAllowed + } + + /// Setzt das Backend und persistiert es. Resettet den Prepare-State, + /// weil die alte Anzeige (z.B. "ready" für Apple FM) auf das neue + /// Backend nicht mehr stimmt. + public func setBackend(_ id: LLMBackendID) { + guard id != backend else { return } + backend = id + LLMBackendPreferenceStore.set(id) + prepareStatus = .idle + prepareProgress = 0 + prepareError = nil + prepareBytesDone = nil + prepareBytesTotal = nil + } + + public func setAllowCellular(_ value: Bool) { + guard value != allowCellular else { return } + allowCellular = value + LLMDownloadOverCellularStore.set(value) + } + + /// Re-Fetcht den Availability-Status aller Backends. Sollte + /// regelmäßig getriggert werden (z.B. `.task` auf der View, oder + /// nach `prepare`/`removeCachedModel`). + public func refreshAvailability() async { + availability = await makeRouter().availabilityMap() + } + + /// Lädt/initialisiert das aktuell gewählte Backend. Für Apple FM + /// effektiv ein Status-Check; für Gemma der HF-Download. + public func prepare() async { + prepareStatus = .preparing + prepareError = nil + prepareProgress = 0 + prepareBytesDone = nil + prepareBytesTotal = nil + let instance = await makeRouter().backend(for: backend) + do { + try await instance.prepare { update in + Task { @MainActor in + // Monotone Updates — out-of-order-Callbacks dürfen + // die Anzeige nicht zurückspringen lassen. + if update.fractionCompleted >= self.prepareProgress { + self.prepareProgress = update.fractionCompleted + } + if let done = update.bytesCompleted, + done >= (self.prepareBytesDone ?? 0) + { + self.prepareBytesDone = done + } + if let total = update.bytesTotal { + self.prepareBytesTotal = total + } + } + } + prepareStatus = .ready + prepareProgress = 1.0 + await refreshAvailability() + } catch { + prepareStatus = .failed + prepareError = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + } + + /// Löscht den lokalen Modell-Cache des aktuell gewählten Backends. + /// Backends ohne Caller-Cache (Apple FM, NoOp) sind No-Op. + public func removeCachedModel() async { + let instance = await makeRouter().backend(for: backend) + try? await instance.removeCachedModel() + prepareStatus = .idle + prepareProgress = 0 + prepareError = nil + await refreshAvailability() + } + + /// `true` wenn das aktuelle Backend cached ist (== verfügbar). + public var currentBackendIsCached: Bool { + switch availability[backend] ?? .unknown("") { + case .available: true + default: false + } + } + + /// `true` wenn das aktuelle Backend einen Prepare-Schritt braucht + /// (heute: nur Gemma-Varianten). + public var currentBackendNeedsPrepare: Bool { + switch backend { + case .gemmaE2B, .gemmaE4B: true + case .appleFM, .noOp: false + } + } + + private func makeRouter() -> LLMRouter { + LLMRouter( + preferred: [backend], + gemmaAllowsCellular: allowCellular + ) + } + + public enum PrepareStatus: Equatable, Sendable { + case idle + case preparing + case ready + case failed + } +} diff --git a/Sources/ManaLLMUI/ManaLLMSettingsView.swift b/Sources/ManaLLMUI/ManaLLMSettingsView.swift new file mode 100644 index 0000000..05cdfb8 --- /dev/null +++ b/Sources/ManaLLMUI/ManaLLMSettingsView.swift @@ -0,0 +1,43 @@ +import ManaLLM +import SwiftUI + +/// Drop-in-Komposition aus den drei Sections: BackendPicker, Prepare +/// (sichtbar nur für Gemma-Backends), DownloadPolicy. +/// +/// **Typische Nutzung:** +/// +/// ```swift +/// // In der Settings-Form der App: +/// ManaLLMSettingsView( +/// context: ManaLLMContext( +/// useCaseShort: "Artikel-Zusammenfassung", +/// useCaseLong: "fasst Artikel in zwei Sätze zusammen" +/// ) +/// ) +/// ``` +/// +/// Apps die feinere Kontrolle wollen (z.B. zwischen den Sections eine +/// app-eigene Section einschieben), nutzen die granularen +/// `ManaLLM*Section`-Views direkt und teilen sich einen explizit +/// erzeugten `ManaLLMSettingsState`. +public struct ManaLLMSettingsView: View { + @State private var state = ManaLLMSettingsState() + private let context: ManaLLMContext + + public init(context: ManaLLMContext = .generic) { + self.context = context + } + + public var body: some View { + Group { + ManaLLMBackendPickerSection(state: state, context: context) + if ManaLLMPrepareSection.shouldShow(for: state) { + ManaLLMPrepareSection(state: state) + } + ManaLLMDownloadPolicySection(state: state) + } + .task { + await state.refreshAvailability() + } + } +}