diff --git a/CHANGELOG.md b/CHANGELOG.md index 436dfa7c29..53ba04b671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/SuperwallKit/Config/ConfigLogic.swift b/Sources/SuperwallKit/Config/ConfigLogic.swift index 15b6647765..8b0075ff6f 100644 --- a/Sources/SuperwallKit/Config/ConfigLogic.swift +++ b/Sources/SuperwallKit/Config/ConfigLogic.swift @@ -306,8 +306,7 @@ enum ConfigLogic { from config: Config ) -> [String: Set] { return Dictionary( - config.products.map { ($0.id, $0.entitlements) }, - uniquingKeysWith: { $0.union($1) } - ) + config.products.map { ($0.id, $0.entitlements) } + ) { $0.union($1) } } } diff --git a/Sources/SuperwallKit/Config/Options/AssetResource.swift b/Sources/SuperwallKit/Config/Options/AssetResource.swift new file mode 100644 index 0000000000..bf35e8ef1f --- /dev/null +++ b/Sources/SuperwallKit/Config/Options/AssetResource.swift @@ -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 diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index c86f925762..f7d42bbeda 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -4,25 +4,33 @@ // // 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`). @@ -30,12 +38,45 @@ public final class SuperwallOptions: NSObject, Encodable { /// ```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) diff --git a/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift b/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift index 54753bd4c9..9ae9ee9226 100644 --- a/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift +++ b/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift @@ -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() @@ -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() { @@ -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 } } @@ -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() diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift index 6a179e4326..dfa78b5ef1 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift @@ -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. /// @@ -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() + #endif + /// Errors that can occur during file loading enum FileError: LocalizedError, Equatable { case invalidURL @@ -83,7 +92,8 @@ 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) { @@ -91,16 +101,38 @@ final class LocalFileSchemeHandler: NSObject, WKURLSchemeHandler { 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 diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index e53dacd309..5c6b2ba1b3 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -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 */; }; @@ -647,6 +648,7 @@ 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewURLConfig.swift; sourceTree = ""; }; 22919EFD263425D38E7D9D38 /* WebEntitlementRedeemer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebEntitlementRedeemer.swift; sourceTree = ""; }; 22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerWrapper.swift; sourceTree = ""; }; + 23307BBFD80385233DDD4C43 /* AssetResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResource.swift; sourceTree = ""; }; 236900A8A8F95CE92E612458 /* IdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManager.swift; sourceTree = ""; }; 2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeLocationManager.swift; sourceTree = ""; }; 23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandlerTests.swift; sourceTree = ""; }; @@ -2906,6 +2908,7 @@ E7D208EAB24A633EB74B1E31 /* Options */ = { isa = PBXGroup; children = ( + 23307BBFD80385233DDD4C43 /* AssetResource.swift */, B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */, CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */, ); @@ -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 */, diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift index 19470ec00a..9125c535b3 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift @@ -9,6 +9,7 @@ import Testing @testable import SuperwallKit import Foundation +import UIKit @Suite(.serialized) struct LocalFileSchemeHandlerTests { @@ -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()