Compare commits
No commits in common. "main" and "v1.5.0" have entirely different histories.
416 changed files with 2694 additions and 1558 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,4 +5,3 @@
|
||||||
Package.resolved
|
Package.resolved
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
build/
|
|
||||||
|
|
|
||||||
195
CHANGELOG.md
195
CHANGELOG.md
|
|
@ -4,201 +4,6 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an
|
||||||
[Keep a Changelog](https://keepachangelog.com), Versionierung nach
|
[Keep a Changelog](https://keepachangelog.com), Versionierung nach
|
||||||
[Semver](https://semver.org).
|
[Semver](https://semver.org).
|
||||||
|
|
||||||
## [1.8.0] — 2026-05-21
|
|
||||||
|
|
||||||
Minor — **`RefreshFailurePolicy`** + Diagnostik-Logging in
|
|
||||||
`refreshAccessToken()`.
|
|
||||||
|
|
||||||
### Hintergrund
|
|
||||||
|
|
||||||
Eine mana-auth-Regression am 2026-05-19 (`project_auth_refresh_bug`)
|
|
||||||
hat ~80% aller `/refresh`-Calls 401en lassen — ManaCore hat daraufhin
|
|
||||||
**sofort** den Keychain gewiped und alle Apps in den Login-Screen
|
|
||||||
geworfen. Server-Bug war binnen Stunden gefixt, aber für User mit
|
|
||||||
einem Cold-Launch in genau diesem Fenster war's "logged out".
|
|
||||||
|
|
||||||
Außerdem fragte Till mehrfach "warum muss ich mich nach jedem TestFlight-
|
|
||||||
Update neu einloggen" — Hypothese: transienter Server-/Deployment-Glitch
|
|
||||||
beim ersten Refresh nach Update löst Wipe aus, obwohl die Session
|
|
||||||
eigentlich gültig ist.
|
|
||||||
|
|
||||||
### Neu
|
|
||||||
|
|
||||||
- **`RefreshFailurePolicy`** (public enum, Sendable):
|
|
||||||
- `.immediateWipe` — Default. Bestehendes Verhalten: jeder Server-
|
|
||||||
"Session-tot" → sofort Keychain wipe → `.signedOut`.
|
|
||||||
- `.softFirst` — Erster Refresh-Fehler im Prozess wird nicht gewiped,
|
|
||||||
Session bleibt erhalten, Fehler wird geworfen. Wipe erst beim
|
|
||||||
zweiten Fehler ODER nach einem zuvor erfolgreichen Refresh im
|
|
||||||
selben Prozess (dann ist der invalidate-Response vertrauenswürdig).
|
|
||||||
- **`ManaAppConfig.refreshFailurePolicy`** (default `.immediateWipe`,
|
|
||||||
Protocol-Extension). Apps können opt-in per `DefaultManaAppConfig`-
|
|
||||||
Init-Parameter oder eigener Adopter-Implementierung.
|
|
||||||
- **`AuthClient.refreshOnceSucceeded`** + **`refreshFailureCount`**
|
|
||||||
(private(set)) — public-readable für Diagnose + Tests.
|
|
||||||
|
|
||||||
### Geändert
|
|
||||||
|
|
||||||
- `refreshAccessToken()` loggt jetzt jeden Attempt inklusive Token-
|
|
||||||
Länge, once-succeeded-Flag, Failure-Count und gewählter Policy.
|
|
||||||
Failure-Pfad loggt zusätzlich HTTP-Status und Response-Body-Excerpt
|
|
||||||
(erste 256 Zeichen). Logs gehen weiter über `CoreLog.auth`, also
|
|
||||||
`log show --predicate 'subsystem == "ev.mana.core"'` fängt sie.
|
|
||||||
- Erfolgreicher Refresh resettet `refreshFailureCount` auf 0.
|
|
||||||
- Nichts breaking. Default-Verhalten für alle bestehenden Apps
|
|
||||||
unverändert.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- 4 neue Tests in `AuthClientGuestAndResilienceTests`:
|
|
||||||
- `softFirst`: erster 401 behält Session, zweiter wiped
|
|
||||||
- `softFirst`: 401 nach erfolgreichem Refresh wiped sofort
|
|
||||||
- `softFirst`: transienter 503 zählt nicht in Failure-Count
|
|
||||||
- `immediateWipe`: erster 401 wiped (Default unverändert)
|
|
||||||
- 89/89 grün (vorher 85/85).
|
|
||||||
|
|
||||||
### Adoption
|
|
||||||
|
|
||||||
Apps die opt-in wollen:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
static let manaAppConfig: ManaAppConfig = DefaultManaAppConfig(
|
|
||||||
authBaseURL: URL(string: "https://auth.mana.how")!,
|
|
||||||
keychainService: ManaSharedKeychainGroup,
|
|
||||||
keychainAccessGroup: ManaSharedKeychainGroup,
|
|
||||||
appGroup: "group.ev.mana.<app>",
|
|
||||||
refreshFailurePolicy: .softFirst
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Pageta-native ist der erste Konsument. Andere SSO-Apps können später
|
|
||||||
nachziehen, falls Till oder Logs es nahelegen.
|
|
||||||
|
|
||||||
## [1.7.0] — 2026-05-17
|
|
||||||
|
|
||||||
Minor — **`ManaAppLog`** + `ManaAppConfig.appGroup`/`logSubsystem`.
|
|
||||||
Audit 2026-05-17 V4. Ersetzt das hand-getippte `Log.swift`-Boilerplate
|
|
||||||
in jeder App durch einen Config-getriebenen Wrapper.
|
|
||||||
|
|
||||||
### Neu
|
|
||||||
|
|
||||||
- `ManaAppLog` (public struct, Sendable) — Factory für OSLog-Logger
|
|
||||||
gegen ein `ManaAppConfig`. Standard-Kategorien `app`/`auth`/`api`/
|
|
||||||
`db`/`web`, plus `category("…")` für app-spezifische Kategorien.
|
|
||||||
- `ManaAppConfig.appGroup: String?` (default `nil`) — Single-Source
|
|
||||||
für den App-Group-String, der heute in jeder App 3-4× hardcoded
|
|
||||||
steht. Apps ohne Widget/ShareExt setzen weiterhin nichts.
|
|
||||||
- `ManaAppConfig.logSubsystem: String` (default = `keychainService`)
|
|
||||||
— Subsystem für `ManaAppLog`. In allen Apps heute schon
|
|
||||||
`ev.mana.<app>`, deshalb default sinnvoll.
|
|
||||||
|
|
||||||
### Geändert
|
|
||||||
|
|
||||||
- Nichts breaking. Beide neuen Felder haben Default-Implementations
|
|
||||||
im Protocol-Extension, bestehende Konsumenten von `ManaAppConfig`
|
|
||||||
brauchen nichts anzupassen.
|
|
||||||
- `DefaultManaAppConfig.init` hat zwei zusätzliche optionale Parameter
|
|
||||||
(`appGroup`, `logSubsystem`), beide mit `nil`-Defaults — Quellkompatibel.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- 4 neue ManaAppConfig-Tests + 5 neue ManaAppLog-Tests. 85/85 grün
|
|
||||||
(vorher 76/76).
|
|
||||||
|
|
||||||
### Migrations-Hinweis
|
|
||||||
|
|
||||||
Apps können ihre lokale `Log.swift` von ~13 LOC auf ~5 LOC schrumpfen:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import ManaCore
|
|
||||||
|
|
||||||
enum Log {
|
|
||||||
private static let mana = ManaAppLog(AppConfig.manaAppConfig)
|
|
||||||
static let app = mana.app
|
|
||||||
static let auth = mana.auth
|
|
||||||
static let api = mana.api
|
|
||||||
static let study = mana.category("study") // app-spezifisch
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Plus `AppConfig.manaAppConfig` kann `appGroup: "group.ev.mana.<app>"`
|
|
||||||
ergänzen, damit der App-Group-String single-source ist.
|
|
||||||
|
|
||||||
## [1.6.0] — 2026-05-17
|
|
||||||
|
|
||||||
Minor — **`ManaTheme`-Variants** in ManaTokens. Acht Web-Themes aus
|
|
||||||
`@mana/themes` (mana, forest, paper, neutral, lume, twilight,
|
|
||||||
skylight, monochrome) sind jetzt als Swift verfügbar; generiert aus
|
|
||||||
den CSS-Quellen via `pnpm --filter @mana/themes gen:swift`.
|
|
||||||
|
|
||||||
Hintergrund: bisher haben Cards, Manaspur und Nutriphi je ~90 LOC
|
|
||||||
forest-HSL-Apparat lokal nachgebaut, weil ManaTokens nur die
|
|
||||||
mana-Variant kannte. Mit v1.6.0 sind diese App-lokalen Theme-Files
|
|
||||||
ablösbar (Audit 2026-05-17 Vorschlag V1).
|
|
||||||
|
|
||||||
### Neu
|
|
||||||
|
|
||||||
- `ManaTheme` (public enum) — `case mana | forest | paper | neutral
|
|
||||||
| lume | twilight | skylight | monochrome`. `String`-RawValue,
|
|
||||||
`CaseIterable`, `Sendable`.
|
|
||||||
- `ManaThemeColors` (public struct, Sendable) — die 12 Tokens als
|
|
||||||
`Color`-Properties. Per-Variant statisch verfügbar
|
|
||||||
(`ManaThemeColors.forest` usw.) aus der generierten Datei
|
|
||||||
`GeneratedThemes.swift`.
|
|
||||||
- `ManaTheme.colors` + Convenience-Accessoren (`.background`,
|
|
||||||
`.foreground`, …) — beide Schreibweisen funktionieren.
|
|
||||||
- `EnvironmentValues.manaTheme` + `View.manaTheme(_:)` —
|
|
||||||
SwiftUI-Environment, Default `.mana`. Apps mit User-Theme-Switching
|
|
||||||
setzen den Wert per `@AppStorage`-gestütztem Binding.
|
|
||||||
|
|
||||||
### Geändert
|
|
||||||
|
|
||||||
- Nichts breaking. `ManaColor.*` und `ManaBrand.*` bleiben unverändert
|
|
||||||
und liefern weiter die mana-Variant-Werte.
|
|
||||||
|
|
||||||
### Generator
|
|
||||||
|
|
||||||
- `mana/packages/themes/scripts/gen-swift-themes.mjs` liest die acht
|
|
||||||
CSS-Variant-Dateien und schreibt `GeneratedThemes.swift`. CI-Drift-
|
|
||||||
Check: nach Generator-Lauf `git diff --exit-code` in beiden Repos.
|
|
||||||
|
|
||||||
### Migrations-Hinweis
|
|
||||||
|
|
||||||
Apps können ihre lokalen `*Theme.swift`-Files (Cards, Manaspur,
|
|
||||||
Nutriphi) durch direkten Aufruf von `ManaTheme.<variant>` ersetzen.
|
|
||||||
Konvention: Apps mit fester Identität setzen `.manaTheme(.<variant>)`
|
|
||||||
an der App-Root; verschachtelte Views lesen via
|
|
||||||
`@Environment(\.manaTheme)`.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- 7 neue Tests (`ThemeTests.swift`). 12/12 ManaTokens grün auf macOS.
|
|
||||||
|
|
||||||
## [1.5.1] — 2026-05-17
|
|
||||||
|
|
||||||
Patch — KeychainStore-Migration-Fallback. Bisher haben die mana-Apps
|
|
||||||
`keychainAccessGroup: nil` gesetzt; Apple legt das Item dann im
|
|
||||||
default-bucket (`$(AppIdentifierPrefix).$(BundleId)`) ab. Beim
|
|
||||||
Wechsel auf eine explizite `accessGroup` (Apps werden mit v1.5.1
|
|
||||||
nachgezogen) hätten User sonst beim ersten Start einen Logout
|
|
||||||
gesehen, weil Apples Read-mit-Group das alte Item nicht immer
|
|
||||||
liefert.
|
|
||||||
|
|
||||||
### Verändert
|
|
||||||
|
|
||||||
- `KeychainStore.getString(for:)` — bei `accessGroup != nil` und
|
|
||||||
einem Miss wird einmalig ohne `kSecAttrAccessGroup` re-queryt.
|
|
||||||
Findet sich der alte Eintrag im default-bucket, wird er in den
|
|
||||||
expliziten Bucket migriert und der alte gelöscht. Transparent für
|
|
||||||
alle Caller — `getString` liefert wie gewohnt den Wert zurück.
|
|
||||||
|
|
||||||
### Migrations-Hinweis
|
|
||||||
|
|
||||||
- Apps, die `keychainAccessGroup` ab v1.5.1 explizit setzen, brauchen
|
|
||||||
keinen Logout zu erwarten. Apps, die weiterhin `nil` setzen, sind
|
|
||||||
von dem Fallback nicht betroffen (er greift nur bei `accessGroup
|
|
||||||
!= nil`).
|
|
||||||
|
|
||||||
## [1.5.0] — 2026-05-14
|
## [1.5.0] — 2026-05-14
|
||||||
|
|
||||||
Minor — `getProfile()` + `ProfileInfo`. Apps können den 2FA-Status
|
Minor — `getProfile()` + `ProfileInfo`. Apps können den 2FA-Status
|
||||||
|
|
|
||||||
|
|
@ -56,19 +56,6 @@ public final class AuthClient {
|
||||||
let keychain: KeychainStore
|
let keychain: KeychainStore
|
||||||
let session: URLSession
|
let session: URLSession
|
||||||
|
|
||||||
/// True nach mindestens einem erfolgreichen Refresh in diesem
|
|
||||||
/// Prozess. Genutzt von `RefreshFailurePolicy.softFirst` um zu
|
|
||||||
/// erkennen, ob ein "Session tot"-Response vertrauenswürdig ist
|
|
||||||
/// (= die Session war in diesem Prozess schon mal nachweislich
|
|
||||||
/// gültig, also ist sie jetzt echt invalidiert) oder ob er ein
|
|
||||||
/// transienter Server-Glitch sein könnte.
|
|
||||||
private(set) var refreshOnceSucceeded: Bool = false
|
|
||||||
|
|
||||||
/// Zählt die Anzahl Session-invalidierender Refresh-Fehler im
|
|
||||||
/// aktuellen Prozess. Genutzt von `RefreshFailurePolicy.softFirst`
|
|
||||||
/// um beim zweiten Fehler in Folge dann doch zu wipen.
|
|
||||||
private(set) var refreshFailureCount: Int = 0
|
|
||||||
|
|
||||||
public init(config: ManaAppConfig, session: URLSession = .shared) {
|
public init(config: ManaAppConfig, session: URLSession = .shared) {
|
||||||
self.config = config
|
self.config = config
|
||||||
keychain = KeychainStore(service: config.keychainService, accessGroup: config.keychainAccessGroup)
|
keychain = KeychainStore(service: config.keychainService, accessGroup: config.keychainAccessGroup)
|
||||||
|
|
@ -292,8 +279,6 @@ public final class AuthClient {
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = try JSONEncoder().encode(RefreshRequest(refreshToken: refresh))
|
request.httpBody = try JSONEncoder().encode(RefreshRequest(refreshToken: refresh))
|
||||||
|
|
||||||
CoreLog.auth.info("Refresh attempt — token len \(refresh.count, privacy: .public), once-succeeded=\(self.refreshOnceSucceeded, privacy: .public), failure-count=\(self.refreshFailureCount, privacy: .public), policy=\(String(describing: self.config.refreshFailurePolicy), privacy: .public)")
|
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse else {
|
guard let http = response as? HTTPURLResponse else {
|
||||||
throw AuthError.networkFailure("Keine HTTP-Antwort")
|
throw AuthError.networkFailure("Keine HTTP-Antwort")
|
||||||
|
|
@ -304,7 +289,6 @@ public final class AuthClient {
|
||||||
data: data,
|
data: data,
|
||||||
retryAfterHeader: http.retryAfterSeconds
|
retryAfterHeader: http.retryAfterSeconds
|
||||||
)
|
)
|
||||||
let bodyExcerpt = String(data: data.prefix(256), encoding: .utf8) ?? "<binary>"
|
|
||||||
// Wipe nur bei tatsächlich invalidierter Session. Transiente
|
// Wipe nur bei tatsächlich invalidierter Session. Transiente
|
||||||
// Fehler (5xx, Rate-Limit) lassen den Token erhalten — sonst
|
// Fehler (5xx, Rate-Limit) lassen den Token erhalten — sonst
|
||||||
// wirft jeder mana-auth-Downtime-Moment alle Apps in den
|
// wirft jeder mana-auth-Downtime-Moment alle Apps in den
|
||||||
|
|
@ -312,21 +296,6 @@ public final class AuthClient {
|
||||||
// beim Wipe in den Guest-Status zurückgefallen statt in
|
// beim Wipe in den Guest-Status zurückgefallen statt in
|
||||||
// strict `.signedOut`.
|
// strict `.signedOut`.
|
||||||
if err.invalidatesSession {
|
if err.invalidatesSession {
|
||||||
refreshFailureCount += 1
|
|
||||||
let shouldWipe: Bool
|
|
||||||
switch config.refreshFailurePolicy {
|
|
||||||
case .immediateWipe:
|
|
||||||
shouldWipe = true
|
|
||||||
case .softFirst:
|
|
||||||
// Vertraue dem Server-"Session-tot" nur, wenn er
|
|
||||||
// bereits einmal in diesem Prozess "Session-OK" gesagt
|
|
||||||
// hat (= Session war nachweislich gültig, ist also
|
|
||||||
// jetzt echt invalidiert) ODER es ist mindestens der
|
|
||||||
// zweite invalidierende Fehler in Folge.
|
|
||||||
shouldWipe = refreshOnceSucceeded || refreshFailureCount >= 2
|
|
||||||
}
|
|
||||||
if shouldWipe {
|
|
||||||
CoreLog.auth.error("Wiping keychain — refresh \(http.statusCode, privacy: .public) invalidates session (attempt #\(self.refreshFailureCount, privacy: .public), once-succeeded=\(self.refreshOnceSucceeded, privacy: .public)). Body: \(bodyExcerpt, privacy: .public)")
|
|
||||||
keychain.wipe()
|
keychain.wipe()
|
||||||
if let guestId = keychain.getString(for: .guestId) {
|
if let guestId = keychain.getString(for: .guestId) {
|
||||||
status = .guest(id: guestId)
|
status = .guest(id: guestId)
|
||||||
|
|
@ -334,10 +303,9 @@ public final class AuthClient {
|
||||||
status = .signedOut
|
status = .signedOut
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
CoreLog.auth.notice("Refresh \(http.statusCode, privacy: .public) invalidates session, but softFirst policy retains keychain (attempt #\(self.refreshFailureCount, privacy: .public)). Body: \(bodyExcerpt, privacy: .public)")
|
CoreLog.auth.notice(
|
||||||
}
|
"Refresh failed transiently (\(http.statusCode, privacy: .public)) — keeping session"
|
||||||
} else {
|
)
|
||||||
CoreLog.auth.notice("Refresh failed transiently (\(http.statusCode, privacy: .public)) — keeping session. Body: \(bodyExcerpt, privacy: .public)")
|
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
@ -347,9 +315,6 @@ public final class AuthClient {
|
||||||
if !token.refreshToken.isEmpty {
|
if !token.refreshToken.isEmpty {
|
||||||
try keychain.setString(token.refreshToken, for: .refreshToken)
|
try keychain.setString(token.refreshToken, for: .refreshToken)
|
||||||
}
|
}
|
||||||
refreshOnceSucceeded = true
|
|
||||||
refreshFailureCount = 0
|
|
||||||
CoreLog.auth.info("Refresh succeeded")
|
|
||||||
return token.accessToken
|
return token.accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,40 +61,10 @@ public final class KeychainStore: Sendable {
|
||||||
query[kSecMatchLimit] = kSecMatchLimitOne
|
query[kSecMatchLimit] = kSecMatchLimitOne
|
||||||
var result: AnyObject?
|
var result: AnyObject?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
if status == errSecSuccess, let data = result as? Data {
|
guard status == errSecSuccess, let data = result as? Data else {
|
||||||
return String(data: data, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// One-shot migration: bis v1.2.0 haben die mana-Apps Keychain-
|
|
||||||
// Items ohne explizite `kSecAttrAccessGroup` geschrieben. Apple
|
|
||||||
// hat sie dann im default-bucket (= `$(AppIdentifierPrefix).$(BundleId)`)
|
|
||||||
// gelandet. Ab v1.2.1 setzen die Apps die accessGroup explizit
|
|
||||||
// auf denselben Identifier. In den meisten Fällen liefert Apple
|
|
||||||
// das alte Item beim Read-mit-Group transparent zurück — wenn
|
|
||||||
// nicht, hat das einen Logout zur Folge. Dieser Fallback liest
|
|
||||||
// den Item nochmal ohne accessGroup, schreibt ihn beim Erfolg
|
|
||||||
// mit accessGroup neu in den expliziten Bucket und löscht den
|
|
||||||
// alten Eintrag.
|
|
||||||
guard accessGroup != nil else { return nil }
|
|
||||||
var fallback: [CFString: Any] = [
|
|
||||||
kSecClass: kSecClassGenericPassword,
|
|
||||||
kSecAttrService: service,
|
|
||||||
kSecAttrAccount: key.rawValue,
|
|
||||||
kSecReturnData: true,
|
|
||||||
kSecMatchLimit: kSecMatchLimitOne,
|
|
||||||
]
|
|
||||||
var fallbackResult: AnyObject?
|
|
||||||
guard SecItemCopyMatching(fallback as CFDictionary, &fallbackResult) == errSecSuccess,
|
|
||||||
let data = fallbackResult as? Data,
|
|
||||||
let value = String(data: data, encoding: .utf8)
|
|
||||||
else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
try? setString(value, for: key)
|
return String(data: data, encoding: .utf8)
|
||||||
fallback.removeValue(forKey: kSecReturnData)
|
|
||||||
fallback.removeValue(forKey: kSecMatchLimit)
|
|
||||||
SecItemDelete(fallback as CFDictionary)
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func remove(for key: Key) {
|
public func remove(for key: Key) {
|
||||||
|
|
|
||||||
|
|
@ -21,49 +21,6 @@ public protocol ManaAppConfig: Sendable {
|
||||||
/// Apple-Developer-Team-ID provisioniert sein und das Entitlement
|
/// Apple-Developer-Team-ID provisioniert sein und das Entitlement
|
||||||
/// `keychain-access-groups` mit demselben Wert tragen.
|
/// `keychain-access-groups` mit demselben Wert tragen.
|
||||||
var keychainAccessGroup: String? { get }
|
var keychainAccessGroup: String? { get }
|
||||||
|
|
||||||
/// App-Group für Daten-Sharing zwischen App ↔ Widget ↔ ShareExt.
|
|
||||||
/// Üblich `group.ev.mana.<app>`. `nil` für Apps ohne Extensions.
|
|
||||||
///
|
|
||||||
/// Single-Source für den App-Group-String, der heute in jeder App
|
|
||||||
/// 3-4× hardcoded steht (AppConfig + App-Entitlement + Widget-
|
|
||||||
/// Entitlement + ShareExt-Entitlement). Die Entitlements bleiben
|
|
||||||
/// hardcoded (das verlangt iOS), aber im Swift-Code ist der Wert
|
|
||||||
/// damit single-source.
|
|
||||||
var appGroup: String? { get }
|
|
||||||
|
|
||||||
/// OSLog-Subsystem für App-Logger, üblich `ev.mana.<app>`. Default
|
|
||||||
/// ist `keychainService` (der schon der Konvention folgt).
|
|
||||||
var logSubsystem: String { get }
|
|
||||||
|
|
||||||
/// Was ``AuthClient/refreshAccessToken()`` macht, wenn der Server
|
|
||||||
/// einen Session-invalidierenden Fehler zurückgibt (401, tokenExpired,
|
|
||||||
/// tokenInvalid, ...). Default ``RefreshFailurePolicy/immediateWipe``
|
|
||||||
/// für Quellkompatibilität mit allen bestehenden Apps.
|
|
||||||
///
|
|
||||||
/// Apps, die einen TestFlight-/Cold-Launch-Logout durch eine
|
|
||||||
/// transiente Server-/Deployment-Glitch verhindern wollen, setzen
|
|
||||||
/// ``RefreshFailurePolicy/softFirst`` — dann überlebt die persistierte
|
|
||||||
/// Session den ersten Refresh-Fehler im Prozess und wird erst gewiped,
|
|
||||||
/// wenn der Server beim nächsten Versuch nochmal "Session tot" sagt
|
|
||||||
/// (oder wenn vorher schon ein erfolgreicher Refresh in diesem
|
|
||||||
/// Prozess passiert ist — dann ist der invalidate-Response
|
|
||||||
/// vertrauenswürdig).
|
|
||||||
var refreshFailurePolicy: RefreshFailurePolicy { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Default-Implementationen
|
|
||||||
|
|
||||||
public extension ManaAppConfig {
|
|
||||||
/// Default `nil` — Apps ohne Widget/ShareExt müssen nichts setzen.
|
|
||||||
var appGroup: String? { nil }
|
|
||||||
|
|
||||||
/// Default = `keychainService`. Beide folgen heute in allen Apps
|
|
||||||
/// derselben Konvention `ev.mana.<app>`.
|
|
||||||
var logSubsystem: String { keychainService }
|
|
||||||
|
|
||||||
/// Default `immediateWipe` — bestehendes Verhalten.
|
|
||||||
var refreshFailurePolicy: RefreshFailurePolicy { .immediateWipe }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Standard-Implementierung von ``ManaAppConfig``. Apps können diese
|
/// Standard-Implementierung von ``ManaAppConfig``. Apps können diese
|
||||||
|
|
@ -72,56 +29,14 @@ public struct DefaultManaAppConfig: ManaAppConfig {
|
||||||
public let authBaseURL: URL
|
public let authBaseURL: URL
|
||||||
public let keychainService: String
|
public let keychainService: String
|
||||||
public let keychainAccessGroup: String?
|
public let keychainAccessGroup: String?
|
||||||
public let appGroup: String?
|
|
||||||
public let logSubsystem: String
|
|
||||||
public let refreshFailurePolicy: RefreshFailurePolicy
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
authBaseURL: URL,
|
authBaseURL: URL,
|
||||||
keychainService: String,
|
keychainService: String,
|
||||||
keychainAccessGroup: String? = nil,
|
keychainAccessGroup: String? = nil
|
||||||
appGroup: String? = nil,
|
|
||||||
logSubsystem: String? = nil,
|
|
||||||
refreshFailurePolicy: RefreshFailurePolicy = .immediateWipe
|
|
||||||
) {
|
) {
|
||||||
self.authBaseURL = authBaseURL
|
self.authBaseURL = authBaseURL
|
||||||
self.keychainService = keychainService
|
self.keychainService = keychainService
|
||||||
self.keychainAccessGroup = keychainAccessGroup
|
self.keychainAccessGroup = keychainAccessGroup
|
||||||
self.appGroup = appGroup
|
|
||||||
// Konvention: log-Subsystem = keychainService, falls nicht
|
|
||||||
// explizit anders gewünscht.
|
|
||||||
self.logSubsystem = logSubsystem ?? keychainService
|
|
||||||
self.refreshFailurePolicy = refreshFailurePolicy
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Policy für ``AuthClient/refreshAccessToken()``-Verhalten bei
|
|
||||||
/// Session-invalidierenden Server-Antworten.
|
|
||||||
///
|
|
||||||
/// `immediateWipe` ist das historische Verhalten von ManaCore: jeder
|
|
||||||
/// Server-Hinweis "Session tot" → Keychain wipe → User wird ausgeloggt.
|
|
||||||
/// Problem: ein transienter Server-Bug (z.B. mana-auth-Regression
|
|
||||||
/// 2026-05-19, siehe `project_auth_refresh_bug` in der Memory) kann
|
|
||||||
/// dann **alle** ManaCore-Apps gleichzeitig auswerfen.
|
|
||||||
///
|
|
||||||
/// `softFirst` macht den ersten Refresh-Fehler eines Prozesses zu einem
|
|
||||||
/// "Vielleicht" — Session bleibt im Keychain, App kann es beim nächsten
|
|
||||||
/// Request nochmal probieren. Erst der **zweite** Fehler in Folge
|
|
||||||
/// (oder ein Fehler nach einem zuvor erfolgreichen Refresh im selben
|
|
||||||
/// Prozess) löst den Wipe aus.
|
|
||||||
///
|
|
||||||
/// Trade-off: bei `softFirst` sieht ein User mit echt invalider
|
|
||||||
/// Session beim ersten Request einen Auth-Fehler statt direkt im
|
|
||||||
/// Login-Screen zu landen. Akzeptabel — der zweite Request wiped
|
|
||||||
/// dann sauber und User landet im Login.
|
|
||||||
public enum RefreshFailurePolicy: Sendable {
|
|
||||||
/// Default — Server-"Session-tot"-Antworten führen sofort zu
|
|
||||||
/// `keychain.wipe()` und Status `.signedOut`.
|
|
||||||
case immediateWipe
|
|
||||||
|
|
||||||
/// Erster invalidierender Refresh-Fehler im Prozess wird **nicht**
|
|
||||||
/// gewiped — Session bleibt erhalten, Fehler wird geworfen. Wipe
|
|
||||||
/// passiert beim zweiten Fehler oder nach mindestens einem
|
|
||||||
/// erfolgreichen Refresh in diesem Prozess.
|
|
||||||
case softFirst
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Kanonische Cross-App-Keychain-Group für alle nativen mana-e.V.-Apps.
|
|
||||||
///
|
|
||||||
/// Apps, die diese Group im `keychain-access-groups` Entitlement haben
|
|
||||||
/// und denselben String als `ManaAppConfig.keychainAccessGroup`
|
|
||||||
/// liefern, teilen ihre Auth-Tokens auf demselben Device. Ein Login in
|
|
||||||
/// einer App bedeutet damit: alle anderen mana-Apps starten direkt im
|
|
||||||
/// `.signedIn`-Status.
|
|
||||||
///
|
|
||||||
/// **Mit Team-ID-Prefix `QP3GLU8PH3.`:** Apple's `Security.framework`
|
|
||||||
/// macht einen exakten String-Match zwischen `kSecAttrAccessGroup`
|
|
||||||
/// (was Swift hier übergibt) und dem `keychain-access-groups`-
|
|
||||||
/// Entitlement des Bundles (das `$(AppIdentifierPrefix)` zur Build-
|
|
||||||
/// Zeit auf den Team-Prefix expandiert). Ohne Prefix in der Konstante
|
|
||||||
/// matched Sec-Framework nicht und gibt `errSecMissingEntitlement`
|
|
||||||
/// (OSStatus -34018) zurück — beobachtet 2026-05-18 auf werdrobe +
|
|
||||||
/// nutriphi (iPhone + Mac).
|
|
||||||
///
|
|
||||||
/// Pendant zum entitlement-Eintrag in `project.yml`:
|
|
||||||
/// ```
|
|
||||||
/// keychain-access-groups:
|
|
||||||
/// - $(AppIdentifierPrefix)ev.mana.session
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Apps, die Cross-App-SSO **nicht** wollen, setzen
|
|
||||||
/// `keychainAccessGroup: nil` und tragen kein
|
|
||||||
/// `keychain-access-groups` Entitlement.
|
|
||||||
public let ManaSharedKeychainGroup = "QP3GLU8PH3.ev.mana.session"
|
|
||||||
|
|
||||||
/// Suffix-Variante ohne Team-Prefix — für Konstanten-Bedarf in
|
|
||||||
/// Entitlement-Files, wo `$(AppIdentifierPrefix)` zur Build-Zeit
|
|
||||||
/// vorgestellt wird.
|
|
||||||
public let ManaSharedKeychainGroupSuffix = "ev.mana.session"
|
|
||||||
|
|
||||||
/// Convenience-Hilfe für SwiftUI-Apps: liefert den Wert, den
|
|
||||||
/// `ManaAppConfig.keychainAccessGroup` bekommen muss, damit Cross-App-
|
|
||||||
/// SSO greift.
|
|
||||||
public func manaSharedKeychainAccessGroup() -> String {
|
|
||||||
ManaSharedKeychainGroup
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
/// Convenience-Factory für OSLog-Logger gegen ein `ManaAppConfig`.
|
|
||||||
///
|
|
||||||
/// Hintergrund: Jede mana-App hatte vor v1.7.0 ein eigenes `Log.swift`
|
|
||||||
/// mit 4-6 hand-getippten `Logger(subsystem: "ev.mana.<slug>", …)`-
|
|
||||||
/// Aufrufen — der Subsystem-String und die Standard-Kategorien
|
|
||||||
/// (`app`/`auth`/`api`) wiederholten sich 8× identisch. `ManaAppLog`
|
|
||||||
/// kapselt den Subsystem-Lookup gegen die App-Config; Standard-
|
|
||||||
/// Kategorien sind Convenience-Accessoren, app-spezifische gehen über
|
|
||||||
/// ``ManaAppLog/category(_:)``.
|
|
||||||
///
|
|
||||||
/// **Verwendung** (in der App):
|
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// import ManaCore
|
|
||||||
///
|
|
||||||
/// enum Log {
|
|
||||||
/// private static let mana = ManaAppLog(AppConfig.manaAppConfig)
|
|
||||||
/// static let app = mana.app
|
|
||||||
/// static let auth = mana.auth
|
|
||||||
/// static let api = mana.api
|
|
||||||
/// // App-spezifische Kategorien:
|
|
||||||
/// static let study = mana.category("study")
|
|
||||||
/// static let sync = mana.category("sync")
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Die Standard-Kategorien sind bewusst eine kleine Schublade
|
|
||||||
/// (app/auth/api/db/web). Cards/Memoro/Manaspur sollen ihre `study`-/
|
|
||||||
/// `audio`-/`tracking`-Kategorien weiterhin app-spezifisch deklarieren.
|
|
||||||
public struct ManaAppLog: Sendable {
|
|
||||||
public let subsystem: String
|
|
||||||
|
|
||||||
/// Direkter Constructor (für Tests oder andere Subsysteme).
|
|
||||||
public init(subsystem: String) {
|
|
||||||
self.subsystem = subsystem
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Constructor aus einer ``ManaAppConfig``. Nutzt `logSubsystem`
|
|
||||||
/// (Default = `keychainService`, beides üblich `ev.mana.<app>`).
|
|
||||||
public init(_ config: ManaAppConfig) {
|
|
||||||
self.subsystem = config.logSubsystem
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allgemeiner App-Logger.
|
|
||||||
public var app: Logger { Logger(subsystem: subsystem, category: "app") }
|
|
||||||
|
|
||||||
/// Auth-bezogene Events.
|
|
||||||
public var auth: Logger { Logger(subsystem: subsystem, category: "auth") }
|
|
||||||
|
|
||||||
/// API-/Netzwerk-Calls.
|
|
||||||
public var api: Logger { Logger(subsystem: subsystem, category: "api") }
|
|
||||||
|
|
||||||
/// Datenbank-/SwiftData-/Persistenz-Events.
|
|
||||||
public var db: Logger { Logger(subsystem: subsystem, category: "db") }
|
|
||||||
|
|
||||||
/// Web-/WKWebView-bezogene Events (für Hybrid-Apps).
|
|
||||||
public var web: Logger { Logger(subsystem: subsystem, category: "web") }
|
|
||||||
|
|
||||||
/// App-spezifische Kategorie. Beliebige Strings, weil Console.app
|
|
||||||
/// Kategorien als Free-Text filtert.
|
|
||||||
public func category(_ name: String) -> Logger {
|
|
||||||
Logger(subsystem: subsystem, category: name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
// ============================================================
|
|
||||||
// AUTOGENERIERT — NICHT MANUELL EDITIEREN.
|
|
||||||
//
|
|
||||||
// Quelle: mana/packages/themes/src/variants/*.css
|
|
||||||
// Generator: mana/packages/themes/scripts/gen-swift-themes.mjs
|
|
||||||
// Aufruf: pnpm --filter @mana/themes gen:swift
|
|
||||||
//
|
|
||||||
// Drift-Check: nach jedem Generator-Lauf `git diff --exit-code`
|
|
||||||
// in CI sicherstellen.
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
public extension ManaThemeColors {
|
|
||||||
|
|
||||||
/// forest-Variant aus `mana/packages/themes/src/variants/forest.css`.
|
|
||||||
static let forest = ManaThemeColors(
|
|
||||||
background: Color.manaToken(light: (0, 0, 100), dark: (142, 30, 8)),
|
|
||||||
foreground: Color.manaToken(light: (142, 30, 12), dark: (142, 15, 95)),
|
|
||||||
surface: Color.manaToken(light: (142, 25, 98), dark: (142, 25, 12)),
|
|
||||||
surfaceHover: Color.manaToken(light: (142, 20, 95), dark: (142, 20, 16)),
|
|
||||||
muted: Color.manaToken(light: (142, 15, 93), dark: (142, 18, 18)),
|
|
||||||
mutedForeground: Color.manaToken(light: (142, 10, 42), dark: (142, 12, 65)),
|
|
||||||
border: Color.manaToken(light: (142, 15, 88), dark: (142, 18, 22)),
|
|
||||||
primary: Color.manaToken(light: (142, 76, 28), dark: (142, 71, 45)),
|
|
||||||
primaryForeground: Color.manaToken(light: (0, 0, 100), dark: (142, 30, 8)),
|
|
||||||
error: Color.manaToken(light: (0, 84, 60), dark: (0, 63, 55)),
|
|
||||||
success: Color.manaToken(light: (142, 71, 45), dark: (142, 71, 45)),
|
|
||||||
warning: Color.manaToken(light: (38, 92, 50), dark: (48, 96, 53))
|
|
||||||
)
|
|
||||||
|
|
||||||
/// lume-Variant aus `mana/packages/themes/src/variants/lume.css`.
|
|
||||||
static let lume = ManaThemeColors(
|
|
||||||
background: Color.manaToken(light: (0, 0, 100), dark: (0, 0, 6)),
|
|
||||||
foreground: Color.manaToken(light: (0, 0, 17), dark: (0, 0, 100)),
|
|
||||||
surface: Color.manaToken(light: (0, 0, 99), dark: (0, 0, 12)),
|
|
||||||
surfaceHover: Color.manaToken(light: (51, 40, 95), dark: (51, 30, 18)),
|
|
||||||
muted: Color.manaToken(light: (0, 0, 92), dark: (0, 0, 17)),
|
|
||||||
mutedForeground: Color.manaToken(light: (0, 0, 42), dark: (0, 0, 65)),
|
|
||||||
border: Color.manaToken(light: (0, 0, 86), dark: (0, 0, 26)),
|
|
||||||
primary: Color.manaToken(light: (51, 95, 58), dark: (51, 95, 58)),
|
|
||||||
primaryForeground: Color.manaToken(light: (0, 0, 13), dark: (0, 0, 6)),
|
|
||||||
error: Color.manaToken(light: (5, 78, 55), dark: (5, 78, 60)),
|
|
||||||
success: Color.manaToken(light: (142, 71, 45), dark: (142, 71, 50)),
|
|
||||||
warning: Color.manaToken(light: (38, 92, 50), dark: (48, 96, 55))
|
|
||||||
)
|
|
||||||
|
|
||||||
/// mana-Variant aus `mana/packages/themes/src/variants/mana.css`.
|
|
||||||
static let mana = ManaThemeColors(
|
|
||||||
background: Color.manaToken(light: (0, 0, 100), dark: (220, 20, 9)),
|
|
||||||
foreground: Color.manaToken(light: (220, 15, 12), dark: (25, 15, 95)),
|
|
||||||
surface: Color.manaToken(light: (0, 0, 99), dark: (220, 18, 13)),
|
|
||||||
surfaceHover: Color.manaToken(light: (25, 30, 95), dark: (25, 20, 18)),
|
|
||||||
muted: Color.manaToken(light: (25, 20, 93), dark: (220, 18, 18)),
|
|
||||||
mutedForeground: Color.manaToken(light: (220, 10, 42), dark: (220, 10, 65)),
|
|
||||||
border: Color.manaToken(light: (25, 15, 88), dark: (220, 15, 22)),
|
|
||||||
primary: Color.manaToken(light: (25, 100, 50), dark: (25, 100, 58)),
|
|
||||||
primaryForeground: Color.manaToken(light: (0, 0, 10), dark: (220, 20, 9)),
|
|
||||||
error: Color.manaToken(light: (0, 84, 60), dark: (0, 63, 55)),
|
|
||||||
success: Color.manaToken(light: (142, 71, 45), dark: (142, 71, 45)),
|
|
||||||
warning: Color.manaToken(light: (38, 92, 50), dark: (48, 96, 53))
|
|
||||||
)
|
|
||||||
|
|
||||||
/// monochrome-Variant aus `mana/packages/themes/src/variants/monochrome.css`.
|
|
||||||
static let monochrome = ManaThemeColors(
|
|
||||||
background: Color.manaToken(light: (0, 0, 100), dark: (0, 0, 5)),
|
|
||||||
foreground: Color.manaToken(light: (0, 0, 10), dark: (0, 0, 95)),
|
|
||||||
surface: Color.manaToken(light: (0, 0, 99), dark: (0, 0, 10)),
|
|
||||||
surfaceHover: Color.manaToken(light: (0, 0, 95), dark: (0, 0, 15)),
|
|
||||||
muted: Color.manaToken(light: (0, 0, 93), dark: (0, 0, 17)),
|
|
||||||
mutedForeground: Color.manaToken(light: (0, 0, 40), dark: (0, 0, 65)),
|
|
||||||
border: Color.manaToken(light: (0, 0, 85), dark: (0, 0, 22)),
|
|
||||||
primary: Color.manaToken(light: (0, 0, 25), dark: (0, 0, 80)),
|
|
||||||
primaryForeground: Color.manaToken(light: (0, 0, 100), dark: (0, 0, 5)),
|
|
||||||
error: Color.manaToken(light: (0, 70, 45), dark: (0, 65, 55)),
|
|
||||||
success: Color.manaToken(light: (142, 60, 35), dark: (142, 60, 50)),
|
|
||||||
warning: Color.manaToken(light: (38, 90, 45), dark: (48, 90, 55))
|
|
||||||
)
|
|
||||||
|
|
||||||
/// neutral-Variant aus `mana/packages/themes/src/variants/neutral.css`.
|
|
||||||
static let neutral = ManaThemeColors(
|
|
||||||
background: Color.manaToken(light: (0, 0, 99), dark: (0, 0, 8)),
|
|
||||||
foreground: Color.manaToken(light: (0, 0, 13), dark: (0, 0, 95)),
|
|
||||||
surface: Color.manaToken(light: (0, 0, 100), dark: (0, 0, 12)),
|
|
||||||
surfaceHover: Color.manaToken(light: (0, 0, 96), dark: (0, 0, 17)),
|
|
||||||
muted: Color.manaToken(light: (0, 0, 94), dark: (0, 0, 18)),
|
|
||||||
mutedForeground: Color.manaToken(light: (0, 0, 45), dark: (0, 0, 65)),
|
|
||||||
border: Color.manaToken(light: (0, 0, 88), dark: (0, 0, 22)),
|
|
||||||
primary: Color.manaToken(light: (215, 60, 40), dark: (215, 70, 60)),
|
|
||||||
primaryForeground: Color.manaToken(light: (0, 0, 100), dark: (0, 0, 8)),
|
|
||||||
error: Color.manaToken(light: (0, 65, 45), dark: (0, 63, 55)),
|
|
||||||
success: Color.manaToken(light: (135, 35, 35), dark: (135, 35, 55)),
|
|
||||||
warning: Color.manaToken(light: (38, 92, 45), dark: (48, 96, 53))
|
|
||||||
)
|
|
||||||
|
|
||||||
/// paper-Variant aus `mana/packages/themes/src/variants/paper.css`.
|
|
||||||
static let paper = ManaThemeColors(
|
|
||||||
background: Color.manaToken(light: (38, 28, 95), dark: (24, 14, 9)),
|
|
||||||
foreground: Color.manaToken(light: (20, 14, 16), dark: (38, 24, 88)),
|
|
||||||
surface: Color.manaToken(light: (0, 0, 100), dark: (24, 12, 13)),
|
|
||||||
surfaceHover: Color.manaToken(light: (38, 24, 92), dark: (24, 14, 17)),
|
|
||||||
muted: Color.manaToken(light: (38, 20, 90), dark: (24, 12, 18)),
|
|
||||||
mutedForeground: Color.manaToken(light: (20, 14, 50), dark: (38, 12, 60)),
|
|
||||||
border: Color.manaToken(light: (38, 18, 80), dark: (24, 10, 25)),
|
|
||||||
primary: Color.manaToken(light: (18, 50, 38), dark: (24, 60, 65)),
|
|
||||||
primaryForeground: Color.manaToken(light: (0, 0, 100), dark: (24, 14, 9)),
|
|
||||||
error: Color.manaToken(light: (0, 65, 45), dark: (0, 60, 55)),
|
|
||||||
success: Color.manaToken(light: (135, 35, 35), dark: (135, 35, 55)),
|
|
||||||
warning: Color.manaToken(light: (38, 80, 40), dark: (38, 70, 55))
|
|
||||||
)
|
|
||||||
|
|
||||||
/// skylight-Variant aus `mana/packages/themes/src/variants/skylight.css`.
|
|
||||||
static let skylight = ManaThemeColors(
|
|
||||||
background: Color.manaToken(light: (205, 50, 99), dark: (215, 40, 9)),
|
|
||||||
foreground: Color.manaToken(light: (215, 30, 15), dark: (205, 25, 95)),
|
|
||||||
surface: Color.manaToken(light: (0, 0, 100), dark: (215, 35, 13)),
|
|
||||||
surfaceHover: Color.manaToken(light: (205, 40, 95), dark: (215, 30, 18)),
|
|
||||||
muted: Color.manaToken(light: (205, 30, 93), dark: (215, 30, 19)),
|
|
||||||
mutedForeground: Color.manaToken(light: (215, 15, 45), dark: (205, 18, 65)),
|
|
||||||
border: Color.manaToken(light: (205, 25, 88), dark: (215, 25, 24)),
|
|
||||||
primary: Color.manaToken(light: (205, 90, 40), dark: (205, 90, 60)),
|
|
||||||
primaryForeground: Color.manaToken(light: (0, 0, 100), dark: (215, 40, 9)),
|
|
||||||
error: Color.manaToken(light: (0, 80, 50), dark: (0, 70, 60)),
|
|
||||||
success: Color.manaToken(light: (142, 65, 40), dark: (142, 65, 50)),
|
|
||||||
warning: Color.manaToken(light: (38, 92, 50), dark: (48, 95, 55))
|
|
||||||
)
|
|
||||||
|
|
||||||
/// twilight-Variant aus `mana/packages/themes/src/variants/twilight.css`.
|
|
||||||
static let twilight = ManaThemeColors(
|
|
||||||
background: Color.manaToken(light: (250, 30, 97), dark: (260, 35, 8)),
|
|
||||||
foreground: Color.manaToken(light: (260, 25, 15), dark: (250, 20, 92)),
|
|
||||||
surface: Color.manaToken(light: (250, 40, 99), dark: (260, 30, 12)),
|
|
||||||
surfaceHover: Color.manaToken(light: (260, 30, 94), dark: (260, 28, 17)),
|
|
||||||
muted: Color.manaToken(light: (260, 25, 92), dark: (260, 25, 19)),
|
|
||||||
mutedForeground: Color.manaToken(light: (260, 15, 45), dark: (250, 15, 65)),
|
|
||||||
border: Color.manaToken(light: (260, 20, 87), dark: (260, 20, 25)),
|
|
||||||
primary: Color.manaToken(light: (260, 70, 55), dark: (260, 75, 70)),
|
|
||||||
primaryForeground: Color.manaToken(light: (0, 0, 100), dark: (260, 35, 10)),
|
|
||||||
error: Color.manaToken(light: (0, 75, 55), dark: (0, 65, 60)),
|
|
||||||
success: Color.manaToken(light: (142, 60, 45), dark: (142, 60, 55)),
|
|
||||||
warning: Color.manaToken(light: (38, 90, 55), dark: (48, 90, 55))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Eine der acht Verein-Theme-Variants aus `mana/packages/themes`.
|
|
||||||
///
|
|
||||||
/// Die Werte-Schicht (``ManaThemeColors``) wird per `colors`-Accessor
|
|
||||||
/// geliefert. Convenience-Properties (`background`, `foreground`, …)
|
|
||||||
/// erlauben Apps, ohne `.colors`-Indirektion zu schreiben:
|
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// ManaTheme.forest.foreground // Convenience
|
|
||||||
/// ManaTheme.forest.colors.foreground // explizit
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Für User-Theme-Switching nutze ``SwiftUICore/View/manaTheme(_:)``
|
|
||||||
/// und lese im View per `@Environment(\.manaTheme)`.
|
|
||||||
public enum ManaTheme: String, CaseIterable, Sendable {
|
|
||||||
case mana
|
|
||||||
case forest
|
|
||||||
case paper
|
|
||||||
case neutral
|
|
||||||
case lume
|
|
||||||
case twilight
|
|
||||||
case skylight
|
|
||||||
case monochrome
|
|
||||||
|
|
||||||
/// Konkrete Token-Werte für diese Variant.
|
|
||||||
public var colors: ManaThemeColors {
|
|
||||||
switch self {
|
|
||||||
case .mana: return .mana
|
|
||||||
case .forest: return .forest
|
|
||||||
case .paper: return .paper
|
|
||||||
case .neutral: return .neutral
|
|
||||||
case .lume: return .lume
|
|
||||||
case .twilight: return .twilight
|
|
||||||
case .skylight: return .skylight
|
|
||||||
case .monochrome: return .monochrome
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Convenience-Accessors
|
|
||||||
|
|
||||||
public var background: Color { colors.background }
|
|
||||||
public var foreground: Color { colors.foreground }
|
|
||||||
public var surface: Color { colors.surface }
|
|
||||||
public var surfaceHover: Color { colors.surfaceHover }
|
|
||||||
public var muted: Color { colors.muted }
|
|
||||||
public var mutedForeground: Color { colors.mutedForeground }
|
|
||||||
public var border: Color { colors.border }
|
|
||||||
public var primary: Color { colors.primary }
|
|
||||||
public var primaryForeground: Color { colors.primaryForeground }
|
|
||||||
public var error: Color { colors.error }
|
|
||||||
public var success: Color { colors.success }
|
|
||||||
public var warning: Color { colors.warning }
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// Die 12 Vereins-Tokens als konkrete Color-Werte für eine bestimmte
|
|
||||||
/// Theme-Variant. Wird heute per-Variant von ``ManaTheme/colors``
|
|
||||||
/// geliefert; die statischen Variants (``ManaThemeColors/mana``,
|
|
||||||
/// ``ManaThemeColors/forest`` etc.) leben im generierten File
|
|
||||||
/// `GeneratedThemes.swift`.
|
|
||||||
///
|
|
||||||
/// **Verwendung:**
|
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// // Direkt:
|
|
||||||
/// Text("Hi").foregroundColor(ManaTheme.forest.foreground)
|
|
||||||
///
|
|
||||||
/// // Via Environment (für Apps, die User-Theme-Switching anbieten):
|
|
||||||
/// @Environment(\.manaTheme) var theme
|
|
||||||
/// Text("Hi").foregroundColor(theme.foreground)
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Beide Werte (`light:`, `dark:`) sind CSS-HSL-Tripel `(hue 0–360,
|
|
||||||
/// saturation 0–100, lightness 0–100)`. Quelle:
|
|
||||||
/// `mana/packages/themes/src/variants/*.css`.
|
|
||||||
public struct ManaThemeColors: Sendable {
|
|
||||||
/// Token 1 — `--color-background`. Page-Hintergrund.
|
|
||||||
public let background: Color
|
|
||||||
/// Token 2 — `--color-foreground`. Standard-Text.
|
|
||||||
public let foreground: Color
|
|
||||||
/// Token 3 — `--color-surface`. Card, Panel, Modal, Popover.
|
|
||||||
public let surface: Color
|
|
||||||
/// Token 4 — `--color-surface-hover`. Hover-State auf Surface.
|
|
||||||
public let surfaceHover: Color
|
|
||||||
/// Token 5 — `--color-muted`. Disabled-Felder, Skeleton.
|
|
||||||
public let muted: Color
|
|
||||||
/// Token 6 — `--color-muted-foreground`. Sekundär-Text, Placeholder.
|
|
||||||
public let mutedForeground: Color
|
|
||||||
/// Token 7 — `--color-border`. Rahmen, Trennlinien.
|
|
||||||
public let border: Color
|
|
||||||
/// Token 8 — `--color-primary`. App-Akzent.
|
|
||||||
public let primary: Color
|
|
||||||
/// Token 9 — `--color-primary-foreground`. Text auf Primary-Flächen.
|
|
||||||
public let primaryForeground: Color
|
|
||||||
/// Token 10 — `--color-error`. Fehler, Lösch-Aktion.
|
|
||||||
public let error: Color
|
|
||||||
/// Token 11 — `--color-success`. Erfolg, Bestätigung.
|
|
||||||
public let success: Color
|
|
||||||
/// Token 12 — `--color-warning`. Warnung, Aufmerksamkeit.
|
|
||||||
public let warning: Color
|
|
||||||
|
|
||||||
public init(
|
|
||||||
background: Color,
|
|
||||||
foreground: Color,
|
|
||||||
surface: Color,
|
|
||||||
surfaceHover: Color,
|
|
||||||
muted: Color,
|
|
||||||
mutedForeground: Color,
|
|
||||||
border: Color,
|
|
||||||
primary: Color,
|
|
||||||
primaryForeground: Color,
|
|
||||||
error: Color,
|
|
||||||
success: Color,
|
|
||||||
warning: Color
|
|
||||||
) {
|
|
||||||
self.background = background
|
|
||||||
self.foreground = foreground
|
|
||||||
self.surface = surface
|
|
||||||
self.surfaceHover = surfaceHover
|
|
||||||
self.muted = muted
|
|
||||||
self.mutedForeground = mutedForeground
|
|
||||||
self.border = border
|
|
||||||
self.primary = primary
|
|
||||||
self.primaryForeground = primaryForeground
|
|
||||||
self.error = error
|
|
||||||
self.success = success
|
|
||||||
self.warning = warning
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
private struct ManaThemeEnvironmentKey: EnvironmentKey {
|
|
||||||
static let defaultValue: ManaTheme = .mana
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension EnvironmentValues {
|
|
||||||
/// Aktuell aktive Theme-Variant. Default: ``ManaTheme/mana``.
|
|
||||||
var manaTheme: ManaTheme {
|
|
||||||
get { self[ManaThemeEnvironmentKey.self] }
|
|
||||||
set { self[ManaThemeEnvironmentKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension View {
|
|
||||||
/// Setzt die aktive Theme-Variant für diesen View-Subtree.
|
|
||||||
///
|
|
||||||
/// Apps mit fixer Identität setzen das an der App-Root:
|
|
||||||
///
|
|
||||||
/// ```swift
|
|
||||||
/// @main
|
|
||||||
/// struct CardsApp: App {
|
|
||||||
/// var body: some Scene {
|
|
||||||
/// WindowGroup {
|
|
||||||
/// ContentView()
|
|
||||||
/// .manaTheme(.forest)
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Apps mit User-Theme-Switching binden den Wert an `@AppStorage`
|
|
||||||
/// oder eine Settings-Source und re-rendern bei Wechsel automatisch.
|
|
||||||
func manaTheme(_ theme: ManaTheme) -> some View {
|
|
||||||
environment(\.manaTheme, theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -210,89 +210,6 @@ struct AuthClientGuestAndResilienceTests {
|
||||||
#expect(mocked.auth.currentGuestId() == id)
|
#expect(mocked.auth.currentGuestId() == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - RefreshFailurePolicy.softFirst
|
|
||||||
|
|
||||||
@Test("softFirst: erster 401-Refresh behält Session, zweiter wiped")
|
|
||||||
func softFirstSecondFailureWipes() async throws {
|
|
||||||
let mocked = makeMockedAuth(refreshFailurePolicy: .softFirst)
|
|
||||||
try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r")
|
|
||||||
mocked.setHandler { _ in
|
|
||||||
(401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Erster Versuch — wirft, aber Session bleibt
|
|
||||||
do {
|
|
||||||
_ = try await mocked.auth.refreshAccessToken()
|
|
||||||
Issue.record("Expected throw")
|
|
||||||
} catch let err as AuthError {
|
|
||||||
#expect(err.invalidatesSession)
|
|
||||||
}
|
|
||||||
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
|
||||||
#expect(mocked.auth.refreshFailureCount == 1)
|
|
||||||
|
|
||||||
// Zweiter Versuch — wiped jetzt
|
|
||||||
do {
|
|
||||||
_ = try await mocked.auth.refreshAccessToken()
|
|
||||||
Issue.record("Expected throw")
|
|
||||||
} catch let err as AuthError {
|
|
||||||
#expect(err.invalidatesSession)
|
|
||||||
}
|
|
||||||
#expect(mocked.auth.status == .signedOut)
|
|
||||||
#expect(mocked.auth.refreshFailureCount == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("softFirst: 401 nach erfolgreichem Refresh wiped sofort")
|
|
||||||
func softFirstAfterSuccessWipes() async throws {
|
|
||||||
let mocked = makeMockedAuth(refreshFailurePolicy: .softFirst)
|
|
||||||
try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r")
|
|
||||||
|
|
||||||
// Erst erfolgreich refreshen
|
|
||||||
mocked.setHandler { _ in
|
|
||||||
(200, Data(#"{"accessToken":"new-a","refreshToken":"new-r"}"#.utf8))
|
|
||||||
}
|
|
||||||
_ = try await mocked.auth.refreshAccessToken()
|
|
||||||
#expect(mocked.auth.refreshOnceSucceeded)
|
|
||||||
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
|
||||||
|
|
||||||
// Dann 401 — server sagt "Session tot", glaubhaft weil wir
|
|
||||||
// gerade vorher noch refreshen konnten
|
|
||||||
mocked.setHandler { _ in
|
|
||||||
(401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8))
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try await mocked.auth.refreshAccessToken()
|
|
||||||
Issue.record("Expected throw")
|
|
||||||
} catch let err as AuthError {
|
|
||||||
#expect(err.invalidatesSession)
|
|
||||||
}
|
|
||||||
#expect(mocked.auth.status == .signedOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("softFirst: transienter 503 ändert nichts an Counter")
|
|
||||||
func softFirstTransientDoesNotCount() async throws {
|
|
||||||
let mocked = makeMockedAuth(refreshFailurePolicy: .softFirst)
|
|
||||||
try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r")
|
|
||||||
mocked.setHandler { _ in
|
|
||||||
(503, Data(#"{"error":"SERVICE_UNAVAILABLE","status":503}"#.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = try? await mocked.auth.refreshAccessToken()
|
|
||||||
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
|
||||||
#expect(mocked.auth.refreshFailureCount == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("immediateWipe: erster 401 wiped sofort (Default-Verhalten)")
|
|
||||||
func immediateWipeFirstFailureWipes() async throws {
|
|
||||||
let mocked = makeMockedAuth(refreshFailurePolicy: .immediateWipe)
|
|
||||||
try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r")
|
|
||||||
mocked.setHandler { _ in
|
|
||||||
(401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = try? await mocked.auth.refreshAccessToken()
|
|
||||||
#expect(mocked.auth.status == .signedOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - AuthError.invalidatesSession
|
// MARK: - AuthError.invalidatesSession
|
||||||
|
|
||||||
@Test("invalidatesSession unterscheidet Session-Tot vs. transient")
|
@Test("invalidatesSession unterscheidet Session-Tot vs. transient")
|
||||||
|
|
|
||||||
|
|
@ -24,42 +24,4 @@ struct ManaAppConfigTests {
|
||||||
)
|
)
|
||||||
#expect(config.keychainAccessGroup == nil)
|
#expect(config.keychainAccessGroup == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("AppGroup ist optional, default nil")
|
|
||||||
func appGroupDefaultsNil() {
|
|
||||||
let config = DefaultManaAppConfig(
|
|
||||||
authBaseURL: URL(string: "https://auth.mana.how")!,
|
|
||||||
keychainService: "ev.mana.cards"
|
|
||||||
)
|
|
||||||
#expect(config.appGroup == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("AppGroup wird durchgereicht wenn gesetzt")
|
|
||||||
func appGroupPassedThrough() {
|
|
||||||
let config = DefaultManaAppConfig(
|
|
||||||
authBaseURL: URL(string: "https://auth.mana.how")!,
|
|
||||||
keychainService: "ev.mana.cards",
|
|
||||||
appGroup: "group.ev.mana.cards"
|
|
||||||
)
|
|
||||||
#expect(config.appGroup == "group.ev.mana.cards")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("LogSubsystem default = keychainService")
|
|
||||||
func logSubsystemDefaultsToKeychainService() {
|
|
||||||
let config = DefaultManaAppConfig(
|
|
||||||
authBaseURL: URL(string: "https://auth.mana.how")!,
|
|
||||||
keychainService: "ev.mana.cards"
|
|
||||||
)
|
|
||||||
#expect(config.logSubsystem == "ev.mana.cards")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("LogSubsystem kann explizit überschrieben werden")
|
|
||||||
func logSubsystemOverride() {
|
|
||||||
let config = DefaultManaAppConfig(
|
|
||||||
authBaseURL: URL(string: "https://auth.mana.how")!,
|
|
||||||
keychainService: "ev.mana.cards",
|
|
||||||
logSubsystem: "ev.mana.cards.debug"
|
|
||||||
)
|
|
||||||
#expect(config.logSubsystem == "ev.mana.cards.debug")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import ManaCore
|
|
||||||
|
|
||||||
@Suite("ManaAppLog")
|
|
||||||
struct ManaAppLogTests {
|
|
||||||
@Test("Init aus Config nimmt logSubsystem")
|
|
||||||
func initFromConfig() {
|
|
||||||
let config = DefaultManaAppConfig(
|
|
||||||
authBaseURL: URL(string: "https://auth.mana.how")!,
|
|
||||||
keychainService: "ev.mana.cards"
|
|
||||||
)
|
|
||||||
let log = ManaAppLog(config)
|
|
||||||
#expect(log.subsystem == "ev.mana.cards")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Explizit gesetztes logSubsystem überschreibt keychainService")
|
|
||||||
func initFromConfigWithCustomSubsystem() {
|
|
||||||
let config = DefaultManaAppConfig(
|
|
||||||
authBaseURL: URL(string: "https://auth.mana.how")!,
|
|
||||||
keychainService: "ev.mana.cards",
|
|
||||||
logSubsystem: "ev.mana.cards.test"
|
|
||||||
)
|
|
||||||
let log = ManaAppLog(config)
|
|
||||||
#expect(log.subsystem == "ev.mana.cards.test")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Direkter String-Constructor")
|
|
||||||
func initFromString() {
|
|
||||||
let log = ManaAppLog(subsystem: "ev.mana.test")
|
|
||||||
#expect(log.subsystem == "ev.mana.test")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Standard-Kategorien sind erreichbar")
|
|
||||||
func standardCategoriesExist() {
|
|
||||||
let log = ManaAppLog(subsystem: "ev.mana.test")
|
|
||||||
// Logger ist nicht Equatable, aber wir können Existenz prüfen
|
|
||||||
// indem wir den Wert binden — Compile genügt.
|
|
||||||
let _ = log.app
|
|
||||||
let _ = log.auth
|
|
||||||
let _ = log.api
|
|
||||||
let _ = log.db
|
|
||||||
let _ = log.web
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("category() liefert beliebige Custom-Kategorie")
|
|
||||||
func customCategory() {
|
|
||||||
let log = ManaAppLog(subsystem: "ev.mana.test")
|
|
||||||
let _ = log.category("study")
|
|
||||||
let _ = log.category("tracking")
|
|
||||||
let _ = log.category("llm")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -93,9 +93,7 @@ struct MockedAuth {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func makeMockedAuth(
|
func makeMockedAuth() -> MockedAuth {
|
||||||
refreshFailurePolicy: RefreshFailurePolicy = .immediateWipe
|
|
||||||
) -> MockedAuth {
|
|
||||||
let testID = UUID().uuidString
|
let testID = UUID().uuidString
|
||||||
let configuration = URLSessionConfiguration.ephemeral
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
configuration.protocolClasses = [MockURLProtocol.self]
|
configuration.protocolClasses = [MockURLProtocol.self]
|
||||||
|
|
@ -104,8 +102,7 @@ func makeMockedAuth(
|
||||||
let config = DefaultManaAppConfig(
|
let config = DefaultManaAppConfig(
|
||||||
authBaseURL: URL(string: "https://auth.test")!,
|
authBaseURL: URL(string: "https://auth.test")!,
|
||||||
keychainService: "ev.mana.test.\(testID)",
|
keychainService: "ev.mana.test.\(testID)",
|
||||||
keychainAccessGroup: nil,
|
keychainAccessGroup: nil
|
||||||
refreshFailurePolicy: refreshFailurePolicy
|
|
||||||
)
|
)
|
||||||
return MockedAuth(auth: AuthClient(config: config, session: session), testID: testID)
|
return MockedAuth(auth: AuthClient(config: config, session: session), testID: testID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
import Testing
|
|
||||||
@testable import ManaTokens
|
|
||||||
|
|
||||||
@Suite("ManaTheme Variants")
|
|
||||||
struct ThemeTests {
|
|
||||||
@Test("Alle 8 Variants sind im enum vorhanden")
|
|
||||||
func allCasesPresent() {
|
|
||||||
let cases = ManaTheme.allCases.map(\.rawValue).sorted()
|
|
||||||
#expect(cases == [
|
|
||||||
"forest",
|
|
||||||
"lume",
|
|
||||||
"mana",
|
|
||||||
"monochrome",
|
|
||||||
"neutral",
|
|
||||||
"paper",
|
|
||||||
"skylight",
|
|
||||||
"twilight",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Jede Variant liefert nicht-leere Colors")
|
|
||||||
func everyVariantHasColors() {
|
|
||||||
for variant in ManaTheme.allCases {
|
|
||||||
let colors = variant.colors
|
|
||||||
// Existenz-Smoke — Color hat keinen Equatable-Color-Vergleich,
|
|
||||||
// aber wir können die Properties gegen Optional/Crash absichern.
|
|
||||||
let _: Color = colors.background
|
|
||||||
let _: Color = colors.foreground
|
|
||||||
let _: Color = colors.surface
|
|
||||||
let _: Color = colors.surfaceHover
|
|
||||||
let _: Color = colors.muted
|
|
||||||
let _: Color = colors.mutedForeground
|
|
||||||
let _: Color = colors.border
|
|
||||||
let _: Color = colors.primary
|
|
||||||
let _: Color = colors.primaryForeground
|
|
||||||
let _: Color = colors.error
|
|
||||||
let _: Color = colors.success
|
|
||||||
let _: Color = colors.warning
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Convenience-Accessor matcht colors-Accessor")
|
|
||||||
func convenienceMatchesColors() {
|
|
||||||
// Identitäts-Smoke — beide Pfade müssen denselben Color-Wert liefern.
|
|
||||||
// Color ist nicht Equatable, also testen wir, dass es überhaupt
|
|
||||||
// möglich ist, beide Pfade typkorrekt zu nutzen.
|
|
||||||
let theme = ManaTheme.forest
|
|
||||||
let direct = theme.background
|
|
||||||
let viaColors = theme.colors.background
|
|
||||||
let _: (Color, Color) = (direct, viaColors)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("forest.primary entspricht forest.css (142, 76, 28)")
|
|
||||||
func forestPrimaryMatchesSpec() {
|
|
||||||
// Wir rekonstruieren den erwarteten Light-Wert aus HSL und
|
|
||||||
// vergleichen mit dem, was `Color.manaToken` für denselben Input
|
|
||||||
// liefert. Da `Color`-Vergleich nicht direkt möglich ist, prüfen
|
|
||||||
// wir indirekt über PlatformColor.fromHSL.
|
|
||||||
let expected = PlatformColor.fromHSL(142, 76, 28)
|
|
||||||
let components = rgbComponents(of: expected)
|
|
||||||
// Forest-Primary Light: HSL(142, 76, 28) → ~RGB(0.067, 0.493, 0.231)
|
|
||||||
#expect(approxEqual(components.r, 0.067, tolerance: 0.02))
|
|
||||||
#expect(approxEqual(components.g, 0.493, tolerance: 0.02))
|
|
||||||
#expect(approxEqual(components.b, 0.231, tolerance: 0.02))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("ManaTheme ist via rawValue rekonstruierbar")
|
|
||||||
func rawValueRoundtrip() {
|
|
||||||
for variant in ManaTheme.allCases {
|
|
||||||
let raw = variant.rawValue
|
|
||||||
let roundtrip = ManaTheme(rawValue: raw)
|
|
||||||
#expect(roundtrip == variant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Environment-Default ist mana")
|
|
||||||
func environmentDefault() {
|
|
||||||
// Wir können das Environment hier nicht direkt anzapfen ohne
|
|
||||||
// einen Host-View, aber wir spiegeln das Default per Konstante:
|
|
||||||
// ManaThemeEnvironmentKey.defaultValue ist auf .mana gesetzt.
|
|
||||||
// Smoke-Test, der die Konvention dokumentiert.
|
|
||||||
#expect(ManaTheme.allCases.contains(.mana))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
4
build/GeneratedModuleMaps/ManaCore.modulemap
Normal file
4
build/GeneratedModuleMaps/ManaCore.modulemap
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
module ManaCore {
|
||||||
|
header "ManaCore-Swift.h"
|
||||||
|
export *
|
||||||
|
}
|
||||||
4
build/GeneratedModuleMaps/ManaTokens.modulemap
Normal file
4
build/GeneratedModuleMaps/ManaTokens.modulemap
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
module ManaTokens {
|
||||||
|
header "ManaTokens-Swift.h"
|
||||||
|
export *
|
||||||
|
}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"ABIRoot": {
|
||||||
|
"kind": "Root",
|
||||||
|
"name": "NO_MODULE",
|
||||||
|
"printedName": "NO_MODULE",
|
||||||
|
"json_format_version": 8
|
||||||
|
},
|
||||||
|
"ConstValues": []
|
||||||
|
}
|
||||||
BIN
build/Release/ManaTokens.swiftmodule/x86_64-apple-macos.swiftdoc
Normal file
BIN
build/Release/ManaTokens.swiftmodule/x86_64-apple-macos.swiftdoc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue