Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup
### Enhancements

- Adds an `onCustomCallback` parameter to `getPaywall`.
- `SuperwallOptions.localResources` now accepts `AssetResource` values, so paywall assets can be registered from an asset catalog (`.xcassets` Data Sets) via `CatalogAsset(name:bundle:)` in addition to file URLs. `URL` conforms to `AssetResource`, so existing call sites are unaffected.

## 4.15.0

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) }
}
}
63 changes: 63 additions & 0 deletions Sources/SuperwallKit/Config/Options/AssetResource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// AssetResource.swift
// SuperwallKit
//
// Created by Yusuf Tör on 24/04/2026.
//

import Foundation
#if canImport(UIKit)
import UIKit
#endif
Comment thread
yusuftor marked this conversation as resolved.

/// 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.
/// - ``CatalogAsset`` — a deferred lookup against an `.xcassets` entry. Handles
/// both Image Sets and Data Sets (video, Lottie JSON, etc.).
///
/// ```swift
/// options.localResources = [
/// "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")!,
/// "logo": UIImage(named: "Logo")!,
/// "hero-video": CatalogAsset(name: "HeroVideo")
/// ]
/// ```
public protocol AssetResource {}

extension URL: AssetResource {}

#if canImport(UIKit)
extension UIImage: AssetResource {}
#endif

/// An entry in an asset catalog (`.xcassets`).
///
/// Resolved at load time by trying `UIImage(named:in:compatibleWith:)` first
/// (Image Set, re-encoded as PNG), then falling back to
/// `NSDataAsset(name:bundle:)` (Data Set, raw bytes preserved with no
/// re-encoding).
///
/// Use a Data Set for non-image content (video, Lottie JSON, etc.) or when
/// you need lossless bytes. Image Sets work as-is for typical paywall imagery
/// like logos and icons.
public struct CatalogAsset: AssetResource {
/// The name of the data asset as it appears in the asset catalog.
public let name: String

/// The bundle that contains the asset catalog.
public let bundle: Bundle

/// Creates a reference to a Data Set entry in an asset catalog.
///
/// - Parameters:
/// - name: The name of the data asset as it appears in the asset catalog.
/// - bundle: The bundle that contains the asset catalog. Defaults to `.main`.
public init(name: String, bundle: Bundle = .main) {
self.name = name
self.bundle = bundle
}
}
32 changes: 25 additions & 7 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,56 @@
//
// Created by Yusuf Tör on 11/07/2022.
//
// swiftlint:disable file_length

import Foundation

/// 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
/// entries from an `.xcassets` Data Set with ``CatalogAsset``.
///
/// 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")!,
/// "hero-video": CatalogAsset(name: "OnboardingHero"),
/// "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``. Exposed to ObjC under the name
/// `localResources` and limited to `URL` values (asset-catalog entries are Swift-only).
@available(swift, obsoleted: 1.0)
@objc(localResources)
public var localResourcesObjC: [String: URL] {
get {
return localResources.compactMapValues { $0 as? URL }
}
set {
localResources = newValue.mapValues { $0 as AssetResource }
}
}

/// Controls when the SDK enters test mode.
@objc(SWKTestModeBehavior)
Expand Down
43 changes: 39 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,22 @@ 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 if let catalog = resource as? CatalogAsset {
configureCatalogAsset(id: id, catalog: catalog)
} 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 All @@ -249,6 +264,26 @@ private final class LocalResourceCell: UICollectionViewCell {
}
}

private func configureCatalogAsset(id: String, catalog: CatalogAsset) {
idLabel.text = "\(id) (asset: \(catalog.name))"
spinner.startAnimating()
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let asset = NSDataAsset(name: catalog.name, bundle: catalog.bundle)
let image = asset.flatMap { UIImage(data: $0.data) }
DispatchQueue.main.async {
self?.spinner.stopAnimating()
if let image = image {
self?.imageView.image = image
} else if let asset = asset {
let byteCount = ByteCountFormatter.string(fromByteCount: Int64(asset.data.count), countStyle: .file)
self?.showErrorText("No preview\n\(asset.typeIdentifier) · \(byteCount)")
Comment on lines 265 to +288
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Image-Set CatalogAsset shows "Asset not found" in debug view

configureCatalogAsset loads asset data exclusively via NSDataAsset, but NSDataAsset only resolves Data Set entries — it returns nil for Image Sets. The scheme handler in LocalFileSchemeHandler.load(resource:key:) tries UIImage(named:in:compatibleWith:) first, so an Image Set CatalogAsset is served successfully in the webview, but the debug preview shows "Asset not found" because NSDataAsset finds nothing. The fix is to mirror the scheme handler's resolution order: try UIImage(named:) first, and only then fall back to NSDataAsset.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift
Line: 265-279

Comment:
**Image-Set `CatalogAsset` shows "Asset not found" in debug view**

`configureCatalogAsset` loads asset data exclusively via `NSDataAsset`, but `NSDataAsset` only resolves *Data Set* entries — it returns `nil` for *Image Sets*. The scheme handler in `LocalFileSchemeHandler.load(resource:key:)` tries `UIImage(named:in:compatibleWith:)` first, so an Image Set CatalogAsset is served successfully in the webview, but the debug preview shows "Asset not found" because `NSDataAsset` finds nothing. The fix is to mirror the scheme handler's resolution order: try `UIImage(named:)` first, and only then fall back to `NSDataAsset`.

How can I resolve this? If you propose a fix, please make it concise.

} else {
self?.showErrorText("Asset not found")
}
}
}
}
Comment thread
yusuftor marked this conversation as resolved.

private func loadImage(from url: URL) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@

import Foundation
import WebKit
#if canImport(UIKit)
import UIKit
#endif
#if canImport(UniformTypeIdentifiers)
import UniformTypeIdentifiers
#endif
#if canImport(MobileCoreServices)
import MobileCoreServices
#endif

/// Handles custom URL scheme requests for serving local files to the paywall webview.
///
Expand Down Expand Up @@ -83,24 +92,73 @@ final class LocalFileSchemeHandler: NSObject, WKURLSchemeHandler {

// MARK: - File Loading

/// Loads a file from `SuperwallOptions.localResources` based on the URL host (the localResourceId).
/// Loads a file from `SuperwallOptions.localResources` based on the URL host
/// (the localResourceId).
/// - Parameter url: The swlocal:// URL where the host is the localResourceId
/// - Returns: Tuple of file data and MIME type
func loadFile(from url: URL) throws -> (Data, String) {
guard let host = url.host else {
throw FileError.invalidURL
}

guard let localURL = Superwall.shared.options.localResources[host] else {
guard let resource = Superwall.shared.options.localResources[host] else {
throw FileError.fileNotFound(host)
}

return try load(resource: resource, key: host)
}

/// Resolves an ``AssetResource`` to its data and MIME type.
private func load(resource: AssetResource, key: String) throws -> (Data, String) {
if let localURL = resource as? URL {
return try loadFile(at: localURL)
}
#if canImport(UIKit)
if let image = resource as? UIImage {
guard let data = image.pngData() else {
throw FileError.unableToReadFile("\(key) (UIImage pngData nil)")
}
return (data, "image/png")
}
#endif
if let catalog = resource as? CatalogAsset {
#if canImport(UIKit)
if let image = UIImage(named: catalog.name, in: catalog.bundle, compatibleWith: nil),
let data = image.pngData() {
return (data, "image/png")
}
#endif
if let asset = NSDataAsset(name: catalog.name, bundle: catalog.bundle) {
return (asset.data, mimeType(forUTI: asset.typeIdentifier))
}
throw FileError.fileNotFound("\(key) (asset \(catalog.name))")
}
throw FileError.fileNotFound(key)
}

private func loadFile(at localURL: URL) throws -> (Data, String) {
guard let data = try? Data(contentsOf: localURL) else {
throw FileError.unableToReadFile(localURL.path)
}
return (data, mimeType(for: localURL.pathExtension))
}

let mimeType = self.mimeType(for: localURL.pathExtension)
return (data, mimeType)
/// Maps a UTI (e.g. `public.png`, `public.mpeg-4`) to a MIME type.
/// Falls back to `application/octet-stream` if the UTI can't be resolved.
private func mimeType(forUTI uti: String) -> String {
if #available(iOS 14.0, *) {
if let type = UTType(uti),
let mime = type.preferredMIMEType {
return mime
}
} else {
let cfUTI = uti as CFString
if let mime = UTTypeCopyPreferredTagWithClass(cfUTI, kUTTagClassMIMEType)?
.takeRetainedValue() as String? {
return mime
}
}
return "application/octet-stream"
}
Comment thread
yusuftor marked this conversation as resolved.

// MARK: - MIME Type Detection
Expand Down
4 changes: 4 additions & 0 deletions SuperwallKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@
CE43399599386456DCCD1FDC /* FakeLocationAuthorizationStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3529B422EB2D09B69265B591 /* FakeLocationAuthorizationStatusTests.swift */; };
CE5307A037FCFD8CB8380EB5 /* GetPaywallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3781CF21200CD2333F6779A /* GetPaywallManager.swift */; };
CE821667CB6676EA02510FE9 /* TrackingManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78C5C57C3C92444EBAC2E38 /* TrackingManagerProxy.swift */; };
CE85C4CC2D3E98738F2C37E7 /* AssetResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23307BBFD80385233DDD4C43 /* AssetResource.swift */; };
CEF9BBFFE0D64A011A59FB4C /* TestModeManagerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17DB6AB272712E9350966E4 /* TestModeManagerFactory.swift */; };
CF2064883604B915C8768FC5 /* ASIdManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF989B3D90DC3D88FACC4D45 /* ASIdManagerProxy.swift */; };
CF3683E2AD703237EC0CE22E /* PaywallProducts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95DABA23C6CBEF0AAA63C0 /* PaywallProducts.swift */; };
Expand Down Expand Up @@ -647,6 +648,7 @@
22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewURLConfig.swift; sourceTree = "<group>"; };
22919EFD263425D38E7D9D38 /* WebEntitlementRedeemer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebEntitlementRedeemer.swift; sourceTree = "<group>"; };
22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerWrapper.swift; sourceTree = "<group>"; };
23307BBFD80385233DDD4C43 /* AssetResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResource.swift; sourceTree = "<group>"; };
236900A8A8F95CE92E612458 /* IdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManager.swift; sourceTree = "<group>"; };
2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeLocationManager.swift; sourceTree = "<group>"; };
23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandlerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2906,6 +2908,7 @@
E7D208EAB24A633EB74B1E31 /* Options */ = {
isa = PBXGroup;
children = (
23307BBFD80385233DDD4C43 /* AssetResource.swift */,
B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */,
CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */,
);
Expand Down Expand Up @@ -3323,6 +3326,7 @@
9DC74748B0DC9DA519E70FE6 /* Array+Capability.swift in Sources */,
F8E799A3A83A2758D6EAA385 /* Array+Guarded.swift in Sources */,
B0B0AD9409CEFE7CA8225146 /* Array+SafeRemove.swift in Sources */,
CE85C4CC2D3E98738F2C37E7 /* AssetResource.swift in Sources */,
2428529A6B2B6E873DEC22E8 /* Assignment.swift in Sources */,
75083E470EB6E25E01F4F28B /* AsyncSequence+Extract.swift in Sources */,
69D24C6E0411E512FECF7258 /* Attribution.swift in Sources */,
Expand Down
Loading
Loading