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
52 changes: 29 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
xcodebuild -showsdks

- name: Lint Swift formatting
run: xcrun swift-format lint --recursive --strict Sources Tests Package.swift
run: xcrun swift-format lint --recursive --strict Sources Tests Tools/TestApp Package.swift

- name: Run Swift package tests
run: swift test --build-system swiftbuild --no-parallel
Expand Down Expand Up @@ -98,42 +98,38 @@ jobs:
ios-tests:
name: Package Tests (iOS Simulator)
runs-on: macos-26
timeout-minutes: 20
timeout-minutes: 10
env:
XCODE_MAJOR: "26"
XCODE_VERSION: "26.5"
steps:
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Resolve latest stable Xcode
- name: Select Xcode
run: |
xcode_app="$(ruby <<'RUBY'
xcode_major = ENV.fetch("XCODE_MAJOR")
candidates = Dir["/Applications/Xcode_#{xcode_major}*.app"].select { |path| File.directory?(path) }
candidates = candidates.reject do |path|
labels = [path, File.realpath(path)].map { |candidate| File.basename(candidate).downcase }
labels.any? { |label| label.match?(/beta|release[ _-]?candidate|(?:^|[_. -])rc(?:$|[_. -])/) }
end
selected = candidates.max_by do |path|
File.basename(path)[/Xcode_(\d+(?:\.\d+)*)/, 1].to_s.split(".").map(&:to_i)
end
abort("No stable Xcode #{xcode_major} installation found") unless selected
puts selected
RUBY
)"
xcode_app="/Applications/Xcode_${XCODE_VERSION}.app"
if [[ ! -d "${xcode_app}" ]]; then
echo "Xcode ${XCODE_VERSION} is not installed on the runner." >&2
exit 1
fi
echo "DEVELOPER_DIR=${xcode_app}/Contents/Developer" >> "$GITHUB_ENV"
echo "Resolved Xcode: ${xcode_app}"
echo "Selected Xcode: ${xcode_app}"

- name: Show Xcode environment
run: |
xcodebuild -version
swift --version

- name: Select iOS Simulator
run: |
simulator_id="$(xcrun simctl list devices available --json | ruby -rjson -e '
runtimes = JSON.parse(STDIN.read).fetch("devices").select { |name, _| name.include?(".iOS-") }
_, devices = runtimes.max_by { |name, _| name.scan(/\d+/).map(&:to_i) }
abort("No available iOS runtime found") unless devices
runtimes = JSON.parse(STDIN.read).fetch("devices")
_, devices = runtimes.find { |name, _| name.end_with?(".iOS-26-2") }
abort("The iOS 26.2 Simulator runtime is required for StoreKit tests") unless devices
device = devices.find { |candidate| candidate["isAvailable"] && candidate["name"].start_with?("iPhone") }
abort("No available iPhone Simulator found") unless device
abort("No available iPhone with iOS 26.2 found") unless device
puts device.fetch("udid")
')"
echo "SIMULATOR_ID=${simulator_id}" >> "$GITHUB_ENV"
Expand All @@ -145,3 +141,13 @@ jobs:
-destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO

- name: Run StoreKit integration tests
run: |
xcodebuild test \
-workspace Tools/TestApp/StoreTransactionKitTestApp.xcworkspace \
-scheme StoreTransactionKitIntegrationTests \
-destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \
-parallel-testing-enabled NO \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ private enum EntitlementID: String, Hashable, Sendable {
@MainActor
struct Consumer {
static func main() async throws {
let store = Store<EntitlementID>(
let store = TransactionStore<EntitlementID>(
handleTransaction: { transaction in
print("Handle transaction \(transaction.id)")
},
Expand Down
103 changes: 82 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ durable processing, and `Transaction.finish()` into one process-owned,
observable store.
It supports iOS 18.4 and later and macOS 15.4 and later.

## Create a Store
## Create a TransactionStore

Define the entitlement identifiers in the app. A string-backed enum keeps
StoreKit product identifiers typed without requiring a framework protocol.
Expand All @@ -20,10 +20,20 @@ enum SubscriptionID: String, Hashable, Sendable {

actor PurchaseLedger {
func apply(_ transaction: StoreTransactionSnapshot) async throws {
try await database.commitPurchase(
transactionID: transaction.id,
productID: transaction.productID
)
if let revocationDate = transaction.revocationDate {
try await database.revokePurchase(
transactionID: transaction.id,
productID: transaction.productID,
signedDate: transaction.signedDate,
revocationDate: revocationDate
)
} else {
try await database.commitPurchase(
transactionID: transaction.id,
productID: transaction.productID,
signedDate: transaction.signedDate
)
}
}
}

Expand All @@ -37,8 +47,8 @@ actor StoreDiagnostics {
func makeStore(
ledger: PurchaseLedger,
diagnostics: StoreDiagnostics
) -> Store<SubscriptionID> {
Store(
) -> TransactionStore<SubscriptionID> {
TransactionStore(
handleTransaction: { transaction in
try await ledger.apply(transaction)
},
Expand All @@ -49,15 +59,34 @@ func makeStore(
}
```

`Store` is `@MainActor` and `@Observable`. It starts `Transaction.unfinished`
and `Transaction.updates` monitoring during initialization. Retain one instance
in the application's process-lifetime composition.
`TransactionStore` is `@MainActor` and `@Observable`. It monitors
`Transaction.unfinished`, `Transaction.updates`, and subscription status
changes during initialization. Retain one instance in the application's
process-lifetime composition.

Startup and each entitlement refresh reconcile `Transaction.unfinished`
before publishing entitlement state. Every verified delivery is durably
handled, including consumables. If the handler fails, startup or the refresh
fails, the transaction remains unfinished, and a later refresh retries it.
An unverified unfinished delivery is sent to `reportFailure` with source
`.unfinished`.

`activeEntitlements` contains the app-defined identifiers represented by
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`.
identifier matches a current entitlement. A subscription superseded by an
upgrade is excluded from this set. `entitlements` contains the complete
verified snapshot, including superseded transactions and product identifiers
outside `SubscriptionID`. Use `StoreTransactionSnapshot.subscriptionGroupID`
when the app grants access at subscription-group rather than product-tier
granularity. A current-entitlement element that StoreKit can't verify is
omitted from the projection and delivered to `reportFailure` with source
`.currentEntitlementVerification`.

For renewal dates, grace periods, billing retry, and expiration messaging,
read `Product.SubscriptionInfo.Status` directly. `TransactionStore` owns the
durable transaction path and current-entitlement projection, not subscription
status presentation.

## Use SubscriptionStoreView

Expand All @@ -75,14 +104,29 @@ import StoreKit
import SwiftUI

struct PremiumStoreView: View {
@Environment(Store<SubscriptionID>.self) private var store
@Environment(TransactionStore<SubscriptionID>.self) private var store
@State private var refreshError: (any Error)?

var body: some View {
VStack {
if let activeEntitlements = store.activeEntitlements {
if activeEntitlements.contains(.yearly) {
if activeEntitlements.contains(.monthly)
|| activeEntitlements.contains(.yearly)
{
Label("Premium active", systemImage: "checkmark.seal.fill")
}
} else if let error = refreshError ?? store.startupError {
Text(error.localizedDescription)
Button("Retry") {
Task {
do {
try await store.refreshEntitlements()
refreshError = nil
} catch {
refreshError = error
}
}
}
} else {
ProgressView()
}
Expand All @@ -98,21 +142,38 @@ struct PremiumStoreView: View {
No `onInAppPurchaseCompletion` modifier is needed here. By default, successful
StoreKit view purchases are delivered through `Transaction.updates`, which the
store already monitors. A non-`nil` completion action replaces that default;
if you add one, pass its successful `Product.PurchaseResult` to
`store.process(_:)`.

`PurchaseLedger.apply(_:)` must be idempotent because StoreKit delivery is at
least once. It must return only after the business effect is durable. The
store calls `finish()` after that return.
if you add one, pass each `.success` value (the `Product.PurchaseResult`) to
`store.process(_:)`. The action also replaces StoreKit's default failure alert,
so it must own `.failure` presentation or diagnostics.

StoreTransactionKit exposes an at-least-once handler-delivery contract, so
`PurchaseLedger.apply(_:)` must be idempotent. It must return only after the
business effect is durable. The store calls `finish()` after that return. Treat
purchase and revocation revisions as distinct durable business events;
transaction ID alone is not a sufficient idempotency key for both. The handler
must not call methods on the same store, including through an awaited detached
task, because that creates a dependency cycle with the transaction being
handled.

Apps that support promoted purchases or applicable win-back flows own
`PurchaseIntent.intents`: complete each intent's product purchase and pass its
result to `store.process(_:)`.

Call `store.restorePurchases()` only from an explicit user action because
`AppStore.sync()` may present authentication UI.
`AppStore.sync()` presents authentication UI. Treat `StoreKitError.userCancelled`
as a normal user outcome rather than a diagnostic failure.

For product merchandising and UI composition, use Apple's
[Getting started with In-App Purchase using StoreKit views](https://developer.apple.com/documentation/storekit/getting-started-with-in-app-purchases-using-storekit-views)
and
[Implementing a store in your app using the StoreKit API](https://developer.apple.com/documentation/storekit/implementing-a-store-in-your-app-using-the-storekit-api).

## Testing

The app-hosted StoreKit integration suite runs with `xcodebuild`. See
[Tools/TestApp/README.md](Tools/TestApp/README.md) for the scenarios and
command.

## License

StoreTransactionKit is available under the MIT License.
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ package actor FailureReporterDispatcher {

private func startWorkerIfNeeded() {
guard worker == nil else { return }
worker = Task {
await drainQueue()
worker = Task.detached { [weak self] in
guard let self else { return }
await self.drainQueue()
}
}

Expand Down
Loading