We do not suggest to convert older legacy bootstrapped extensions or legacy overlay extensions (as used in Thunderbird 60) directly to modern WebExtensions. They should first be converted to legacy WebExtensions.
If you need any help, get in touch with the add-on developer community:
Converting a legacy WebExtension into a modern WebExtension will be a complex task: almost all interactions with Thunderbird will need to be re-written to use the new WebExtension APIs. If these APIs are not yet sufficient for your add-on, you may even need to implement additional Experiment APIs yourself. Don't worry though: you can find information on all aspects of the migration process below, including links to many advanced topics.
Before working on an update, it is advised to read some information about the WebExtension technology first. Our Extension guide and our "Hello World" Extension Tutorial are good starting points.
Please add a background script to your extension, which will be needed during the update process. The guide assumes that the background script is loaded as a module.
Step 1: Dropping the legacy key
The technical conversion from a legacy WebExtension to a modern WebExtension is simple: drop the legacy key from the manifest.json file.
Your add-on should now install in current versions of Thunderbird without issues, but it will not yet do anything, because the chrome.manifest file is no longer read.
Step 2: Replace the chrome.manifest file
The most common entries in the chrome.manifest file are listed below:
These entries registered global URLs used by the extension to access its assets. Use the LegacyHelper Experiment to register content, resource and locale entries. To replicate the entries shown in the previous example, add the following to your background script:
There are no direct equivalents to manifest flags, so add-ons now need to provide their own mechanisms to switch code or resources depending on the runtime environment. Relevant information is accessible through the runtime API.
skin
This entry type is no longer supported, it has to be replaced by a resource:// URL. In the above example we had the following skin definition:
skin myaddon classic/1.0 /chrome/skin/classic/
The skin folder is a subfolder of /chrome/, which is already available as a resource:// URL. We can therefore replace all usages of
chrome://myaddon/skin/*
by
resource://myaddon/skin/classic/*
style
This entry is no longer supported, it has to be replaced by the LegacyCSS Experiment. To replicate the style entry shown in the previous example, add the following to your background script:
All *.xul files have been renamed to *.xhtml files in recent versions of Thunderbird! Still using *.xul files is unsupported and will cause issues.
// Define all CSS files for core windows.let files = {"chrome://messenger/content/activity.xhtml":"style.css"}// Inject CSS into all open windows during add-on start (if any).for (let [url, file] ofObject.entries(files) ) {messenger.LegacyCSS.inject(url, file);}// Listen for opened windows and inject CSS.messenger.LegacyCSS.onWindowOpened.addListener((url) => {if (files.hasOwnProperty(url)) {messenger.LegacyCSS.inject(url, files[url]); }});
This should only be a temporary step. After the initial conversion from a style entry to using the LegacyCSS Experiment, the required styles should be applied by using standard WebExtension theming support.
overlay
This entry type is no longer supported. Replacing it will be the main conversion work, which is described in step 7 and later.
interfaces, component, contract, category
These entries are no longer supported.
Step 3: Replace the /defaults/preferences/ folder
Most legacy extensions stored their preferences in an nsIPrefBranch, and the /defaults/preferences/ folder contained JavaScript files with default preference values. An example default preference file could look like this:
We can now use the LegacyPrefs Experiment to access existing preferences, for example the preference entry at extensions.myaddon.enableDebug can be read from any WebExtension script via:
let enableDebug =awaitbrowser.LegacyPrefs.getPref("extensions.myaddon.enableDebug");
Modern WebExtension should eventually use browser.storage.local.* for their preferences, but to simplify the conversion process, we will keep using the nsIPrefBranch for now. The very last conversion step will migrate the preferences.
Step 4: The XUL options dialog
The XUL options dialog is no longer registered, after the legacy key has been removed from manifest.json. We will use the LegacyHelper Experiment to open the XUL options dialog via a menu entry in the tools menu. Add the following to your background script:
This will be removed after the XUL options dialog has been converted to a standard WebExtension HTML options page.
Step 5: Converting locale files
Even though the LegacyHelper Experiment allows to register legacy locales, the technology itself is deprecated: WebExtension HTML pages cannot access DTD or property files. Instead, they use the i18n API to access locales stored in simple JSON files.
The localeConverter.py python script will do most of the work to convert your locale files (DTD and property files) into the new JSON format.
The new locale data can be accessed from any WebExtension script:
browser.i18n.getMessage("a-locale-string");
Step 6: Converting the XUL options page
Instead of a XUL dialog, WebExtensions use an HTML page for their options page, which will be accessible to the user through the add-on manager. The page is registered in manifest.json:
"options_ui": {"page":"options.html"}
JavaScript loaded by that options.html document can access all WebExtension APIs in the same way as for example the background script.
In this step the old XUL options dialog has to be re-created as an HTML page, using only HTML elements, JavaScript and CSS. It is no longer possible to use XUL elements. Some custom elements and 3rd party libraries to simplify this step can be found in the webext-support repository.
It may help during development, that the old XUL options page can still be opened through the tools menu.
Localisation
There is no automatic replacement of locale placeholder entities like &myLocaleIdentifier; in WebExtension HTML files any more. Instead, you can use placeholders like __MSG_myLocaleIdentifier__ in your markup and include the i18n.mjs module and automatically replace all __MSG_*__ locale placeholders on page load.
The script is using the i18n API to read the modern JSON locale files created in the previous step.
Alternative for preferencesBindings.js
The legacy XUL options page used a framework to automatically load and save preference values, controlled by the preferencesBindings.js script. That automatism does not exist for HTML option pages. But it is possible to implement a similar mechanism using a data-preference attribute:
We can loop over all elements which have such an attribute, and load their value from storage. Additionally we can attach an event listener to store the value after the input field has been changed by the user:
let prefElements =document.querySelectorAll('[data-preference]');for (let prefElement of prefElements) {let value =awaitbrowser.LegacyPrefs.getPref(`extensions.myaddon.${prefElement.dataset.preference}` );// handle checkboxesif (prefElement.tagName =="INPUT"&&prefElement.type =="checkbox") {if (value ==true) {prefElement.setAttribute("checked","true"); }// enable auto saveprefElement.addEventListener("change", () => {browser.LegacyPrefs.setPref(`extensions.myaddon.${prefElement.dataset.preference}`,prefElement.checked ); }) }}
Step 7: Find matching WebExtension entry points and WebExtension APIs
Now it's time to find out how your add-on can leverage the existing WebExtension entry points. What UI elements did you use? Do any of the supported WebExtension UI elements fit?
Even if they are not a perfect match, try to replace as many of your legacy UI entry points by WebExtension entry points:
The goal of this step is to re-create as much of the functionality of your add-on by using only WebExtension technology. Browse through the list of supported WebExtension APIs to see if any of them provide what is needed by your add-on. Check available Web APIs, there is a high chance to find simple replacements for complicated XPCOM calls:
Step 8: Creating missing UI entry points and APIs as Experiments
If certain crucial features of your add-on cannot be implemented using the available WebExtension APIs or Web APIs, you can create your own Experiment APIs.
As Experiments usually run in the main process and have unrestricted access to any aspect of Thunderbird, they are expected to require updates for each new version of Thunderbird. To reduce the maintenance burden in the future, it is in your own interest to use Experiment APIs only to the extent necessary for the add-on.
Best practice: Try to write APIs that would be useful for a wide range of add-ons, not just the one you're porting. That way, you can later on propose the API you designed for inclusion in Thunderbird, with your add-on serving as the reference implementation. If your APIs become a part of Thunderbird, you no longer need to maintain them as part of the add-on.
A basic description of Experiment APIs can be found in a separate article:
Overlay methods
Manipulating Thunderbirds UI through Experiments is historically referred to as overlaying. The basic principle of overlaying is to get hold of a native Thunderbird window object and to add or remove DOM elements (or monkey-patch functions living inside that native window to change some behaviour).
Adding or removing DOM elements can be achieved through JavaScript (note that Thunderbird sometimes still uses non-standard XUL elements, which are however slowly replaced by standard HTML elements):
A more detailed explanation of the shown code snippet is beyond the scope of this guide. It is advised to study Thunderbird's code for more details.
Generating complex and nested DOM elements through JavaScript can become cumbersome, and legacy add-ons were able to provide a simple DOM string instead. This is still possible by using the following helper function:
// Helper function to inject a legacy XUL string into the DOM of Thunderbird.// All injected elements will get the data attribute "data-extension-injected"// set to the extension id, for easy removal.constinjectElements=function (extension, window, xulString, debug =false) {functioncheckElements(stringOfIDs) {let arrayOfIDs =stringOfIDs.split(",").map((e) =>e.trim());for (let id of arrayOfIDs) {let element =window.document.getElementById(id);if (element) {return element; } }returnnull; }functionlocalize(entity) {let msg =entity.slice("__MSG_".length,-2);returnextension.localeData.localizeMessage(msg); }functioninjectChildren(elements, container) {if (debug) console.log(elements);for (let i =0; i <elements.length; i++) {if ( elements[i].hasAttribute("insertafter") &&checkElements(elements[i].getAttribute("insertafter")) ) {let insertAfterElement =checkElements( elements[i].getAttribute("insertafter") );if (debug)console.log( elements[i].tagName +"#"+ elements[i].id +": insertafter "+insertAfterElement.id );if ( debug && elements[i].id &&window.document.getElementById(elements[i].id) ) {console.error("The id <"+ elements[i].id +"> of the injected element already exists in the document!" ); } elements[i].setAttribute("data-extension-injected",extension.id);insertAfterElement.parentNode.insertBefore( elements[i],insertAfterElement.nextSibling ); } elseif ( elements[i].hasAttribute("insertbefore") &&checkElements(elements[i].getAttribute("insertbefore")) ) {let insertBeforeElement =checkElements( elements[i].getAttribute("insertbefore") );if (debug)console.log( elements[i].tagName +"#"+ elements[i].id +": insertbefore "+insertBeforeElement.id );if ( debug && elements[i].id &&window.document.getElementById(elements[i].id) ) {console.error("The id <"+ elements[i].id +"> of the injected element already exists in the document!" ); } elements[i].setAttribute("data-extension-injected",extension.id);insertBeforeElement.parentNode.insertBefore( elements[i], insertBeforeElement ); } elseif ( elements[i].id &&window.document.getElementById(elements[i].id) ) {// existing container match, dive into recursivelyif (debug)console.log( elements[i].tagName +"#"+ elements[i].id +" is an existing container, injecting into "+ elements[i].id );injectChildren(Array.from(elements[i].children),window.document.getElementById(elements[i].id) ); } else {// append element to the current containerif (debug)console.log( elements[i].tagName +"#"+ elements[i].id +": append to "+container.id ); elements[i].setAttribute("data-extension-injected",extension.id);container.appendChild(elements[i]); } } }if (debug) console.log("Injecting into root document:");let localizedXulString =xulString.replace(/__MSG_(.*?)__/g, localize );injectChildren(Array.from(window.MozXULElement.parseXULToFragment(localizedXulString, []).children ),window.document.documentElement );};
The function supports XUL strings with WebExtension __MSG_*__ locale placeholders. It also supports insertbefore or insertafter attributes, to specify where the element should be added. If an existing id is specified, the element will be added as a child inside the existing element:
A more detailed explanation of the shown code snippet is beyond the scope of this guide. The shown code is taken from the FolderFlags add-on. The Restart Experiment Example is also using this method.
Overlay strategies
In order to add custom UI entry points, the add-on has to manipulate the native window object of all already open windows/tabs and also any window/tab which is opened in the future. The two most common concepts to achieve this are described below.
Detect open windows/tabs through WebExtension APIs
This is the preferred method, since the add-on can leverage existing WebExtension APIs and reduces the amount of code which has to be maintained by the add-on developer. For example, to manipulate all message display tabs, the following code can be used in the WebExtension background script:
// Handle all already open/displayed messages.let tabs =awaitbrowser.tabs.query({ type: ["messageDisplay","mail"] })for (let tab of tabs) {let message =awaitbrowser.messageDisplay.getDisplayedMessage(tab.id);if (message) {awaitremoveAttachmentsIfJunk(tab, message); }}// React on any new message being displayed.browser.messageDisplay.onMessageDisplayed.addListener(removeAttachmentsIfJunk);asyncfunctionremoveAttachmentsIfJunk(tab, message) {// Only remove attachments, if message is junk.if (!message.junk) {return; }// browser.MessageDisplayAttachment.removeAttachments is an Experiment API,// which operates on the given tab and removes all displayed attachments.awaitbrowser.MessageDisplayAttachment.removeAttachments(tab.id);}
If the window of interest is not supported by WebExtension APIs, it is not detectable through WebExtension APIs and the detection code has to live inside an Experiment.
The following example is based on the Activity Manager Experiment Example. Its background script triggers the Experiment to register a global window listener, which manipulates the window of interest:
classActivityManagerextendsExtensionCommon.ExtensionAPI {getAPI(context) {return {// This key must match the class name. ActivityManager: {registerWindowListener() {// Register a listener for newly opened activity windows.ExtensionSupport.registerWindowListener(context.extension.id, { chromeURLs: ["chrome://messenger/content/activity.xhtml", ],onLoadWindow(window) {// Add our event listener.window._exampleAddOnClickHandler= (e) => {console.log("The button was clicked, let's do something!") }window.document.getElementById("clearListButton").addEventListener("click",window._exampleAddOnClickHandler ); }, }); }, }, }; }onShutdown(isAppShutdown) {if (isAppShutdown) {return; }// Remove our event listener.const { extension } =this;for (let window ofExtensionSupport.openWindows) {if (["chrome://messenger/content/activity.xhtml", ].includes(window.location.href)) {// Remove our event listener.window.document.getElementById("clearListButton").removeEventListener("click",window._exampleAddOnClickHandler );deletewindow._exampleAddOnClickHandler; } }// Unregister our listener for newly opened windows.ExtensionSupport.unregisterWindowListener(extension.id); }}
Custom WebExtension events
So far we have only discussed Experiments which perform a direct action inside the Experiment implementation. To move as much code out of the Experiment implementation, we can trigger a standard WebExtension event and let any follow-up action be handled by the WebExtension.
A common use case is a custom button added to Thunderbird's UI through an Experiment. The action which is triggered by clicking on the button should not be handled in the Experiment, but by the WebExtension background script, which has registered a listener for that button being pressed. For this to work we need to define an EventEmitter in the Experiment:
// An EventEmitter has the following basic functions:// * EventEmitter.on(emitterName, callback): Registers a callback for a// custom emitter.// * EventEmitter.off(emitterName, callback): Unregisters a callback for a// custom emitter.// * EventEmitter.emit(emitterName, ...args): Emit a custom emitter, all// provided args will be forwarded to the registered callbacks.constemitter=newExtensionCommon.EventEmitter();
The boilerplate, which connects the internals of a WebExtension event to the defined EventEmitter, is the added EventManger in lines 5-21 of the following example. The glue part to actually trigger the event is
So far we stored our preferences in an nsIPrefBranch, which could be accessed from WebExtension scripts through the LegacyPrefs Experiment, and from other Experiments directly through the nsIPrefBranch.
WebExtensions should eventually store their preferences in browser.storage.local.*, which removes the data when the add-on is uninstalled. The user should be able to start fresh by uninstalling and reinstalling an extension, if a specific configuration causes the add-on to malfunction. This is a common pattern, which however does not work for preferences stored in an nsIPrefBranch, as they are not cleared on add-on uninstall.
The user actually expects that all his data associated with a certain add-on is removed from the Thunderbird profile, when the add-on is removed. An add-on can of course offer import and export functions.
Accessing preferences in custom Experiments
In order to migrate preferences, your custom Experiments may no longer access the nsIPrefBranch directly, as they later cannot access the migrated values in browser.storage.local.*. All your custom Experiments must be independent of the used storage. The two most common strategies are outlined below.
Passing preferences as function parameters
Consider a simple debug log in an Experiment function, which used to query the extensions.myaddon.enableDebug preference directly:
fancyExperimentFunction:asyncfunction () {// Be verbose during development.let debug =Services.prefs.getBoolPref("extensions.myaddon.enableDebug");if (debug) {console.log(`This is a fancy Experiment function`); }// Do something ...}
The function can receive the debug flag as a parameter:
fancyExperimentFunction:asyncfunction (debug) {// Be verbose during development.if (debug) {console.log(`This is a fancy Experiment function`); }// Do something ...}
In the WebExtension script calling that method, we continue (for now) to use the LegacyPrefs Experiment to retrieve the value for the enableDebug preference before passing it to the Experiment:
let debug =awaitbrowser.LegacyPrefs.getPref("extensions.myaddon.enableDebug");awaitbrowser.fancyExperiment.fancyExperimentFunction(debug);
Keeping a local preference cache in the Experiment
Cached preferences can be accessed everywhere inside the Experiment implementation. A simple implementation could be:
"use strict";// Using a closure to not leak anything but the API to the outside world.(function (exports) {constcachedPreferences=newMap();constcachedDefaults=newMap();functiongetPref(prefName) {returncachedPreferences.get(prefName) ??cachedDefaults.get(prefName) }classExperimentWithPreferenceCacheextendsExtensionCommon.ExtensionAPI {getAPI(context) {return { ExperimentWithPreferenceCache: {updatePreference(prefName, currentValue, defaultValue) {// Update the local preference cache, which can be accessed from// everywhere inside this Experiment implementation.if (currentValue !==null) {cachedPreferences.set(prefName, currentValue); } else {cachedPreferences.delete(prefName); }if (defaultValue !==null) {cachedDefaults.set(prefName, defaultValue); } },fancyFunction:asyncfunction () {// Be verbose during development if indicated by the cached preference.if (getPref("enableDebug")) {console.log(`This is a fancy Experiment function`); }// Do something ... } }, }; } };// Export the API by assigning it to the exports parameter of the anonymous// closure function, which is the global this.exports.ExperimentWithPreferenceCache = ExperimentWithPreferenceCache;})(this)
In the background script we have to monitor the extensions.myaddon.* preference branch and update the cache if needed. The LegacyPrefs Experiment provides an onChanged event for that purpose:
// Cache initial values.for (let [prefName, defaultValue] ofObject.entries(DEFAULTS)) {let currentValue =awaitbrowser.LegacyPrefs.getUserPref(`extensions.myaddon.${prefName}` );awaitbrowser.ExperimentWithPreferenceCache.updatePreference( prefName, currentValue, defaultValue, );}// Update cache if user value changed.browser.LegacyPrefs.onChanged.addListener(async (prefName, newValue) => {awaitbrowser.ExperimentWithPreferenceCache.updatePreference( prefName, newValue,null,// default value is not modified );},"extensions.myaddon.");
Migration strategy
The last step is to move all preferences into browser.storage.local.* and update all WebExtension scripts to no longer use the LegacyPrefs Experiment. The preferences.mjs module can be used as a drop-in replacement for the LegacyPrefs Experiment. Add the following to the top of your background script:
Move the definition of the DEFAULTS object from the top of your background script into your copy of the preferences.mjs module.
Remove all code which used browser.LegacyPrefs.setDefaultPref() and update all other calls to access your preferences through the LegacyPrefs Experiment by the matching method of the preferences.mjs module.
The preference caching mechanism for Experiments can be updated as follows:
// Cache initial values.for (let { prefName, defaultValue } ofprefs.getDefaults()) {let currentValue =awaitprefs.getUserPref(prefName);awaitbrowser.ExperimentWithPreferenceCache.updatePreference( prefName, currentValue, defaultValue, );}// Update cache if user value changed.browser.storage.local.onChanged.addListener(async (changes) => {for (constprefNameofObject.keys(changes)) {awaitbrowser.ExperimentWithPreferenceCache.updatePreference( prefName, changes[prefName].newValue,null,// default value is not modified ); }});
Wait about 6-12 months after the migration code has been shipped to your users, before removing the migration code and the LegacyPrefs Experiment.