fix(account): merge durable session refresh
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Preserve refresh idempotency and Apple credential revocation handling alongside the consolidated app updates.
This commit is contained in:
Rocky
2026-08-25 16:44:51 +08:00
16 changed files with 438 additions and 33 deletions
@@ -161,7 +161,8 @@ private enum AccountAppleAuthorizationPayload {
identityToken: identityToken,
authorizationCode: authorizationCode,
nonce: rawNonce,
displayName: displayName(from: credential.fullName)
displayName: displayName(from: credential.fullName),
userIdentifier: credential.user
)
}
+15 -1
View File
@@ -245,25 +245,35 @@ struct AppleAuthorizationPayload: Equatable, Sendable {
let authorizationCode: String
let nonce: String
let displayName: String?
let userIdentifier: String?
init(
identityToken: String,
authorizationCode: String,
nonce: String,
displayName: String? = nil
displayName: String? = nil,
userIdentifier: String? = nil
) {
self.identityToken = identityToken
self.authorizationCode = authorizationCode
self.nonce = nonce
self.displayName = displayName
self.userIdentifier = userIdentifier
}
}
enum AccountAppleCredentialState: Equatable, Sendable {
case authorized
case revoked
case unknown
}
protocol AccountSessionServicing: Sendable {
func restoreSession() async throws -> AccountSession?
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession
func signOut() async throws
func deleteAccount(with payload: AppleAuthorizationPayload) async throws
func appleCredentialState() async -> AccountAppleCredentialState
func prepareManagedGateway() async throws
func clearManagedGateway() async
}
@@ -277,6 +287,10 @@ protocol AccountSessionEventSourcing: Sendable {
}
extension AccountSessionServicing {
func appleCredentialState() async -> AccountAppleCredentialState {
.unknown
}
func prepareManagedGateway() async throws {}
func clearManagedGateway() async {}
}
@@ -4,6 +4,7 @@
// Main-actor state machine for optional account features. Local and BYOK
// features never consult this coordinator and remain available when signed out.
import AuthenticationServices
import Combine
import Foundation
import OSGKeyboardShared
@@ -58,6 +59,7 @@ final class AccountSessionCoordinator: ObservableObject {
private var accountRefreshTask: Task<Void, Never>?
private var accountRefreshRequestID: UUID?
private var sessionEventsTask: Task<Void, Never>?
private var appleCredentialRevocationCancellable: AnyCancellable?
init(
dependencies: AccountDependencies,
@@ -100,6 +102,13 @@ final class AccountSessionCoordinator: ObservableObject {
await self?.handleSessionEvent(event)
}
}
appleCredentialRevocationCancellable = NotificationCenter.default
.publisher(for: ASAuthorizationAppleIDProvider.credentialRevokedNotification)
.sink { [weak self] _ in
Task { @MainActor [weak self] in
await self?.handleAppleCredentialRevocation()
}
}
}
deinit {
@@ -157,6 +166,14 @@ final class AccountSessionCoordinator: ObservableObject {
}
}
func validateAppleCredentialState() async {
guard isSignedIn,
await sessionService.appleCredentialState() == .revoked else {
return
}
await handleAppleCredentialRevocation()
}
@discardableResult
func handleIncomingURL(_ url: URL) -> Bool {
guard let code = ReferralUniversalLink.code(from: url) else { return false }
@@ -410,18 +427,28 @@ final class AccountSessionCoordinator: ObservableObject {
private func handleSessionEvent(_ event: AccountSessionEvent) async {
switch event {
case .expired:
guard isSignedIn else { return }
await sessionService.clearManagedGateway()
await onAccountSignedOut()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession()
sessionPhase = .signedOut
snapshotPhase = .idle
operationErrorKey = "account.error.sessionExpired"
await transitionToExpiredSession()
}
}
private func handleAppleCredentialRevocation() async {
guard isSignedIn else { return }
try? await sessionService.signOut()
await transitionToExpiredSession()
}
private func transitionToExpiredSession() async {
guard isSignedIn else { return }
await sessionService.clearManagedGateway()
await onAccountSignedOut()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession()
sessionPhase = .signedOut
snapshotPhase = .idle
operationErrorKey = "account.error.sessionExpired"
}
private func redeemPendingReferralIfNeeded() async {
guard isSignedIn, let code = pendingReferralStore.code else { return }
@@ -5,6 +5,7 @@
// session credentials never enter App Group storage; only a non-secret local
// eligibility marker and scope-limited gateway grants are shared.
import AuthenticationServices
import Foundation
import OSGKeyboardHostSupport
import OSGKeyboardShared
@@ -37,7 +38,8 @@ enum LiveAccountDependencyFactory {
integrity: integrity,
grants: grants,
grantStore: grantStore,
configuration: AppGroupStore()
configuration: AppGroupStore(),
appleUserIdentifiers: sessionVault
)
return AccountDependencies(
sessionService: service,
@@ -164,6 +166,35 @@ actor LiveReferralProfileService: ReferralProfileServicing {
}
}
private protocol AppleCredentialStateChecking: Sendable {
func state(for userIdentifier: String) async -> AccountAppleCredentialState
}
private struct SystemAppleCredentialStateChecker: AppleCredentialStateChecking {
func state(for userIdentifier: String) async -> AccountAppleCredentialState {
await withCheckedContinuation { continuation in
ASAuthorizationAppleIDProvider().getCredentialState(
forUserID: userIdentifier
) { state, error in
guard error == nil else {
continuation.resume(returning: .unknown)
return
}
switch state {
case .authorized:
continuation.resume(returning: .authorized)
case .revoked, .notFound:
continuation.resume(returning: .revoked)
case .transferred:
continuation.resume(returning: .unknown)
@unknown default:
continuation.resume(returning: .unknown)
}
}
}
}
}
private actor LiveAccountService:
AccountSessionServicing,
AccountSessionEventSourcing,
@@ -174,6 +205,8 @@ private actor LiveAccountService:
private let grants: GatewayGrantCoordinator
private let grantStore: any GatewayGrantCredentialStore
private let configuration: AppGroupStore
private let appleUserIdentifiers: any AppleUserIdentifierStoring
private let appleCredentialChecker: any AppleCredentialStateChecking
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
@@ -182,7 +215,10 @@ private actor LiveAccountService:
integrity: DeviceIntegrityCoordinator,
grants: GatewayGrantCoordinator,
grantStore: any GatewayGrantCredentialStore,
configuration: AppGroupStore
configuration: AppGroupStore,
appleUserIdentifiers: any AppleUserIdentifierStoring,
appleCredentialChecker: any AppleCredentialStateChecking =
SystemAppleCredentialStateChecker()
) {
self.apiClient = apiClient
accountCenterLoader = AccountCenterSnapshotLoader(apiClient: apiClient)
@@ -190,6 +226,8 @@ private actor LiveAccountService:
self.grants = grants
self.grantStore = grantStore
self.configuration = configuration
self.appleUserIdentifiers = appleUserIdentifiers
self.appleCredentialChecker = appleCredentialChecker
}
func events() async -> AsyncStream<AccountSessionEvent> {
@@ -214,6 +252,7 @@ private actor LiveAccountService:
func restoreSession() async throws -> AccountSession? {
guard let cachedSession = try await apiClient.currentSession() else {
OSGDiag.log("session restore status=not-found", category: "account")
try? await appleUserIdentifiers.clearAppleUserIdentifier()
await invalidateManagedGatewaySession()
return nil
}
@@ -229,6 +268,7 @@ private actor LiveAccountService:
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
try? await appleUserIdentifiers.clearAppleUserIdentifier()
await invalidateManagedGatewaySession()
return nil
default:
@@ -303,6 +343,10 @@ private actor LiveAccountService:
)
throw error
}
if let userIdentifier = payload.userIdentifier,
!userIdentifier.isEmpty {
try? await appleUserIdentifiers.saveAppleUserIdentifier(userIdentifier)
}
let account: OSGAccount
do {
account = try await apiClient.account()
@@ -331,9 +375,11 @@ private actor LiveAccountService:
try await apiClient.logout()
} catch {
try? await grants.clearGrant()
try? await appleUserIdentifiers.clearAppleUserIdentifier()
throw error
}
try? await grants.clearGrant()
try? await appleUserIdentifiers.clearAppleUserIdentifier()
}
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
@@ -343,11 +389,20 @@ private actor LiveAccountService:
nonce: payload.nonce
)
try? await grants.clearGrant()
try? await appleUserIdentifiers.clearAppleUserIdentifier()
await integrity.clearLocalKeyState()
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
}
func appleCredentialState() async -> AccountAppleCredentialState {
guard let userIdentifier = try? await appleUserIdentifiers.loadAppleUserIdentifier(),
!userIdentifier.isEmpty else {
return .unknown
}
return await appleCredentialChecker.state(for: userIdentifier)
}
func prepareManagedGateway() async throws {
guard try await apiClient.currentSession() != nil else {
await invalidateManagedGatewaySession()
+5 -2
View File
@@ -125,6 +125,7 @@ struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var config = ProviderConfig.shared
@EnvironmentObject private var flowManager: FlowSessionManager
@@ -590,15 +591,17 @@ struct HomeView: View {
// MARK: - Header
// logo 尺寸保持 144:41 比例;小屏进一步缩小,给下方内容让空间。
// Logo 保持 144:41 比例,并使用随系统深浅色切换的黑白单色。
private func logoHeader(compact: Bool) -> some View {
let logoWidth: CGFloat = compact ? 104 : 124
let logoWidth: CGFloat = compact ? 88 : 108
let logoHeight = logoWidth * (41.0 / 144.0)
return VStack(spacing: Spacing.xxl) {
Image("osglogo")
.resizable()
.renderingMode(.template)
.scaledToFit()
.frame(width: logoWidth, height: logoHeight)
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity)
+4
View File
@@ -151,6 +151,9 @@ struct MainAppRoot: View {
}
.task {
await accountSession.restoreIfNeeded()
if scenePhase == .active {
await accountSession.validateAppleCredentialState()
}
config.reloadFromPersistedStorage()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
@@ -215,6 +218,7 @@ struct MainAppRoot: View {
}
}
Task {
await accountSession.validateAppleCredentialState()
await AppCloudSync.shared.pullAllIfEnabled()
}
}
+1 -1
View File
@@ -830,7 +830,7 @@
"account.loading.session" = "Checking account status…";
"account.loading.center" = "Loading account…";
"account.signedOut.title" = "Your OSG account";
"account.signedOut.body" = "Sign in to use managed credits and referrals.";
"account.signedOut.body" = "Register and invite friends to earn credits.";
"account.signedOut.localUnaffected" = "On-device features and your own API keys work without an account.";
"home.accountRewards.signedOut.title" = "Sign in for free credits";
"home.accountRewards.signedOut.summary" = "Eligible new users receive 1,000 credits; after an invited friend completes qualifying usage, you both receive 1,000 credits.";
@@ -829,7 +829,7 @@
"account.loading.session" = "正在检查账号状态…";
"account.loading.center" = "正在加载账号…";
"account.signedOut.title" = "OSG 账号";
"account.signedOut.body" = "登录后可使用托管积分与邀请功能。";
"account.signedOut.body" = "注册和邀请好友,都可获赠积分";
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
"home.accountRewards.signedOut.title" = "登录领免费积分";
"home.accountRewards.signedOut.summary" = "符合资格的新用户可领 1,000 积分;邀请好友完成首次有效使用,双方各得 1,000 积分。";