diff --git a/VoidLink.xcodeproj/project.pbxproj b/VoidLink.xcodeproj/project.pbxproj index 4011d015..c10a1283 100644 --- a/VoidLink.xcodeproj/project.pbxproj +++ b/VoidLink.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 18416EA42EBB3DCE00AE0124 /* UITouchUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18416EA32EBB3DCE00AE0124 /* UITouchUtil.swift */; }; 1850C82A2E667E4000845308 /* MicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850C8292E667E4000845308 /* MicHandler.swift */; }; 18729EDE2E8BD68800101194 /* UUIDHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18729EDD2E8BD68800101194 /* UUIDHelper.swift */; }; + 18780CC72F10D66000B90483 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 18780CC62F10D66000B90483 /* StoreKit.framework */; }; + 18780CCA2F10D6CC00B90483 /* IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18780CC92F10D6CC00B90483 /* IAPManager.swift */; }; 187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */; }; 1888C1922E24C33100108730 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1912E24C33100108730 /* AboutViewController.swift */; }; 1888C1932E24C41000108730 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1902E24C2C800108730 /* AboutView.swift */; }; @@ -179,6 +181,8 @@ 18416EA32EBB3DCE00AE0124 /* UITouchUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITouchUtil.swift; sourceTree = ""; }; 1850C8292E667E4000845308 /* MicHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicHandler.swift; sourceTree = ""; }; 18729EDD2E8BD68800101194 /* UUIDHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDHelper.swift; sourceTree = ""; }; + 18780CC62F10D66000B90483 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; + 18780CC92F10D6CC00B90483 /* IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPManager.swift; sourceTree = ""; }; 187F4B232E04633100EECC59 /* WidgetPanelStackView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetPanelStackView.h; sourceTree = ""; }; 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WidgetPanelStackView.m; sourceTree = ""; }; 1888C1902E24C2C800108730 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -433,6 +437,7 @@ F74BEF9C2C1A705600224667 /* OpenSSL in Frameworks */, 98882A072AF60F7200C5A11C /* libavformat.a in Frameworks */, FB290CF619B2C406004C83CF /* UIKit.framework in Frameworks */, + 18780CC72F10D66000B90483 /* StoreKit.framework in Frameworks */, FB290CF219B2C406004C83CF /* Foundation.framework in Frameworks */, FB4679011A57048000377732 /* CoreFoundation.framework in Frameworks */, 182760FA2E83E57D00EC1C5C /* DequeModule in Frameworks */, @@ -600,6 +605,7 @@ FB290CF019B2C406004C83CF /* Frameworks */ = { isa = PBXGroup; children = ( + 18780CC62F10D66000B90483 /* StoreKit.framework */, 98882A032AF60F5300C5A11C /* libavcodec.a */, 98882A052AF60F5300C5A11C /* libavformat.a */, 98882A0A2AF60F9000C5A11C /* libavcodec.a */, @@ -782,6 +788,7 @@ children = ( 18729EDD2E8BD68800101194 /* UUIDHelper.swift */, 18A0EE2B2EFBCCFD00BA853A /* GraphicUtils.swift */, + 18780CC92F10D6CC00B90483 /* IAPManager.swift */, 97E20DD42E2164A9000D5DCA /* BandwidthTracker.h */, 97E20DD52E2164A9000D5DCA /* BandwidthTracker.m */, 97E20DD62E2164A9000D5DCA /* FloatBuffer.h */, @@ -1142,6 +1149,7 @@ 18341A042DEB662C0094E5F2 /* FixedTintImageView.m in Sources */, 18416EA42EBB3DCE00AE0124 /* UITouchUtil.swift in Sources */, FB89463119F646E200339C8A /* StreamManager.m in Sources */, + 18780CCA2F10D6CC00B90483 /* IAPManager.swift in Sources */, 84351B2D2C29AD6100A77989 /* ToolBarContainerView.m in Sources */, 18E4BCEA2E891895000A8657 /* MotionHandler.swift in Sources */, 988FCD41293B091B003050E2 /* KeyboardInputField.m in Sources */, diff --git a/VoidLink/Base.lproj/iPad.storyboard b/VoidLink/Base.lproj/iPad.storyboard index aff9018a..c82ad121 100644 --- a/VoidLink/Base.lproj/iPad.storyboard +++ b/VoidLink/Base.lproj/iPad.storyboard @@ -1,9 +1,9 @@ - + - + @@ -1899,7 +1899,7 @@ - + diff --git a/VoidLink/Input/PencilHandler.swift b/VoidLink/Input/PencilHandler.swift index d36c7d0b..b1657b62 100644 --- a/VoidLink/Input/PencilHandler.swift +++ b/VoidLink/Input/PencilHandler.swift @@ -40,6 +40,14 @@ import UIKit let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero) let oscProfile = oscProfileMan.getSelectedProfile() pressureCurveEnabled = oscProfile.pressureCurveEnabled + + if #available(iOS 15.0, *) { + IAPManager.checkPurchaseInfo(.PencilProPack) { info in + self.pressureCurveEnabled = self.pressureCurveEnabled && info.valid + self.pencilTickEnabled = self.pencilTickEnabled && info.valid + } + } + let persistedCurvePoints = PressureCurve.importCurvePoints(oscProfile.pressureCurvePoints) let curve = PressureCurve() curve.polylinePoints = persistedCurvePoints diff --git a/VoidLink/Input/PressureCurve.swift b/VoidLink/Input/PressureCurve.swift index d43bb42c..8014f628 100644 --- a/VoidLink/Input/PressureCurve.swift +++ b/VoidLink/Input/PressureCurve.swift @@ -288,7 +288,7 @@ final class PressureCurveView: UIView { private func drawCurve(_ ctx: CGContext) { ctx.setStrokeColor(UIColor.systemBlue.cgColor) - ctx.setLineWidth(3) + ctx.setLineWidth(5) let points = curve.sampleCurve(stepsPerSegment: 60) for (i,p) in points.enumerated() { let mapped = map(p) @@ -300,7 +300,7 @@ final class PressureCurveView: UIView { private func drawTangents(_ ctx: CGContext) { let pts = curve.tangentPoints ctx.setStrokeColor(UIColor.systemPurple.cgColor) - ctx.setLineWidth(1.5) + ctx.setLineWidth(2.5) for i in 0.., with event: UIEvent?) { - - if testStage == .drawingStage { let altitude = touches.first!.altitudeAngle let force = (touches.first!.force/touches.first!.maximumPossibleForce)/sin(altitude) pressures.append(force) return } - guard let touch = touches.first, @@ -519,6 +516,19 @@ class PressureCurveViewController: UIViewController { curveView.widthAnchor.constraint(equalToConstant: curveWidth), curveView.heightAnchor.constraint(equalToConstant: curveHeight) ]) + + NotificationCenter.default.addObserver( + self, + selector: #selector(pencilProPurchaseAborted(_:)), + name: AddOnProduct.PencilProPack.purchaseAbortedNotification(), + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(persistCurve), + name: AddOnProduct.PencilProPack.purchaseSucceededNotification(), + object: nil + ) } override func viewDidAppear(_ animated: Bool) { @@ -547,6 +557,10 @@ class PressureCurveViewController: UIViewController { } + override func viewWillDisappear(_ animated: Bool) { + NotificationCenter.default.removeObserver(self) + } + private func loadPersistedCurve() { let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero) let persistedCurvePoints = PressureCurve.importCurvePoints(oscProfileMan.getSelectedProfile().pressureCurvePoints) @@ -618,7 +632,16 @@ class PressureCurveViewController: UIViewController { @objc private func resetTapped() { curveView.curve.polylinePoints = [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 1)] - curveView.setNeedsDisplay() + DispatchQueue.main.async { + self.curveView.setNeedsDisplay() + } + } + + @objc private func pencilProPurchaseAborted(_ notification: Notification) { + guard let interruption = notification.object as? PurchaseInterruption else { return } + if interruption != .learnMore { + resetTapped() + } } @objc private func pressureRangeTest() { @@ -636,25 +659,41 @@ class PressureCurveViewController: UIViewController { self.setupNavigationBar() }) } - - @objc private func saveTapped() { - + + @objc private func persistCurve() { let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero) let selectedProfile = oscProfileMan.getSelectedProfile() selectedProfile.pressureCurvePoints = PressureCurve.exportCurvePoints(curveView.curve) oscProfileMan.replaceSelectedProfile(with: selectedProfile, overwriteDefault: true) - AlertControllerUtil.showAlert( - in: self, - title: SwiftLocalizationHelper.localizedString(forKey: "Pen Pressure Curve"), - message: SwiftLocalizationHelper.localizedString(forKey:"Pressure curve saved with current on-screen widget profile!"), - withCancel: false, - buttonTitle: SwiftLocalizationHelper.localizedString(forKey: "OK"), - countdown: 0, - completion: { - PencilHandler.sharedInstance?.setupPressureLUT() + DispatchQueue.main.async { + AlertControllerUtil.showAlert( + in: self, + title: SwiftLocalizationHelper.localizedString(forKey: "Pen Pressure Curve"), + message: SwiftLocalizationHelper.localizedString(forKey:"Pressure curve saved with current on-screen widget profile!"), + withCancel: false, + buttonTitle: SwiftLocalizationHelper.localizedString(forKey: "OK"), + countdown: 0, + completion: { + PencilHandler.sharedInstance?.setupPressureLUT() + }) + } + } + + @objc private func saveTapped() { + if #available(iOS 15.0, *) { + IAPManager.checkPurchaseInfo(.PencilProPack) { info in + if info.valid { + self.persistCurve() + } + else{ + IAPManager.inAppPurchaseAction(viewController: self, product: .PencilProPack) + } } - ) + } + else { + return + } } @objc private func exitTapped() { diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index e45ae284..f14ef6aa 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -3085,22 +3085,28 @@ "Host information updated" : { "extractionState" : "manual", "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The address has been added to the host. You won’t need to enter it manually next time." + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "主机信息已更新" + "value" : "已为主机添加地址,下次无须手动输入该地址。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "主機資訊已更新" + "value" : "已為主機新增位址,下次無須手動輸入該位址。" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "主機資訊已更新" + "value" : "已為主機新增位址,下次無須手動輸入該位址。" } } } @@ -3381,6 +3387,29 @@ } } }, + "Learn More" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "了解更多" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "了解更多" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "了解更多" + } + } + } + }, "left" : { "extractionState" : "manual", "localizations" : { @@ -4102,6 +4131,35 @@ } } }, + "No purchase found" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No purchase found for [%@]. If you’ve already purchased it, please try Restore Purchase." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到「%@」的购买记录。如果您此前已购买,请尝试「恢复购买」。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未偵測到「%@」的購買紀錄。若您先前已購買,請嘗試「回復購買」。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "未偵測到「%@」的購買紀錄。若您先前已購買,請嘗試「回復購買」。" + } + } + } + }, "No video received from host." : { "extractionState" : "manual", "localizations" : { @@ -4741,6 +4799,61 @@ } } }, + "Pencil Pro Pack" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pencil 专业套装" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pencil 專業套裝" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pencil 專業套裝" + } + } + } + }, + "PencilProPackDescription" : { + "extractionState" : "manual" + }, + "PencilProPackURL" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://voidlink.yuque.com/org-wiki-voidlink-znirha/fa3tgr/vrwqg8xco75n9c9z" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://voidlink.yuque.com/org-wiki-voidlink-znirha/fa3tgr/sl96pxao4a0oid1x" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://voidlink.yuque.com/org-wiki-voidlink-znirha/fa3tgr/sl96pxao4a0oid1x" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://voidlink.yuque.com/org-wiki-voidlink-znirha/fa3tgr/sl96pxao4a0oid1x" + } + } + } + }, "PerfModeTip" : { "extractionState" : "manual", "localizations" : { @@ -5917,6 +6030,29 @@ } } }, + "Restore Purchase" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复购买" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "回復購買" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "回復購買" + } + } + } + }, "Resume App" : { "extractionState" : "manual", "localizations" : { @@ -7248,6 +7384,29 @@ } } }, + "Unlock Now" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "即刻解锁" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "即刻解鎖" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "即刻解鎖" + } + } + } + }, "unlockDisplayOrientationStackTip" : { "extractionState" : "manual", "localizations" : { diff --git a/VoidLink/Utility/IAPManager.swift b/VoidLink/Utility/IAPManager.swift new file mode 100644 index 00000000..404290a2 --- /dev/null +++ b/VoidLink/Utility/IAPManager.swift @@ -0,0 +1,450 @@ +// +// IAPManager.swift +// VoidLink +// +// Created by True砖家 on 2026/1/9. +// Copyright © 2026 True砖家@Bilibili. All rights reserved. +// + +import Foundation +import StoreKit + +@objc public enum PurchaseStatus: Int { + case notPurchased + case purchased + case revoked +} + +@objc public enum PurchaseInterruption: Int { + case unlockNow + case restore + case learnMore +} + +@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 "com.pencilpro.voidlink.tf.iOS" + default: + return "" + } + } + + func productName() -> String { + switch self { + case .PencilProPack: + return SwiftLocalizationHelper.localizedString(forKey: "Pencil Pro Pack") + default: + return "" + } + } + + func productURL() -> String { + switch self { + case .PencilProPack: + return SwiftLocalizationHelper.localizedString(forKey: "PencilProPackURL") + default: + return "" + } + } + + func productDescription() -> String { + switch self { + case .PencilProPack: + return SwiftLocalizationHelper.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() + } + } + } + + // 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 { + 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() + + switch result { + case .success(let verification): + switch verification { + case .verified(let transaction): + await MainActor.run { + self.delegate?.iapManagerDidPurchase(product) + } + await transaction.finish() + NotificationCenter.default.post(name: product.purchaseSucceededNotification(), object: nil) + case .unverified(_, let err): + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow) + await MainActor.run { + self.delegate?.iapManagerDidFailWithError(err) + } + } + case .userCancelled: + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow) + break + default: + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow) + break + } + } catch { + await MainActor.run { + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow) + self.delegate?.iapManagerDidFailWithError(error) + } + } + } + + 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() + } + } + } + + // MARK: Utils + @available(iOS 15.0, *) + @objc static public func checkPurchaseInfo( + _ product: AddOnProduct, + completion: @escaping (PurchaseInfo) -> Void + ) { + let productID = product.productId() + 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) + } + } + } + + @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: SwiftLocalizationHelper.localizedString(forKey: "No purchase found", product.productName()), + preferredStyle: .alert) + + let unlockAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Unlock Now"), style: .default) { _ in + IAPManager.shared.purchase(product) + } + + let restoreAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Restore Purchase"), style: .default) { _ in + if #available(iOS 13.0, *) { + Task{ + do { + if #available(iOS 15.0, *) { + try await AppStore.sync() + } else { + // Fallback on earlier versions + } + } catch { + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.restore) + print("Restore failed: \(error)") + } + } + } else { + // Fallback on earlier versions + } + } + + let learnMoreAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Learn More"), style: .default) { _ in + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.learnMore) + if let url = URL(string: product.productURL()) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + 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 + } + } + } +} diff --git a/VoidLink/ViewControllers/MainFrameViewController.m b/VoidLink/ViewControllers/MainFrameViewController.m index 7deba677..3e9ac4ab 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.m +++ b/VoidLink/ViewControllers/MainFrameViewController.m @@ -33,7 +33,7 @@ #import "DataManager.h" #import "ThemeManager.h" #import "FixedTintImageView.h" -#import "VoidLink-Swift.h" // not used yet. +#import "VoidLink-Swift.h" #if !TARGET_OS_TV #import "SettingsViewController.h" @@ -1683,6 +1683,30 @@ static NSMutableSet* hostList; }]; [self prewarmSoftKeyboard]; + + [IAPManager.shared fetchProducts]; + + /* + if (@available(iOS 15.0, *)) { + [IAPManager checkPurchaseInfo:AddOnProductPencilProPack completion:^(PurchaseInfo* info) { + switch (info.status) { + case PurchaseStatusPurchased: + NSLog(@"PurchaseStatus Purchased"); + break; + case PurchaseStatusNotPurchased: + NSLog(@"PurchaseStatus NotPurchased"); + break; + case PurchaseStatusRevoked: + NSLog(@"PurchaseStatus Revoked"); + break; + default: + break; + } + NSLog(@"PurchaseStatus Valid: %d", info.valid); + NSLog(@"PurchaseStatus Expiration: %@", info.expirationDate); + }]; + } + */ } - (void)prewarmSoftKeyboard { diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index d53f94d1..5fedac4c 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -467,6 +467,10 @@ BOOL isCustomResolution(int resolutionSelected) { selector:@selector(reloadGameProfileConfigs) name:@"OscLayoutCloseNotification" object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(pencilProPurchaseAborted) + name:@"PencilProPurchaseAbortedNotification" + object:nil]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ if(self.mainFrameViewController.settingsExpandedInStreamView){ @@ -3334,6 +3338,24 @@ BOOL isCustomResolution(int resolutionSelected) { - (void)pencilTickModeChanged:(UISegmentedControl* )sender{ [self setHidden:sender.selectedSegmentIndex != ManualTick forStack:self.pencilTickIntervalStack]; + + if(settingsViewJustExpanded) return; + + if (@available(iOS 15.0, *)) { + if(sender.selectedSegmentIndex != ManualTick) return; + [IAPManager checkPurchaseInfo:AddOnProductPencilProPack completion:^(PurchaseInfo* info) { + if(!info.valid){ + [IAPManager inAppPurchaseActionWithViewController:self product:AddOnProductPencilProPack]; + } + }]; + } +} + +- (void)pencilProPurchaseAborted{ + dispatch_async(dispatch_get_main_queue(), ^{ + self.pencilTickSelector.selectedSegmentIndex = PencilTickDisabled; + [self pencilTickModeChanged:self.pencilTickSelector]; + }); } - (void)pencilTickIntervalSliderMoved:(UISlider* )sender{ diff --git a/VoidLink/mul.lproj/iPad.xcstrings b/VoidLink/mul.lproj/iPad.xcstrings index 3a312a3e..4d7d1958 100644 --- a/VoidLink/mul.lproj/iPad.xcstrings +++ b/VoidLink/mul.lproj/iPad.xcstrings @@ -1688,31 +1688,31 @@ } }, "bVv-CW-kN8.segmentTitles[1]" : { - "comment" : "Class = \"UISegmentedControl\"; bVv-CW-kN8.segmentTitles[1] = \"Manual\"; ObjectID = \"bVv-CW-kN8\";", + "comment" : "Class = \"UISegmentedControl\"; bVv-CW-kN8.segmentTitles[1] = \"Manual (240Hz)\"; ObjectID = \"bVv-CW-kN8\";", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Manual" + "value" : "Manual (240Hz)" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "手动" + "state" : "needs_review", + "value" : "手动 (240Hz)" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", - "value" : "手動" + "state" : "needs_review", + "value" : "手動 (240Hz)" } }, "zh-HK" : { "stringUnit" : { - "state" : "translated", - "value" : "手動" + "state" : "needs_review", + "value" : "手動 (240Hz)" } } }