Expanding the browser experience with web extensions

By Chen Hui Jing / @huijing.bsky.social

🇲🇾 👾 🏀 🚲 🖌️ 👟 💻 🖊️ 🎙 🐈‍⬛ 🧗 🏳️‍🌈
Chen Hui Jing
Jing
@huijing.bsky.social
Interledger Foundation
Firefox extensions Chrome extensions Safari extensions Opera extensions

Plug-ins

Image credit: How to Install Java Plugin for Google Chrome Browser in Windows 7

User stylesheets

Illustration of different cascade origins
§ 6.2. Cascading Origins

Each style rule has a cascade origin, which determines where it enters the cascade. CSS defines three core origins:

Author Origin: The author specifies style sheets for a source document according to the conventions of the document language.

User Origin: The user may be able to specify style information for a particular document.

User-Agent Origin: Conforming user agents must apply a default style sheet (or behave as if they did).

Bookmarklets

Illustration of JS that can go into the URL field of a bookmark
Screenshot of IE4
Firefox extensions Chrome extensions Safari extensions Opera extensions

manifest.json

                {
  "manifest_version": 3,
  "name": "Awesome Extension",
  "version": "1.0.0",
}
              

"manifest_version", "version", and "name" are the only mandatory keys.

See manifest.json page on MDN for full list of keys

Firefox extensions
Image source: Anatomy of an extension

AE1: Does nothing

Folder structure:

.
  └── AE1/
      ├── manifest.json
      ├── nothing.html
      └── icons/
          ├── icon32.png
          └── icon48.png

nothing.html:

<html>
  <body>
    <h1>Nothing</h1>
  </body>
</html>

Icons:

48px icon 32px icon
              {
  "manifest_version": 3,
  "name": "AE1",
  "version": "1.0",

  "description": "This extension doesn't actually do anything",
  "icons": {
    "32": "icons/icon32.png",
    "48": "icons/icon48.png"
  },

  "action": {
    "default_popup": "nothing.html"
  }
}
about:debugging#/runtime/this-firefox

Open the about:debugging page, click the This Firefox option, click the Load Temporary Add-on button, then select any file in your extension's directory.

The extension now installs, and remains installed until you restart Firefox.

chrome://extensions

Enable Developer Mode by clicking the toggle switch next to Developer mode.

Click the Load unpacked button and select the extension directory.

AE1.1: Does a tiny something

nothing.html:

<html>
  <body>
    <h1>Nothing</h1>
    <button>Something</button>
  </body>
  <style>
    body { text-align: center }
  </style>
  <script src="nothing.js"></script>
</html>

nothing.js:

document.querySelector("button").addEventListener("click", () => {
  document.querySelector("h1").style.color = "tomato";
});

Content scripts

  1. Static declaration

    Register the script using the content_scripts key in your manifest.json. This automatically loads the script on pages that match the specified pattern.

  2. Dynamic declaration

    The script is registered with the browser via the scripting.registerContentScripts() method. The difference with the static declaration method is you can add or remove content scripts at runtime.

  3. Programatic injection

    Load the script via the scripting.executeScript() method into a specific tab based on particular triggers, e.g. an user action.

Content Script Environment

Firefox

Xray vision in Firefox

  • The global scope (globalThis) is composed of standard JavaScript features as usual, plus window as the prototype of the global scope.
  • Most DOM APIs are inherited from the page through window, through Xray vision to shield the content script from modifications by the web page.
  • A content script may encounter JavaScript objects from its global scope or Xray-wrapped versions from the web page.

Chrome

Isolated worlds in Chrome

  • An isolated world is a private execution environment that isn't accessible to the page or other extensions.
  • The global scope is window, and the available DOM APIs are generally independent of the web page (other than sharing the underlying DOM).
  • Content scripts cannot directly access JavaScript objects from the web page.

AE2: Click button, change page

Folder structure:

.
└── AE2/
    ├── content.css
    ├── content.js
    ├── manifest.json
    ├── pixel.html
    ├── pixel.js
    ├── icons/
    │   ├── icon32.png
    │   └── icon48.png
    └── images/
        ├── pixel-adventure-time.png 
        ├── pixel-cat.jpg 
        ├── pixel-city.png 
        └── pixel-zen-garden.png
              {
  "manifest_version": 3,
  "name": "AE2",
  "version": "1.0",
  "description": "Activate pixel art",
  "icons": {
    "32": "icons/icon32.png",
    "48": "icons/icon48.png"
  },
  "permissions": ["activeTab", "scripting"],
  "action": {
    "default_popup": "pixel.html"
  },
  "web_accessible_resources": [
    {
      "resources": [
        "images/pixel-adventure-time.png",
        "images/pixel-cat.jpg",
        "images/pixel-city.png",
        "images/pixel-zen-garden.png"
      ],
      "extension_ids": ["*"],
      "matches": ["*://*/*"]
    }
  ]
}

AE2: Click button, change page

pixel.html:

<html>
  <head>
    <meta charset="UTF-8">
    <style>
      body { text-align: center }
      h1 { white-space: nowrap }
      button:first-of-type { margin-block-end: 0.5em }
    </style>
  </head>
  <body>
    <h1>Pixel-time</h1>
    <button id="pixelate">Pixelate</button>
    <button id="reset">Reset</button>
  </body>
  <script src="pixel.js"></script>
</html>

pixel.js:

                window.browser = (function () {
  return window.msBrowser || window.browser || window.chrome;
})();

let id;
browser.tabs.query({ active: true, currentWindow: true }, (tabs) => {
  id = tabs[0].id;
  browser.scripting.executeScript({
    target: { tabId: tabs[0].id },
    files: ["content.js"],
  });
  browser.scripting.insertCSS({
    target: { tabId: tabs[0].id },
    files: ["content.css"],
  });
});

document.getElementById("pixelate").addEventListener("click", () => {
  browser.tabs.sendMessage(id, { message: "pixelate" });
});

document.getElementById("reset").addEventListener("click", () => {
  browser.tabs.sendMessage(id, { message: "reset" });
});

AE2: Click button, change page

content.js:

window.browser = (function () {
  return window.msBrowser || window.browser || window.chrome;
})();

function pickPixelArt(art) {
  switch (art) {
    case "a":
      return browser.runtime.getURL("images/pixel-adventure-time.png");
    case "b":
      return browser.runtime.getURL("images/pixel-cat.jpg");
    case "c":
      return browser.runtime.getURL("images/pixel-city.png");
    case "d":
      return browser.runtime.getURL("images/pixel-zen-garden.png");
  }
}

function pickRandomImage() {
  const array = ["a", "b", "c", "d"];
  let index = Math.floor(Math.random() * array.length);
  let random = array[index];
  return random;
}

browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.message === "pixelate") {
    if (!document.getElementById("pixelTime")) {
      const container = document.createElement("div");
      container.setAttribute("id", "pixelTime");
      container.className = "pixel-time";
      const pixelArt = document.createElement("img");
      const url = pickPixelArt(pickRandomImage());
      pixelArt.setAttribute("src", url);
      container.appendChild(pixelArt);
      document.body.appendChild(container);
    } else {
      console.log("Already pixelated");
    }
  }

  if (request.message === "reset") {
    if (document.getElementById("pixelTime")) {
      document.getElementById("pixelTime").remove();
    } else {
      console.log("No pixels left");
    }
  }
});

content.css:

                body:has(.pixel-time) {
  overflow: hidden;
}

.pixel-time {
  position: fixed;
  z-index: 9999;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: black;
  display: flex;
}

.pixel-time img {
  margin: auto;
  width: 100%;
}

Background scripts

Background scripts or a background page enable you to monitor and react to events in the browser, such as navigating to a new page, removing a bookmark, or closing a tab.

Source: MDN: Background scripts

AE3: Press key, change page

manifest.json:

                {
  "background": {
    "service_worker": "background.js",
    "scripts": ["background.js"]
  },
  "commands": {
    "_execute_action": {
      "suggested_key": {
        "default": "Ctrl+Shift+Y"
      }
    },
    "pixelate": {
      "suggested_key": {
        "default": "Alt+A"
      },
      "description": "Send a 'pixelate' event to the extension"
    },
    "reset": {
      "suggested_key": {
        "default": "Ctrl+Shift+E"
      },
      "description": "Send a 'reset' event to the extension"
    }
  }
}

Firefox (when only service_worker is used):

Firefox error message when using service_worker

Chrome (when scripts exist in the manifest):

Chrome warning when using scripts

Proposal: declaring background scripts in a neutral way

AE3: Press key, change page

background.js:

              const API = chrome || browser;

  API.tabs.onActivated.addListener((activeInfo) => {
  API.tabs.get(activeInfo.tabId, function (tab) {
    API.commands.onCommand.addListener((command) => {
      if (command === "pixelate") {
        API.tabs.sendMessage(tab.id, { message: "pixelate" });
      } else if (command === "reset") {
        API.tabs.sendMessage(tab.id, { message: "reset" });
      }
    });
  });
});
Issue #72 on the webextensions GitHub repo

https://github.com/w3c/webextensions/issues/72

Manifest v2 versus v3

v2

  • Extension authors have a choice on whether background scripts or a page can be persistent or non-persistent
  • Uses the chrome.webRequest API, a flexible API that lets extensions intercept and block or otherwise modify HTTP requests and responses
  • Code can be hosted remotely
  • Most API methods use callback functions

v3

  • Background scripts are run with non-persistent service workers
  • Uses the declarativeNetRequest API, declarative API to specify conditions and actions that describe how network requests should be handled
  • Support is removed for remotely hosted code and execution of arbitrary strings
  • Most API methods return promises

Migrate v2 to v3 😮‍💨

Problem:

Manifest version 2 is deprecated, and support will be removed in 2024.
See https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline for details.

Fix:

"manifest_version": 3

Migrate v2 to v3 😮‍💨

Problem:

Error when loading extension

Fix:

"action": { ... },

"web_accessible_resources": [
  {
    "resources": ["beasts/*.jpg"],
    "extension_ids": ["*"],
    "matches": ["*://*/*"]
  }
]

Migrate v2 to v3 😮‍💨

Problem:

Uncaught ReferenceError: browser is not defined
  at choose_beast.js:100:1

Fix:

window.browser = (function () {
  return window.msBrowser || window.browser || window.chrome;
})();

Migrate v2 to v3 😮‍💨

Problem:

Uncaught TypeError: Cannot read properties of undefined (reading 'then')
  at choose_beast.js:105:3

Fix:

/* Replace tabs.executeScript */
browser.tabs
  .executeScript({ file: "/content_scripts/beastify.js" })
  .then(listenForClicks)
  .catch(reportExecuteScriptError);
/* With scripting.executeScript */
browser.tabs.query({ active: true, currentWindow: true }, (tabs) => {
  browser.scripting
    .executeScript({
      target: { tabId: tabs[0].id },
      files: ["/content_scripts/beastify.js"],
    })
    .then(listenForClicks)
    .catch(reportExecuteScriptError);
});

Migrate v2 to v3 😮‍💨

Problem:

Error handling response: TypeError: Cannot read properties of undefined (reading 'executeScript')
  at chrome-extension://lejlhkohkjhglbclhhbnbpfjmljmkmkl/popup/choose_beast.js:105:6

Fix:

"permissions": ["activeTab", "scripting"]

Migrate v2 to v3 😮‍💨

Problem:

choose_beast.js:63 Could not beastify: TypeError: browser.tabs.insertCSS is not a function

Fix:

/* Replace tabs.insertCSS */
function beastify(tabs) {
  browser.tabs.insertCSS({ code: hidePage }).then(() => {
    const url = beastNameToURL(e.target.textContent);
    browser.tabs.sendMessage(tabs[0].id, {
      command: "beastify",
      beastURL: url,
    });
  });
}
/* With scripting.insertCSS */
function beastify(tabs) {
  browser.scripting.insertCSS({
    target: { tabId: tabs[0].id },
    css: `body > :not(.beastify-image) { display: none; }`,
  })
  .then(() => {
    const url = beastNameToURL(e.target.textContent);
    browser.tabs.sendMessage(tabs[0].id, {
      command: "beastify",
      beastURL: url,
    });
  });
}

Migrate v2 to v3 😮‍💨

Problem:

Uncaught (in promise) Error: Could not establish connection. Receiving end does not exist.

Fix:

/* Add to content script */
window.browser = (function () {
  return window.msBrowser || window.browser || window.chrome;
})();
Web Monetization

https://webmonetization.org/

https://issues.chromium.org/issues/40110471

Payment Pointers

  • A standardized identifier for payment accounts
  • Used by an account holder to share the details of their account with a counter-party
https://ilp.gatehub.net/747467740/USD

To implement web monetization on a website:

Web Monetization extension

https://github.com/interledger/web-monetization-extension

https://github.com/interledger/web-monetization-extension/blob/eff212733de71444ff033f131ee3e3c4f000af29/src/background/services/background.ts#L70-L73

async injectPolyfill() {
  try {
    await this.browser.scripting.registerContentScripts([
      {
        world: 'MAIN',
        id: 'polyfill',
        allFrames: true,
        js: ['polyfill/polyfill.js'],
        matches: PERMISSION_HOSTS.origins,
        runAt: 'document_start',
      },
    ]);
  } catch (error) {
    // Firefox <128 will throw saying world: MAIN isn't supported. So, we'll
    // inject via contentScript later. Injection via contentScript is slow,
    // but apart from WM detection on page-load, everything else works fine.
    if (!error.message.includes(`world`)) {
      this.logger.error(
        `Content script execution world \`MAIN\` not supported by your browser.\n` +
          `Check https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld#browser_compatibility for browser compatibility.`,
        error,
      );
    }
  }
}

https://github.com/interledger/web-monetization-extension/blob/eff212733de71444ff033f131ee3e3c4f000af29/src/content/services/contentScript.ts#L70-L74

// Todo: When Firefox has good support for `world: MAIN`, inject this directly
// via manifest.json https://bugzilla.mozilla.org/show_bug.cgi?id=1736575 and
// remove this, along with injectPolyfill from background
// See: https://github.com/interledger/web-monetization-extension/issues/607

async injectPolyfill() {
  const document = this.window.document;
  const script = document.createElement('script');
  script.src = this.browser.runtime.getURL('polyfill/polyfill.js');
  await new Promise<void>((resolve) => {
    script.addEventListener('load', () => resolve(), { once: true });
    document.documentElement.appendChild(script);
  });
  script.remove();
}

https://github.com/interledger/web-monetization-extension/blob/eff212733de71444ff033f131ee3e3c4f000af29/src/content/polyfill.ts#L103

window.addEventListener(
  '__wm_ext_monetization',
  (event: CustomEvent<MonetizationEventPayload['details']>) => {
    if (!(event.target instanceof HTMLLinkElement)) return;
    if (!event.target.isConnected) return;

    const monetizationTag = event.target;
    monetizationTag.dispatchEvent(
      new MonetizationEvent('monetization', event.detail),
    );
  },
  { capture: true },
);

window.addEventListener(
  '__wm_ext_onmonetization_attr_change',
  (event: CustomEvent<{ attribute?: string }>) => {
    if (!event.target) return;

    const { attribute } = event.detail;
    // @ts-expect-error: we're defining this now
    event.target.onmonetization = attribute
      ? new Function(attribute).bind(event.target)
      : null;
  },
  { capture: true },
);
webmonetization.org

https://webmonetization.org/

References

Thank you

Websitehttps://chenhuijing.com

GitHub@huijing

Mastadon@huijing@tech.lgbt

BlueSky@huijing.bsky.social

Font is Figtree by Erik Kennedy.