# Conflicts: # VoidLink/Localization/Localizable.xcstrings xiap button for aboutView
545 lines
19 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|