Releases: Kosikowski/swift-storekit
Release list
0.3.0
One-time purchases, trials and subscriptions over StoreKit 2, for macOS 26 and iOS 26, with a simulated store for testing them.
Early. Non-consumables, trials, auto-renewable subscriptions with their offers, and non-renewing subscriptions. No consumables. The API may still move before 1.0, and it moved here: see Breaking below.
.package(url: "https://github.com/Kosikowski/swift-storekit.git", .upToNextMinor(from: "0.3.0"))This release adds subscriptions. Before anything was built, each StoreKit behaviour the design depends on was measured against real StoreKit, on macOS 26.6 and in the iOS 27 simulator. Seven of the answers changed the design. For example, the listing cannot decide who is subscribed. And at the end of every period, StoreKit says for a moment that the subscription has ended. A subscriber must never be locked out by that.
Auto-renewable subscriptions
static let catalogue: Catalogue = [
.subscription(monthly, in: membership, level: 2),
.subscription(yearly, in: membership, level: 2),
.subscription(plus, in: membership, level: 1),
]
let membership = store.standing.subscription(in: Shop.membership) // .unknown, .none, .active or .inactive- Access follows Apple's rule: subscribed, or in a grace period. Billing retry is reported, but grants no access.
HeldSubscriptiongives the state, the renewal, the offer in force, who owns it, and the date access ends. - The status decides, not the listing. The iOS simulator lists a subscription in billing retry, and both platforms list nothing for a moment at each renewal.
- A lapse at the end of a period is believed only if it lasts. For up to 0.7 s at every renewal, StoreKit says the subscription has ended. On iOS this looks exactly like a real lapse.
- Plan changes are read by comparison. A downgrade comes back from
purchase()as a success with the old plan still held. The store compares what it asked for with what it got, and returns.planChangeScheduled(to:at:). - Status changes are heard as they happen. Expiries, cancellations and grace periods send no transaction, so the App Store front also listens to
Status.updates. ManageSubscriptionsButtonshows Apple's sheet on iOS. On the Mac, in a Mac Catalyst app, and for an iPhone or iPad app running on a Mac, it opens the App Store's subscriptions page instead. The store reads again when the app becomes active.
Offers
StoreProduct.subscriptioncarries each offer's terms as StoreKit states them, so a paywall never writes prices itself.- Introductory eligibility has four states. StoreKit's own answer never changes during a process, so the App Store front corrects it using the group's transactions.
- Win-back offers are the ones Apple allows on the account's own status:
winBackOffers(in:). - Promotional offers and the introductory override are signed by the app's
OfferSigning, usually its server. The package holds no key and never buys without a signature. It never asks the signer for someone who has never subscribed. - If StoreKit did not apply an offer, the purchase returns
.offerNotApplied. PurchaseOptionscarries the offer, a billing plan, and an app account token.
The rest of what StoreKit sells subscriptions with
- Non-renewing subscriptions:
.nonRenewing(_:lasting:stacking:). StoreKit gives them no expiry, so the store works out each period from the purchase dates. - 12-month commitments billed monthly: billing plans on a purchase and on a product, and where a subscription stands in its commitment.
- Purchases asked for outside the app (
PurchaseIntent): kept inrequestedPurchasesuntil the app buys or dismisses them. - Subscription bundles, from the 27 SDK.
- Apple's messages:
.storeMessages(deferredWhile:showing:)holds price-rise, billing and win-back sheets while the app asks it to. - Purchases made in Apple's views:
takePurchase(_:of:)andtakeRedemption(_:). In the iOS simulator, an unlock bought inProductViewis announced nowhere else.
Testing
The simulated store renews on its own clock. It goes into a grace period, then billing retry, then expiry, and by default it reproduces the moment at each renewal. A test runs a year of monthly renewals, and the subscriber is never locked out. It also covers offers, commitments, non-renewing subscriptions and requested purchases.
- Scenarios gain
subscribed=,cancelled=,grace=,retry=,lapsed=,period=,renewal=,intro=,winback=,promo=andsignatures=. - The debug panel gains a line for each group, and controls to match.
StoreKitConfigurationchecks subscriptions and offers in the.storekitfile against the catalogue.
Not measured
Xcode's test environment could not produce purchase intents, the 12-month commitment, Apple's messages or subscription bundles. These are built on Apple's documented API. Family Sharing, a renewal while the app is closed, and a promotional offer signed with a real key need the sandbox. None of these has been run by hand in the sandbox for this release. The roadmap's known limits lists them.
Verified
- CI, with Xcode 26.6: every push runs
make check, which covers:- the tests in debug and release;
- the iOS and Mac Catalyst builds;
- the release check, of the package and of the Demo's Release build.
- The hosted lane: real StoreKit on macOS, with Xcode 26.6. It passes. That test environment renews when told to fail a charge, and gives no grace period when told to give one. The package reports what StoreKit said, and the suite expects this with Xcode 26.6 only (spike).
- The same suite with Xcode 27, on the Mac and in the iOS 27 simulator: run locally, along with the UI tests.
Breaking
ProductAccessgains.subscribedand.nonRenewing.PurchaseCompletiongains.subscribed,.offerNotApplied,.planChangeScheduledand.nonRenewing. Exhaustive switches must handle them.- The ports have new requirements, which matter to a store front or fake an app writes itself.
ProductPurchasingandPurchaseCommandingtakeoptions:, andPurchaseCommandinggainsdismissRequestedPurchase(_:).PurchaseStateProvidinggainsrequestedPurchases,introductoryOffer(for:)andwinBackOffers(in:). Calls topurchase(_:)andpurchase(_:confirmation:)compile as before.
Why each of these was chosen, and what was rejected, is in decisions D35–D59 in docs/10-decisions.md. Start with subscriptions and offers.
0.2.0
One-time purchases and trials over StoreKit 2, for macOS 26 and iOS 26, with a simulated store for testing them.
Early. Non-consumables and trials only. No subscriptions, no consumables. The API may still move before 1.0, and it moved here: see Breaking below.
.package(url: "https://github.com/Kosikowski/swift-storekit.git", .upToNextMinor(from: "0.2.0"))This release comes from moving the first real app onto the package and reviewing it. The biggest change is how apps and tests reach the simulated store.
An app imports nothing that is empty in release
In 0.1.0, an app wrote #if DEBUG import PurchaseTestKit #endif round its composition root, its previews and its debug panel. Now every name an app uses exists in every build, and in a release build it does nothing:
import PurchaseLaunch
let launch = StoreLaunch.make(catalogue: Shop.catalogue) // live, or simulated when a debug build is launched with -PurchaseScenario
WindowGroup { ContentView().purchaseStore(launch.store) }| Product | Who imports it | In a release build |
|---|---|---|
PurchaseCore, PurchaseStoreKit, PurchaseUI |
the app | Everything |
PurchaseLaunch (new) |
the app | StoreLaunch.make: the App Store, always. StoreLaunch.preview(catalogue:scenario:) for previews |
PurchaseDebugUI |
the app | PurchaseDebugPanel(launch), which draws nothing. PurchaseDebugPanel.isAvailable is false |
PurchaseTestKit |
test targets only. An app that links it does not build | The clock, the waits, the .storekit check. The simulated store in debug builds only |
PurchaseSimulator (new) |
nobody, usually | Nothing at all: behind #if DEBUG from first line to last |
PurchaseDirectDistribution |
a build sold outside the App Store | EverythingOwnedStoreFront |
The only #if DEBUG an app may still need is round a Window scene, if the debug panel gets a window of its own.
An app that links the test kit does not build
The test kit now calls Swift Testing, which only a test target can link. So an app that links PurchaseTestKit stops in the linker, in Debug and in Release, even if it uses nothing from it. make demo holds this true with an app built to fail. swift package release-check --app still reads a built app for anything that gets past the linker.
The call that does this is new API in its own right. StoreKitConfiguration.expectNoProblems(against:) records each disagreement between the .storekit file and the catalogue as its own failure, worded as a sentence, at the calling line.
What the first integration asked for
- A read that finds nothing new is not published, so an app that reads on every activation no longer redraws every gate. A trial running out still is.
ProductAccess.isGranted: aBool?for "owned or on trial". It stays nil until the store has answered.PurchaseStore.clockis public, so an app and the store never disagree about the time.loadProductsIfNeeded(), used by.purchaseStore(_:).PurchaseStore.diagnose().SimulatedStoreFront(catalogue:owned:clock:behaviour:)andBehaviour(purchase:restore:catalogue:), the one-line set-ups every app's tests were writing for themselves.
Breaking
PurchaseTestSupportis gone. A test imports onlyPurchaseTestKit, which re-exports the simulator.PurchaseTestKitErroris nowScenarioError.- The simulated store moved to
PurchaseSimulator. An app that wrote its own composition root imports that module, under its own#if. Most apps switch toStoreLaunch.makeand need no#ifat all.
Why each of these was chosen, and what was rejected, is in decisions D32–D34 in docs/10-decisions.md. Start with getting started.
0.1.0
One-time purchases and trials over StoreKit 2, for macOS 26 and iOS 26, with a simulated store for testing them.
Early. Non-consumables and trials only. No subscriptions, no consumables. The API may still move before 1.0.
.package(url: "https://github.com/Kosikowski/swift-storekit.git", from: "0.1.0")What is in it
| Product | Link it into | |
|---|---|---|
PurchaseCore |
the app | All the logic. Foundation and Observation only: no StoreKit, no SwiftUI. |
PurchaseStoreKit |
the app | AppStoreFront: the App Store behind Core's protocols. |
PurchaseUI |
the app | .purchaseStore(_:), PurchaseButton, RestorePurchasesButton. No paywall. |
PurchaseTestKit, PurchaseDebugUI |
the app | A simulated store, scenarios, a debug panel. DEBUG only, whole: in a release build both modules are empty. |
PurchaseTestSupport |
test targets only | ManualClock, waitUntil, a .storekit validator. |
PurchaseDirectDistribution |
a build sold outside the App Store | EverythingOwnedStoreFront. |
What it gets right, so that your app does not have to
Each of these was found in shipping code or measured against real StoreKit, and has a test:
- Ownership and prices are read in a task nothing can cancel. Real StoreKit answers a cancelled task with nothing owned and an empty product list — not errors — and SwiftUI cancels
.taskwhenever a view goes away. - A purchase unlocks at once, though StoreKit may list it a second after
purchase()returns. - "Not answered yet" is a state (
unknownis notnone), and ownership never waits for prices. - A purchase that does not verify — or a result StoreKit adds later — is a failure that gets said, never a cancellation.
- A trial is a free non-consumable dated by the App Store: the same trial on every device, and it ends by itself.
- An app using the package links in Release. (With Xcode 27, a package's public function returning
some Viewthat ends in.taskdoes not; see decision D25.)
Testing
swift test runs everything that decides anything, offline. make check adds the layer check, a release test run, the iOS build, builds of the Demo, and swift package release-check, which proves the simulated store is absent from a release build — of the package, and of a built app. The real adapter runs against real StoreKit from a hosted test bundle, on the Mac and in an iOS simulator, nightly. What StoreKit was measured to do, per OS and per Xcode, is in docs/05-testing.md, docs/10-decisions.md and spike/README.md.
Start with getting started; if you have hand-written StoreKit 2 code already, migrating an existing app.