commit df6f67ee4534d92a6a718ac66fb7a6a6c4e50512 Author: Till JS Date: Tue May 12 19:13:31 2026 +0200 v1.0.0 — initiale Extraktion aus memoro-native 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) 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 +}