Search code examples
swiftcommand-linewkwebview

How to use WKWebView in a command line app


I'm trying to write a command line tool that takes a screenshot of a given webpage using WKWebView. The problem is that WKNavigationDelegate methods aren't being called. This is what I have:

import WebKit

class Main: NSObject {
    let webView: WKWebView = WKWebView()
    func load(request: URLRequest) {
        webView.navigationDelegate = self
        webView.load(request)
    }
}

extension Main: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        print("Did start")
    }
    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
        print("Did commit")
    }
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        print("Did finish")
    }
    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
        print("Did fail")
    }
}

let main: Main = Main()
let input: String = CommandLine.arguments[1]
if let url: URL = URL(string: input) {
    let request: URLRequest = URLRequest(url: url)
    main.load(request: request)
} else {
    print("Invalid URL")
}

Almost all examples I've found involve using WKWebView in a view controller. My guess is that in the command line, the app exits before the webpage finishes loading, but I'm not sure how to prevent that from happening.

I did find this example of a command line tool using WKWebView. The author uses RunLoop.main.run(), which to my understanding effectively simulates the event loop of a UI app? That allows the webpage to load, but I'm looking for a different solution because I want the app to behave like a normal command line tool and exit on its own after running. For example, is there some way to use async/await with WKWebView.load() much like with URLSession?


Solution

  • I ended up solving this problem using continuation. In short, I wrap webView.load() in a continuation and then call continuation.resume() in one of the WKNavigationDelegate methods. That allows me to treat webView.load() as an async task; the continuation determines its runtime. I took this solution entirely from the example in this blog post.

    Here's a barebones implementation of this solution:

    import WebKit
    
    @MainActor
    class WebContainer: NSObject {
        
        lazy var webView: WKWebView = {
            let webView: WKWebView = WKWebView()
            webView.navigationDelegate = self
            return webView
        }()
            
        var continuation: UnsafeContinuation<Void, Error>?
        
        func load(request: URLRequest) async throws -> Int {
            try await withUnsafeThrowingContinuation { continuation in
                self.continuation = continuation
                webView.load(request)
            }
        }
    }
    
    extension WebContainer: WKNavigationDelegate {
    
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            continuation?.resume(returning: ())
        }
        
        func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
            continuation?.resume(throwing: error)
        }
    
        func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
            continuation?.resume(throwing: error)
        }
    }
    

    To load a website using the above implementation, simply run:

    let webContainer: WebContainer = WebContainer()
    let request: URLRequest = // insert URLRequest here
    try await webContainer.load(request: request)
    

    Practically speaking this implementation doesn't handle redirects reliably as some redirects are initiated after didFinish is called. I have a partial solution to that which however runs into other problems. Since all this is out of scope for this question, if anyone's interested please refer to this other question.