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>
93 lines
4.1 KiB
Swift
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]?
|
|
}
|