From 7d3c2d93acb0fe396beca084860d715399ae4943 Mon Sep 17 00:00:00 2001 From: True Zhuanjia Date: Sat, 20 Jun 2026 22:56:04 +0800 Subject: [PATCH 1/2] 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( From d6c1059e997274c8ade3fe7d113612d9333933bd Mon Sep 17 00:00:00 2001 From: True Zhuanjia Date: Mon, 22 Jun 2026 00:53:01 +0800 Subject: [PATCH 2/2] improved iap workflow --- VoidLink/Input/PencilHandler.swift | 9 +- VoidLink/Localization/Localizable.xcstrings | 85 +++++++++++++++++-- VoidLink/Utility/IAPManager.swift | 29 ++++++- .../ViewControllers/SettingsViewController.m | 1 + 4 files changed, 109 insertions(+), 15 deletions(-) diff --git a/VoidLink/Input/PencilHandler.swift b/VoidLink/Input/PencilHandler.swift index d907bb6a..cd198fff 100644 --- a/VoidLink/Input/PencilHandler.swift +++ b/VoidLink/Input/PencilHandler.swift @@ -260,17 +260,18 @@ import UIKit let strokePhase = getStrokePhase(sampleIndex: self.strokeSampleIndex) + let forceMapping = pressureCurveEnabled ? self.strokeLUT.value(at: force) : force switch strokePhase { case .phase1: - targetForce = pencilTickEnabled ? 0 : self.strokeLUT.value(at: force) + targetForce = pencilTickEnabled ? 0 : forceMapping case .phase2: - targetForce = self.strokeLUT.value(at: force) + targetForce = forceMapping targetForce = max(targetForce,previousTargetForce) equalizedForce = targetForce*(phase2EqualizationStrength - equalizationStep*Float(strokeSampleIndex-phase1StrokeSampleIndexEnd-1)) case .phase3: - targetForce = self.strokeLUT.value(at: force) + targetForce = forceMapping } - targetForce = self.pressureCurveEnabled ? targetForce : force + targetForce = pencilTickEnabled ? targetForce : force previousForce = force previousTargetForce = targetForce diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index 68548f33..3512d9fb 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -513,6 +513,29 @@ } } }, + "[%@] has been restored" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "【%@】已恢复" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」已恢復" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」已恢復" + } + } + } + }, "%@ is currently running" : { "extractionState" : "manual", "localizations" : { @@ -715,7 +738,7 @@ }, "zh-HK" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "方向鍵" } } @@ -6612,19 +6635,42 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "绘画" + "value" : "绘画进阶套件" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "繪畫" + "value" : "繪畫進階套件" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "繪畫" + "value" : "繪畫進階套件" + } + } + } + }, + "Drawing Toolkit already purchased" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您已拥有【绘画进阶套件】" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "您已擁有「繪畫進階套件」" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "您已擁有「繪畫進階套件」" } } } @@ -10994,19 +11040,19 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "购买 [%@] 可解锁:\n- 专业级压力曲线工具。\n- 在远程绘图流程中提升笔触传输效率与响应表现,并支持用户自定义的双击及捏合键盘/鼠标快捷键。\n- 专属绘图相关的屏显按钮。\n以及更多功能..." + "value" : "购买 [%@] 可解锁:\n- 专业级笔压曲线工具。\n- 远程绘画笔迹传输、笔触响应性能优化。\n- 支持用户自定义的双击及捏合键盘/鼠标快捷键。\n- 专属绘图相关的屏显按钮。\n以及更多...\n未购买该套件不影响基础绘画功能。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "解鎖 [%@] :\n- 專業級壓力曲線工具。\n- 在遠端繪圖流程中提升筆觸傳輸效率與響應表現,並支援使用者自訂的雙擊及捏合鍵盤/滑鼠快捷鍵。\n- 專屬繪圖相關的螢幕顯示按鈕。\n- 以及更多功能..." + "value" : "解鎖 [%@] :\n- 專業級筆壓曲線工具。\n- 最佳化遠端繪畫的筆跡傳輸與筆觸響應效能。\n- 並支援使用者自訂的雙擊及捏合鍵盤/滑鼠快捷鍵。\n- 專屬繪圖相關的螢幕顯示按鈕。\n以及更多...\n未購買此套件不影響基礎繪畫功能。" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "解鎖 [%@] :\n- 專業級壓力曲線工具。\n- 在遠端繪圖流程中提升筆觸傳輸效率與響應表現,並支援使用者自訂的雙擊及捏合鍵盤/滑鼠快捷鍵。\n- 專屬繪圖相關的螢幕顯示按鈕。\n- 以及更多功能...\n基礎繪畫功能無需購買 [%@] 即可使用。" + "value" : "解鎖 [%@] :\n- 專業級筆壓曲線工具。\n- 最佳化遠端繪畫的筆跡傳輸與筆觸響應效能。\n- 並支援使用者自訂的雙擊及捏合鍵盤/滑鼠快捷鍵。\n- 專屬繪圖相關的螢幕顯示按鈕。\n以及更多...\n未購買此套件不影響基礎繪畫功能。" } } } @@ -16376,6 +16422,29 @@ } } }, + "Unable to detect purchased product: %@" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未检测到已购产品【%@】" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未偵測到已購產品「%@」" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "未偵測到已購產品「%@」" + } + } + } + }, "Undo" : { "extractionState" : "manual", "localizations" : { @@ -17335,4 +17404,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/VoidLink/Utility/IAPManager.swift b/VoidLink/Utility/IAPManager.swift index 06db4cd7..9b1768c1 100644 --- a/VoidLink/Utility/IAPManager.swift +++ b/VoidLink/Utility/IAPManager.swift @@ -259,7 +259,6 @@ import UIKit } 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 { @@ -279,6 +278,7 @@ import UIKit 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") @@ -288,6 +288,7 @@ import UIKit default: break } + NotificationCenter.default.post(name: product.purchaseSucceededNotification(), object: nil) } private func purchaseLegacy(_ product: AddOnProduct) { @@ -437,8 +438,30 @@ import UIKit do { if #available(iOS 15.0, *) { try await AppStore.sync() - } else { - // Fallback on earlier versions + 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]) diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index c5b7ff1e..6d945afd 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -3847,6 +3847,7 @@ BOOL isCustomResolution(int resolutionSelected) { - (void)pencilProPurchaseSucceeded:(NSNotification *)notification{ self.onScreenWidgetSelector.selectedSegmentIndex = OnScreenControlsLevelCustom; + self.pencilTickSelector.selectedSegmentIndex = ManualTick; } - (void)pencilTickIntervalSliderMoved:(UISlider* )sender{