JavaScript synchronous native communication to WKWebView - swift

Is synchronous communication between JavaScript and Swift/Obj-C native code possible using the WKWebView?
These are the approaches I have tried and have failed.
Approach 1: Using script handlers
WKWebView's new way of receiving JS messages is by using the delegate method userContentController:didReceiveScriptMessage: which is invoked from JS by window.webkit.messageHandlers.myMsgHandler.postMessage('What's the meaning of life, native code?')
The problem with this approach is that during execution of the native delegate method, JS execution is not blocked, so we can't return a value by immediately invoking webView.evaluateJavaScript("something = 42", completionHandler: nil).
Example (JavaScript)
var something;
function getSomething() {
window.webkit.messageHandlers.myMsgHandler.postMessage("What's the meaning of life, native code?"); // Execution NOT blocking here :(
return something;
}
getSomething(); // Returns undefined
Example (Swift)
func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
webView.evaluateJavaScript("something = 42", completionHandler: nil)
}
Approach 2: Using a custom URL scheme
In JS, redirecting using window.location = "js://webView?hello=world" invokes the native WKNavigationDelegate methods, where the URL query parameters can be extracted. However, unlike the UIWebView, the delegate method is not blocking the JS execution, so immediately invoking evaluateJavaScript to pass a value back to the JS doesn't work here either.
Example (JavaScript)
var something;
function getSomething() {
window.location = "js://webView?question=meaning" // Execution NOT blocking here either :(
return something;
}
getSomething(); // Returns undefined
Example (Swift)
func webView(webView: WKWebView, decidePolicyForNavigationAction navigationAction: WKNavigationAction, decisionHandler decisionHandler: (WKNavigationActionPolicy) -> Void) {
webView.evaluateJavaScript("something = 42", completionHandler: nil)
decisionHandler(WKNavigationActionPolicy.Allow)
}
Approach 3: Using a custom URL scheme and an IFRAME
This approach only differs in the way that window.location is assigned. Instead of assigning it directly, the src attribute of an empty iframe is used.
Example (JavaScript)
var something;
function getSomething() {
var iframe = document.createElement("IFRAME");
iframe.setAttribute("src", "js://webView?hello=world");
document.documentElement.appendChild(iframe); // Execution NOT blocking here either :(
iframe.parentNode.removeChild(iframe);
iframe = null;
return something;
}
getSomething();
This nonetheless, is not a solution either, it invokes the same native method as Approach 2, which is not synchronous.
Appendix: How to achieve this with the old UIWebView
Example (JavaScript)
var something;
function getSomething() {
// window.location = "js://webView?question=meaning" // Execution is NOT blocking if you use this.
// Execution IS BLOCKING if you use this.
var iframe = document.createElement("IFRAME");
iframe.setAttribute("src", "js://webView?question=meaning");
document.documentElement.appendChild(iframe);
iframe.parentNode.removeChild(iframe);
iframe = null;
return something;
}
getSomething(); // Returns 42
Example (Swift)
func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
webView.stringByEvaluatingJavaScriptFromString("something = 42")
}

No I don't believe it is possible due to the multi-process architecture of WKWebView. WKWebView runs in the same process as your application but it communicates with WebKit which runs in its own process (Introducing the Modern WebKit API). The JavaScript code will be running in the WebKit process. So essentially you are asking to have synchronous communication between two different processes which goes against their design.

I found a hack for doing synchronous communication but haven't tried it yet: https://stackoverflow.com/a/49474323/2870783
Edit: Basically you can use the the JS prompt() to carry your payload from the js side to the native side. In the native WKWebView will have to intercept the prompt call and decide if it is a normal call or if it is a jsbridge call. Then you can return your result as a callback to the prompt call. Because the prompt call is implemented in such a way that it waits for user input your javascript-native communication will be synchronous. The downside is that you can only communicate trough strings.

I also investigated this issue, and failed as you. To workaround, you have to pass a JavaScript function as a callback. Native function needs to evaluate the callback function to return result. Actually, this is the JavaScript way, because JavaScript never wait. Blocking JavaScript thread may cause ANR, it's very bad.
I have created a project named XWebView which can establish a bridge between native and JavaScript. It offers binding styled API for calling native from JavaScript and vice versa. There is a sample app.

It's possible to synchronously wait for the result of evaluateJavaScript by polling the current RunLoop's acceptInput method. What this does is allow your UI thread to respond to input while you wait for the the Javascript to finish.
Please read warnings before you blindly paste this into your code
//
// WKWebView.swift
//
// Created by Andrew Rondeau on 7/18/21.
//
import Cocoa
import WebKit
extension WKWebView {
func evaluateJavaScript(_ javaScriptString: String) throws -> Any? {
var result: Any? = nil
var error: Error? = nil
var waiting = true
self.evaluateJavaScript(javaScriptString) { (r, e) in
result = r
error = e
waiting = false
}
while waiting {
RunLoop.current.acceptInput(forMode: RunLoop.Mode.default, before: Date.distantFuture)
}
if let error = error {
throw error
}
return result
}
}
What happens is that, while the Javascript is executing, the thread calls RunLoop.current.acceptInput until waiting is false. This allows your application's UI to be responsive.
Some warnings:
Buttons, ect, on your UI will still respond. If you don't want someone to push a button while your Javascript is running, you should probably disable interacting with your UI. (This is especially the case if you're calling out to another server in Javascript.)
The multi-process nature of calling evaluateJavaScript may be slower than you expect. If you're calling code that is "instant," things may still slow down if you make repeated calls into Javascript in a tight loop.
I've only tested this on the Main UI thread. I don't know how this code will work on a background thread. If there are problems, investigate using a NSCondition.
I've only tested this on macOS. I do not know if this works on iOS.

I was facing a similar issue, i resolved it by storing promise callbacks.
The js that you load in your web view via WKUserContentController::addUserScript
var webClient = {
id: 1,
handlers: {},
};
webClient.onMessageReceive = (handle, error, data) => {
if (error && webClient.handlers[handle].reject) {
webClient.handlers[handle].reject(data);
} else if (webClient.handlers[handle].resolve){
webClient.handlers[handle].resolve(data);
}
delete webClient.handlers[handle];
};
webClient.sendMessage = (data) => {
return new Promise((resolve, reject) => {
const handle = 'm' + webClient.id++;
webClient.handlers[handle] = { resolve, reject };
window.webkit.messageHandlers.<message_handler_name>.postMessage({data: data, id: handle});
});
}
Perform Js Request like
webClient.sendMessage(<request_data>).then((response) => {
...
}).catch((reason) => {
...
});
Receive request in userContentController :didReceiveScriptMessage
Call evaluateJavaScript with webClient.onMessageReceive(handle, error, response_data).

Related

Web Text Grabber in Swift

I have been trying to get the text content of any web page via:
func getTextContentFromUrl (url: URL) -> String? {
var content = ""
do {
content = try String(contentsOf: url)
} catch {
return nil
}
return content
}
It works fine if the web page contains texts inside html/body tags, but not if the Web Page contains only javascript, e.g.: https://twitter.com/search?q=tesla&src=typed_query
I know about Swifter, but I cannot program potentially hundreds of API to access any WEB site: twitter, facebook, linkedin, quora, amazon etc. Obviously, WKWebView views know how to display and print their text, therefore I tried to get the text content from WKWebView:
(1) Unfortunately, the following method always returns "" even though I call it from webView(_ webView: WKWebView, didFinish navigation: WKNavigation!):
func getTextContentFromWebView () -> String {
var content = ""
myWKWebView.evaluateJavaScript("document.documentElement") { (string, error) in
if string != nil {
content = string as! String
}
}
return content
}
I tried variants of this code published on the WEB, such as "document.body.textContent", "document.body.innerText", "document.body.outerHTML", "document.body.innerHTML", but this method always returns ""...
(2) I have also tried to use the clipboard to get the text content (myWKWebView.SelectAll(), myWWKWebView.copy()), but myWKWebView.copy() always sends an exception (even though this method is supposed to work for any NSView, as Apple's documentation states):
2020-03-13 15:21:26.251341+0100 Text Miner[7313:603242] -[WKWebView copyWithZone:]: unrecognized selector sent to instance 0x101b815c0
If anyone can manually copy & paste and print the textual content of any web page via any web browser regardless of its content (html/javascript), there should be a generic easy and documented way to grab text from WKWebView, shouldn't be?
I figured out that:
my mistake in the first problem was that myWKWebView.evaluateJavaScript is an asynchronous function, i.e. it returns right away with content="" (without any time to set this variable). The solution is to process the content of the variable "content" inside its body inside the method.
WKwebViews do accept a copy() method but do not implement it: it is up to developers to implement it. I read somewhere that it is done it via an interface javascript-swift...
Anyways, first solution works for me.

How to guarantee one function runs after another in the Grand Central Dispatch?

Basically, I want to upload some images before I run other functions that rely on the image being uploaded. I think I may have a misunderstanding of what GCD is/how the threads work. I want function 1 and 2 to happen AFTER I upload the images. They are both quick to execute but rely heavily on the upload images to be complete. Maybe I shouldn't use GCD (as I want to implement a waiting indicator)? I just can't seem to get this to execute properly
if goToHome {
DispatchQueue.global().async {
DispatchQueue.main.sync {
self.uploadImages() // Uploads the images, takes a good amount of time to execute
function1()
function2()
}
}
Functions 1 and 2 keep running before the upload images get completed as they take much less time to execute.
The basic pattern in Swift is to do work such as uploading on a background thread, then call a completion function on the main thread and continue work based on whether or not your upload finished successfully.
Generally you'll call back onto the main thread in case you need to do something with the user interface such as setting a progress indicator (which has to happen on the main thread).
So something like this:
func uploadInBackground(_ images: [Image], completion: #escaping (_ success: Bool) -> Void) {
DispatchQueue.global(qos: .background).async {
var success = true
// upload the images but stop on any error
for image in images {
success = uploadImage(image) // upload your images somehow
guard success else { break }
}
DispatchQueue.main.async {
completion(success)
}
}
}
func mainThreadUploader() {
let images = [Image(), Image()] // get your images from somewhere
// we are on the main thread where UI operations are ok, so:
startProgressIndicator()
uploadInBackground(images) { success in
// this callback is called on the main thread, so:
stopProgressIndicator()
guard success else { return }
// images uploaded ok, so proceed with functions that depend on
// the upload(s) completing successfully:
function1()
function2()
}
}
Despite running the upload image function in the main queue, the upload image function itself is running operations in a background queue. To fix this, possible strategies would be:
use a completion handler with the image upload, likely the easiest to implement depending on the implementation of the self.uploadImages() function
make the image uploading happen on the main thread, which is likely hard to implement and inadvisable
use a dispatch group, which I personally have less experience with but is an option

safari app extensions: broadcast a message to all tabs from swift background process

In a legacy extension it was possible to iterate over safari.application.activeBrowserWindow.tabs to send a message to all tabs registered with the extension.
Is there any equivalent available with the new safari app extensions?
I've been trough the docs but did not find any hints on how to achieve this very basic thing.
A horrible workaround would be to have all tabs ping the Swift background, but really this is such a basic thing it seems absurd that it is not available or covered by the docs, am I missing something?
I also tried keeping a weak map of all "page" instances as seen by "messageReceived" handler in the hope the SFSafariPage reference would be kept until a tab is closed but they are instead lost almost immediately, suggesting they are more message channels than actual Safari pages.
The way should be next:
in injected.js you send the message to your app-ext, e.g.
document.addEventListener("DOMContentLoaded", function (event) {
safari.extension.dispatchMessage('REGISTER_PAGE')
})
And in app-ext handle it with smth like this:
var pages: [SFSafariPage] = []
class SafariExtensionHandler: SFSafariExtensionHandler {
override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String : Any]?) {
switch messageName {
case "REGISTER_PAGE":
if !pages.contains(page) {
pages.append(page)
}
default:
return
}
}
}
Well, then you can send the message to all opened pages during runtime by smth like this:
for p in pages {
p.dispatchMessageToScript(withName: "message name", userInfo: userInfo)
}
It looks hacky but yet workable. Enjoy :)

Ionic Worklight Page not displaying after HTTP request

I'm having an issue displaying the content in the page after the Worklight http request has been executed.
The weird thing is that when I go to another page and I come back, the content gets displayed. It's like if it needs to be refreshed or something. I can see the console.log() data was received, but page was not refreshed.
This is my code:
$stateProvider.state('accounts', {
url: "/accounts",
templateUrl: 'views/accounts.html',
controller: function($scope, $ionicScrollDelegate, $rootScope){
var req = new WLResourceRequest("/adapters/JavaMQ/bankmq/getAccounts/"+$rootScope.globalReqUserId, WLResourceRequest.GET);
req.send().then(function(resp){
var x2js = new X2JS();
resp.responseText = x2js.xml_str2json(resp.responseText); //to JSON
$scope.reqUserId = resp.responseText['ASI_Message']['Riyad_Bank_Header']['Requestor_User_ID'];
$scope.accountsList = resp.responseText['ASI_Message']['Repeating_Group_Section']['Repeating_Group'];
console.log($rootScope);
})
}
});
UPDATE:
I noticed that I also keep getting the following when I moved the project to Windows (Never happened in my mac)
Deviceready has not fired after 5 seconds
Channel not fired: onCordovaInfoReady
Channel not fired: onCordovaConnectionReady
I don't really know Worklight but the documentation indicate that the send().then() handles both the onSuccess and onFailure.
Maybe the then() is expecting 2 parameters like this:
var request = WLResourceRequest(url, method, timeout);
request.send(content).then(
function(response) {
// success flow
},
function(error) {
// fail flow
}
);
If that doesn't work, can you put a breakpoint at the start of var x2js = new X2JS(); and tell us what happens?

Triggering shouldStartLoadWithRequest with multiple window.location.href calls

Im trying to pass multiple things from a webpage inside a UIWebView back to my iPhone app via the shouldStartLoadWithRequest method of the UIWebView.
Basically my webpage calls window.location.href = "command://foo=bar" and i am able to intercept that in my app no problem. Now if i create a loop and do multiple window.location.href calls at once, then shouldStartLoadWithRequest only appears to get called on once and the call it gets is the very last firing of window.location.href at the end of the loop.
The same thing happens with the webview for Android, only the last window.location.href gets processed.
iFrame = document.createElement("IFRAME");
iFrame.setAttribute("src", "command://foo=bar");
document.body.appendChild(iFrame);
iFrame.parentNode.removeChild(iFrame);
iFrame = null;
So this creates an iframe, sets its source to a command im trying to pass to the app, then as soon as its appended to the body shouldStartLoadWithRequest gets called, then we remove the iframe from the body, and set it to null to free up the memory.
I also tested this on an Android webview using shouldOverrideUrlLoading and it also worked properly!
I struck this problem also and here is my solution that works for me.
All my JavaScript functions use this function __js2oc(msg) to pass data
and events to Objective-C via shouldStartLoadWithRequest:
P.S. replace "command:" with your "appname:" trigger you use.
/* iPhone JS2Objective-C bridge interface */
var __js2oc_wait = 300; // min delay between calls in milliseconds
var __prev_t = 0;
function __js2oc(m) {
// It's a VERY NARROW Bridge so traffic must be throttled
var __now = new Date();
var __curr_t = __now.getTime();
var __diff_t = __curr_t - __prev_t;
if (__diff_t > __js2oc_wait) {
__prev_t = __curr_t;
window.location.href = "command:" + m;
} else {
__prev_t = __curr_t + __js2oc_wait - __diff_t;
setTimeout( function() {
window.location.href = "command:" + m;
}, (__js2oc_wait - __diff_t));
}
}
No, iframe's url changing won't trigger shouldOverrideUrlLoading, at least no in Android 2.2.