storekit2 in app purchase implementation

This commit is contained in:
True Zhuanjia committed 2026-01-14 20:47:29 +08:00
1 parent 008beb9444
commit ab1d6bfa90
9 files changed
+744 -34

No files matched your search

+8
View File
@@ -18,6 +18,8 @@
18416EA42EBB3DCE00AE0124 /* UITouchUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18416EA32EBB3DCE00AE0124 /* UITouchUtil.swift */; }; 18416EA42EBB3DCE00AE0124 /* UITouchUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18416EA32EBB3DCE00AE0124 /* UITouchUtil.swift */; };
1850C82A2E667E4000845308 /* MicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850C8292E667E4000845308 /* MicHandler.swift */; }; 1850C82A2E667E4000845308 /* MicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850C8292E667E4000845308 /* MicHandler.swift */; };
18729EDE2E8BD68800101194 /* UUIDHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18729EDD2E8BD68800101194 /* UUIDHelper.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 */; }; 187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */; };
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1912E24C33100108730 /* AboutViewController.swift */; }; 1888C1922E24C33100108730 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1912E24C33100108730 /* AboutViewController.swift */; };
1888C1932E24C41000108730 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1902E24C2C800108730 /* AboutView.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 1888C1902E24C2C800108730 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
@@ -433,6 +437,7 @@
F74BEF9C2C1A705600224667 /* OpenSSL in Frameworks */, F74BEF9C2C1A705600224667 /* OpenSSL in Frameworks */,
98882A072AF60F7200C5A11C /* libavformat.a in Frameworks */, 98882A072AF60F7200C5A11C /* libavformat.a in Frameworks */,
FB290CF619B2C406004C83CF /* UIKit.framework in Frameworks */, FB290CF619B2C406004C83CF /* UIKit.framework in Frameworks */,
18780CC72F10D66000B90483 /* StoreKit.framework in Frameworks */,
FB290CF219B2C406004C83CF /* Foundation.framework in Frameworks */, FB290CF219B2C406004C83CF /* Foundation.framework in Frameworks */,
FB4679011A57048000377732 /* CoreFoundation.framework in Frameworks */, FB4679011A57048000377732 /* CoreFoundation.framework in Frameworks */,
182760FA2E83E57D00EC1C5C /* DequeModule in Frameworks */, 182760FA2E83E57D00EC1C5C /* DequeModule in Frameworks */,
@@ -600,6 +605,7 @@
FB290CF019B2C406004C83CF /* Frameworks */ = { FB290CF019B2C406004C83CF /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
18780CC62F10D66000B90483 /* StoreKit.framework */,
98882A032AF60F5300C5A11C /* libavcodec.a */, 98882A032AF60F5300C5A11C /* libavcodec.a */,
98882A052AF60F5300C5A11C /* libavformat.a */, 98882A052AF60F5300C5A11C /* libavformat.a */,
98882A0A2AF60F9000C5A11C /* libavcodec.a */, 98882A0A2AF60F9000C5A11C /* libavcodec.a */,
@@ -782,6 +788,7 @@
children = ( children = (
18729EDD2E8BD68800101194 /* UUIDHelper.swift */, 18729EDD2E8BD68800101194 /* UUIDHelper.swift */,
18A0EE2B2EFBCCFD00BA853A /* GraphicUtils.swift */, 18A0EE2B2EFBCCFD00BA853A /* GraphicUtils.swift */,
18780CC92F10D6CC00B90483 /* IAPManager.swift */,
97E20DD42E2164A9000D5DCA /* BandwidthTracker.h */, 97E20DD42E2164A9000D5DCA /* BandwidthTracker.h */,
97E20DD52E2164A9000D5DCA /* BandwidthTracker.m */, 97E20DD52E2164A9000D5DCA /* BandwidthTracker.m */,
97E20DD62E2164A9000D5DCA /* FloatBuffer.h */, 97E20DD62E2164A9000D5DCA /* FloatBuffer.h */,
@@ -1142,6 +1149,7 @@
18341A042DEB662C0094E5F2 /* FixedTintImageView.m in Sources */, 18341A042DEB662C0094E5F2 /* FixedTintImageView.m in Sources */,
18416EA42EBB3DCE00AE0124 /* UITouchUtil.swift in Sources */, 18416EA42EBB3DCE00AE0124 /* UITouchUtil.swift in Sources */,
FB89463119F646E200339C8A /* StreamManager.m in Sources */, FB89463119F646E200339C8A /* StreamManager.m in Sources */,
18780CCA2F10D6CC00B90483 /* IAPManager.swift in Sources */,
84351B2D2C29AD6100A77989 /* ToolBarContainerView.m in Sources */, 84351B2D2C29AD6100A77989 /* ToolBarContainerView.m in Sources */,
18E4BCEA2E891895000A8657 /* MotionHandler.swift in Sources */, 18E4BCEA2E891895000A8657 /* MotionHandler.swift in Sources */,
988FCD41293B091B003050E2 /* KeyboardInputField.m in Sources */, 988FCD41293B091B003050E2 /* KeyboardInputField.m in Sources */,
+3 -3
View File
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"/> <device id="ipad11_0rounded" orientation="portrait" layout="fullscreen" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <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="Image references" minToolsVersion="12.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
@@ -1899,7 +1899,7 @@
<rect key="frame" x="0.0" y="25.5" width="380" height="32"/> <rect key="frame" x="0.0" y="25.5" width="380" height="32"/>
<segments> <segments>
<segment title="Off"/> <segment title="Off"/>
<segment title="Manual"/> <segment title="Manual (240Hz)"/>
</segments> </segments>
<color key="tintColor" red="0.6716768742" green="0.61711704730000005" blue="0.99902987480000005" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="tintColor" red="0.6716768742" green="0.61711704730000005" blue="0.99902987480000005" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="selectedSegmentTintColor" systemColor="linkColor"/> <color key="selectedSegmentTintColor" systemColor="linkColor"/>
+8
View File
@@ -40,6 +40,14 @@ import UIKit
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero) let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
let oscProfile = oscProfileMan.getSelectedProfile() let oscProfile = oscProfileMan.getSelectedProfile()
pressureCurveEnabled = oscProfile.pressureCurveEnabled 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 persistedCurvePoints = PressureCurve.importCurvePoints(oscProfile.pressureCurvePoints)
let curve = PressureCurve() let curve = PressureCurve()
curve.polylinePoints = persistedCurvePoints curve.polylinePoints = persistedCurvePoints
+58 -19
View File
@@ -288,7 +288,7 @@ final class PressureCurveView: UIView {
private func drawCurve(_ ctx: CGContext) { private func drawCurve(_ ctx: CGContext) {
ctx.setStrokeColor(UIColor.systemBlue.cgColor) ctx.setStrokeColor(UIColor.systemBlue.cgColor)
ctx.setLineWidth(3) ctx.setLineWidth(5)
let points = curve.sampleCurve(stepsPerSegment: 60) let points = curve.sampleCurve(stepsPerSegment: 60)
for (i,p) in points.enumerated() { for (i,p) in points.enumerated() {
let mapped = map(p) let mapped = map(p)
@@ -300,7 +300,7 @@ final class PressureCurveView: UIView {
private func drawTangents(_ ctx: CGContext) { private func drawTangents(_ ctx: CGContext) {
let pts = curve.tangentPoints let pts = curve.tangentPoints
ctx.setStrokeColor(UIColor.systemPurple.cgColor) ctx.setStrokeColor(UIColor.systemPurple.cgColor)
ctx.setLineWidth(1.5) ctx.setLineWidth(2.5)
for i in 0..<pts.count { for i in 0..<pts.count {
let p = map(pts[i]) let p = map(pts[i])
if i==0 { ctx.move(to:p) } else { ctx.addLine(to:p) } 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?) { override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if testStage == .drawingStage { if testStage == .drawingStage {
let altitude = touches.first!.altitudeAngle let altitude = touches.first!.altitudeAngle
let force = (touches.first!.force/touches.first!.maximumPossibleForce)/sin(altitude) let force = (touches.first!.force/touches.first!.maximumPossibleForce)/sin(altitude)
pressures.append(force) pressures.append(force)
return return
} }
guard guard
let touch = touches.first, let touch = touches.first,
@@ -519,6 +516,19 @@ class PressureCurveViewController: UIViewController {
curveView.widthAnchor.constraint(equalToConstant: curveWidth), curveView.widthAnchor.constraint(equalToConstant: curveWidth),
curveView.heightAnchor.constraint(equalToConstant: curveHeight) 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) { override func viewDidAppear(_ animated: Bool) {
@@ -547,6 +557,10 @@ class PressureCurveViewController: UIViewController {
} }
override func viewWillDisappear(_ animated: Bool) {
NotificationCenter.default.removeObserver(self)
}
private func loadPersistedCurve() { private func loadPersistedCurve() {
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero) let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
let persistedCurvePoints = PressureCurve.importCurvePoints(oscProfileMan.getSelectedProfile().pressureCurvePoints) let persistedCurvePoints = PressureCurve.importCurvePoints(oscProfileMan.getSelectedProfile().pressureCurvePoints)
@@ -618,7 +632,16 @@ class PressureCurveViewController: UIViewController {
@objc private func resetTapped() { @objc private func resetTapped() {
curveView.curve.polylinePoints = [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 1)] 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() { @objc private func pressureRangeTest() {
@@ -636,25 +659,41 @@ class PressureCurveViewController: UIViewController {
self.setupNavigationBar() self.setupNavigationBar()
}) })
} }
@objc private func saveTapped() { @objc private func persistCurve() {
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero) let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
let selectedProfile = oscProfileMan.getSelectedProfile() let selectedProfile = oscProfileMan.getSelectedProfile()
selectedProfile.pressureCurvePoints = PressureCurve.exportCurvePoints(curveView.curve) selectedProfile.pressureCurvePoints = PressureCurve.exportCurvePoints(curveView.curve)
oscProfileMan.replaceSelectedProfile(with: selectedProfile, overwriteDefault: true) oscProfileMan.replaceSelectedProfile(with: selectedProfile, overwriteDefault: true)
AlertControllerUtil.showAlert( DispatchQueue.main.async {
in: self, AlertControllerUtil.showAlert(
title: SwiftLocalizationHelper.localizedString(forKey: "Pen Pressure Curve"), in: self,
message: SwiftLocalizationHelper.localizedString(forKey:"Pressure curve saved with current on-screen widget profile!"), title: SwiftLocalizationHelper.localizedString(forKey: "Pen Pressure Curve"),
withCancel: false, message: SwiftLocalizationHelper.localizedString(forKey:"Pressure curve saved with current on-screen widget profile!"),
buttonTitle: SwiftLocalizationHelper.localizedString(forKey: "OK"), withCancel: false,
countdown: 0, buttonTitle: SwiftLocalizationHelper.localizedString(forKey: "OK"),
completion: { countdown: 0,
PencilHandler.sharedInstance?.setupPressureLUT() 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() { @objc private func exitTapped() {
+162 -3
View File
@@ -3085,22 +3085,28 @@
"Host information updated" : { "Host information updated" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "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" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "主机信息已更新" "value" : "已为主机添加地址,下次无须手动输入该地址。"
} }
}, },
"zh-Hant" : { "zh-Hant" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "主機資訊已更新" "value" : "已為主機新增位址,下次無須手動輸入該位址。"
} }
}, },
"zh-HK" : { "zh-HK" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "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" : { "left" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "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." : { "No video received from host." : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "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" : { "PerfModeTip" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "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" : { "Resume App" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "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" : { "unlockDisplayOrientationStackTip" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "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 "DataManager.h"
#import "ThemeManager.h" #import "ThemeManager.h"
#import "FixedTintImageView.h" #import "FixedTintImageView.h"
#import "VoidLink-Swift.h" // not used yet. #import "VoidLink-Swift.h"
#if !TARGET_OS_TV #if !TARGET_OS_TV
#import "SettingsViewController.h" #import "SettingsViewController.h"
@@ -1683,6 +1683,30 @@ static NSMutableSet* hostList;
}]; }];
[self prewarmSoftKeyboard]; [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 { - (void)prewarmSoftKeyboard {
@@ -467,6 +467,10 @@ BOOL isCustomResolution(int resolutionSelected) {
selector:@selector(reloadGameProfileConfigs) selector:@selector(reloadGameProfileConfigs)
name:@"OscLayoutCloseNotification" name:@"OscLayoutCloseNotification"
object:nil]; object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(pencilProPurchaseAborted)
name:@"PencilProPurchaseAbortedNotification"
object:nil];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
if(self.mainFrameViewController.settingsExpandedInStreamView){ if(self.mainFrameViewController.settingsExpandedInStreamView){
@@ -3334,6 +3338,24 @@ BOOL isCustomResolution(int resolutionSelected) {
- (void)pencilTickModeChanged:(UISegmentedControl* )sender{ - (void)pencilTickModeChanged:(UISegmentedControl* )sender{
[self setHidden:sender.selectedSegmentIndex != ManualTick forStack:self.pencilTickIntervalStack]; [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{ - (void)pencilTickIntervalSliderMoved:(UISlider* )sender{
+8 -8
View File
@@ -1688,31 +1688,31 @@
} }
}, },
"bVv-CW-kN8.segmentTitles[1]" : { "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", "extractionState" : "extracted_with_value",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "new", "state" : "new",
"value" : "Manual" "value" : "Manual (240Hz)"
} }
}, },
"zh-Hans" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "手动" "value" : "手动 (240Hz)"
} }
}, },
"zh-Hant" : { "zh-Hant" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "手動" "value" : "手動 (240Hz)"
} }
}, },
"zh-HK" : { "zh-HK" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "手動" "value" : "手動 (240Hz)"
} }
} }
} }