Browser Extension with Blazor WASM - Cross-Browser Compatibility

Justin Yoo - Sep 5 '22 - - Dev Community

In my previous post, I discussed the JavaScript interop feature in Blazor WebAssembly (WASM) and how to leverage it for the Chrome extension development. In this post, I'm going to expand the same extension to support cross-browser compatibility, like Mozilla Firefox, and what to consider for compatibility.

You can download the sample Chrome extension from this GitHub repository:

GitHub logo devkimchi / blazor-wasm-chrome-extension

This provides sample codes for a Chrome Extension app built on Blazor WASM

Blazor WASM Browser Extension Sample

This provides sample codes for a cross-browser extension app built on Blazor WASM. This sample app originally started for building a Chrome extension with Blazor WASM, but it now does the cross-browser support including Chromium-based browsers and Mozilla FireFox.

Acknowledgement

This sample code includes Mozilla's WebExtension browser API Polyfill, which is licensed under MPL 2.0.

Getting Started

  1. Build the app

    dotnet build .
    Enter fullscreen mode Exit fullscreen mode
  2. Publish the app

    dotnet publish ./src/ChromeExtensionV2/ -c Release -o published
    Enter fullscreen mode Exit fullscreen mode
  3. Run PowerShell script

    ./Run-PostBuild.ps1
    Enter fullscreen mode Exit fullscreen mode
  4. Register the extension to your Chromium-based browser like Chrome or Edge, or Mozilla FireFox.

  5. Visit any website on https://developer.chrome.com, https://developer.mozilla.org or https://docs.microsoft.com.

  6. Run the extension by clicking the icon at the top of your web browser.

Browser Extension Polyfill

The Browser Extension Working Group at W3.org proposes the web standards based on the Chrome extension manifest, which supports all web browsers. Based on that proposal, Mozilla has released the Browser Extension Polyfill library that supports the modern promise pattern instead of callback. Therefore, if you import this polyfill library, theoretically, your Chrome extension quickly turns into the browser extension that runs on multiple browser engines.

Therefore, add the polyfill library through CDN to index.html like below. The link takes the latest release of the polyfill.

<!DOCTYPE html>
<html lang="en">
...
<body>
    <div id="app">Loading...</div>
    ...
    <!-- ⬇️⬇️⬇️ Add this line ⬇️⬇️⬇️ -->
    <script src="https://unpkg.com/browse/webextension-polyfill/dist/browser-polyfill.min.js"></script>
    <!-- ⬆️⬆️⬆️ Add this line ⬆️⬆️⬆️ -->
    <script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

However, you can't use the direct link to the polyfill because of the "Content Security Policy" violation error.

Refused to load script

In order to sort out this issue, you should download the JavaScript file and give its local reference. Visit the CDN page and download each files and save them into the wwwroot/js/dist directory. Then, update the index.html file like below:

<!DOCTYPE html>
<html lang="en">
...
<body>
    <div id="app">Loading...</div>
    ...
    <!-- ⬇️⬇️⬇️ Add this line ⬇️⬇️⬇️ -->
    <script src="js/dist/browser-polyfill.min.js"></script>
    <!-- ⬆️⬆️⬆️ Add this line ⬆️⬆️⬆️ -->
    <script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Now, it's OK to use the polyfill. Next, let's move on to manifest.json for cross-browser support.

manifest.json Accommodation

First of all, you need to get rid of Chrome's exclusive features. Especially, as the Declarative Content part only works for the Chrome extensions, you need to replace it with other approaches. In this example, if you want the extension to only work with the domain like developer.chrome.com, developer.mozilla.org or docs.microsoft.com, add domain URLs to the permissions collection like below and remove the declarativeContent value from it.

{
  ...

  "permissions": [
    "*://developer.chrome.com/*",
    "*://developer.mozilla.org/*",
    "*://docs.microsoft.com/*",
    "activeTab",
    // "declarativeContent",
    "storage"
  ],

  ...
}
Enter fullscreen mode Exit fullscreen mode

Then, add the polyfill to the background script collection.

{
  ...

  "background": {
    "scripts": [
      "js/dist/browser-polyfill.min.js",
      "js/background.js"
    ],
    "persistent": false
  },

  ...
}
Enter fullscreen mode Exit fullscreen mode

Initially, manifest.json only uses the options_page attribute, but for compatibility, add the options_ui attribute as well.

{
  ...

  "options_page": "options.html",

  "options_ui": {
    "page": "options.html",
    "browser_style": true
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

And finally, Mozilla-based browsers need a unique ID for each extension. Therefore, give the browser_specific_settings for it.

{
  ...

  "browser_specific_settings": {
    "gecko": {
      "id": "browser-extension-sample@devkimchi.com"
    }
  },

  ...
}
Enter fullscreen mode Exit fullscreen mode

Updating manifest.json is over. Now, let's move on to the JavaScript files.

background.js Update

For background.js, you must change all the chrome. instances to the browser. ones because the chrome instances are specific to the Chrome extension, while the browser instances are for the browser extensions in general. In other words, if your background.js looks like the following:

chrome.runtime.onInstalled.addListener(function() {
  chrome.storage.sync.set({color: '#3aa757'}, function() {
    console.log("The color is green.");
  });

  chrome.declarativeContent.onPageChanged.removeRules(undefined, function() {
    chrome.declarativeContent.onPageChanged.addRules([{
      conditions: [
        new chrome.declarativeContent.PageStateMatcher({
          pageUrl: { hostEquals: 'developer.chrome.com' },
        }),
        new chrome.declarativeContent.PageStateMatcher({
          pageUrl: { hostEquals: 'docs.microsoft.com' },
        })
      ],
      actions: [new chrome.declarativeContent.ShowPageAction()]
    }]);
  });
});
Enter fullscreen mode Exit fullscreen mode

All chrome. declared above must be replaced with browser. like below:

// Use 'browser.' instead of 'chrome.'
browser.runtime.onInstalled.addListener(function() {
  // Use 'browser.' instead of 'chrome.'
  browser.storage.sync.set({color: '#3aa757'}, function() {
    console.log("The color is green.");
  });

  // Use 'browser.' instead of 'chrome.'
  browser.declarativeContent.onPageChanged.removeRules(undefined, function() {
    // Use 'browser.' instead of 'chrome.'
    browser.declarativeContent.onPageChanged.addRules([{
      conditions: [
        // Use 'browser.' instead of 'chrome.'
        new browser.declarativeContent.PageStateMatcher({
          pageUrl: { hostEquals: 'developer.browser.com' },
        }),
        // Use 'browser.' instead of 'chrome.'
        new browser.declarativeContent.PageStateMatcher({
          pageUrl: { hostEquals: 'docs.microsoft.com' },
        })
      ],
      // Use 'browser.' instead of 'chrome.'
      actions: [new browser.declarativeContent.ShowPageAction()]
    }]);
  });
});
Enter fullscreen mode Exit fullscreen mode

Once you replace all the chrome instances with browser ones, change all the callback patterns to promise patterns. For example, this example requires the browser's local storage access. How can it be changed?

  // Before
  browser.storage.sync.set({color: '#3aa757'}, function() {
    console.log("The color is green.");
  });

  // After
  browser.storage.sync.set({color: '#3aa757'})
  .then(() => {
    console.log("The color is green.");
  });
Enter fullscreen mode Exit fullscreen mode

It's written as an anonymous function and added as the event handler to the onInstalled event. Therefore, it's better to create an independent function like the below:

function handleRuntimeOnInstalled(details) {
  browser.storage.sync.set({color: '#3aa757'})
  .then(() => {
    console.log("The color is green.");
  });
}
Enter fullscreen mode Exit fullscreen mode

As mentioned above, we can't use the Declarative Content feature any longer. Hence, instead of the events that belong to the declarativeContent property, you need other events and event handlers. This sample extension only works in specific domains like developer.google.com, developer.mozilla.org or docs.microsoft.com, and the declarativeContent property performs this sort of detection. Therefore, replace it with the handleTabs() function like below, using the pageAction property.

function handleTabs() {
  browser.tabs.query({active: true, currentWindow: true})
  .then((tabs) => {
    console.log(tabs[0].url);

    let matched = tabs[0].url.includes('developer.chrome.com') || tabs[0].url.includes('developer.mozilla.org') || tabs[0].url.includes('docs.microsoft.com');
    if (matched) {
      browser.pageAction.show(tabs[0].id);
    } else {
      browser.pageAction.hide(tabs[0].id);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Once everything is done, register both event handlers for the events below:

browser.runtime.onInstalled.addListener(handleRuntimeOnInstalled);
browser.tabs.onActivated.addListener(handleTabs);
browser.tabs.onHighlighted.addListener(handleTabs);
browser.tabs.onUpdated.addListener(handleTabs);
Enter fullscreen mode Exit fullscreen mode

popup.js Update

Let's update popup.js. It was initially like this:

let changeColor = document.getElementById('changeColor');

chrome.storage.sync.get('color', function(data) {
  changeColor.style.backgroundColor = data.color;
  changeColor.setAttribute('value', data.color);
});

changeColor.onclick = function(element) {
  let color = element.target.value;
  chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
    chrome.tabs.executeScript(
        tabs[0].id,
        {code: 'document.body.style.backgroundColor = "' + color + '";'});
  });
};
Enter fullscreen mode Exit fullscreen mode

Like the same in the previous JavaScript file,

  1. Replace all chrome. instances with browser. ones.
  2. Change all callback patterns to the promise patterns.
let changeColor = document.getElementById('changeColor');

// Use 'browser.' instead of 'chrome.'
// Use the promise pattern
browser.storage.sync.get('color')
.then((data) => {
  changeColor.style.backgroundColor = data.color;
  changeColor.setAttribute('value', data.color);
});

changeColor.onclick = function(element) {
  let color = element.target.value;

  // Use 'browser.' instead of 'chrome.'
  // Use the promise pattern
  browser.tabs.query({active: true, currentWindow: true})
  .then((tabs) => {
    let matched = tabs[0].url.includes('developer.chrome.com') || tabs[0].url.includes('developer.mozilla.org') || tabs[0].url.includes('docs.microsoft.com');
    if (matched) {
      // Use 'browser.' instead of 'chrome.'
      browser.tabs.executeScript(
        tabs[0].id,
        {code: 'document.body.style.backgroundColor = "' + color + '";'});
    } else {
      console.log('URL not matched');
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

The popup.js is done.

options.js Update

This time, it's the turn for options.js, which initially looks like:

let page = document.getElementById('buttonDiv');

const kButtonColors = ['#3aa757', '#e8453c', '#f9bb2d', '#4688f1'];

function constructOptions(kButtonColors) {
  for (let item of kButtonColors) {
    let button = document.createElement('button');
    button.className = 'color-button';
    button.style.backgroundColor = item;
    button.style.padding = '10px';

    button.addEventListener('click', function() {
      chrome.storage.sync.set({color: item}, function() {
        console.log('color is ' + item);
      })
    });
    page.appendChild(button);
  }
}

constructOptions(kButtonColors);
Enter fullscreen mode Exit fullscreen mode
  1. Replace all chrome. instances with browser. ones.
  2. Change all callback patterns to the promise patterns.
let page = document.getElementById('buttonDiv');

const kButtonColors = ['#3aa757', '#e8453c', '#f9bb2d', '#4688f1'];

function constructOptions(kButtonColors) {
  for (let item of kButtonColors) {
    let button = document.createElement('button');
    button.className = 'color-button';
    button.style.backgroundColor = item;
    button.style.padding = '10px';

    button.addEventListener('click', function() {
      // Use 'browser.' instead of 'chrome.'
      // Use the promise pattern
      browser.storage.sync.set({color: item})
      .then(() => {
        console.log('color is ' + item);
      })
    });
    page.appendChild(button);
  }
}

constructOptions(kButtonColors);
Enter fullscreen mode Exit fullscreen mode

The options.js file is updated.

Blazor Component Abstraction

Suppose you want to get rid of importing the browser polyfill from index.html and import it directly from the Blazor components. In that case, it's a good idea to create a common page component that each Popup.razor and Options.razor can inherit. First of all, declare a LoadAdditionalJsAsync() method that is called within the OnAfterRenderAsync(...) method.

public class PageComponentBase : ComponentBase
{
    protected abstract Task LoadAdditionalJsAsync();
}
Enter fullscreen mode Exit fullscreen mode

Then, invoke the method at the end of OnAfterRenderAsync(...). By doing so, the OnAfterRenderAsync(...) method first imports the js/main.js followed by the browser polyfill script, then import the page-specific JavaScript files.

public class PageComponentBase : ComponentBase
{
    [Inject]
    private IJSRuntime JS { get; set; }

    protected IJSObjectReference Module { get; private set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        this.Module = await this.JS.InvokeAsync<IJSObjectReference>("import", "./js/main.js");

        var src = "js/dist/browser-polyfill.min.js";
        await this.Module.InvokeVoidAsync("loadJs", src);

        // Invoke the page-specific JavaScript loader
        await this.LoadAdditionalJsAsync();
    }

    protected abstract Task LoadAdditionalJsAsync();
}
Enter fullscreen mode Exit fullscreen mode

With the Popup.razor page, inherit the PageComponentBase class and implement the LoadAdditionalJsAsync() method like below, which imports the js/popup.js file. You don't need the IJSRuntime instance as a dependency any longer, so remove it.

@page "/popup.html"

@* @inject IJSRuntime JS *@

@using ChromeExtensionV2.Components
@inherits PageComponentBase

...

@code {
    protected override async Task LoadAdditionalJsAsync()
    {
        var src = "js/popup.js";
        await this.Module.InvokeVoidAsync("loadJs", src);
    }
}
Enter fullscreen mode Exit fullscreen mode

Similarly, the Options.razor page also inherits the PageComponentBase class and implement the LoadAdditionalJsAsync() method to import js/options.js. The IJSRuntime dependency is no longer necessary, either.

@page "/options.html"

@* @inject IJSRuntime JS *@

@using ChromeExtensionV2.Components
@inherits PageComponentBase

...

@code {
    protected override async Task LoadAdditionalJsAsync()
    {
        var src = "js/options.js";
        await this.Module.InvokeVoidAsync("loadJs", src);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run-PostBuild.ps1 Adjustment

Unlike the Chrome extensions, the Mozilla-based web extension needs a .zip file for installation. Therefore, the PowerShell script, Run-PostBuild.ps1, requires an additional step to generate a .zip file as an artifact.

Compress-Archive -Path ./published/wwwroot/* -DestinationPath ./published/wwwroot/wwwroot.zip -Force
Enter fullscreen mode Exit fullscreen mode

Once you complete all the steps above, build the project and run the PowerShell script, and it will generate the wwwroot.zip file under the published directory. Next, install the zip file to Firefox, and you will see the screen below:

Browser extension on FireFox #1

You can see the options page as a pop-up modal due to the manifest.json update. In the options modal, change the background colour to yellow.

Browser extension options on FireFox

Then, you are able to change the background colour to yellow.

Browser extension on FireFox #2


So far, we've walked through how our Blazor WASM-based chrome extension can support cross-browser compatibility and showed how it works on the Firefox browser.

Do you want to know more about Blazor?

Here are some tutorials for you.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .