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
}
}
}
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
}
}