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") + } +}