From 903d70ebf76a7eb25b86d02fcec234cfcf298815 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yusuf=20To=CC=88r?=
<3296904+yusuftor@users.noreply.github.com>
Date: Tue, 25 Nov 2025 16:19:48 +0100
Subject: [PATCH] Add a scheme handler to get local files into paywall
---
.../Web View/LocalFileSchemeHandler.swift | 207 ++++++++++++++++++
.../View Controller/Web View/SWWebView.swift | 3 +
SuperwallKit.xcodeproj/project.pbxproj | 8 +
.../LocalFileSchemeHandlerTests.swift | 41 ++++
4 files changed, 259 insertions(+)
create mode 100644 Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift
create mode 100644 Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift
diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift
new file mode 100644
index 0000000000..9e0ae2e787
--- /dev/null
+++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift
@@ -0,0 +1,207 @@
+//
+// LocalFileSchemeHandler.swift
+// SuperwallKit
+//
+// Created by Yusuf Tör on 25/11/2025.
+//
+
+import Foundation
+import WebKit
+
+/// Handles custom URL scheme requests for serving local files to the paywall webview.
+///
+/// This enables paywalls to reference local files (videos, images, etc.) bundled with the app.
+///
+/// ## URL Format
+/// ```
+/// swlocal://path/to/file.mp4
+/// ```
+///
+/// ## Usage in Paywall HTML
+/// ```html
+///
+///
+/// ```
+final class LocalFileSchemeHandler: NSObject, WKURLSchemeHandler {
+ /// The custom URL scheme for local files
+ static let scheme = "swlocal"
+
+ /// Errors that can occur during file loading
+ enum FileError: LocalizedError {
+ case invalidURL
+ case fileNotFound(String)
+ case unableToReadFile(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL:
+ return "Invalid local file URL format"
+ case .fileNotFound(let path):
+ return "File not found: \(path)"
+ case .unableToReadFile(let path):
+ return "Unable to read file: \(path)"
+ }
+ }
+ }
+
+ // MARK: - WKURLSchemeHandler
+
+ func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
+ guard let url = urlSchemeTask.request.url else {
+ urlSchemeTask.didFailWithError(FileError.invalidURL)
+ return
+ }
+
+ do {
+ let (data, mimeType) = try loadFile(from: url)
+
+ let response = URLResponse(
+ url: url,
+ mimeType: mimeType,
+ expectedContentLength: data.count,
+ textEncodingName: nil
+ )
+
+ urlSchemeTask.didReceive(response)
+ urlSchemeTask.didReceive(data)
+ urlSchemeTask.didFinish()
+ } catch {
+ Logger.debug(
+ logLevel: .error,
+ scope: .paywallViewController,
+ message: "Failed to load local file",
+ info: ["url": url.absoluteString, "error": error.localizedDescription]
+ )
+ urlSchemeTask.didFailWithError(error)
+ }
+ }
+
+ func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
+ // No cleanup needed for synchronous file loading
+ }
+
+ // MARK: - File Loading
+
+ /// Loads a file from the app bundle based on the URL path
+ /// - Parameter url: The swlocal:// URL
+ /// - Returns: Tuple of file data and MIME type
+ private func loadFile(from url: URL) throws -> (Data, String) {
+ // URL format: swlocal://path/to/file.ext
+ // The host + path together form the file path
+ guard let host = url.host else {
+ throw FileError.invalidURL
+ }
+
+ // Combine host and path to get full file path
+ // e.g., swlocal://design/video.mp4 -> design/video.mp4
+ let filePath: String
+ if url.path.isEmpty {
+ filePath = host
+ } else {
+ filePath = host + url.path
+ }
+
+ guard let fileURL = findInBundle(path: filePath) else {
+ throw FileError.fileNotFound(filePath)
+ }
+
+ guard let data = try? Data(contentsOf: fileURL) else {
+ throw FileError.unableToReadFile(filePath)
+ }
+
+ let mimeType = self.mimeType(for: fileURL.pathExtension)
+
+ return (data, mimeType)
+ }
+
+ /// Attempts to find a file in the bundle
+ private func findInBundle(path: String) -> URL? {
+ // Try with the path as-is in the resource path
+ if let resourcePath = Bundle.main.resourcePath {
+ let fullPath = (resourcePath as NSString).appendingPathComponent(path)
+ if FileManager.default.fileExists(atPath: fullPath) {
+ return URL(fileURLWithPath: fullPath)
+ }
+ }
+
+ // Try using url(forResource:withExtension:)
+ let nsPath = path as NSString
+ let name = nsPath.deletingPathExtension
+ let ext = nsPath.pathExtension
+
+ if !ext.isEmpty, let url = Bundle.main.url(forResource: name, withExtension: ext) {
+ return url
+ }
+
+ // Try with subdirectory
+ let directory = (path as NSString).deletingLastPathComponent
+ let filename = (path as NSString).lastPathComponent
+ let fileNameWithoutExt = (filename as NSString).deletingPathExtension
+ let fileExt = (filename as NSString).pathExtension
+
+ if !directory.isEmpty,
+ let url = Bundle.main.url(
+ forResource: fileNameWithoutExt,
+ withExtension: fileExt,
+ subdirectory: directory
+ ) {
+ return url
+ }
+
+ return nil
+ }
+
+ // MARK: - MIME Type Detection
+
+ /// Returns the MIME type for a file extension
+ private func mimeType(for pathExtension: String) -> String {
+ switch pathExtension.lowercased() {
+ // Video
+ case "mp4", "m4v":
+ return "video/mp4"
+ case "mov":
+ return "video/quicktime"
+ case "webm":
+ return "video/webm"
+ case "avi":
+ return "video/x-msvideo"
+
+ // Audio
+ case "mp3":
+ return "audio/mpeg"
+ case "wav":
+ return "audio/wav"
+ case "m4a", "aac":
+ return "audio/aac"
+ case "ogg":
+ return "audio/ogg"
+
+ // Images
+ case "jpg", "jpeg":
+ return "image/jpeg"
+ case "png":
+ return "image/png"
+ case "gif":
+ return "image/gif"
+ case "webp":
+ return "image/webp"
+ case "svg":
+ return "image/svg+xml"
+
+ // Other
+ case "json":
+ return "application/json"
+ case "pdf":
+ return "application/pdf"
+ case "html", "htm":
+ return "text/html"
+ case "css":
+ return "text/css"
+ case "js":
+ return "application/javascript"
+
+ default:
+ return "application/octet-stream"
+ }
+ }
+}
diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift
index 1d5a6a5e64..d6e79a2cd7 100644
--- a/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift
+++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift
@@ -65,6 +65,9 @@ class SWWebView: WKWebView {
config.allowsPictureInPictureMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
+ // Register custom URL scheme handler for local files (videos, images, etc.)
+ config.setURLSchemeHandler(LocalFileSchemeHandler(), forURLScheme: LocalFileSchemeHandler.scheme)
+
if featureFlags?.enableSuppressesIncrementalRendering == true {
config.suppressesIncrementalRendering = true
}
diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj
index 1bf16261a2..6c89207fab 100644
--- a/SuperwallKit.xcodeproj/project.pbxproj
+++ b/SuperwallKit.xcodeproj/project.pbxproj
@@ -191,6 +191,7 @@
69D24C6E0411E512FECF7258 /* Attribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B42460730F1CDB856A35CFA /* Attribution.swift */; };
6B01DA1FF5FE0240089DEB36 /* StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7B78DEECD91AFD89B39F9D /* StoreTransaction.swift */; };
6BA614F410A95F36CBA42F93 /* EntitlementProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */; };
+ 6C752C1F0F303B77FB243D10 /* LocalFileSchemeHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */; };
6C98C5DAAC3F493511A57AC3 /* WaitForEntitlementsAndConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFD245D3CB9044C225E1503 /* WaitForEntitlementsAndConfigTests.swift */; };
6C9B0FB29EA135B4D9A20705 /* WebViewURLConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */; };
6D60D1CC06D717C764BDE181 /* ProductPurchaserSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99ACA54EC311F399BB025CD /* ProductPurchaserSK2.swift */; };
@@ -315,6 +316,7 @@
AEAB0C0C168B0B3D864109EA /* CoreDataManagerFakeDataMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E74C7DE0FAFE0C01F374DDF0 /* CoreDataManagerFakeDataMock.swift */; };
AEB9D461AF5103FB7257AD25 /* SwiftyJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */; };
AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27F0D55EF3480E2B65C8DFD /* HandleTriggerResultOperatorTests.swift */; };
+ B078481CA0ADD4B4F3BEFD15 /* LocalFileSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */; };
B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */; };
B0B0AD9409CEFE7CA8225146 /* Array+SafeRemove.swift in Sources */ = {isa = PBXBuildFile; fileRef = C855DE8F5341D67C614E3AF5 /* Array+SafeRemove.swift */; };
B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79E9DBDDA7FEE63C15FBEAF /* CELEvaluatorTests.swift */; };
@@ -680,6 +682,7 @@
61D3EA7000250D02303BEF81 /* Date+WithinAnHourBefore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+WithinAnHourBefore.swift"; sourceTree = ""; };
62AC69B94A568B7E14A391A8 /* SWProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProductDiscount.swift; sourceTree = ""; };
62EC6A60945A85646E1230C1 /* ThrowableDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThrowableDecodable.swift; sourceTree = ""; };
+ 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileSchemeHandler.swift; sourceTree = ""; };
63F4E993A2A86075BB6FB9FD /* SuperwallEventObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallEventObjc.swift; sourceTree = ""; };
641BC3C3F8AC2D6E1EF44D55 /* ProductsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsManager.swift; sourceTree = ""; };
64293B1D6F648DE113908AE7 /* EventsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsRequest.swift; sourceTree = ""; };
@@ -831,6 +834,7 @@
A5C4AD6349F2D432132F36D5 /* MockSubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSubscriptionPeriod.swift; sourceTree = ""; };
A6B47DD5F59411CC529CD2DB /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; };
A79E9DBDDA7FEE63C15FBEAF /* CELEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CELEvaluatorTests.swift; sourceTree = ""; };
+ A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileSchemeHandlerTests.swift; sourceTree = ""; };
A7C3FC26DCBF604D7319FBB2 /* zh_Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh_Hant; path = zh_Hant.lproj/Localizable.strings; sourceTree = ""; };
A94120D51C7B36AC9EA32B8B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; };
A99ACA54EC311F399BB025CD /* ProductPurchaserSK2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK2.swift; sourceTree = ""; };
@@ -1292,6 +1296,7 @@
29AF8407DB4560B41050C02F /* Web View */ = {
isa = PBXGroup;
children = (
+ 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */,
9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */,
8D9545633A97A2E63FEDF78A /* SWWebViewLoadingHandler.swift */,
4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */,
@@ -1491,6 +1496,7 @@
3BE2C31A01D986D7FED5C4F4 /* Web View */ = {
isa = PBXGroup;
children = (
+ A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */,
23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */,
4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */,
FD8F67EFF69ECDBE85EB24F5 /* Message Handling */,
@@ -2896,6 +2902,7 @@
77EDD2927FF8DCF95579BE3E /* IdentityLogicTests.swift in Sources */,
D89E9C69317044050B97B573 /* IdentityManagerMock.swift in Sources */,
7909E7B477A2E2EBF84598F2 /* InternallySetSubscriptionStatusTests.swift in Sources */,
+ 6C752C1F0F303B77FB243D10 /* LocalFileSchemeHandlerTests.swift in Sources */,
4DE01655FC4CC148DD3D161C /* LoggerMock.swift in Sources */,
B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */,
BA957415E2E1A38A25550B99 /* MockIntroductoryPeriod.swift in Sources */,
@@ -3099,6 +3106,7 @@
3CF2307C2CB994D00A35FADD /* LoadingModel.swift in Sources */,
7DA2CF4C6FF5C8A1C44282E6 /* LoadingView.swift in Sources */,
D443CC56731E9A20E4F43F4C /* LoadingViewController.swift in Sources */,
+ B078481CA0ADD4B4F3BEFD15 /* LocalFileSchemeHandler.swift in Sources */,
999B569F8B14C4DF8FD5ACFF /* LocalNotification.swift in Sources */,
C90E075C3BEE92968BBC4E1B /* LocalizationConfig.swift in Sources */,
B84CA6014D8D6EF201DB3935 /* LocalizationGrouping.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
new file mode 100644
index 0000000000..5f028404e9
--- /dev/null
+++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/LocalFileSchemeHandlerTests.swift
@@ -0,0 +1,41 @@
+//
+// LocalFileSchemeHandlerTests.swift
+// SuperwallKit
+//
+// Created by Yusuf Tör on 25/11/2025.
+//
+// swiftlint:disable all
+
+import Testing
+@testable import SuperwallKit
+import Foundation
+
+struct LocalFileSchemeHandlerTests {
+
+ // MARK: - Scheme Tests
+
+ @Test("Scheme constant is correct")
+ func schemeConstant() {
+ #expect(LocalFileSchemeHandler.scheme == "swlocal")
+ }
+
+ // MARK: - Error Tests
+
+ @Test("FileError invalidURL has description")
+ func fileErrorInvalidURL() {
+ let error = LocalFileSchemeHandler.FileError.invalidURL
+ #expect(error.errorDescription == "Invalid local file URL format")
+ }
+
+ @Test("FileError fileNotFound includes path")
+ func fileErrorFileNotFound() {
+ let error = LocalFileSchemeHandler.FileError.fileNotFound("design/video.mp4")
+ #expect(error.errorDescription == "File not found: design/video.mp4")
+ }
+
+ @Test("FileError unableToReadFile includes path")
+ func fileErrorUnableToRead() {
+ let error = LocalFileSchemeHandler.FileError.unableToReadFile("design/video.mp4")
+ #expect(error.errorDescription == "Unable to read file: design/video.mp4")
+ }
+}