fix(account): load permanent referral profiles
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:
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
||||
|
||||
### 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 |
|
||||
|-------|------|------|
|
||||
|
||||
@@ -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. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
|
||||
|
||||
### 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 或键盘扩展的旧快照覆盖其他较新的设置。
|
||||
- **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 意图,让网关可为低延迟转换任务关闭高成本思考。
|
||||
|
||||
@@ -20,7 +20,8 @@ struct AccountCenterUITestHarness: View {
|
||||
wrappedValue: AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
centerService: service,
|
||||
referralService: service
|
||||
),
|
||||
creditStore: store
|
||||
)
|
||||
@@ -54,7 +55,8 @@ struct ManagedCloudConsentUITestHarness: View {
|
||||
wrappedValue: AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
centerService: service,
|
||||
referralService: service
|
||||
),
|
||||
creditStore: AccountCenterUITestCreditStore(
|
||||
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(
|
||||
accountID: UUID(uuidString: "00000000-0000-0000-0000-000000000200")!,
|
||||
createdAtEpochSeconds: 1_700_000_000,
|
||||
@@ -106,7 +111,6 @@ private actor AccountCenterUITestService: AccountSessionServicing, AccountCenter
|
||||
AccountCenterSnapshot(
|
||||
account: account,
|
||||
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
|
||||
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
|
||||
referrals: []
|
||||
)
|
||||
}
|
||||
@@ -119,14 +123,22 @@ private actor AccountCenterUITestService: AccountSessionServicing, AccountCenter
|
||||
)
|
||||
}
|
||||
|
||||
func createReferralCode() async throws -> String {
|
||||
"UITestInvite_1234567890"
|
||||
}
|
||||
|
||||
func redeemReferral(code: String) async throws {
|
||||
_ = 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] {
|
||||
[
|
||||
AccountCreditProduct(productID: "500tks", credits: 500),
|
||||
|
||||
@@ -751,10 +751,11 @@ struct AccountInvitationButton: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@State private var showsShareDrawer = false
|
||||
|
||||
let invitationURL: URL
|
||||
let invitationURL: URL?
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
guard invitationURL != nil else { return }
|
||||
showsShareDrawer = true
|
||||
} label: {
|
||||
Label("account.referral.inviteTitle", systemImage: "person.badge.plus")
|
||||
@@ -771,10 +772,14 @@ struct AccountInvitationButton: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(invitationURL == nil)
|
||||
.accessibilityIdentifier("account.referral.share")
|
||||
.sheet(isPresented: $showsShareDrawer) {
|
||||
AccountInvitationActivityView(invitationURL: invitationURL)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
if let invitationURL {
|
||||
AccountInvitationActivityView(invitationURL: invitationURL)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,7 +855,6 @@ struct IAPReviewScreenshotHarness: View {
|
||||
displayName: "OSG User"
|
||||
),
|
||||
credits: AccountCreditSummary(balance: 1_000, usedCredits: 0),
|
||||
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
|
||||
referrals: []
|
||||
)
|
||||
}
|
||||
@@ -860,10 +864,6 @@ private struct IAPReviewAccountService: AccountCenterServicing {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func createReferralCode() async throws -> String {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func redeemReferral(code: String) async throws {}
|
||||
|
||||
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||
|
||||
@@ -75,29 +75,150 @@ struct AccountReferral: Identifiable, Equatable, Sendable {
|
||||
let rewardCredits: Int64?
|
||||
}
|
||||
|
||||
struct AccountReferralProfile: Equatable, Sendable {
|
||||
let code: String?
|
||||
let boundCode: String?
|
||||
let inviterRewardCredits: Int64?
|
||||
let inviteeRewardCredits: Int64?
|
||||
struct ReferralCode: Codable, Equatable, Sendable {
|
||||
let code: String
|
||||
let inviteURL: URL
|
||||
let campaignID: String?
|
||||
let createdAt: Date
|
||||
|
||||
init(
|
||||
code: String?,
|
||||
boundCode: String?,
|
||||
inviterRewardCredits: Int64? = nil,
|
||||
inviteeRewardCredits: Int64? = nil
|
||||
) {
|
||||
self.code = code
|
||||
self.boundCode = boundCode
|
||||
self.inviterRewardCredits = inviterRewardCredits
|
||||
self.inviteeRewardCredits = inviteeRewardCredits
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code
|
||||
case inviteURL = "inviteUrl"
|
||||
case campaignID = "campaignId"
|
||||
case createdAt
|
||||
}
|
||||
|
||||
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 {
|
||||
let account: AccountSession
|
||||
let credits: AccountCreditSummary
|
||||
let referralProfile: AccountReferralProfile
|
||||
let referrals: [AccountReferral]
|
||||
}
|
||||
|
||||
@@ -158,7 +279,6 @@ protocol AccountCenterServicing: Sendable {
|
||||
cachedSnapshot: AccountCenterSnapshot?
|
||||
) async throws -> AccountCenterSnapshot
|
||||
func updateDisplayName(_ displayName: String) async throws -> AccountSession
|
||||
func createReferralCode() async throws -> String
|
||||
func redeemReferral(code: String) async throws
|
||||
func loadCreditProducts() async throws -> [AccountCreditProduct]
|
||||
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase
|
||||
@@ -195,13 +315,32 @@ extension AccountCenterServicing {
|
||||
}
|
||||
}
|
||||
|
||||
protocol ReferralProfileServicing: Sendable {
|
||||
func loadReferralProfile() async throws -> ReferralProfile
|
||||
}
|
||||
|
||||
struct AccountDependencies: Sendable {
|
||||
let sessionService: any AccountSessionServicing
|
||||
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 = {
|
||||
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
|
||||
}
|
||||
|
||||
func createReferralCode() async throws -> String {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func redeemReferral(code: String) async throws {
|
||||
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 {
|
||||
static let host = "osglab.com"
|
||||
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
|
||||
@@ -326,3 +463,43 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
|
||||
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?
|
||||
|
||||
let creditPurchases: AccountCreditPurchaseManager
|
||||
let referralProfile: ReferralProfileViewModel
|
||||
|
||||
private let sessionService: any AccountSessionServicing
|
||||
private let centerService: any AccountCenterServicing
|
||||
@@ -60,6 +61,8 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
creditStore: any AccountCreditStore = LiveAccountCreditStore(),
|
||||
pendingReferralStore: any PendingReferralCodeStoring =
|
||||
UserDefaultsPendingReferralCodeStore(),
|
||||
referralProfileStore: any ReferralProfileStoring =
|
||||
UserDefaultsReferralProfileStore(),
|
||||
accountRefreshInterval: TimeInterval = 10 * 60,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||
@@ -73,6 +76,10 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
store: creditStore,
|
||||
analyticsClient: analyticsClient
|
||||
)
|
||||
referralProfile = ReferralProfileViewModel(
|
||||
service: dependencies.referralService,
|
||||
store: referralProfileStore
|
||||
)
|
||||
self.pendingReferralStore = pendingReferralStore
|
||||
self.analyticsClient = analyticsClient
|
||||
self.onAccountAuthenticated = onAccountAuthenticated
|
||||
@@ -107,6 +114,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
}
|
||||
await onAccountAuthenticated(session.accountID)
|
||||
sessionPhase = .signedIn(session)
|
||||
referralProfile.startSession(accountID: session.accountID)
|
||||
await redeemPendingReferralIfNeeded()
|
||||
await refreshAccountData(force: true)
|
||||
} catch {
|
||||
@@ -153,6 +161,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
let session = try await sessionService.signIn(with: payload)
|
||||
await onAccountAuthenticated(session.accountID)
|
||||
sessionPhase = .signedIn(session)
|
||||
referralProfile.startSession(accountID: session.accountID)
|
||||
await redeemPendingReferralIfNeeded()
|
||||
await refreshAccountData(force: true)
|
||||
} catch {
|
||||
@@ -210,7 +219,6 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
AccountCenterSnapshot(
|
||||
account: account,
|
||||
credits: snapshot.credits,
|
||||
referralProfile: snapshot.referralProfile,
|
||||
referrals: snapshot.referrals
|
||||
)
|
||||
)
|
||||
@@ -251,11 +259,13 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
operation = .signingOut
|
||||
operationErrorKey = nil
|
||||
defer { operation = nil }
|
||||
referralProfile.cancelRefresh()
|
||||
|
||||
do {
|
||||
try await sessionService.signOut()
|
||||
creditPurchases.reset()
|
||||
clearAccountRefreshState()
|
||||
referralProfile.endSession()
|
||||
sessionPhase = .signedOut
|
||||
snapshotPhase = .idle
|
||||
} catch {
|
||||
@@ -271,12 +281,14 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
operation = .deletingAccount
|
||||
operationErrorKey = nil
|
||||
defer { operation = nil }
|
||||
referralProfile.cancelRefresh()
|
||||
|
||||
do {
|
||||
try await sessionService.deleteAccount(with: payload)
|
||||
await onAccountDeleted()
|
||||
creditPurchases.reset()
|
||||
clearAccountRefreshState()
|
||||
referralProfile.endSession(removeCache: true)
|
||||
sessionPhase = .signedOut
|
||||
snapshotPhase = .idle
|
||||
pendingReferralStore.clear()
|
||||
|
||||
@@ -38,7 +38,11 @@ enum LiveAccountDependencyFactory {
|
||||
grantStore: grantStore,
|
||||
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 let account = apiClient.account()
|
||||
async let balance = resource(CreditBalanceDTO.self, .creditsBalance)
|
||||
async let profile = optionalResource(
|
||||
ReferralProfileDTO.self,
|
||||
.referralProfile,
|
||||
diagnosticName: "referral-profile"
|
||||
)
|
||||
async let referrals = optionalResource(
|
||||
[ReferralBindingDTO].self,
|
||||
.referrals(limit: 50),
|
||||
diagnosticName: "referrals"
|
||||
)
|
||||
async let campaigns = optionalResource(
|
||||
[ReferralCampaignDTO].self,
|
||||
.referralCampaigns,
|
||||
diagnosticName: "referral-campaigns"
|
||||
)
|
||||
|
||||
let core: (OSGAccount, CreditBalanceDTO)
|
||||
do {
|
||||
@@ -81,28 +75,7 @@ actor AccountCenterSnapshotLoader {
|
||||
)
|
||||
throw error
|
||||
}
|
||||
let optionalValues = await (profile, referrals, campaigns)
|
||||
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 {
|
||||
let loadedReferrals = await referrals?.map {
|
||||
AccountReferral(
|
||||
id: UUID(),
|
||||
status: Self.referralStatus($0.rewardStatus),
|
||||
@@ -121,7 +94,6 @@ actor AccountCenterSnapshotLoader {
|
||||
balance: core.1.balance,
|
||||
usedCredits: core.1.lifetimeUsed ?? 0
|
||||
),
|
||||
referralProfile: referralProfile,
|
||||
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 {
|
||||
switch value.uppercased() {
|
||||
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 let apiClient: AccountAPIClient
|
||||
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 {
|
||||
struct Request: Encodable { let code: String }
|
||||
let body = try encoder.encode(Request(code: code))
|
||||
@@ -586,21 +551,6 @@ private struct CreditBalanceDTO: Decodable, Sendable {
|
||||
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 {
|
||||
let boundAt: 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,55 +230,50 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
private var accountRewardsEntrySection: some View {
|
||||
Group {
|
||||
VStack(spacing: 0) {
|
||||
switch accountSession.snapshotPhase {
|
||||
case let .loaded(snapshot):
|
||||
let totalCredits = creditTotal(snapshot)
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Button {
|
||||
path.append(SettingsRoute.account)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(creditRemainingText(snapshot.credits.balance))
|
||||
.font(TypeStyle.title3.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.credits.balance")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||
Text(creditUsedText(snapshot.credits.usedCredits))
|
||||
Text(creditTotalText(totalCredits))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(minWidth: 32, minHeight: 32)
|
||||
.accessibilityHidden(true)
|
||||
Button {
|
||||
path.append(SettingsRoute.account)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(creditRemainingText(snapshot.credits.balance))
|
||||
.font(TypeStyle.title3.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.credits.balance")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
AccountCreditProgress(
|
||||
remaining: snapshot.credits.balance,
|
||||
used: snapshot.credits.usedCredits,
|
||||
showsLabels: false
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
invitationLink(snapshot.referralProfile)
|
||||
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||
Text(creditUsedText(snapshot.credits.usedCredits))
|
||||
Text(creditTotalText(totalCredits))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(minWidth: 32, minHeight: 32)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
AccountCreditProgress(
|
||||
remaining: snapshot.credits.balance,
|
||||
used: snapshot.credits.usedCredits,
|
||||
showsLabels: false
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(Spacing.md)
|
||||
case .failed:
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
@@ -302,39 +297,13 @@ struct SettingsView: View {
|
||||
}
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
AccountReferralLinkView(viewModel: accountSession.referralProfile)
|
||||
}
|
||||
.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 {
|
||||
let remaining = max(snapshot.credits.balance, 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)
|
||||
|
||||
private struct SettingsSheetDismissButton: View {
|
||||
|
||||
@@ -824,8 +824,17 @@
|
||||
"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.summaryCompact" = "Rewarded %d · Pending %d";
|
||||
"account.referral.preparingLink" = "Preparing your invitation link…";
|
||||
"account.referral.createCode" = "Create invitation code";
|
||||
"account.referral.preparingLink" = "Loading your invitation link…";
|
||||
"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" = "Couldn’t 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 isn’t 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.rewarded" = "Rewarded";
|
||||
"account.referral.status.ineligible" = "Ineligible";
|
||||
|
||||
@@ -823,8 +823,17 @@
|
||||
"account.referral.equalRewardDescription" = "每成功邀请一位好友,你和好友各获得 1000 积分。";
|
||||
"account.referral.equalRewardGeneric" = "每成功邀请一位好友,双方均可获得积分。";
|
||||
"account.referral.summaryCompact" = "已奖励 %d · 待确认 %d";
|
||||
"account.referral.preparingLink" = "正在生成邀请链接…";
|
||||
"account.referral.createCode" = "生成邀请码";
|
||||
"account.referral.preparingLink" = "正在加载邀请链接…";
|
||||
"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.rewarded" = "已奖励";
|
||||
"account.referral.status.ineligible" = "不符合条件";
|
||||
|
||||
@@ -485,7 +485,7 @@ public actor AccountAPIClient {
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = resource.method
|
||||
request.timeoutInterval = 30
|
||||
request.timeoutInterval = resource == .referralProfile ? 15 : 30
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
if resource == .creditsBalance {
|
||||
@@ -504,6 +504,10 @@ public actor AccountAPIClient {
|
||||
private func send(_ request: URLRequest) async throws -> (data: Data, response: HTTPURLResponse) {
|
||||
do {
|
||||
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 {
|
||||
throw error
|
||||
} catch {
|
||||
|
||||
@@ -54,7 +54,6 @@ public enum AccountAuthorizedResource: Sendable, Equatable {
|
||||
case referralProfile
|
||||
case referralCampaigns
|
||||
case referrals(limit: Int)
|
||||
case createReferralCode
|
||||
case redeemReferral
|
||||
case storeKitProducts
|
||||
case storeKitTransactions(limit: Int, cursor: String?)
|
||||
@@ -63,7 +62,7 @@ public enum AccountAuthorizedResource: Sendable, Equatable {
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .createReferralCode, .redeemReferral, .submitStoreKitTransaction:
|
||||
case .redeemReferral, .submitStoreKitTransaction:
|
||||
return "POST"
|
||||
case .revokeGatewayGrant:
|
||||
return "DELETE"
|
||||
@@ -82,8 +81,6 @@ public enum AccountAuthorizedResource: Sendable, Equatable {
|
||||
return "/v1/referrals/campaigns"
|
||||
case .referrals(let limit):
|
||||
return "/v1/referrals?limit=\(min(max(limit, 1), 100))"
|
||||
case .createReferralCode:
|
||||
return "/v1/referrals/code"
|
||||
case .redeemReferral:
|
||||
return "/v1/referrals/redeem"
|
||||
case .storeKitProducts:
|
||||
|
||||
@@ -163,10 +163,6 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
let latest = AccountCenterSnapshot(
|
||||
account: account,
|
||||
credits: latestCredits,
|
||||
referralProfile: AccountReferralProfile(
|
||||
code: "LatestCode_1234567890",
|
||||
boundCode: nil
|
||||
),
|
||||
referrals: [
|
||||
AccountReferral(
|
||||
id: UUID(),
|
||||
@@ -213,7 +209,6 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
let latest = AccountCenterSnapshot(
|
||||
account: account,
|
||||
credits: AccountCreditSummary(balance: 500, usedCredits: 500),
|
||||
referralProfile: original.referralProfile,
|
||||
referrals: original.referrals
|
||||
)
|
||||
let service = AccountServiceSpy(
|
||||
@@ -443,7 +438,6 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
balance: 9_223_372_036_854_775_000,
|
||||
usedCredits: 1_234
|
||||
),
|
||||
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
|
||||
referrals: []
|
||||
)
|
||||
}
|
||||
@@ -551,10 +545,6 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
return centerSnapshot
|
||||
}
|
||||
|
||||
func createReferralCode() async throws -> String {
|
||||
"CreatedCode_1234567890"
|
||||
}
|
||||
|
||||
func redeemReferral(code: String) async throws {
|
||||
if shouldFailRedemption {
|
||||
throw AccountServiceSpyError.failed
|
||||
|
||||
@@ -200,10 +200,6 @@ private actor CreditServiceStub: AccountCenterServicing {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func createReferralCode() async throws -> String {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func redeemReferral(code: String) async throws {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import XCTest
|
||||
|
||||
final class AccountSnapshotLoaderTests: XCTestCase {
|
||||
@MainActor
|
||||
func testOptionalReferralFailuresUseCachedAccountData() async throws {
|
||||
func testOptionalReferralListFailureUsesCachedAccountData() async throws {
|
||||
let account = OSGKeyboard.AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: 1_700_000_000
|
||||
@@ -24,12 +24,6 @@ final class AccountSnapshotLoaderTests: XCTestCase {
|
||||
let cached = AccountCenterSnapshot(
|
||||
account: account,
|
||||
credits: AccountCreditSummary(balance: 1_000, usedCredits: 0),
|
||||
referralProfile: AccountReferralProfile(
|
||||
code: "CachedCode_1234567890",
|
||||
boundCode: nil,
|
||||
inviterRewardCredits: 1_000,
|
||||
inviteeRewardCredits: 1_000
|
||||
),
|
||||
referrals: [cachedReferral]
|
||||
)
|
||||
let transport = AccountCenterRoutingTransport(accountID: account.accountID)
|
||||
@@ -48,12 +42,15 @@ final class AccountSnapshotLoaderTests: XCTestCase {
|
||||
refreshed.credits,
|
||||
AccountCreditSummary(balance: 750, usedCredits: 250)
|
||||
)
|
||||
XCTAssertEqual(refreshed.referralProfile, cached.referralProfile)
|
||||
XCTAssertEqual(refreshed.referrals, cached.referrals)
|
||||
let requests = await transport.requests
|
||||
XCTAssertEqual(
|
||||
requests.count { $0.url?.path == "/v1/referrals/me" },
|
||||
2
|
||||
0
|
||||
)
|
||||
XCTAssertEqual(
|
||||
requests.count { $0.url?.path == "/v1/referrals/code" },
|
||||
0
|
||||
)
|
||||
XCTAssertEqual(
|
||||
requests.count { $0.url?.path == "/v1/referrals" },
|
||||
@@ -88,7 +85,7 @@ private actor AccountCenterRoutingTransport: AccountHTTPTransport {
|
||||
statusCode: 200,
|
||||
body: Data(#"{"balance":750,"lifetimeUsed":250}"#.utf8)
|
||||
)
|
||||
case "/v1/referrals/me", "/v1/referrals":
|
||||
case "/v1/referrals":
|
||||
response = .init(
|
||||
statusCode: 503,
|
||||
body: apiErrorData(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
> numbers come from `project.yml`. The repository also contains a separate
|
||||
@@ -11,7 +11,7 @@
|
||||
| App name | `OSGKeyboard` | ≤ 30 characters |
|
||||
| Subtitle | `Voice input, everywhere` | ≤ 30 characters |
|
||||
| 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 |
|
||||
| Primary locale | `en-US` | Simplified Chinese is also bundled |
|
||||
| Primary category | Utilities | |
|
||||
@@ -283,7 +283,7 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added.
|
||||
|
||||
## 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)
|
||||
- [ ] Run the release build and test suites on macOS with Xcode 26
|
||||
- [ ] 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
|
||||
- [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks
|
||||
no feature
|
||||
- [ ] Upload, select build 83, add review notes, and submit
|
||||
- [ ] Upload, select build 86, add review notes, and submit
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ settings:
|
||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "2.0.0"
|
||||
CURRENT_PROJECT_VERSION: "85"
|
||||
CURRENT_PROJECT_VERSION: "86"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
|
||||
Reference in New Issue
Block a user