I am trying to read the console logs of a webapp that is loaded in my WKWebView programmatically.
So far in my research it's not possible.
How can I achieve this?
I am trying to read the console logs of a webapp that is loaded in my WKWebView programmatically.
So far in my research it's not possible.
How can I achieve this?
It's possible to connect Safari browser on your Mac to the WKWebView and get access to the console.
From Safari, open "Develop" tab and while the iOS Simulator is running with the WKWebView open - just click it to open the console. See:
This worked for me (Swift 4.2/5):
// inject JS to capture console.log output and send to iOS
let source = "function captureLog(msg) { window.webkit.messageHandlers.logHandler.postMessage(msg); } window.console.log = captureLog;"
let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
webView.configuration.userContentController.addUserScript(script)
// register the bridge script that listens for the output
webView.configuration.userContentController.add(self, name: "logHandler")
Then, conforming to the protocol WKScriptMessageHandler, pick up redirected console messages with the following:
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "logHandler" {
print("LOG: \(message.body)")
}
}
I needed a way to see JavaScript logs in Xcode's console. Based on the answer by noxo, here's what I came up with:
let overrideConsole = """
function log(emoji, type, args) {
window.webkit.messageHandlers.logging.postMessage(
`${emoji} JS ${type}: ${Object.values(args)
.map(v => typeof(v) === "undefined" ? "undefined" : typeof(v) === "object" ? JSON.stringify(v) : v.toString())
.map(v => v.substring(0, 3000)) // Limit msg to 3000 chars
.join(", ")}`
)
}
let originalLog = console.log
let originalWarn = console.warn
let originalError = console.error
let originalDebug = console.debug
console.log = function() { log("", "log", arguments); originalLog.apply(null, arguments) }
console.warn = function() { log("", "warning", arguments); originalWarn.apply(null, arguments) }
console.error = function() { log("", "error", arguments); originalError.apply(null, arguments) }
console.debug = function() { log("", "debug", arguments); originalDebug.apply(null, arguments) }
window.addEventListener("error", function(e) {
log("", "Uncaught", [`${e.message} at ${e.filename}:${e.lineno}:${e.colno}`])
})
"""
class LoggingMessageHandler: NSObject, WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print(message.body)
}
}
let userContentController = WKUserContentController()
userContentController.add(LoggingMessageHandler(), name: "logging")
userContentController.addUserScript(WKUserScript(source: overrideConsole, injectionTime: .atDocumentStart, forMainFrameOnly: true))
let webViewConfig = WKWebViewConfiguration()
webViewConfig.userContentController = userContentController
let webView = WKWebView(frame: .zero, configuration: webViewConfig)
It has a few improvements:
log
, warn
, error
and debug
console.log
, not just the first oneYou can re-evaluate (override) Javascript console.log() default implementation to use window.webkit.messageHandlers.postMessage(msg) to pass message forwards instead. And then intercept the javascript postMessage(msg) call at native code using WKScriptMessageHandler ::didReceiveScriptMessage to get the logged message.
Step 1) Re-evaluate console.log default implementation to use postMessage()
// javascript to override console.log to use messageHandlers.postmessage
NSString * js = @"var console = { log: function(msg){window.webkit.messageHandlers.logging.postMessage(msg) } };";
// evaluate js to wkwebview
[self.webView evaluateJavaScript:js completionHandler:^(id _Nullable ignored, NSError * _Nullable error) {
if (error != nil)
NSLog(@"installation of console.log() failed: %@", error);
}];
Step 2) Intercept javascript postMessage in native code at WKScriptMessageHandler::didReceiveScriptMessage
- (void)viewDidLoad
{
// create message handler named "logging"
WKUserContentController *ucc = [[WKUserContentController alloc] init];
[ucc addScriptMessageHandler:self name:@"logging"];
// assign usercontentcontroller to configuration
WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
[configuration setUserContentController:ucc];
// assign configuration to wkwebview
self.webView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height) configuration:configuration];
}
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
// what ever were logged with console.log() in wkwebview arrives here in message.body property
NSLog(@"log: %@", message.body);
}
Here's a tweak of Richard's answer (well, several answers use the same method) that handles console.log
's string-substitution.
I needed this because React Errors are logged with strings like Hey you had an error:%s
, and of course I needed to see what %s
was. I also used .documentStart
since I want to catch errors right away.
Adding the WKUserScript
to the WKUserContentController
(which is part of the WKWebViewConfiguration
you init the WKWebView
with):
let source = """
function sprintf(str, ...args) { return args.reduce((_str, val) => _str.replace(/%s|%v|%d|%f|%d/, val), str); }
function captureLog(str, ...args) { var msg = sprintf(str, ...args); window.webkit.messageHandlers.logHandler.postMessage({ msg, level: 'log' }); }
function captureWarn(str, ...args) { var msg = sprintf(str, ...args); window.webkit.messageHandlers.logHandler.postMessage({ msg, level: 'warn' }); }
function captureError(str, ...args) { var msg = sprintf(str, ...args); window.webkit.messageHandlers.logHandler.postMessage({ msg, level: 'error' }); }
window.console.error = captureError; window.console.warn = captureWarn;
window.console.log = captureLog; window.console.debug = captureLog; window.console.info = captureLog;
"""
let script = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false)
let config = WKWebViewConfiguration.init()
let userContentController = WKUserContentController()
config.userContentController = userContentController
userContentController.addUserScript(script)
// ... potentially somewhere else ...
let webView = WKWebView(frame: CGRect.zero, configuration: config)
... and handling it in whatever class (hint: must be an NSObject
) that you implement WKScriptMessageHandler
in:
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard
let body = message.body as? [String:Any],
let msg = body["msg"] as? String,
let level = body["level"] as? String
else {
assert(false)
return
}
NSLog("WEB \(level): \(msg)")
}
Replace NSLog
with whatever you actually use (ie. your custom Swift log handler function).
Some of the otherwise great solutions on this page may mysteriously crash your page. I have found this is because some objects (e.g. events) cause JSON.stringify to throw an exeception (for example see How to stringify event object?)
For simplicity I catch the exception and move on. While I am at it, I encapsulate the logic into a class so that the usage with your userContentController object becomes a one-liner:
WebLogHandler().register(with: userContentController)
The source for the implementation follows. My injected script is simpler than some here, for my purposes I didn't actually want to hear from warn / error etc so I could focus on log, and I didn't want to see any emoji, but otherwise this is similar to Soeholm's answer. It's an exercise to make the class more configurable with those options or to finesse the handling of the problematic objects so as to stringify them at least in part.
class WebLogHandler: NSObject, WKScriptMessageHandler {
let messageName = "logHandler"
lazy var scriptSource:String = {
return """
function stringifySafe(obj) {
try {
return JSON.stringify(obj)
}
catch (err) {
return "Stringify error"
}
}
function log(type, args) {
window.webkit.messageHandlers.\(messageName).postMessage(
`JS ${type}: ${Object.values(args)
.map(v => typeof(v) === "undefined" ? "undefined" : typeof(v) === "object" ? stringifySafe(v) : v.toString())
.map(v => v.substring(0, 3000)) // Limit msg to 3000 chars
.join(", ")}`
)
}
let originalLog = console.log
console.log = function() { log("log", arguments); originalLog.apply(null, arguments) }
"""
}()
func register(with userContentController: WKUserContentController) {
userContentController.add(self, name: messageName)
// inject JS to capture console.log output and send to iOS
let script = WKUserScript(source: scriptSource,
injectionTime: .atDocumentStart,
forMainFrameOnly: false)
userContentController.addUserScript(script)
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print(message.body)
}
}
Normally the console logging is defined in js as
"window.addEventListener("message",function(e){console.log(e.data)});"
My answer was adapted from handling-javascript-events-in-wkwebview!
Initialize WKWebView with configuration
let config = WKWebViewConfiguration()
let source = "document.addEventListener('message', function(e){
window.webkit.messageHandlers.iosListener.postMessage(e.data); })"
let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
config.userContentController.addUserScript(script)
config.userContentController.add(self, name: "iosListener")
webView = WKWebView(frame: UIScreen.main.bounds, configuration: config)
Or use KVO observe property "estimatedProgress" and inject js by evalueate JavaScript
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if ([keyPath isEqualToString:@"estimatedProgress"])
{
CGFloat progress = [change[NSKeyValueChangeNewKey] floatValue];
if (progress>= 0.9)
{
NSString *jsCmd = @"window.addEventListener(\"message\",function(e){window.webkit.messageHandlers.iosListener.postMessage(e.data)});";
//@"document.addEventListener('click', function(e){ window.webkit.messageHandlers.iosListener.postMessage('Customize click'); })";
[_webView evaluateJavaScript:jsCmd completionHandler:^(id _Nullable obj, NSError * _Nullable error) {
NSLog(@"error:%@",error);
}];
}
}
}
implement the WKScriptMessageHandler protocol to receive the message:
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
{
print("message: \(message.body)")
// and whatever other actions you want to take
}
Swift 4.2 & 5
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript("your javascript string") { (value, error) in
if let errorMessage = (error! as NSError).userInfo["WKJavaScriptExceptionMessage"] as? String {
print(errorMessage)
}
}
}
Please use this beautiful in-app "Bridge"
Edit:
self.webView = [[WBWKWebView alloc] initWithFrame:self.view.bounds];
self.webView.JSBridge.interfaceName = @"WKWebViewBridge";
WBWebDebugConsoleViewController * controller = [[WBWebDebugConsoleViewController alloc] initWithConsole:_webView.console];
Then , you can use the delegate method:
- (void)webDebugInspectCurrentSelectedElement:(id)sender
{
// To use the referenced log values
}