Skip to content
Open
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,18 @@ When bumping the version, update all three files:
- **ALWAYS run `swiftlint --fix` on any files that have linting violations.**
- **Remember: No trailing whitespace is allowed on any lines.**
- **Update CHANGELOG.md for customer-facing changes:** Include new API additions, bug fixes, and crash fixes. Focus on what the change does for developers, not internal implementation details. For example: "Added `setIntegrationAttribute()` method to enable setting individual attribution provider IDs" or "Fixed crash when handling expired subscriptions".

### Pull Requests

When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPLATE.md`:

- [ ] All unit tests pass.
- [ ] All UI tests pass.
- [ ] Demo project builds and runs on iOS.
- [ ] Demo project builds and runs on Mac Catalyst.
- [ ] Demo project builds and runs on visionOS.
- [ ] I added/updated tests or detailed why my change isn't tested.
- [ ] I added an entry to the `CHANGELOG.md` for any breaking changes, enhancements, or bug fixes.
- [ ] I have run `swiftlint` in the main directory and fixed any issues.
- [ ] I have updated the SDK documentation as well as the online docs.
- [ ] I have reviewed the [contributing guide](https://github.com/superwall-me/paywall-ios/tree/master/.github/CONTRIBUTING.md)
4 changes: 2 additions & 2 deletions Examples/Advanced/Advanced.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.superwall.Advanced;
PRODUCT_BUNDLE_IDENTIFIER = com.superwall.Advancedn;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator";
SUPPORTS_MACCATALYST = YES;
Expand Down Expand Up @@ -450,7 +450,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.superwall.Advanced;
PRODUCT_BUNDLE_IDENTIFIER = com.superwall.Advancedn;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator";
SUPPORTS_MACCATALYST = YES;
Expand Down
68 changes: 67 additions & 1 deletion Sources/SuperwallKit/Config/ConfigManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
& AudienceFilterAttributesFactory
& ReceiptFactory
& DeviceHelperFactory
& TestModeManagerFactory
& HasExternalPurchaseControllerFactory
private let factory: Factory

init(
Expand Down Expand Up @@ -394,12 +396,26 @@
triggersByPlacementName = ConfigLogic.getTriggersByPlacementName(from: config.triggers)
choosePaywallVariants(from: config.triggers)

await factory.loadPurchasedProducts(config: config)
// Evaluate test mode before loading products
let testModeManager = factory.makeTestModeManager()
testModeManager.evaluateTestMode(config: config)

if testModeManager.isTestMode {
// In test mode, fetch products from API instead of StoreKit
await fetchTestModeProducts(testModeManager: testModeManager)
} else {
await factory.loadPurchasedProducts(config: config)
}

Task {
await webEntitlementRedeemer.pollWebEntitlements(config: config, isFirstTime: isFirstTime)
}
if isFirstTime {
await checkForTouchesBeganTrigger(in: config.triggers)

if testModeManager.isTestMode, let reason = testModeManager.testModeReason {
await presentTestModeColdLaunchAlert(reason: reason)
}
}
}

Expand Down Expand Up @@ -619,4 +635,54 @@
)
await Superwall.shared.track(preloadComplete)
}

// MARK: - Test Mode

private func fetchTestModeProducts(testModeManager: TestModeManager) async {
do {
let response = try await network.getSuperwallProducts()
testModeManager.setProducts(response.data)

// Also populate storeKitManager.productsById with test products
for superwallProduct in response.data {
let testProduct = TestStoreProduct(
superwallProduct: superwallProduct,
entitlements: []
)
let storeProduct = StoreProduct(testProduct: testProduct)
await storeKitManager.setProduct(storeProduct, forIdentifier: superwallProduct.identifier)
}

Logger.debug(
logLevel: .info,
scope: .superwallCore,
message: "Test mode: loaded \(response.data.count) products from API"
)
} catch {
Logger.debug(
logLevel: .error,
scope: .superwallCore,
message: "Test mode: failed to fetch products",
error: error
)
}
}

@MainActor
private func presentTestModeColdLaunchAlert(reason: TestModeReason) {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController else {

Check warning on line 674 in Sources/SuperwallKit/Config/ConfigManager.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Indentation Width Violation: Code should be indented using one tab or 2 spaces (indentation_width)
return
}

let userId = factory.makeTestModeManager().identityManager.userId
let hasPurchaseController = factory.makeHasExternalPurchaseController()

TestModeColdLaunchAlert.present(
reason: reason,
userId: userId,
hasPurchaseController: hasPurchaseController,
from: rootVC
)
}
}
6 changes: 6 additions & 0 deletions Sources/SuperwallKit/Config/Models/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ struct Config: Codable, Equatable {
}
}
var iosAppId: String?
var bundleIdConfig: String?
var testModeUserIds: [TestStoreUser]?

struct Web2AppConfig: Codable, Equatable {
let entitlementsMaxAge: Seconds
Expand Down Expand Up @@ -82,6 +84,8 @@ struct Config: Codable, Equatable {
case products = "productsV3"
case web2appConfig
case iosAppId
case bundleIdConfig
case testModeUserIds = "test_mode_user_ids"
}

init(from decoder: Decoder) throws {
Expand All @@ -97,6 +101,8 @@ struct Config: Codable, Equatable {
attribution = try values.decodeIfPresent(Attribution.self, forKey: .attribution)
web2appConfig = try values.decodeIfPresent(Web2AppConfig.self, forKey: .web2appConfig)
iosAppId = try values.decodeIfPresent(String.self, forKey: .iosAppId)
bundleIdConfig = try values.decodeIfPresent(String.self, forKey: .bundleIdConfig)
testModeUserIds = try values.decodeIfPresent([TestStoreUser].self, forKey: .testModeUserIds)

let localization = try values.decode(LocalizationConfig.self, forKey: .localization)
locales = Set(localization.locales.map { $0.locale })
Expand Down
24 changes: 24 additions & 0 deletions Sources/SuperwallKit/Config/Models/TestStoreUser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// TestStoreUser.swift
// Superwall
//
// Created by Claude on 2026-01-27.
//

import Foundation

/// Identifies a user who should be in test mode.
struct TestStoreUser: Codable, Equatable, Sendable {
/// The type of identifier used to match the user.
let type: TestStoreUserType

/// The identifier value.
let value: String
}

/// The type of identifier used for test store user matching.
enum TestStoreUserType: String, Codable, Equatable, Sendable {
case userId
case vendorId
case aliasId
}
22 changes: 22 additions & 0 deletions Sources/SuperwallKit/Dependencies/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class DependencyContainer {
var webEntitlementRedeemer: WebEntitlementRedeemer!
var deepLinkRouter: DeepLinkRouter!
var attributionFetcher: AttributionFetcher!
var testModeManager: TestModeManager!
let permissionHandler = PermissionHandler()
// swiftlint:enable implicitly_unwrapped_optional
let paywallArchiveManager = PaywallArchiveManager()
Expand Down Expand Up @@ -149,6 +150,17 @@ final class DependencyContainer {
}
}

testModeManager = TestModeManager(
identityManager: identityManager,
deviceHelper: deviceHelper
)

deviceHelper.testModeManager = testModeManager

Task {
await storeKitManager.setTestModeManager(testModeManager)
}

appSessionManager = AppSessionManager(
configManager: configManager,
identityManager: identityManager,
Expand Down Expand Up @@ -206,6 +218,13 @@ extension DependencyContainer: TransactionManagerFactory {
}
}

// MARK: - TestModeManagerFactory
extension DependencyContainer: TestModeManagerFactory {
func makeTestModeManager() -> TestModeManager {
return testModeManager
}
}

// MARK: - CacheFactory
extension DependencyContainer: CacheFactory {
func makeCache() -> PaywallViewControllerCache {
Expand All @@ -230,6 +249,9 @@ extension DependencyContainer: DeviceHelperFactory {
}

func makeIsSandbox() -> Bool {
if testModeManager.isTestMode {
return true
}
return deviceHelper.isSandbox == "true"
}

Expand Down
16 changes: 13 additions & 3 deletions Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,18 @@ class DeviceHelper {
return Bundle.main.bundleIdentifier ?? ""
}()

/// Returns true if built for the simulator or using TestFlight.
let isSandbox: String = {
/// Set after initialization to enable test mode sandbox override.
var testModeManager: TestModeManager?

/// Returns true if built for the simulator, using TestFlight, or in test mode.
var isSandbox: String {
if testModeManager?.isTestMode == true {
return "true"
}
return Self.detectSandbox()
}

private static func detectSandbox() -> String {
#if targetEnvironment(simulator)
return "true"
#else
Expand All @@ -188,7 +198,7 @@ class DeviceHelper {

return "\(url.path.contains("sandboxReceipt"))"
#endif
}()
}

/// The first URL scheme defined in the Info.plist. Assumes there's only one.
let urlScheme: String = {
Expand Down
17 changes: 17 additions & 0 deletions Sources/SuperwallKit/Network/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,20 @@
)
}
}

// MARK: - V2 Products
extension Endpoint where
Kind == EndpointKinds.Web2App,
Response == SuperwallProductsResponse {
/// Fetches all products from the subscriptions API.
/// The application is inferred from the SDK's public API key.
static func superwallProducts() -> Self {
return Endpoint(
components: Components(
host: .web2app,
path: "products"
),
method: .get
)
}
}

Check warning on line 411 in Sources/SuperwallKit/Network/Endpoint.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

File Length Violation: File should contain 400 lines or less: currently contains 411 (file_length)
21 changes: 21 additions & 0 deletions Sources/SuperwallKit/Network/Network.swift
Original file line number Diff line number Diff line change
Expand Up @@ -361,4 +361,25 @@ class Network {
data: SuperwallRequestData(factory: factory)
).tokensByProductId
}

/// Fetches all products from the subscriptions API.
/// The application is inferred from the SDK's public API key.
///
/// - Returns: A response containing all products for this application.
func getSuperwallProducts() async throws -> SuperwallProductsResponse {
do {
return try await urlSession.request(
.superwallProducts(),
data: SuperwallRequestData(factory: factory)
)
} catch {
Logger.debug(
logLevel: .error,
scope: .network,
message: "Request Failed: /v1/products",
error: error
)
throw error
}
}
}
Loading
Loading