Skip to content

Instantly share code, notes, and snippets.

@NiklasGollenstede
Last active January 14, 2021 18:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save NiklasGollenstede/63a6099d97e82ffe0cc064d4d4d82b62 to your computer and use it in GitHub Desktop.
Save NiklasGollenstede/63a6099d97e82ffe0cc064d4d4d82b62 to your computer and use it in GitHub Desktop.
Firefox extension development: keep extension tabs open

This drop-in module does one simple thing: it allows you to keep the extension tabs of your WebExtension open when you reload it during development.

Pleas read the README for more information.

Keep WebExtension tabs open

This drop-in module does one simple thing: it allows you to keep the extension tabs of your WebExtension open when you reload it during development.

Notes / Limitations

It is not possible to catch the 'unload' event of the background page (in Firefox 65), so this module (only) hooks into the browser.runtime.reload() method, which means that it will only handle reloads initiated by calls to that method, and not reloads caused by clicking Reload on about:debugging#addons or updating/re-installing the extension. I'd suggest you put a button that invokes that function on your extension pages (which you want to have open anyway) or add a context menu option (see below).

I was reminded of this problem and decided to use it as a quick exercise. Is it Worth the Time is already giving me a clear no, so here is the solution I came up with in about two hours:

  • Firefox keeps unloaded/discarded extension tabs open, so unload all tabs prior to reloading the extension, then reload them to load them again.
  • But that only works if the tab is not active in its window, so if that is the case, open a new tab with a reload notice.
  • But now we have those additional tabs, so close them after reloading the extension.
  • But then you have those closed tabs in the session history, so remove them from there as well.
  • And I hope that's it.

This probably requires the 'tabs' permission, and to remove the temporary tabs from the history, it (optionally) requires the 'sessions' permission (which you might want to only add to development builds).

Usage

This module is available via NPM, so do npm install keep-tabs-open in a terminal in your project to install. Now include the file node_modules/keep-tabs-open/index.js in your extension (web-ext might mess with this) and load it as a background script (however you do that with your other scripts).

If you use an AMD loader (like require.js) the script will define an anonymous module, otherwise it exposes itself as a keepExtTabsOpen global function. Either way, you need to call that function with extension specific options (all optional) to activate it:

  • iconUrl: favicon of the temporary reload tab.
  • title: HTML title of the temporary reload tab.
  • message: HTML message to display on the temporary reload tab.
  • browser/chrome: APIs to use and patch. Defaults to the respective globals. Doesn't need Promise capability, prefers to use browser.

E.g.:

background/index.js:

const manifest = browser.runtime.getManifest();
require([ 'node_modules/keep-tabs-open/index', ], keepExtTabsOpen => {
	keepExtTabsOpen({ browser: global.browser, iconUrl: '/icon.png',
		title: 'Reloading: '+ manifest.name, message: `
			<style> :root { background: #424F5A; filter: invert(1) hue-rotate(180deg); font-family: Segoe UI, Tahoma, sans-serif; } </style>
			<h1>Reloading ${manifest.name}</h1><p>This tab should close in a few seconds ...</p>
		`,
	}).catch(console.error);
});

If you have the 'menus' or 'contextMenus' permission, you can also add this to make reloading the extension more convenient.

const browser = window.browser || window.chrome;
const Menus = browser.menus || browser.contextMenus; if (Menus) {
	Menus.create({ contexts: [ 'browser_action', ], id: 'extension:restart', title: 'Restart Extension', });
	Menus.onClicked.addListener(({ menuItemId, }) => { menuItemId === 'extension:restart' && browser.runtime.reload(); });
}
(function(global) { 'use strict'; const factory = function keepExtTabsOpen(exports) { return async options => { // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
// TODO: test uncommitted changes!
const { browser = global.browser, chrome = global.chrome, iconUrl, title, message, } = options, api = browser || chrome;
/**
* save open tabs (when reloading)
*/
const doReload = api.runtime.reload;
chrome && Object.defineProperty(chrome.runtime, 'reload', { value: reload, });
browser && Object.defineProperty(browser.runtime, 'reload', { value: reload, });
async function reload() {
const notice = URL.createObjectURL(new global.Blob([ `
<!DOCTYPE html>
<html><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="viewport" content="initial-scale=1, maximum-scale=1.0, user-scalable=no">
<title>${title}</title><link rel="icon" href="${ iconUrl && (await fetchB64(iconUrl)) }">
</head><body>
${ message || '<h1>Extension is reloading ...</h1>' }
</body></html>
`.trim(), ], { type: 'text/html', })) + suffix;
const tabs = (await call(api.tabs, 'query', { url: api.runtime.getURL('*'), discarded: false, }));
(await Promise.all(tabs.filter(_=>_.active).map(({ windowId, index, }) => call(api.tabs, 'create', { windowId, index: index + 1, url: notice, }))));
// (await Promise.all(tabs.map(tab => call(api.tabs, 'discard', tab.id))));
tabs.map(tab => api.tabs.discard(tab.id)); // firefox does not allow a callback here
setTimeout(doReload, 300);
}
/**
* restore previous tabs
*/
const prefix = 'blob:'+ api.runtime.getURL(''), suffix = '#reloading';
const tabs = (await call(api.tabs, 'query', { url: api.runtime.getURL('*'), })); // BUG[FF65]: extension tabs that are being undiscarded would be missed by this query -.-
(await Promise.all(tabs.filter(_=>_.discarded).map(tab => call(api.tabs, 'reload', tab.id))));
(await new Promise(wake => setTimeout(wake, 1000)));
for (const { id, windowId, index, } of tabs) { // sequential to avoid race conditions with the sessions API
const active = (await call(api.tabs, 'query', { windowId, index: index + 1, }))[0];
if (active && active.url && active.url.startsWith(prefix) && active.url.endsWith(suffix)) {
(await call(api.tabs, 'update', id, { active: true, }));
(await call(api.tabs, 'remove', active.id));
const session = api.sessions && api.sessions.getRecentlyClosed && api.sessions.forgetClosedTab
&& (await call(api.sessions, 'getRecentlyClosed', { maxResults: 1, }))[0];
session && session.tab && api.sessions.forgetClosedTab(session.tab.windowId, session.tab.sessionId);
}
}
/**
* utils
*/
function fetchB64(url) { return global.fetch(url).then(_=>_.blob()).then(blob => new Promise((y, n) => { const r = new global.FileReader; r.onerror = n; r.onload = () => y(r.result); r.readAsDataURL(blob); })); }
function call(api, method, ...args) { return new Promise((y, n) => api[method](...args, v => api.runtime.lastError ? n(api.runtime.lastError) : y(v))); }
}; }; if (typeof define === 'function' /* global define */ && define.amd) { define([ 'exports', ], factory); } else { const exp = { }, result = factory(exp) || exp; if (typeof exports === 'object' && typeof module === 'object') { /* eslint-disable */ module.exports = result; /* eslint-enable */ } else { global[factory.name] = result; } } })(this);
{
"name": "keep-tabs-open",
"version": "1.0.2",
"description": "npm + AMD module that prevents Firefox from closing WebExtension tabs during development",
"keywords": [ "Firefox", "WebExtension", "tabs", "close", "development" ],
"author": "NiklasGollenstede",
"license": "MPL-2.0",
"repo": "gist:63a6099d97e82ffe0cc064d4d4d82b62",
"homepage": "https://gist.github.com/NiklasGollenstede/63a6099d97e82ffe0cc064d4d4d82b62#file-readme-md"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment