From eb33eb6911e172677b8d1ee5d0f8a7739dbfcd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 23 Jan 2025 13:28:13 +0100 Subject: [PATCH] Makes SK2ReceiptManager an actor - Fixes a concurrency crash that was caused by reading and writing to `sk2IntroOfferEligibility`. Made `SK2ReceiptManager` an actor to prevent this as it could potentially happen with the purchases variable too. --- CHANGELOG.md | 6 + Examples/Advanced/Advanced/HomeView.swift | 2 +- .../Graveyard/SuperwallGraveyard.swift | 5 - Sources/SuperwallKit/Misc/Constants.swift | 2 +- .../SuperwallKit/Models/Product/Product.swift | 293 +++++++++++++++--- .../Models/Product/ProductItem.swift | 273 ---------------- .../Receipt Manager/ReceiptManager.swift | 8 +- .../Receipt Manager/SK2ReceiptManager.swift | 4 +- SuperwallKit.podspec | 2 +- 9 files changed, 260 insertions(+), 335 deletions(-) delete mode 100644 Sources/SuperwallKit/Models/Product/ProductItem.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2244b5c41..7458b3a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.0.0-beta.4 + +### Fixes + +- Fixes a crash that was caused by a concurrency issue. + ## 4.0.0-beta.3 ### Breaking Changes diff --git a/Examples/Advanced/Advanced/HomeView.swift b/Examples/Advanced/Advanced/HomeView.swift index 2db09eedc..3123e870d 100644 --- a/Examples/Advanced/Advanced/HomeView.swift +++ b/Examples/Advanced/Advanced/HomeView.swift @@ -93,7 +93,7 @@ struct HomeView: View { ZStack { switch page { case .nonGated: - Text("Non gated feature launched") + Text("Non-gated feature launched") case .pro: Text("Pro feature launched") case .diamond: diff --git a/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift b/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift index ee037b126..85d4c7379 100644 --- a/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift +++ b/Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift @@ -142,8 +142,6 @@ extension Superwall { } @available(*, unavailable, renamed: "SuperwallPlacementInfo") -@objc(SWKSuperwallEventInfo) -@objcMembers public final class SuperwallEventInfo: NSObject {} extension SuperwallDelegate { @@ -152,12 +150,9 @@ extension SuperwallDelegate { } @available(*, unavailable, renamed: "ProductStore") -@objc(SWKStore) public enum Store: Int { case appStore } @available(*, unavailable, renamed: "Product") -@objc(SWKProductInfo) -@objcMembers public final class ProductInfo: NSObject, Codable, Sendable {} diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index f452eb69a..9c2fca358 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.0.0-beta.3 +4.0.0-beta.4 """ diff --git a/Sources/SuperwallKit/Models/Product/Product.swift b/Sources/SuperwallKit/Models/Product/Product.swift index ba7b8905c..f01d73138 100644 --- a/Sources/SuperwallKit/Models/Product/Product.swift +++ b/Sources/SuperwallKit/Models/Product/Product.swift @@ -1,76 +1,273 @@ // -// ProductType.swift -// Superwall +// File.swift // -// Created by Yusuf Tör on 01/03/2022. +// +// Created by Yusuf Tör on 29/03/2024. // import Foundation -/// The type of product. -@objc(SWKProductType) -public enum ProductType: Int, Codable, Sendable { - /// The primary product of the paywall. - case primary - - /// The secondary product of the paywall. - case secondary - - /// The tertiary product of the paywall. - case tertiary +/// An enum whose types specify the store which the product belongs to. +@objc(SWKProductStore) +public enum ProductStore: Int, Codable, Sendable { + /// An Apple App Store product. + case appStore - enum InternalProductType: String, Codable { - case primary - case secondary - case tertiary + enum CodingKeys: String, CodingKey { + case appStore = "APP_STORE" } public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() - switch self { - case .primary: - try container.encode(InternalProductType.primary.rawValue) - case .secondary: - try container.encode(InternalProductType.secondary.rawValue) - case .tertiary: - try container.encode(InternalProductType.tertiary.rawValue) + case .appStore: + try container.encode(CodingKeys.appStore.rawValue) } } public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let rawValue = try container.decode(String.self) - guard let internalProductType = InternalProductType(rawValue: rawValue) else { + let type = CodingKeys(rawValue: rawValue) + switch type { + case .appStore: + self = .appStore + case .none: + throw DecodingError.valueNotFound( + String.self, + .init( + codingPath: [], + debugDescription: "Unsupported product store type." + ) + ) + } + } +} + +/// An Apple App Store product. +@objc(SWKAppStoreProduct) +@objcMembers +public final class AppStoreProduct: NSObject, Codable, Sendable { + /// The bundleId that the product is associated with + let bundleId: String? + + /// The store the product belongs to. + let store: ProductStore + + /// The product identifier. + public let id: String + + enum CodingKeys: String, CodingKey { + case bundleId + case id = "productIdentifier" + case store + } + + init( + store: ProductStore = .appStore, + id: String + ) { + self.bundleId = Bundle.main.bundleIdentifier + self.store = store + self.id = id + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(store, forKey: .store) + try container.encodeIfPresent(bundleId, forKey: .bundleId) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.store = try container.decode(ProductStore.self, forKey: .store) + + // If the bundle ID is present, and it's not equal to the bundle + // ID of the app, it gets ignored. + let bundleId = try container.decodeIfPresent(String.self, forKey: .bundleId) + if let bundleId = bundleId, + bundleId != Bundle.main.bundleIdentifier { throw DecodingError.typeMismatch( - InternalProductType.self, - .init( - codingPath: [], - debugDescription: "Didn't find a primary, secondary, or tertiary product type." - ) + String.self, + .init( + codingPath: [], + debugDescription: "The bundle id of the product didn't match the bundle id of the app." + ) ) } - switch internalProductType { - case .primary: - self = .primary - case .secondary: - self = .secondary - case .tertiary: - self = .tertiary + self.bundleId = bundleId + super.init() + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? AppStoreProduct else { + return false } + return bundleId == other.bundleId + && store == other.store + && id == other.id + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(bundleId) + hasher.combine(store) + hasher.combine(id) + return hasher.finalize() } } -// MARK: - CustomStringConvertible -extension ProductType: CustomStringConvertible { - public var description: String { - switch self { - case .primary: - return InternalProductType.primary.rawValue - case .secondary: - return InternalProductType.secondary.rawValue - case .tertiary: - return InternalProductType.tertiary.rawValue +/// An objc-only type that specifies a store and a product. +@objc(SWKStoreProductAdapter) +@objcMembers +public final class StoreProductAdapterObjc: NSObject, Codable, Sendable { + /// The store associated with the product. + public let store: ProductStore + + /// The App Store product. This is non-nil if `store` is + /// `appStore`. + public let appStoreProduct: AppStoreProduct? + + init( + store: ProductStore, + appStoreProduct: AppStoreProduct? + ) { + self.store = store + self.appStoreProduct = appStoreProduct + } +} + +/// The product in the paywall. +@objc(SWKProduct) +@objcMembers +public final class Product: NSObject, Codable, Sendable { + /// The type of store and its associated product. + public enum StoreProductType: Codable, Sendable, Hashable { + case appStore(AppStoreProduct) + } + + private enum CodingKeys: String, CodingKey { + case name = "referenceName" + case storeProduct + case entitlements + } + + /// The name of the product in the editor. + /// + /// This is optional because products can also be decoded from outside + /// of a paywall. + public let name: String? + + /// The type of product + public let type: StoreProductType + + /// Convenience variable that accesses the product's identifier. + public var id: String { + switch type { + case .appStore(let product): + return product.id } } + + /// The entitlement associated with the product. + public let entitlements: Set + + /// The objc-only type of product. + @objc(adapter) + public let objcAdapter: StoreProductAdapterObjc + + init( + name: String?, + type: StoreProductType, + entitlements: Set + ) { + self.name = name + self.type = type + self.entitlements = entitlements + + switch type { + case .appStore(let product): + objcAdapter = .init( + store: .appStore, + appStoreProduct: product + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(name, forKey: .name) + + try container.encode(entitlements, forKey: .entitlements) + + switch type { + case .appStore(let product): + try container.encode(product, forKey: .storeProduct) + } + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) + + // These will throw an error if the StoreProduct is not an AppStoreProduct or if the + // entitlement type is not `SERVICE_LEVEL`, which must be caught in a `Throwable` and + // ignored in the paywall object. + entitlements = try container.decode(Set.self, forKey: .entitlements) + let storeProduct = try container.decode(AppStoreProduct.self, forKey: .storeProduct) + type = .appStore(storeProduct) + objcAdapter = .init(store: .appStore, appStoreProduct: storeProduct) + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Product else { + return false + } + return name == other.name + && type == other.type + && entitlements == other.entitlements + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(name) + hasher.combine(type) + hasher.combine(entitlements) + return hasher.finalize() + } +} + +struct TemplatingProductItem: Encodable { + let name: String + let productId: String + + private enum CodingKeys: String, CodingKey { + case product + case productId + } + + static func create(from productItems: [Product]) -> [TemplatingProductItem] { + return productItems.compactMap { + guard let name = $0.name else { + return nil + } + return TemplatingProductItem( + name: name, + productId: $0.id + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + // Encode name as "product" for templating + try container.encode(name, forKey: .product) + + // Encode product ID as "productId" for templating + try container.encode(productId, forKey: .productId) + } } diff --git a/Sources/SuperwallKit/Models/Product/ProductItem.swift b/Sources/SuperwallKit/Models/Product/ProductItem.swift deleted file mode 100644 index f01d73138..000000000 --- a/Sources/SuperwallKit/Models/Product/ProductItem.swift +++ /dev/null @@ -1,273 +0,0 @@ -// -// File.swift -// -// -// Created by Yusuf Tör on 29/03/2024. -// - -import Foundation - -/// An enum whose types specify the store which the product belongs to. -@objc(SWKProductStore) -public enum ProductStore: Int, Codable, Sendable { - /// An Apple App Store product. - case appStore - - enum CodingKeys: String, CodingKey { - case appStore = "APP_STORE" - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .appStore: - try container.encode(CodingKeys.appStore.rawValue) - } - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let rawValue = try container.decode(String.self) - let type = CodingKeys(rawValue: rawValue) - switch type { - case .appStore: - self = .appStore - case .none: - throw DecodingError.valueNotFound( - String.self, - .init( - codingPath: [], - debugDescription: "Unsupported product store type." - ) - ) - } - } -} - -/// An Apple App Store product. -@objc(SWKAppStoreProduct) -@objcMembers -public final class AppStoreProduct: NSObject, Codable, Sendable { - /// The bundleId that the product is associated with - let bundleId: String? - - /// The store the product belongs to. - let store: ProductStore - - /// The product identifier. - public let id: String - - enum CodingKeys: String, CodingKey { - case bundleId - case id = "productIdentifier" - case store - } - - init( - store: ProductStore = .appStore, - id: String - ) { - self.bundleId = Bundle.main.bundleIdentifier - self.store = store - self.id = id - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(store, forKey: .store) - try container.encodeIfPresent(bundleId, forKey: .bundleId) - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(String.self, forKey: .id) - self.store = try container.decode(ProductStore.self, forKey: .store) - - // If the bundle ID is present, and it's not equal to the bundle - // ID of the app, it gets ignored. - let bundleId = try container.decodeIfPresent(String.self, forKey: .bundleId) - if let bundleId = bundleId, - bundleId != Bundle.main.bundleIdentifier { - throw DecodingError.typeMismatch( - String.self, - .init( - codingPath: [], - debugDescription: "The bundle id of the product didn't match the bundle id of the app." - ) - ) - } - self.bundleId = bundleId - super.init() - } - - public override func isEqual(_ object: Any?) -> Bool { - guard let other = object as? AppStoreProduct else { - return false - } - return bundleId == other.bundleId - && store == other.store - && id == other.id - } - - public override var hash: Int { - var hasher = Hasher() - hasher.combine(bundleId) - hasher.combine(store) - hasher.combine(id) - return hasher.finalize() - } -} - -/// An objc-only type that specifies a store and a product. -@objc(SWKStoreProductAdapter) -@objcMembers -public final class StoreProductAdapterObjc: NSObject, Codable, Sendable { - /// The store associated with the product. - public let store: ProductStore - - /// The App Store product. This is non-nil if `store` is - /// `appStore`. - public let appStoreProduct: AppStoreProduct? - - init( - store: ProductStore, - appStoreProduct: AppStoreProduct? - ) { - self.store = store - self.appStoreProduct = appStoreProduct - } -} - -/// The product in the paywall. -@objc(SWKProduct) -@objcMembers -public final class Product: NSObject, Codable, Sendable { - /// The type of store and its associated product. - public enum StoreProductType: Codable, Sendable, Hashable { - case appStore(AppStoreProduct) - } - - private enum CodingKeys: String, CodingKey { - case name = "referenceName" - case storeProduct - case entitlements - } - - /// The name of the product in the editor. - /// - /// This is optional because products can also be decoded from outside - /// of a paywall. - public let name: String? - - /// The type of product - public let type: StoreProductType - - /// Convenience variable that accesses the product's identifier. - public var id: String { - switch type { - case .appStore(let product): - return product.id - } - } - - /// The entitlement associated with the product. - public let entitlements: Set - - /// The objc-only type of product. - @objc(adapter) - public let objcAdapter: StoreProductAdapterObjc - - init( - name: String?, - type: StoreProductType, - entitlements: Set - ) { - self.name = name - self.type = type - self.entitlements = entitlements - - switch type { - case .appStore(let product): - objcAdapter = .init( - store: .appStore, - appStoreProduct: product - ) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encodeIfPresent(name, forKey: .name) - - try container.encode(entitlements, forKey: .entitlements) - - switch type { - case .appStore(let product): - try container.encode(product, forKey: .storeProduct) - } - } - - public required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decodeIfPresent(String.self, forKey: .name) - - // These will throw an error if the StoreProduct is not an AppStoreProduct or if the - // entitlement type is not `SERVICE_LEVEL`, which must be caught in a `Throwable` and - // ignored in the paywall object. - entitlements = try container.decode(Set.self, forKey: .entitlements) - let storeProduct = try container.decode(AppStoreProduct.self, forKey: .storeProduct) - type = .appStore(storeProduct) - objcAdapter = .init(store: .appStore, appStoreProduct: storeProduct) - } - - public override func isEqual(_ object: Any?) -> Bool { - guard let other = object as? Product else { - return false - } - return name == other.name - && type == other.type - && entitlements == other.entitlements - } - - public override var hash: Int { - var hasher = Hasher() - hasher.combine(name) - hasher.combine(type) - hasher.combine(entitlements) - return hasher.finalize() - } -} - -struct TemplatingProductItem: Encodable { - let name: String - let productId: String - - private enum CodingKeys: String, CodingKey { - case product - case productId - } - - static func create(from productItems: [Product]) -> [TemplatingProductItem] { - return productItems.compactMap { - guard let name = $0.name else { - return nil - } - return TemplatingProductItem( - name: name, - productId: $0.id - ) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - // Encode name as "product" for templating - try container.encode(name, forKey: .product) - - // Encode product ID as "productId" for templating - try container.encode(productId, forKey: .productId) - } -} diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 84e5d5ded..87a47394a 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -90,16 +90,16 @@ actor ReceiptManager: NSObject { } /// Determines whether the user is subscribed to the given product id. - func isSubscribed(to productId: String) -> Bool { - return manager.purchases + func isSubscribed(to productId: String) async -> Bool { + return await manager.purchases .filter { $0.id == productId } .sorted { $0.purchaseDate > $1.purchaseDate } .first? .isActive == true } - func getActiveProductIds() -> Set { - return Set( + func getActiveProductIds() async -> Set { + return await Set( manager.purchases .filter(\.isActive) .map(\.id) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift index 773d2d5a7..330d8d6cd 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift @@ -9,7 +9,7 @@ import Foundation import StoreKit protocol ReceiptManagerType: AnyObject { - var purchases: Set { get } + var purchases: Set { get async } func loadIntroOfferEligibility(forProducts storeProducts: Set) async func loadPurchases() async -> Set @@ -17,7 +17,7 @@ protocol ReceiptManagerType: AnyObject { } @available(iOS 15.0, *) -final class SK2ReceiptManager: ReceiptManagerType { +actor SK2ReceiptManager: ReceiptManagerType { private var sk2IntroOfferEligibility: [String: Bool] = [:] var purchases: Set = [] diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index acfd0e121..07ae56562 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.0.0-beta.3" + s.version = "4.0.0-beta.4" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com"