5

I am trying to work with a WKWebView in swift and currently have a download engine using AlamoFire. I have run into a site that uses the blob: url scheme to download items. Is there a way to download blob files using AlamoFire or WKWebView in general?

My specific goal is to download the content from this blob URI to a file.

I would appreciate any help. Thank you.

All relevant code is attached below.

Here's the URL I was having a problem with:

blob:https://cubari.moe/87d49857-dfef-4f0f-bb83-db8517fd3094

Here is the error in my logs:

2021-12-10 22:41:45.382527-0500 Asobi[14529:358202] -canOpenURL: failed for URL: "blob:https://cubari.moe/87d49857-dfef-4f0f-bb83-db8517fd3094" - error: "This app is not allowed to query for scheme blob"
2021-12-10 22:41:45.474214-0500 Asobi[14529:358357] Task <4B011CC1-60E9-4AAD-98F0-BB6A6D0C92FB>.<1> finished with error [-1002] Error Domain=NSURLErrorDomain Code=-1002 "unsupported URL" UserInfo={NSLocalizedDescription=unsupported URL, NSErrorFailingURLStringKey=blob:https://cubari.moe/87d49857-dfef-4f0f-bb83-db8517fd3094, NSErrorFailingURLKey=blob:https://cubari.moe/87d49857-dfef-4f0f-bb83-db8517fd3094, _NSURLErrorRelatedURLSessionTaskErrorKey=(
    "LocalDownloadTask <4B011CC1-60E9-4AAD-98F0-BB6A6D0C92FB>.<1>"
), _NSURLErrorFailingURLSessionTaskErrorKey=LocalDownloadTask <4B011CC1-60E9-4AAD-98F0-BB6A6D0C92FB>.<1>, NSUnderlyingError=0x6000017e99b0 {Error Domain=kCFErrorDomainCFNetwork Code=-1002 "(null)"}}
2021-12-10 22:41:45.476703-0500 Asobi[14529:358202] [Process] 0x124034e18 - [pageProxyID=6, webPageID=7, PID=14540] WebPageProxy::didFailProvisionalLoadForFrame: frameID=3, domain=WebKitErrorDomain, code=102
Failed provisional nav: Error Domain=WebKitErrorDomain Code=102 "Frame load interrupted" UserInfo={_WKRecoveryAttempterErrorKey=<WKReloadFrameErrorRecoveryAttempter: 0x6000019a88c0>, NSErrorFailingURLStringKey=blob:https://cubari.moe/87d49857-dfef-4f0f-bb83-db8517fd3094, NSErrorFailingURLKey=blob:https://cubari.moe/87d49857-dfef-4f0f-bb83-db8517fd3094, NSLocalizedDescription=Frame load interrupted}

Here is the code for my download decision handler in WKNavigation decision policy

// Check if a page can be downloaded
func webView(_ webView: WKWebView,
             decidePolicyFor navigationResponse: WKNavigationResponse,
             decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
    
    if navigationResponse.canShowMIMEType {
        decisionHandler(.allow)
    } else {
        let url = navigationResponse.response.url
        
        // Alternative to decisionHandler(.download) since that's iOS 15 and up
        //let documentUrl = url?.appendingPathComponent(navigationResponse.response.suggestedFilename!)
        parent.webModel.downloadDocumentFrom(url: url!)
        decisionHandler(.cancel)
    }
}

Here is the code for my download data function (it uses the AF.download method)

// Download file from page
func downloadDocumentFrom(url downloadUrl : URL) {
    if currentDownload != nil {
        showDuplicateDownloadAlert = true
        return
    }
    
    let queue = DispatchQueue(label: "download", qos: .userInitiated)
    var lastTime = Date()
    
    let destination: DownloadRequest.Destination = { tempUrl, response in
        let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let suggestedName = response.suggestedFilename ?? "unknown"
        
        let fileURL = documentsURL.appendingPathComponent(suggestedName)

        return (fileURL, [.removePreviousFile, .createIntermediateDirectories])
    }
    
    self.showDownloadProgress = true
    
    currentDownload = AF.download(downloadUrl, to: destination)
        .downloadProgress(queue: queue) { progress in
            if Date().timeIntervalSince(lastTime) > 1.5 {
                lastTime = Date()
                
                DispatchQueue.main.async {
                    self.downloadProgress = progress.fractionCompleted
                }
            }
        }
        .response { response in
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                self.showDownloadProgress = false
                self.downloadProgress = 0.0
            }
            
            if response.error == nil, let currentPath = response.fileURL {
                self.downloadFileUrl = currentPath
                self.showFileMover = true
            }
            
            if let error = response.error {
                self.errorDescription = "Download could not be completed. \(error)"
                self.showError = true
            }
        }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
code24
  • 458
  • 1
  • 3
  • 14
  • Another note: I am targeting for iOS 14 and up, so I cannot use WKDownloadDelegate since that's only available for iOS 15 and higher. – code24 Dec 11 '21 at 03:57
  • This is your error: “This app is not allowed to query for scheme blob". You need to add blob to LSApplicationQueriesSchemes. – koen Dec 11 '21 at 07:37
  • So, I just tried this and the application can now open blob URLs, but that's not what I want to do here. Instead, I want to download the contents within that blob URL format. When I try to open the URL normally, I get this error now `-canOpenURL: failed for URL: "blob:https://cubari.moe/6d964a07-c4fe-4b22-95ac-7e3a6da88c6f" - error: "The operation couldn’t be completed.` – code24 Dec 11 '21 at 13:51
  • I don't know what blob is, but is that a valid url? – koen Dec 11 '21 at 15:27
  • Yes a blob URL is valid, here's the [MDN spec](https://developer.mozilla.org/en-US/docs/Web/API/Blob) – code24 Dec 11 '21 at 18:55
  • Maybe this is helpful: https://stackoverflow.com/questions/61702414/wkwebview-how-to-handle-blob-url – koen Dec 11 '21 at 19:37
  • I'm not sure how to run this JS code, through a userscript? Also, how would I save that data URL to files? – code24 Dec 11 '21 at 20:13
  • I don't know, it was the first link that showed up when I searched for 'blob swift wkwebview'. There were some other links as well, so maybe they will suit you better. Good luck.. – koen Dec 12 '21 at 12:09

2 Answers2

1

After a few days, I was able to figure out how to download a blob URL without WKDownloadDelegate. The following code builds upon this answer.

A message handler needs to be created to respond to JS messages. I created this in the makeUIView function

webModel.webView.configuration.userContentController.add(context.coordinator, name: "jsListener")

Inside your WKNavigationDelegate, you need to add this code on a navigation action.

NOTE: Since I use SwiftUI, all of my variables/models are located in the parent class (UIViewRepresentable coordinator).

func webView(_ webView: WKWebView,
             decidePolicyFor navigationAction: WKNavigationAction,
             decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    if let url = navigationAction.request.url, let scheme = url.scheme?.lowercased() {
        if scheme == "blob" {
            // Defer to JS handling
            parent.webModel.executeBlobDownloadJS(url: url)
            
            decisionHandler(.cancel)
        } else {
            decisionHandler(.allow)
        }
    }
}

Here's the JS to request for the blob stored in the browser memory. I added this JS in a wrapper function which called evaluateJavaScript with the url for cleanliness of my code.

function blobToDataURL(blob, callback) {
    var reader = new FileReader()
    reader.onload = function(e) {callback(e.target.result.split(",")[1])}
    reader.readAsDataURL(blob)
}

async function run() {
    const url = "\(url)"
    const blob = await fetch(url).then(r => r.blob())

    blobToDataURL(blob, datauri => {
        const responseObj = {
            url: url,
            mimeType: blob.type,
            size: blob.size,
            dataString: datauri
        }
        window.webkit.messageHandlers.jsListener.postMessage(JSON.stringify(responseObj))
    })
}

run()

In addition to the returned JS object, I had to make a struct where I can deserialize the JSON string:

struct BlobComponents: Codable {
    let url: String
    let mimeType: String
    let size: Int64
    let dataString: String
}

I then took the messages sent to the WKScriptMessageHandler and interpreted them for saving to files. I used the SwiftUI file mover here, but you can do anything you want with this content.

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    guard let jsonString = message.body as? String else {
        return
    }
    
    parent.webModel.blobDownloadWith(jsonString: jsonString)
}

In my web model (needed to import CoreServices):

func blobDownloadWith(jsonString: String) {
    guard let jsonData = jsonString.data(using: .utf8) else {
        print("Cannot convert blob JSON into data!")
        return
    }

    let decoder = JSONDecoder()
    
    do {
        let file = try decoder.decode(BlobComponents.self, from: jsonData)
        
        guard let data = Data(base64Encoded: file.dataString),
            let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, file.mimeType as CFString, nil),
            let ext = UTTypeCopyPreferredTagWithClass(uti.takeRetainedValue(), kUTTagClassFilenameExtension)
        else {
            print("Error! \(error)")
            return
        }
        
        let fileName = file.url.components(separatedBy: "/").last ?? "unknown"
        let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let url = path.appendingPathComponent("blobDownload-\(fileName).\(ext.takeRetainedValue())")
        
        try data.write(to: url)
        
        downloadFileUrl = url
        showFileMover = true
    } catch {
        print("Error! \(error)")
        return
    }
}
code24
  • 458
  • 1
  • 3
  • 14
1

Here is more modern way of getting blob data from a WKWebView using callAsyncJavaScript

func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        if let blobUrl = navigationAction.request.url, blobUrl.scheme == "blob" {
            let script = """
                async function createBlobFromUrl(url) {
                  const response = await fetch(url);
                  const blob = await response.blob();
                  return blob;
                }
            
                function blobToDataURLAsync(blob) {
                  return new Promise((resolve, reject) => {
                    const reader = new FileReader();
                    reader.onload = () => {
                      resolve(reader.result);
                    };
                    reader.onerror = reject;
                    reader.readAsDataURL(blob);
                  });
                }

                const url = await createBlobFromUrl(blobUrl)
                return await blobToDataURLAsync(url)
            """
            
            webView.callAsyncJavaScript(script,
                                        arguments: ["blobUrl": blobUrl.absoluteString],
                                        in: nil,
                                        in: WKContentWorld.defaultClient) { result in
                                                switch result {
                                                case .success(let dataUrl):
                                                    guard let url = URL(string: dataUrl as! String) else {
                                                        print("Failed to get data")
                                                        return
                                                    }
                                                    guard let data = try? Data(contentsOf: url) else {
                                                        print("Failed to decode data URL")
                                                        return
                                                    }
                                                    // Do anything with the data. It was a pdf on my case. 
                                                    //So I used UIDocumentInteractionController to show the pdf
                                                case .failure(let error):
                                                    print("Failed with: \(error)")
                                                }
                                        }
            decisionHandler(.cancel)
        } else {
            decisionHandler(.allow)
        }
    }
}
osrl
  • 8,168
  • 8
  • 36
  • 57