2026-02-11 10:20:17 +00:00
import Foundation
import Combine
import StoreKit
2026-02-14 13:24:01 +00:00
// / MARK: - S u p p o r t P u r c h a s e M a n a g e r
2026-02-19 19:11:18 +00:00
// H a n d l e s o p t i o n a l c o n s u m a b l e s u p p o r t p u r c h a s e s t a t e v i a S t o r e K i t .
2026-02-11 10:20:17 +00:00
@ MainActor
final class SupportPurchaseManager : ObservableObject {
2026-02-25 00:21:58 +00:00
static let supportProductID = " 002420160 "
2026-02-25 13:58:26 +00:00
static let externalSupportURL = URL ( string : " https://www.patreon.com/h3p " )
2026-02-11 10:20:17 +00:00
@ Published private ( set ) var supportProduct : Product ?
@ Published private ( set ) var hasSupported : Bool = false
@ Published private ( set ) var isLoadingProducts : Bool = false
@ Published private ( set ) var isPurchasing : Bool = false
@ Published private ( set ) var canUseInAppPurchases : Bool = false
@ Published private ( set ) var allowsTestingBypass : Bool = false
2026-02-23 18:58:03 +00:00
@ Published private ( set ) var lastSuccessfulPriceRefreshAt : Date ?
2026-02-11 10:20:17 +00:00
@ Published var statusMessage : String ?
private var transactionUpdatesTask : Task < Void , Never > ?
private let bypassDefaultsKey = " SupportPurchaseBypassEnabled "
2026-02-12 17:52:59 +00:00
2026-02-14 13:24:01 +00:00
// A l l o w s b y p a s s i n s i m u l a t o r / d e b u g e n v i r o n m e n t s f o r t e s t i n g p u r c h a s e - g a t e d U I .
2026-02-12 17:57:56 +00:00
private func shouldAllowTestingBypass ( environment : AppStore . Environment ) -> Bool {
2026-02-12 17:52:59 +00:00
#if targetEnvironment ( simulator )
return true
#elseif DEBUG
return true
#else
2026-02-12 18:12:33 +00:00
_ = environment
return false
2026-02-12 17:52:59 +00:00
#endif
}
2026-02-11 10:20:17 +00:00
init ( ) {
transactionUpdatesTask = observeTransactionUpdates ( )
}
deinit {
transactionUpdatesTask ? . cancel ( )
}
var supportPriceLabel : String {
2026-02-23 18:58:03 +00:00
supportProduct ? . displayPrice ? ? NSLocalizedString ( " Unavailable " , comment : " " )
2026-02-11 10:20:17 +00:00
}
var canBypassInCurrentBuild : Bool {
allowsTestingBypass
}
2026-02-25 13:58:26 +00:00
var hasExternalSupportFallback : Bool {
Self . externalSupportURL != nil
}
2026-02-19 19:11:18 +00:00
// R e f r e s h e s S t o r e K i t c a p a b i l i t y a n d p r o d u c t m e t a d a t a .
2026-02-11 10:20:17 +00:00
func refreshStoreState ( ) async {
await refreshBypassEligibility ( )
await refreshProducts ( showStatusOnFailure : false )
}
2026-02-14 13:24:01 +00:00
// E n a b l e s t e s t i n g b y p a s s w h e r e a l l o w e d .
2026-02-11 10:20:17 +00:00
func bypassForTesting ( ) {
guard canBypassInCurrentBuild else { return }
UserDefaults . standard . set ( true , forKey : bypassDefaultsKey )
hasSupported = true
2026-02-23 18:58:03 +00:00
statusMessage = NSLocalizedString ( " Support purchase bypass enabled for TestFlight/Sandbox testing. " , comment : " " )
2026-02-11 10:20:17 +00:00
}
2026-02-19 19:11:18 +00:00
// C l e a r s t e s t i n g b y p a s s .
2026-02-11 10:20:17 +00:00
func clearBypassForTesting ( ) {
UserDefaults . standard . removeObject ( forKey : bypassDefaultsKey )
2026-02-19 19:11:18 +00:00
hasSupported = false
2026-02-11 10:20:17 +00:00
}
2026-02-14 13:24:01 +00:00
// L o a d s s u p p o r t p r o d u c t m e t a d a t a f r o m A p p S t o r e .
2026-02-11 10:20:17 +00:00
func refreshProducts ( showStatusOnFailure : Bool = true ) async {
guard canUseInAppPurchases else {
supportProduct = nil
isLoadingProducts = false
2026-02-16 19:02:43 +00:00
if showStatusOnFailure {
2026-03-09 19:39:32 +00:00
statusMessage = NSLocalizedString ( " App Store pricing is currently unavailable. " , comment : " " )
2026-02-16 19:02:43 +00:00
}
2026-02-11 10:20:17 +00:00
return
}
isLoadingProducts = true
defer { isLoadingProducts = false }
2026-02-27 14:34:49 +00:00
do {
let products = try await Product . products ( for : [ Self . supportProductID ] )
supportProduct = products . first
if supportProduct != nil {
lastSuccessfulPriceRefreshAt = Date ( )
statusMessage = nil
2026-02-11 10:20:17 +00:00
}
2026-02-27 14:34:49 +00:00
if supportProduct = = nil , showStatusOnFailure {
let format = NSLocalizedString (
" App Store did not return product %@. Check App Store Connect and TestFlight availability. " ,
comment : " "
)
statusMessage = String ( format : format , Self . supportProductID )
}
} catch {
if showStatusOnFailure {
let format = NSLocalizedString ( " Failed to load App Store products: %@ " , comment : " " )
statusMessage = String ( format : format , error . localizedDescription )
2026-02-11 10:20:17 +00:00
}
2026-02-27 08:41:19 +00:00
}
2026-02-11 10:20:17 +00:00
}
2026-02-16 19:02:43 +00:00
// R e f r e s h e s i n - a p p p u r c h a s e a v a i l a b i l i t y a n d p r o d u c t p r i c i n g f o r s e t t i n g s U I .
func refreshPrice ( ) async {
2026-02-23 18:58:03 +00:00
statusMessage = nil
2026-02-16 19:02:43 +00:00
await refreshBypassEligibility ( )
await refreshProducts ( showStatusOnFailure : true )
}
2026-02-14 13:24:01 +00:00
// S t a r t s p u r c h a s e f l o w f o r t h e o p t i o n a l s u p p o r t p r o d u c t .
2026-02-11 10:20:17 +00:00
func purchaseSupport ( ) async {
2026-02-25 13:58:26 +00:00
// P r e v e n t o v e r l a p p i n g S t o r e K i t p u r c h a s e f l o w s t h a t c a n r a c e a n d s u r f a c e m i s l e a d i n g c a n c e l s t a t e s .
guard ! isPurchasing else { return }
2026-02-11 10:20:17 +00:00
guard canUseInAppPurchases else {
2026-03-09 19:39:32 +00:00
statusMessage = NSLocalizedString ( " In-App Purchases are currently unavailable on this device. Check App Store login and Screen Time restrictions. " , comment : " " )
2026-02-11 10:20:17 +00:00
return
}
2026-02-19 19:11:18 +00:00
if supportProduct = = nil {
await refreshProducts ( showStatusOnFailure : true )
}
2026-02-11 10:20:17 +00:00
guard let product = supportProduct else {
2026-02-23 18:58:03 +00:00
statusMessage = NSLocalizedString ( " Support purchase is currently unavailable. " , comment : " " )
2026-02-11 10:20:17 +00:00
return
}
2026-02-25 13:58:26 +00:00
statusMessage = nil
let hadSupportedBeforeAttempt = hasSupported
2026-02-11 10:20:17 +00:00
isPurchasing = true
defer { isPurchasing = false }
do {
let result = try await product . purchase ( )
switch result {
case . success ( let verificationResult ) :
let transaction = try verify ( verificationResult )
await transaction . finish ( )
2026-02-19 19:11:18 +00:00
hasSupported = true
2026-02-23 18:58:03 +00:00
statusMessage = NSLocalizedString ( " Thank you for supporting Neon Vision Editor. " , comment : " " )
2026-02-11 10:20:17 +00:00
case . pending :
2026-02-23 18:58:03 +00:00
statusMessage = NSLocalizedString ( " Purchase is pending approval. " , comment : " " )
2026-02-11 10:20:17 +00:00
case . userCancelled :
2026-02-25 14:14:53 +00:00
// O n s o m e d e v i c e s a v e r i f i e d t r a n s a c t i o n u p d a t e m a y a r r i v e s h o r t l y a f t e r a c a n c e l - l i k e r e s u l t .
// W a i t b r i e f l y t o a v o i d s u r f a c i n g a f a l s e c a n c e l l a t i o n s t a t e .
do {
try await Task . sleep ( nanoseconds : 700_000_000 )
} catch {
// I g n o r e c a n c e l l a t i o n o f t h e d e l a y ; s t a t e c h e c k b e l o w r e m a i n s s a f e .
}
2026-02-25 13:58:26 +00:00
if ! hasSupported && ! hadSupportedBeforeAttempt {
statusMessage = NSLocalizedString ( " Purchase canceled. " , comment : " " )
}
2026-02-11 10:20:17 +00:00
@ unknown default :
2026-02-23 18:58:03 +00:00
statusMessage = NSLocalizedString ( " Purchase did not complete. " , comment : " " )
2026-02-11 10:20:17 +00:00
}
} catch {
2026-02-25 00:21:58 +00:00
let details = String ( describing : error )
if details = = error . localizedDescription {
let format = NSLocalizedString ( " Purchase failed: %@ " , comment : " " )
statusMessage = String ( format : format , error . localizedDescription )
} else {
let format = NSLocalizedString ( " Purchase failed: %@ (%@) " , comment : " " )
statusMessage = String ( format : format , error . localizedDescription , details )
}
2026-02-11 10:20:17 +00:00
}
}
2026-03-09 19:39:32 +00:00
// D e t e c t s w h e t h e r t h i s d e v i c e c a n u s e i n - a p p p u r c h a s e s .
2026-02-11 10:20:17 +00:00
private func refreshBypassEligibility ( ) async {
2026-02-25 14:10:18 +00:00
#if os ( iOS ) || os ( macOS )
2026-02-25 00:21:58 +00:00
canUseInAppPurchases = AppStore . canMakePayments
#else
canUseInAppPurchases = false
#endif
2026-02-11 10:20:17 +00:00
do {
let appTransactionResult = try await AppTransaction . shared
switch appTransactionResult {
case . verified ( let appTransaction ) :
2026-02-12 17:57:56 +00:00
allowsTestingBypass = shouldAllowTestingBypass ( environment : appTransaction . environment )
2026-02-11 10:20:17 +00:00
case . unverified :
2026-02-25 00:21:58 +00:00
canUseInAppPurchases = AppStore . canMakePayments
2026-02-11 10:20:17 +00:00
allowsTestingBypass = false
}
} catch {
2026-02-25 14:10:18 +00:00
#if os ( iOS ) || os ( macOS )
2026-02-25 00:21:58 +00:00
canUseInAppPurchases = AppStore . canMakePayments
#else
2026-02-11 10:20:17 +00:00
canUseInAppPurchases = false
2026-02-25 00:21:58 +00:00
#endif
2026-02-11 10:20:17 +00:00
allowsTestingBypass = false
}
2026-02-25 00:21:58 +00:00
#if targetEnvironment ( simulator ) || DEBUG
if ! allowsTestingBypass {
allowsTestingBypass = true
}
#endif
2026-02-11 10:20:17 +00:00
}
2026-02-14 13:24:01 +00:00
// L i s t e n s f o r t r a n s a c t i o n u p d a t e s a n d a p p l i e s v e r i f i e d c h a n g e s .
2026-02-11 10:20:17 +00:00
private func observeTransactionUpdates ( ) -> Task < Void , Never > {
Task { [ weak self ] in
guard let self else { return }
for await result in Transaction . updates {
do {
let transaction = try self . verify ( result )
await transaction . finish ( )
2026-02-19 19:11:18 +00:00
await MainActor . run {
self . hasSupported = true
}
2026-02-11 10:20:17 +00:00
} catch {
await MainActor . run {
2026-02-23 18:58:03 +00:00
self . statusMessage = NSLocalizedString ( " Transaction verification failed. " , comment : " " )
2026-02-11 10:20:17 +00:00
}
}
}
}
}
2026-02-14 13:24:01 +00:00
// E n f o r c e s S t o r e K i t v e r i f i c a t i o n b e f o r e u s i n g t r a n s a c t i o n p a y l o a d s .
2026-02-11 10:20:17 +00:00
private func verify < T > ( _ result : VerificationResult < T > ) throws -> T {
switch result {
case . verified ( let safe ) :
return safe
case . unverified :
throw SupportPurchaseError . failedVerification
}
}
}
2026-02-14 13:24:01 +00:00
// / MARK: - S t o r e K i t E r r o r s
2026-02-11 10:20:17 +00:00
enum SupportPurchaseError : LocalizedError {
case failedVerification
var errorDescription : String ? {
switch self {
case . failedVerification :
return " Transaction could not be verified. "
}
}
}