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.