Search code examples
javascriptgoogle-chrome-extensionchromiumuserscriptschrome-extension-manifest-v3

How to apply Dynamic user scripts to a particular tab in Chrome MV3 extension (UserScripts API)


Chrome has released the User Scripts API in chrome 120 beta. Now we are able to run dynamic scripts from the extension ,but the only way of targetting specific tabs seems to be the matches property which matches against urls. What i need is to target a particular tabID .

Use case : Content scripts sends message to Background script and then we apply a dynamic script to only that particular tab. Url based targetting is not enough as at one point there can be multiple tabs open with same url.

https://developer.chrome.com/docs/extensions/reference/userScripts

chrome.userScripts.register([{
  id: 'test',
  matches: ['*://*/*'],//not specific enough
  tabId: 124473838,//not a part of api
  js: [{code: 'alert("Hi!")'}]
}]);

Solution

  • Until userScripts API implements execute(), we'll have to use these workarounds:

    USER_SCRIPT world

    The inefficient workaround is to run the script on all urls in an inactive state, e.g. as a function, then run this function upon receiving a message sent via chrome.tabs.sendMessage from the popup or the background script:

    chrome.userScripts.register([{
      id: '1',
      runAt: 'document_start',
      matches: ['<all_urls>'],
      js: [{
        code: 'chrome.runtime.onMessage.addListener(fn);' +
          function fn(msg, sender, sendResponse) {
            console.log('onMessage', ...arguments);
            // do something useful
            sendResponse('foo');
          },
      }],
    }]);
    

    For large scripts you can slightly optimize it by using window.eval on the code passed in the message. This requires first allowing eval in the background/popup script:

    chrome.userScripts.configureWorld({
      csp: "script-src 'self' 'unsafe-eval'"
    });
    

    Both these approaches would wastefully create a separate JS world in all pages, so the most efficient workaround in a personal extension (see the note inside the linked answer) is to use chrome.scripting.executeScript to create a DOM script element (example), but it's subject to the CSP of the page, so you might have to strip it via declarativeNetRequest (and reduce the site's security) or patch it on a per-site basis (until https://crbug.com/1141166 is fully fixed this is quite complicated and requires a separate request to the site to get the current CSP). Note that in Chrome we can't strip/override the embedded <meta> tag with CSP.

    MAIN world

    Since it can't receive onMessage you'll have to use DOM messaging via CustomEvent from code injected by chrome.script.executeScript.

    First, register the inactive function fn with your code in the MAIN world:

    async function register() {
      const eventId = `${Math.random()}`;
      await chrome.storage.local.set({eventId});
      return chrome.userScripts.register([{
        id: '1',
        runAt: 'document_start',
        matches: ['<all_urls>'],
        world: 'MAIN',
        js: [{
          code: `addEventListener("${eventId}", ${
            async function fn(evt) {
              let data = evt.detail;
              // do something useful
              let result = 123;
              // send the result back
              dispatchEvent(new CustomEvent(evt.type + 'cb', {detail: result}));
            }
          })`,
        }],
      }]);
    }
    

    Then activate it from the popup or the background script:

    async function run() {
      const [tab] = await chrome.tabs.query({active: true, currentWindow: true});
      const {eventId} = await chrome.storage.local.get('eventId');
      const [{result}] = await chrome.scripting.executeScript({
        target: {tabId: tab.id},
        args: [eventId, {foo: 'bar'}],
        func: (eventId, data) => {
          let resolve;
          let result = new Promise(r => { resolve = r; });
          addEventListener(eventId + 'cb', function fn(evt) {
            removeEventListener(evt.type, fn);
            resolve(evt.detail);
          });
          dispatchEvent(new CustomEvent(eventId, {detail: data}));
          return result;
        },
      });
      console.log({result});
    }