Possible to call functions defined in Scriptable in WebView?

I’m interested in using WebView to create more complex UIs than what’s possible with UITable. One basic example that would be really nice in UITable is the ability to control font size. And then of course more powerful features like animations and more control over styling.

I’ve been playing around with WebView and it seems like you can only execute JS as a string, which means you can’t access any other functions defined in Scriptable. So for example, it would be cool if this would work:

const getReminders = async () => Reminder.allIncomplete();

const wv = new WebView();
await wv.loadHTML(`
<meta name="viewport" content="width=device-width",initial-scale=1" />
<style>
  body { font-family: -apple-system; }
</style>
<a href="#" id="cta">Click me!</a>
<div id="results"></div>
`);
await wv.evaluateJavaScript(`
  document.getElementById("cta").setAttribute("onClick", async () => {
    const reminders = await getReminders();
    document.getElementById("results").innerHTML = reminders
      .map(({title}) => "<p>" + title + "<p>")
      .join("<br />");
  });
`);
await wv.present();

I have here something, which is not quite the same what you asked, but it is the only workaround I know of.

There is a way to send data to the Scriptable script from the WebView:

Add a function to WebView.shouldAllowRequest that blocks all requests with a URL that starts with scriptable:// for example or something else. Now you can just use window.location.href from the WebView to “navigate” to a URL with your defined keyword at the start. As this request is blocked by Scriptable, the WebView stays on the current page, but the script in Scriptable got the data.

Together with the WebView.evaluateJavaScript function you now have a two way communication set up.

Of course you can make a wrapper around this to have a simpler API.

Edit: You need to use window.location.href, because Scriptable can’t intercept XMLHttpRequest. At least in my tests it haven’t caught these.

2 Likes

Very delayed thanks for the tip – I finally got around to trying this out and built a very simple API which I’ll try building on.

const RECEIVED_DATA_PREFIX = 'data://';

const present2WayWebview = async (initHTML, handleReceivedData) => {
  const w = new WebView();
  w.shouldAllowRequest = request => {
    const isPassingData = request.url.startsWith(RECEIVED_DATA_PREFIX);
    if (isPassingData) {
      const receivedData = request.url.split(RECEIVED_DATA_PREFIX)[1];
      handleReceivedData(w, receivedData);
      return false;
    }
    return true;
  };
  w.loadHTML(initHTML);
  await w.present();
};

await present2WayWebview(
  '<a href="${RECEIVED_DATA_PREFIX}selectedTab=A">Click me!</a>',
  (webview, data) => webview.loadHTML(data)
);
1 Like

I had missed (or not understood) your tip about forcing window.location.href to navigate. Posting here the code I just wrote to sort that out. I’ll post more if I keep going, I think I’ll play around a little with the 2-way interactions again.


For my use case, in which I’m loading HTML from email bodies, it made sense to add Javascript to the HTML before loading it into the WebView. I also experimented with executing Javascript within the loaded WebView, but there were still some issues to work out.

In my testing, this has resulted in 100% caught link clicks, whereas I would often experience “dropped” clicks before.


Javascript to insert into the HTML

/** This ensures that all link clicks pass through `shouldAllowRequest` */
const routeAllLinksThroughWindowLocation = [
  `window.addEventListener(`,
  ` 'click',`,
  ` event => {`,
  `   const closestLink = event.target && event.target.closest('a');`,
  `   if (!closestLink) return;`,
  `   event.preventDefault();`,
  `   const url = closestLink.getAttribute('href');`,
  `   window.location.href = url;`,
  ` },`,
  ` true`,
  `);`,
]
  .map(line => line.trim())
  .join('');

Code to insert above script into the HTML body
I’m not sure if it’s all really necessary to satisfy the browser engine, but it works.

export const injectScriptInHtmlStr = (htmlStr, script) => {
  const hasHead =
    lowerIncludes(htmlStr, '<head') && lowerIncludes(htmlStr, '</head>');
  const hasHtml =
    lowerIncludes(htmlStr, '<html') && lowerIncludes(htmlStr, '</html>');
  const scriptInTag = `<script>${script}</script>`;
  const scriptTagInHead = `<head>${scriptInTag}</head>`;
  const insertedIntoExistingHead = spliceInPlace(
    htmlStr.split('</head>'),
    1,
    0,
    `${scriptInTag}</head>`
  ).join('');

  if (hasHead && hasHtml) return insertedIntoExistingHead;
  if (hasHead && !hasHtml)
    return ['<html>', insertedIntoExistingHead, '</html>'].join('');
  if (!hasHead && hasHtml)
    return [
      '<html>',
      `${scriptTagInHead}`,
      splitByRegex(htmlStr, /<html[^<>]*>/i)[1],
    ].join('');
  if (!hasHead && !hasHtml)
    return [`<html>${scriptTagInHead}`, htmlStr, '</html>'].join('');
};

Utils used above

const lowerIncludes = (containingString, query) =>
  containingString.toLowerCase().includes(query.toLowerCase());

const splitByRegex = (str, regex) => {
  const uniqueRegexReplacer = UUID.string();
  const globalRegex = new RegExp(
    regex.source,
    regex.flags.includes('g') ? regex.flags : regex.flags + 'g'
  );
  const withUniqueDividers = str.replace(globalRegex, uniqueRegexReplacer);
  return withUniqueDividers.split(uniqueRegexReplacer);
};

const spliceInPlace = (arr, startIndex, deleteCount, ...items) => {
  const shallowClone = [...arr];
  shallowClone.splice(startIndex, deleteCount, ...items);
  return shallowClone;
};

Edit – bad splicing

1 Like

Just discovered this concept and tested it out myself. This is awesome! I think I can finally make simple stateful mini apps in Scriptable :heart_eyes:

Can you provide an example piecing things together? I’ve been playing around with your code and I haven’t had much luck yet (it may be my HTML, and that is why I am asking). I am trying to create a nice UI that that calls Scriptable functions to perform other actions. Thanks!

Heya, here’s a simple example that creates a notification with the input value whenever you click the button.

This shows passing data from the webview back to Scriptable, then Scriptable acting on it.

To update the webview based on activity in Scriptable, you could use the evaluateJs method of the webview. For example, you could update the contents of the webview when a user clicks a button (a request handler “hears” the click and runs evaluateJs), or periodically with a repeating timer.

Feel free to reply or DM me if you have any questions

const WEBVIEW_PASS_DATA_PREFIX = "passdata://";

const notifyNow = title => {
  const n = new Notification();
  n.title = title;
  n.schedule();
};

/**
 * Given the reques URL starting with "passdata://", this function will extract
 * the query parameters from it. In this example, we're only passing 1 query
 * parameter (text), but this function would work for any number.
 */
const parsePassedData = requestUrl => {
  const dataParamStr = requestUrl.split(WEBVIEW_PASS_DATA_PREFIX)[1];
  const keyValPairStrs = dataParamStr.split(/\?|&/g).filter(Boolean);
  return keyValPairStrs.reduce((acc, keyValPairStr) => {
    const splitArr = keyValPairStr.split("=");
    const key = splitArr[0];
    const encodedVal = splitArr[1];
    return { ...acc, [key]: decodeURIComponent(encodedVal) };
  }, {});
};

/**
 * Get the text value of the input, then set the window's href to pass that
 * information back into Scriptable code.
 */
const sendUserInputFn = [
  `(function() {`,
  `  const text = document.querySelector('input').value || 'Hello world';`,
  `  const encodedText = encodeURIComponent(text);`,
  `  window.location.href='${WEBVIEW_PASS_DATA_PREFIX}?text=' + encodedText;`,
  `})()`,
].join("");

const html = [
  `<input type="text" />`,
  `<br />`,
  `<button type="button" onclick="${sendUserInputFn}">`,
  `Click me to show a notification`,
  `</button>`,
].join("");

const w = new WebView();

/**
 * For every request in the webview, check if the request URL starts with
 * passdata://. If it does, get the value for "text" from that URL and create a
 * notification with that value.
 */
w.shouldAllowRequest = request => {
  const isPassingData = request.url.startsWith(WEBVIEW_PASS_DATA_PREFIX);
  if (isPassingData) {
    const parsedData = parsePassedData(request.url);
    notifyNow(parsedData.text);
    return false;
  }
  return true;
};

await w.loadHTML(html);

await w.present();

1 Like