Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
5cf675c
Fix redeem auth race during startup
anglinb Mar 26, 2026
cbabbcd
Expose abandoned product params
SebastianSzturo Apr 9, 2026
0deb490
Tighten abandoned product filtering
SebastianSzturo Apr 9, 2026
ca12e2c
Sanitize email user attribute before sending to checkout API
MathisDetourbet Apr 16, 2026
8866d4b
Merge branch 'develop' into fix/redeem-auth-race
yusuftor Apr 20, 2026
9c743b7
Update StorageTests.swift
yusuftor Apr 20, 2026
49577fe
Move apiKey configuration into DependencyContainer init
yusuftor Apr 20, 2026
243718c
Merge pull request #457 from superwall/fix/redeem-auth-race
yusuftor Apr 20, 2026
df85189
Update Package.resolved
yusuftor Apr 22, 2026
3a76986
Wire custom callbacks through getPaywall
yusuftor Apr 22, 2026
a8dce74
Shorten 4.15.1 CHANGELOG entry
yusuftor Apr 22, 2026
85539a7
Track callback registration ownership per VC
yusuftor Apr 22, 2026
23c873e
Merge pull request #464 from superwall/feat/get-paywall-custom-callback
yusuftor Apr 24, 2026
2968b23
Update packages
yusuftor Apr 24, 2026
dc89136
Accept asset catalog entries in localResources
yusuftor Apr 24, 2026
61d38f2
Back-fill CatalogAsset MIME type on iOS 13
yusuftor Apr 24, 2026
45fa834
Show non-image catalog assets in debug view, drop unused import
yusuftor Apr 24, 2026
db63ec1
Use trailing closure for entitlements dict uniquing
yusuftor Apr 24, 2026
3cd051d
Drop unused UIKit import; preserve catalog entries on ObjC set
yusuftor Apr 24, 2026
2d8f980
Revert ObjC setter merge; keep simple replace semantics
yusuftor Apr 24, 2026
5b23f97
Fall back to Image Set when CatalogAsset has no Data Set
yusuftor Apr 24, 2026
e7f5c50
Try UIImage before NSDataAsset to avoid CoreUI warning
yusuftor Apr 24, 2026
c65894e
Conform UIImage to AssetResource
yusuftor Apr 24, 2026
de085d5
Mirror scheme handler order in debug catalog preview
yusuftor Apr 25, 2026
05d0691
Remove CatalogAsset stuff just use UIImage
yusuftor Apr 27, 2026
2842481
Update LocalFileSchemeHandlerTests.swift
yusuftor Apr 27, 2026
9aed73f
Accept UIImage in ObjC localResources bridge
yusuftor Apr 27, 2026
a3d1e56
Cache UIImage PNG bytes in LocalFileSchemeHandler
yusuftor Apr 27, 2026
64a3a6e
Promote image PNG cache to static for cross-paywall reuse
yusuftor Apr 27, 2026
0397e04
Merge pull request #465 from superwall/feat/local-resources-asset-cat…
yusuftor Apr 27, 2026
837602d
Merge remote-tracking branch 'origin/develop' into SebastianSzturo/ab…
yusuftor Apr 27, 2026
4c0b483
Add to changelog, updates test, simplifies code
yusuftor Apr 28, 2026
4c77411
Remove stray character in CHANGELOG
yusuftor Apr 28, 2026
d9e8548
Merge pull request #459 from SebastianSzturo/SebastianSzturo/abandone…
yusuftor Apr 28, 2026
fd41bb1
Anchor email regex with \z to reject trailing newline
yusuftor Apr 28, 2026
17ca290
Merge remote-tracking branch 'origin/develop' into fix/sanitize-email…
yusuftor Apr 28, 2026
c6c5788
Add CHANGELOG entry for email user attribute sanitization
yusuftor Apr 28, 2026
98bfde3
Merge pull request #462 from MathisDetourbet/fix/sanitize-email-user-…
yusuftor Apr 28, 2026
9a2a8bf
Make sanitizeAttribute private and rename test funcs
yusuftor Apr 28, 2026
8ee55bc
Drop email value from invalid-attribute warning log
yusuftor Apr 28, 2026
793b1a6
Update project.pbxproj
yusuftor Apr 28, 2026
7d87c68
Always emit abandoned product attributes
yusuftor Apr 28, 2026
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.

## 4.15.1

### Enhancements

- Adds an `onCustomCallback` parameter to `getPaywall`.
- `SuperwallOptions.localResources` now accepts UIImage's from xcasset files, e.g. `UIImage(named: "my-image")`.
- Exposes abandoned transaction product params in audience filters.

### Fixes

- Sanitizes email user attribute.

## 4.15.0

### Enhancements
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,9 @@ enum InternalSuperwallEvent {
var params = paywallInfo.audienceFilterParams()
if let product = product {
params["abandoned_product_id"] = product.productIdentifier
for (key, value) in product.attributes where key != "identifier" {
params["abandoned_product_\(key.camelCaseToSnakeCase())"] = value
}
}
return params
default:
Expand Down
5 changes: 2 additions & 3 deletions Sources/SuperwallKit/Config/ConfigLogic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,7 @@ enum ConfigLogic {
from config: Config
) -> [String: Set<Entitlement>] {
return Dictionary(
config.products.map { ($0.id, $0.entitlements) },
uniquingKeysWith: { $0.union($1) }
)
config.products.map { ($0.id, $0.entitlements) }
) { $0.union($1) }
}
}
33 changes: 33 additions & 0 deletions Sources/SuperwallKit/Config/Options/AssetResource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// AssetResource.swift
// SuperwallKit
//
// Created by Yusuf Tör on 24/04/2026.
//

import Foundation
#if canImport(UIKit)
import UIKit
#endif

/// A type that can be registered against ``SuperwallOptions/localResources`` and
/// served to the paywall webview via the `swlocal://` URL scheme.
///
/// Conforming types:
/// - `URL` — a file on disk.
/// - `UIImage` — re-encoded as PNG when served to the webview. Use this to
/// register an asset catalog Image Set: `UIImage(named: "Logo")!`.
///
/// ```swift
/// options.localResources = [
/// "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")!,
/// "logo": UIImage(named: "Logo")!
/// ]
/// ```
public protocol AssetResource {}

extension URL: AssetResource {}

#if canImport(UIKit)
extension UIImage: AssetResource {}
#endif
55 changes: 48 additions & 7 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,79 @@
//
// Created by Yusuf Tör on 11/07/2022.
//
// swiftlint:disable file_length

import Foundation
#if canImport(UIKit)
import UIKit
#endif

/// Options for configuring Superwall, including paywall presentation and appearance.
///
/// Pass an instance of this class to
/// ``Superwall/configure(apiKey:purchaseController:options:completion:)-52tke``.
// swiftlint:disable type_body_length
@objc(SWKSuperwallOptions)
@objcMembers
public final class SuperwallOptions: NSObject, Encodable {
/// Configures the appearance and behaviour of paywalls.
public var paywalls = PaywallOptions()

/// A mapping of local resource IDs to local file URLs.
/// A mapping of local resource IDs to ``AssetResource`` values.
///
/// Use this to serve paywall assets (images, videos, Lottie animations) from local files
/// instead of fetching them over the network. When a paywall references a `localResourceId`,
/// the SDK will look up the corresponding URL in this dictionary and serve the file via the
/// `swlocal://` URL scheme.
/// Use this to serve paywall assets (images, videos, Lottie animations) from the app
/// bundle or an asset catalog instead of fetching them over the network. When a paywall
/// references a `localResourceId`, the SDK looks up the corresponding entry here and
/// serves it via the `swlocal://` URL scheme.
///
/// `URL` conforms to ``AssetResource`` so file-URL call sites keep working. Register an
/// `.xcassets` Image Set by passing a `UIImage`.
///
/// Set this before calling ``Superwall/configure(apiKey:purchaseController:options:completion:)-52tke``
/// to ensure resources are available before any paywall can trigger (e.g. on `app_launch`).
///
/// ```swift
/// let options = SuperwallOptions()
/// options.localResources = [
/// "hero-video": Bundle.main.url(forResource: "onboarding", withExtension: "mp4")!,
/// "logo": UIImage(named: "Logo")!,
/// "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")!
/// ]
/// Superwall.configure(apiKey: "your-api-key", options: options)
/// ```
public var localResources: [String: URL] = [:]
@nonobjc public var localResources: [String: AssetResource] = [:]

/// Objective-C bridge for ``localResources``. Accepts `NSURL` and `UIImage` values
/// (mirroring the Swift surface); any other value type is dropped.
@available(swift, obsoleted: 1.0)
@objc(localResources)
public var localResourcesObjC: [String: NSObject] {
get {
return localResources.compactMapValues { resource in
if let url = resource as? URL {
return url as NSURL
}
#if canImport(UIKit)
if let image = resource as? UIImage {
return image
}
#endif
return nil
}
}
set {
localResources = newValue.compactMapValues { value in
if let url = value as? URL {
return url
}
#if canImport(UIKit)
if let image = value as? UIImage {
return image
}
#endif
return nil
}
}
}

/// Controls when the SDK enters test mode.
@objc(SWKTestModeBehavior)
Expand Down
21 changes: 17 additions & 4 deletions Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import UIKit
import AVFoundation

final class SWLocalResourcesViewController: UICollectionViewController {
private var resources: [(id: String, url: URL)] = []
private var resources: [(id: String, resource: AssetResource)] = []

init() {
let layout = UICollectionViewFlowLayout()
Expand Down Expand Up @@ -53,7 +53,7 @@ final class SWLocalResourcesViewController: UICollectionViewController {

resources = Superwall.shared.options.localResources
.sorted { $0.key < $1.key }
.map { (id: $0.key, url: $0.value) }
.map { (id: $0.key, resource: $0.value) }
}

@objc private func doneTapped() {
Expand Down Expand Up @@ -86,7 +86,7 @@ final class SWLocalResourcesViewController: UICollectionViewController {
// swiftlint:disable:next force_cast
) as! LocalResourceCell
let resource = resources[indexPath.item]
cell.configure(id: resource.id, url: resource.url)
cell.configure(id: resource.id, resource: resource.resource)
return cell
}
}
Expand Down Expand Up @@ -231,7 +231,20 @@ private final class LocalResourceCell: UICollectionViewCell {
spinner.stopAnimating()
}

func configure(id: String, url: URL) {
func configure(id: String, resource: AssetResource) {
if let url = resource as? URL {
configureURL(id: id, url: url)
} else if let image = resource as? UIImage {
idLabel.text = "\(id) (UIImage)"
spinner.stopAnimating()
imageView.image = image
} else {
idLabel.text = id
showErrorText("Unsupported resource type")
}
}

private func configureURL(id: String, url: URL) {
let ext = url.pathExtension.lowercased()
idLabel.text = ext.isEmpty ? id : "\(id).\(ext)"
spinner.startAnimating()
Expand Down
5 changes: 4 additions & 1 deletion Sources/SuperwallKit/Dependencies/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ final class DependencyContainer {
let paywallArchiveManager = PaywallArchiveManager()

init(
apiKey: String = "",
purchaseController controller: PurchaseController? = nil,
options: SuperwallOptions? = nil
) {
delegateAdapter = SuperwallDelegateAdapter()
storage = Storage(factory: self)
storage.configure(apiKey: apiKey)
entitlementsInfo = EntitlementsInfo(
storage: storage,
delegateAdapter: delegateAdapter
Expand Down Expand Up @@ -312,7 +314,8 @@ extension DependencyContainer: ViewControllerFactory {
webView: webView,
webEntitlementRedeemer: webEntitlementRedeemer,
cache: cache,
paywallArchiveManager: paywallArchiveManager
paywallArchiveManager: paywallArchiveManager,
customCallbackRegistry: customCallbackRegistry
)

webView.delegate = paywallViewController
Expand Down
3 changes: 2 additions & 1 deletion Sources/SuperwallKit/Graveyard/SuperwallGraveyard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ extension Superwall {
),
webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer,
cache: dependencyContainer.makeCache(),
paywallArchiveManager: dependencyContainer.paywallArchiveManager
paywallArchiveManager: dependencyContainer.paywallArchiveManager,
customCallbackRegistry: dependencyContainer.customCallbackRegistry
)
}

Expand Down
32 changes: 32 additions & 0 deletions Sources/SuperwallKit/Identity/Email.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// Email.swift
// SuperwallKit
//

import Foundation

/// A validated email address.
///
/// The failable initializer rejects any string that does not match the
/// pattern expected by the checkout API (`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z`).
/// Holding an `Email` instance proves the value was validated — downstream
/// code never needs to re-check.
struct Email: Equatable, Sendable {
let rawValue: String

// `\z` (not `$`) is used so that a trailing `\n` is rejected — ICU treats `$`
// as matching at end-of-string *or* just before a final newline.
// Pattern is a validated literal — initialization can never throw at runtime.
private static let regex = try! NSRegularExpression( // swiftlint:disable:this force_try
pattern: #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z"#
)

/// Returns `nil` when `rawValue` is not a syntactically valid email address.
init?(_ rawValue: String) {
let range = NSRange(rawValue.startIndex..., in: rawValue)
guard Self.regex.firstMatch(in: rawValue, range: range) != nil else {
return nil
}
self.rawValue = rawValue
}
}
30 changes: 29 additions & 1 deletion Sources/SuperwallKit/Identity/UserAttributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,39 @@ extension Superwall {
continue
}
if JSONSerialization.isValidJSONObject([key: value]) {
customAttributes[key] = value
customAttributes[key] = Self.sanitizeAttribute(key: key, value: value)
}
}
}

dependencyContainer.identityManager.mergeUserAttributes(customAttributes)
}

/// Validates attribute values that have server-side schema constraints.
///
/// The checkout API rejects `context.identity.email` unless it is either a
/// valid email address or `null`. Apps that set a placeholder like `"none"`
/// would silently break the Stripe checkout flow, so the SDK parses the
/// value through ``Email`` and drops it when invalid.
private static func sanitizeAttribute(key: String, value: Any?) -> Any? {
guard let stringValue = value as? String else {
return value
}

switch key {
case "email":
guard let email = Email(stringValue) else {
Logger.debug(
logLevel: .warn,
scope: .identityManager,
message: "Invalid email user attribute — sending null to server"
)
return nil
}
return email.rawValue

default:
return value
}
}
}
2 changes: 1 addition & 1 deletion Sources/SuperwallKit/Misc/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ let sdkVersion = """
*/

let sdkVersion = """
4.15.0
4.15.1
"""
Loading
Loading