From df6f67ee4534d92a6a718ac66fb7a6a6c4e50512 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 12 May 2026 19:13:31 +0200 Subject: [PATCH] =?UTF-8?q?v1.0.0=20=E2=80=94=20initiale=20Extraktion=20au?= =?UTF-8?q?s=20memoro-native?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManaCore + ManaTokens als Swift-Package für alle nativen mana-e.V.-Apps. Phase α aus mana/docs/MANA_SWIFT.md durch. ManaCore: - AuthClient gegen mana-auth (Login, Refresh, Status-Maschine) - AuthenticatedTransport (URLSession + 401-Retry) - ManaAppConfig-Protocol für App-injizierbare Konfig - KeychainStore mit optionaler Shared-Access-Group - JWT-Parser für lokale Expiry-Prüfung - AuthError, CoreLog (interne OSLog-Logger) ManaTokens: - 12 Vereins-Tokens als dynamic Light/Dark Colors - 5 Brand-Literale (mana-yellow, spectrum-orange, ...) - Spacing, Radius, Typography aus mana/docs/THEMING.md Tests: 12 Unit-Tests grün via swift test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 + .swiftformat | 10 + .swiftlint.yml | 27 +++ CHANGELOG.md | 29 +++ CLAUDE.md | 107 +++++++++++ Package.swift | 41 +++++ README.md | 55 ++++++ .../ManaCore/API/AuthenticatedTransport.swift | 61 +++++++ Sources/ManaCore/Auth/AuthClient.swift | 172 ++++++++++++++++++ Sources/ManaCore/Auth/AuthError.swift | 31 ++++ Sources/ManaCore/Auth/JWT.swift | 25 +++ Sources/ManaCore/Auth/KeychainStore.swift | 84 +++++++++ Sources/ManaCore/Auth/ManaAppConfig.swift | 42 +++++ Sources/ManaCore/Telemetry/CoreLog.swift | 9 + Sources/ManaTokens/Colors/ManaBrand.swift | 24 +++ Sources/ManaTokens/Colors/ManaColor.swift | 84 +++++++++ Sources/ManaTokens/Colors/PlatformColor.swift | 78 ++++++++ Sources/ManaTokens/Spacing/Radius.swift | 42 +++++ Sources/ManaTokens/Spacing/Spacing.swift | 48 +++++ .../ManaTokens/Typography/Typography.swift | 36 ++++ Tests/ManaCoreTests/JWTTests.swift | 37 ++++ Tests/ManaCoreTests/ManaAppConfigTests.swift | 27 +++ Tests/ManaTokensTests/ColorTests.swift | 75 ++++++++ 23 files changed, 1151 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftformat create mode 100644 .swiftlint.yml create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/ManaCore/API/AuthenticatedTransport.swift create mode 100644 Sources/ManaCore/Auth/AuthClient.swift create mode 100644 Sources/ManaCore/Auth/AuthError.swift create mode 100644 Sources/ManaCore/Auth/JWT.swift create mode 100644 Sources/ManaCore/Auth/KeychainStore.swift create mode 100644 Sources/ManaCore/Auth/ManaAppConfig.swift create mode 100644 Sources/ManaCore/Telemetry/CoreLog.swift create mode 100644 Sources/ManaTokens/Colors/ManaBrand.swift create mode 100644 Sources/ManaTokens/Colors/ManaColor.swift create mode 100644 Sources/ManaTokens/Colors/PlatformColor.swift create mode 100644 Sources/ManaTokens/Spacing/Radius.swift create mode 100644 Sources/ManaTokens/Spacing/Spacing.swift create mode 100644 Sources/ManaTokens/Typography/Typography.swift create mode 100644 Tests/ManaCoreTests/JWTTests.swift create mode 100644 Tests/ManaCoreTests/ManaAppConfigTests.swift create mode 100644 Tests/ManaTokensTests/ColorTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f37740 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.build/ +.swiftpm/ +.DS_Store +*.xcodeproj +Package.resolved +xcuserdata/ +DerivedData/ diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..c1b2534 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,10 @@ +--swiftversion 6.0 +--indent 4 +--maxwidth 120 +--wraparguments before-first +--wrapparameters before-first +--wrapcollections before-first +--commas inline +--semicolons never +--self remove +--importgrouping testable-bottom diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..e4770c4 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,27 @@ +disabled_rules: + - todo + - trailing_comma + +opt_in_rules: + - empty_count + - empty_string + - explicit_init + - first_where + - sorted_first_last + - toggle_bool + - unneeded_parentheses_in_closure_argument + +line_length: + warning: 120 + error: 160 + ignores_comments: true + +identifier_name: + min_length: 2 + excluded: + - id + - ok + +included: + - Sources + - Tests diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b2dfe3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +Alle Änderungen werden hier dokumentiert. Format orientiert an +[Keep a Changelog](https://keepachangelog.com), Versionierung nach +[Semver](https://semver.org). + +## [1.0.0] — 2026-05-12 + +Initiale Extraktion aus `memoro-native` (Phase α aus +`mana/docs/MANA_SWIFT.md`). + +### ManaCore (neu) + +- `ManaAppConfig`-Protocol für App-injizierbare Konfiguration + (`authBaseURL`, `keychainService`, `keychainAccessGroup`). +- `AuthClient` — mana-auth-Login per E-Mail+PW, Status-Maschine, + Token-Speicherung im Keychain, proaktiver Refresh. +- `JWT` — Token-Expiry-Berechnung (lokaler Parse, keine + Signatur-Verifikation). +- `KeychainStore` — generisches Token-Storage, konfigurierbarer + Service-Identifier + Access-Group. +- `AuthError` — sprechende Fehlertypen mit `LocalizedError`-Texten. +- `AuthenticatedTransport` — URLSession-Wrapper mit Auth-Header und + automatischem 401-Retry-mit-Refresh. + +### ManaTokens (neu) + +- Farben, Spacings, Typography, Radius — gespiegelt aus + `mana/docs/THEMING.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3a9ee85 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md — mana-swift-core + +Guidance für Claude Code in diesem Repo. + +> **Übergeordneter Plan:** `../mana/docs/MANA_SWIFT.md` ist die SOT für +> die ganze native-App-Plattform. Dieses CLAUDE.md ist die Repo-lokale +> Konventionen-Doku. + +## Was dieses Repo ist + +Swift-Package-Plattform für alle nativen mana-e.V.-Apps. Enthält genau +zwei Library-Products: + +- **ManaCore** — Auth (mana-auth-Login, JWT-Refresh, Keychain), + Transport (URLSession-Wrapper mit 401-Retry). +- **ManaTokens** — Verein-Designwerte. Spiegelt `mana/docs/THEMING.md`. + +Wird konsumiert von `memoro-native`, geplant `cards-native`, +`nutriphi-native` und weiteren. + +## Status + +**v1.0.0 — initiale Extraktion aus memoro-native (2026-05-12).** + +Phase α aus `mana/docs/MANA_SWIFT.md` — wortwörtliche Verschiebung +von `Core/Auth`, `Core/API/Transport`, plus `ManaTokens` neu aus +`mana/docs/THEMING.md`. Keine spekulativen Verallgemeinerungen. + +## Architektonische Invarianten + +Nicht ohne explizite Diskussion antasten: + +1. **Genau zwei Library-Products.** ManaCore + ManaTokens. Ein + `ManaUI`-Komponenten-Package entsteht später in einem **eigenen** + Repo, nicht hier (`mana/docs/MANA_SWIFT.md` Phase ε). +2. **Keine externen Dependencies.** ManaCore darf nur Foundation, + Security, OSLog, Combine importieren. Kein Alamofire, kein Sentry, + kein nichts. Compliance (`mana/docs/COMPLIANCE.md`) + Build-Stabilität. +3. **Public API ist `Sendable`.** Jeder Cross-Actor-Type. Swift-6- + Concurrency-Korrektheit. +4. **Keine app-spezifischen Annahmen.** ManaCore weiß nichts über + Memos, Decks, Meals. PRs, die "irgendwas für Memoro" hinzufügen + wollen, gehören in memoro-native, nicht hier. +5. **mana-auth-Wissen ist erlaubt.** Endpoint-Pfade `/api/v1/auth/login`, + `/api/v1/auth/refresh`, Wire-Format mit `accessToken`/`refreshToken`. + Das ist Verein-Plattform-Konstante, nicht App-Detail. +6. **OSLog statt print/Sentry.** ManaCore loggt intern unter Subsystem + `ev.mana.core`. Apps haben ihr eigenes Subsystem. +7. **App injiziert ihre Config.** ManaCore hardcoded *nichts* (außer + die mana-auth-Endpoint-Pfade als Konstante). `authBaseURL`, + `keychainService`, `keychainAccessGroup` kommen vom App-Caller. +8. **Tests gegen reine Logik.** JWT-Decode, Token-Expiry, Keychain- + Key-Mapping. URLSession-Calls werden über Protokoll-Injection + getestet, nicht über echtes Netzwerk. + +## Versionierung + +- **Semver strikt.** Patch = Bugfix ohne API-Änderung. Minor = neue + API additiv. Major = breaking. +- **Git-Tag nach jedem Sinn-Abschnitt** auf `main`. Nicht jeden Commit. +- **CHANGELOG.md pflicht.** Was hat sich geändert, müssen Apps was anpassen. +- **Pflege-Politik:** Letzte zwei Major-Versionen werden mit Patches + bedient. Ältere nicht. + +## Konventionen + +- **Swift 6.0**, Strict Concurrency komplett +- **iOS 18 / macOS 15** Minimum (gleiche Targets wie memoro-native) +- **SwiftFormat** mit `.swiftformat` (4-space, 120-col, sorted imports) +- **SwiftLint** mit `.swiftlint.yml` +- **Doc-Comments** pflicht auf jedem `public`-Symbol (`///`) +- **Lokalisierung:** Public-API-Strings auf Deutsch (Verein-Konvention), + Code-Identifier auf Englisch + +## Lokal entwickeln + +```bash +swift build # baut beide Targets +swift test # Unit-Tests +swift package generate-xcodeproj # optional, für Xcode +``` + +Für die Integration in eine App (memoro-native, cards-native): + +- Dev-Cycle: in `project.yml` der App `path: ../mana-swift-core` setzen +- Release: `from: 1.0.0` mit Git-Tag + +## Wenn eine neue Funktion dazukommt + +1. Frage: gehört das wirklich in ManaCore/ManaTokens, oder ist es + App-spezifisch? Wenn unsicher: in die App, später extrahieren. +2. Public API mit Doc-Comments. +3. Unit-Tests gegen die State-Machine, nicht gegen echtes Netzwerk. +4. CHANGELOG.md ergänzen. +5. Bei Breaking-Change: Major-Bump-Plan in `mana/docs/MANA_SWIFT.md` + notieren, alle aktiven Apps informieren. + +## Wichtige Cross-Repo-Doks + +- `mana/docs/MANA_SWIFT.md` — Plattform-SOT für native Apps +- `mana/docs/MANA_AUTH_FEDERATION.md` — Auth-Protokoll +- `mana/docs/THEMING.md` — Token-SOT, spiegelt sich in ManaTokens +- `mana/docs/COMPLIANCE.md` — Telemetrie/Crash-Reporter-Regeln +- `mana/services/mana-auth/CLAUDE.md` — Server-Konventionen, § + "Native-App-Koordination" beschreibt die Refresh-Token-Invariante +- `memoro-native/CLAUDE.md` — erste Konsumenten-App, Konventionen + hier sind aus deren PLAN.md gewachsen diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..0ab56f6 --- /dev/null +++ b/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "mana-swift-core", + defaultLocalization: "de", + platforms: [ + .iOS(.v18), + .macOS(.v15), + ], + products: [ + .library(name: "ManaCore", targets: ["ManaCore"]), + .library(name: "ManaTokens", targets: ["ManaTokens"]), + ], + targets: [ + .target( + name: "ManaCore", + path: "Sources/ManaCore", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] + ), + .target( + name: "ManaTokens", + path: "Sources/ManaTokens", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] + ), + .testTarget( + name: "ManaCoreTests", + dependencies: ["ManaCore"], + path: "Tests/ManaCoreTests" + ), + .testTarget( + name: "ManaTokensTests", + dependencies: ["ManaTokens"], + path: "Tests/ManaTokensTests" + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..3923417 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# mana-swift-core + +Swift-Package-Plattform für alle nativen mana-e.V.-Apps. + +Geteilter Code zwischen `memoro-native`, `cards-native`, `nutriphi-native` +und allen weiteren `ev.mana.*`-Apps. Analog zu `@mana/*` aus Verdaccio +für die Web-Plattform. + +## Products + +- **ManaCore** — Auth (mana-auth-Login, JWT-Refresh, Keychain), + Transport (URLSession-Wrapper mit 401-Retry). +- **ManaTokens** — Verein-Designwerte: Farben, Spacings, Typography, + Radius, SF-Symbol-Aliases. Spiegelt `mana/docs/THEMING.md`. + +## Verwendung + +Im `project.yml` einer nativen App: + +```yaml +packages: + ManaSwiftCore: + url: https://git.mana.how/till/mana-swift-core + from: 1.0.0 + +targets: + YourApp: + dependencies: + - package: ManaSwiftCore + product: ManaCore + - package: ManaSwiftCore + product: ManaTokens +``` + +Während Entwicklung lokal: + +```yaml +packages: + ManaSwiftCore: + path: ../mana-swift-core +``` + +## Konventionen + +Siehe [CLAUDE.md](CLAUDE.md) für die vollständigen Regeln. +Übergeordneter Plan: `mana/docs/MANA_SWIFT.md`. + +## Lokal entwickeln + +```bash +swift build +swift test +``` + +iOS + macOS-Targets, Swift 6 strict concurrency, keine externen Dependencies. diff --git a/Sources/ManaCore/API/AuthenticatedTransport.swift b/Sources/ManaCore/API/AuthenticatedTransport.swift new file mode 100644 index 0000000..ccd1682 --- /dev/null +++ b/Sources/ManaCore/API/AuthenticatedTransport.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Authentifizierter HTTP-Transport. Setzt automatisch den `Authorization: Bearer`- +/// Header, refreshed bei `401` einmal proaktiv und wiederholt den Request. +/// +/// Eine Instanz pro App-Backend (z.B. memoro-api, cards-api). Beim Init +/// die Basis-URL des jeweiligen App-Servers übergeben, nicht die mana-auth-URL. +public actor AuthenticatedTransport { + private let baseURL: URL + private let session: URLSession + private let auth: AuthClient + + public init(baseURL: URL, auth: AuthClient, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + self.auth = auth + } + + /// Führt einen authentifizierten Request aus. Bei `401` wird der + /// Access-Token einmal refreshed und der Call wiederholt. + public func request( + path: String, + method: String = "GET", + body: Data? = nil, + contentType: String = "application/json" + ) async throws -> (Data, HTTPURLResponse) { + let token = try await auth.freshAccessToken() + let response = try await send(path: path, method: method, body: body, contentType: contentType, token: token) + if response.1.statusCode == 401 { + let refreshed = try await auth.refreshAccessToken() + return try await send(path: path, method: method, body: body, contentType: contentType, token: refreshed) + } + return response + } + + private func send( + path: String, + method: String, + body: Data?, + contentType: String, + token: String + ) async throws -> (Data, HTTPURLResponse) { + var request = URLRequest(url: baseURL.appending(path: path)) + request.httpMethod = method + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + if let body { + request.httpBody = body + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw AuthError.networkFailure("Keine HTTP-Antwort") + } + return (data, http) + } catch let error as URLError { + throw AuthError.networkFailure(error.localizedDescription) + } + } +} diff --git a/Sources/ManaCore/Auth/AuthClient.swift b/Sources/ManaCore/Auth/AuthClient.swift new file mode 100644 index 0000000..943db71 --- /dev/null +++ b/Sources/ManaCore/Auth/AuthClient.swift @@ -0,0 +1,172 @@ +import Foundation +import Observation + +/// Auth-Client gegen mana-auth. Beobachtbarer Status, Login-Flow, +/// proaktiver Token-Refresh. +/// +/// Eine Instanz pro App, beim App-Start initialisiert. `bootstrap()` +/// füllt den Status aus dem Keychain (offline möglich). +@MainActor +@Observable +public final class AuthClient { + public enum Status: Equatable, Sendable { + case unknown + case signedOut + case signingIn + case signedIn(email: String) + case error(String) + } + + public private(set) var status: Status = .unknown + + private let config: ManaAppConfig + private let keychain: KeychainStore + private let session: URLSession + + public init(config: ManaAppConfig, session: URLSession = .shared) { + self.config = config + keychain = KeychainStore(service: config.keychainService, accessGroup: config.keychainAccessGroup) + self.session = session + } + + public var currentEmail: String? { + if case let .signedIn(email) = status { return email } + return nil + } + + public func bootstrap() { + let email = keychain.getString(for: .email) + let hasToken = keychain.getString(for: .accessToken) != nil + if let email, hasToken { + status = .signedIn(email: email) + } else { + status = .signedOut + } + } + + public func signIn(email: String, password: String) async { + let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !password.isEmpty else { + status = .error("Email und Passwort sind erforderlich") + return + } + + status = .signingIn + do { + let url = config.authBaseURL.appending(path: "/api/v1/auth/login") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(LoginRequest(email: trimmed, password: password)) + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + status = .error("Ungültige Server-Antwort") + return + } + guard http.statusCode == 200 else { + let message = (try? JSONDecoder().decode(ServerError.self, from: data))?.message + if http.statusCode == 401 { + status = .error("Email oder Passwort falsch") + } else { + status = + .error("Login fehlgeschlagen (HTTP \(http.statusCode))" + (message.map { " — \($0)" } ?? "")) + } + return + } + + let token = try JSONDecoder().decode(TokenResponse.self, from: data) + try keychain.setString(token.accessToken, for: .accessToken) + try keychain.setString(token.refreshToken, for: .refreshToken) + try keychain.setString(trimmed, for: .email) + status = .signedIn(email: trimmed) + CoreLog.auth.info("Sign-in successful") + } catch let error as URLError { + status = .error("Netzwerk: \(error.localizedDescription)") + CoreLog.auth.error("Sign-in network error: \(error.localizedDescription, privacy: .public)") + } catch { + status = .error(String(describing: error)) + CoreLog.auth.error("Sign-in error: \(String(describing: error), privacy: .public)") + } + } + + public func signOut() async { + if let token = keychain.getString(for: .accessToken) { + let url = config.authBaseURL.appending(path: "/api/v1/auth/logout") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + _ = try? await session.data(for: request) + } + keychain.wipe() + status = .signedOut + CoreLog.auth.info("Signed out") + } + + public func currentAccessToken() throws -> String { + guard let token = keychain.getString(for: .accessToken) else { + throw AuthError.notSignedIn + } + return token + } + + /// Liefert einen Access-Token, der nicht innerhalb der nächsten + /// `refreshLeeway` Sekunden abläuft. Refreshed proaktiv, wenn nötig. + public func freshAccessToken(refreshLeeway: TimeInterval = 300) async throws -> String { + let token = try currentAccessToken() + if let expiry = JWT.expiry(of: token), expiry.timeIntervalSinceNow > refreshLeeway { + return token + } + CoreLog.auth.notice("Token expiring within \(Int(refreshLeeway), privacy: .public)s — refreshing proactively") + return try await refreshAccessToken() + } + + public func refreshAccessToken() async throws -> String { + guard let refresh = keychain.getString(for: .refreshToken) else { + throw AuthError.notSignedIn + } + let url = config.authBaseURL.appending(path: "/api/v1/auth/refresh") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(RefreshRequest(refreshToken: refresh)) + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw AuthError.networkFailure("Keine HTTP-Antwort") + } + guard http.statusCode == 200 else { + keychain.wipe() + status = .signedOut + throw AuthError.serverError( + status: http.statusCode, + message: (try? JSONDecoder().decode(ServerError.self, from: data))?.message + ) + } + + let token = try JSONDecoder().decode(TokenResponse.self, from: data) + try keychain.setString(token.accessToken, for: .accessToken) + if !token.refreshToken.isEmpty { + try keychain.setString(token.refreshToken, for: .refreshToken) + } + return token.accessToken + } +} + +private struct LoginRequest: Encodable { + let email: String + let password: String +} + +private struct RefreshRequest: Encodable { + let refreshToken: String +} + +private struct TokenResponse: Decodable { + let accessToken: String + let refreshToken: String +} + +private struct ServerError: Decodable { + let message: String? +} diff --git a/Sources/ManaCore/Auth/AuthError.swift b/Sources/ManaCore/Auth/AuthError.swift new file mode 100644 index 0000000..4a686fe --- /dev/null +++ b/Sources/ManaCore/Auth/AuthError.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Fehler-Typen des Auth- und Transport-Layers. +public enum AuthError: Error, LocalizedError, Sendable { + case notSignedIn + case invalidCredentials + case networkFailure(String) + case serverError(status: Int, message: String?) + case decoding(String) + case keychain(OSStatus) + case encoding + + public var errorDescription: String? { + switch self { + case .notSignedIn: + "Nicht angemeldet" + case .invalidCredentials: + "Ungültige Anmeldedaten" + case let .networkFailure(message): + "Netzwerkfehler: \(message)" + case let .serverError(status, message): + "Server-Fehler (\(status))" + (message.map { ": \($0)" } ?? "") + case let .decoding(detail): + "Antwort konnte nicht gelesen werden: \(detail)" + case let .keychain(status): + "Keychain-Fehler (OSStatus \(status))" + case .encoding: + "Datenkodierung fehlgeschlagen" + } + } +} diff --git a/Sources/ManaCore/Auth/JWT.swift b/Sources/ManaCore/Auth/JWT.swift new file mode 100644 index 0000000..bf05ebd --- /dev/null +++ b/Sources/ManaCore/Auth/JWT.swift @@ -0,0 +1,25 @@ +import Foundation + +/// JWT-Parsing-Helfer. **Verifiziert keine Signatur** — der Server bleibt +/// die Wahrheitsinstanz. Hier nur lokale Expiry-Prüfung für proaktiven +/// Refresh. +public enum JWT { + /// Liest `exp`-Claim aus einem JWT, falls vorhanden. + public static func expiry(of token: String) -> Date? { + let parts = token.split(separator: ".") + guard parts.count == 3 else { return nil } + guard let payloadData = base64URLDecode(String(parts[1])) else { return nil } + guard let dict = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] else { return nil } + guard let exp = dict["exp"] as? TimeInterval else { return nil } + return Date(timeIntervalSince1970: exp) + } + + private static func base64URLDecode(_ value: String) -> Data? { + var base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let paddingNeeded = (4 - base64.count % 4) % 4 + base64.append(String(repeating: "=", count: paddingNeeded)) + return Data(base64Encoded: base64) + } +} diff --git a/Sources/ManaCore/Auth/KeychainStore.swift b/Sources/ManaCore/Auth/KeychainStore.swift new file mode 100644 index 0000000..b6847c8 --- /dev/null +++ b/Sources/ManaCore/Auth/KeychainStore.swift @@ -0,0 +1,84 @@ +import Foundation +import Security + +/// Generische, in der App injizierbare Keychain-Wrapper. Eine Instanz +/// pro `ManaAppConfig`. +public final class KeychainStore: Sendable { + /// Bekannte Keys für mana-auth-Tokens. + public enum Key: String, Sendable { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case email + } + + private let service: String + private let accessGroup: String? + + public init(service: String, accessGroup: String? = nil) { + self.service = service + self.accessGroup = accessGroup + } + + public func setString(_ value: String, for key: Key) throws { + guard let data = value.data(using: .utf8) else { + throw AuthError.encoding + } + + var query = baseQuery(for: key) + let attributes: [CFString: Any] = [ + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, + ] + + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + switch updateStatus { + case errSecSuccess: + return + case errSecItemNotFound: + for (attrKey, attrValue) in attributes { + query[attrKey] = attrValue + } + let addStatus = SecItemAdd(query as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw AuthError.keychain(addStatus) + } + default: + throw AuthError.keychain(updateStatus) + } + } + + public func getString(for key: Key) -> String? { + var query = baseQuery(for: key) + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } + + public func remove(for key: Key) { + let query = baseQuery(for: key) + SecItemDelete(query as CFDictionary) + } + + public func wipe() { + remove(for: .accessToken) + remove(for: .refreshToken) + remove(for: .email) + } + + private func baseQuery(for key: Key) -> [CFString: Any] { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key.rawValue, + ] + if let accessGroup { + query[kSecAttrAccessGroup] = accessGroup + } + return query + } +} diff --git a/Sources/ManaCore/Auth/ManaAppConfig.swift b/Sources/ManaCore/Auth/ManaAppConfig.swift new file mode 100644 index 0000000..599ad7f --- /dev/null +++ b/Sources/ManaCore/Auth/ManaAppConfig.swift @@ -0,0 +1,42 @@ +import Foundation + +/// App-spezifische Konfiguration für ManaCore. Wird von der konsumierenden +/// App beim Erzeugen eines `AuthClient` injiziert. +/// +/// ManaCore hardcoded nichts App-Spezifisches. Bundle-ID, Auth-Server-URL +/// und Keychain-Adressierung kommen ausschließlich hierüber. +public protocol ManaAppConfig: Sendable { + /// Basis-URL des mana-auth-Servers, z.B. `https://auth.mana.how`. + var authBaseURL: URL { get } + + /// Keychain-Service-Identifier, üblich `ev.mana.`. Trennt + /// Token-Einträge verschiedener Apps voneinander, falls keine + /// shared Access-Group benutzt wird. + var keychainService: String { get } + + /// Optional: Shared-Keychain-Access-Group für Cross-App-SSO. + /// `nil` bedeutet: nur App-eigener Keychain-Zugriff. + /// + /// Wenn gesetzt, müssen alle teilnehmenden Apps unter derselben + /// Apple-Developer-Team-ID provisioniert sein und das Entitlement + /// `keychain-access-groups` mit demselben Wert tragen. + var keychainAccessGroup: String? { get } +} + +/// Standard-Implementierung von ``ManaAppConfig``. Apps können diese +/// nutzen oder ein eigenes Type adoptieren. +public struct DefaultManaAppConfig: ManaAppConfig { + public let authBaseURL: URL + public let keychainService: String + public let keychainAccessGroup: String? + + public init( + authBaseURL: URL, + keychainService: String, + keychainAccessGroup: String? = nil + ) { + self.authBaseURL = authBaseURL + self.keychainService = keychainService + self.keychainAccessGroup = keychainAccessGroup + } +} diff --git a/Sources/ManaCore/Telemetry/CoreLog.swift b/Sources/ManaCore/Telemetry/CoreLog.swift new file mode 100644 index 0000000..fd55e45 --- /dev/null +++ b/Sources/ManaCore/Telemetry/CoreLog.swift @@ -0,0 +1,9 @@ +import Foundation +import OSLog + +/// Interne OSLog-Logger von ManaCore. Apps haben ihre eigenen Logger +/// unter eigenem Subsystem — diese hier sind für die Bibliothek selbst. +enum CoreLog { + static let auth = Logger(subsystem: "ev.mana.core", category: "auth") + static let transport = Logger(subsystem: "ev.mana.core", category: "transport") +} diff --git a/Sources/ManaTokens/Colors/ManaBrand.swift b/Sources/ManaTokens/Colors/ManaBrand.swift new file mode 100644 index 0000000..61169b7 --- /dev/null +++ b/Sources/ManaTokens/Colors/ManaBrand.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// Brand-Literal-Schicht aus `mana/docs/THEMING.md`. Theme-unabhängig: +/// diese Werte bleiben gleich in Light, Dark, allen Variants. +/// +/// Nur für Stellen verwenden, wo bewusst Brand-Identität gemeint ist +/// (Logo-Komponenten, App-spezifische Sondertöne). Allgemeine UI bleibt +/// auf ``ManaColor``. +public enum ManaBrand { + /// `--brand-mana-yellow` — Vereins-Gelb. + public static let manaYellow = Color.manaHex(0xFFB700) + + /// `--brand-mana-spectrum-orange` — Vereins-Spektrum-Orange. + public static let manaSpectrumOrange = Color.manaHex(0xFF6600) + + /// `--brand-spiral-indigo` — Spiral-Indigo. + public static let spiralIndigo = Color.manaHex(0x6366F1) + + /// `--brand-period-pink` — Period-Pink. + public static let periodPink = Color.manaHex(0xF902AC) + + /// `--brand-zitare-paper` — Zitare-Papier-Ton. + public static let zitarePaper = Color.manaHex(0xF7F3E9) +} diff --git a/Sources/ManaTokens/Colors/ManaColor.swift b/Sources/ManaTokens/Colors/ManaColor.swift new file mode 100644 index 0000000..6e24d88 --- /dev/null +++ b/Sources/ManaTokens/Colors/ManaColor.swift @@ -0,0 +1,84 @@ +import SwiftUI + +/// Die 12 Vereins-Tokens als SwiftUI-`Color`. Wechselt automatisch +/// zwischen Light- und Dark-Variante via System-Appearance. +/// +/// Quelle: `mana/docs/THEMING.md` — eingefrorenes Vokabular, +/// keine 13. Farbe ohne Vereins-Beschluss. +/// +/// `primary` und `primaryForeground` zeigen aktuell die `mana`-Variant +/// (Spektrum-Orange, `25 100% 50%`). Weitere Theme-Variants kommen +/// in einer späteren Version (`mana/docs/MANA_SWIFT.md` Phase ε). +public enum ManaColor { + /// Token 1 — `--color-background`. Page-Hintergrund. + public static let background = Color.manaToken( + light: (0, 0, 100), + dark: (222, 47, 11) + ) + + /// Token 2 — `--color-foreground`. Standard-Text. + public static let foreground = Color.manaToken( + light: (222, 47, 11), + dark: (210, 40, 98) + ) + + /// Token 3 — `--color-surface`. Card, Panel, Modal, Popover. + public static let surface = Color.manaToken( + light: (0, 0, 99), + dark: (217, 33, 17) + ) + + /// Token 4 — `--color-surface-hover`. Hover-State auf Surface. + public static let surfaceHover = Color.manaToken( + light: (220, 14, 96), + dark: (215, 28, 22) + ) + + /// Token 5 — `--color-muted`. Disabled-Felder, Skeleton. + public static let muted = Color.manaToken( + light: (220, 14, 94), + dark: (217, 33, 23) + ) + + /// Token 6 — `--color-muted-foreground`. Sekundär-Text, Placeholder. + public static let mutedForeground = Color.manaToken( + light: (220, 9, 46), + dark: (215, 20, 65) + ) + + /// Token 7 — `--color-border`. Rahmen, Trennlinien. + public static let border = Color.manaToken( + light: (220, 13, 91), + dark: (217, 33, 25) + ) + + /// Token 8 — `--color-primary`. App-Akzent. Aktuell mana-Variant. + public static let primary = Color.manaToken( + light: (25, 100, 50), + dark: (25, 100, 50) + ) + + /// Token 9 — `--color-primary-foreground`. Text auf Primary-Flächen. + public static let primaryForeground = Color.manaToken( + light: (0, 0, 100), + dark: (0, 0, 100) + ) + + /// Token 10 — `--color-error`. Fehler, Lösch-Aktion. + public static let error = Color.manaToken( + light: (0, 84, 60), + dark: (0, 63, 55) + ) + + /// Token 11 — `--color-success`. Erfolg, Bestätigung. + public static let success = Color.manaToken( + light: (142, 71, 45), + dark: (142, 71, 45) + ) + + /// Token 12 — `--color-warning`. Warnung, Aufmerksamkeit. + public static let warning = Color.manaToken( + light: (38, 92, 50), + dark: (48, 96, 53) + ) +} diff --git a/Sources/ManaTokens/Colors/PlatformColor.swift b/Sources/ManaTokens/Colors/PlatformColor.swift new file mode 100644 index 0000000..5137a16 --- /dev/null +++ b/Sources/ManaTokens/Colors/PlatformColor.swift @@ -0,0 +1,78 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +typealias PlatformColor = UIColor +#elseif canImport(AppKit) +import AppKit +typealias PlatformColor = NSColor +#endif + +extension PlatformColor { + /// Konstruiert eine RGB-Farbe aus CSS-HSL-Werten. + /// - Parameters: + /// - hue: 0–360 (Grad) + /// - saturation: 0–100 (Prozent) + /// - lightness: 0–100 (Prozent) + static func fromHSL(_ hue: Double, _ saturation: Double, _ lightness: Double) -> PlatformColor { + let h = hue / 360 + let s = saturation / 100 + let l = lightness / 100 + + if s == 0 { + return PlatformColor(red: l, green: l, blue: l, alpha: 1) + } + + let q = l < 0.5 ? l * (1 + s) : l + s - l * s + let p = 2 * l - q + let r = hueToRGB(p, q, h + 1.0 / 3.0) + let g = hueToRGB(p, q, h) + let b = hueToRGB(p, q, h - 1.0 / 3.0) + + return PlatformColor(red: r, green: g, blue: b, alpha: 1) + } + + private static func hueToRGB(_ p: Double, _ q: Double, _ rawT: Double) -> Double { + var t = rawT + if t < 0 { t += 1 } + if t > 1 { t -= 1 } + if t < 1.0 / 6.0 { return p + (q - p) * 6 * t } + if t < 1.0 / 2.0 { return q } + if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6 } + return p + } +} + +extension Color { + /// Erzeugt eine SwiftUI-Color, die abhängig vom System-Appearance + /// zwischen Light- und Dark-Variante umschaltet. Beide werden als + /// CSS-HSL-Tripel übergeben (siehe `mana/docs/THEMING.md`). + static func manaToken( + light: (Double, Double, Double), + dark: (Double, Double, Double) + ) -> Color { + let lightColor = PlatformColor.fromHSL(light.0, light.1, light.2) + let darkColor = PlatformColor.fromHSL(dark.0, dark.1, dark.2) + + #if canImport(UIKit) + return Color(uiColor: UIColor { trait in + trait.userInterfaceStyle == .dark ? darkColor : lightColor + }) + #elseif canImport(AppKit) + return Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil + return isDark ? darkColor : lightColor + }) + #else + return Color(red: 0, green: 0, blue: 0) + #endif + } + + /// Konstruktor aus einem Hex-RGB-Literal (`0xFFB700`). + static func manaHex(_ rgb: UInt32) -> Color { + let r = Double((rgb >> 16) & 0xFF) / 255.0 + let g = Double((rgb >> 8) & 0xFF) / 255.0 + let b = Double(rgb & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } +} diff --git a/Sources/ManaTokens/Spacing/Radius.swift b/Sources/ManaTokens/Spacing/Radius.swift new file mode 100644 index 0000000..3eb7c0d --- /dev/null +++ b/Sources/ManaTokens/Spacing/Radius.swift @@ -0,0 +1,42 @@ +import CoreGraphics + +/// Border-Radius-Werte für die drei Vereins-Modi `sharp`, `soft`, `pill` +/// aus `mana/docs/THEMING.md`. Apps wählen einen Default und lassen +/// User optional umstellen. +public enum Radius { + /// 0pt — sharp-Modus + public static let sharp: CGFloat = 0 + + /// 2pt — kantig-aber-nicht-spitz + public static let xs: CGFloat = 2 + + /// 4pt + public static let sm: CGFloat = 4 + + /// 8pt — Standard für Buttons, Cards (soft-Modus) + public static let md: CGFloat = 8 + + /// 12pt + public static let lg: CGFloat = 12 + + /// 16pt + public static let xl: CGFloat = 16 + + /// 9999pt — voll abgerundet (pill-Modus) + public static let pill: CGFloat = 9999 +} + +/// Multiplikator für die `radius`-Achse aus `mana/docs/THEMING.md`. +public enum RadiusMode: Sendable { + case sharp + case soft + case pill + + public var scale: CGFloat { + switch self { + case .sharp: 0 + case .soft: 1 + case .pill: 2 + } + } +} diff --git a/Sources/ManaTokens/Spacing/Spacing.swift b/Sources/ManaTokens/Spacing/Spacing.swift new file mode 100644 index 0000000..e021ae0 --- /dev/null +++ b/Sources/ManaTokens/Spacing/Spacing.swift @@ -0,0 +1,48 @@ +import CoreGraphics + +/// Spacing-Skala für Padding, Margins, Stack-Spacing. +/// +/// Tailwind-kompatible Werte. `mana/docs/THEMING.md` definiert die +/// Achse `density` (compact/normal/comfortable) — diese skaliert hier +/// nicht, sondern wird in der App über einen Multiplikator angewandt +/// (siehe `densityScale`). +public enum Spacing { + /// 4pt — sehr eng + public static let xxs: CGFloat = 4 + + /// 8pt — eng + public static let xs: CGFloat = 8 + + /// 12pt + public static let sm: CGFloat = 12 + + /// 16pt — Standard-Abstand zwischen Elementen + public static let md: CGFloat = 16 + + /// 24pt + public static let lg: CGFloat = 24 + + /// 32pt — Sektion-Abstand + public static let xl: CGFloat = 32 + + /// 48pt — Major-Sektion + public static let xxl: CGFloat = 48 + + /// 64pt + public static let xxxl: CGFloat = 64 +} + +/// Multiplikator für die `density`-Achse aus `mana/docs/THEMING.md`. +public enum Density: Sendable { + case compact + case normal + case comfortable + + public var scale: CGFloat { + switch self { + case .compact: 0.85 + case .normal: 1.0 + case .comfortable: 1.15 + } + } +} diff --git a/Sources/ManaTokens/Typography/Typography.swift b/Sources/ManaTokens/Typography/Typography.swift new file mode 100644 index 0000000..131ba44 --- /dev/null +++ b/Sources/ManaTokens/Typography/Typography.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// Typographie-Skala. Nutzt SwiftUI-Standard-`Font`-Styles, die +/// automatisch Dynamic Type respektieren (Accessibility). +/// +/// Apps mit speziellen Display-Anforderungen können die Werte +/// überschreiben — der Default folgt Apple's Human Interface Guidelines +/// für gemischte Lese-/Aktions-Apps. +public enum ManaTypography { + /// Großüberschrift — Screen-Titel. + public static let largeTitle: Font = .largeTitle + + /// Sektion-Titel. + public static let title: Font = .title + + /// Sub-Sektion. + public static let title2: Font = .title2 + + /// Card-Titel. + public static let title3: Font = .title3 + + /// Standard-Überschrift im Body. + public static let headline: Font = .headline + + /// Body-Text. + public static let body: Font = .body + + /// Body, hervorgehoben. + public static let bodyEmphasized: Font = .body.weight(.semibold) + + /// Sekundär-Text, Caption. + public static let caption: Font = .caption + + /// Kleinste Schrift — Meta, Footnote. + public static let footnote: Font = .footnote +} diff --git a/Tests/ManaCoreTests/JWTTests.swift b/Tests/ManaCoreTests/JWTTests.swift new file mode 100644 index 0000000..7a5ad98 --- /dev/null +++ b/Tests/ManaCoreTests/JWTTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import ManaCore + +@Suite("JWT") +struct JWTTests { + @Test("Liefert expiry aus gültigem JWT") + func extractsExpiry() throws { + // Header: {"alg":"HS256","typ":"JWT"} + // Payload: {"sub":"u1","exp":2000000000} + let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.signature" + let expiry = try #require(JWT.expiry(of: token)) + #expect(expiry == Date(timeIntervalSince1970: 2_000_000_000)) + } + + @Test("Nil für nicht-JWT-Strings") + func nilForNonJWT() { + #expect(JWT.expiry(of: "nonsense") == nil) + #expect(JWT.expiry(of: "a.b") == nil) + #expect(JWT.expiry(of: "a.b.c.d") == nil) + } + + @Test("Nil wenn exp-Claim fehlt") + func nilWhenExpMissing() { + // Payload: {"sub":"u1"} + let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1MSJ9.signature" + #expect(JWT.expiry(of: token) == nil) + } + + @Test("Toleriert Base64URL-Padding") + func toleratesBase64URLPadding() throws { + // Payload ohne Padding: {"exp":1700000000} + let token = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDAwMDAwMDB9.sig" + let expiry = try #require(JWT.expiry(of: token)) + #expect(expiry == Date(timeIntervalSince1970: 1_700_000_000)) + } +} diff --git a/Tests/ManaCoreTests/ManaAppConfigTests.swift b/Tests/ManaCoreTests/ManaAppConfigTests.swift new file mode 100644 index 0000000..0d5699e --- /dev/null +++ b/Tests/ManaCoreTests/ManaAppConfigTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import ManaCore + +@Suite("ManaAppConfig") +struct ManaAppConfigTests { + @Test("DefaultManaAppConfig setzt Felder") + func defaultSetsFields() throws { + let config = DefaultManaAppConfig( + authBaseURL: URL(string: "https://auth.mana.how")!, + keychainService: "ev.mana.memoro", + keychainAccessGroup: "TEAMID.ev.mana.shared" + ) + #expect(config.authBaseURL.absoluteString == "https://auth.mana.how") + #expect(config.keychainService == "ev.mana.memoro") + #expect(config.keychainAccessGroup == "TEAMID.ev.mana.shared") + } + + @Test("AccessGroup ist optional") + func accessGroupOptional() { + let config = DefaultManaAppConfig( + authBaseURL: URL(string: "https://auth.mana.how")!, + keychainService: "ev.mana.cards" + ) + #expect(config.keychainAccessGroup == nil) + } +} diff --git a/Tests/ManaTokensTests/ColorTests.swift b/Tests/ManaTokensTests/ColorTests.swift new file mode 100644 index 0000000..4735078 --- /dev/null +++ b/Tests/ManaTokensTests/ColorTests.swift @@ -0,0 +1,75 @@ +import SwiftUI +import Testing +@testable import ManaTokens + +@Suite("ManaTokens Colors") +struct ColorTests { + @Test("HSL Pure Schwarz") + func hslPureBlack() { + let color = PlatformColor.fromHSL(0, 0, 0) + let components = rgbComponents(of: color) + #expect(components.r == 0) + #expect(components.g == 0) + #expect(components.b == 0) + } + + @Test("HSL Pure Weiß") + func hslPureWhite() { + let color = PlatformColor.fromHSL(0, 0, 100) + let components = rgbComponents(of: color) + #expect(components.r == 1) + #expect(components.g == 1) + #expect(components.b == 1) + } + + @Test("HSL Rot 0 100 50") + func hslPureRed() { + let color = PlatformColor.fromHSL(0, 100, 50) + let components = rgbComponents(of: color) + #expect(approxEqual(components.r, 1)) + #expect(approxEqual(components.g, 0)) + #expect(approxEqual(components.b, 0)) + } + + @Test("HSL Spektrum-Orange 25 100 50") + func hslSpectrumOrange() { + // Mana-Variant primary aus THEMING.md + let color = PlatformColor.fromHSL(25, 100, 50) + let components = rgbComponents(of: color) + #expect(approxEqual(components.r, 1.0)) + #expect(approxEqual(components.g, 0.4167, tolerance: 0.01)) + #expect(approxEqual(components.b, 0)) + } + + @Test("ManaColor.primary ist abrufbar") + func manaColorPrimary() { + let _: Color = ManaColor.primary + let _: Color = ManaColor.background + let _: Color = ManaColor.foreground + } + + @Test("ManaBrand.manaYellow entspricht FFB700") + func manaBrandYellow() { + let yellow = Color.manaHex(0xFFB700) + // Roundtrip ist nicht trivial, aber wir prüfen Existenz + let _ = yellow + let _ = ManaBrand.manaYellow + } +} + +// MARK: - Helpers + +private func rgbComponents(of color: PlatformColor) -> (r: Double, g: Double, b: Double) { + #if canImport(UIKit) + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + return (Double(r), Double(g), Double(b)) + #else + let space = color.usingColorSpace(.deviceRGB) ?? color + return (Double(space.redComponent), Double(space.greenComponent), Double(space.blueComponent)) + #endif +} + +private func approxEqual(_ a: Double, _ b: Double, tolerance: Double = 0.001) -> Bool { + abs(a - b) < tolerance +}