Read receipts in a serial fashion (#824)
Deal with reentrancy issues by ensuring serial execution.
This commit is contained in:
parent
63a0a661c9
commit
5949ff1508
|
@ -33,6 +33,8 @@ public actor FallbackReceiptReader: AppReceiptReader {
|
||||||
|
|
||||||
private let localReader: (URL) -> InAppReceiptReader?
|
private let localReader: (URL) -> InAppReceiptReader?
|
||||||
|
|
||||||
|
private var pendingTask: Task<InAppReceipt?, Never>?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
reader: (InAppReceiptReader & Sendable)?,
|
reader: (InAppReceiptReader & Sendable)?,
|
||||||
localReader: @escaping @Sendable (URL) -> InAppReceiptReader & Sendable
|
localReader: @escaping @Sendable (URL) -> InAppReceiptReader & Sendable
|
||||||
|
@ -42,16 +44,30 @@ public actor FallbackReceiptReader: AppReceiptReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func receipt(at userLevel: AppUserLevel) async -> InAppReceipt? {
|
public func receipt(at userLevel: AppUserLevel) async -> InAppReceipt? {
|
||||||
|
if let pendingTask {
|
||||||
|
_ = await pendingTask.value
|
||||||
|
}
|
||||||
|
pendingTask = Task {
|
||||||
|
await asyncReceipt(at: userLevel)
|
||||||
|
}
|
||||||
|
let receipt = await pendingTask?.value
|
||||||
|
pendingTask = nil
|
||||||
|
return receipt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FallbackReceiptReader {
|
||||||
|
func asyncReceipt(at userLevel: AppUserLevel) async -> InAppReceipt? {
|
||||||
let localURL = Bundle.main.appStoreReceiptURL
|
let localURL = Bundle.main.appStoreReceiptURL
|
||||||
|
|
||||||
pp_log(.app, .debug, "Parse receipt for user level \(userLevel)")
|
pp_log(.iap, .debug, "\tParse receipt for user level \(userLevel)")
|
||||||
|
|
||||||
// 1. TestFlight, look for release receipt
|
// 1. TestFlight, look for release receipt
|
||||||
let releaseReceipt: InAppReceipt? = await {
|
let releaseReceipt: InAppReceipt? = await {
|
||||||
guard userLevel == .beta, let localURL else {
|
guard userLevel == .beta, let localURL else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
pp_log(.app, .debug, "\tTestFlight, look for release receipt")
|
pp_log(.iap, .debug, "\tTestFlight, look for release receipt")
|
||||||
let releaseURL = localURL
|
let releaseURL = localURL
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.appendingPathComponent("receipt")
|
.appendingPathComponent("receipt")
|
||||||
|
@ -67,7 +83,7 @@ public actor FallbackReceiptReader: AppReceiptReader {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if let releaseReceipt {
|
if let releaseReceipt {
|
||||||
pp_log(.app, .debug, "\tTestFlight, return release receipt")
|
pp_log(.iap, .debug, "\tTestFlight, return release receipt")
|
||||||
return releaseReceipt
|
return releaseReceipt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,18 +95,18 @@ public actor FallbackReceiptReader: AppReceiptReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. primary receipt + build from local receipt
|
// 2. primary receipt + build from local receipt
|
||||||
pp_log(.app, .debug, "\tNo release receipt, read primary receipt")
|
pp_log(.iap, .debug, "\tNo release receipt, read primary receipt")
|
||||||
if let receipt = await reader?.receipt() {
|
if let receipt = await reader?.receipt() {
|
||||||
if let build = await localReceiptBlock()?.originalBuildNumber {
|
if let build = await localReceiptBlock()?.originalBuildNumber {
|
||||||
pp_log(.app, .debug, "\tReturn primary receipt with local build: \(build)")
|
pp_log(.iap, .debug, "\tReturn primary receipt with local build: \(build)")
|
||||||
return receipt.withBuildNumber(build)
|
return receipt.withBuildNumber(build)
|
||||||
}
|
}
|
||||||
pp_log(.app, .debug, "\tReturn primary receipt without local build")
|
pp_log(.iap, .debug, "\tReturn primary receipt without local build")
|
||||||
return receipt
|
return receipt
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. fall back to local receipt
|
// 3. fall back to local receipt
|
||||||
pp_log(.app, .debug, "\tReturn local receipt")
|
pp_log(.iap, .debug, "\tReturn local receipt")
|
||||||
return await localReceiptBlock()
|
return await localReceiptBlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,8 @@ public final class IAPManager: ObservableObject {
|
||||||
|
|
||||||
private var eligibleFeatures: Set<AppFeature>
|
private var eligibleFeatures: Set<AppFeature>
|
||||||
|
|
||||||
|
private var pendingReceiptTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var subscriptions: Set<AnyCancellable>
|
private var subscriptions: Set<AnyCancellable>
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
@ -81,7 +83,7 @@ extension IAPManager {
|
||||||
inAppProducts[$0]
|
inAppProducts[$0]
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .error, "Unable to fetch in-app products: \(error)")
|
pp_log(.iap, .error, "Unable to fetch in-app products: \(error)")
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -100,75 +102,14 @@ extension IAPManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reloadReceipt() async {
|
public func reloadReceipt() async {
|
||||||
purchasedAppBuild = nil
|
if let pendingReceiptTask {
|
||||||
purchasedProducts.removeAll()
|
await pendingReceiptTask.value
|
||||||
eligibleFeatures.removeAll()
|
|
||||||
|
|
||||||
pp_log(.app, .notice, "Reload IAP receipt...")
|
|
||||||
|
|
||||||
if let receipt = await receiptReader.receipt(at: userLevel) {
|
|
||||||
if let originalBuildNumber = receipt.originalBuildNumber {
|
|
||||||
purchasedAppBuild = originalBuildNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
if let purchasedAppBuild {
|
|
||||||
pp_log(.app, .info, "Original purchased build: \(purchasedAppBuild)")
|
|
||||||
|
|
||||||
// assume some purchases by build number
|
|
||||||
let entitled = productsAtBuild?(purchasedAppBuild) ?? []
|
|
||||||
pp_log(.app, .notice, "Entitled features: \(entitled.map(\.rawValue))")
|
|
||||||
|
|
||||||
entitled.forEach {
|
|
||||||
purchasedProducts.insert($0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let iapReceipts = receipt.purchaseReceipts {
|
|
||||||
pp_log(.app, .info, "In-app receipts:")
|
|
||||||
iapReceipts.forEach {
|
|
||||||
guard let pid = $0.productIdentifier, let product = AppProduct(rawValue: pid) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let expirationDate = $0.expirationDate {
|
|
||||||
let now = Date()
|
|
||||||
pp_log(.app, .debug, "\t\(pid) [expiration date: \(expirationDate), now: \(now)]")
|
|
||||||
if now >= expirationDate {
|
|
||||||
pp_log(.app, .info, "\t\(pid) [expired on: \(expirationDate)]")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let cancellationDate = $0.cancellationDate {
|
|
||||||
pp_log(.app, .info, "\t\(pid) [cancelled on: \(cancellationDate)]")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let purchaseDate = $0.originalPurchaseDate {
|
|
||||||
pp_log(.app, .info, "\t\(pid) [purchased on: \(purchaseDate)]")
|
|
||||||
}
|
|
||||||
purchasedProducts.insert(product)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eligibleFeatures = purchasedProducts.reduce(into: []) { eligible, product in
|
|
||||||
product.features.forEach {
|
|
||||||
eligible.insert($0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pp_log(.app, .error, "Could not parse App Store receipt!")
|
|
||||||
}
|
}
|
||||||
|
pendingReceiptTask = Task {
|
||||||
userLevel.features.forEach {
|
await asyncReloadReceipt()
|
||||||
eligibleFeatures.insert($0)
|
|
||||||
}
|
}
|
||||||
unrestrictedFeatures.forEach {
|
await pendingReceiptTask?.value
|
||||||
eligibleFeatures.insert($0)
|
pendingReceiptTask = nil
|
||||||
}
|
|
||||||
|
|
||||||
pp_log(.app, .notice, "Reloaded IAP receipt:")
|
|
||||||
pp_log(.app, .notice, "\tPurchased build number: \(purchasedAppBuild?.description ?? "unknown")")
|
|
||||||
pp_log(.app, .notice, "\tPurchased products: \(purchasedProducts.map(\.rawValue))")
|
|
||||||
pp_log(.app, .notice, "\tEligible features: \(eligibleFeatures)")
|
|
||||||
|
|
||||||
objectWillChange.send()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -214,15 +155,101 @@ extension IAPManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Receipt
|
||||||
|
|
||||||
|
private extension IAPManager {
|
||||||
|
func asyncReloadReceipt() async {
|
||||||
|
pp_log(.iap, .notice, "Start reloading in-app receipt...")
|
||||||
|
|
||||||
|
purchasedAppBuild = nil
|
||||||
|
purchasedProducts.removeAll()
|
||||||
|
eligibleFeatures.removeAll()
|
||||||
|
|
||||||
|
if let receipt = await receiptReader.receipt(at: userLevel) {
|
||||||
|
if let originalBuildNumber = receipt.originalBuildNumber {
|
||||||
|
purchasedAppBuild = originalBuildNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
if let purchasedAppBuild {
|
||||||
|
pp_log(.iap, .info, "Original purchased build: \(purchasedAppBuild)")
|
||||||
|
|
||||||
|
// assume some purchases by build number
|
||||||
|
let entitled = productsAtBuild?(purchasedAppBuild) ?? []
|
||||||
|
pp_log(.iap, .notice, "Entitled features: \(entitled.map(\.rawValue))")
|
||||||
|
|
||||||
|
entitled.forEach {
|
||||||
|
purchasedProducts.insert($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let iapReceipts = receipt.purchaseReceipts {
|
||||||
|
pp_log(.iap, .info, "Process in-app purchase receipts...")
|
||||||
|
|
||||||
|
let products: [AppProduct] = iapReceipts.compactMap {
|
||||||
|
guard let pid = $0.productIdentifier else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let product = AppProduct(rawValue: pid) else {
|
||||||
|
pp_log(.iap, .debug, "\tDiscard unknown product identifier: \(pid)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let expirationDate = $0.expirationDate {
|
||||||
|
let now = Date()
|
||||||
|
pp_log(.iap, .debug, "\t\(pid) [expiration date: \(expirationDate), now: \(now)]")
|
||||||
|
if now >= expirationDate {
|
||||||
|
pp_log(.iap, .info, "\t\(pid) [expired on: \(expirationDate)]")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let cancellationDate = $0.cancellationDate {
|
||||||
|
pp_log(.iap, .info, "\t\(pid) [cancelled on: \(cancellationDate)]")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let purchaseDate = $0.originalPurchaseDate {
|
||||||
|
pp_log(.iap, .info, "\t\(pid) [purchased on: \(purchaseDate)]")
|
||||||
|
}
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
products.forEach {
|
||||||
|
purchasedProducts.insert($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eligibleFeatures = purchasedProducts.reduce(into: []) { eligible, product in
|
||||||
|
product.features.forEach {
|
||||||
|
eligible.insert($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pp_log(.iap, .error, "Could not parse App Store receipt!")
|
||||||
|
}
|
||||||
|
|
||||||
|
userLevel.features.forEach {
|
||||||
|
eligibleFeatures.insert($0)
|
||||||
|
}
|
||||||
|
unrestrictedFeatures.forEach {
|
||||||
|
eligibleFeatures.insert($0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pp_log(.iap, .notice, "Finished reloading in-app receipt for user level \(userLevel)")
|
||||||
|
pp_log(.iap, .notice, "\tPurchased build number: \(purchasedAppBuild?.description ?? "unknown")")
|
||||||
|
pp_log(.iap, .notice, "\tPurchased products: \(purchasedProducts.map(\.rawValue))")
|
||||||
|
pp_log(.iap, .notice, "\tEligible features: \(eligibleFeatures)")
|
||||||
|
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Observation
|
// MARK: - Observation
|
||||||
|
|
||||||
private extension IAPManager {
|
private extension IAPManager {
|
||||||
func observeObjects() {
|
func observeObjects() {
|
||||||
Task {
|
Task {
|
||||||
await fetchLevelIfNeeded()
|
await fetchLevelIfNeeded()
|
||||||
|
await reloadReceipt()
|
||||||
do {
|
do {
|
||||||
let products = try await inAppHelper.fetchProducts()
|
let products = try await inAppHelper.fetchProducts()
|
||||||
pp_log(.app, .info, "Available in-app products: \(products.map(\.key))")
|
pp_log(.iap, .info, "Available in-app products: \(products.map(\.key))")
|
||||||
|
|
||||||
inAppHelper
|
inAppHelper
|
||||||
.didUpdate
|
.didUpdate
|
||||||
|
@ -235,7 +262,7 @@ private extension IAPManager {
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .error, "Unable to fetch in-app products: \(error)")
|
pp_log(.iap, .error, "Unable to fetch in-app products: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -246,11 +273,11 @@ private extension IAPManager {
|
||||||
}
|
}
|
||||||
if let customUserLevel {
|
if let customUserLevel {
|
||||||
userLevel = customUserLevel
|
userLevel = customUserLevel
|
||||||
pp_log(.app, .info, "App level (custom): \(userLevel)")
|
pp_log(.iap, .info, "App level (custom): \(userLevel)")
|
||||||
} else {
|
} else {
|
||||||
let isBeta = await SandboxChecker().isBeta
|
let isBeta = await SandboxChecker().isBeta
|
||||||
userLevel = isBeta ? .beta : .freemium
|
userLevel = isBeta ? .beta : .freemium
|
||||||
pp_log(.app, .info, "App level: \(userLevel)")
|
pp_log(.iap, .info, "App level: \(userLevel)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,8 @@ import PassepartoutWireGuardGo
|
||||||
|
|
||||||
extension LoggerDestination {
|
extension LoggerDestination {
|
||||||
public static let app = Self(category: "app")
|
public static let app = Self(category: "app")
|
||||||
|
|
||||||
|
public static let iap = Self(category: "iap")
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WireGuard.Configuration.Builder {
|
extension WireGuard.Configuration.Builder {
|
||||||
|
|
Loading…
Reference in New Issue