Returning a Promise from WebView's evaluateJavascript?

I’m trying to run JavaScript in a WebView to pull some data out of a single-page app and I’m running into an issue with asynchronous code, stuff like MutationObserver and anything that returns a Promise: is there any way to return an asynchronous result from evaluateJavaScript?

In Shortcuts, it’s possible to do this because it requires you to call a completion handler with the result and the shortcut waits until that handler gets called. Does Scriptable have anything similar?

Scriptable doesn’t currently have anything similar. Evaluating JavaScript in Scriptables WebView uses WKWebViews support for evaluating JavaScript under the hood.

However, it’s an interesting use case that I’d like to look into for a future update.

1 Like

I ran into the same problem this weekend: I tried to offload timer functions, aka setTimeout and setInterval to an unrendered web view (I may or may not have been trying to find a solution to this :sunglasses:), but it fails whenever I return the Promise I wrapped them in to the main Scriptable script.

It would be really useful to be able to handle async scripting in Scriptable’s WKWebView wrapper; also, an implementation of the messaging mechanisms between WKWebView JS and its caller would be welcome (it is doable, although I have absolutely no idea how easily: JSBoxWeb API, another WKWebView wrapper for JS, includes two way messaging).

The latest beta build supports running asynchronous operations in the web view and pass back a result. For example, you can do the following to offload setTimeout to the web view.

let webView = new WebView()
let js = `
setTimeout(function() {
  completion(null)
}, 3000)
`
log("Waiting three seconds...")
let result = await webView.evaluateJavaScript(js, true)
log("Time's up!")

It’s in the latest beta build and will be available on the App Store soon.

1 Like

@simonbs yeah, I’ve noticed that in the release notes, great news. You are doing awesome work on Scriptable.

With the newest beta’s support for asynchronous operations in WebViews, the solution I cobbled together for setTimeOut functionality actually works. The following class:

class Timer {
  constructor () {
    this.view = new WebView()
    let html = '<script>function wait (ms) { setTimeout(completion, ms) }</script>'
    this.ready = this.view.loadHTML(html)
  }
  
  async add (delay) {
    let target = Date.now() + delay
    await this.ready
    let remain = target - Date.now()
    return remain > 0 ? this.view.evaluateJavaScript(`wait(${remain})`, true) : Promise.resolve()
  }
}

will give you a Timer object that allows you to generate timer promises. For instance, doing this:

const timer = new Timer

const first = timer.add(2500)
first.then(() => console.log('First countdown ended'))
  
const second = timer.add(5000)
second.then(() => console.log('Second countdown ended'))

await first
await second

will log after, respectively, 2.5 and 5 seconds. The advantage of this construct over Simon’s example is that with only one WebView, you can set several timers that will run in parallel – without the function embedded in the HTML, you need one WebView per, which is expensive when it comes to memory (setting multiple timers without the embedded function will simply block the script). There is a downside, which is that the HTML has to load before the first timer will run, hence the await and remain shenanigans in the add function; on my iPad Air 2, loading HTML into a WebView the first time takes 1 to 1.5 seconds, so it’s not the right solution if all you need is one short timeout below that threshold. EDIT: I found that if I load only the bare <script> tag instead of a whole HTML document, loading delay is around 200 ms (presumably because that way loading does not trigger DOM construction?), so there is no real downside unless you absolutely need a shorter timeout than that.

I recommend making a module out of the class rather than copy-pasting the code, i.e. storing it in its own file and loading it with my require shim EDIT for Scriptable 1.3: Scriptable’s loadModule functionality.

5 Likes

Hi!
Thanks for the implementation! I have a problem when running application in Safari, for example Safari.open() then waiting to call Safari.open() with other application. It seems like the control is lost and timer is off, I must use the 1st as Safari.openInApp but then the call is not going to the “background”. Is there any option to keep the script running while other application is in front?
Thanks!

Sorry to hear that. AFAIK Scriptable 1.4 brings its own Timer module (I’m not on iOS 13 yet, as I have to wait for iPadOS, but I think I’ve seen it mentioned in the beta release notes), so I would not invest time into my implementation until you have checked that out …

@simonbs
When I copy & paste the script, Log screen says “Error: The web view is already evaluating JavaScript. Please wait until the current operation have completed.”
So currently this script is not valid?

If you add one of these two lines before the evaluateJavaScript() it should work

await webView.loadURL("about:blank")

or

await webView.loadHTML("")

Thank you for your comment!
But it seems still not working…

You added both lines, not just one of them as was suggested by @schl3ck.

With just one…

Thank you!
It’s working now.
I should be more careful…

That is why I ended using a <script> HTML fragment instead of the bare js function above: the web view needs to load something …

1 Like