9

I am trying to create an extension that will have a side panel. This side panel will have buttons that will perform actions based on the host page state.

I followed this example to inject the side panel and I am able to wire up a button onClick listener. However, I am unable to access the global js variable. In developer console, in the scope of the host page I am able to see the variable (name of variable - config) that I am after. but when I which to the context of the sidepanel (popup.html) I get the following error -
VM523:1 Uncaught ReferenceError: config is not defined. It seems like popup.html also runs in a separate thread.

How can I access the global js variable for the onClick handler of my button?

My code:

manifest.json

{
    "manifest_version": 2,

    "name": "Hello World",
    "description": "This extension to test html injection",
    "version": "1.0",
    "content_scripts": [{
        "run_at": "document_end",
        "matches": [
            "https://*/*",
            "http://*/*"
        ],
        "js": ["content-script.js"]
    }],
    "browser_action": {
        "default_icon": "icon.png"
    },
    "background": {
        "scripts":["background.js"]
    },
    "permissions": [
        "activeTab"
    ],
    "web_accessible_resources": [
        "popup.html",
        "popup.js"
    ]
}

background.js

chrome.browserAction.onClicked.addListener(function(){
    chrome.tabs.query({active: true, currentWindow: true}, function(tabs){
        chrome.tabs.sendMessage(tabs[0].id,"toggle");
    })
});

content-script.js

chrome.runtime.onMessage.addListener(function(msg, sender){
    if(msg == "toggle"){
        toggle();
    }
})

var iframe = document.createElement('iframe'); 
iframe.style.background = "green";
iframe.style.height = "100%";
iframe.style.width = "0px";
iframe.style.position = "fixed";
iframe.style.top = "0px";
iframe.style.right = "0px";
iframe.style.zIndex = "9000000000000000000";
iframe.frameBorder = "none"; 
iframe.src = chrome.extension.getURL("popup.html")

document.body.appendChild(iframe);

function toggle(){
    if(iframe.style.width == "0px"){
        iframe.style.width="400px";
    }
    else{
        iframe.style.width="0px";
    }
}

popup.html

<head>
<script src="popup.js"> </script>
</head>
<body>
<h1>Hello World</h1>
<button name="toggle" id="toggle" >on</button>
</body>

popup.js

document.addEventListener('DOMContentLoaded', function() {
  document.getElementById("toggle").addEventListener("click", handler);
});

function handler() {
  console.log("Hello");
  console.log(config);
}
Leon
  • 1,141
  • 13
  • 25
  • I do not have access to the host page. The host page does not store it in chrome.storage.local – Leon Oct 22 '17 at 01:59
  • Extension JS can not interact with the page JS. They tell you that in the docs. – Jerinaw Oct 22 '17 at 02:02
  • @wOxxOm Thanks for the link. I have come across that but some what confused by it. Can you please help me understand. I have to inject another script for the purpose of extracting the JS variable and messaging it? – Leon Oct 22 '17 at 02:08
  • or should I inject this script the content-script and wire up the onClick listener differently? – Leon Oct 22 '17 at 02:10
  • Ok, thank you, how do I invoke that script from the button? – Leon Oct 22 '17 at 02:11
  • I thought he was asking how to access the JS of the page, not something he injected. @leon Google gives you an API for sending messages between a content script and your background script. wOxxOm's link talks about it. – Jerinaw Oct 22 '17 at 02:12
  • @Jerinaw, thanks. I am just somewhat confused by all the new terms, content script, background script and injected script. I would like to make sure that I am not trying to do something the wrong way. I would like an injected iframe to have a panel with a button. A button will perform a certain action based on the JS variable of the host page. – Leon Oct 22 '17 at 02:15
  • Is my code to inject the onClick event listener correct? – Leon Oct 22 '17 at 02:16

1 Answers1

24

Since content scripts run in an "isolated world" the JS variables of the page cannot be directly accessed from an extension, you need to run code in page's main world.

WARNING! DOM element cannot be extracted as an element so just send its innerHTML or another attribute. Only JSON-compatible data types can be extracted (string, number, boolean, null, and arrays/objects of these types), no circular references.

1. ManifestV3 in modern Chrome 95 or newer

This is the entire code in your extension popup/background script:

async function getPageVar(name, tabId) {
  const [{result}] = await chrome.scripting.executeScript({
    func: name => window[name],
    args: [name],
    target: {
      tabId: tabId ??
        (await chrome.tabs.query({active: true, currentWindow: true}))[0].id
    },
    world: 'MAIN',
  });
  return result;
}

Usage:

(async () => {
  const v = await getPageVar('foo');
  console.log(v);
})();

See also how to open correct devtools console.

2. ManifestV3 in old Chrome and ManifestV2

We'll extract the variable and send it into the content script via DOM messaging. Then the content script can relay the message to the extension script in iframe or popup/background pages.

  • ManifestV3 for Chrome 94 or older needs two separate files

    content script:

    const evtToPage = chrome.runtime.id;
    const evtFromPage = chrome.runtime.id + '-response';
    
    chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
      if (msg === 'getConfig') {
        // DOM messaging is synchronous so we don't need `return true` in onMessage
        addEventListener(evtFromPage, e => {
          sendResponse(JSON.parse(e.detail));
        }, {once: true});
        dispatchEvent(new Event(evtToPage));
      }
    });
    
    // Run the script in page context and pass event names
    const script = document.createElement('script');
    script.src = chrome.runtime.getURL('page-context.js');
    script.dataset.args = JSON.stringify({evtToPage, evtFromPage});
    document.documentElement.appendChild(script);
    

    page-context.js should be exposed in manifest.json's web_accessible_resources, example.

    // This script runs in page context and registers a listener.
    // Note that the page may override/hook things like addEventListener... 
    (() => {
      const el = document.currentScript;
      const {evtToPage, evtFromPage} = JSON.parse(el.dataset.args);
      el.remove();
      addEventListener(evtToPage, () => {
        dispatchEvent(new CustomEvent(evtFromPage, {
          // stringifying strips nontranferable things like functions or DOM elements
          detail: JSON.stringify(window.config),
        }));
      });
    })();
    
  • ManifestV2 content script:

    const evtToPage = chrome.runtime.id;
    const evtFromPage = chrome.runtime.id + '-response';
    
    // this creates a script element with the function's code and passes event names
    const script = document.createElement('script');
    script.textContent = `(${inPageContext})("${evtToPage}", "${evtFromPage}")`;
    document.documentElement.appendChild(script);
    script.remove();
    
    // this function runs in page context and registers a listener
    function inPageContext(listenTo, respondWith) {
      addEventListener(listenTo, () => {
        dispatchEvent(new CustomEvent(respondWith, {
          detail: window.config,
        }));
      });
    }
    
    chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
      if (msg === 'getConfig') {
        // DOM messaging is synchronous so we don't need `return true` in onMessage
        addEventListener(evtFromPage, e => sendResponse(e.detail), {once: true});
        dispatchEvent(new Event(evtToPage));
      }
    });
    
  • usage example for extension iframe script in the same tab:

    function handler() {
      chrome.tabs.getCurrent(tab => {
        chrome.tabs.sendMessage(tab.id, 'getConfig', config => {
          console.log(config);
          // do something with config
        });
      });  
    }
    
  • usage example for popup script or background script:

    function handler() {
      chrome.tabs.query({active: true, currentWindow: true}, tabs => {
        chrome.tabs.sendMessage(tabs[0].id, 'getConfig', config => {
          console.log(config);
          // do something with config
        });
      });  
    }
    

So, basically:

  1. the iframe script gets its own tab id (or the popup/background script gets the active tab id) and sends a message to the content script
  2. the content script sends a DOM message to a previously inserted page script
  3. the page script listens to that DOM message and sends another DOM message back to the content script
  4. the content script sends it in a response back to the extension script.
wOxxOm
  • 65,848
  • 11
  • 132
  • 136
  • thanks so much, the code works pretty much as is. I had some small trouble as config was not being passed as event.details. I believe this is because one of it's members is a function pointer. I can work around by selectively passing the parameters that i need instead of the whole config. – Leon Oct 23 '17 at 13:25
  • What is the difference between `CustomEvent` and simply `Event` – masonCherry Mar 13 '21 at 13:35
  • CustomEvent allows passing custom data in `detail`. – wOxxOm Mar 13 '21 at 13:46
  • 1
    Can the same logic be used to set a variable on the window object before the page loads up, for example on document_start? – Samarth Agarwal Feb 03 '22 at 18:37