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
23 changes: 23 additions & 0 deletions .github/workflows/build-platforms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
paths:
- '.github/workflows/build-platforms.yml'
- 'project.yml'
- 'Package.swift'
- '**/*.swift'
- '!Examples/**'
workflow_dispatch:
Expand All @@ -29,3 +30,25 @@ jobs:
- name: Build Mac Catalyst
run: |
xcodebuild -project SuperwallKit.xcodeproj -scheme SuperwallKit -destination 'generic/platform=macOS,variant=Mac Catalyst' build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=""

build-visionos:
runs-on: macos-26

steps:
- name: Git Checkout
uses: actions/checkout@v3
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Install visionOS platform
# Only downloads when the runner image doesn't already ship the SDK.
run: |
xcodebuild -showsdks | grep -q xrsimulator || xcodebuild -downloadPlatform visionOS
- name: Remove Xcodeproj
# The xcodegen project only targets iOS; build through Package.swift instead.
run: |
rm -r SuperwallKit.xcodeproj
- name: Build visionOS
run: |
xcodebuild -scheme SuperwallKit -destination 'generic/platform=visionOS Simulator' build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=""
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.

## 4.18.0
### Breaking Changes

- Raises the minimum iOS deployment target from iOS 13 to iOS 15, and the Swift Package Manager minimums for macOS and watchOS to macOS 12 and watchOS 8.
- Requires Xcode 26 (Swift 6.2) or later to build the SDK. Apple has required the iOS 26 SDK for App Store Connect uploads since April 28, 2026. The SDK itself still compiles in the Swift 5 language mode.
- This ships as a minor release because every App Store Connect upload already needs Xcode 26, so any app that can ship an update already meets the new Xcode requirement. If your app still supports iOS 13 or 14, Swift Package Manager will fail to resolve this version for it: pin SuperwallKit to `4.17.x` (`.upToNextMinor(from: "4.17.0")`, or `~> 4.17.0` in CocoaPods) until you raise your deployment target.

### Enhancements

- Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`. Requires iOS 15+.
- Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`.
- Adds `CustomerCenterDelegate` and events for when the Customer Center opens, closes, and when users pick an action, answer a survey or request a refund.

### Fixes
Expand Down
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
### Package Management
- Swift Package Manager: Primary dependency management via `Package.swift`
- CocoaPods: Also supported via `SuperwallKit.podspec`
- Dependencies: `superscript-ios-next` at exact version 1.0.14 (slim binary-target distribution; replaces the legacy `Superscript-iOS` repo whose committed xcframework bloated clones)
- Dependencies: `superscript-ios-next` at exact version 1.0.15 (slim binary-target distribution; replaces the legacy `Superscript-iOS` repo whose committed xcframework bloated clones)

## Architecture Overview

Expand Down Expand Up @@ -56,6 +56,14 @@ SuperwallKit is an iOS SDK for remote paywall configuration and A/B testing. The
4. Purchases are handled by `StoreKitManager` with automatic retry logic
5. Events are tracked through the analytics system

### Minimum Toolchain and Platforms

- Xcode 26 / Swift 6.2 (`swift-tools-version:6.2`), iOS 15, macOS 12, watchOS 8. tvOS isn't supported (WebKit, SafariServices and Superscript have no tvOS build). CI builds iOS (tests), Mac Catalyst and visionOS (`build-platforms.yml`). Apple requires the iOS 26 SDK for App Store Connect uploads, so there's no reason to support older Xcodes.
- Don't add `#if compiler(...)` checks for anything below 6.2; only gate APIs newer than that (e.g. `compiler(>=6.3.2)` for iOS 26.4 StoreKit APIs).
- Don't add `@available`/`#available` checks or fallback branches for iOS 15 or older (StoreKit 2, sheet detents, `UIMenu` buttons and friends are always there).
- The package pins `swiftLanguageModes: [.v5]`. Moving to Swift 6 language mode is a separate change.
- Keep `Package.swift`, `SuperwallKit.podspec` and `project.yml` deployment targets in sync.

### Code Conventions

- 2-space indentation (enforced by SwiftLint)
Expand Down
13 changes: 7 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.5
// swift-tools-version:6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand All @@ -7,9 +7,9 @@ let package = Package(
name: "SuperwallKit",
defaultLocalization: "en",
platforms: [
.iOS(.v13),
.macOS(.v10_12),
.watchOS("6.2")
.iOS(.v15),
.macOS(.v12),
.watchOS(.v8)
],
Comment thread
yusuftor marked this conversation as resolved.
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
Expand All @@ -19,7 +19,7 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/superwall/superscript-ios-next", .exact("1.0.15"))
.package(url: "https://github.com/superwall/superscript-ios-next", exact: "1.0.15")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
Expand All @@ -40,5 +40,6 @@ let package = Package(
name: "SuperwallKitTests",
dependencies: ["SuperwallKit"]
)
]
],
swiftLanguageModes: [.v5]
)
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/pod-compatible-informational" alt="Cocoapods Compatible">
</a>
<a href="https://superwall.com/">
<img src="https://img.shields.io/badge/ios%20version-%3E%3D%2013.0-blueviolet" alt="iOS Versions Supported">
<img src="https://img.shields.io/badge/ios%20version-%3E%3D%2015.0-blueviolet" alt="iOS Versions Supported">
</a>
<a href="https://github.com/superwall/Superwall-iOS/blob/master/LICENSE">
<img src="https://img.shields.io/badge/license-MIT-green/" alt="MIT License">
Expand Down
60 changes: 27 additions & 33 deletions Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,33 +38,32 @@ final class AttributionFetcher {
var identifierForAdvertisers: String? {
// should match available platforms here:
// https://developer.apple.com/documentation/adsupport/asidentifiermanager/1614151-advertisingidentifier
#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS)
if #available(macCatalyst 13.1, macOS 10.14, *) {
let identifierManagerProxy = AttributionTypeFactory.asIdProxy()
guard let identifierManagerProxy = identifierManagerProxy else {
Logger.debug(
logLevel: .warn,
scope: .analytics,
message: "AdSupport framework not imported. Attribution data incomplete."
)
return nil
}

guard let identifierValue = identifierManagerProxy.adsIdentifier else {
return nil
}
#if os(iOS) || os(macOS) || os(visionOS)
let identifierManagerProxy = AttributionTypeFactory.asIdProxy()
guard let identifierManagerProxy = identifierManagerProxy else {
Logger.debug(
logLevel: .warn,
scope: .analytics,
message: "AdSupport framework not imported. Attribution data incomplete."
)
return nil
}

// When ATT hasn't been authorized iOS returns the all-zeros UUID
// sentinel. Don't pass that through as an IDFA — it pollutes attribution
// payloads with junk that downstream MMPs treat as a real id.
if identifierValue == Self.zeroAdvertisingIdentifier {
return nil
}
guard let identifierValue = identifierManagerProxy.adsIdentifier else {
return nil
}

return identifierValue.uuidString
// When ATT hasn't been authorized iOS returns the all-zeros UUID
// sentinel. Don't pass that through as an IDFA — it pollutes attribution
// payloads with junk that downstream MMPs treat as a real id.
if identifierValue == Self.zeroAdvertisingIdentifier {
return nil
}
#endif

return identifierValue.uuidString
#else
return nil
#endif
}

// Non-optional construction via `init(uuid:)` — `init(uuidString:)` returns
Expand All @@ -89,8 +88,6 @@ final class AttributionFetcher {
#endif
}

// should match OS availability in https://developer.apple.com/documentation/ad_services
@available(iOS 14.3, tvOS 14.3, macOS 11.1, watchOS 6.2, macCatalyst 14.3, *)
var adServicesToken: String? {
get async throws {
#if canImport(AdServices)
Expand All @@ -113,7 +110,6 @@ final class AttributionFetcher {
}

#if canImport(AdServices)
@available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *)
private static var realAdServicesToken: String? {
get throws {
return try AAAttribution.attributionToken()
Expand Down Expand Up @@ -306,12 +302,11 @@ extension AttributionFetcher {
if let attStatusProvider {
return attStatusProvider()
}
#if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(macOS) || os(visionOS)
if #available(iOS 14, macCatalyst 14, tvOS 14, macOS 11, *) {
return TrackingManagerProxy().trackingAuthorizationStatus()
}
#endif
#if os(iOS) || targetEnvironment(macCatalyst) || os(macOS) || os(visionOS)
return TrackingManagerProxy().trackingAuthorizationStatus()
#else
return nil
#endif
}

private var currentDeviceIdentifiers: [String: String] {
Expand All @@ -326,8 +321,7 @@ extension AttributionFetcher {
attributes["attStatus"] = String(attStatus)
}

// Don't gate this on the ATT status. Before iOS 14.5 the IDFA is available
// while ATT still reads `notDetermined`, and `TrackingManagerProxy` returns
// Don't gate this on the ATT status. `TrackingManagerProxy` returns
// `notDetermined` both for a genuine one and for a build where the class
// can't be found, so the status can't tell those apart. The OS hands back
// the all-zero id when it doesn't want to share one, and
Expand Down
13 changes: 2 additions & 11 deletions Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ final class AttributionPoster {
self.configManager = configManager
self.attributionFetcher = attributionFetcher

if #available(iOS 14.3, *) {
listenToConfig()
}
listenToConfig()

NotificationCenter.default.addObserver(
self,
Expand All @@ -70,7 +68,6 @@ final class AttributionPoster {
)
}

@available(iOS 14.3, *)
private func listenToConfig() {
// Track the enabled flag across config refreshes. `removeDuplicates` has
// to see the toggles to detect a transition, so we dedup on the bool
Expand Down Expand Up @@ -106,9 +103,7 @@ final class AttributionPoster {
// deferred indefinitely under load, but the attribution token is only
// useful within ~24h of install.
Task(priority: .utility) {
if #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) {
await getAdServicesTokenIfNeeded()
}
await getAdServicesTokenIfNeeded()
}
#endif
}
Expand Down Expand Up @@ -143,8 +138,6 @@ final class AttributionPoster {
}

// Should match OS availability in https://developer.apple.com/documentation/ad_services
@available(iOS 14.3, tvOS 14.3, watchOS 6.2, macOS 11.1, macCatalyst 14.3, *)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
func getAdServicesTokenIfNeeded() async {
// Single-flight: only one collection at a time. Synchronous check on the
Expand Down Expand Up @@ -234,7 +227,6 @@ final class AttributionPoster {
return true
}

@available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *)
// swiftlint:disable:next function_body_length
private func runAttempt(existingAttempts: AdServicesAttributionAttempts?) async {
// Fire `.start` from inside the task body so the event isn't orphaned if
Expand Down Expand Up @@ -342,7 +334,6 @@ final class AttributionPoster {
/// errors a few times in-session. `AAAttribution.attributionToken()` can
/// throw `networkError` very early in the app's lifecycle even though the
/// next call milliseconds later would succeed.
@available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *)
private func fetchTokenWithBackoff() async throws -> String {
var lastError: Error?
for delay in [0.0] + Self.tokenFetchBackoff {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -727,12 +727,9 @@ enum InternalSuperwallEvent {
}

func getSuperwallParameters() async -> [String: Any] {
var storefrontCountryCode = ""
var storefrontId = ""
if #available(iOS 15.0, *) {
storefrontCountryCode = await Storefront.current?.countryCode ?? ""
storefrontId = await Storefront.current?.id ?? ""
}
let storefront = await Storefront.current
let storefrontCountryCode = storefront?.countryCode ?? ""
let storefrontId = storefront?.id ?? ""
var placementParams: [String: Any] = [
"store": store.description,
"source": source.rawValue
Expand Down
2 changes: 1 addition & 1 deletion Sources/SuperwallKit/Config/ConfigManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ class ConfigManager {
// StoreKit 2 only: its read is the slow one, and it is the only one that
// saves the device rows the restore rebuilds from. A StoreKit 1 receipt is
// parsed locally and saves no rows, so there would be nothing to restore.
guard #available(iOS 15.0, *), options.storeKitVersion == .storeKit2 else {
guard options.storeKitVersion == .storeKit2 else {
return nil
}
guard let customerInfo = storage.get(LatestCustomerInfo.self) else {
Expand Down
9 changes: 2 additions & 7 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,7 @@ public final class SuperwallOptions: NSObject, Encodable {

/// The StoreKit version that the SDK should use.
///
/// The SDK will use StoreKit 2 by default if the app is running on iOS 15+, otherwise it
/// will fallback to StoreKit 1.
/// Defaults to StoreKit 2.
public var storeKitVersion: StoreKitVersion

/// **WARNING**: The different network environments that the SDK should use.
Expand Down Expand Up @@ -523,11 +522,7 @@ public final class SuperwallOptions: NSObject, Encodable {
self.storeKitVersion = .storeKit1
}
} else {
if #available(iOS 15.0, *) {
self.storeKitVersion = .storeKit2
} else {
self.storeKitVersion = .storeKit1
}
self.storeKitVersion = .storeKit2
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ protocol StoreKitTransactionLooking: Sendable {
func isFamilyShared(productId: String) async -> Bool
}

@available(iOS 15.0, *)
struct StoreKitTransactionLookup: StoreKitTransactionLooking {
func latestTransactionID(for productId: String) async -> UInt64? {
guard case .verified(let transaction)? = await Transaction.latest(for: productId) else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import UIKit

/// Builds the dependencies backing ``CustomerCenterView`` and owns the single Customer Center
/// presentation for ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)``.
@available(iOS 15.0, *)
@MainActor
final class CustomerCenterManager {
private unowned let container: DependencyContainer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import Foundation
/// The view controller does not retain its delegate. Keep a strong reference to it for the
/// duration of the presentation — or present via `Superwall.shared.presentCustomerCenter(delegate:)`,
/// which retains the delegate while the Customer Center is presented.
@available(iOS 15.0, *)
@MainActor
public protocol CustomerCenterDelegate: AnyObject {
/// Called before purchases are restored. Return `false` to cancel, for example after the user
Expand All @@ -31,7 +30,6 @@ public protocol CustomerCenterDelegate: AnyObject {
func customerCenterDidDismiss()
}

@available(iOS 15.0, *)
public extension CustomerCenterDelegate {
func customerCenterShouldRestorePurchases() async -> Bool { true }
func customerCenterDidSelectAction(_ action: CustomerCenterAction, pathId: String, purchase: CustomerCenterPurchase?) {}
Expand All @@ -45,7 +43,6 @@ public extension CustomerCenterDelegate {
/// The view controller does not retain its delegate. Keep a strong reference to it for the
/// duration of the presentation — or present via `Superwall.shared.presentCustomerCenter(delegate:)`,
/// which retains the delegate while the Customer Center is presented.
@available(iOS 15.0, *)
@objc(SWKCustomerCenterDelegate)
@MainActor
public protocol CustomerCenterDelegateObjc: AnyObject {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,7 @@ struct AppStoreVersionLookup: CustomerCenterAppStoreVersionProviding {
}

static func currentStorefrontCountryCode() async -> String? {
if #available(iOS 15.0, *) {
return await Storefront.current?.countryCode
}
return nil
return await Storefront.current?.countryCode
}

/// The two-letter country to look the app up in: the App Store's, else the device region.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ extension CustomerCenterConfiguration {
/// Disfavored so that `.init(light: .red, dark: .blue)` keeps meaning `UIColor`, which also
/// has a `.red`, rather than becoming ambiguous.
@_disfavoredOverload
@available(iOS 14.0, *)
@nonobjc public convenience init(light: Color, dark: Color) {
self.init(light: UIColor(light), dark: UIColor(dark))
}
Expand Down
Loading
Loading