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
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ jobs:
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO

- name: Build for Mac Catalyst
run: |
xcodebuild build \
-scheme StoreTransactionKit \
-destination 'generic/platform=macOS,variant=Mac Catalyst' \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO

- name: Build DocC archive
run: |
xcodebuild docbuild \
Expand All @@ -95,6 +103,56 @@ jobs:
fi
swift build --build-system swiftbuild

supported-platform-builds:
name: Test Build (${{ matrix.name }})
runs-on: macos-26
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- name: tvOS Simulator
destination: generic/platform=tvOS Simulator
slug: tvos
- name: watchOS Simulator
destination: generic/platform=watchOS Simulator
slug: watchos
- name: visionOS Simulator
destination: generic/platform=visionOS Simulator
slug: visionos
env:
XCODE_VERSION: "26.5"
steps:
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Select Xcode
run: |
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 "Selected Xcode: ${xcode_app}"

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

- name: Build app-hosted tests
run: |
xcodebuild build-for-testing \
-workspace Tools/TestApp/StoreTransactionKitTestApp.xcworkspace \
-scheme StoreTransactionKitIntegrationTests \
-destination '${{ matrix.destination }}' \
-derivedDataPath ".build/${{ matrix.slug }}-ci" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO

ios-tests:
name: Package Tests (iOS Simulator)
runs-on: macos-26
Expand Down
4 changes: 4 additions & 0 deletions Fixtures/ExternalConsumer/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ let package = Package(
name: "StoreTransactionKitExternalConsumer",
platforms: [
.iOS("18.4"),
.macCatalyst("18.4"),
.macOS("15.4"),
.tvOS("18.4"),
.watchOS("11.4"),
.visionOS("2.4"),
],
dependencies: [
.package(path: "../..")
Expand Down
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ let package = Package(
name: "StoreTransactionKit",
platforms: [
.iOS("18.4"),
.macCatalyst("18.4"),
.macOS("15.4"),
.tvOS("18.4"),
.watchOS("11.4"),
.visionOS("2.4"),
Comment thread
lynnswap marked this conversation as resolved.
],
products: [
.library(
Expand Down
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ observable store.

- iOS 18.4+
- macOS 15.4+
- Mac Catalyst 18.4+
- tvOS 18.4+
- watchOS 11.4+
- visionOS 2.4+
- Swift 6.3+

## What it owns — and what your app owns
Expand All @@ -25,10 +29,12 @@ The store owns the durable transaction path for the process lifetime:

Your app owns everything the user sees and everything it persists:

- Paywall and purchase UI (StoreKit views or `Product.purchase`)
- Paywall and purchase UI (StoreKit views or the platform-appropriate
StoreKit purchase action)
- The durable ledger that the transaction handler writes to
- Subscription status presentation (`Product.SubscriptionInfo.Status`)
- Purchases that begin outside the app (`PurchaseIntent.intents`)
- Purchases that begin outside the app on platforms that provide
`PurchaseIntent.intents`

## Quick start

Expand Down Expand Up @@ -208,15 +214,16 @@ For the full delivery, reconciliation, and failure-reporting model, see

## Beyond the basics

- **Custom purchase UI** — load products and purchase with StoreKit, then pass
the `Product.PurchaseResult` to `store.process(_:)`. `.pending` outcomes
arrive later through the handler.
- **Custom purchase UI** — load products and start the purchase with StoreKit
views, SwiftUI's `PurchaseAction`, or the platform-appropriate `Product`
purchase API. Pass the resulting `Product.PurchaseResult` to
`store.process(_:)`; `.pending` outcomes arrive later through the handler.
- **Restore** — call `store.restorePurchases()` only from an explicit user
action; `AppStore.sync()` presents authentication UI, and
`StoreKitError.userCancelled` is a normal outcome, not a diagnostic failure.
- **Promoted purchases and win-back offers** — the app owns
`PurchaseIntent.intents`: complete each intent's purchase and pass the
result to `store.process(_:)`.
- **Promoted purchases and win-back offers** — on platforms that provide
`PurchaseIntent.intents`, the app completes each intent's purchase and
passes the result to `store.process(_:)`.
- **Renewal, grace-period, and billing-retry UI** — read
`Product.SubscriptionInfo.Status` directly; the store owns the durable
transaction path, not subscription status presentation.
Expand All @@ -225,6 +232,9 @@ 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).
Apple's
[purchase API guidance](https://developer.apple.com/documentation/storekit/product/purchase(options:))
explains which purchase entry point to use for each UI framework and platform.

## Testing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ StoreKit can deliver a purchase as a direct `Product.PurchaseResult`, through
`Transaction.updates`, or as unfinished work on a later launch.
``TransactionStore`` normalizes these paths into one verified, FIFO transaction
processor and publishes observable current-entitlement state. It supports
iOS 18.4 and later and macOS 15.4 and later.
iOS and Mac Catalyst 18.4 and later, macOS 15.4 and later, tvOS 18.4 and
later, watchOS 11.4 and later, and visionOS 2.4 and later.

Create one store in the application composition root and retain it for the
process lifetime; call ``TransactionStore/close()`` only from controlled
Expand All @@ -30,8 +31,8 @@ coalescing, `finish()`, entitlement refresh, history ordering, restore
synchronization, background failure delivery, and explicit shutdown. The app
continues to own persistence, server communication, access presentation, the
concrete purchase scene or window, raw `Product.SubscriptionInfo.Status`
interpretation for renewal UI, and `PurchaseIntent.intents` handling for
purchases that begin outside the app.
interpretation for renewal UI, and, where the API is available,
`PurchaseIntent.intents` handling for purchases that begin outside the app.

> Important: StoreTransactionKit exposes an at-least-once handler-delivery
> contract. Make the injected transaction handler durably idempotent using
Expand Down
27 changes: 23 additions & 4 deletions Tools/TestApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ The serialized suite covers:
- empty, existing-entitlement, and failed-then-retried restores
- Ask to Buy approval and decline

Run the suite on an installed iOS Simulator. StoreKit Test owns one environment
per process, so parallel test execution must remain disabled.
The host app and test bundle support iOS, tvOS, watchOS, and visionOS. Run the
StoreKit runtime suite on an installed iOS Simulator. StoreKit Test owns one
environment per process, so parallel test execution must remain disabled.

```sh
xcodebuild test \
Expand All @@ -32,14 +33,32 @@ xcodebuild test \
-parallel-testing-enabled NO
```

Cross-build the complete app-hosted test bundle for the additional platforms:

```sh
for destination in \
'generic/platform=tvOS Simulator' \
'generic/platform=watchOS Simulator' \
'generic/platform=visionOS Simulator'
do
xcodebuild build-for-testing \
-workspace Tools/TestApp/StoreTransactionKitTestApp.xcworkspace \
-scheme StoreTransactionKitIntegrationTests \
-destination "$destination" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO
done
```

The tests wait for observable state changes with cancellation-aware event
signals. They don't use fixed sleeps or accelerated wall-clock time. A suite
time limit exists only to surface a missing event as a failed test instead of
hanging the test process.

CI runs this suite with Xcode 26.5 on the iOS 26.2 simulator available in the
GitHub macOS 26 image. The same suite is also validated locally with Xcode 26.5
on iOS 18.6 to cover the supported iOS 18 line.
GitHub macOS 26 image. CI also cross-builds the app host and test bundle for
tvOS, watchOS, and visionOS simulators. The same runtime suite is validated
locally with Xcode 26.5 on iOS 18.6 to cover the supported iOS 18 line.

Local StoreKit testing doesn't validate App Store Connect configuration,
App Store Server Notifications, cross-device propagation, Family Sharing or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import StoreKit
import StoreKitTest
import StoreTransactionKit
import Testing
#if os(visionOS)
import UIKit
#endif

private enum Entitlement: String, Hashable, Sendable {
case lifetime = "com.example.StoreTransactionKit.lifetime"
Expand Down Expand Up @@ -77,7 +80,8 @@ struct StoreTransactionKitIntegrationTests {
try await withTestContext { context in
let product = try await context.product(.plus)
let appAccountToken = UUID()
let result = try await product.purchase(
let result = try await purchase(
product,
options: [.appAccountToken(appAccountToken)]
)

Expand Down Expand Up @@ -221,7 +225,7 @@ struct StoreTransactionKitIntegrationTests {
try await withTestContext { context in
context.session.interruptedPurchasesEnabled = true
let product = try await context.product(.plus)
let result = try await product.purchase()
let result = try await purchase(product)
guard case .pending = result else {
Issue.record("Expected the interrupted purchase to remain pending.")
return
Expand Down Expand Up @@ -531,7 +535,7 @@ struct StoreTransactionKitIntegrationTests {
try await withTestContext { context in
context.session.askToBuyEnabled = true
let product = try await context.product(.plus)
let result = try await product.purchase()
let result = try await purchase(product)

let outcome = try await context.store.process(result)

Expand All @@ -556,7 +560,7 @@ struct StoreTransactionKitIntegrationTests {
try await withTestContext { context in
context.session.askToBuyEnabled = true
let product = try await context.product(.plus)
let result = try await product.purchase()
let result = try await purchase(product)

let outcome = try await context.store.process(result)

Expand Down Expand Up @@ -584,6 +588,23 @@ struct StoreTransactionKitIntegrationTests {
}
}

private func purchase(
_ product: Product,
options: Set<Product.PurchaseOption> = []
) async throws -> Product.PurchaseResult {
#if os(visionOS)
let scene = try #require(
UIApplication.shared.connectedScenes.first {
$0.activationState == .foregroundActive
},
"The visionOS test host does not have a foreground scene."
)
return try await product.purchase(confirmIn: scene, options: options)
#else
return try await product.purchase(options: options)
#endif
}

private func withTestContext(
preexistingSubscription: Entitlement? = nil,
preexistingPurchaseOptions: Set<Product.PurchaseOption> = [],
Expand Down
Loading