diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift index 4d90876f..92ba6572 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift @@ -33,6 +33,8 @@ public actor FallbackReceiptReader: AppReceiptReader { private let localReader: (URL) -> InAppReceiptReader? + private var pendingTask: Task? + public init( reader: (InAppReceiptReader & Sendable)?, localReader: @escaping @Sendable (URL) -> InAppReceiptReader & Sendable @@ -42,16 +44,30 @@ public actor FallbackReceiptReader: AppReceiptReader { } 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 - 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 let releaseReceipt: InAppReceipt? = await { guard userLevel == .beta, let localURL else { return nil } - pp_log(.app, .debug, "\tTestFlight, look for release receipt") + pp_log(.iap, .debug, "\tTestFlight, look for release receipt") let releaseURL = localURL .deletingLastPathComponent() .appendingPathComponent("receipt") @@ -67,7 +83,7 @@ public actor FallbackReceiptReader: AppReceiptReader { }() if let releaseReceipt { - pp_log(.app, .debug, "\tTestFlight, return release receipt") + pp_log(.iap, .debug, "\tTestFlight, return release receipt") return releaseReceipt } @@ -79,18 +95,18 @@ public actor FallbackReceiptReader: AppReceiptReader { } // 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 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) } - pp_log(.app, .debug, "\tReturn primary receipt without local build") + pp_log(.iap, .debug, "\tReturn primary receipt without local build") return receipt } // 3. fall back to local receipt - pp_log(.app, .debug, "\tReturn local receipt") + pp_log(.iap, .debug, "\tReturn local receipt") return await localReceiptBlock() } } diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift index b76de745..b54757bd 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift @@ -48,6 +48,8 @@ public final class IAPManager: ObservableObject { private var eligibleFeatures: Set + private var pendingReceiptTask: Task? + private var subscriptions: Set public init( @@ -81,7 +83,7 @@ extension IAPManager { inAppProducts[$0] } } catch { - pp_log(.app, .error, "Unable to fetch in-app products: \(error)") + pp_log(.iap, .error, "Unable to fetch in-app products: \(error)") return [] } } @@ -100,75 +102,14 @@ extension IAPManager { } public func reloadReceipt() async { - purchasedAppBuild = nil - purchasedProducts.removeAll() - 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!") + if let pendingReceiptTask { + await pendingReceiptTask.value } - - userLevel.features.forEach { - eligibleFeatures.insert($0) + pendingReceiptTask = Task { + await asyncReloadReceipt() } - unrestrictedFeatures.forEach { - eligibleFeatures.insert($0) - } - - 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() + await pendingReceiptTask?.value + pendingReceiptTask = nil } } @@ -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 private extension IAPManager { func observeObjects() { Task { await fetchLevelIfNeeded() + await reloadReceipt() do { 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 .didUpdate @@ -235,7 +262,7 @@ private extension IAPManager { .store(in: &subscriptions) } 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 { userLevel = customUserLevel - pp_log(.app, .info, "App level (custom): \(userLevel)") + pp_log(.iap, .info, "App level (custom): \(userLevel)") } else { let isBeta = await SandboxChecker().isBeta userLevel = isBeta ? .beta : .freemium - pp_log(.app, .info, "App level: \(userLevel)") + pp_log(.iap, .info, "App level: \(userLevel)") } } } diff --git a/Passepartout/Library/Sources/CommonLibrary/Shared.swift b/Passepartout/Library/Sources/CommonLibrary/Shared.swift index a5b260ad..829124ab 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Shared.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Shared.swift @@ -29,6 +29,8 @@ import PassepartoutWireGuardGo extension LoggerDestination { public static let app = Self(category: "app") + + public static let iap = Self(category: "iap") } extension WireGuard.Configuration.Builder {