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
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 UIImage's from xcasset files, e.g. `UIImage(named: "my-image")`.

## 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) }
}
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

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

/// Handles custom URL scheme requests for serving local files to the paywall webview.
///
Expand All @@ -27,6 +30,12 @@ final class LocalFileSchemeHandler: NSObject, WKURLSchemeHandler {
/// The custom URL scheme for local files
static let scheme = "swlocal"

#if canImport(UIKit)
/// Caches PNG-encoded bytes per `UIImage` so repeat webview requests skip re-encoding.
/// Shared across handler instances so reuse across paywalls also hits the cache.
private static let imageDataCache = NSCache<UIImage, NSData>()
#endif

/// Errors that can occur during file loading
enum FileError: LocalizedError, Equatable {
case invalidURL
Expand Down Expand Up @@ -83,24 +92,47 @@ 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 {
if let cached = Self.imageDataCache.object(forKey: image) {
return (cached as Data, "image/png")
}
guard let data = image.pngData() else {
throw FileError.unableToReadFile("\(key) (UIImage pngData nil)")
}
Self.imageDataCache.setObject(data as NSData, forKey: image)
return (data, "image/png")
}
#endif
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)
}

let mimeType = self.mimeType(for: localURL.pathExtension)
return (data, mimeType)
return (data, mimeType(for: localURL.pathExtension))
}

// 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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Testing
@testable import SuperwallKit
import Foundation
import UIKit

@Suite(.serialized)
struct LocalFileSchemeHandlerTests {
Expand Down Expand Up @@ -98,6 +99,45 @@ struct LocalFileSchemeHandlerTests {
Superwall.shared.options.localResources = [:]
}

// MARK: - AssetResource Tests

@Test("URL conforms to AssetResource and registers via the same dictionary")
func loadFileURLConformance() throws {
let handler = LocalFileSchemeHandler()
let tempDir = FileManager.default.temporaryDirectory
let tempFile = tempDir.appendingPathComponent("asset-resource-url.png")
try Data("png-bytes".utf8).write(to: tempFile)
defer { try? FileManager.default.removeItem(at: tempFile) }

Superwall.shared.options.localResources = ["hero-image": tempFile]
let url = URL(string: "swlocal://hero-image")!

let (data, mimeType) = try handler.loadFile(from: url)
#expect(data == Data("png-bytes".utf8))
#expect(mimeType == "image/png")

Superwall.shared.options.localResources = [:]
}

@Test("UIImage conforms to AssetResource and is served as image/png")
func loadFileUIImageConformance() throws {
let handler = LocalFileSchemeHandler()
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4))
let image = renderer.image { ctx in
UIColor.red.setFill()
ctx.fill(CGRect(x: 0, y: 0, width: 4, height: 4))
}

Superwall.shared.options.localResources = ["logo": image]
let url = URL(string: "swlocal://logo")!

let (data, mimeType) = try handler.loadFile(from: url)
#expect(mimeType == "image/png")
#expect(data == image.pngData())

Superwall.shared.options.localResources = [:]
}

@Test("loadFile detects correct mime types from file extension")
func loadFileMimeTypes() throws {
let handler = LocalFileSchemeHandler()
Expand Down
Loading