diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index 33715621..f88bcaab 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -88,4 +88,7 @@ actor IapState { func snapshotSubscriptionBillingIssue() -> [SubscriptionBillingIssueListener] { subscriptionBillingIssueListeners.map { $0.listener } } + func hasSubscriptionBillingIssueListeners() -> Bool { + !subscriptionBillingIssueListeners.isEmpty + } } diff --git a/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift new file mode 100644 index 00000000..c52576cc --- /dev/null +++ b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift @@ -0,0 +1,285 @@ +import Foundation + +/// Owns the mutable connection resources that must move together during +/// init/end races. OpenIapModule keeps StoreKit behavior; this helper keeps the +/// lock, generation checks, and task handles in one place. +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +final class OpenIapConnectionLifecycle { + // MARK: - Resource Snapshots + + struct CleanupResources { + let updateListenerTask: Task? + let messageListenerTask: Task? + let unfinishedTransactionTask: Task? + let productManager: ProductManager? + let didRegisterPaymentQueueObserver: Bool + } + + struct DeinitResources { + let initTask: Task? + let endTask: Task? + let updateListenerTask: Task? + let messageListenerTask: Task? + let unfinishedTransactionTask: Task? + } + + private let lock = NSLock() + private var connectionGeneration: UInt64 = 0 + private var initTask: Task? + private var initTaskGeneration: UInt64? + private var endTask: Task? + private var endTaskGeneration: UInt64? + private var updateListenerTask: Task? + private var messageListenerTask: Task? + private var unfinishedTransactionTask: Task? + private var productManager: ProductManager? + + #if os(iOS) + private var didRegisterPaymentQueueObserver = false + #endif + + // MARK: - Init / End Tasks + + func currentEndTask() -> Task? { + withLock { endTask } + } + + func makeInitTask( + operation: @escaping (UInt64) async throws -> Bool + ) -> (task: Task, generation: UInt64)? { + withLock { + guard endTask == nil else { + return nil + } + + if let initTask, initTaskGeneration == connectionGeneration { + return (initTask, connectionGeneration) + } + + connectionGeneration += 1 + let generation = connectionGeneration + let task = Task { + try await operation(generation) + } + initTask = task + initTaskGeneration = generation + return (task, generation) + } + } + + func makeEndTask(cleanup: @escaping () async -> Void) -> Task { + withLock { + if let endTask { + return endTask + } + + connectionGeneration += 1 + let generation = connectionGeneration + let taskToCancel = initTask + initTask = nil + initTaskGeneration = nil + + let task = Task { [weak self] in + taskToCancel?.cancel() + if let taskToCancel { + await Self.awaitCancelledInitTask(taskToCancel) + } + + await cleanup() + self?.clearEndTask(generation: generation) + } + endTask = task + endTaskGeneration = generation + return task + } + } + + func clearInitTask(generation: UInt64) { + withLock { + if initTaskGeneration == generation { + initTask = nil + initTaskGeneration = nil + } + } + } + + func clearUnfinishedTransactionTask(generation: UInt64) { + withLock { + if connectionGeneration == generation { + unfinishedTransactionTask = nil + } + } + } + + func ensureCurrent(_ generation: UInt64) throws { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + } + } + + // MARK: - Resource Access + + func currentProductManager() -> ProductManager? { + withLock { productManager } + } + + func getOrCreateProductManager(generation: UInt64) throws -> ProductManager { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + + if let productManager { + return productManager + } + + let productManager = ProductManager() + self.productManager = productManager + return productManager + } + } + + #if os(iOS) + func markPaymentQueueObserverRegisteredIfNeeded(generation: UInt64) throws -> Bool { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + + if didRegisterPaymentQueueObserver { + return false + } + + didRegisterPaymentQueueObserver = true + return true + } + } + #endif + + // MARK: - Listener Tasks + + func startTransactionListenerTask( + generation: UInt64, + makeTask: () -> Task + ) throws { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + guard updateListenerTask == nil else { + return + } + + updateListenerTask = makeTask() + } + } + + func startUnfinishedTransactionTask( + generation: UInt64, + makeTask: () -> Task + ) throws { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + guard unfinishedTransactionTask == nil else { + return + } + + unfinishedTransactionTask = makeTask() + } + } + + func startMessageListenerTask( + generation: UInt64?, + makeTask: () -> Task + ) throws { + try withLock { + if let generation, connectionGeneration != generation { + throw CancellationError() + } + guard endTask == nil, messageListenerTask == nil else { + return + } + + messageListenerTask = makeTask() + } + } + + // MARK: - Cleanup + + func detachResourcesForCleanup() -> CleanupResources { + withLock { + #if os(iOS) + let wasRegistered = didRegisterPaymentQueueObserver + didRegisterPaymentQueueObserver = false + #else + let wasRegistered = false + #endif + + let resources = CleanupResources( + updateListenerTask: updateListenerTask, + messageListenerTask: messageListenerTask, + unfinishedTransactionTask: unfinishedTransactionTask, + productManager: productManager, + didRegisterPaymentQueueObserver: wasRegistered + ) + + updateListenerTask = nil + messageListenerTask = nil + unfinishedTransactionTask = nil + productManager = nil + return resources + } + } + + func detachTasksForDeinit() -> DeinitResources { + withLock { + let resources = DeinitResources( + initTask: initTask, + endTask: endTask, + updateListenerTask: updateListenerTask, + messageListenerTask: messageListenerTask, + unfinishedTransactionTask: unfinishedTransactionTask + ) + + initTask = nil + initTaskGeneration = nil + endTask = nil + endTaskGeneration = nil + updateListenerTask = nil + messageListenerTask = nil + unfinishedTransactionTask = nil + return resources + } + } + + // MARK: - Locking + + private func clearEndTask(generation: UInt64) { + withLock { + if endTaskGeneration == generation { + endTask = nil + endTaskGeneration = nil + } + } + } + + private func withLock(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } + + private static func awaitCancelledInitTask(_ task: Task) async { + do { + _ = try await task.value + } catch is CancellationError { + // Expected when endConnection cancels an in-flight initConnection. + } catch { + OpenIapLog.warn("initConnection failed while endConnection was cancelling it: \(error)") + } + } +} diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 313922e5..8092dac9 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -21,11 +21,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// aren't @objc-bridged automatically. @objc public class func sharedInstance() -> OpenIapModule { shared } - private var updateListenerTask: Task? - private var messageListenerTask: Task? - private var productManager: ProductManager? private let state = IapState() - private var initTask: Task? + private let connection = OpenIapConnectionLifecycle() + private static let initRetryDelayNanoseconds: UInt64 = 1_000_000 private static let subscriptionPreflightTimeoutNanoseconds: UInt64 = 750_000_000 private enum SubscriptionPreflightOutcome { @@ -33,19 +31,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { case timedOut } - // iOS-only: SKPaymentQueue observer for promoted in-app purchases - // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases - #if os(iOS) - private var didRegisterPaymentQueueObserver = false - #endif - private override init() { super.init() } deinit { - updateListenerTask?.cancel() - messageListenerTask?.cancel() + cancelConnectionTasksForDeinit() } // MARK: - Connection Management @@ -59,68 +50,50 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// /// See: https://www.openiap.dev/docs/apis/init-connection public func initConnection() async throws -> Bool { - if await state.isInitialized { - return true - } - - if let task = initTask { - return try await task.value - } - - let task = Task { [weak self] () -> Bool in - guard let self else { return false } - - if await self.state.isInitialized { - return true + while true { + if let endTask = connection.currentEndTask() { + await endTask.value + continue } - if self.productManager == nil { - self.productManager = ProductManager() + if await state.isInitialized { + return true } - // iOS-only: Register SKPaymentQueue observer for promoted in-app purchases - // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases - #if os(iOS) - if !self.didRegisterPaymentQueueObserver { - await MainActor.run { - SKPaymentQueue.default().add(self) + guard let initWork = connection.makeInitTask(operation: { [weak self] generation in + guard let self else { return false } + return try await self.performInitConnection(generation: generation) + }) else { + if let endTask = connection.currentEndTask() { + await endTask.value + } else { + try await Task.sleep(nanoseconds: Self.initRetryDelayNanoseconds) } - self.didRegisterPaymentQueueObserver = true + continue } - #endif // os(iOS) - guard AppStore.canMakePayments else { - self.emitPurchaseError(self.makePurchaseError(code: .iapNotAvailable)) - await self.state.setInitialized(false) + do { + let value = try await initWork.task.value + connection.clearInitTask(generation: initWork.generation) + return value + } catch is CancellationError { + connection.clearInitTask(generation: initWork.generation) return false + } catch { + connection.clearInitTask(generation: initWork.generation) + throw error } - - await self.state.setInitialized(true) - self.startTransactionListener() - Task { [weak self] in - guard let self else { return } - await self.processUnfinishedTransactions() - } - return true - } - initTask = task - - do { - let value = try await task.value - initTask = nil - return value - } catch { - initTask = nil - throw error } } /// Close the store connection and release resources. /// See: https://www.openiap.dev/docs/apis/end-connection public func endConnection() async throws -> Bool { - initTask?.cancel() - initTask = nil - await cleanupExistingState() + let task = connection.makeEndTask { [weak self] in + guard let self else { return } + await self.cleanupExistingState() + } + await task.value return true } @@ -146,7 +119,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } try await ensureConnection() - guard let productManager else { + guard let productManager = connection.currentProductManager() else { let error = makePurchaseError(code: .notPrepared) emitPurchaseError(error) throw error @@ -1424,7 +1397,70 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Private Helpers + private func performInitConnection(generation: UInt64) async throws -> Bool { + try Task.checkCancellation() + try connection.ensureCurrent(generation) + + if await state.isInitialized { + return true + } + + _ = try connection.getOrCreateProductManager(generation: generation) + + // iOS-only: Register SKPaymentQueue observer for promoted in-app purchases + // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases + #if os(iOS) + if try connection.markPaymentQueueObserverRegisteredIfNeeded(generation: generation) { + try Task.checkCancellation() + await MainActor.run { + SKPaymentQueue.default().add(self) + } + } + #endif // os(iOS) + + try Task.checkCancellation() + try connection.ensureCurrent(generation) + if await state.isInitialized { + return true + } + + guard AppStore.canMakePayments else { + emitPurchaseError(makePurchaseError(code: .iapNotAvailable)) + await state.setInitialized(false) + return false + } + + try Task.checkCancellation() + try connection.ensureCurrent(generation) + + await state.setInitialized(true) + try startTransactionListener(generation: generation) + try startUnfinishedTransactionProcessing(generation: generation) + let hasSubscriptionBillingIssueListeners = await state.hasSubscriptionBillingIssueListeners() + try Task.checkCancellation() + try connection.ensureCurrent(generation) + if hasSubscriptionBillingIssueListeners { + try startMessageListener(generation: generation) + } + try Task.checkCancellation() + try connection.ensureCurrent(generation) + return true + } + + private func cancelConnectionTasksForDeinit() { + let resources = connection.detachTasksForDeinit() + resources.initTask?.cancel() + resources.endTask?.cancel() + resources.updateListenerTask?.cancel() + resources.messageListenerTask?.cancel() + resources.unfinishedTransactionTask?.cancel() + } + private func ensureConnection() async throws { + if let endTask = connection.currentEndTask() { + await endTask.value + } + if await state.isInitialized == false { _ = try await initConnection() } @@ -1443,27 +1479,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } private func cleanupExistingState() async { - updateListenerTask?.cancel() - updateListenerTask = nil - messageListenerTask?.cancel() - messageListenerTask = nil + let resources = connection.detachResourcesForCleanup() + resources.updateListenerTask?.cancel() + resources.messageListenerTask?.cancel() + resources.unfinishedTransactionTask?.cancel() await state.reset() // iOS-only: Remove SKPaymentQueue observer for promoted in-app purchases // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases #if os(iOS) - if didRegisterPaymentQueueObserver { + if resources.didRegisterPaymentQueueObserver { await MainActor.run { SKPaymentQueue.default().remove(self) } - didRegisterPaymentQueueObserver = false } #endif // os(iOS) - if let manager = productManager { await manager.removeAll() } - productManager = nil + if let manager = resources.productManager { await manager.removeAll() } } private func storeProduct(for sku: String) async throws -> StoreKit.Product { - guard let productManager else { + guard let productManager = connection.currentProductManager() else { let error = makePurchaseError(code: .notPrepared) emitPurchaseError(error) throw error @@ -1499,75 +1533,86 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { throw makePurchaseError(code: .purchaseError, message: "Missing iOS purchase parameters") } - private func startTransactionListener() { - if updateListenerTask != nil { - return - } - - OpenIapLog.debug("๐ŸŽง [TransactionListener] Starting Transaction.updates listener...") - updateListenerTask = Task { [weak self] in - guard let self else { - OpenIapLog.debug("โš ๏ธ [TransactionListener] Self is nil, exiting listener") - return - } - OpenIapLog.debug("โœ… [TransactionListener] Listener task started, waiting for transactions...") - for await verification in Transaction.updates { - do { - guard await self.state.isInitialized else { continue } - let transaction = try self.checkVerified(verification) - let transactionId = String(transaction.id) - - // Log all transaction details for debugging - OpenIapLog.debug(""" - ๐Ÿ“ฆ Transaction received: - - ID: \(transactionId) - - Product: \(transaction.productID) - - purchaseDate: \(transaction.purchaseDate) - - subscriptionGroupID: \(transaction.subscriptionGroupID ?? "nil") - - revocationDate: \(transaction.revocationDate?.description ?? "nil") - """) - - if transaction.productType == .autoRenewable, - self.isInactiveSubscriptionTransaction(transaction) { - await transaction.finish() - await self.state.removePending(id: transactionId) + private func startTransactionListener(generation: UInt64) throws { + try connection.startTransactionListenerTask(generation: generation) { + OpenIapLog.debug("๐ŸŽง [TransactionListener] Starting Transaction.updates listener...") + return Task { [weak self] in + guard let self else { + OpenIapLog.debug("โš ๏ธ [TransactionListener] Self is nil, exiting listener") + return + } + OpenIapLog.debug("โœ… [TransactionListener] Listener task started, waiting for transactions...") + for await verification in Transaction.updates { + if Task.isCancelled { return } + do { + guard await self.state.isInitialized else { continue } + let transaction = try self.checkVerified(verification) + let transactionId = String(transaction.id) + + // Log all transaction details for debugging OpenIapLog.debug(""" - ๐Ÿงน [TransactionListener] Finished inactive subscription update without emitting: - - SKU: \(transaction.productID) - - Transaction ID: \(transaction.id) - - Expiration: \(transaction.expirationDate?.description ?? "none") - - Revoked: \(transaction.revocationDate?.description ?? "none") - - Upgraded: \(transaction.isUpgraded) + ๐Ÿ“ฆ Transaction received: + - ID: \(transactionId) + - Product: \(transaction.productID) + - purchaseDate: \(transaction.purchaseDate) + - subscriptionGroupID: \(transaction.subscriptionGroupID ?? "nil") + - revocationDate: \(transaction.revocationDate?.description ?? "nil") """) - continue - } - if transaction.revocationDate != nil { - OpenIapLog.debug("โญ๏ธ Skipping revoked transaction: \(transactionId)") - continue + if transaction.productType == .autoRenewable, + self.isInactiveSubscriptionTransaction(transaction) { + await transaction.finish() + await self.state.removePending(id: transactionId) + OpenIapLog.debug(""" + ๐Ÿงน [TransactionListener] Finished inactive subscription update without emitting: + - SKU: \(transaction.productID) + - Transaction ID: \(transaction.id) + - Expiration: \(transaction.expirationDate?.description ?? "none") + - Revoked: \(transaction.revocationDate?.description ?? "none") + - Upgraded: \(transaction.isUpgraded) + """) + continue + } + + if transaction.revocationDate != nil { + OpenIapLog.debug("โญ๏ธ Skipping revoked transaction: \(transactionId)") + continue + } + + // Store pending and emit + await self.state.storePending(id: transactionId, transaction: transaction) + let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) + + OpenIapLog.debug("โœ… [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)") + self.emitPurchaseUpdate(purchase) + } catch { + let purchaseError: PurchaseError + if let existing = error as? PurchaseError { + purchaseError = existing + } else { + purchaseError = makePurchaseError(code: .transactionValidationFailed, message: error.localizedDescription) + } + self.emitPurchaseError(purchaseError) } - - // Store pending and emit - await self.state.storePending(id: transactionId, transaction: transaction) - let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) - - OpenIapLog.debug("โœ… [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)") - self.emitPurchaseUpdate(purchase) - } catch { - let purchaseError: PurchaseError - if let existing = error as? PurchaseError { - purchaseError = existing - } else { - purchaseError = makePurchaseError(code: .transactionValidationFailed, message: error.localizedDescription) - } - self.emitPurchaseError(purchaseError) } } } } + private func startUnfinishedTransactionProcessing(generation: UInt64) throws { + try connection.startUnfinishedTransactionTask(generation: generation) { + Task { [weak self] in + guard let self else { return } + defer { self.connection.clearUnfinishedTransactionTask(generation: generation) } + await self.processUnfinishedTransactions() + } + } + } + private func processUnfinishedTransactions() async { for await verification in Transaction.unfinished { + if Task.isCancelled { return } + guard await state.isInitialized else { return } do { let transaction = try checkVerified(verification) if transaction.productType == .autoRenewable, isInactiveSubscriptionTransaction(transaction) { @@ -1719,24 +1764,34 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - https://developer.apple.com/documentation/storekit/message /// - https://developer.apple.com/documentation/storekit/message/reason-swift.struct/billingissue private func startMessageListener() { + try? startMessageListener(generation: nil) + } + + private func startMessageListener(generation: UInt64) throws { + try startMessageListener(generation: Optional(generation)) + } + + private func startMessageListener(generation: UInt64?) throws { #if os(iOS) || targetEnvironment(macCatalyst) if #available(iOS 18.0, macCatalyst 18.0, *) { - messageListenerTask?.cancel() - OpenIapLog.debug("๐Ÿ”” [MessageListener] Starting Message.messages listener (iOS 18+)") - messageListenerTask = Task { [weak self] in - guard let self else { return } - for await message in StoreKit.Message.messages { - OpenIapLog.debug("๐Ÿ”” [MessageListener] Received message: reason=\(message.reason)") - guard await self.state.isInitialized else { - OpenIapLog.debug("๐Ÿ”” [MessageListener] Skipping โ€” not initialized") - continue - } - guard case .billingIssue = message.reason else { - OpenIapLog.debug("๐Ÿ”” [MessageListener] Skipping non-billingIssue message") - continue + try connection.startMessageListenerTask(generation: generation) { + OpenIapLog.debug("๐Ÿ”” [MessageListener] Starting Message.messages listener (iOS 18+)") + return Task { [weak self] in + guard let self else { return } + for await message in StoreKit.Message.messages { + if Task.isCancelled { return } + OpenIapLog.debug("๐Ÿ”” [MessageListener] Received message: reason=\(message.reason)") + guard await self.state.isInitialized else { + OpenIapLog.debug("๐Ÿ”” [MessageListener] Skipping โ€” not initialized") + continue + } + guard case .billingIssue = message.reason else { + OpenIapLog.debug("๐Ÿ”” [MessageListener] Skipping non-billingIssue message") + continue + } + OpenIapLog.debug("๐Ÿ”” [MessageListener] billingIssue received โ€” dispatching") + await self.dispatchBillingIssueMessage() } - OpenIapLog.debug("๐Ÿ”” [MessageListener] billingIssue received โ€” dispatching") - await self.dispatchBillingIssueMessage() } } } else { diff --git a/packages/apple/Tests/OpenIapTests/OpenIapProviderTests.swift b/packages/apple/Tests/OpenIapTests/OpenIapProviderTests.swift index 78e5a1d8..f037c9f4 100644 --- a/packages/apple/Tests/OpenIapTests/OpenIapProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/OpenIapProviderTests.swift @@ -102,6 +102,23 @@ final class OpenIapProviderTests: XCTestCase { // All listeners should have been cleaned up automatically } + func testConcurrentInitAndEndConnectionDoesNotCrash() async throws { + let module = OpenIapModule.shared + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<20 { + group.addTask { + _ = try? await module.initConnection() + } + group.addTask { + _ = try? await module.endConnection() + } + } + } + + _ = try? await module.endConnection() + } + // MARK: - Introductory Offer Eligibility Tests @MainActor @@ -182,4 +199,4 @@ final class OpenIapProviderTests: XCTestCase { // Clean up try await store.endConnection() } -} \ No newline at end of file +} diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 9624739c..0cd3554c 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,6 +26,175 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // May 8, 2026 โ€” openiap-apple + framework SDK iOS connection teardown patches + { + id: 'apple-2-1-7-framework-ios-connection-teardown-patches', + date: new Date('2026-05-08'), + element: ( +
+ + May 8, 2026 โ€” openiap-apple + framework SDK iOS connection teardown + patches + + +

+ Publishes openiap-apple 2.1.7 and framework-library + patch releases for an iOS lifecycle race where{' '} + endConnection() could + run while{' '} + initConnection() was + still preparing StoreKit resources. The crash was reported from an{' '} + expo-iap unmount path, but the + shared Apple runtime is consumed by all framework SDKs, so the + native Apple patch and six framework patches are released together. + See{' '} + + issue #140 + {' '} + and{' '} + + PR #142 + + . +

+ +
    +
  • + iOS lifecycle fix โ€” connection teardown now + cancels and waits for in-flight initialization before clearing + listener tasks, pending StoreKit work, product cache state, and + promoted-purchase observer registration. +
  • +
  • + Unmount-safe cleanup โ€” duplicate cleanup calls + from JS hooks and native module destruction share the same + teardown path, reducing the crash window on physical iOS devices. +
  • +
  • + Listener stability โ€” subscription billing issue + listeners restore the StoreKit message stream after reconnects + while avoiding duplicate stream tasks when one is already active. +
  • +
  • + No API changes โ€” app code can keep calling{' '} + initConnection() and endConnection() the + same way; direct SPM/CocoaPods consumers should upgrade + openiap-apple, and framework consumers should upgrade their + wrapper package. +
  • +
+ + {/* Package Releases */} + +
+ ), + }, + // May 6, 2026 โ€” maui-iap 1.0.0 published { id: 'maui-iap-1-0-0',