storekit2 in app purchase implementation
This commit is contained in:
@@ -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 */,
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user