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) <noreply@anthropic.com>
This commit is contained in:
commit
100a65ce02
23 changed files with 1151 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.build/
|
||||
.swiftpm/
|
||||
.DS_Store
|
||||
*.xcodeproj
|
||||
Package.resolved
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
10
.swiftformat
Normal file
10
.swiftformat
Normal file
|
|
@ -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
|
||||
27
.swiftlint.yml
Normal file
27
.swiftlint.yml
Normal file
|
|
@ -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
|
||||
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal file
|
|
@ -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`.
|
||||
107
CLAUDE.md
Normal file
107
CLAUDE.md
Normal file
|
|
@ -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
|
||||
41
Package.swift
Normal file
41
Package.swift
Normal file
|
|
@ -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"
|
||||
),
|
||||
]
|
||||
)
|
||||
55
README.md
Normal file
55
README.md
Normal file
|
|
@ -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.
|
||||
61
Sources/ManaCore/API/AuthenticatedTransport.swift
Normal file
61
Sources/ManaCore/API/AuthenticatedTransport.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
172
Sources/ManaCore/Auth/AuthClient.swift
Normal file
172
Sources/ManaCore/Auth/AuthClient.swift
Normal file
|
|
@ -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?
|
||||
}
|
||||
31
Sources/ManaCore/Auth/AuthError.swift
Normal file
31
Sources/ManaCore/Auth/AuthError.swift
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Sources/ManaCore/Auth/JWT.swift
Normal file
25
Sources/ManaCore/Auth/JWT.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
84
Sources/ManaCore/Auth/KeychainStore.swift
Normal file
84
Sources/ManaCore/Auth/KeychainStore.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
42
Sources/ManaCore/Auth/ManaAppConfig.swift
Normal file
42
Sources/ManaCore/Auth/ManaAppConfig.swift
Normal file
|
|
@ -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.<app>`. 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
|
||||
}
|
||||
}
|
||||
9
Sources/ManaCore/Telemetry/CoreLog.swift
Normal file
9
Sources/ManaCore/Telemetry/CoreLog.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
24
Sources/ManaTokens/Colors/ManaBrand.swift
Normal file
24
Sources/ManaTokens/Colors/ManaBrand.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
84
Sources/ManaTokens/Colors/ManaColor.swift
Normal file
84
Sources/ManaTokens/Colors/ManaColor.swift
Normal file
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
78
Sources/ManaTokens/Colors/PlatformColor.swift
Normal file
78
Sources/ManaTokens/Colors/PlatformColor.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
42
Sources/ManaTokens/Spacing/Radius.swift
Normal file
42
Sources/ManaTokens/Spacing/Radius.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
48
Sources/ManaTokens/Spacing/Spacing.swift
Normal file
48
Sources/ManaTokens/Spacing/Spacing.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Sources/ManaTokens/Typography/Typography.swift
Normal file
36
Sources/ManaTokens/Typography/Typography.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
37
Tests/ManaCoreTests/JWTTests.swift
Normal file
37
Tests/ManaCoreTests/JWTTests.swift
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
27
Tests/ManaCoreTests/ManaAppConfigTests.swift
Normal file
27
Tests/ManaCoreTests/ManaAppConfigTests.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
75
Tests/ManaTokensTests/ColorTests.swift
Normal file
75
Tests/ManaTokensTests/ColorTests.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue