Hello forum!
I have a special problem with my iOS App.
My setup is the following:
- Swift 4 app with minimum target iOS 11
- the app consists more or less of single view with a WKWebView which is connected to the client portal of a health insurance
- the client portal provides a login (session cookies) and allows to download pdf documents when logged-in
I want the downloades pdf files to be opened in QuickLookPreviewController instead of WKWebView.
The customer need is that they want to be able to print or share their documents and I like to use the QuickLookPreview therefore, because it offers the best native feeling for this case.
Now to my problem:
I managed to download the documents to local storage (temp folder) and to open it in QuickLookPreviewController with a little trick:
The response of the download is intercepted (by using webView(..., decidePolicyFor ...)) and the download url is used to trigger a separate download (using URLSession.shared.dataTask(with: downloadUrl)) to iPhone storage, because QuickLookPreviewController needs a local file and cannot deal with the download url to the pdf.
In general this works - most of the time. Since I need to be in a logged-in state to download the pdf, I have to pass the authentication cookies from WKWebView (where I logged-in) to the shared cookie storage (which is used by the download task). This cookie sync between the two cookie storages is a known issue and I think I read all the related postings in the web, but couldn't get it working reliably.
In my debug logs I can see that my logic works in general, but sometimes the cookies aren't synchronized at all.
Here's my code. I appreciate every help 🙂
import UIKit
import WebKit
import QuickLook
class ViewController: UIViewController, WKUIDelegate, WKNavigationDelegate, QLPreviewControllerDataSource {
@IBOutlet var webView: WKWebView!
var documentPreviewController = QLPreviewController()
var documentUrl = URL(fileURLWithPath: "")
var webViewCookieStore: WKHTTPCookieStore!
let webViewConfiguration = WKWebViewConfiguration()
...
override func viewDidLoad() {
super.viewDidLoad()
// link the appDelegate to be able to receive the deviceToken
let appDelegate = UIApplication.shared.delegate as! AppDelegate
appDelegate.viewController = self
// initial configuration of custom JavaScripts
webViewConfiguration.userContentController = userContentController
// QuickLook document preview
documentPreviewController.dataSource = self
webView = WKWebView(frame: CGRect.zero, configuration: webViewConfiguration)
// see "The 2 delegates": https://samwize.com/2016/06/08/complete-guide-to-implementing-wkwebview/
webView.uiDelegate = self
webView.navigationDelegate = self
view.addSubview(webView)
webViewCookieStore = webView.configuration.websiteDataStore.httpCookieStore
...
load("https://clientportal.xyz"!)
}
private func load(_ url: URL) {
load(URLRequest(url:url))
}
private func load(_ req: URLRequest) {
var request = req
request.setValue(self.deviceToken, forHTTPHeaderField: "iosDeviceToken")
request.setValue(self.myVersion as? String, forHTTPHeaderField: "iosVersion")
request.setValue(self.myBuild as? String, forHTTPHeaderField: "iosBuild")
request.setValue(UIDevice.current.modelName, forHTTPHeaderField: "iosModelName")
debugPrintHeaderFields(of: request, withMessage: "Loading request")
webView.load(request)
debugPrint("Loaded request=\(request.url?.absoluteString ?? "n/a")")
}
/*
Intercept decision handling to be able to present documents in QuickLook preview
Needs to be intercepted here, because I need the suggestedFilename for download
*/
func webView(_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
let url = navigationResponse.response.url
if (openInDocumentPreview(url!)) {
let documentUrl = url?.appendingPathComponent(navigationResponse.response.suggestedFilename!)
loadAndDisplayDocumentFrom(url: documentUrl!)
decisionHandler(.cancel)
} else {
decisionHandler(.allow)
}
}
/*
Download the file from the given url and store it locally in the app's temp folder.
The stored file is then opened using QuickLook preview.
*/
private func loadAndDisplayDocumentFrom(url downloadUrl : URL) {
let localFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(downloadUrl.lastPathComponent)
// getAllCookies needs to be called in main thread??? (https://medium.com/appssemble/wkwebview-and-wkcookiestore-in-ios-11-5b423e0829f8)
//??? needed?? DispatchQueue.main.async {
self.webViewCookieStore.getAllCookies { (cookies) in
for cookie in cookies {
if cookie.domain.range(of: "my.domain.xyz") != nil {
HTTPCookieStorage.shared.setCookie(cookie)
debugPrint("Sync cookie [\(cookie.domain)] \(cookie.name)=\(cookie.value)")
} else {
debugPrint("Skip cookie [\(cookie.domain)] \(cookie.name)=\(cookie.value)")
}
}
debugPrint("FINISHED COOKIE SYNC")
debugPrint("Downloading document from url=\(downloadUrl.absoluteString)")
URLSession.shared.dataTask(with: downloadUrl) { data, response, err in
guard let data = data, err == nil else {
debugPrint("Error while downloading document from url=\(downloadUrl.absoluteString): \(err.debugDescription)")
return
}
if let httpResponse = response as? HTTPURLResponse {
debugPrint("Download http status=\(httpResponse.statusCode)")
}
// write the downloaded data to a temporary folder
do {
try data.write(to: localFileURL, options: .atomic) // atomic option overwrites it if needed
debugPrint("Stored document from url=\(downloadUrl.absoluteString) in folder=\(localFileURL.absoluteString)")
DispatchQueue.main.async {
self.documentUrl = localFileURL
self.documentPreviewController.refreshCurrentPreviewItem()
self.present(self.documentPreviewController, animated: true, completion: nil)
}
} catch {
debugPrint(error)
return
}
}.resume()
}
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
return documentUrl as QLPreviewItem
}
/*
We always have just one preview item
*/
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
return 1
}
/*
Checks if the given url points to a document provided by Vaadin FileDownloader and returns 'true' if yes
*/
private func openInDocumentPreview(_ url : URL) -> Bool {
return url.absoluteString.contains("/APP/connector")
}
P.S. I tried many solutions:
- Reading and syncing the cookies in main thread (there are many hints in the web, that you have to acces cookies in this way) - does not work reliably
- Reading the WKWebViews cookies via Javascript to avoid the main thread handling - but here I get too less information about the cookies (e.g. domain and path is missing)
- I configured a WKProcessPool - didn't help
Most of the time the cookies are synchronized, but with the same implementation I get problems when trying it later on, e.g. when I uploaded the presumably working code to TestFlight. It even differs when using a solution in Simulator or on a real device.
The error is a downloaded file (HTTP status 200), but its size is less than 8 kB, because I wasn't allowed to download it it because of the missing synchronized authentication cookie.
Hi again
With external help, I was able to fix my problem.
The solution ist now to avoid the separate download task with URLSession.shared.dataTask and hence avoid the sync problem between the WKWebView cookie storage and the shared cookie storage.
The download ist now performed with JavaScript.
I'm able to detect and intercept the request in func webView(_ webView: WKWebView, decidePolicyFor navigationAction ...).
When such a request is detected, I cancel the decision handling and call the JavaScript part with the download.
The JavaScript is executed in the WKWebView and hence I have all the authorization cookies in place and the download is successfull.
JavaScript then returns the downloaded document as binary blob which is taken over by a JavaScript message handler in Swift.
Then I just have to write the blob to a file and open the file in QuickLook preview. Works like a charm!
Here's the code in case somebody struggles with the same problem:
class ViewController: UIViewController, WKUIDelegate, WKNavigationDelegate, QLPreviewControllerDataSource, WKScriptMessageHandler {
...
@IBOutlet var webView: WKWebView!
var documentPreviewController = QLPreviewController()
var documentUrl = URL(fileURLWithPath: "")
var documentDownloadTask: URLSessionTask?
override func viewDidLoad() {
super.viewDidLoad()
// initial configuration of custom JavaScripts
webViewConfiguration.userContentController = userContentController
webViewConfiguration.websiteDataStore = WKWebsiteDataStore.default()
// init this view controller to receive JavaScript callbacks
userContentController.add(self, name: "openDocument")
userContentController.add(self, name: "jsError")
// QuickLook document preview
documentPreviewController.dataSource = self
webView = WKWebView(frame: CGRect.zero, configuration: webViewConfiguration)
...
}
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = navigationAction.request.url
if openInDocumentPreview(url!) {
decisionHandler(.cancel)
executeDocumentDownloadScript(forAbsoluteUrl: url!.absoluteString)
} else {
decisionHandler(.allow)
}
}
/*
Handler method for JavaScript calls.
Receive JavaScript message with downloaded document
*/
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
debugPrint("did receive message \(message.name)")
if (message.name == "openDocument") {
previewDocument(messageBody: message.body as! String)
} else if (message.name == "jsError") {
debugPrint(message.body as! String)
}
}
/*
Open downloaded document in QuickLook preview
*/
private func previewDocument(messageBody: String) {
// messageBody is in the format ;data:;base64,
// split on the first ";", to reveal the filename
let filenameSplits = messageBody.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false)
let filename = String(filenameSplits[0])
// split the remaining part on the first ",", to reveal the base64 data
let dataSplits = filenameSplits[1].split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false)
let data = Data(base64Encoded: String(dataSplits[1]))
if (data == nil) {
debugPrint("Could not construct data from base64")
return
}
// store the file on disk (.removingPercentEncoding removes possible URL encoded characters like "%20" for blank)
let localFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename.removingPercentEncoding ?? filename)
do {
try data!.write(to: localFileURL);
} catch {
debugPrint(error)
return
}
// and display it in QL
DispatchQueue.main.async {
self.documentUrl = localFileURL
self.documentPreviewController.refreshCurrentPreviewItem()
self.present(self.documentPreviewController, animated: true, completion: nil)
}
}
/*
Implementation for QLPreviewControllerDataSource
*/
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
return documentUrl as QLPreviewItem
}
/*
Implementation for QLPreviewControllerDataSource
We always have just one preview item
*/
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
return 1
}
/*
Checks if the given url points to a document download url
*/
private func openInDocumentPreview(_ url : URL) -> Bool {
// this is specific for our application - can be everything in your application
return url.absoluteString.contains("/APP/connector")
}
/*
Intercept the download of documents in webView, trigger the download in JavaScript and pass the binary file to JavaScript handler in Swift code
*/
private func executeDocumentDownloadScript(forAbsoluteUrl absoluteUrl : String) {
// TODO: Add more supported mime-types for missing content-disposition headers
webView.evaluateJavaScript("""
(async function download() {
const url = '\(absoluteUrl)';
try {
// we use a second try block here to have more detailed error information
// because of the nature of JS the outer try-catch doesn't know anything where the error happended
let res;
try {
res = await fetch(url, {
credentials: 'include'
});
} catch (err) {
window.webkit.messageHandlers.jsError.postMessage(`fetch threw, error: ${err}, url: ${url}`);
return;
}
if (!res.ok) {
window.webkit.messageHandlers.jsError.postMessage(`Response status was not ok, status: ${res.status}, url: ${url}`);
return;
}
const contentDisp = res.headers.get('content-disposition');
if (contentDisp) {
const match = contentDisp.match(/(^;|)\\s*filename=\\s*(\"([^\"]*)\"|([^;\\s]*))\\s*(;|$)/i);
if (match) {
filename = match[3] || match[4];
} else {
// TODO: we could here guess the filename from the mime-type (e.g. unnamed.pdf for pdfs, or unnamed.tiff for tiffs)
window.webkit.messageHandlers.jsError.postMessage(`content-disposition header could not be matched against regex, content-disposition: ${contentDisp} url: ${url}`);
}
} else {
window.webkit.messageHandlers.jsError.postMessage(`content-disposition header missing, url: ${url}`);
return;
}
if (!filename) {
const contentType = res.headers.get('content-type');
if (contentType) {
if (contentType.indexOf('application/json') === 0) {
filename = 'unnamed.pdf';
} else if (contentType.indexOf('image/tiff') === 0) {
filename = 'unnamed.tiff';
}
}
}
if (!filename) {
window.webkit.messageHandlers.jsError.postMessage(`Could not determine filename from content-disposition nor content-type, content-dispositon: ${contentDispositon}, content-type: ${contentType}, url: ${url}`);
}
let data;
try {
data = await res.blob();
} catch (err) {
window.webkit.messageHandlers.jsError.postMessage(`res.blob() threw, error: ${err}, url: ${url}`);
return;
}
const fr = new FileReader();
fr.onload = () => {
window.webkit.messageHandlers.openDocument.postMessage(`${filename};${fr.result}`)
};
fr.addEventListener('error', (err) => {
window.webkit.messageHandlers.jsError.postMessage(`FileReader threw, error: ${err}`)
})
fr.readAsDataURL(data);
} catch (err) {
// TODO: better log the error, currently only TypeError: Type error
window.webkit.messageHandlers.jsError.postMessage(`JSError while downloading document, url: ${url}, err: ${err}`)
}
})();
// null is needed here as this eval returns the last statement and we can't return a promise
null;
""") { (result, err) in
if (err != nil) {
debugPrint("JS ERR: \(String(describing: err))")
}
}
}
}