mana-swift-core/Sources/ManaCore/Auth/ThemePass.swift
Till JS 895d906aaa ManaCore 1.14.0: ThemePass (Unlock-Ledger-Client, A+C)
ThemePass-Actor — Set ausprobierter App-Slugs aus zwei Schichten:
Keychain (geteilter Service ev.mana.themepass + Shared-Group → cross-app
on-device, offline/anonym) + Server (mana-auth /settings + POST /visited/:app,
cross-device, Vereinigungsmenge). recordVisit/sync/localTriedApps.
ManaAppConfig.appSlug + KeychainStore.Key.triedApps. Foundation-only.
Clients leiten Unlock via ManaTheme.isUnlocked(triedApps:) ab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:08:54 +02:00

93 lines
4.1 KiB
Swift

import Foundation
/// Theme-Pass — verwaltet den Set der vom User **ausprobierten App-Slugs**,
/// aus dem Clients die freigeschalteten Signatur-Theme-Variants ableiten
/// (`ManaTheme.isUnlocked(triedApps:)` in ManaTokens). Siehe
/// `mana/docs/THEME_PASS.md`.
///
/// **Zwei Schichten (A + C):**
/// - **C (Keychain):** ein geteilter Keychain-Eintrag unter dem Service
/// `ev.mana.themepass` + der Shared-Access-Group — **cross-app on-device,
/// sofort, auch offline/ohne Login.** Probiert der User App B, sieht
/// App A das Theme von B sofort freigeschaltet.
/// - **A (Server):** `mana-auth` hält `user_settings.triedApps` als
/// Quelle der Wahrheit (cross-device). `recordVisit` pusht, `sync`
/// merged Server ↔ Keychain zur Vereinigungsmenge.
///
/// Foundation-only (ManaCore-Invariante): Keychain via ``KeychainStore``,
/// Netz via ``AuthenticatedTransport`` gegen mana-auth.
public actor ThemePass {
private let transport: AuthenticatedTransport
private let keychain: KeychainStore
/// Geteilter Keychain-Service — gleich über alle mana-Apps, damit der
/// Tried-Set cross-app lesbar ist (zusammen mit der Shared-Access-Group).
public static let keychainService = "ev.mana.themepass"
/// - Parameters:
/// - auth: der App-`AuthClient` (für die authentifizierten Calls).
/// - authBaseURL: mana-auth-Basis-URL (`config.authBaseURL`).
/// - keychainAccessGroup: die Shared-Access-Group (`config.keychainAccessGroup`)
/// — `nil` deaktiviert die Cross-App-Keychain-Schicht (nur Server).
public init(auth: AuthClient, authBaseURL: URL, keychainAccessGroup: String?) {
transport = AuthenticatedTransport(baseURL: authBaseURL, auth: auth)
keychain = KeychainStore(service: Self.keychainService, accessGroup: keychainAccessGroup)
}
/// Lokaler (Keychain) Set ausprobierter App-Slugs — cross-app on-device,
/// auch offline/ohne Login.
public func localTriedApps() -> Set<String> {
guard let json = keychain.getString(for: .triedApps),
let data = json.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data)
else { return [] }
return Set(arr)
}
private func storeLocal(_ set: Set<String>) {
guard let data = try? JSONEncoder().encode(Array(set).sorted()),
let json = String(data: data, encoding: .utf8) else { return }
try? keychain.setString(json, for: .triedApps)
}
/// Markiert `app` als ausprobiert: **sofort lokal** (Keychain, cross-app)
/// und **best-effort am Server** (nur wenn eingeloggt — sonst wirft der
/// Token-Fetch und der Push wird still übersprungen). Gibt den neuen
/// lokalen Set zurück.
@discardableResult
public func recordVisit(app: String) async -> Set<String> {
var set = localTriedApps()
let isNew = set.insert(app).inserted
if isNew { storeLocal(set) }
_ = try? await transport.request(path: "/api/v1/settings/visited/\(app)", method: "POST")
return set
}
/// Holt den Server-Set (eingeloggt) und merged ihn in den lokalen
/// Keychain → **Vereinigungsmenge**. Lokale Visits, die der Server noch
/// nicht kennt, werden nachgereicht. Bei Fehler/ausgeloggt: nur der
/// lokale Set.
@discardableResult
public func sync() async -> Set<String> {
var union = localTriedApps()
guard let (data, http) = try? await transport.request(path: "/api/v1/settings"),
http.statusCode == 200,
let resp = try? JSONDecoder().decode(SettingsTriedResponse.self, from: data),
let server = resp.triedApps
else { return union }
let serverSet = Set(server)
union.formUnion(serverSet)
storeLocal(union)
// Lokale (offline/anonym gesammelte) Slugs, die der Server noch
// nicht hat, nachreichen — best effort.
for app in union.subtracting(serverSet) {
_ = try? await transport.request(path: "/api/v1/settings/visited/\(app)", method: "POST")
}
return union
}
}
private struct SettingsTriedResponse: Decodable {
let triedApps: [String]?
}