From dc89136837bcc3be5c362829748eef2b2323c9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:16:02 +0100 Subject: [PATCH 01/15] Accept asset catalog entries in localResources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalises SuperwallOptions.localResources from `[String: URL]` to `[String: AssetResource]`. URL conforms to AssetResource so existing call sites are unaffected. New `CatalogAsset(name:bundle:)` registers a Data Set entry from an .xcassets, resolved at load time via NSDataAsset — the iOS equivalent of Android's R.raw.* resource IDs. ObjC keeps a URL-only shim under the same `localResources` name. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../Config/Options/AssetResource.swift | 54 +++++++++++++++++++ .../Config/Options/SuperwallOptions.swift | 32 ++++++++--- .../SWLocalResourcesViewController.swift | 36 +++++++++++-- .../Web View/LocalFileSchemeHandler.swift | 43 +++++++++++++-- SuperwallKit.xcodeproj/project.pbxproj | 4 ++ .../LocalFileSchemeHandlerTests.swift | 35 ++++++++++++ 7 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 Sources/SuperwallKit/Config/Options/AssetResource.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 436dfa7c29..67ce2c5dae 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 `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 diff --git a/Sources/SuperwallKit/Config/Options/AssetResource.swift b/Sources/SuperwallKit/Config/Options/AssetResource.swift new file mode 100644 index 0000000000..2a348cb640 --- /dev/null +++ b/Sources/SuperwallKit/Config/Options/AssetResource.swift @@ -0,0 +1,54 @@ +// +// 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. +/// +/// `URL` conforms out of the box, so existing call sites registering file URLs +/// keep working. To register an asset from an `.xcassets` Data Set (the iOS +/// equivalent of Android's `R.raw.*` resource IDs), use ``CatalogAsset``. +/// +/// ```swift +/// options.localResources = [ +/// "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")!, +/// "hero-video": CatalogAsset(name: "HeroVideo") +/// ] +/// ``` +public protocol AssetResource {} + +extension URL: AssetResource {} + +/// An entry in an asset catalog (`.xcassets`) stored as a Data Set. +/// +/// Resolved at load time via `NSDataAsset(name:bundle:)`, so the raw bytes are +/// preserved without re-encoding. Use this when you want to ship paywall assets +/// inside your `.xcassets` instead of as loose files in the bundle. +/// +/// - Note: Add the asset to your `.xcassets` as a **Data Set** (not an Image Set) +/// so `NSDataAsset` can read it and the webview receives the bytes verbatim. +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 + } +} diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index c86f925762..f0efad1909 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -4,6 +4,7 @@ // // Created by Yusuf Tör on 11/07/2022. // +// swiftlint:disable file_length import Foundation @@ -11,18 +12,22 @@ import Foundation /// /// 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`). @@ -30,12 +35,25 @@ public final class SuperwallOptions: NSObject, Encodable { /// ```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) diff --git a/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift b/Sources/SuperwallKit/Debug/SWLocalResourcesViewController.swift index 54753bd4c9..b1412d4583 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,18 @@ 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 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() @@ -249,6 +260,23 @@ 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 asset == nil { + self?.showErrorText("Asset not found") + } + } + } + } + private func loadImage(from url: URL) { DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift index 6a179e4326..cda5e6a732 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift @@ -7,6 +7,12 @@ import Foundation import WebKit +#if canImport(UIKit) +import UIKit +#endif +#if canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif /// Handles custom URL scheme requests for serving local files to the paywall webview. /// @@ -83,7 +89,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 +98,44 @@ 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 let catalog = resource as? CatalogAsset { + guard let asset = NSDataAsset(name: catalog.name, bundle: catalog.bundle) else { + throw FileError.fileNotFound("\(key) (data asset \(catalog.name))") + } + return (asset.data, mimeType(forUTI: asset.typeIdentifier)) + } + 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 + } + } + return "application/octet-stream" } // 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..a53cae1572 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift @@ -98,6 +98,41 @@ struct LocalFileSchemeHandlerTests { Superwall.shared.options.localResources = [:] } + // MARK: - AssetResource Tests + + @Test("loadFile throws fileNotFound when CatalogAsset name is missing from bundle") + func loadFileMissingCatalogAsset() { + let handler = LocalFileSchemeHandler() + Superwall.shared.options.localResources = [ + "hero-video": CatalogAsset(name: "ThisAssetDoesNotExist", bundle: .main) + ] + let url = URL(string: "swlocal://hero-video")! + + #expect(throws: LocalFileSchemeHandler.FileError.fileNotFound("hero-video (data asset ThisAssetDoesNotExist)")) { + try handler.loadFile(from: url) + } + + Superwall.shared.options.localResources = [:] + } + + @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("loadFile detects correct mime types from file extension") func loadFileMimeTypes() throws { let handler = LocalFileSchemeHandler() From 61d38f2223f4caee8d992682af5af9e2f3cf7aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:25:37 +0100 Subject: [PATCH 02/15] Back-fill CatalogAsset MIME type on iOS 13 `UTType.preferredMIMEType` is iOS 14+. On iOS 13, fall back to `UTTypeCopyPreferredTagWithClass` from MobileCoreServices so catalog assets are served with a real MIME type (an `` or `