mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2024-12-24 02:21:00 +00:00
21340e9f56
Loading remote profiles before local profiles may cause duplicated NE managers. This happened because if local profiles are empty, any remote profile is imported regardless of their former existence in the local store. The importer just doesn't know. Therefore, revisit the sequence of AppContext registrations: - First off - Skip Tunnel prepare() because NEProfileRepository.fetch() does it already - NE is both Tunnel and ProfileRepository, so calling tunnel.prepare() loads local NE profiles twice - onLaunch() - **run this once and before anything else** - Read local profiles - Reload in-app receipt - Observe in-app eligibility → Triggers onEligibleFeatures() - Observe profile save → Triggers onSaveProfile() - Fetch providers index - onForeground() - Read local profiles - Read remote profiles, and toggle CloudKit sync based on eligibility - onEligibleFeatures() - Read remote profiles, and toggle CloudKit sync based on eligibility - onSaveProfile() - Reconnect if necessary
290 lines
9.3 KiB
Swift
290 lines
9.3 KiB
Swift
//
|
|
// IAPManager.swift
|
|
// Passepartout
|
|
//
|
|
// Created by Davide De Rosa on 9/10/24.
|
|
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
|
//
|
|
// https://github.com/passepartoutvpn
|
|
//
|
|
// This file is part of Passepartout.
|
|
//
|
|
// Passepartout is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// Passepartout is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
|
//
|
|
|
|
import Combine
|
|
import CommonUtils
|
|
import Foundation
|
|
import PassepartoutKit
|
|
|
|
@MainActor
|
|
public final class IAPManager: ObservableObject {
|
|
private let customUserLevel: AppUserLevel?
|
|
|
|
private let inAppHelper: any AppProductHelper
|
|
|
|
private let receiptReader: AppReceiptReader
|
|
|
|
private let unrestrictedFeatures: Set<AppFeature>
|
|
|
|
private let productsAtBuild: BuildProducts<AppProduct>?
|
|
|
|
private(set) var userLevel: AppUserLevel
|
|
|
|
private var purchasedAppBuild: Int?
|
|
|
|
public private(set) var purchasedProducts: Set<AppProduct>
|
|
|
|
@Published
|
|
public private(set) var eligibleFeatures: Set<AppFeature>
|
|
|
|
private var pendingReceiptTask: Task<Void, Never>?
|
|
|
|
private var subscriptions: Set<AnyCancellable>
|
|
|
|
public init(
|
|
customUserLevel: AppUserLevel? = nil,
|
|
inAppHelper: any AppProductHelper,
|
|
receiptReader: AppReceiptReader,
|
|
unrestrictedFeatures: Set<AppFeature> = [],
|
|
productsAtBuild: BuildProducts<AppProduct>? = nil
|
|
) {
|
|
self.customUserLevel = customUserLevel
|
|
self.inAppHelper = inAppHelper
|
|
self.receiptReader = receiptReader
|
|
self.unrestrictedFeatures = unrestrictedFeatures
|
|
self.productsAtBuild = productsAtBuild
|
|
userLevel = .undefined
|
|
purchasedProducts = []
|
|
eligibleFeatures = []
|
|
subscriptions = []
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
extension IAPManager {
|
|
public func purchasableProducts(for products: [AppProduct]) async throws -> [InAppProduct] {
|
|
do {
|
|
let inAppProducts = try await inAppHelper.fetchProducts()
|
|
return products.compactMap {
|
|
inAppProducts[$0]
|
|
}
|
|
} catch {
|
|
pp_log(.App.iap, .error, "Unable to fetch in-app products: \(error)")
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public func purchase(_ purchasableProduct: InAppProduct) async throws -> InAppPurchaseResult {
|
|
let result = try await inAppHelper.purchase(purchasableProduct)
|
|
if result == .done {
|
|
await reloadReceipt()
|
|
}
|
|
return result
|
|
}
|
|
|
|
public func restorePurchases() async throws {
|
|
try await inAppHelper.restorePurchases()
|
|
await reloadReceipt()
|
|
}
|
|
|
|
public func reloadReceipt() async {
|
|
if let pendingReceiptTask {
|
|
await pendingReceiptTask.value
|
|
}
|
|
pendingReceiptTask = Task {
|
|
await fetchLevelIfNeeded()
|
|
await asyncReloadReceipt()
|
|
}
|
|
await pendingReceiptTask?.value
|
|
pendingReceiptTask = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Eligibility
|
|
|
|
extension IAPManager {
|
|
public var isRestricted: Bool {
|
|
userLevel.isRestricted
|
|
}
|
|
|
|
public func isEligible(for feature: AppFeature) -> Bool {
|
|
eligibleFeatures.contains(feature)
|
|
}
|
|
|
|
public func isEligible(for features: [AppFeature]) -> Bool {
|
|
features.allSatisfy(eligibleFeatures.contains)
|
|
}
|
|
|
|
public func isEligible(forProvider providerId: ProviderID) -> Bool {
|
|
if providerId == .oeck {
|
|
return true
|
|
}
|
|
return isEligible(for: .providers)
|
|
}
|
|
|
|
public func isEligibleForFeedback() -> Bool {
|
|
#if os(tvOS)
|
|
false
|
|
#else
|
|
userLevel == .beta || isPayingUser()
|
|
#endif
|
|
}
|
|
|
|
public func paywallReason(forFeature feature: AppFeature, suggesting product: AppProduct?) -> PaywallReason? {
|
|
if isEligible(for: feature) {
|
|
return nil
|
|
}
|
|
return isRestricted ? .restricted : .purchase(feature, product)
|
|
}
|
|
|
|
public func isPayingUser() -> Bool {
|
|
!purchasedProducts.isEmpty
|
|
}
|
|
}
|
|
|
|
// MARK: - Receipt
|
|
|
|
private extension IAPManager {
|
|
func asyncReloadReceipt() async {
|
|
pp_log(.App.iap, .notice, "Start reloading in-app receipt...")
|
|
|
|
var purchasedAppBuild: Int?
|
|
var purchasedProducts: Set<AppProduct> = []
|
|
var eligibleFeatures: Set<AppFeature> = []
|
|
|
|
if let receipt = await receiptReader.receipt(at: userLevel) {
|
|
if let originalBuildNumber = receipt.originalBuildNumber {
|
|
purchasedAppBuild = originalBuildNumber
|
|
}
|
|
|
|
if let purchasedAppBuild {
|
|
pp_log(.App.iap, .info, "Original purchased build: \(purchasedAppBuild)")
|
|
|
|
// assume some purchases by build number
|
|
let entitled = productsAtBuild?(purchasedAppBuild) ?? []
|
|
pp_log(.App.iap, .notice, "Entitled features: \(entitled.map(\.rawValue))")
|
|
|
|
entitled.forEach {
|
|
purchasedProducts.insert($0)
|
|
}
|
|
}
|
|
if let iapReceipts = receipt.purchaseReceipts {
|
|
pp_log(.App.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(.App.iap, .debug, "\tDiscard unknown product identifier: \(pid)")
|
|
return nil
|
|
}
|
|
if let expirationDate = $0.expirationDate {
|
|
let now = Date()
|
|
pp_log(.App.iap, .debug, "\t\(pid) [expiration date: \(expirationDate), now: \(now)]")
|
|
if now >= expirationDate {
|
|
pp_log(.App.iap, .info, "\t\(pid) [expired on: \(expirationDate)]")
|
|
return nil
|
|
}
|
|
}
|
|
if let cancellationDate = $0.cancellationDate {
|
|
pp_log(.App.iap, .info, "\t\(pid) [cancelled on: \(cancellationDate)]")
|
|
return nil
|
|
}
|
|
if let purchaseDate = $0.originalPurchaseDate {
|
|
pp_log(.App.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(.App.iap, .error, "Could not parse App Store receipt!")
|
|
}
|
|
|
|
userLevel.features.forEach {
|
|
eligibleFeatures.insert($0)
|
|
}
|
|
unrestrictedFeatures.forEach {
|
|
eligibleFeatures.insert($0)
|
|
}
|
|
|
|
pp_log(.App.iap, .notice, "Finished reloading in-app receipt for user level \(userLevel)")
|
|
pp_log(.App.iap, .notice, "\tPurchased build number: \(purchasedAppBuild?.description ?? "unknown")")
|
|
pp_log(.App.iap, .notice, "\tPurchased products: \(purchasedProducts.map(\.rawValue))")
|
|
pp_log(.App.iap, .notice, "\tEligible features: \(eligibleFeatures)")
|
|
|
|
self.purchasedAppBuild = purchasedAppBuild
|
|
self.purchasedProducts = purchasedProducts
|
|
self.eligibleFeatures = eligibleFeatures // @Published -> objectWillChange.send()
|
|
}
|
|
}
|
|
|
|
// MARK: - Observation
|
|
|
|
extension IAPManager {
|
|
public func observeObjects() {
|
|
Task {
|
|
await fetchLevelIfNeeded()
|
|
do {
|
|
let products = try await inAppHelper.fetchProducts()
|
|
pp_log(.App.iap, .info, "Available in-app products: \(products.map(\.key))")
|
|
|
|
inAppHelper
|
|
.didUpdate
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] in
|
|
Task {
|
|
await self?.reloadReceipt()
|
|
}
|
|
}
|
|
.store(in: &subscriptions)
|
|
|
|
} catch {
|
|
pp_log(.App.iap, .error, "Unable to fetch in-app products: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension IAPManager {
|
|
func fetchLevelIfNeeded() async {
|
|
guard userLevel == .undefined else {
|
|
return
|
|
}
|
|
if let customUserLevel {
|
|
userLevel = customUserLevel
|
|
pp_log(.App.iap, .info, "App level (custom): \(userLevel)")
|
|
} else {
|
|
let isBeta = await SandboxChecker().isBeta
|
|
guard userLevel == .undefined else {
|
|
return
|
|
}
|
|
userLevel = isBeta ? .beta : .freemium
|
|
pp_log(.App.iap, .info, "App level: \(userLevel)")
|
|
}
|
|
}
|
|
}
|