//
// StoreKitHelper.swift
// Passepartout
//
// Created by Davide De Rosa on 9/9/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 .
//
import Combine
import Foundation
import StoreKit
@MainActor
public final class StoreKitHelper: InAppHelper where ProductType: RawRepresentable & Hashable,
ProductType.RawValue == String {
private let products: [ProductType]
private let inAppIdentifier: (ProductType) -> String
private var nativeProducts: [ProductType: InAppProduct]
private var activeTransactions: Set
private nonisolated let didUpdateSubject: PassthroughSubject
private var observer: Task?
public init(products: [ProductType], inAppIdentifier: @escaping (ProductType) -> String) {
self.products = products
self.inAppIdentifier = inAppIdentifier
nativeProducts = [:]
activeTransactions = []
didUpdateSubject = PassthroughSubject()
observer = transactionsObserverTask()
}
deinit {
observer?.cancel()
}
}
extension StoreKitHelper {
public nonisolated var canMakePurchases: Bool {
AppStore.canMakePayments
}
public nonisolated var didUpdate: AnyPublisher {
didUpdateSubject.eraseToAnyPublisher()
}
public func fetchProducts() async throws -> [ProductType: InAppProduct] {
if !nativeProducts.isEmpty {
return nativeProducts
}
let skProducts = try await Product.products(for: products.map(inAppIdentifier))
nativeProducts = skProducts.reduce(into: [:]) {
guard let pid = ProductType(rawValue: $1.id) else {
return
}
$0[pid] = InAppProduct(
productIdentifier: $1.id,
localizedTitle: $1.displayName,
localizedPrice: $1.displayPrice,
native: $1
)
}
return nativeProducts
}
public func purchase(_ inAppProduct: InAppProduct) async throws -> InAppPurchaseResult {
guard let skProduct = inAppProduct.native as? Product else {
return .notFound
}
switch try await skProduct.purchase() {
case .success(let verificationResult):
guard let transaction = try? verificationResult.payloadValue else {
break
}
activeTransactions.insert(transaction)
didUpdateSubject.send()
await transaction.finish()
return .done
case .pending:
return .pending
case .userCancelled:
break
@unknown default:
break
}
return .cancelled
}
public func restorePurchases() async throws {
try await AppStore.sync()
}
}
private extension StoreKitHelper {
nonisolated func transactionsObserverTask() -> Task {
Task {
for await update in Transaction.updates {
guard let transaction = try? update.payloadValue else {
continue
}
await fetchActiveTransactions()
await transaction.finish()
guard !Task.isCancelled else {
break
}
}
}
}
func fetchActiveTransactions() async {
var activeTransactions: Set = []
for await entitlement in Transaction.currentEntitlements {
if let transaction = try? entitlement.payloadValue {
activeTransactions.insert(transaction)
}
}
self.activeTransactions = activeTransactions
didUpdateSubject.send()
}
}