Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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
/// <video src="swlocal://design/intro-video.mp4" autoplay></video>
/// <img src="swlocal://images/hero.png">
/// ```
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(

Check warning on line 143 in Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Indentation Width Violation: Code should be indented using one tab or 2 spaces (indentation_width)
forResource: fileNameWithoutExt,

Check warning on line 144 in Sources/SuperwallKit/Paywall/View Controller/Web View/LocalFileSchemeHandler.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Indentation Width Violation: Code should be indented using one tab or 2 spaces (indentation_width)
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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
8 changes: 8 additions & 0 deletions SuperwallKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -680,6 +682,7 @@
61D3EA7000250D02303BEF81 /* Date+WithinAnHourBefore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+WithinAnHourBefore.swift"; sourceTree = "<group>"; };
62AC69B94A568B7E14A391A8 /* SWProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProductDiscount.swift; sourceTree = "<group>"; };
62EC6A60945A85646E1230C1 /* ThrowableDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThrowableDecodable.swift; sourceTree = "<group>"; };
63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileSchemeHandler.swift; sourceTree = "<group>"; };
63F4E993A2A86075BB6FB9FD /* SuperwallEventObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallEventObjc.swift; sourceTree = "<group>"; };
641BC3C3F8AC2D6E1EF44D55 /* ProductsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsManager.swift; sourceTree = "<group>"; };
64293B1D6F648DE113908AE7 /* EventsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsRequest.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -831,6 +834,7 @@
A5C4AD6349F2D432132F36D5 /* MockSubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSubscriptionPeriod.swift; sourceTree = "<group>"; };
A6B47DD5F59411CC529CD2DB /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = "<group>"; };
A79E9DBDDA7FEE63C15FBEAF /* CELEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CELEvaluatorTests.swift; sourceTree = "<group>"; };
A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileSchemeHandlerTests.swift; sourceTree = "<group>"; };
A7C3FC26DCBF604D7319FBB2 /* zh_Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh_Hant; path = zh_Hant.lproj/Localizable.strings; sourceTree = "<group>"; };
A94120D51C7B36AC9EA32B8B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
A99ACA54EC311F399BB025CD /* ProductPurchaserSK2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK2.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1292,6 +1296,7 @@
29AF8407DB4560B41050C02F /* Web View */ = {
isa = PBXGroup;
children = (
63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */,
9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */,
8D9545633A97A2E63FEDF78A /* SWWebViewLoadingHandler.swift */,
4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */,
Expand Down Expand Up @@ -1491,6 +1496,7 @@
3BE2C31A01D986D7FED5C4F4 /* Web View */ = {
isa = PBXGroup;
children = (
A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */,
23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */,
4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */,
FD8F67EFF69ECDBE85EB24F5 /* Message Handling */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading