How to create a global hotkey for opening the “browserAction” popup in Firefox (WebExtensions)?

Available natively in Firefox versions >= 52

This functionality will be natively available in Firefox 52, which is currently Firefox Developer Edition (i.e. Firefox 52.0a2).
As you know, for WebExtensions, you create a global hotkey using the _execute_browser_action key within the object supplied for the commands key. For example:

"commands":{
    "_execute_browser_action": {
        "suggested_key": {
            "default": "Alt+Shift+J"
        }
    }
}

Open a pseudo-popup (polyfill this functionality in the older versions of Firefox)

While the explicit functionality will not be available until Firefox 52, you can polyfill this functionality in the current version of Firefox, by defining a custom command that is named "_execute_browser_action". It’s going to look a bit different than your normal popup, but it will be functional. It will be in a panel, which you may have to account for with some associated styling which is applied only when it is in a panel instead of a popup. There may also be some differences in what the active tab is when your panel is open. However, the code below at least accounts for that when performing queries with chrome.tabs.query(), or browser.tabs.query(), by making the response be what would be expected if it was open in a real popup instead of a panel.

The same code will continue to work on Firefox 52+. On Firefox 52+, the "_execute_browser_action" directly activates the browser action click, or popup.

For when you aren’t using a popup, the primary thing is that you do not use an anonymous function for the browserAction.onClicked listener. This allows the functionality to also be called by the commands.onCommand listener. The commands.onCommand was introduced in Firefox 48, so this should work on any version which is 48+.

You may have some issues with needing permissions other than activeTab when using this polyfill. Exactly what is needed, if anything, will depend on your code.

The following is an extension which causes the functionality invoked with a browser action button to be executed when you hit the keyboard shortcut Alt-Shift-J. It will either activate the doActionButton() function, or, if a popup is defined, it will open your popup as a panel which will behave similarly to how a popup normally behaves, but it is not perfect. It gets the name of the popup file from the one that is currently defined for the current active tab, as would be the case for clicking the browserAction button.

manifest.json:

{
    "description": "Polyfill browserAction keyboard shortcut, including popups.",
    "manifest_version": 2,
    "name": "Polyfill browserAction keyboard shortcut",
    "version": "0.1",

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

    "browser_action": {
        "default_icon": {
            "32": "myIcon.png"
        },
        "default_title": "Open popup",
        "default_popup": "popup.html"
    },
    
    "commands": {
        "_execute_browser_action": {
            "suggested_key": {
                "default": "Alt+Shift+J"
            }
        }
    }
}

background.js:

chrome.browserAction.onClicked.addListener(doActionButton);

function doActionButton(tab){
    console.log('Action Button clicked. Tab:',tab);
}

chrome.commands.onCommand.addListener(function(command) {
    //Polyfill the Browser Action button
    if(command === '_execute_browser_action') {
        chrome.tabs.query({active:true,currentWindow:true},function(tabs){
            //Get the popup for the current tab
            chrome.browserAction.getPopup({tabId:tabs[0].id},function(popupFile){
                if(popupFile){
                    openPopup(tabs[0],popupFile);
                } else {
                    //There is no popup defined, so we do what is supposed to be done for
                    //  the browserAction button.
                    doActionButton(tabs[0]);
                }
            });
        });
        return;
    } //else
});

//popupWindowId can be true, false, or the popup's window Id.
var popupWindowId = false;
var lastFocusedWin;
var lastActiveTab;
function openPopup(tab,popupFile){
    chrome.windows.getLastFocused(function(win){
        lastFocusedWin=win;
        if(popupWindowId === false){
            //This prevents user from pressing the button quickly multiple times in a row.
            popupWindowId = true;
            lastActiveTab = tab;
            chrome.windows.create({ 
                url: popupFile, 
                type: 'popup',
            },function(win){
                popupWindowId = win.id;
                //Poll for the view of the window ID. Poll every 50ms for a
                //  maximum of 20 times (1 second). Then do a second set of polling to
                //  accommodate slower machines.
                //  Testing on a single moderately fast machine indicated the view 
                //  was available after, at most, the second 50ms delay.
                waitForWindowId(popupWindowId,50,20,actOnPopupViewFound,do2ndWaitForWinId);
            });
            return;
        }else if(typeof popupWindowId === 'number'){
            //The window is open, and the user pressed the hotkey combo.
            //  Close the window (as happens for a browserAction popup).
            closePopup();
        }
    });
}

function closePopup(){
    if(typeof popupWindowId === 'number'){
        chrome.windows.remove(popupWindowId,function(){
            popupWindowId = false;
        });
    }
}

chrome.windows.onRemoved.addListener(function(winId){
    if(popupWindowId === winId){
        popupWindowId = false;
    }
});

chrome.windows.onFocusChanged.addListener(function(winId){
    //If the focus is no longer the popup, then close the popup.
    if(typeof popupWindowId === 'number'){
        if(popupWindowId !== winId){
            closePopup();
        }
    } else if(popupWindowId){
    }
});

function actOnPopupViewFound(view){
    //Make tabs.query act as if the panel is a popup.
    if(typeof view.chrome === 'object'){
        view.chrome.tabs.query = fakeTabsQuery;
    }
    if(typeof view.browser === 'object'){
        view.browser.tabs.query = fakeTabsQuery;
    }
    view.document.addEventListener('DOMContentLoaded',function(ev){
        let boundRec = view.document.body.getBoundingClientRect();
        updatePopupWindow({
            width:boundRec.width + 20,
            height:boundRec.height + 40
        });
    });
    updatePopupWindow({});
}

function updatePopupWindow(opt){
    let width,height;
    if(opt){
        width =typeof opt.width  === 'number'?opt.width :400;
        height=typeof opt.height === 'number'?opt.height:300;
    }
    //By the time we get here it is too late to find the window for which we
    //  are trying to open the popup.
    let left = lastFocusedWin.left + lastFocusedWin.width - (width +40);
    let top = lastFocusedWin.top + 85; //Just a value that works in the default case.
    let updateInfo = {
        width:width,
        height:height,
        top:top,
        left:left
    };
    chrome.windows.update(popupWindowId,updateInfo);
}

function waitForWindowId(id,delay,maxTries,foundCallback,notFoundCallback) {
    if(maxTries--<=0){
        if(typeof notFoundCallback === 'function'){
            notFoundCallback(id,foundCallback);
        }
        return;
    }
    let views = chrome.extension.getViews({windowId:id});
    if(views.length > 0){
        if(typeof foundCallback === 'function'){
            foundCallback(views[0]);
        }
    } else {
        setTimeout(waitForWindowId,delay,id,delay,maxTries,foundCallback,notFoundCallback);
    }
}

function do2ndWaitForWinId(winId,foundCallback){
    //Poll for the view of the window ID. Poll every 500ms for a
    //  maximum of 40 times (20 seconds). 
    waitForWindowId(winId,500,40,foundCallback,windowViewNotFound);
}

function windowViewNotFound(winId,foundCallback){
    //Did not find the view for the window. Do what you want here.
    //  Currently fail quietly.
}

function fakeTabsQuery(options,callback){
    //This fakes the response of chrome.tabs.query and browser.tabs.query, which in
    //  a browser action popup returns the tab that is active in the window which
    //  was the current window when the popup was opened. We need to emulate this
    //  in the popup as panel.
    //The popup is also stripped from responses if the response contains multiple
    //  tabs.
    let origCallback = callback;
    function stripPopupWinFromResponse(tabs){
        return tabs.filter(tab=>{
            return tab.windowId !== popupWindowId;
        });
    }
    function stripPopupWinFromResponseIfMultiple(tabs){
        if(tabs.length>1){
            return stripPopupWinFromResponse(tabs);
        }else{
            return tabs;
        }
    }
    function callbackWithStrippedTabs(tabs){
        origCallback(stripPopupWinFromResponseIfMultiple(tabs));
    }
    if(options.currentWindow || options.lastFocusedWindow){
        //Make the query use the window which was active prior to the panel being
        //  opened.
        delete options.currentWindow;
        delete options.lastFocusedWindow;
        options.windowId = lastActiveTab.windowId;
    }
    if(typeof callback === 'function') {
        callback = callbackWithStrippedTabs;
        chrome.tabs.query.apply(this,arguments);
        return;
    }else{
        return browser.tabs.query.apply(this,arguments)
                                 .then(stripPopupWinFromResponseIfMultiple);
    }
}

WebExtensions is still in development:

The WebExtensions API is very much still in development. What is working improves with each version of Firefox. For now, you are probably best off developing and testing your WebExtension add-on with Firefox Developer Edition, or Firefox Nightly (for _execute_browser_action). You should also make careful note of what version of Firefox is required for the functionality you desire to use. This information is contained in the “Browser compatibility” section of the MDN documentation pages.


Some portions of the code in this question have been copied/modified from various other answers of mine.

Leave a Comment