Forivin
Forivin

Reputation: 15508

Messaging problems between content script and background script (WebExtensions)

I'm currently trying to write a browser extension that catches all mailto links, instead of letting them being opened by the default mail application. The mechanism can be enabled and disabled using a simple toggle button.
When my extension loads everything seems to work, but I get a Error: Could not establish connection. Receiving end does not exist.. Then when I use the toggle button to disable and enable it again I get the following:

23:52:15.247 function sendMsgToTabs(msg)  background.js:16:9
23:52:15.256 this.sendMsgToTabs(...) is undefined  background.js:17
23:52:15.302 Error: Could not establish connection. Receiving end does not exist.  (unknown)
23:53:29.347 TypeError: this._recipeManager is null[Learn More]  LoginManagerParent.jsm:77:9

Edit: And the disabling doesn't seem to work properly in the content script. The click handler is not properly removed for some reason...

enter image description here

So what could be causing all these errors? I checked this.sendMsgToTabs in the debugger and it actually isn't undefined. I also don't have any unusual sites open, so I don't understand why there is a connection problem.

I've only tested my code in Firefox for now. But people say Chrome's API is essentially the same. Here is my code:

background.js

'use strict'
class MyWebExtensionBackend {
    constructor() {
        this.icons = {
            enabled: '/icons/on.png',
            disabled: '/icons/off.png'
        }
        this.isEnabled = true
        browser.browserAction.onClicked.addListener(this.toggle.bind(this)) //toolbar button
        browser.runtime.onMessage.addListener(this.msgListener.bind(this))
        this.enable()
    }
    enable() {
        browser.browserAction.setIcon({ path: this.icons.enabled })
        this.isEnabled = true
        console.log(this.sendMsgToTabs)
        this.sendMsgToTabs({isEnabled: this.isEnabled}).catch(console.error)
    }
    disable() {
        browser.browserAction.setIcon({ path: this.icons.disabled })
        this.isEnabled = false
        this.sendMsgToTabs({isEnabled: this.isEnabled}).catch(console.error)
    }
    toggle() {
        if (this.isEnabled)
            this.disable()
        else
            this.enable()
    }
    sendMsgToTabs(msg) {
        return browser.tabs.query({}, tabs => {
            let msgPromises = []
            for (let tab of tabs) {
                let msgPromise = browser.tabs.sendMessage(tab.id, msg)
                msgPromises.push(msgPromise)
            }
            return Promise.all(msgPromises)
        })
    }
    msgListener(msg, sender, sendResponse) {
        console.log(msg.link)
        /* browser.notifications.create({ // doesn't work
            "type": "basic",
            "iconUrl": browser.extension.getURL("icons/on.png"),
            "title": 'url opened',
            "message": msg.link
        }); */
    }
}

let myWebExtensionBackend = new MyWebExtensionBackend()

content-script.js

'use strict'
class MyWebExtensionFrontend {
    constructor() {
        this.isEnabled = false
        browser.runtime.onMessage.addListener(this.msgListener.bind(this))
    }
    linkHandler(event) {
        if (event.target.tagName !== 'A')
            return
        let link = event.target.href
        if (link.startsWith('mailto:')) {
            event.preventDefault() // doesn't appear to have an effect
            console.log(link)
            browser.runtime.sendMessage({'link': link}).catch(console.error)
            //using a promise here felt kind of wrong 
            //because event handler functions can't really deal with that 
            //from what I can tell
            return false  // doesn't appear to have an effect
        }
    }
    enable() {
        console.log('enable frontend')
        window.addEventListener('click', this.linkHandler.bind(this))
        this.isEnabled = true
    }
    disable() {
        console.log('disable frontend')
        window.removeEventListener('click', this.linkHandler.bind(this))
        this.isEnabled = false
    }
    msgListener(req) {
        if (req.isEnabled)
            this.enable()
        else 
            this.disable()
        return Promise.resolve({res: ''})
    }
}

let myWebExtensionFrontend = new MyWebExtensionFrontend()

manifest.json

{

  "description": "A basic toggle button",
  "manifest_version": 2,
  "name": "toggle-button",
  "version": "1.0",
  "homepage_url": "https://github.com/TODO",
  "icons": {
    "48": "icons/on.png"
  },

  "background": {
    "scripts": ["background.js"]
  },

  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content-script.js"]
    }
  ],

  "browser_action": {
    "default_icon": "icons/on.png"
  }

}

Upvotes: 1

Views: 1942

Answers (2)

Rob W
Rob W

Reputation: 349042

There are two issues with your code. One is specific to WebExtensions, another is specific to DOM and JavaScript.

Issue 1: Return value of asynchronous methods in WebExtensions API

In Firefox, WebExtensions are available at two namespaces, chrome. and browser.. chrome. is designed to behave as close to Chrome's extension API, whereas browser. has some enhancements. In particular, the asynchronous browser. APIs may return a promise. The promise is only returned if the method is not called with a callback function. If it does receive a callback as parameter, the browser. API behaves like chrome.. Here are some examples:

// These have a callback and return undefined.
chrome.tabs.query({}, function(tabs) { /* ... */ });
browser.tabs.query({}, function(tabs) { /* ... */ });

chrome.tabs.query({}); // No callback, returns undefined (this is quite useless).
browser.tabs.query({}); // No callback, returns a Promise<Array>.

browser.tabs.query({}).then(function(tabs) { /* ... */});

Note that the error message that you received showed that the return value is undefined:

this.sendMsgToTabs(...) is undefined

If the method was undefined (as you thought), Firefox would print the following error:

this.sendMsgToTabs is not a function

The fix for your case is to use promises as follows:

sendMsgToTabs(msg) {
    // Note: Changed ", tabs =>" to ").then(tabs =>"
    return browser.tabs.query({}).then(tabs => {
        let msgPromises = []
        for (let tab of tabs) {
            let msgPromise = browser.tabs.sendMessage(tab.id, msg)
            msgPromises.push(msgPromise)
        }
        return Promise.all(msgPromises)
    });
}

If there is any tab without a content script, the promise will be rejected though. If you don't care about the return value of the promise, add the following after let msgPromise:

msgPromise = msgPromise.catch(() => {}); // Ignore errors.

Issue 2: Using .bind() with addEventListener / removeEventListener

Your code looks like this:

// your enable() function:
window.addEventListener('click', this.linkHandler.bind(this))

// your disable() function:
window.removeEventListener('click', this.linkHandler.bind(this))

The issue with this is that bind returns a new function. So when the enable() method is called, a new function is created and used as an event listener. When disable() is called, a new function is created and passed to removeEventListener. Since this new function is distinct from any other function (the previous parameter to addEventListener in particular), the result is that the click handler is not removed.

There are three ways to register DOM events with the desired this value:

  1. At the construction of the class, replace the instance method with a bound function:

    // In the constructor:
    this.linkHandler = this.linkHandler.bind(this);
    
    // In your enable() function:
    window.addEventListener('click', this.linkHandler);
    
    // In your disable() function:
    window.removeEventListener('click', this.linkHandler);
    
  2. At the call of enable() create a bound function if needed:

    // In your enable() function:
    if (!this.linkHandlerBound) {
        this.linkHandlerBound = this.linkHandler.bind(this);
    }
    window.addEventListener('click', this.linkHandlerBound);
    
    // In your disable() function:
    window.removeEventListener('click', this.linkHandlerBound);
    // If wanted (but not needed), run: this.linkHandlerBound = null;
    
  3. Pass an object with the handleEvent method:

    // A method of your class
    handleEvent(event) {
        if (event.type === 'click') {
            this.linkHandler(event);
        }
    }
    
    // In your enable() function:
    window.addEventListener('click', this);
    
    // In your disable() function:
    window.removeEventListener('click', this);
    

    Note, do not make the similar mistake of defining the object with the handleEvent method as follows:

    // Do NOT do this! You won't be able to remove the listener because
    // you did not store a reference to the event handler object.
    window.addEventListener('click', {
        handleEvent: this.linkHandler.bind(this)
    });
    

Upvotes: 2

Teyam
Teyam

Reputation: 8102

I just noticed that you haven't shared how you handle connections but you may want to check Long-lived connections to distinguish different types of connections and how to have an established connection.

As mentioned in the given link,

When establishing a connection, each end is given a runtime.Port object which is used for sending and receiving messages through that connection.

Here is a sample on how you open a channel from a content script, and send and listen for messages:

var port = chrome.runtime.connect({name: "knockknock"});
port.postMessage({joke: "Knock knock"});
port.onMessage.addListener(function(msg) {
  if (msg.question == "Who's there?")
    port.postMessage({answer: "Madame"});
  else if (msg.question == "Madame who?")
    port.postMessage({answer: "Madame... Bovary"});
});

Furthermore, you may want to also check the suggested solutions in this related SO post and see which works for you.

Upvotes: 0

Related Questions