fix(account): load permanent referral profiles
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

Use authenticated server invitation URLs with account-scoped caching and bounded state transitions, then advance the iOS build metadata to 86.
This commit is contained in:
Rocky
2026-08-20 22:59:58 +08:00
parent ca0a1f9b9a
commit 7bd77509b6
19 changed files with 1151 additions and 228 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
### Version format ### Version format
The current source-of-truth version is **2.0.0 (build 83)**. Releases use stable SemVer: The current source-of-truth version is **2.0.0 (build 86)**. Releases use stable SemVer:
| Field | File | Rule | | Field | File | Rule |
|-------|------|------| |-------|------|------|
+1
View File
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。 - **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
### Fixed ### Fixed
- **Permanent invitation links**: load the account-scoped server invitation profile after sign-in, cache it per account, and keep sharing the exact stable server URL across refreshes and offline failures. / **永久邀请链接**:登录后异步加载账号级服务端邀请资料,按账号缓存,并在刷新或离线失败时持续分享服务端返回的固定链接。
- **Cross-process settings safety**: App Group updates now write only changed fields so a stale main-app or keyboard-extension snapshot cannot overwrite newer unrelated settings. / **跨进程设置安全**:App Group 更新现在只写入发生变化的字段,避免主 App 或键盘扩展的旧快照覆盖其他较新的设置。 - **Cross-process settings safety**: App Group updates now write only changed fields so a stale main-app or keyboard-extension snapshot cannot overwrite newer unrelated settings. / **跨进程设置安全**:App Group 更新现在只写入发生变化的字段,避免主 App 或键盘扩展的旧快照覆盖其他较新的设置。
- **Account-data freshness**: Settings and Account now share one background-refreshed snapshot, preserve cached content on failure, retry transient read errors, and isolate optional referral outages from core account and credit updates. / **账号数据新鲜度**:设置页与账号页现在共用同一份后台刷新快照,刷新失败时保留缓存内容,对瞬时读取错误自动重试,并避免可选邀请接口故障影响账号与积分更新。 - **Account-data freshness**: Settings and Account now share one background-refreshed snapshot, preserve cached content on failure, retry transient read errors, and isolate optional referral outages from core account and credit updates. / **账号数据新鲜度**:设置页与账号页现在共用同一份后台刷新快照,刷新失败时保留缓存内容,对瞬时读取错误自动重试,并避免可选邀请接口故障影响账号与积分更新。
- **Managed AI task routing**: credit-backed requests now preserve dictation, translation, editing, question, clipboard, custom-skill, and agent intent so the gateway can disable costly reasoning for low-latency transforms. / **托管 AI 任务路由**:积分请求现在会保留听写、翻译、编辑、问答、剪贴板、自定义技能与 Agent 意图,让网关可为低延迟转换任务关闭高成本思考。 - **Managed AI task routing**: credit-backed requests now preserve dictation, translation, editing, question, clipboard, custom-skill, and agent intent so the gateway can disable costly reasoning for low-latency transforms. / **托管 AI 任务路由**:积分请求现在会保留听写、翻译、编辑、问答、剪贴板、自定义技能与 Agent 意图,让网关可为低延迟转换任务关闭高成本思考。
@@ -20,7 +20,8 @@ struct AccountCenterUITestHarness: View {
wrappedValue: AccountSessionCoordinator( wrappedValue: AccountSessionCoordinator(
dependencies: AccountDependencies( dependencies: AccountDependencies(
sessionService: service, sessionService: service,
centerService: service centerService: service,
referralService: service
), ),
creditStore: store creditStore: store
) )
@@ -54,7 +55,8 @@ struct ManagedCloudConsentUITestHarness: View {
wrappedValue: AccountSessionCoordinator( wrappedValue: AccountSessionCoordinator(
dependencies: AccountDependencies( dependencies: AccountDependencies(
sessionService: service, sessionService: service,
centerService: service centerService: service,
referralService: service
), ),
creditStore: AccountCenterUITestCreditStore( creditStore: AccountCenterUITestCreditStore(
accountID: service.account.accountID accountID: service.account.accountID
@@ -80,7 +82,10 @@ struct ManagedCloudConsentUITestHarness: View {
} }
} }
private actor AccountCenterUITestService: AccountSessionServicing, AccountCenterServicing { private actor AccountCenterUITestService:
AccountSessionServicing,
AccountCenterServicing,
ReferralProfileServicing {
nonisolated let account = AccountSession( nonisolated let account = AccountSession(
accountID: UUID(uuidString: "00000000-0000-0000-0000-000000000200")!, accountID: UUID(uuidString: "00000000-0000-0000-0000-000000000200")!,
createdAtEpochSeconds: 1_700_000_000, createdAtEpochSeconds: 1_700_000_000,
@@ -106,7 +111,6 @@ private actor AccountCenterUITestService: AccountSessionServicing, AccountCenter
AccountCenterSnapshot( AccountCenterSnapshot(
account: account, account: account,
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500), credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
referrals: [] referrals: []
) )
} }
@@ -119,14 +123,22 @@ private actor AccountCenterUITestService: AccountSessionServicing, AccountCenter
) )
} }
func createReferralCode() async throws -> String {
"UITestInvite_1234567890"
}
func redeemReferral(code: String) async throws { func redeemReferral(code: String) async throws {
_ = code _ = code
} }
func loadReferralProfile() async throws -> ReferralProfile {
ReferralProfile(
code: ReferralCode(
code: "UITestInvite_123456789",
inviteURL: URL(string: "https://osglab.com/i/UITestInvite_123456789")!,
campaignID: nil,
createdAt: Date(timeIntervalSince1970: 1_700_000_000)
),
binding: nil
)
}
func loadCreditProducts() async throws -> [AccountCreditProduct] { func loadCreditProducts() async throws -> [AccountCreditProduct] {
[ [
AccountCreditProduct(productID: "500tks", credits: 500), AccountCreditProduct(productID: "500tks", credits: 500),
@@ -751,10 +751,11 @@ struct AccountInvitationButton: View {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@State private var showsShareDrawer = false @State private var showsShareDrawer = false
let invitationURL: URL let invitationURL: URL?
var body: some View { var body: some View {
Button { Button {
guard invitationURL != nil else { return }
showsShareDrawer = true showsShareDrawer = true
} label: { } label: {
Label("account.referral.inviteTitle", systemImage: "person.badge.plus") Label("account.referral.inviteTitle", systemImage: "person.badge.plus")
@@ -771,13 +772,17 @@ struct AccountInvitationButton: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(invitationURL == nil)
.accessibilityIdentifier("account.referral.share")
.sheet(isPresented: $showsShareDrawer) { .sheet(isPresented: $showsShareDrawer) {
if let invitationURL {
AccountInvitationActivityView(invitationURL: invitationURL) AccountInvitationActivityView(invitationURL: invitationURL)
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
} }
} }
} }
}
private struct AccountInvitationActivityView: UIViewControllerRepresentable { private struct AccountInvitationActivityView: UIViewControllerRepresentable {
let invitationURL: URL let invitationURL: URL
@@ -850,7 +855,6 @@ struct IAPReviewScreenshotHarness: View {
displayName: "OSG User" displayName: "OSG User"
), ),
credits: AccountCreditSummary(balance: 1_000, usedCredits: 0), credits: AccountCreditSummary(balance: 1_000, usedCredits: 0),
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
referrals: [] referrals: []
) )
} }
@@ -860,10 +864,6 @@ private struct IAPReviewAccountService: AccountCenterServicing {
throw AccountIntegrationError.unavailable throw AccountIntegrationError.unavailable
} }
func createReferralCode() async throws -> String {
throw AccountIntegrationError.unavailable
}
func redeemReferral(code: String) async throws {} func redeemReferral(code: String) async throws {}
func loadCreditProducts() async throws -> [AccountCreditProduct] { func loadCreditProducts() async throws -> [AccountCreditProduct] {
+203 -26
View File
@@ -75,29 +75,150 @@ struct AccountReferral: Identifiable, Equatable, Sendable {
let rewardCredits: Int64? let rewardCredits: Int64?
} }
struct AccountReferralProfile: Equatable, Sendable { struct ReferralCode: Codable, Equatable, Sendable {
let code: String? let code: String
let boundCode: String? let inviteURL: URL
let inviterRewardCredits: Int64? let campaignID: String?
let inviteeRewardCredits: Int64? let createdAt: Date
init( private enum CodingKeys: String, CodingKey {
code: String?, case code
boundCode: String?, case inviteURL = "inviteUrl"
inviterRewardCredits: Int64? = nil, case campaignID = "campaignId"
inviteeRewardCredits: Int64? = nil case createdAt
) {
self.code = code
self.boundCode = boundCode
self.inviterRewardCredits = inviterRewardCredits
self.inviteeRewardCredits = inviteeRewardCredits
} }
init(code: String, inviteURL: URL, campaignID: String?, createdAt: Date) {
self.code = code
self.inviteURL = inviteURL
self.campaignID = campaignID
self.createdAt = createdAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let code = try container.decode(String.self, forKey: .code)
let inviteURL = try container.decode(URL.self, forKey: .inviteURL)
let createdAtValue = try container.decode(String.self, forKey: .createdAt)
guard ReferralUniversalLink.isValid(code: code),
inviteURL.scheme?.lowercased() == "https",
inviteURL.host != nil,
let createdAt = Self.date(from: createdAtValue) else {
throw DecodingError.dataCorrupted(
.init(
codingPath: container.codingPath,
debugDescription: "Invalid referral code, invite URL, or creation date."
)
)
}
self.code = code
self.inviteURL = inviteURL
campaignID = try container.decodeIfPresent(String.self, forKey: .campaignID)
self.createdAt = createdAt
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(code, forKey: .code)
try container.encode(inviteURL, forKey: .inviteURL)
try container.encodeIfPresent(campaignID, forKey: .campaignID)
try container.encode(Self.string(from: createdAt), forKey: .createdAt)
}
private static func date(from 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 static func string(from date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
}
}
/// The binding payload can gain server-owned fields without breaking cached
/// profiles. The client only needs to preserve it; invitation sharing uses code.
struct ReferralBinding: Codable, Equatable, Sendable {
let fields: [String: ReferralJSONValue]
init(from decoder: Decoder) throws {
fields = try decoder.singleValueContainer().decode(
[String: ReferralJSONValue].self
)
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(fields)
}
}
enum ReferralJSONValue: Codable, Equatable, Sendable {
case string(String)
case integer(Int64)
case number(Double)
case boolean(Bool)
case object([String: ReferralJSONValue])
case array([ReferralJSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .boolean(value)
} else if let value = try? container.decode(Int64.self) {
self = .integer(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: ReferralJSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([ReferralJSONValue].self) {
self = .array(value)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unsupported referral binding value."
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value):
try container.encode(value)
case .integer(let value):
try container.encode(value)
case .number(let value):
try container.encode(value)
case .boolean(let value):
try container.encode(value)
case .object(let value):
try container.encode(value)
case .array(let value):
try container.encode(value)
case .null:
try container.encodeNil()
}
}
}
struct ReferralProfile: Codable, Equatable, Sendable {
let code: ReferralCode
let binding: ReferralBinding?
} }
struct AccountCenterSnapshot: Equatable, Sendable { struct AccountCenterSnapshot: Equatable, Sendable {
let account: AccountSession let account: AccountSession
let credits: AccountCreditSummary let credits: AccountCreditSummary
let referralProfile: AccountReferralProfile
let referrals: [AccountReferral] let referrals: [AccountReferral]
} }
@@ -158,7 +279,6 @@ protocol AccountCenterServicing: Sendable {
cachedSnapshot: AccountCenterSnapshot? cachedSnapshot: AccountCenterSnapshot?
) async throws -> AccountCenterSnapshot ) async throws -> AccountCenterSnapshot
func updateDisplayName(_ displayName: String) async throws -> AccountSession func updateDisplayName(_ displayName: String) async throws -> AccountSession
func createReferralCode() async throws -> String
func redeemReferral(code: String) async throws func redeemReferral(code: String) async throws
func loadCreditProducts() async throws -> [AccountCreditProduct] func loadCreditProducts() async throws -> [AccountCreditProduct]
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase
@@ -195,13 +315,32 @@ extension AccountCenterServicing {
} }
} }
protocol ReferralProfileServicing: Sendable {
func loadReferralProfile() async throws -> ReferralProfile
}
struct AccountDependencies: Sendable { struct AccountDependencies: Sendable {
let sessionService: any AccountSessionServicing let sessionService: any AccountSessionServicing
let centerService: any AccountCenterServicing let centerService: any AccountCenterServicing
let referralService: any ReferralProfileServicing
init(
sessionService: any AccountSessionServicing,
centerService: any AccountCenterServicing,
referralService: (any ReferralProfileServicing)? = nil
) {
self.sessionService = sessionService
self.centerService = centerService
self.referralService = referralService ?? UnavailableReferralProfileService()
}
static let unavailable: AccountDependencies = { static let unavailable: AccountDependencies = {
let service = UnavailableAccountService() let service = UnavailableAccountService()
return AccountDependencies(sessionService: service, centerService: service) return AccountDependencies(
sessionService: service,
centerService: service,
referralService: UnavailableReferralProfileService()
)
}() }()
} }
@@ -238,10 +377,6 @@ private struct UnavailableAccountService: AccountSessionServicing, AccountCenter
throw AccountIntegrationError.unavailable throw AccountIntegrationError.unavailable
} }
func createReferralCode() async throws -> String {
throw AccountIntegrationError.unavailable
}
func redeemReferral(code: String) async throws { func redeemReferral(code: String) async throws {
throw AccountIntegrationError.unavailable throw AccountIntegrationError.unavailable
} }
@@ -255,6 +390,12 @@ private struct UnavailableAccountService: AccountSessionServicing, AccountCenter
} }
} }
private struct UnavailableReferralProfileService: ReferralProfileServicing {
func loadReferralProfile() async throws -> ReferralProfile {
throw AccountIntegrationError.unavailable
}
}
enum ReferralUniversalLink { enum ReferralUniversalLink {
static let host = "osglab.com" static let host = "osglab.com"
static let codeLength = 22 static let codeLength = 22
@@ -285,10 +426,6 @@ enum ReferralUniversalLink {
} }
} }
static func invitationURL(for code: String) -> URL? {
guard isValid(code: code) else { return nil }
return URL(string: "https://\(host)/i/\(code)")
}
} }
@MainActor @MainActor
@@ -326,3 +463,43 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
defaults.removeObject(forKey: Self.storageKey) defaults.removeObject(forKey: Self.storageKey)
} }
} }
@MainActor
protocol ReferralProfileStoring: AnyObject {
func profile(for accountID: UUID) -> ReferralProfile?
func save(_ profile: ReferralProfile, for accountID: UUID)
func removeProfile(for accountID: UUID)
}
@MainActor
final class UserDefaultsReferralProfileStore: ReferralProfileStoring {
private static let storageKeyPrefix = "account.referralProfile.v1."
private let defaults: UserDefaults
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func profile(for accountID: UUID) -> ReferralProfile? {
guard let data = defaults.data(forKey: storageKey(for: accountID)) else {
return nil
}
return try? decoder.decode(ReferralProfile.self, from: data)
}
func save(_ profile: ReferralProfile, for accountID: UUID) {
guard let data = try? encoder.encode(profile) else { return }
defaults.set(data, forKey: storageKey(for: accountID))
}
func removeProfile(for accountID: UUID) {
defaults.removeObject(forKey: storageKey(for: accountID))
}
private func storageKey(for accountID: UUID) -> String {
Self.storageKeyPrefix + accountID.uuidString.lowercased()
}
}
@@ -43,6 +43,7 @@ final class AccountSessionCoordinator: ObservableObject {
@Published private(set) var accountRefreshErrorKey: String? @Published private(set) var accountRefreshErrorKey: String?
let creditPurchases: AccountCreditPurchaseManager let creditPurchases: AccountCreditPurchaseManager
let referralProfile: ReferralProfileViewModel
private let sessionService: any AccountSessionServicing private let sessionService: any AccountSessionServicing
private let centerService: any AccountCenterServicing private let centerService: any AccountCenterServicing
@@ -60,6 +61,8 @@ final class AccountSessionCoordinator: ObservableObject {
creditStore: any AccountCreditStore = LiveAccountCreditStore(), creditStore: any AccountCreditStore = LiveAccountCreditStore(),
pendingReferralStore: any PendingReferralCodeStoring = pendingReferralStore: any PendingReferralCodeStoring =
UserDefaultsPendingReferralCodeStore(), UserDefaultsPendingReferralCodeStore(),
referralProfileStore: any ReferralProfileStoring =
UserDefaultsReferralProfileStore(),
accountRefreshInterval: TimeInterval = 10 * 60, accountRefreshInterval: TimeInterval = 10 * 60,
now: @escaping () -> Date = Date.init, now: @escaping () -> Date = Date.init,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(), analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
@@ -73,6 +76,10 @@ final class AccountSessionCoordinator: ObservableObject {
store: creditStore, store: creditStore,
analyticsClient: analyticsClient analyticsClient: analyticsClient
) )
referralProfile = ReferralProfileViewModel(
service: dependencies.referralService,
store: referralProfileStore
)
self.pendingReferralStore = pendingReferralStore self.pendingReferralStore = pendingReferralStore
self.analyticsClient = analyticsClient self.analyticsClient = analyticsClient
self.onAccountAuthenticated = onAccountAuthenticated self.onAccountAuthenticated = onAccountAuthenticated
@@ -107,6 +114,7 @@ final class AccountSessionCoordinator: ObservableObject {
} }
await onAccountAuthenticated(session.accountID) await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session) sessionPhase = .signedIn(session)
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded() await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true) await refreshAccountData(force: true)
} catch { } catch {
@@ -153,6 +161,7 @@ final class AccountSessionCoordinator: ObservableObject {
let session = try await sessionService.signIn(with: payload) let session = try await sessionService.signIn(with: payload)
await onAccountAuthenticated(session.accountID) await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session) sessionPhase = .signedIn(session)
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded() await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true) await refreshAccountData(force: true)
} catch { } catch {
@@ -210,7 +219,6 @@ final class AccountSessionCoordinator: ObservableObject {
AccountCenterSnapshot( AccountCenterSnapshot(
account: account, account: account,
credits: snapshot.credits, credits: snapshot.credits,
referralProfile: snapshot.referralProfile,
referrals: snapshot.referrals referrals: snapshot.referrals
) )
) )
@@ -251,11 +259,13 @@ final class AccountSessionCoordinator: ObservableObject {
operation = .signingOut operation = .signingOut
operationErrorKey = nil operationErrorKey = nil
defer { operation = nil } defer { operation = nil }
referralProfile.cancelRefresh()
do { do {
try await sessionService.signOut() try await sessionService.signOut()
creditPurchases.reset() creditPurchases.reset()
clearAccountRefreshState() clearAccountRefreshState()
referralProfile.endSession()
sessionPhase = .signedOut sessionPhase = .signedOut
snapshotPhase = .idle snapshotPhase = .idle
} catch { } catch {
@@ -271,12 +281,14 @@ final class AccountSessionCoordinator: ObservableObject {
operation = .deletingAccount operation = .deletingAccount
operationErrorKey = nil operationErrorKey = nil
defer { operation = nil } defer { operation = nil }
referralProfile.cancelRefresh()
do { do {
try await sessionService.deleteAccount(with: payload) try await sessionService.deleteAccount(with: payload)
await onAccountDeleted() await onAccountDeleted()
creditPurchases.reset() creditPurchases.reset()
clearAccountRefreshState() clearAccountRefreshState()
referralProfile.endSession(removeCache: true)
sessionPhase = .signedOut sessionPhase = .signedOut
snapshotPhase = .idle snapshotPhase = .idle
pendingReferralStore.clear() pendingReferralStore.clear()
@@ -38,7 +38,11 @@ enum LiveAccountDependencyFactory {
grantStore: grantStore, grantStore: grantStore,
configuration: AppGroupStore() configuration: AppGroupStore()
) )
return AccountDependencies(sessionService: service, centerService: service) return AccountDependencies(
sessionService: service,
centerService: service,
referralService: LiveReferralProfileService(apiClient: apiClient)
)
} }
} }
@@ -55,21 +59,11 @@ actor AccountCenterSnapshotLoader {
) async throws -> AccountCenterSnapshot { ) async throws -> AccountCenterSnapshot {
async let account = apiClient.account() async let account = apiClient.account()
async let balance = resource(CreditBalanceDTO.self, .creditsBalance) async let balance = resource(CreditBalanceDTO.self, .creditsBalance)
async let profile = optionalResource(
ReferralProfileDTO.self,
.referralProfile,
diagnosticName: "referral-profile"
)
async let referrals = optionalResource( async let referrals = optionalResource(
[ReferralBindingDTO].self, [ReferralBindingDTO].self,
.referrals(limit: 50), .referrals(limit: 50),
diagnosticName: "referrals" diagnosticName: "referrals"
) )
async let campaigns = optionalResource(
[ReferralCampaignDTO].self,
.referralCampaigns,
diagnosticName: "referral-campaigns"
)
let core: (OSGAccount, CreditBalanceDTO) let core: (OSGAccount, CreditBalanceDTO)
do { do {
@@ -81,28 +75,7 @@ actor AccountCenterSnapshotLoader {
) )
throw error throw error
} }
let optionalValues = await (profile, referrals, campaigns) let loadedReferrals = await referrals?.map {
let invitationCode = await resolvedInvitationCode(
profile: optionalValues.0,
cachedProfile: cachedSnapshot?.referralProfile
)
let campaign = optionalValues.2?.first {
$0.id == optionalValues.0?.code?.campaignId
} ?? optionalValues.2?.first
let referralProfile = if optionalValues.0 == nil,
let cachedProfile = cachedSnapshot?.referralProfile {
cachedProfile
} else {
AccountReferralProfile(
code: invitationCode,
boundCode: cachedSnapshot?.referralProfile.boundCode,
inviterRewardCredits: campaign?.inviterRewardCredits
?? cachedSnapshot?.referralProfile.inviterRewardCredits,
inviteeRewardCredits: campaign?.inviteeRewardCredits
?? cachedSnapshot?.referralProfile.inviteeRewardCredits
)
}
let loadedReferrals = optionalValues.1?.map {
AccountReferral( AccountReferral(
id: UUID(), id: UUID(),
status: Self.referralStatus($0.rewardStatus), status: Self.referralStatus($0.rewardStatus),
@@ -121,7 +94,6 @@ actor AccountCenterSnapshotLoader {
balance: core.1.balance, balance: core.1.balance,
usedCredits: core.1.lifetimeUsed ?? 0 usedCredits: core.1.lifetimeUsed ?? 0
), ),
referralProfile: referralProfile,
referrals: loadedReferrals referrals: loadedReferrals
) )
} }
@@ -151,26 +123,6 @@ actor AccountCenterSnapshotLoader {
} }
} }
private func resolvedInvitationCode(
profile: ReferralProfileDTO?,
cachedProfile: AccountReferralProfile?
) async -> String? {
if let code = profile?.code?.code ?? cachedProfile?.code {
return code
}
do {
let data = try await apiClient.authorizedResourceData(.createReferralCode)
return try decoder.decode(ReferralCodeDTO.self, from: data).code
} catch {
OSGDiag.log(
"account refresh optional=referral-code fallback=cache "
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
return cachedProfile?.code
}
}
private static func referralStatus(_ value: String) -> AccountReferralStatus { private static func referralStatus(_ value: String) -> AccountReferralStatus {
switch value.uppercased() { switch value.uppercased() {
case "REWARDED": case "REWARDED":
@@ -192,6 +144,24 @@ actor AccountCenterSnapshotLoader {
} }
} }
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, AccountCenterServicing { private actor LiveAccountService: AccountSessionServicing, AccountCenterServicing {
private let apiClient: AccountAPIClient private let apiClient: AccountAPIClient
private let accountCenterLoader: AccountCenterSnapshotLoader private let accountCenterLoader: AccountCenterSnapshotLoader
@@ -356,11 +326,6 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
) )
} }
func createReferralCode() async throws -> String {
let data = try await apiClient.authorizedResourceData(.createReferralCode)
return try decoder.decode(ReferralCodeDTO.self, from: data).code
}
func redeemReferral(code: String) async throws { func redeemReferral(code: String) async throws {
struct Request: Encodable { let code: String } struct Request: Encodable { let code: String }
let body = try encoder.encode(Request(code: code)) let body = try encoder.encode(Request(code: code))
@@ -586,21 +551,6 @@ private struct CreditBalanceDTO: Decodable, Sendable {
let lifetimeUsed: Int64? let lifetimeUsed: Int64?
} }
private struct ReferralCodeDTO: Decodable, Sendable {
let code: String
let campaignId: String?
}
private struct ReferralProfileDTO: Decodable, Sendable {
let code: ReferralCodeDTO?
}
private struct ReferralCampaignDTO: Decodable, Sendable {
let id: String
let inviterRewardCredits: Int64
let inviteeRewardCredits: Int64
}
private struct ReferralBindingDTO: Decodable, Sendable { private struct ReferralBindingDTO: Decodable, Sendable {
let boundAt: String let boundAt: String
let rewardStatus: String let rewardStatus: String
@@ -0,0 +1,197 @@
// ReferralProfileViewModel.swift
// OSGKeyboard · Main App
//
// Account-scoped invitation state. The server profile is authoritative while
// the local cache keeps the permanent invitation link visible when offline.
import Foundation
import OSGKeyboardHostSupport
@MainActor
final class ReferralProfileViewModel: ObservableObject {
enum State: Equatable {
case idle
case loading
case loaded(ReferralProfile)
case failed(messageKey: String)
}
@Published private(set) var state: State = .idle
@Published private(set) var isRefreshing = false
@Published private(set) var refreshErrorKey: String?
private let service: any ReferralProfileServicing
private let store: any ReferralProfileStoring
private var accountID: UUID?
private var requestID: UUID?
private var refreshTask: Task<Void, Never>?
init(
service: any ReferralProfileServicing,
store: any ReferralProfileStoring = UserDefaultsReferralProfileStore()
) {
self.service = service
self.store = store
}
deinit {
refreshTask?.cancel()
}
/// Starts one automatic load for this signed-in account session. Repeated
/// view appearances are ignored; ending or changing the session cancels it.
func startSession(accountID: UUID) {
guard self.accountID != accountID else { return }
cancelCurrentRequest()
self.accountID = accountID
refreshErrorKey = nil
if let cachedProfile = store.profile(for: accountID) {
state = .loaded(cachedProfile)
} else {
state = .loading
}
beginRefresh()
}
func endSession(removeCache: Bool = false) {
let previousAccountID = accountID
cancelCurrentRequest()
accountID = nil
state = .idle
refreshErrorKey = nil
if removeCache, let previousAccountID {
store.removeProfile(for: previousAccountID)
}
}
func cancelRefresh() {
guard let accountID else { return }
cancelCurrentRequest()
if case .loaded = state {
// Preserve the visible server profile.
} else if let cachedProfile = store.profile(for: accountID) {
state = .loaded(cachedProfile)
} else {
state = .idle
}
refreshErrorKey = nil
}
func refresh() async {
guard accountID != nil else {
state = .idle
return
}
if let refreshTask {
await refreshTask.value
return
}
beginRefresh()
await refreshTask?.value
}
private func beginRefresh() {
guard refreshTask == nil, let accountID else { return }
let id = UUID()
requestID = id
refreshErrorKey = nil
isRefreshing = true
if case .loaded = state {
// Keep a cached or previously loaded profile visible while refreshing.
} else {
state = .loading
}
refreshTask = Task { @MainActor [weak self] in
await self?.performRefresh(accountID: accountID, requestID: id)
}
}
private func performRefresh(accountID: UUID, requestID: UUID) async {
let previousProfile: ReferralProfile? = if case let .loaded(profile) = state {
profile
} else {
nil
}
defer {
if self.requestID == requestID {
isRefreshing = false
refreshTask = nil
self.requestID = nil
}
}
do {
try Task.checkCancellation()
let profile = try await service.loadReferralProfile()
try Task.checkCancellation()
guard self.accountID == accountID, self.requestID == requestID else {
return
}
store.save(profile, for: accountID)
state = .loaded(profile)
refreshErrorKey = nil
} catch is CancellationError {
guard self.accountID == accountID, self.requestID == requestID else {
return
}
state = previousProfile.map(State.loaded) ?? .idle
refreshErrorKey = nil
} catch {
guard self.accountID == accountID, self.requestID == requestID else {
return
}
let messageKey = Self.errorMessageKey(for: error)
refreshErrorKey = messageKey
if let previousProfile {
state = .loaded(previousProfile)
} else {
state = .failed(messageKey: messageKey)
}
}
}
private func cancelCurrentRequest() {
refreshTask?.cancel()
refreshTask = nil
requestID = nil
isRefreshing = false
}
private static func errorMessageKey(for error: Error) -> String {
guard let error = error as? AccountAPIError else {
return error as? AccountIntegrationError == .unavailable
? "account.error.unavailable"
: "account.referral.error.load"
}
switch error {
case .unauthorized, .refreshTokenReuse, .sessionUnavailable:
return "account.referral.error.session"
case .transport:
return "account.referral.error.network"
case .externalServiceUnavailable:
return "account.referral.error.unavailable"
case .conflict:
return "account.referral.error.conflict"
case .server(let statusCode, _, _):
switch statusCode {
case 401:
return "account.referral.error.session"
case 404:
return "account.referral.error.notFound"
case 409:
return "account.referral.error.conflict"
case 500..<600:
return "account.referral.error.unavailable"
default:
return "account.referral.error.load"
}
default:
return "account.referral.error.load"
}
}
}
+79 -35
View File
@@ -230,11 +230,10 @@ struct SettingsView: View {
} }
private var accountRewardsEntrySection: some View { private var accountRewardsEntrySection: some View {
Group { VStack(spacing: 0) {
switch accountSession.snapshotPhase { switch accountSession.snapshotPhase {
case let .loaded(snapshot): case let .loaded(snapshot):
let totalCredits = creditTotal(snapshot) let totalCredits = creditTotal(snapshot)
VStack(alignment: .leading, spacing: Spacing.sm) {
Button { Button {
path.append(SettingsRoute.account) path.append(SettingsRoute.account)
} label: { } label: {
@@ -275,10 +274,6 @@ struct SettingsView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Divider().background(palette.divider)
invitationLink(snapshot.referralProfile)
}
.padding(Spacing.md) .padding(Spacing.md)
case .failed: case .failed:
VStack(alignment: .leading, spacing: Spacing.sm) { VStack(alignment: .leading, spacing: Spacing.sm) {
@@ -302,39 +297,13 @@ struct SettingsView: View {
} }
.settingsListRow() .settingsListRow()
} }
Divider().background(palette.divider)
AccountReferralLinkView(viewModel: accountSession.referralProfile)
} }
.surfaceCard() .surfaceCard()
} }
@ViewBuilder
private func invitationLink(_ profile: AccountReferralProfile) -> some View {
if let code = profile.code,
let invitationURL = ReferralUniversalLink.invitationURL(for: code) {
HStack(spacing: Spacing.md) {
Text(
AppL10n.string(
"account.referral.equalRewardDescription",
language: config.uiLanguage
)
)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: Spacing.xs)
AccountInvitationButton(invitationURL: invitationURL)
}
} else {
HStack(spacing: Spacing.sm) {
ProgressView()
.controlSize(.small)
.tint(palette.accent)
Text("account.referral.preparingLink")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
}
}
}
private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 { private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 {
let remaining = max(snapshot.credits.balance, 0) let remaining = max(snapshot.credits.balance, 0)
let used = max(snapshot.credits.usedCredits, 0) let used = max(snapshot.credits.usedCredits, 0)
@@ -493,6 +462,81 @@ struct SettingsView: View {
} }
} }
private struct AccountReferralLinkView: View {
@Environment(\.themePalette) private var palette
@ObservedObject var viewModel: ReferralProfileViewModel
var body: some View {
Group {
switch viewModel.state {
case .idle:
retryRow(
messageKey: "account.referral.loadLink",
actionKey: "account.referral.loadLink"
)
case .loading:
HStack(spacing: Spacing.sm) {
ProgressView()
.controlSize(.small)
.tint(palette.accent)
Text("account.referral.loadingLink")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
}
case .failed(let messageKey):
retryRow(messageKey: messageKey, actionKey: "account.retry")
case .loaded(let profile):
loadedContent(profile)
}
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("account.referral.profile")
}
private func loadedContent(_ profile: ReferralProfile) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
HStack(spacing: Spacing.md) {
Text("account.referral.equalRewardDescription")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: Spacing.xs)
AccountInvitationButton(invitationURL: profile.code.inviteURL)
}
if viewModel.isRefreshing {
HStack(spacing: Spacing.xs) {
ProgressView()
.controlSize(.mini)
Text("account.referral.refreshingLink")
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
} else if let refreshErrorKey = viewModel.refreshErrorKey {
retryRow(messageKey: refreshErrorKey, actionKey: "account.retry")
}
}
}
private func retryRow(
messageKey: String,
actionKey: LocalizedStringKey
) -> some View {
HStack(spacing: Spacing.sm) {
Text(LocalizedStringKey(messageKey))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
Spacer(minLength: Spacing.xs)
Button(actionKey) {
Task { await viewModel.refresh() }
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.accent)
}
}
}
// MARK: - Sheet dismiss (isolated from Settings root) // MARK: - Sheet dismiss (isolated from Settings root)
private struct SettingsSheetDismissButton: View { private struct SettingsSheetDismissButton: View {
+11 -2
View File
@@ -824,8 +824,17 @@
"account.referral.equalRewardDescription" = "For each successful invitation, you and your friend each receive 1,000 credits."; "account.referral.equalRewardDescription" = "For each successful invitation, you and your friend each receive 1,000 credits.";
"account.referral.equalRewardGeneric" = "For each successful invitation, both of you receive credits."; "account.referral.equalRewardGeneric" = "For each successful invitation, both of you receive credits.";
"account.referral.summaryCompact" = "Rewarded %d · Pending %d"; "account.referral.summaryCompact" = "Rewarded %d · Pending %d";
"account.referral.preparingLink" = "Preparing your invitation link…"; "account.referral.preparingLink" = "Loading your invitation link…";
"account.referral.createCode" = "Create invitation code"; "account.referral.createCode" = "Load invitation link";
"account.referral.loadLink" = "Load invitation link";
"account.referral.loadingLink" = "Loading your invitation link…";
"account.referral.refreshingLink" = "Refreshing invitation link…";
"account.referral.error.load" = "Couldnt load your invitation link.";
"account.referral.error.session" = "Sign in again to load your invitation link.";
"account.referral.error.network" = "Check your connection and try again.";
"account.referral.error.notFound" = "Your invitation link isnt available yet.";
"account.referral.error.conflict" = "Your invitation link is temporarily unavailable.";
"account.referral.error.unavailable" = "The invitation service is temporarily unavailable.";
"account.referral.status.pending" = "Pending"; "account.referral.status.pending" = "Pending";
"account.referral.status.rewarded" = "Rewarded"; "account.referral.status.rewarded" = "Rewarded";
"account.referral.status.ineligible" = "Ineligible"; "account.referral.status.ineligible" = "Ineligible";
+11 -2
View File
@@ -823,8 +823,17 @@
"account.referral.equalRewardDescription" = "每成功邀请一位好友,你和好友各获得 1000 积分。"; "account.referral.equalRewardDescription" = "每成功邀请一位好友,你和好友各获得 1000 积分。";
"account.referral.equalRewardGeneric" = "每成功邀请一位好友,双方均可获得积分。"; "account.referral.equalRewardGeneric" = "每成功邀请一位好友,双方均可获得积分。";
"account.referral.summaryCompact" = "已奖励 %d · 待确认 %d"; "account.referral.summaryCompact" = "已奖励 %d · 待确认 %d";
"account.referral.preparingLink" = "正在生成邀请链接…"; "account.referral.preparingLink" = "正在加载邀请链接…";
"account.referral.createCode" = "生成邀请码"; "account.referral.createCode" = "加载邀请链接";
"account.referral.loadLink" = "加载邀请链接";
"account.referral.loadingLink" = "正在加载邀请链接…";
"account.referral.refreshingLink" = "正在刷新邀请链接…";
"account.referral.error.load" = "无法加载邀请链接。";
"account.referral.error.session" = "请重新登录后加载邀请链接。";
"account.referral.error.network" = "请检查网络连接后重试。";
"account.referral.error.notFound" = "邀请链接暂不可用。";
"account.referral.error.conflict" = "邀请链接暂时不可用。";
"account.referral.error.unavailable" = "邀请服务暂时不可用。";
"account.referral.status.pending" = "待确认"; "account.referral.status.pending" = "待确认";
"account.referral.status.rewarded" = "已奖励"; "account.referral.status.rewarded" = "已奖励";
"account.referral.status.ineligible" = "不符合条件"; "account.referral.status.ineligible" = "不符合条件";
@@ -485,7 +485,7 @@ public actor AccountAPIClient {
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = resource.method request.httpMethod = resource.method
request.timeoutInterval = 30 request.timeoutInterval = resource == .referralProfile ? 15 : 30
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
if resource == .creditsBalance { if resource == .creditsBalance {
@@ -504,6 +504,10 @@ public actor AccountAPIClient {
private func send(_ request: URLRequest) async throws -> (data: Data, response: HTTPURLResponse) { private func send(_ request: URLRequest) async throws -> (data: Data, response: HTTPURLResponse) {
do { do {
return try await transport.data(for: request) return try await transport.data(for: request)
} catch is CancellationError {
throw CancellationError()
} catch let error as URLError where error.code == .cancelled {
throw CancellationError()
} catch let error as AccountAPIError { } catch let error as AccountAPIError {
throw error throw error
} catch { } catch {
@@ -54,7 +54,6 @@ public enum AccountAuthorizedResource: Sendable, Equatable {
case referralProfile case referralProfile
case referralCampaigns case referralCampaigns
case referrals(limit: Int) case referrals(limit: Int)
case createReferralCode
case redeemReferral case redeemReferral
case storeKitProducts case storeKitProducts
case storeKitTransactions(limit: Int, cursor: String?) case storeKitTransactions(limit: Int, cursor: String?)
@@ -63,7 +62,7 @@ public enum AccountAuthorizedResource: Sendable, Equatable {
var method: String { var method: String {
switch self { switch self {
case .createReferralCode, .redeemReferral, .submitStoreKitTransaction: case .redeemReferral, .submitStoreKitTransaction:
return "POST" return "POST"
case .revokeGatewayGrant: case .revokeGatewayGrant:
return "DELETE" return "DELETE"
@@ -82,8 +81,6 @@ public enum AccountAuthorizedResource: Sendable, Equatable {
return "/v1/referrals/campaigns" return "/v1/referrals/campaigns"
case .referrals(let limit): case .referrals(let limit):
return "/v1/referrals?limit=\(min(max(limit, 1), 100))" return "/v1/referrals?limit=\(min(max(limit, 1), 100))"
case .createReferralCode:
return "/v1/referrals/code"
case .redeemReferral: case .redeemReferral:
return "/v1/referrals/redeem" return "/v1/referrals/redeem"
case .storeKitProducts: case .storeKitProducts:
@@ -163,10 +163,6 @@ final class AccountCenterViewModelTests: XCTestCase {
let latest = AccountCenterSnapshot( let latest = AccountCenterSnapshot(
account: account, account: account,
credits: latestCredits, credits: latestCredits,
referralProfile: AccountReferralProfile(
code: "LatestCode_1234567890",
boundCode: nil
),
referrals: [ referrals: [
AccountReferral( AccountReferral(
id: UUID(), id: UUID(),
@@ -213,7 +209,6 @@ final class AccountCenterViewModelTests: XCTestCase {
let latest = AccountCenterSnapshot( let latest = AccountCenterSnapshot(
account: account, account: account,
credits: AccountCreditSummary(balance: 500, usedCredits: 500), credits: AccountCreditSummary(balance: 500, usedCredits: 500),
referralProfile: original.referralProfile,
referrals: original.referrals referrals: original.referrals
) )
let service = AccountServiceSpy( let service = AccountServiceSpy(
@@ -443,7 +438,6 @@ final class AccountCenterViewModelTests: XCTestCase {
balance: 9_223_372_036_854_775_000, balance: 9_223_372_036_854_775_000,
usedCredits: 1_234 usedCredits: 1_234
), ),
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
referrals: [] referrals: []
) )
} }
@@ -551,10 +545,6 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
return centerSnapshot return centerSnapshot
} }
func createReferralCode() async throws -> String {
"CreatedCode_1234567890"
}
func redeemReferral(code: String) async throws { func redeemReferral(code: String) async throws {
if shouldFailRedemption { if shouldFailRedemption {
throw AccountServiceSpyError.failed throw AccountServiceSpyError.failed
@@ -200,10 +200,6 @@ private actor CreditServiceStub: AccountCenterServicing {
throw AccountIntegrationError.unavailable throw AccountIntegrationError.unavailable
} }
func createReferralCode() async throws -> String {
throw AccountIntegrationError.unavailable
}
func redeemReferral(code: String) async throws { func redeemReferral(code: String) async throws {
throw AccountIntegrationError.unavailable throw AccountIntegrationError.unavailable
} }
@@ -10,7 +10,7 @@ import XCTest
final class AccountSnapshotLoaderTests: XCTestCase { final class AccountSnapshotLoaderTests: XCTestCase {
@MainActor @MainActor
func testOptionalReferralFailuresUseCachedAccountData() async throws { func testOptionalReferralListFailureUsesCachedAccountData() async throws {
let account = OSGKeyboard.AccountSession( let account = OSGKeyboard.AccountSession(
accountID: UUID(), accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000 createdAtEpochSeconds: 1_700_000_000
@@ -24,12 +24,6 @@ final class AccountSnapshotLoaderTests: XCTestCase {
let cached = AccountCenterSnapshot( let cached = AccountCenterSnapshot(
account: account, account: account,
credits: AccountCreditSummary(balance: 1_000, usedCredits: 0), credits: AccountCreditSummary(balance: 1_000, usedCredits: 0),
referralProfile: AccountReferralProfile(
code: "CachedCode_1234567890",
boundCode: nil,
inviterRewardCredits: 1_000,
inviteeRewardCredits: 1_000
),
referrals: [cachedReferral] referrals: [cachedReferral]
) )
let transport = AccountCenterRoutingTransport(accountID: account.accountID) let transport = AccountCenterRoutingTransport(accountID: account.accountID)
@@ -48,12 +42,15 @@ final class AccountSnapshotLoaderTests: XCTestCase {
refreshed.credits, refreshed.credits,
AccountCreditSummary(balance: 750, usedCredits: 250) AccountCreditSummary(balance: 750, usedCredits: 250)
) )
XCTAssertEqual(refreshed.referralProfile, cached.referralProfile)
XCTAssertEqual(refreshed.referrals, cached.referrals) XCTAssertEqual(refreshed.referrals, cached.referrals)
let requests = await transport.requests let requests = await transport.requests
XCTAssertEqual( XCTAssertEqual(
requests.count { $0.url?.path == "/v1/referrals/me" }, requests.count { $0.url?.path == "/v1/referrals/me" },
2 0
)
XCTAssertEqual(
requests.count { $0.url?.path == "/v1/referrals/code" },
0
) )
XCTAssertEqual( XCTAssertEqual(
requests.count { $0.url?.path == "/v1/referrals" }, requests.count { $0.url?.path == "/v1/referrals" },
@@ -88,7 +85,7 @@ private actor AccountCenterRoutingTransport: AccountHTTPTransport {
statusCode: 200, statusCode: 200,
body: Data(#"{"balance":750,"lifetimeUsed":250}"#.utf8) body: Data(#"{"balance":750,"lifetimeUsed":250}"#.utf8)
) )
case "/v1/referrals/me", "/v1/referrals": case "/v1/referrals":
response = .init( response = .init(
statusCode: 503, statusCode: 503,
body: apiErrorData( body: apiErrorData(
+528
View File
@@ -0,0 +1,528 @@
// ReferralProfileTests.swift
// OSGKeyboardTests
//
// Server-owned invitation decoding, transport, cache, and state-machine coverage.
@testable import OSGKeyboard
@testable import OSGKeyboardHostSupport
import XCTest
final class ReferralProfileTests: XCTestCase {
private let accountA = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!
private let accountB = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!
func testReferralProfileDecodesServerInviteURLAndOptionalBinding() throws {
let inviteURL = "https://osglab.com/i/Abcdefghij_1234567890-?source=server"
let data = Data(
"""
{
"code": {
"code": "Abcdefghij_1234567890-",
"inviteUrl": "\(inviteURL)",
"campaignId": null,
"createdAt": "2026-08-20T12:34:56.123Z"
},
"binding": {
"boundAt": "2026-08-20T12:35:00Z",
"rewardStatus": "PENDING",
"serverOwnedField": 42
}
}
""".utf8
)
let profile = try JSONDecoder().decode(ReferralProfile.self, from: data)
XCTAssertEqual(profile.code.code, "Abcdefghij_1234567890-")
XCTAssertEqual(profile.code.inviteURL.absoluteString, inviteURL)
XCTAssertNil(profile.code.campaignID)
XCTAssertNotNil(profile.binding)
}
func testLiveReferralServiceUsesAuthenticatedGETWithBoundedTimeout() async throws {
let session = makeAccountSession()
let body = referralProfileData(inviteSuffix: "?source=transport")
let transport = QueueAccountTransport([
.init(statusCode: 200, body: body)
])
let client = AccountAPIClient(
baseURL: URL(string: "https://account.test")!,
transport: transport,
sessionVault: InMemoryAccountSecurityStore(session: session)
)
let service = LiveReferralProfileService(apiClient: client)
let profile = try await service.loadReferralProfile()
XCTAssertEqual(
profile.code.inviteURL.absoluteString,
"https://osglab.com/i/Abcdefghij_1234567890-?source=transport"
)
let requests = await transport.requests
let request = try XCTUnwrap(requests.single)
XCTAssertEqual(request.url?.path, "/v1/referrals/me")
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.timeoutInterval, 15)
XCTAssertEqual(
request.value(forHTTPHeaderField: "Authorization"),
"Bearer \(session.accessToken)"
)
XCTAssertFalse(requests.contains { $0.url?.path == "/v1/referrals/code" })
}
func testReferralServiceDecodesLegacyStringErrorResponse() async {
let transport = QueueAccountTransport([
.init(statusCode: 404, body: Data(#"{"error":"missing"}"#.utf8))
])
let client = AccountAPIClient(
baseURL: URL(string: "https://account.test")!,
transport: transport,
sessionVault: InMemoryAccountSecurityStore(session: makeAccountSession())
)
let service = LiveReferralProfileService(apiClient: client)
do {
_ = try await service.loadReferralProfile()
XCTFail("Expected a not-found error.")
} catch let error as AccountAPIError {
XCTAssertEqual(
error,
.server(statusCode: 404, code: "http_error", message: "missing")
)
} catch {
XCTFail("Unexpected error: \(error)")
}
}
@MainActor
func testSuccessfulRefreshAlwaysExitsLoading() async {
let profile = makeProfile(inviteSuffix: "?version=fresh")
let service = ReferralProfileServiceSpy(profiles: [profile])
let viewModel = makeViewModel(service: service)
viewModel.startSession(accountID: accountA)
await viewModel.refresh()
XCTAssertEqual(viewModel.state, .loaded(profile))
XCTAssertFalse(viewModel.isRefreshing)
XCTAssertNil(viewModel.refreshErrorKey)
}
@MainActor
func testFailedRefreshAlwaysExitsLoadingAndShowsRetryState() async {
let service = ReferralProfileServiceSpy(error: .transport)
let viewModel = makeViewModel(service: service)
viewModel.startSession(accountID: accountA)
await viewModel.refresh()
XCTAssertEqual(
viewModel.state,
.failed(messageKey: "account.referral.error.network")
)
XCTAssertFalse(viewModel.isRefreshing)
}
@MainActor
func testManualRetryLoadsProfileAfterInitialFailure() async {
let profile = makeProfile(inviteSuffix: "?retry=success")
let service = RetryReferralProfileService(profile: profile)
let viewModel = ReferralProfileViewModel(
service: service,
store: InMemoryReferralProfileStore()
)
viewModel.startSession(accountID: accountA)
await viewModel.refresh()
XCTAssertEqual(
viewModel.state,
.failed(messageKey: "account.referral.error.network")
)
await viewModel.refresh()
XCTAssertEqual(viewModel.state, .loaded(profile))
XCTAssertFalse(viewModel.isRefreshing)
}
@MainActor
func testHTTPFailuresMapToFiniteRetryStates() async {
let cases: [(AccountAPIError, String)] = [
(
.server(statusCode: 401, code: "http_error", message: "unauthorized"),
"account.referral.error.session"
),
(
.server(statusCode: 404, code: "http_error", message: "missing"),
"account.referral.error.notFound"
),
(
.conflict("already bound"),
"account.referral.error.conflict"
),
(
.server(statusCode: 503, code: "http_error", message: "unavailable"),
"account.referral.error.unavailable"
)
]
for (error, expectedKey) in cases {
let viewModel = makeViewModel(
service: ReferralProfileServiceSpy(error: error)
)
viewModel.startSession(accountID: accountA)
await viewModel.refresh()
XCTAssertEqual(viewModel.state, .failed(messageKey: expectedKey))
XCTAssertFalse(viewModel.isRefreshing)
}
}
@MainActor
func testCancellationCannotLeaveViewModelLoading() async {
let service = ReferralProfileServiceSpy(
profiles: [makeProfile()],
delaysNanoseconds: [2_000_000_000]
)
let viewModel = makeViewModel(service: service)
viewModel.startSession(accountID: accountA)
viewModel.endSession()
await Task.yield()
XCTAssertEqual(viewModel.state, .idle)
XCTAssertFalse(viewModel.isRefreshing)
XCTAssertNil(viewModel.refreshErrorKey)
}
@MainActor
func testRepeatedSessionStartSharesOneAutomaticRequest() async {
let service = ReferralProfileServiceSpy(
profiles: [makeProfile()],
delaysNanoseconds: [20_000_000]
)
let viewModel = makeViewModel(service: service)
viewModel.startSession(accountID: accountA)
viewModel.startSession(accountID: accountA)
await viewModel.refresh()
let loadCount = await service.loadCount()
XCTAssertEqual(loadCount, 1)
}
@MainActor
func testAccountSwitchRejectsStalePreviousAccountResponse() async {
let profileA = makeProfile(inviteSuffix: "?account=A")
let profileB = makeProfile(inviteSuffix: "?account=B")
let service = ReferralProfileServiceSpy(
profiles: [profileA, profileB],
delaysNanoseconds: [150_000_000, 0],
ignoresCancellationAt: [0]
)
let store = InMemoryReferralProfileStore()
let viewModel = ReferralProfileViewModel(service: service, store: store)
viewModel.startSession(accountID: accountA)
await waitUntil { await service.loadCount() == 1 }
viewModel.startSession(accountID: accountB)
await viewModel.refresh()
try? await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(viewModel.state, .loaded(profileB))
XCTAssertNil(store.profile(for: accountA))
XCTAssertEqual(store.profile(for: accountB), profileB)
}
@MainActor
func testCachedProfileDisplaysBeforeBackgroundRefresh() async {
let cached = makeProfile(inviteSuffix: "?version=cached")
let refreshed = makeProfile(inviteSuffix: "?version=server")
let store = InMemoryReferralProfileStore(profiles: [accountA: cached])
let service = ReferralProfileServiceSpy(
profiles: [refreshed],
delaysNanoseconds: [50_000_000]
)
let viewModel = ReferralProfileViewModel(service: service, store: store)
viewModel.startSession(accountID: accountA)
XCTAssertEqual(viewModel.state, .loaded(cached))
XCTAssertTrue(viewModel.isRefreshing)
await viewModel.refresh()
XCTAssertEqual(viewModel.state, .loaded(refreshed))
XCTAssertEqual(store.profile(for: accountA), refreshed)
}
@MainActor
func testCachedProfileRemainsVisibleWhenBackgroundRefreshFails() async {
let cached = makeProfile(inviteSuffix: "?version=cached")
let store = InMemoryReferralProfileStore(profiles: [accountA: cached])
let service = ReferralProfileServiceSpy(error: .transport)
let viewModel = ReferralProfileViewModel(service: service, store: store)
viewModel.startSession(accountID: accountA)
await viewModel.refresh()
XCTAssertEqual(viewModel.state, .loaded(cached))
XCTAssertEqual(viewModel.refreshErrorKey, "account.referral.error.network")
XCTAssertFalse(viewModel.isRefreshing)
}
@MainActor
func testProfileCacheIsIsolatedByAccountAndSurvivesSessionEnd() async {
let profileA = makeProfile(inviteSuffix: "?account=A")
let profileB = makeProfile(inviteSuffix: "?account=B")
let store = InMemoryReferralProfileStore(
profiles: [accountA: profileA, accountB: profileB]
)
let viewModel = ReferralProfileViewModel(
service: ReferralProfileServiceSpy(profiles: [profileA]),
store: store
)
viewModel.startSession(accountID: accountA)
viewModel.endSession()
XCTAssertEqual(store.profile(for: accountA), profileA)
XCTAssertEqual(store.profile(for: accountB), profileB)
}
@MainActor
func testSignInStartsReferralLoadWithoutWaitingForIt() async {
let account = OSGKeyboard.AccountSession(
accountID: accountA,
createdAtEpochSeconds: 1_700_000_000
)
let accountService = SignInAccountService(account: account)
let referralService = ReferralProfileServiceSpy(
profiles: [makeProfile()],
delaysNanoseconds: [2_000_000_000]
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: accountService,
centerService: accountService,
referralService: referralService
),
pendingReferralStore: InMemoryPendingReferralStore(),
referralProfileStore: InMemoryReferralProfileStore()
)
let clock = ContinuousClock()
let start = clock.now
await coordinator.signIn(
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
)
)
XCTAssertLessThan(start.duration(to: clock.now), .seconds(1))
XCTAssertEqual(
coordinator.sessionPhase,
AccountSessionCoordinator.SessionPhase.signedIn(account)
)
XCTAssertEqual(
coordinator.referralProfile.state,
ReferralProfileViewModel.State.loading
)
coordinator.referralProfile.endSession()
}
@MainActor
func testShareSourcePreservesExactServerInviteURL() {
let profile = makeProfile(inviteSuffix: "?source=server%20owned#invite")
XCTAssertEqual(
profile.code.inviteURL.absoluteString,
"https://osglab.com/i/Abcdefghij_1234567890-?source=server%20owned#invite"
)
}
@MainActor
private func makeViewModel(
service: ReferralProfileServiceSpy
) -> ReferralProfileViewModel {
ReferralProfileViewModel(
service: service,
store: InMemoryReferralProfileStore()
)
}
@MainActor
private func waitUntil(
_ condition: @escaping () async -> Bool
) async {
for _ in 0..<100 {
if await condition() {
return
}
try? await Task.sleep(nanoseconds: 1_000_000)
}
XCTFail("Condition was not met before timeout.")
}
private func makeProfile(inviteSuffix: String = "") -> ReferralProfile {
ReferralProfile(
code: ReferralCode(
code: "Abcdefghij_1234567890-",
inviteURL: URL(
string: "https://osglab.com/i/Abcdefghij_1234567890-\(inviteSuffix)"
)!,
campaignID: nil,
createdAt: Date(timeIntervalSince1970: 1_700_000_000)
),
binding: nil
)
}
private func referralProfileData(inviteSuffix: String = "") -> Data {
Data(
"""
{
"code": {
"code": "Abcdefghij_1234567890-",
"inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-\(inviteSuffix)",
"campaignId": null,
"createdAt": "2026-08-20T12:34:56Z"
},
"binding": null
}
""".utf8
)
}
}
private actor ReferralProfileServiceSpy: ReferralProfileServicing {
private let profiles: [ReferralProfile]
private let error: AccountAPIError?
private let delaysNanoseconds: [UInt64]
private let ignoresCancellationAt: Set<Int>
private var count = 0
init(
profiles: [ReferralProfile] = [],
error: AccountAPIError? = nil,
delaysNanoseconds: [UInt64] = [],
ignoresCancellationAt: Set<Int> = []
) {
self.profiles = profiles
self.error = error
self.delaysNanoseconds = delaysNanoseconds
self.ignoresCancellationAt = ignoresCancellationAt
}
func loadReferralProfile() async throws -> ReferralProfile {
let index = count
count += 1
let delay = index < delaysNanoseconds.count ? delaysNanoseconds[index] : 0
if delay > 0 {
if ignoresCancellationAt.contains(index) {
try? await Task.sleep(nanoseconds: delay)
} else {
try await Task.sleep(nanoseconds: delay)
}
}
if let error {
throw error
}
guard !profiles.isEmpty else {
throw AccountIntegrationError.unavailable
}
return profiles[min(index, profiles.count - 1)]
}
func loadCount() -> Int {
count
}
}
private actor RetryReferralProfileService: ReferralProfileServicing {
private let profile: ReferralProfile
private var callCount = 0
init(profile: ReferralProfile) {
self.profile = profile
}
func loadReferralProfile() async throws -> ReferralProfile {
callCount += 1
if callCount == 1 {
throw AccountAPIError.transport
}
return profile
}
}
@MainActor
private final class InMemoryReferralProfileStore: ReferralProfileStoring {
private var profiles: [UUID: ReferralProfile]
init(profiles: [UUID: ReferralProfile] = [:]) {
self.profiles = profiles
}
func profile(for accountID: UUID) -> ReferralProfile? {
profiles[accountID]
}
func save(_ profile: ReferralProfile, for accountID: UUID) {
profiles[accountID] = profile
}
func removeProfile(for accountID: UUID) {
profiles.removeValue(forKey: accountID)
}
}
@MainActor
private final class InMemoryPendingReferralStore: PendingReferralCodeStoring {
private(set) var code: String?
func save(_ code: String) {
self.code = code
}
func clear() {
code = nil
}
}
private actor SignInAccountService: AccountSessionServicing, AccountCenterServicing {
let account: OSGKeyboard.AccountSession
init(account: OSGKeyboard.AccountSession) {
self.account = account
}
func restoreSession() async throws -> OSGKeyboard.AccountSession? {
nil
}
func signIn(
with payload: AppleAuthorizationPayload
) async throws -> OSGKeyboard.AccountSession {
account
}
func signOut() async throws {}
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {}
func loadAccountCenter() async throws -> AccountCenterSnapshot {
AccountCenterSnapshot(
account: account,
credits: AccountCreditSummary(balance: 0, usedCredits: 0),
referrals: []
)
}
func redeemReferral(code: String) async throws {}
}
private extension Array {
var single: Element? {
count == 1 ? first : nil
}
}
+4 -4
View File
@@ -1,4 +1,4 @@
# App Store Connect — OSGKeyboard 2.0.0 (build 83) # App Store Connect — OSGKeyboard 2.0.0 (build 86)
> Current metadata baseline for the iOS/iPadOS App Store build. Version and build > Current metadata baseline for the iOS/iPadOS App Store build. Version and build
> numbers come from `project.yml`. The repository also contains a separate > numbers come from `project.yml`. The repository also contains a separate
@@ -11,7 +11,7 @@
| App name | `OSGKeyboard` | ≤ 30 characters | | App name | `OSGKeyboard` | ≤ 30 characters |
| Subtitle | `Voice input, everywhere` | ≤ 30 characters | | Subtitle | `Voice input, everywhere` | ≤ 30 characters |
| Bundle ID | `com.osgkeyboard.ios` | iOS host target | | Bundle ID | `com.osgkeyboard.ios` | iOS host target |
| Version / build | `2.0.0` / `83` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` | | Version / build | `2.0.0` / `86` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` |
| Minimum system | iOS/iPadOS 26 | iPhone and iPad | | Minimum system | iOS/iPadOS 26 | iPhone and iPad |
| Primary locale | `en-US` | Simplified Chinese is also bundled | | Primary locale | `en-US` | Simplified Chinese is also bundled |
| Primary category | Utilities | | | Primary category | Utilities | |
@@ -283,7 +283,7 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added.
## Submission checklist ## Submission checklist
- [ ] Confirm `project.yml` still reads version 2.0.0 / build 83 - [ ] Confirm `project.yml` still reads version 2.0.0 / build 86
- [ ] Open the existing Xcode project (do not regenerate unless needed) - [ ] Open the existing Xcode project (do not regenerate unless needed)
- [ ] Run the release build and test suites on macOS with Xcode 26 - [ ] Run the release build and test suites on macOS with Xcode 26
- [ ] Replace screenshots with captures from the submitted build - [ ] Replace screenshots with captures from the submitted build
@@ -294,4 +294,4 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added.
and mapped to the server credit catalog and mapped to the server credit catalog
- [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks - [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks
no feature no feature
- [ ] Upload, select build 83, add review notes, and submit - [ ] Upload, select build 86, add review notes, and submit
+1 -1
View File
@@ -49,7 +49,7 @@ settings:
STRING_CATALOG_GENERATE_SYMBOLS: YES STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17 CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "2.0.0" MARKETING_VERSION: "2.0.0"
CURRENT_PROJECT_VERSION: "85" CURRENT_PROJECT_VERSION: "86"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖) # 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target # 项目级签名 xcconfig,适用于所有 target