storekit2 in app purchase implementation

This commit is contained in:
True Zhuanjia
2026-01-14 20:47:29 +08:00
parent 008beb9444
commit ab1d6bfa90
9 changed files with 744 additions and 34 deletions
+8
View File
@@ -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 = "<group>"; };
1850C8292E667E4000845308 /* MicHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicHandler.swift; sourceTree = "<group>"; };
18729EDD2E8BD68800101194 /* UUIDHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDHelper.swift; sourceTree = "<group>"; };
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 = "<group>"; };
187F4B232E04633100EECC59 /* WidgetPanelStackView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetPanelStackView.h; sourceTree = "<group>"; };
187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WidgetPanelStackView.m; sourceTree = "<group>"; };
1888C1902E24C2C800108730 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
@@ -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 */,
+3 -3
View File
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch.iPad" propertyAccessControl="none" useAutolayout="YES" colorMatched="YES" initialViewController="EVd-wq-ego">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24506" targetRuntime="iOS.CocoaTouch.iPad" propertyAccessControl="none" useAutolayout="YES" colorMatched="YES" initialViewController="EVd-wq-ego">
<device id="ipad11_0rounded" orientation="portrait" layout="fullscreen" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24405"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24504"/>
<capability name="Image references" minToolsVersion="12.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@@ -1899,7 +1899,7 @@
<rect key="frame" x="0.0" y="25.5" width="380" height="32"/>
<segments>
<segment title="Off"/>
<segment title="Manual"/>
<segment title="Manual (240Hz)"/>
</segments>
<color key="tintColor" red="0.6716768742" green="0.61711704730000005" blue="0.99902987480000005" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="selectedSegmentTintColor" systemColor="linkColor"/>
+8
View File
@@ -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
+58 -19
View File
@@ -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..<pts.count {
let p = map(pts[i])
if i==0 { ctx.move(to:p) } else { ctx.addLine(to:p) }
@@ -374,15 +374,12 @@ final class PressureCurveView: UIView {
}
override func touchesMoved(_ touches: Set<UITouch>, 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() {
+162 -3
View File
@@ -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" : {
+450
View File
@@ -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
}
}
}
}
@@ -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 {
@@ -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{
+8 -8
View File
@@ -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)"
}
}
}