Download files in a WKWebView

As you know if you follow me I have many hybrid apps, with native code and web content. Recently I encountered a problem: I needed to show a web page and download a file from it. As most developer do these days, I looked for this very post title on Google, and I couldn’t find a simple and straight answer, so this blog post is for the future self, as usual, but even for a guy like you who’s looking for the same answer.
I’ll show you how to detect a mime type you may be interested into (PDF, Excel etc.) and instead of letting the WKWebView open it save the file with a URLSession and then offer the user the ability to move the file with a share sheet. He may open it in another app, or store it into the File app.

The sample project

As usual you can find all the code in this article on GitHub
It is a simple ViewController that instantiates the class WKWebViewDownloadHelper. The class is the navigationDelegate of the WKWebView and is responsible for detecting the correct mimeType, download the file and call its delegate with the path of the file.

MIME Type

When browsing the web in our WKWebView we may want to download some kind of files. A web view is perfectly capable of displaying a PDF document, but although it supports a basic view of an Excel file you may want to open it with Numbers or MS Excel.
How do we know the URL we’re opening contains an Excel document? By looking at the MIME type.
Our class is the navigationDelegate for the WKWebView and we have two methods called each time the page wants to redirect to a new URL.
The first one is decidePolicyFor navigationAction, and the second is decidePolicyFor navigationResponse. The latter is called when we received the first response from the server, we can still tell the web view to avoid opening the URL and we need this method so we can parse the response to read the HTTP headers.


func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
        if let mimeType = navigationResponse.response.mimeType {
...
}

mimeType is a string, representing, guess what, the MIME type.
I found out there are a few MIME types for Excel, for example application/x-ms-excel application/vnd.ms-excel application/x-ms-excel
In my example I wrote a simple function to find a portion of text, so you can configure excel as your MIME type and all the types above will match.
There is another HTTP header we can read in order to find out the file name. The header is “Content-Disposition” and when we download a file we may find the string “attachment; filename=test.xls”.

I used a simple struct to contain the MIME type and the expected file extension, so if I can’t figure out the file name from the response, in Content Disposition I can at least use a default file name with the correct extension.


if isMimeTypeConfigured(mimeType) {
    if let url = navigationResponse.response.url {
        let fileName = getFileNameFromResponse(navigationResponse.response)
        downloadData(fromURL: url, fileName: fileName) { success, destinationURL in
           if success, let destinationURL = destinationURL {
               self.delegate.fileDownloadedAtURL(url: destinationURL)
            }
        }
        decisionHandler(.cancel)
        return
    }
}

private func getFileNameFromResponse(_ response:URLResponse) -> String {
    if let httpResponse = response as? HTTPURLResponse {
        let headers = httpResponse.allHeaderFields
        if let disposition = headers["Content-Disposition"] as? String {
            let components = disposition.components(separatedBy: " ")
            if components.count > 1 {
                let innerComponents = components[1].components(separatedBy: "=")
                if innerComponents.count > 1 {
                    if innerComponents[0].contains("filename") {
                        return innerComponents[1]
                    }
                }
            }
        }
    }
    return "default"
}

Download the file

Once we have the file name, we can start downloading the file.
The site we’re visiting, may require authentication. The WKWebViews is able to manage cookies, you can see for yourself that if you open the site with your app a second time you don’t have to login again, as it happens in Safari. We cannot download the file with the WKWebView directly though, we do need to open a URLSession and if there is authentication, we need to pass it to the session, otherwise the download will fail.


private func downloadData(fromURL url:URL,
                          fileName:String,
                          completion:@escaping (Bool, URL?) -> Void) {
    webView.configuration.websiteDataStore.httpCookieStore.getAllCookies() { cookies in
        let session = URLSession.shared
        session.configuration.httpCookieStorage?.setCookies(cookies, for: url, mainDocumentURL: nil)
        let task = session.downloadTask(with: url) { localURL, urlResponse, error in
            if let localURL = localURL {
                let destinationURL = self.moveDownloadedFile(url: localURL, fileName: fileName)
                completion(true, destinationURL)
            }
            else {
                completion(false, nil)
            }
        }

        task.resume()
    }
}

The first line of this function copies the cookies stored into the web view. Once we have the cookies, we can add them to the URLSession we’re about to use to download the file.
With the cookies set, we can use a downloadTask to download a file, and when we’re done we can call the callback which will notify the delegate about the new file. As you can see, I’m using the file name specified by the caller to move the downloaded file from URLSession to a new destination inside my app. I’m using the temporary directory but you can use Cache or Document as well.

Once the file is downloaded we may give control to the user with a share sheet, so he can open it in Excel or save it in the File app


func fileDownloadedAtURL(url: URL) {
    DispatchQueue.main.async {
        let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
        activityVC.popoverPresentationController?.sourceView = self.view
        activityVC.popoverPresentationController?.barButtonItem = self.navigationItem.rightBarButtonItem
        self.present(activityVC, animated: true, completion: nil)
    }
}

WKDownload

In iOS 14.5 Apple introduced a new way to manage downloads in a WKWebView. The new API is described in Explore WKWebView additions.
I created a Swift package called WKDownloadHelper to deal with the new API while being compatible with previous versions of iOS.
I’m going to show you how to interact with WKDownload, my API for older iOS versions is similar to the one I described above.


public func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
        guard let url = navigationResponse.response.url else {
            decisionHandler(.cancel)
            return
        }
        if let delegate = delegate {
            let canNavigate = delegate.canNavigate(toUrl: url)
            if canNavigate == false {
                decisionHandler(.cancel)
                return
            }
        }
        if let mimeType = navigationResponse.response.mimeType {
            if isMimeTypeConfigured(mimeType) {
                if #available(iOS 14.5, *) {
                    decisionHandler(.download)
                } else { // old API

first, there is a delegate function to allow a particular URL. I implemented that as my WKDownloadHelper become the WKNavigation delegate so there is a way to block undesired URLs.
Then, if the MIME type is among the ones configured, we can call the decitionHandler with the new .download value. This tells the WKWebView to download the file at the given URL.


@available(iOS 14.5, *)
public func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
    download.delegate = self
}

After calling decisionHandler(.download) this new delegate method is called to tell us there is a new WKDownload. We can now set our class to be the WKDownload delegate, so we’ll be able to follow the download progress until it ends or fails with an error.


public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) {
    let temporaryDir = NSTemporaryDirectory()
    let fileName = temporaryDir + "/" + suggestedFilename
    let url = URL(fileURLWithPath: fileName)
    fileDestinationURL = url
    completionHandler(url)
}

public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
    delegate?.didFailDownloadingFile(error: error)
}

public func downloadDidFinish(_ download: WKDownload) {
    if let url = fileDestinationURL,
       let delegate = self.delegate {
        delegate.didDownloadFile(atUrl: url)
    }
}

Those are the WKDownload delegate methods. The first one allows us to specify the path of the local file downloaded by the WKWebView. With the old API, we had to move the file to our app’s internal storage, with WKDownload we can specify the destination URL so once the transfer is completed we don’t need to move the file, it is already were we need it.
We then have a success function when the file is downloaded, and we can call the helper’s delegate to notify it about the successful transfer, or we can have an error. In case of error, WKDownload gives us the error and the Data downloaded so far, in case we want to resume the download later and avoid starting from scratch.

That’s it. Hope you found this quick tutorial useful. Happy coding 🙂