Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Fixtures/ExternalConsumer/Sources/Consumer/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ struct Consumer {
}
)

print("Active entitlements: \(store.activeEntitlements)")
if let activeEntitlements = store.activeEntitlements {
print("Active entitlements: \(activeEntitlements)")
} else {
print("Active entitlements are unresolved")
}
try await store.close()
}
}
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ and `Transaction.updates` monitoring during initialization. Retain one instance
in the application's process-lifetime composition.

`activeEntitlements` contains the app-defined identifiers represented by
StoreKit's current entitlements. `entitlements` contains the complete verified
StoreKit's current entitlements. It is `nil` while the initial entitlement
query is unresolved and becomes a non-`nil` empty set when no known
subscription is active. `entitlements` contains the complete verified
snapshot, including product identifiers outside `SubscriptionID`.

## Use SubscriptionStoreView
Expand All @@ -77,8 +79,12 @@ struct PremiumStoreView: View {

var body: some View {
VStack {
if store.activeEntitlements.contains(.yearly) {
Label("Premium active", systemImage: "checkmark.seal.fill")
if let activeEntitlements = store.activeEntitlements {
if activeEntitlements.contains(.yearly) {
Label("Premium active", systemImage: "checkmark.seal.fill")
}
} else {
ProgressView()
}

SubscriptionStoreView(
Expand Down
17 changes: 12 additions & 5 deletions Sources/StoreTransactionKit/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,18 @@ where
public private(set) var entitlements: StoreEntitlements?

/// App-defined identifiers represented by the latest current entitlements.
public private(set) var activeEntitlements: Set<EntitlementID> = []
///
/// The value is `nil` until the first entitlement query succeeds. An empty
/// set is non-`nil` and means none of the app-defined identifiers is
/// currently entitled.
public var activeEntitlements: Set<EntitlementID>? {
entitlements.map { entitlements in
Set(
entitlements.transactions.compactMap {
EntitlementID(rawValue: $0.productID)
})
}
}

/// The error from the initial entitlement query when startup did not reach
/// readiness.
Expand Down Expand Up @@ -156,10 +167,6 @@ where

fileprivate func apply(_ entitlements: StoreEntitlements) {
self.entitlements = entitlements
activeEntitlements = Set(
entitlements.transactions.compactMap {
EntitlementID(rawValue: $0.productID)
})
startupError = nil
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ processor and publishes observable current-entitlement state.

Create one store in the application composition root. Supply an idempotent
transaction handler that commits the app's business effect before returning.
The app defines a string-backed entitlement identifier type; ``Store/activeEntitlements``
then exposes a typed set derived from StoreKit's verified current entitlements.
Pass direct results from custom purchase UI into ``Store/process(_:)``.
The app defines a string-backed entitlement identifier type;
``Store/activeEntitlements`` then exposes an optional typed set derived from
StoreKit's verified current entitlements. `nil` means the initial entitlement
query remains unresolved; an empty set means the query completed with no
matching entitlement. Pass direct results from custom purchase UI into
``Store/process(_:)``.

The framework owns StoreKit verification, process-local exact-revision
coalescing, `finish()`, entitlement refresh, history ordering, restore
Expand Down
19 changes: 19 additions & 0 deletions Tests/StoreTransactionKitTests/StoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct StoreTests {
reportFailure: { _ in }
)

#expect(store.activeEntitlements == nil)
await store.waitForStartup()

#expect(store.activeEntitlements == [.monthly])
Expand Down Expand Up @@ -57,10 +58,12 @@ struct StoreTests {
reportFailure: { _ in }
)

#expect(store.activeEntitlements == nil)
await query.waitForRequest(1)
await query.fail(TestFailure())
await store.waitForStartup()
#expect(store.entitlements == nil)
#expect(store.activeEntitlements == nil)
#expect(store.startupError != nil)

let refresh = Task { try await store.refreshEntitlements() }
Expand All @@ -75,6 +78,22 @@ struct StoreTests {
try await store.close()
}

@Test("an empty set means entitlement resolution completed")
func emptyTypedEntitlementProjection() async throws {
let fixture = TestSourceFixture(currentEntitlements: { [] })
fixture.unfinished.finish()
let store = Store<SubscriptionID>(
source: fixture.source,
handleTransaction: { _ in },
reportFailure: { _ in }
)

#expect(store.activeEntitlements == nil)
await store.waitForStartup()
#expect(store.activeEntitlements == Set<SubscriptionID>())
try await store.close()
}

@Test("the Store facade rejects handler reentry during startup")
func startupHandlerReentrancy() async throws {
let fixture = TestSourceFixture()
Expand Down