Files
VoidLink/VoidLink/Utility/IAPManager.swift
T
True Zhuanjia 45ccfa0c7f Merge branch 'IAPImrovements' into Integration
# Conflicts:
#	VoidLink/Localization/Localizable.xcstrings
xiap button for aboutView
2026-06-22 00:57:49 +08:00

545 lines
19 KiB
Swift

//
// IAPManager.swift
// VoidLink
//
// Created by True砖家 on 2026/1/9.
// Copyright © 2026 True砖家@Bilibili. All rights reserved.
//
import Foundation
import StoreKit
import UIKit
@objc public enum PurchaseStatus: Int {
case notPurchased
case purchased
case revoked
}
@objc public enum PurchaseInterruption: Int {
case unlockNow
case restore
case learnMore
case lowOSVersion
}
@objc public class PurchaseInfo: NSObject {
@objc public let valid: Bool
@objc public let status: PurchaseStatus
@objc public let expirationDate: NSDate?
@objc public init(status: PurchaseStatus, expirationDate: NSDate?) {
self.status = status
self.expirationDate = expirationDate
self.valid = (status == .purchased)
&& (expirationDate?.timeIntervalSinceNow ?? .infinity > 0)
}
}
@objc public enum AddOnProduct: UInt8, CaseIterable {
case PencilProPack
func productId() -> String {
let bundleId = Bundle.main.bundleIdentifier ?? ""
switch (self, bundleId) {
case (.PencilProPack, "com.voidlink.iOS"):
return "com.pencilpro.voidlink.iOS"
case (.PencilProPack, "com.voidlinkextreme.iOS"):
return "com.pencilpro.voidlinkextreme.iOS"
case (.PencilProPack, "com.voidlink.tf.debug10.iOS"):
return "com.pencilpro.voidlink.debug.iOS"
case (.PencilProPack, "com.voidlink.tf.iOS"):
return ""
default:
return ""
}
}
func productName() -> String {
switch self {
case .PencilProPack:
return LocalizationHelper.localizedString(forKey: "Drawing Toolkit")
default:
return ""
}
}
func productURL() -> String {
switch self {
case .PencilProPack:
return LocalizationHelper.localizedString(forKey: "PencilProPackURL")
default:
return ""
}
}
func productDescription() -> String {
switch self {
case .PencilProPack:
return LocalizationHelper.localizedString(forKey: "PencilProPackDescription")
default:
return ""
}
}
func purchaseAbortedNotification() -> Notification.Name {
switch self {
case .PencilProPack:
return Notification.Name("PencilProPurchaseAbortedNotification")
default:
return Notification.Name("")
}
}
func purchaseSucceededNotification() -> Notification.Name {
switch self {
case .PencilProPack:
return Notification.Name("PencilProPurchaseSucceededNotification")
default:
return Notification.Name("")
}
}
static func from(productId: String) -> AddOnProduct? {
switch productId {
case "com.pencilpro.voidlink.iOS",
"com.pencilpro.voidlinkextreme.iOS",
"com.pencilpro.voidlink.debug.iOS",
"com.pencilpro.voidlink.tf.iOS":
return .PencilProPack
default:
return nil
}
}
}
@objc public protocol IAPManagerDelegate: NSObjectProtocol {
@objc func iapManagerDidFetchProducts(_ products: [String])
@objc func iapManagerDidFailWithError(_ error: Error)
@objc func iapManagerDidPurchase(_ product: AddOnProduct)
@objc func iapManagerDidRestore(_ product: AddOnProduct)
}
@objc public class IAPManager: NSObject {
@objc public static let shared = IAPManager()
@objc public weak var delegate: IAPManagerDelegate?
@objc public private(set) var fetchedProductIds: [String] = []
private override init() {
super.init()
if #available(iOS 15.0, *, *) {
Task {
await listenForTransactions()
}
}
if #available(iOS 16.4, *) {
Task {
await listenForPurchaseIntents()
}
}
}
// MARK: - Fetch Products (StoreKit 2)
@objc public func fetchProducts() {
if #available(iOS 15.0, *, *) {
Task {
await fetchProductsStoreKit2()
}
} else {
fetchProductsLegacy()
}
}
@available(iOS 15.0, *, *)
private func fetchProductsStoreKit2() async {
let ids = Set(AddOnProduct.allCases.map { $0.productId() })
do {
let sk2Products = try await Product.products(for: ids)
let idsFetched = sk2Products.map { $0.id }
await MainActor.run {
self.delegate?.iapManagerDidFetchProducts(idsFetched)
self.fetchedProductIds = idsFetched
}
} catch {
await MainActor.run {
self.delegate?.iapManagerDidFailWithError(error)
}
}
}
@objc public var skProducts: [SKProduct] = []
private var productsRequest: SKProductsRequest?
private func fetchProductsLegacy() {
let productIds = Set(AddOnProduct.allCases.map { $0.productId() })
guard productsRequest == nil else { return }
guard productIds.count>0 else { return }
fetchedProductIds = Array(productIds)
productsRequest = SKProductsRequest(
productIdentifiers: productIds
)
productsRequest?.delegate = self
productsRequest?.start()
}
// MARK: - Purchase (StoreKit 2)
@objc public func purchase(_ product: AddOnProduct) {
if #available(iOS 15.0, *, *) {
Task {
await purchaseStoreKit2(product)
}
} else {
purchaseLegacy(product)
}
}
@available(iOS 15.0, *, *)
private func purchaseStoreKit2(_ product: AddOnProduct) async {
guard IAPManager.canPurchase(product) else {
AlertControllerUtil.showAlert(
in: GenericUtils.topViewController(),
title: "In-app-purchase not available".localized,
message: "In-app-purchase not available on this device.".localized,
withCancel: false,
buttonTitle: "OK".localized,
countdown: 6
)
return
}
let pid = product.productId()
do {
let products = try await Product.products(for: [pid])
guard let sk2p = products.first else {
throw NSError(
domain: "IAPManager",
code: -2,
userInfo: [NSLocalizedDescriptionKey: "Product not found"]
)
}
let result = try await sk2p.purchase()
await handleStoreKit2PurchaseResult(result, product: product)
} catch {
await MainActor.run {
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue])
self.delegate?.iapManagerDidFailWithError(error)
}
}
}
private static func canPurchase(_ product: AddOnProduct) -> Bool {
switch product {
case .PencilProPack:
return GenericUtils.isIPad()
default:
return true
}
}
@available(iOS 15.0, *)
private func handleStoreKit2PurchaseResult(_ result: Product.PurchaseResult,
product: AddOnProduct) async {
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
await MainActor.run {
self.delegate?.iapManagerDidPurchase(product)
}
await transaction.finish()
IAPManager.handlePurchaseSuccess(product)
case .unverified(_, let err):
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue])
await MainActor.run {
self.delegate?.iapManagerDidFailWithError(err)
}
}
case .userCancelled:
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue])
default:
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue])
}
}
@objc static func handlePurchaseSuccess(_ product: AddOnProduct) {
switch product {
case .PencilProPack:
let dataMan = DataManager()
let settings = dataMan.retrieveSettings()
settings?.onscreenControls = 1
settings?.pencilTickMode = NSNumber(value: PencilTickMode.ManualTick.rawValue)
dataMan.saveData()
let profileMan = OSCProfilesManager.sharedManager(.zero)
var toolkitProfileIndex = profileMan.getIndex(byName: "Pencil Pro")
if toolkitProfileIndex == nil {profileMan.importDefaultTemplates()}
toolkitProfileIndex = profileMan.getIndex(byName: "Pencil Pro")
profileMan.setProfileToSelected(toolkitProfileIndex ?? 1)
default:
break
}
NotificationCenter.default.post(name: product.purchaseSucceededNotification(), object: nil)
}
private func purchaseLegacy(_ product: AddOnProduct) {
let productId = product.productId()
print("products \(skProducts.count)")
guard let skProduct = skProducts.first(
where: {
print("$0.productIdentifier \($0.productIdentifier), productId \(productId)")
return $0.productIdentifier == productId
}
) else { return }
let payment = SKPayment(product: skProduct)
SKPaymentQueue.default().add(payment)
}
// MARK: - Restore (StoreKit 2)
@objc public func restorePurchasesStoreKit2() {
if #available(iOS 15.0, *, *) {
Task {
await restoreStoreKit2()
}
}
else{
restorePurchasesLegacy()
}
}
@available(iOS 15.0, *, *)
private func restoreStoreKit2() async {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
let adp = AddOnProduct.from(productId: transaction.productID) {
await MainActor.run {
self.delegate?.iapManagerDidRestore(adp)
}
}
}
}
private func restorePurchasesLegacy() {
SKPaymentQueue.default().restoreCompletedTransactions()
}
// MARK: - Listen update
@available(iOS 15.0, *, *)
private func listenForTransactions() async {
for await verificationResult in Transaction.updates {
if case .verified(let transaction) = verificationResult,
let adp = AddOnProduct.from(productId: transaction.productID) {
await MainActor.run {
// 这里把支付成功回调出来
self.delegate?.iapManagerDidPurchase(adp)
}
await transaction.finish()
}
}
}
@available(iOS 16.4, *)
private func listenForPurchaseIntents() async {
for await intent in PurchaseIntent.intents {
guard let product = AddOnProduct.from(productId: intent.product.id) else {
continue
}
guard IAPManager.canPurchase(product) else {
continue
}
do {
let result = try await intent.product.purchase()
await handleStoreKit2PurchaseResult(result, product: product)
} catch {
await MainActor.run {
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue])
self.delegate?.iapManagerDidFailWithError(error)
}
}
}
}
// MARK: Utils
@objc static public func checkPurchaseInfo(
_ product: AddOnProduct,
completion: @escaping (PurchaseInfo) -> Void
) {
let productID = product.productId()
if #available(iOS 15.0, *) {
Task {
var status: PurchaseStatus = .notPurchased
var expiration: Date? = nil
for await verificationResult in Transaction.currentEntitlements {
if case .verified(let transaction) = verificationResult,
transaction.productID == productID {
// 有退款 / 撤销
if transaction.revocationDate != nil {
status = .revoked
} else {
status = .purchased
expiration = transaction.expirationDate
}
break
}
}
let info = PurchaseInfo(status: status, expirationDate: expiration as NSDate?)
await MainActor.run {
completion(info)
}
}
}
else {
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.lowOSVersion, userInfo:["interruption": PurchaseInterruption.lowOSVersion.rawValue])
}
}
@available(iOS 15.0, *)
static public func checkPurchaseInfo(
_ product: AddOnProduct
) async -> PurchaseInfo {
await withCheckedContinuation { continuation in
checkPurchaseInfo(product) { info in
continuation.resume(returning: info)
}
}
}
@objc static public func inAppPurchaseAction(viewController: UIViewController, product: AddOnProduct){
let alert = UIAlertController(title: product.productName(),
message: LocalizationHelper.localizedString(forKey: "No purchase found", product.productName()),
preferredStyle: .alert)
let unlockAction = UIAlertAction(title: LocalizationHelper.localizedString(forKey: "Purchase Now"), style: .default) { _ in
IAPManager.shared.purchase(product)
}
let restoreAction = UIAlertAction(title: LocalizationHelper.localizedString(forKey: "Restore Purchase"), style: .default) { _ in
if #available(iOS 13.0, *) {
Task{
do {
if #available(iOS 15.0, *) {
try await AppStore.sync()
IAPManager.checkPurchaseInfo(product) { info in
if info.valid {
IAPManager.handlePurchaseSuccess(product)
AlertControllerUtil.showAlert(
in: viewController,
title: "",
message: LocalizationHelper.localizedString(forKey:"[%@] has been restored", product.productName()),
withCancel: false,
buttonTitle: "OK".localized,
countdown: 0
)
}
else {
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.restore, userInfo:["interruption": PurchaseInterruption.restore.rawValue])
AlertControllerUtil.showAlert(
in: viewController,
title: "",
message: LocalizationHelper.localizedString(forKey:"Unable to detect purchased product: %@", product.productName()),
withCancel: false,
buttonTitle: "OK".localized,
countdown: 0
)
}
}
}
} catch {
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.restore, userInfo:["interruption": PurchaseInterruption.restore.rawValue])
print("Restore failed: \(error)")
}
}
} else {
// Fallback on earlier versions
}
}
let learnMoreAction = UIAlertAction(title: LocalizationHelper.localizedString(forKey: "Learn More"), style: .default) { _ in
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.learnMore, userInfo:["interruption": PurchaseInterruption.learnMore.rawValue])
GenericUtils.openUrl(product.productURL())
return
}
alert.addAction(unlockAction)
alert.addAction(restoreAction)
alert.addAction(learnMoreAction)
viewController.present(alert, animated: true, completion: {
})
}
@objc static public func inAppPurchaseURLMessage(viewController: UIViewController, product: AddOnProduct){
return
}
}
extension IAPManager: SKProductsRequestDelegate {
public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
skProducts = response.products
let productIds = Array(skProducts.map { $0.productIdentifier })
delegate?.iapManagerDidFetchProducts(productIds)
}
public func request(_ request: SKRequest, didFailWithError error: Error) {
delegate?.iapManagerDidFailWithError(error)
}
}
extension IAPManager: SKPaymentTransactionObserver {
public func paymentQueue(_ queue: SKPaymentQueue,
updatedTransactions transactions: [SKPaymentTransaction]) {
print("paymentQueue \(CACurrentMediaTime())")
for transaction in transactions {
let productId = transaction.payment.productIdentifier
guard let product = AddOnProduct.from(productId: productId) else {
SKPaymentQueue.default().finishTransaction(transaction)
continue
}
print("transaction.transactionState \(transaction.transactionState)")
switch transaction.transactionState {
case .purchased:
delegate?.iapManagerDidPurchase(product)
SKPaymentQueue.default().finishTransaction(transaction)
case .restored:
delegate?.iapManagerDidRestore(product)
SKPaymentQueue.default().finishTransaction(transaction)
case .failed:
if let error = transaction.error {
delegate?.iapManagerDidFailWithError(error)
}
SKPaymentQueue.default().finishTransaction(transaction)
default:
break
}
}
}
}