Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Executing code at page-level from Background.js and returning the value

I've got a web page with its own scripts and variables that I need to execute and retrieve return values from my extension's Background.js.

I understand (I think!) that in order to interact with the web page, it must be done via chrome.tabs.executeScript or a ContentScript, but because the code must execute in the context of the original page (in order to have scope to the scripts and variables), it needs to be injected into the page first.

Following this great post by Rob W, I'm able to invoke the page-level script/variables, but I'm struggling to understand how to return values in this way.

Here's what I've got so far...

Web page code (that I want to interact with):

<html>
<head>
<script>
    var favColor = "Blue";

    function getURL() {
      return window.location.href;
    }
</script>
</head>

<body>
    <p>Example web page with script content I want interact with...</p>
</body>
</html>

manifest.json:

{
  // Extension ID: behakphdmjpjhhbilolgcfgpnpcoamaa
  "name": "MyExtension",
  "version": "1.0",
  "manifest_version": 2,
  "description": "My Desc Here",
  "background": {
    "scripts": ["background.js"]
  },  
  "icons": {
    "128": "icon-128px.png"
  },
  "permissions": [
    "background",
    "tabs",
    "http://*/",
    "https://*/",
    "file://*/",           //### (DEBUG ONLY)
    "nativeMessaging"
  ]
}

background.js

codeToExec = ['var actualCode = "alert(favColor)";',
                  'var script = document.createElement("script");',
                  ' script.textContent = actualCode;',
                  '(document.head||document.documentElement).appendChild(script);',
                  'script.parentNode.removeChild(script);'].join('\n');
chrome.tabs.executeScript( tab.id, {code:codeToExec}, function(result) {
   console.log('Result = ' + result);
} );

I realise the code is currently just "alerting" the favColor variable (this was just a test to make sure I could see it working). However, if I ever try returning that variable (either by leaving it as the last statement or by saying "return favColor"), the executeScript callback never has the value.

So, there appear to be (at least) three levels here:

  1. background.js
  2. content scripts
  3. actual web page (containing scripts/variables)

...and I would like to know what is the recommended way to talk from level 1 to level 3 (above) and return values?

Thanks in advance :o)

like image 313
Paul Nicholas Avatar asked Dec 01 '22 01:12

Paul Nicholas


2 Answers

You are quite right in understanding the 3-layer context separation.

  • A background page is a separate page and therefore doesn't share JS or DOM with visible pages.
  • Content scripts are isolated from the webpage's JS context, but share DOM.
  • You can inject code into the page's context using the shared DOM. It has access to the JS context, but not to Chrome APIs.

To communicate, those layers use different methods:

Background <-> Content talk through Chrome API.
The most primitive is the callback of executeScript, but it's impractical for anything but one-liners.
The common way is to use Messaging.
Uncommon, but it's possible to communicate using chrome.storage and its onChanged event.

Page <-> Extension cannot use the same techniques.
Since injected page-context scripts do not technically differ from page's own scripts, you're looking for methods for a webpage to talk to an extension. There are 2 methods available:

  1. While pages have very, very limited access to chrome.* APIs, they can nevertheless use Messaging to contact the extension. This is achieved through "externally_connectable" method.

    I have recently described it in detail this answer. In short, if your extension declared that a domain is allowed to communicate with it, and the domain knows the extension's ID, it can send an external message to the extension.

    The upside is directly talking to the extension, but the downside is the requirement to specifically whitelist domains you're using this from, and you need to keep track of your extension ID (but since you're injecting the code, you can supply the code with the ID). If you need to use it on any domain, this is unsuitable.

  2. Another solution is to use DOM Events. Since the DOM is shared between the content script and the page script, an event generated by one will be visible to another.

    The documentation demonstrates how to use window.postMessage for this effect; using Custom Events is conceptually more clear.

    Again, I answered about this before.

    The downside of this method is the requirement for a content script to act as a proxy. Something along these lines must be present in the content script:

    window.addEventListener("PassToBackground", function(evt) {
      chrome.runtime.sendMessage(evt.detail);
    }, false);
    

    while the background script processes this with a chrome.runtime.onMessage listener.

I encourage you to write a separate content script and invoke executeScript with a file attribute instead of code, and not rely on its callback. Messaging is cleaner and allows to return data to background script more than once.

like image 200
Xan Avatar answered Dec 02 '22 15:12

Xan


The approach in Xan's answer (using events for communication) is the recommended approach. Implementing the concept (and in a secure way!) is however more difficult.

So I'll point out that it is possible to synchronously return a value from the page to the content script. When a <script> tag with an inline script is inserted in the page, the script is immediately and synchronously executed (before the .appendChild(script) method returns).

You can take advantage of this behavior by using the injected script to assign the result to a DOM object which can be accessed by the content script. For example, by overwriting the text content of the currently active <script> tag. The code in a <script> tag is executed only once, so you can assign any rubbish to the content of the <script> tag, because it won't be parsed as code any more. For example:

// background script
// The next code will run as a content script (via chrome.tabs.executeScript)
var codeToExec = [
    // actualCode will run in the page's context
    'var actualCode = "document.currentScript.textContent = favColor;";',
    'var script = document.createElement("script");',
    'script.textContent = actualCode;',
    '(document.head||document.documentElement).appendChild(script);',
    'script.remove();',
    'script.textContent;'
].join('\n');

chrome.tabs.executeScript(tab.id, {
    code: codeToExec
}, function(result) {
    // NOTE: result is an array of results. It is usually an array with size 1,
    // unless an error occurs (e.g. no permission to access page), or
    // when you're executing in multiple frames (via allFrames:true).
    console.log('Result = ' + result[0]);
});

This example is usable, but not perfect. Before you use this in your code, make sure that you implement proper error handling. Currently, when favColor is not defined, the script throws an error. Consequently the script text is not updated and the returned value is incorrect. After implementing proper error handling, this example will be quite solid.

And the example is barely readable because the script is constructed from a string. If the logic is quite big, but the content script in a separate file and use chrome.tabs.executeScript(tab.id, {file: ...}, ...);.

When actualCode becomes longer than a few lines, I suggest to wrap the code in a function literal and concatenate it with '(' and ')(); to allow you to more easily write code without having to add quotes and backslashes in actualCode (basically "Method 2b" of the answer that you've cited in the question).

like image 39
Rob W Avatar answered Dec 02 '22 13:12

Rob W