DEV Community

Gualtiero Frigerio
Gualtiero Frigerio

Posted on • Updated on

Custom URL schemes in a WKWebView

Sometimes you may need to display HTML content in your app, even if you don’t use a framework to make hybrid apps.
Usually you can just open the mobile version of your website to show a particular page, but what if your app need to work offline? Turns out it is still a requirement, even in the era of 5G.
This article is about handling custom URL schemes inside a WKWebView, and I’ll give you a couple of examples that allow you to serve HTML content offline if necessary.
First, I’ll show local assets inside the HTML page, then I’ll implement a simple REST service backed by a sqlite database.
As usual, all the code can be found on GitHub.

WKURLSchemeHandler

WKWebView was introduced back in 2014 with iOS 8 to replace the old UIWebView, but in order to implement custom url scheme we had to wait for iOS 11 that added WKURLSchemeHandler for that purpose.
Two functions must be implemented to conform to the protocol: a start function called when the handler needs to load a resource, and a stop function to stop loading data. My understanding is that the stop function may be used for cleanup, the most important one is the start where you need to respond with data loading. Note that didReceive and didFinish may raise an exception if stop was called, so you may need to store a state and avoid calling didReceive and didFinish in that case.

func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
    guard let url = urlSchemeTask.request.url,
          let fileUrl = fileUrlFromUrl(url),
          let mimeType = mimeType(ofFileAtUrl: fileUrl),
          let data = try? Data(contentsOf: fileUrl) else { return }

    let response = HTTPURLResponse(url: url,
                                   mimeType: mimeType,
                                   expectedContentLength: data.count, textEncodingName: nil)

    urlSchemeTask.didReceive(response)
    urlSchemeTask.didReceive(data)
    urlSchemeTask.didFinish()
}

func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {

}
Enter fullscreen mode Exit fullscreen mode

the start function is passed a WKURLSchemeTask object. From it, you can get the URLRequest, and you can send back a response via didReceive(URLResponse) and send data by calling didReceive(Data). By calling didFinish we notify the web view that the task is completed and all the data is sent.
In order to add a custom scheme handler, we need to use a WKWebViewConfiguration.

let webView: WKWebView

init(withSchemeHandlers handlers: [SchemeHandlerEntry]) {
    let preferences = WKPreferences()

    let configuration = WKWebViewConfiguration()
    configuration.preferences = preferences

    for entry in handlers {
        configuration.setURLSchemeHandler(entry.schemeHandler,
                                          forURLScheme: entry.urlScheme)
    }

    webView = WKWebView(frame: CGRect.zero, configuration: configuration)
    webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    webView.scrollView.bounces = false

    super.init()
}
Enter fullscreen mode Exit fullscreen mode

each URL scheme handler has a custom URL scheme, so for example you can register the asset handler with the string “assets” and the one dealing with SQL with “sqlite”.

Serve local assets

Let’s start with the first example: serving local assets to an HTML page.
The code sample I pasted previously for the start function of the scheme handler is the one for serving local assets. This is the entire class

import Foundation
import UniformTypeIdentifiers
import WebKit

class AssetsSchemeHandler: NSObject, SchemeHandler {
    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        guard let url = urlSchemeTask.request.url,
              let fileUrl = fileUrlFromUrl(url),
              let mimeType = mimeType(ofFileAtUrl: fileUrl),
              let data = try? Data(contentsOf: fileUrl) else { return }

        let response = HTTPURLResponse(url: url,
                                       mimeType: mimeType,
                                       expectedContentLength: data.count, textEncodingName: nil)

        urlSchemeTask.didReceive(response)
        urlSchemeTask.didReceive(data)
        urlSchemeTask.didFinish()
    }

    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {

    }

    // MARK: - Private

    private func fileUrlFromUrl(_ url: URL) -> URL? {
        guard let assetName = url.host else { return nil }
        return Bundle.main.url(forResource: assetName,
                               withExtension: "",
                               subdirectory: "assets")
    }

    private func mimeType(ofFileAtUrl url: URL) -> String? {
        guard let type = UTType(filenameExtension: url.pathExtension) else {
            return nil
        }
        return type.preferredMIMEType
    }
}
Enter fullscreen mode Exit fullscreen mode

for the sake of this example, we assume the page will have an url like assets://test.jpg and test.jpg will be found in the assets folder of the app. This way we can extract the host part from the url (test.jpg) and look for this file into the app Bundle.
In order to set a correct MIME type to the HTTP response, I used UTType. It is a struct describing type information, by creating it with a file name extension I’m able to get the preferredMIMEType and use it for the response. For example test.jpg will return image/jpeg.
If you want to try some assets with my GitHub project just add files to the assets directory, and refer to them with assets:// in the HTML page

<html>
    <body>
        <h2>Test page</h2>
        <img src="assets://test.jpg"></img>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

this is a test page with an image called test.jpg

Support REST calls

As I mentioned at the beginning, we may need to support offline mode in our app. While serving local assets could be easy if the HTML and the assets are in the same directory, the page may need to make REST calls to a server. One way to support offline for REST call, is to implement a custom scheme and have a local DB like CoreData or, in this example, a SQLite DB so you can make queries and return a dictionary with the result to the page.

<html>
    <script>
        function getData() {
            var xhr = new XMLHttpRequest();
            xhr.open('GET', "sqlite://products", true);
            xhr.responseType = 'json';
            xhr.onload = function() {
              var status = xhr.status;
              if (status === 200) {
                console.log(xhr.response);
              } else {
                console.log("error")
              }
              document.getElementById("results").innerHTML = JSON.stringify(xhr.response);
            };
            xhr.send();
        }
    </script>
    <body>
        <h2>Test page</h2>
        <img src="assets://test.jpg"></img>
        <div id="button" onclick="getData()">Get data</div>
        <div id="results"></div>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

this is a simple page with some javascript to make a GET and expect a JSON back. We use the sqlite:// schema, in a real scenario you may try to call the https service first and fall back to the local DB if it fails, but you may also use the local DB every time. The advantage of this approach is that you can use the same code you have for calling a remote web service and only change the URL.
If you see a Access-Control-Allow-Origin in the console when trying to use the custom scheme, try enabling universal access from file URL in your WKWebView configuration like this

let configuration = WKWebViewConfiguration()
configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
Enter fullscreen mode Exit fullscreen mode

The handler is similar to the one for local assets, let’s have a look

class SQLiteSchemeHandler: NSObject, SchemeHandler {
    init(databasePath: String) {
        sqlWrapper = SQLiteWrapper(path: databasePath)
    }

    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        guard let url = urlSchemeTask.request.url else { return }
        let data = dataForURL(url)
        let response = HTTPURLResponse(url: url,
                                       mimeType: "application/json",
                                       expectedContentLength: data?.count ?? 0,
                                       textEncodingName: "utf-8")

        urlSchemeTask.didReceive(response)
        if let data = data {
            urlSchemeTask.didReceive(data)
        }
        urlSchemeTask.didFinish()
    }

    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {

    }
}

// MARK: - Private

private var sqlWrapper: SQLiteWrapper?

private func dataForURL(_ url: URL) -> Data? {
    var data: Data?
    guard let wrapper = sqlWrapper,
          let urlValue = url.host,
          let jsonObject = wrapper.performQuery("select * from \(urlValue)") else { return nil }
    if JSONSerialization.isValidJSONObject(jsonObject) {
        data = try? JSONSerialization.data(withJSONObject: jsonObject,
                                           options: .fragmentsAllowed)
    }
    return data
}
Enter fullscreen mode Exit fullscreen mode

this time, the MIME type is always application/json. We then use the host part of the url to get the name of the table and perform a query, then try to convert the result to a JSON data and send it back to the page.
Not bad, but we can easily do better than that and allow the page to filter our data.

private func dataForURL(_ url: URL) -> Data? {
    var data: Data?
    guard let wrapper = sqlWrapper,
          let host = url.host else { return nil }
    let queryItems = URLComponents(string: url.absoluteString)?.queryItems ?? []
    var query: String = ""
    if queryItems.count > 0 {
        var whereStr = ""
        for item in queryItems {
            if let value = item.value {
                if whereStr != "" {
                    whereStr += " AND "
                }
                whereStr += "\(item.name) = \(value)"
            }
        }
        query = "select * from \(host) WHERE \(whereStr)"
    }
    else {
        query = "select * from \(host)"
    }

    guard let jsonObject = wrapper.performQuery(query) else { return nil }
    if JSONSerialization.isValidJSONObject(jsonObject) {
        data = try? JSONSerialization.data(withJSONObject: jsonObject,
                                           options: .fragmentsAllowed)
    }
    return data
}
Enter fullscreen mode Exit fullscreen mode

in the function above, everything in query string will be converted to a WHERE statement in SQL.
For example, calling sqlite://products?name=test&brand=testbrand will become SELECT * from products where name = test and brand = testbrand
By adding more complexity, we could support other filters or even POST/PUT requests and convert them to INSERT or UPDATE in SQL.

Test custom schemes

We all love testing our code. Ok, maybe you don't really love it, but it is a good idea to cover your code with unit tests.
If you've implemented a custom scheme you need to interact with some HTML content, but testing that part is beyond the scope of this article.
Let's see how you can test the WKURLSchemeHandler to make sure it will be able to act correctly when invoked by your HTML content, and will respond with the correct data.
The first thing I needed to do was creating a concrete type for the WKURLSchemeTask protocol, in order to create a valid task to pass to the scheme handler and test the response. This is the implementation (link to the file on GitHub)

class WKURLSchemeTaskTest: NSObject {
    private (set) var request: URLRequest

    init(withRequest: URLRequest) {
        self.request = withRequest
    }

    func expectData(data: Data, completionHandler: @escaping (Bool) -> Void) {
        self.expectedData = data
        self.completionHandler = completionHandler
    }

    private var completionHandler: ((Bool) -> Void)?
    private var expectedData: Data?
    private var receivedData: Data?
}

extension WKURLSchemeTaskTest: WKURLSchemeTask {
    func didReceive(_ response: URLResponse) {

    }

    func didReceive(_ data: Data) {
        if receivedData == nil {
            receivedData = data
        }
        else {
            receivedData?.append(data)
        }
    }

    func didFinish() {
        if receivedData == expectedData {
            completionHandler?(true)
        }
        else {
            completionHandler?(false)
        }
    }

    func didFailWithError(_ error: Error) {
        completionHandler?(false)
    }
}
Enter fullscreen mode Exit fullscreen mode

The test class can be initialised with a URLRequest to satisfy the requirement of WKURLSchemeTask, and I added a function to set the expected data. This way, when didFinish is called, I can check if the received data is equal to the expected data and use this information for my unit test.

func testCoreDataGet() {
    let productName = "Name1"
    guard let data = coreDataTest.dataArray(forProductName: productName) else {
        XCTFail("Cannot get data for product")
        return
    }
    guard let task = schemeHandlerTest.schemeTask(forProductName: productName) else {
        XCTFail("Cannot create scheme task")
        return
    }
    let expectation = expectation(description: "testCoreDataGet")

    task.expectData(data: data) { success in
        XCTAssertTrue(success)
        expectation.fulfill()
    }
    coreDataSchemeHandler.webView(webViewTest, start: task)

    waitForExpectations(timeout: 1.0)
}
Enter fullscreen mode Exit fullscreen mode

the code sample above is for a test involving CoreData, first I generated the Data with an array of one Product, then I created the WKURLSchemeHandler object with the correct URLResponse to retrieve the data and I called the start function, the one that would be called after the HTML content asked for a resource in GET or POST.
I had to use expectation as expectData returns it result asynchronously, but I'm going to show you another way to test this kind of code by using async await

func testCoreDataGetAsync() async {
    let productName = "Name1"
    guard let data = coreDataTest.dataArray(forProductName: productName) else {
        XCTFail("Cannot get data for product")
        return
    }
    guard let task = schemeHandlerTest.schemeTask(forProductName: productName) else {
        XCTFail("Cannot create scheme task")
        return
    }
    let success: Bool = await withUnsafeContinuation{ continuation in
        task.expectData(data: data) { success in
            continuation.resume(returning: success)
        }
        coreDataSchemeHandler.webView(webViewTest, start: task)
    }
    XCTAssertTrue(success)
}
Enter fullscreen mode Exit fullscreen mode

if you're interested, check out my previous article Test asynchronous code

Conclusion

I gave you a couple of example of custom scheme handlers, but you may have many more use cases, like making a bridge to Core Data, or to the user photo library, or assets you don’t have locally on your device but need to fetch with some custom code in Swift.
Happy coding 🙂

Original post

Latest comments (0)