Files
OSGKeyboard/OSGKeyboard/Views/Account/LiveAccountServices.swift
T
2026-08-25 13:42:00 +08:00

651 lines
23 KiB
Swift

// LiveAccountServices.swift
// OSGKeyboard · Main App
//
// Adapts the host-private account protocol to the account-center UI. Account
// session credentials never enter App Group storage; only a non-secret local
// eligibility marker and scope-limited gateway grants are shared.
import Foundation
import OSGKeyboardHostSupport
import OSGKeyboardShared
@MainActor
enum LiveAccountDependencyFactory {
static func make(bundle: Bundle = .main) -> AccountDependencies {
guard let accessGroup = bundle.object(
forInfoDictionaryKey: "OSGPrivateKeychainAccessGroup"
) as? String,
let descriptor = try? HostPrivateAccountKeychainDescriptor(
accessGroup: accessGroup
) else {
return .unavailable
}
let sessionVault = HostPrivateAccountKeychain(descriptor: descriptor)
let apiClient = AccountAPIClient(sessionVault: sessionVault)
HostAnalyticsBearerBridge.shared.install(
AccountAnalyticsBearerProvider(apiClient: apiClient)
)
let integrity = DeviceIntegrityCoordinator(
apiClient: apiClient,
keyStateStore: sessionVault
)
let grantStore = GatewayGrantKeychainStore()
let grants = GatewayGrantCoordinator(store: grantStore)
let service = LiveAccountService(
apiClient: apiClient,
integrity: integrity,
grants: grants,
grantStore: grantStore,
configuration: AppGroupStore()
)
return AccountDependencies(
sessionService: service,
sessionEventSource: service,
centerService: service,
referralService: LiveReferralProfileService(apiClient: apiClient)
)
}
}
actor AccountCenterSnapshotLoader {
private let apiClient: AccountAPIClient
private let decoder = JSONDecoder()
init(apiClient: AccountAPIClient) {
self.apiClient = apiClient
}
func load(
cachedSnapshot: AccountCenterSnapshot?
) async throws -> AccountCenterSnapshot {
async let account = apiClient.account()
async let balance = resource(CreditBalanceDTO.self, .creditsBalance)
async let referrals = optionalResource(
[ReferralBindingDTO].self,
.referrals(limit: 50),
diagnosticName: "referrals"
)
let core: (OSGAccount, CreditBalanceDTO)
do {
core = try await (account, balance)
} catch {
OSGDiag.log(
"account refresh core failed error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
throw error
}
let loadedReferrals = await referrals?.map {
AccountReferral(
id: UUID(),
status: Self.referralStatus($0.rewardStatus),
createdAtEpochSeconds: Self.epochSeconds($0.boundAt),
rewardCredits: nil
)
} ?? cachedSnapshot?.referrals ?? []
return AccountCenterSnapshot(
account: AccountSession(
accountID: core.0.id,
createdAtEpochSeconds: core.0.createdAtEpochSeconds,
displayName: core.0.displayName
),
credits: AccountCreditSummary(
balance: core.1.balance,
usedCredits: core.1.lifetimeUsed ?? 0
),
referrals: loadedReferrals
)
}
private func resource<Value: Decodable & Sendable>(
_ type: Value.Type,
_ resource: AccountAuthorizedResource
) async throws -> Value {
let data = try await apiClient.authorizedResourceData(resource)
return try decoder.decode(type, from: data)
}
private func optionalResource<Value: Decodable & Sendable>(
_ type: Value.Type,
_ resource: AccountAuthorizedResource,
diagnosticName: String
) async -> Value? {
do {
return try await self.resource(type, resource)
} catch {
OSGDiag.log(
"account refresh optional=\(diagnosticName) fallback=cache "
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
return nil
}
}
private static func referralStatus(_ value: String) -> AccountReferralStatus {
switch value.uppercased() {
case "REWARDED":
return .rewarded
case "INELIGIBLE_BUDGET":
return .ineligible
default:
return .pending
}
}
private static func epochSeconds(_ value: String) -> Int64? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let standard = ISO8601DateFormatter()
standard.formatOptions = [.withInternetDateTime]
let date = fractional.date(from: value) ?? standard.date(from: value)
return date.map { Int64($0.timeIntervalSince1970) }
}
}
actor LiveReferralProfileService: ReferralProfileServicing {
private let apiClient: AccountAPIClient
private let decoder = JSONDecoder()
init(apiClient: AccountAPIClient) {
self.apiClient = apiClient
}
func loadReferralProfile() async throws -> ReferralProfile {
let data = try await apiClient.authorizedResourceData(.referralProfile)
do {
return try decoder.decode(ReferralProfile.self, from: data)
} catch {
throw AccountAPIError.decoding
}
}
}
private actor LiveAccountService:
AccountSessionServicing,
AccountSessionEventSourcing,
AccountCenterServicing {
private let apiClient: AccountAPIClient
private let accountCenterLoader: AccountCenterSnapshotLoader
private let integrity: DeviceIntegrityCoordinator
private let grants: GatewayGrantCoordinator
private let grantStore: any GatewayGrantCredentialStore
private let configuration: AppGroupStore
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
init(
apiClient: AccountAPIClient,
integrity: DeviceIntegrityCoordinator,
grants: GatewayGrantCoordinator,
grantStore: any GatewayGrantCredentialStore,
configuration: AppGroupStore
) {
self.apiClient = apiClient
accountCenterLoader = AccountCenterSnapshotLoader(apiClient: apiClient)
self.integrity = integrity
self.grants = grants
self.grantStore = grantStore
self.configuration = configuration
}
func events() async -> AsyncStream<AccountSessionEvent> {
let invalidations = await apiClient.sessionInvalidations()
return AsyncStream { continuation in
let task = Task {
for await invalidation in invalidations {
switch invalidation {
case .expired:
await self.invalidateManagedGatewaySession()
continuation.yield(.expired)
}
}
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
func restoreSession() async throws -> AccountSession? {
guard let cachedSession = try await apiClient.currentSession() else {
OSGDiag.log("session restore status=not-found", category: "account")
await invalidateManagedGatewaySession()
return nil
}
OSGDiag.log("session restore status=keychain-found", category: "account")
let account: OSGAccount?
do {
account = try await apiClient.account()
} catch let error as AccountAPIError {
switch error {
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
OSGDiag.log(
"session restore status=rejected "
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
await invalidateManagedGatewaySession()
return nil
default:
OSGDiag.log(
"session restore status=cached-fallback "
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
account = nil
}
} catch {
OSGDiag.log(
"session restore status=cached-fallback "
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
account = nil
}
let session = try await apiClient.currentSession() ?? cachedSession
configuration.setManagedGatewayAccountSessionAvailable(true)
await synchronizeManagedGrant()
OSGDiag.log(
"session restore status=restored accountValidated=\(account == nil ? 0 : 1)",
category: "account"
)
return uiSession(
session,
createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0,
displayName: account?.displayName
)
}
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
let credential = AppleSignInCredential(
identityToken: payload.identityToken,
authorizationCode: payload.authorizationCode
)
let evidence: DeviceIntegrityEvidence
do {
evidence = try await integrity.evidenceForAppleSignIn(
credential: credential,
rawNonce: payload.nonce
)
} catch {
OSGDiag.log(
"signIn failed stage=integrity error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
throw error
}
OSGDiag.log(
"signIn integrity ready deviceCheck=\(evidence.deviceCheckToken == nil ? 0 : 1) "
+ "appAttest=\(evidence.appAttest == nil ? 0 : 1)",
category: "account"
)
let session: OSGKeyboardHostSupport.AccountSession
do {
session = try await apiClient.signInWithApple(
AppleSignInRequest(
identityToken: payload.identityToken,
authorizationCode: payload.authorizationCode,
nonce: payload.nonce,
displayName: payload.displayName,
deviceCheckToken: evidence.deviceCheckToken,
appAttest: evidence.appAttest
)
)
} catch {
OSGDiag.log(
"signIn failed stage=session error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
throw error
}
let account: OSGAccount
do {
account = try await apiClient.account()
} catch {
OSGDiag.log(
"signIn failed stage=account error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
throw error
}
configuration.setManagedGatewayAccountSessionAvailable(true)
await synchronizeManagedGrant()
OSGDiag.log("signIn completed", category: "account")
return uiSession(
session,
createdAtEpochSeconds: account.createdAtEpochSeconds,
displayName: account.displayName
)
}
func signOut() async throws {
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
await revokeManagedGrantIfPossible()
do {
try await apiClient.logout()
} catch {
try? await grants.clearGrant()
throw error
}
try? await grants.clearGrant()
}
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
try await apiClient.deleteAccount(
identityToken: payload.identityToken,
authorizationCode: payload.authorizationCode,
nonce: payload.nonce
)
try? await grants.clearGrant()
await integrity.clearLocalKeyState()
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
}
func prepareManagedGateway() async throws {
guard try await apiClient.currentSession() != nil else {
await invalidateManagedGatewaySession()
throw AccountAPIError.sessionUnavailable
}
configuration.setManagedGatewayAccountSessionAvailable(true)
try await ensureManagedGrant()
}
func clearManagedGateway() async {
await revokeManagedGrantIfPossible()
}
func loadAccountCenter() async throws -> AccountCenterSnapshot {
try await accountCenterLoader.load(cachedSnapshot: nil)
}
func loadAccountCenter(
cachedSnapshot: AccountCenterSnapshot?
) async throws -> AccountCenterSnapshot {
try await accountCenterLoader.load(cachedSnapshot: cachedSnapshot)
}
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
let account = try await apiClient.updateAccount(displayName: displayName)
return AccountSession(
accountID: account.id,
createdAtEpochSeconds: account.createdAtEpochSeconds,
displayName: account.displayName
)
}
func redeemReferral(code: String) async throws {
struct Request: Encodable { let code: String }
let body = try encoder.encode(Request(code: code))
_ = try await apiClient.authorizedResourceData(.redeemReferral, body: body)
}
func loadCreditProducts() async throws -> [AccountCreditProduct] {
let products = try await resource([StoreKitProductDTO].self, .storeKitProducts)
return products.map {
AccountCreditProduct(productID: $0.productId, credits: $0.credits)
}
}
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
let request = StoreKitTransactionRequestDTO(signedTransaction: signedTransaction)
let data = try await apiClient.authorizedResourceData(
.submitStoreKitTransaction,
body: try encoder.encode(request)
)
let purchase = try decoder.decode(StoreKitPurchaseDTO.self, from: data)
return AccountCreditPurchase(
transactionID: purchase.transactionId,
productID: purchase.productId,
creditsGranted: purchase.creditsGranted,
balanceAfter: purchase.balanceAfter,
replayed: purchase.replayed
)
}
func loadCreditPurchaseHistory(
limit: Int,
cursor: String?
) async throws -> AccountCreditPurchaseHistoryPage {
let data = try await apiClient.authorizedResourceData(
.storeKitTransactions(limit: limit, cursor: cursor)
)
let response = try decoder.decode(
StoreKitTransactionHistoryResponseDTO.self,
from: data
)
let items = try response.items.map { item in
guard let purchasedAt = Self.storeKitPurchaseDate(item.purchasedAt),
let status = AccountCreditPurchaseStatus(rawValue: item.status) else {
throw AccountAPIError.decoding
}
return AccountCreditPurchaseRecord(
transactionID: item.transactionId,
productID: item.productId,
creditsGranted: item.creditsGranted,
balanceAfter: item.balanceAfter,
purchasedAt: purchasedAt,
status: status
)
}
return AccountCreditPurchaseHistoryPage(
items: items,
nextCursor: response.nextCursor
)
}
private func resource<Value: Decodable & Sendable>(
_ type: Value.Type,
_ resource: AccountAuthorizedResource
) async throws -> Value {
let data = try await apiClient.authorizedResourceData(resource)
return try decoder.decode(type, from: data)
}
private static func storeKitPurchaseDate(_ value: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let standard = ISO8601DateFormatter()
standard.formatOptions = [.withInternetDateTime]
return fractional.date(from: value) ?? standard.date(from: value)
}
private func synchronizeManagedGrant() async {
if configuration.credentialSource == .managed {
do {
try await ensureManagedGrant()
} catch {
// Keep the user's explicit billing choice. A transient grant,
// network, or balance failure must surface as a managed-service
// error instead of silently switching requests to a BYOK key.
OSGDiag.log(
"managedGrant synchronization failed "
+ "errorCategory=\(String(reflecting: type(of: error)))",
category: "account"
)
}
} else {
try? await grants.clearGrant()
}
}
private func ensureManagedGrant() async throws {
let expected = ManagedGatewayScopePolicy.scopes(
engineMode: configuration.engineMode
)
if let existing = try await grantStore.load(),
existing.scopes == expected,
existing.hasUsableRefreshToken() {
_ = try await grants.accessToken(for: .polish)
return
}
await revokeManagedGrantIfPossible()
let idempotencyKey = UUID().uuidString
let accessToken = try await apiClient.accessTokenForAuthorizedRequest()
do {
_ = try await grants.createGrant(
accountAccessToken: accessToken,
scopes: expected,
idempotencyKey: idempotencyKey
)
} catch ManagedGatewayError.invalidGrant {
let replacement = try await apiClient.refreshAccessToken(
afterUnauthorizedAccessToken: accessToken
)
_ = try await grants.createGrant(
accountAccessToken: replacement,
scopes: expected,
idempotencyKey: idempotencyKey
)
}
}
private func revokeManagedGrantIfPossible() async {
if let existing = try? await grantStore.load(),
let grantId = UUID(uuidString: existing.grantId) {
_ = try? await apiClient.authorizedResourceData(
.revokeGatewayGrant(grantId)
)
}
try? await grants.clearGrant()
}
private func invalidateManagedGatewaySession() async {
// Flip the local gate before any await so the extension immediately
// loses access to cached account grants.
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
try? await grants.clearGrant()
}
private func uiSession(
_ session: OSGKeyboardHostSupport.AccountSession,
createdAtEpochSeconds: Int64,
displayName: String?
) -> AccountSession {
AccountSession(
accountID: session.accountId,
createdAtEpochSeconds: createdAtEpochSeconds,
displayName: displayName
)
}
}
private enum AccountDiagnostic {
static func code(for error: Error) -> String {
guard let error = error as? AccountAPIError else {
return String(describing: type(of: error))
}
switch error {
case .invalidRequest(let message):
return invalidRequestCode(for: message)
case .unauthorized:
return "unauthorized"
case .refreshTokenReuse:
return "refresh-token-reuse"
case .externalServiceUnavailable(let message):
return unavailableServiceCode(for: message)
case .conflict:
return "conflict"
case .rateLimited:
return "rate-limited"
case let .server(statusCode, code, _):
return "server-\(statusCode)-\(code)"
case .transport:
return "transport"
case .invalidResponse:
return "invalid-response"
case .decoding:
return "decoding"
case .secureStorage:
return "secure-storage"
case .sessionUnavailable:
return "session-unavailable"
case .appleAuthorization:
return "apple-authorization"
case .integrityUnavailable:
return "integrity-unavailable"
}
}
private static func invalidRequestCode(for message: String) -> String {
switch message {
case "DeviceCheck verification failed":
return "device-check-rejected"
case "App Attest verification failed":
return "app-attest-rejected"
case "identityToken exceeds the maximum length":
return "identity-token-too-large"
case "authorizationCode exceeds the maximum length":
return "authorization-code-too-large"
case "nonce exceeds the maximum length":
return "nonce-too-large"
default:
return "invalid-request"
}
}
private static func unavailableServiceCode(for message: String) -> String {
switch message {
case "DeviceCheck is temporarily unavailable":
return "device-check-unavailable"
case "App Attest is temporarily unavailable":
return "app-attest-unavailable"
case "Apple identity verification is temporarily unavailable":
return "apple-identity-unavailable"
case "Apple token service is temporarily unavailable":
return "apple-token-service-unavailable"
default:
return "external-service-unavailable"
}
}
}
private struct CreditBalanceDTO: Decodable, Sendable {
let balance: Int64
let lifetimeUsed: Int64?
}
private struct ReferralBindingDTO: Decodable, Sendable {
let boundAt: String
let rewardStatus: String
}
private struct StoreKitProductDTO: Decodable, Sendable {
let productId: String
let credits: Int64
}
private struct StoreKitTransactionRequestDTO: Encodable, Sendable {
let signedTransaction: String
}
private struct StoreKitPurchaseDTO: Decodable, Sendable {
let transactionId: String
let productId: String
let creditsGranted: Int64
let balanceAfter: Int64
let replayed: Bool
}
private struct StoreKitTransactionHistoryResponseDTO: Decodable, Sendable {
let items: [StoreKitTransactionHistoryItemDTO]
let nextCursor: String?
}
private struct StoreKitTransactionHistoryItemDTO: Decodable, Sendable {
let transactionId: String
let productId: String
let creditsGranted: Int64
let balanceAfter: Int64
let purchasedAt: String
let status: String
}