Things I learned from migrating a Chrome extension to Firefox using WebExtensions

I've been developing a browser extension called Copy as Markdown (Chrome, Firefox) for many years. It was started when I was blogging on Jekyll, a Markdown-based blogging system. Recently I rewrote that extension to support the upcoming Firefox 57, which deprecates the add-on SDK I used to build my extension on.

The new SDK on Firefox 57 implements WebExtensions, a W3C Standard for web browser extensions. WebExtensions is mostly based on Chrome’s Extension API. I don’t know much about the history of the standard war, but the old SDK on Firefox, Jetpack, in my opinion is easier to learn than Chrome’s.

Although I already have a Chrome version which is mostly based on the WebExtensions-like API, it doesn’t mean that the same source code works on Firefox. There are some pitfalls I encountered during the re-development. In fact, this project eventually grew into a practice on how to write once and build extensions for different browsers.

The full source code is available at chitsaou/copy-as-markdown. Please check the code if you’re interested, and feel free to give me any feedbacks.

Callback vs Promise

If you check MDN’s WebExtensions Chrome compatibility article, the first thing you’ll notice is the difference of function signatures: Chrome uses callback, and Firefox uses Promise.

For example, the [chrome|browser].runtime.sendMessage API, which sends message from popup window to the process the extension is running, has different usage between two browsers:

// In Chrome
chrome.runtime.sendMessage(payload, (response) => {
  // ...
})

// In Firefox
browser.runtime.sendMessage(payload).then(response => {
  // ...
})

Although Firefox accepts callback on functions that supports Promise, I still feel it easier to do things with Promise. But Chrome does not return Promise on those function calls. What’s worse, Chrome uses chrome as the global API entrypoint, while Firefox uses browser but supports chrome.

Fortunately Mozilla provides a polyfill library to do all the dirty jobs: mozilla/webextensions-polyfill. As the name states, it makes Chrome to support browser object, and make all the functions calls return Promise.

Now I can just write browser.* and use the nice Promise-based APIs that Firefox recommends. Perfect!

By the way, if you submit an add-on with this polyfill library, Firefox Add-On developer dashboard will warn you about unnecessary library. But it can still be published without removing the library.

Clipboard Access

Copy as Markdown helps you generate Markdown code for links and images, and copy it to the clipboard, so clipboard access is the most important part.

Unfortunately, if you want to do copy in the extension environment, for now there is no API allows you to do so with a single function call. The only way is this:

// create a <textarea>
let textbox = document.createElement("textarea")
document.body.appendChild(textbox)

// set content
textbox.value = "text you want to copy"

// select all texts and execute 'copy'
textbox.select()
document.execCommand('Copy')

Further more, in Firefox, you can only do this in content script or popup window. The background of this limitation is that, in order to create a textarea, it must be attached to a document. In WebExtensions, the core code of extension is running in a “Background Page”, a web page that get started when extension is loaded. If you open Task Manager in Chrome, you’ll see each extension running as a process, and they’re actually the Background Page of each extension.

There are many limitations on what you can do in Background Page, and some are browser-specific. In Firefox, one thing you can’t do is document.execCommand() , because the event must be dispatched from a user interaction, as the MDN article Interact with the clipboard says. So say you want to make an extension that shortens the current tab, the easiest way to do so is using a Browser Action Popup.

So how can we solve this? The answer is Content Script. Even you cannot copy text in Background Page, you can still do so if you inject a Content Script into the current page. The only downside of this solution is, you cannot inject Content Script into certain pages for security reason, such as Firefox Add-on website, and all the Firefox internal about:* pages. Therefore, Copy as Markdown for Firefox sometimes doesn’t work if your current tab is a Firefox-restricted page.

Permission

In addition, to enable clipboard copy in Firefox, a permission is required in manifest.json:

"permissions": [
  "clipboardWrite",
],

This permission is required if you want to copy something in Firefox, but not required for Chrome — you can copy text in Chrome even if this permission is not specified — . In fact, when I (accidentally) released the first beta for Chrome, some user reported that there are “extra permissions required”, because I added clipboardWrite in Chrome build. Although there is nothing more I requested for the new version, it still scared the users. So then I decided to remove it from manifest.json . After all we don’t need that.

Now in the code I can do such switch: (see full source code here)

import copyByBackground from "../../lib/clipboard.js"

function copyByContentScriptWithTabWrapping(text, tab) {
  if (!tab) {
    return browser.tabs.getCurrent()
      .then(tab => copyByContentScript(text, tab))
  } else {
    return copyByContentScript(text, tab)
  }
}

let copyText = null;

if (ENVIRONMENT.CAN_COPY_IN_BACKGROUND) {
  copyText = copyByBackground
} else {
  copyText = copyByContentScriptWithTabWrapping
}

As you can see there is a ENVIRONMENT.CAN_COPY_IN_BACKGROUND. How do I feature-detect if the browser supports copy in Background Page or not? Well, I don’t. Instead I build separate targets and specify their behavior, as described in “Webpack” section below.

Native Popup Style

In Firefox Jetpack, there was no popup window that you can display when user clicks a button on toolbar. — Well, at least there was no such thing when I was building my first Jetpack add-on. — But in WebExtensions framework, there is a popup, and there is a CSS framework for you to make it look native. All you have to do is enable it in manifest.json :

"browser_action": {
  "browser_style": true
}

The style it applies is basically the same as Firefox Style Guide. I use samples from Navigations to make menu items in the popup look native.

Copy as Markdown shows different popup styles on Chrome (left) and Firefox (right)

For Chrome, there is no such CSS framework you can use, but I’ve been using a custom style for a while. The first thing I have to do is to change some CSS selectors. But the hard part is how to load my custom CSS in Chrome. Fortunately, we can use a common practice from the web development world: load the CSS file anyways, namespace the CSS selectors, and append body-level class conditionally.

First, the CSS file is always loaded, no matter what browser it is. It’s loaded locally, so doesn’t cost network bandwidth. (Conditionally inserting <style> to load CSS dynamically does not re-draw the page, though.)

In CSS, namespace everything so that it won’t match until the top-level element has some class:

.custom-popup-style .panel-list-item {
  /* ... */
}

.custom-popup-style .panel-list-item:hover {
  /* ... */
}

In popup page’s JavaScript, change the <body>'s class according to a environment variable:

if (!ENVIRONMENT.SUPPORTS_POPUP_BROWSER_STYLE) {
  document.body.classList.add("custom-popup-style")
}

That’s all. Thanks to all the responsive web designers for this tip.

The ENVIRONMENT.SUPPORTS_POPUP_BROWSER_STYLE is also done by Webpack, which will be covered below.

Webpack for Multiple Targets

As I mentioned in the previous sections, Copy as Markdown is now built by Webpack. Although you can develop an extension without Webpack, it still brings some benefits such as module loading and separating environments for browsers.

Module Loading

Separating code by module makes it easier for maintenance. All you need to do is pack scripts into single files, such as background.js , popup.js and content-script.js, and reference them in manifest.json.

You may heard that ES6 module is available in the latest Chrome, but unfortunately it is not available in Chrome Extension environment, because it requires HTTP server to return application/javascript MIME Type, but Chrome extension does not do so for JavaScript files (yet?).

But even if ES6 module is fully supported, there are still some other benefits we can get from Webpack.

Defining Environments

As mentioned above, the extension needs to know whether the browser supports two features: copy in background page, and popup style.

At first I thought it makes more sense to do a real feature detection, like:

function canCopyInBackground() {
  try {
    // ... setup textbox
    document.execCommand('copy')
    return true
  } catch {
    return false
  }
}

But it doesn’t work, because document.execCommand does not throw error when it fails. If I instead validate it by checking the clipboard content, that brings critical privacy issues.

Now because this extension is only shipped to 2 platforms: Firefox and Chrome, and I can lock supported browser versions during publishing, why not just build two packages for those two browsers?

With Webpack it’s easy to do so, just use webpack.DefinePlugin and load different environment files according to command line environment variable.

Separate manifest.json

Besides, we can also separate manifest.json for different browsers. As mentioned above, in manifest.json there are some different keys and values between two browsers. Some keys trigger warnings, some would even trigger permission alert to users. My solution is make two files manifest.firefox.json and manifest.chrome.json , and conditionally copy one of them to the target directory, with copy-webpack-plugin.

Although it may make more sense to use shared manifest.json and modify the JavaScript object dynamically during build process, I didn’t find a good plugin doing this well, and I feel that maintaining two manifest files is a good solution for now. I’ll leave the issue there until there is a 4th browser to support.

One of the benefits of doing so is that, Chrome Web Store and Firefox Add-On have different rules on version numbers and names (described below). This fact makes it hard to use the same version number between Chrome version and Firefox version. Separating manifest.json makes it easier to set different version numbers that are compatible to different publishing platforms.

Publishing on Stores

Firefox: Keep the Original Add-on ID

Once you finished switching to WebExtensions, one thing you need to do is to keep the original Add-on ID from previous version. To find it, just visit the add-on dashboard. Then specify it in the new add-on’s manifest file:

"applications": {
  "gecko": {
    "id": "jid1-xxxxxxxxxxxxxxxx@jetpack"
  }
}

Those jid and jetpack terms are from previous Add-on SDK Jetpack. You have to keep to same ID so that Firefox Add-on can recognize it as the same extension.

However, this key is invalid in Chrome, so, another reason to use two different manifest files.

Version Name Incompatibility

These issues are not directly related to framework switching, but a problem that exists for a long time. If you’re new to Chrome Web Store, then this section may be helpful.

On Chrome Web Store, all versions must contain only digits. For example 1.2.3 is valid, but 1.2.3rc1 is invalid. On Firefox Add-ons, they accept beta versions like 1.2.3rc1 or 1.2.3beta, and it will recognize these as “Development Version”, so you can ask your friends to test it before release.

For Chrome, there is another version_name to display a different version name in Chrome’s Extensions page. While I am doing public testing, the version I used for Firefox Add-ons is "version": "2.0.0rc1" , but for Chrome I have to use "version": "2.0.0", "version_name": "2.0.0rc1" . Firefox does not allow version_name manifest key, though.

What’s worse, Chrome strictly check if newly uploaded extension is versioned greater than the previous one. Say you want to publish a beta version, you cannot publish a 2.0.0rc1 . Instead you can use 1.99.0 . If you accidentally published 2.0.0 as a development version, like me, the next version can only be 2.0.1 or “greater”.

That’s why my final release version becomes 2.1.0. Even if I want to publish a “stable release of 2.0.0” I can never do so because I have already uploaded a 2.0.0 to Chrome Web Store.

Beta Testing

Finally, there is no “Beta Testing Channel” in Chrome Web Store. Well, there is, but it’s too complex for me to figure out how to setup a test user group.

On Firefox Add-ons, there is a Development Channel for beta testers. If the add-on contains version with beta or rc it will be recognized as development version, and published into Development Channel. This is a big plus to add-on developers.

In fact, I accidentally released a broken version 2.0.0 on Chrome Web Store, which I thought it won’t be published due to misleading copies. There is no way to take down a version, and no way to “revert” to older version. All I could do is check out to older version in Git repository, increase version number to 2.0.1 (due to strict restriction on incremented version number), set version_name to something like reverted to older version, zip it, and upload to Chrome Web Store. Sounds crazy, huh?

There are many things that Chrome Web Store could be improved. I just heard that they’re rebuilding the developer dashboard recently. Looking forward to see the new platform.

Conclusion

WebExtensions is a utopia for browser extension developers. Write once, run everywhere. But different browser vendors may have different preferences on things like API design, features and security concerns. I tried to run my extension on Edge, unfortunately it failed to even get loaded.

This is another example of browser diversity, but it’s much harder to develop something on it, due to limitation of web API supports, and tools / libraries we can use. I believe that in the foreseeable future, browser vendors can accomplish the ideal goal, but for now, we still need to handle many browser-specific issues.

By the way, when will Safari support WebExtensions?