Search code examples
google-chrome-extensionfirefox-addonfirefox-addon-webextensions

How do you write HTML and JS to a page with Firefox Extension?


I can't figure out how to inject HTML and javascript onto a webpage with Firefox extensions.

This works with Chrome extension but does NOT work with Firefox. Notice I use chrome.extension.getURL which pulls in HTML and Javascript.

Here is my manifest - note I dont even use the background stuff

{
  "background": {
    "scripts": [ "js/background.js", "js/jquery.js"]
  },
  "content_scripts": [ {
      "js": [ "js/jquery.js", "js/chart-min.js", "js/chart-colors.js", "js/jquery-ui.min.js", "js/profiles/my_request.js", "js/options.js", "js/profiles/projects_builder.js", "js/profiles/board_builder.js", "js/profiles/my_api.js", "js/profiles/user_board_builder.js", "js/profiles/user_board.js","js/profiles/default_labels.js", "js/profiles/default_lists.js", "js/profiles/master_board.js", "js/profiles/projects.js",  "js/profiles/estimates.js", "js/profiles/new_todo_label.js","js/profiles/reports_dashboard.js",  "js/profiles/mutation_observer.js", "js/profiles/completion_chart.js", "js/profiles/cumulative_flow_chart.js"  ],
       "matches": [ "https://asana.com/*" ],
       "all_frames": true
    }],
  "permissions":[ "identity", "cookies", "storage", "activeTab", "https://asana.com/*"],
  "name": "Boards here",
  "short_name" : "boards",
  "version": "3.1.9.5",
  "manifest_version": 2,
  "icons"   : { "48":  "images/logo_thicker.png"},
  "description": "html for website",
  "browser_action":{
    "default_icon":"images/logo_thicker.png",
    "default_popup":"html/popup.html"
  },
  "web_accessible_resources": [ "images/*",  "html/*" ]
}

Example for default lists -- default_lists.js utilizing my_request which is just a jquery ajax wrapper

DefaultLists = (function() {
  function DefaultLists() {

     if (window.location.href.includes('#default_lists')) {
        this.show_form()
    }

   DefaultLists.prototype.show_form = function {
        my_request.ajax({
            url: chrome.extension.getURL("html/manage_default_lists.html"),
            type: 'get',
            success: function (data) {
              $('.panel.panel--project').remove()
              $('.panel.panel--perma').html(data)
            }
         });
    };
  }

  return DefaultLists;
})();
window.default_lists = new DefaultLists();

So now manage_default_lists.html looks something like

<section style="position:relative;top:-50px;" class="message-board__content">
<bc-infinite-page page="1" reached-infinity="" direction="down" trigger="bottom">
  <table class="my_labels" data-infinite-page-container="">
      <tbody>
        <tr id="loading_lists" >
          <td>
            <span style="margin-left:280px;color:grey">Loading...</span>
            </td>
          </tr>
        <tr id="create_row" style="display:none">
          <td>
            <span class="">
              <input id="new_label_name" placeholder="List name" type="text" style="width:180px;font-size:15px;margin-left:42px;padding:5px;border-radius: 0.4rem;border: 1px solid #bfbfbf;" value="">
              <a style="margin-left:10px;float:right;margin-right:80px" class="cancel_new btn--small btn small" href="#">cancel</a>
              <input id="create_label" style="float:right;" type="submit" value="save" class="btn btn--small btn--primary primary small" data-behavior="loading_on_submit primary_submit" data-loading-text="saving…">
            </span>
          </td>
        </tr>
      </tbody>
    </table>
  </bc-infinite-page>     
</section>
</article>

  <script>
  $('#cancel_delete_label_button').on('click', function(event){
    $('#delete_label_modal').hide()
  });

  $('#cancel_force_lists_button').on('click', function(event){
    $('#force_lists_modal').hide()
  });

  $(document).off( "click", '.edit_label').on('click', '.edit_label', function(event) {
    td = $(this).parents('td')
    td.find('.show_row').hide()
    td.find('.edit_row').show()
    event.preventDefault()
  });

  $(document).off( "click", '.cancel_edit').on('click', '.cancel_edit', function(event) {
    td = $(this).parents('td')
    td.find('.show_row').show()
    td.find('.edit_row').hide()
    event.preventDefault()
  });

  $(document).off( "click", '.cancel_new').on('click', '.cancel_new', function(event) {
    // console.log('cancel')
    $('#create_row').hide()
    event.preventDefault()
  });

  $(document).off( "click", '#new_label_button').on('click', '#new_label_button', function(event) {
    $('#create_row').show()
    $('#new_label_name').val('')
    event.preventDefault()
  });

  $(document).off( "click", '#labels_article').on('click', '#labels_article', function(event) {
    // console.log(event.target.className)
    if (event.target.className != 'color-editor-bg'){
      $('.label-colors').hide();
    }
  });


</script>

Solution

  • You're adding this HTML into the web page using your content script and web_accessible_resources. It's not related to the extension's CSP that forbids inline scripts in extension pages like the browser_action popup or the options page. In your case the CSP of the page applies to the stuff you add in it. The page may forbid inline scripts easily, sites often do that.

    You can either rewrite the Content-Security-Policy HTTP header of the page using webRequest API or rework the code a bit, the latter being a better solution not only because it's more focused but also because the result of rewriting an HTTP header is random when several extensions are doing it in the same HTTP request.

    So let's rework the code:

    • store the scripts separately
    • fetch and inject via browser.tabs.executeScript
    • run the injected code when we want it

    The important change of behavior is that the code will be running in the content script's context, using content script's jQuery and variables. Previously your code was running in the page context so it was using jQuery and other variables of the page.

    I'm using Mozilla's browser WebExtension namespace polyfill and async/await syntax.

    manifest.json:

    "background": {
      "scripts": [
        "browser-polyfill.min.js",
        "background.js"
      ]
    },
    "content_scripts": [{
      "js": [
        "browser-polyfill.min.js",
        "content.js"
      ],
      "matches": ["........."]
    }],
    

    directory structure:

    • html/manage_default_lists.html
    • html/manage_default_lists.js

    Since executeScript can only be used in an extension page, not in a content script, we'll send a message to the background script with an instruction.

    content.js:

    async function getResource(name) {
      const htmlUrl = browser.runtime.getURL(`html/${name}.html`);
      const [html, scriptId] = await Promise.all([
        fetch(htmlUrl).then(r => r.text()),
        browser.runtime.sendMessage({
          cmd: 'executeScript',
          path: `html/${name}.js`,
        }),
      ]);
      const js = window[scriptId];
      delete window[scriptId];
      return {html, js};
    }
    
    DefaultLists.prototype.show_form = async () => {
      const {html, js} = await getResource('manage_default_lists');
      $('.panel.panel--project').remove()
      $('.panel.panel--perma').html(html);
      js();
    };
    

    background.js:

    browser.runtime.onMessage.addListener(async (message, sender) => {
      if (!message) return;
      switch (message.cmd) {
        case 'executeScript': {
          const js = await (await fetch(browser.runtime.getURL(message.path))).text();
          const id = `js.${performance.now()}${Math.random()}`;
          await browser.tabs.executeScript(sender.tab.id, {
            // 0 at the end is to prevent executeScript from additionally returning
            // the function's code as a string, which we don't need 
            code: `window["${id}"] = () => { ${js} };0`,
            frameId: sender.frameId,
            matchAboutBlank: true,
            runAt: 'document_start',
          });
          return id;
        }
      }
    });