From 7d3c2d93acb0fe396beca084860d715399ae4943 Mon Sep 17 00:00:00 2001 From: True Zhuanjia Date: Sat, 20 Jun 2026 22:56:04 +0800 Subject: [PATCH] add support for purchase intents --- VoidLink/AppDelegate.m | 1 + VoidLink/Localization/Localizable.xcstrings | 48 +++++++++- VoidLink/Utility/GenericUtils.swift | 38 ++++++++ VoidLink/Utility/IAPManager.swift | 100 +++++++++++++++----- 4 files changed, 162 insertions(+), 25 deletions(-) diff --git a/VoidLink/AppDelegate.m b/VoidLink/AppDelegate.m index 6339fd32..9f2bda4f 100644 --- a/VoidLink/AppDelegate.m +++ b/VoidLink/AppDelegate.m @@ -61,6 +61,7 @@ static NSString* DB_NAME = @"Limelight_iOS.sqlite"; // Override point for command tool customization after application launch (works only when user default is nil) [CommandManager presetDefaultCommands]; [GenericUtils installSegmentedControlPreviousSelectionTracking]; + [IAPManager shared]; // For iOS 12 and below, we need to manually create the window if (@available(iOS 13.0, *)) { diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index 6a52a38f..68548f33 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -9131,6 +9131,52 @@ } } }, + "In-app-purchase not available" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法使用 App 内购买" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法使用 App 內購買" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法使用 App 內購買" + } + } + } + }, + "In-app-purchase not available on this device." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备无法使用该 App 内购买项目。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置無法使用該 App 內購買項目。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置無法使用該 App 內購買項目。" + } + } + } + }, "Indicator offset: %.0f" : { "extractionState" : "manual", "localizations" : { @@ -17289,4 +17335,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/VoidLink/Utility/GenericUtils.swift b/VoidLink/Utility/GenericUtils.swift index 0e1ba9a9..5993297f 100644 --- a/VoidLink/Utility/GenericUtils.swift +++ b/VoidLink/Utility/GenericUtils.swift @@ -702,6 +702,44 @@ import UIKit } return nil } + + @objc static func rootViewController() -> UIViewController? { + if #available(iOS 13.0, *) { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .windows + .first { $0.isKeyWindow }? + .rootViewController + } else { + return UIApplication.shared.keyWindow?.rootViewController + } + } + + @objc static func topViewController() -> UIViewController? { + return topViewController(from: rootViewController()) + } + + private static func topViewController(from rootViewController: UIViewController?) -> UIViewController? { + if let navigationController = rootViewController as? UINavigationController { + return topViewController(from: navigationController.visibleViewController) + } + + if let tabBarController = rootViewController as? UITabBarController { + return topViewController(from: tabBarController.selectedViewController) + } + + if let splitViewController = rootViewController as? UISplitViewController, + let lastViewController = splitViewController.viewControllers.last { + return topViewController(from: lastViewController) + } + + if let presentedViewController = rootViewController?.presentedViewController { + return topViewController(from: presentedViewController) + } + + return rootViewController + } } extension UIView { diff --git a/VoidLink/Utility/IAPManager.swift b/VoidLink/Utility/IAPManager.swift index 28acc4d4..06db4cd7 100644 --- a/VoidLink/Utility/IAPManager.swift +++ b/VoidLink/Utility/IAPManager.swift @@ -8,6 +8,7 @@ import Foundation import StoreKit +import UIKit @objc public enum PurchaseStatus: Int { case notPurchased @@ -135,6 +136,11 @@ import StoreKit await listenForTransactions() } } + if #available(iOS 16.4, *) { + Task { + await listenForPurchaseIntents() + } + } } // MARK: - Fetch Products (StoreKit 2) @@ -199,6 +205,18 @@ import StoreKit @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]) @@ -211,30 +229,7 @@ import StoreKit } let result = try await sk2p.purchase() - - 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) - NotificationCenter.default.post(name: product.purchaseSucceededNotification(), object: nil) - 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]) - break - default: - NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) - break - } + await handleStoreKit2PurchaseResult(result, product: product) } catch { await MainActor.run { NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) @@ -243,6 +238,41 @@ import StoreKit } } + 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) + NotificationCenter.default.post(name: product.purchaseSucceededNotification(), object: nil) + 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: @@ -318,6 +348,28 @@ import StoreKit } } } + + @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(