swift6storekit2

Deinit Task - Main actor-isolated property 'updateListenerTask' can not be referenced from a nonisolated context


I encountered an error during the deinit process of a class, and I suspect the issue stems from using the @Observable macro. Specifically, I’m getting the following error message:

// TODO: ERROR: Main actor-isolated property 'updateListenerTask' cannot be referenced from a nonisolated context It seems like the property updateListenerTask is marked as @MainActor, but the deinit method isn't being executed in a MainActor-isolated context. This leads to the error when trying to clean up or interact with the task.

I’m curious if anyone has tips or best practices for handling these kinds of issues. Adding @Mainactor to the function does not work.

import Foundation
import StoreKit
import SwiftUI

public typealias Transaction = StoreKit.Transaction
public typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
public typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState

@MainActor
@Observable
public class Store {
    // All available Products
    private(set) var consumables: [Product]
    private(set) var subscriptions: [Product]

    // Check renewal state for subscription. Not yet implemented
    private(set) var subscriptionGroupStatus: RenewalState?

    // This is the purchased product
    private(set) var purchasedSubscriptionLevel: SubscriptionLevel?

    // This can be multiple subscriptions. For now the implementation is that there is one subscription group!
    private var purchasedSubscriptions: [Product] = []
    private var productIDs = ProductIdentifiers.shared.getAllProductIDs()

    private var updateListenerTask: Task<Void, Error>?

    private var isPurchaseInProgress: Bool = false

    public init() {
        // Initialize empty products, and then do a product request asynchronously to fill them in.
        consumables = []
        subscriptions = []

        // Start a transaction listener as close to app launch as possible so you don't miss any transactions.
        updateListenerTask = listenForTransactions()

        Task {
            // During store initialization, request products from the App Store.
            await requestProducts()

            // Deliver products that the customer purchases.
            await updateCustomerProductStatus()
        }
    }

    // TODO: ERROR: Main actor-isolated property 'updateListenerTask' can not be referenced from a nonisolated context
    deinit {
        updateListenerTask?.cancel()
    }

    private func listenForTransactions() -> Task<Void, Error> {
        Task.detached {
            // Iterate through any transactions that don't come from a direct call to `purchase()`.
            for await result in Transaction.updates {
                do {
                    let transaction = try await self.checkVerified(result)

                    // Deliver products to the user.
                    await self.updateCustomerProductStatus()

                    // Always finish a transaction.
                    await transaction.finish()
                } catch {
                    // StoreKit has a transaction that fails verification. Don't deliver content to the user.
                    print("Transaction failed verification")
                }
            }
        }
    }

    private func requestProducts() async {
        do {
            // Request products from the App Store using a ProductIdentifiers in StoreKitConfiguration
            let storeProducts = try await Product.products(for: productIDs)

            var newSubscriptions: [Product] = []
            var newConsumable: [Product] = []

            // Filter the products into categories based on their type.
            for product in storeProducts {
                switch product.type {
                case .consumable:
                    newConsumable.append(product)
                case .nonConsumable:
                    // Ignore this product.
                    print("No non consumables at the moment")
                case .autoRenewable:
                    newSubscriptions.append(product)
                case .nonRenewable:
                    print("No non renawbles at the moment")
                default:
                    // Ignore this product.
                    print("Unknown product")
                }
            }

            // Add to Observable variable
            subscriptions = newSubscriptions
            consumables = newConsumable
        } catch {
            print("Failed product request from the App Store server: \(error)")
        }
    }

    private func purchase(_ product: Product) async throws -> Transaction? {
        // Begin purchasing the `Product` the user selects.
        let result = try await product.purchase()

        switch result {
        case let .success(verification):
            // Check whether the transaction is verified. If it isn't,
            // this function rethrows the verification error.
            let transaction = try checkVerified(verification)

            // The transaction is verified. Deliver content to the user.
            await updateCustomerProductStatus()

            // Always finish a transaction.
            await transaction.finish()

            return transaction
        case .userCancelled, .pending:
            return nil
        default:
            return nil
        }
    }

    private func isPurchased(_ product: Product) async throws -> Bool {
        // Determine whether the user purchases a given product.
        switch product.type {
        case .nonRenewable:
            return false
        case .nonConsumable:
            return false
        case .autoRenewable:
            return purchasedSubscriptions.contains(product)
        default:
            return false
        }
    }

    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        // Check whether the JWS passes StoreKit verification.
        switch result {
        case .unverified:
            // StoreKit parses the JWS, but it fails verification.
            throw StoreError.failedVerification
        case let .verified(safe):
            // The result is verified. Return the unwrapped value.
            return safe
        }
    }

    private func updateCustomerProductStatus() async {
        var purchasedSubscriptions: [Product] = []

        // Iterate through all of the user's purchased products.
        for await result in Transaction.currentEntitlements {
            do {
                // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error.
                let transaction = try checkVerified(result)

                // Check the `productType` of the transaction and get the corresponding product from the store.
                switch transaction.productType {
                case .autoRenewable:
                    if let subscription = subscriptions.first(where: { $0.id == transaction.productID }) {
                        purchasedSubscriptions.append(subscription)
                        purchasedSubscriptionLevel = SubscriptionLevel(subscription)
                    }
                default:
                    break
                }
            } catch {
                print("Failed to verify transaction: \(error.localizedDescription)")
            }
        }

        // Update the store information with auto-renewable subscription products.
        self.purchasedSubscriptions = purchasedSubscriptions

        // Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer
        // is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription
        // group, so products in the subscriptions array all belong to the same group. The statuses that
        // `product.subscription.status` returns apply to the entire subscription group.
        subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state
    }

    private func startPurchase() -> Bool {
        if isPurchaseInProgress {
            return false
        }
        isPurchaseInProgress = true
        return true
    }

    private func endPurchase() {
        isPurchaseInProgress = false
    }

    private func performPurchase(for product: Product) async throws -> Transaction {
        guard startPurchase() else {
            throw PurchaseError.purchaseInProgress
        }

        defer {
            endPurchase()
        }

        do {
            if let transaction = try await purchase(product) {
                return transaction
            } else {
                throw PurchaseError.failedPurchase // Or create a new error like PurchaseError.userCancelled
            }
        } catch {
            throw error
        }
    }
}

// MARK: Helper functions

public extension Store {
    func isSubscribed() -> Bool {
        return purchasedSubscriptionLevel != nil
    }
}

// MARK: Purchase functions

public extension Store {
    func purchaseSubscription(for product: Product) async throws -> Transaction {
        guard purchasedSubscriptions.isEmpty else {
            throw PurchaseError.alreadySubscribed
        }

        do {
            let transaction = try await performPurchase(for: product)
            print("LOGC transactionid: \(transaction)")
            return transaction
        } catch StoreError.failedVerification {
            throw PurchaseError.failedVerification
        } catch {
            throw PurchaseError.failedPurchase
        }
    }
}

public enum StoreError: Error {
    case failedVerification
}

enum PurchaseError: Error {
    case purchaseInProgress
    case productNotFound
    case failedPurchase
    case failedVerification
    case alreadySubscribed

    var message: String {
        switch self {
        case .purchaseInProgress:
            return "Another purchase is in progress."
        case .productNotFound:
            return "Product not found."
        case .failedPurchase:
            return "Failed to complete the purchase."
        case .failedVerification:
            return "Could not verify purchase with the Apple Store."
        case .alreadySubscribed:
            return "You are already subscribed."
        }
    }
}

Solution

  • I didn't know what @Observable do under the hood (as I think it might convert your code to computed property), but if I remove @Observable your code compile successfully. In your code updateListenerTask is a private property so there is no need to notify when its value change, so a way to acheive your goal is mark updateListenerTask with macro ObservationIgnored:

    @ObservationIgnored private var updateListenerTask: Task<Void, Error>?
    

    Also there will be Isolated synchronous deinit implemented in this SE-0371 which will available in Swift 6.1