From ab558fa5d758dce9e70cd6518cc7e3472a4d5323 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:02:12 +0900 Subject: [PATCH 1/2] feat(package): support additional StoreKit platforms Declare tvOS, watchOS, and visionOS support and align the external consumer, documentation, app-hosted test project, and CI cross-build coverage. --- .github/workflows/ci.yml | 58 +++++++++++++++++++ Fixtures/ExternalConsumer/Package.swift | 3 + Package.swift | 3 + README.md | 26 ++++++--- .../StoreTransactionKit.md | 7 ++- Tools/TestApp/README.md | 27 +++++++-- .../StoreTransactionKitIntegrationTests.swift | 29 ++++++++-- .../project.pbxproj | 56 +++++++++++++++--- 8 files changed, 182 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd2dc5c..d00e767 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 \ @@ -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 diff --git a/Fixtures/ExternalConsumer/Package.swift b/Fixtures/ExternalConsumer/Package.swift index 7fe7609..0f92855 100644 --- a/Fixtures/ExternalConsumer/Package.swift +++ b/Fixtures/ExternalConsumer/Package.swift @@ -7,6 +7,9 @@ let package = Package( platforms: [ .iOS("18.4"), .macOS("15.4"), + .tvOS("18.4"), + .watchOS("11.4"), + .visionOS("2.4"), ], dependencies: [ .package(path: "../..") diff --git a/Package.swift b/Package.swift index 8802570..c01ccb4 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,9 @@ let package = Package( platforms: [ .iOS("18.4"), .macOS("15.4"), + .tvOS("18.4"), + .watchOS("11.4"), + .visionOS("2.4"), ], products: [ .library( diff --git a/README.md b/README.md index 1391ac2..9727246 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index 0f437e7..9cd3960 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -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 @@ -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 diff --git a/Tools/TestApp/README.md b/Tools/TestApp/README.md index 5bd06be..35e5ae6 100644 --- a/Tools/TestApp/README.md +++ b/Tools/TestApp/README.md @@ -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 \ @@ -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 diff --git a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift index 7dd73c5..38ec2be 100644 --- a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift +++ b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift @@ -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" @@ -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)] ) @@ -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 @@ -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) @@ -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) @@ -584,6 +588,23 @@ struct StoreTransactionKitIntegrationTests { } } + private func purchase( + _ product: Product, + options: Set = [] + ) 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 = [], diff --git a/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj index 7221037..e2dc1ef 100644 --- a/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj +++ b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj @@ -226,12 +226,19 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.IntegrationTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator watchos watchsimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/StoreTransactionKitTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/StoreTransactionKitTestApp"; + TVOS_DEPLOYMENT_TARGET = 18.4; + WATCHOS_DEPLOYMENT_TARGET = 11.4; + XROS_DEPLOYMENT_TARGET = 2.4; }; name = Debug; }; @@ -284,6 +291,9 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; + TVOS_DEPLOYMENT_TARGET = 18.4; + WATCHOS_DEPLOYMENT_TARGET = 11.4; + XROS_DEPLOYMENT_TARGET = 2.4; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 6.0; @@ -346,6 +356,9 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; + TVOS_DEPLOYMENT_TARGET = 18.4; + WATCHOS_DEPLOYMENT_TARGET = 11.4; + XROS_DEPLOYMENT_TARGET = 2.4; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; @@ -361,15 +374,25 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_WKApplication = YES; + "INFOPLIST_KEY_WKWatchOnly[sdk=watchos*]" = YES; + "INFOPLIST_KEY_WKWatchOnly[sdk=watchsimulator*]" = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.TestApp; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator watchos watchsimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; + TVOS_DEPLOYMENT_TARGET = 18.4; + WATCHOS_DEPLOYMENT_TARGET = 11.4; + XROS_DEPLOYMENT_TARGET = 2.4; }; name = Debug; }; @@ -391,13 +414,20 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.IntegrationTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator watchos watchsimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/StoreTransactionKitTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/StoreTransactionKitTestApp"; + TVOS_DEPLOYMENT_TARGET = 18.4; VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 11.4; + XROS_DEPLOYMENT_TARGET = 2.4; }; name = Release; }; @@ -410,16 +440,26 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_WKApplication = YES; + "INFOPLIST_KEY_WKWatchOnly[sdk=watchos*]" = YES; + "INFOPLIST_KEY_WKWatchOnly[sdk=watchsimulator*]" = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.TestApp; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator watchos watchsimulator xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; + TVOS_DEPLOYMENT_TARGET = 18.4; VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 11.4; + XROS_DEPLOYMENT_TARGET = 2.4; }; name = Release; }; From a79c903f847933bd677085dd9754d4ac36c44f63 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:20:14 +0900 Subject: [PATCH 2/2] fix(package): declare Mac Catalyst support --- Fixtures/ExternalConsumer/Package.swift | 1 + Package.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Fixtures/ExternalConsumer/Package.swift b/Fixtures/ExternalConsumer/Package.swift index 0f92855..28fc8da 100644 --- a/Fixtures/ExternalConsumer/Package.swift +++ b/Fixtures/ExternalConsumer/Package.swift @@ -6,6 +6,7 @@ let package = Package( name: "StoreTransactionKitExternalConsumer", platforms: [ .iOS("18.4"), + .macCatalyst("18.4"), .macOS("15.4"), .tvOS("18.4"), .watchOS("11.4"), diff --git a/Package.swift b/Package.swift index c01ccb4..174d991 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,7 @@ let package = Package( name: "StoreTransactionKit", platforms: [ .iOS("18.4"), + .macCatalyst("18.4"), .macOS("15.4"), .tvOS("18.4"), .watchOS("11.4"),