diff --git a/examples.json b/examples.json
index 124cf1c..4ad2101 100644
--- a/examples.json
+++ b/examples.json
@@ -567,7 +567,7 @@
"name": "user-agent-rewriter"
},
{
- "description": "Illustrates how an extension can register URL-matching user scripts at runtime.",
+ "description": "Illustrates how an extension can register URL-matching user scripts at runtime (Manifest Version 2 only).",
"javascript_apis": [
"userScripts.register",
"runtime.onMessage",
@@ -575,6 +575,28 @@
],
"name": "user-script-register"
},
+ {
+ "description": "A user script manager demonstrating the userScripts API, permissions API, optional_permissions, and Manifest Version 3 (MV3).",
+ "javascript_apis": [
+ "userScripts.configureWorld",
+ "userScripts.getScripts",
+ "userScripts.register",
+ "userScripts.resetWorldConfiguration",
+ "userScripts.unregister",
+ "userScripts.update",
+ "permissions.onAdded",
+ "permissions.onRemoved",
+ "permissions.request",
+ "runtime.onInstalled",
+ "runtime.onUserScriptMessage",
+ "runtime.openOptionsPage",
+ "runtime.sendMessage",
+ "storage.local",
+ "storage.onChanged",
+ "storage.session"
+ ],
+ "name": "userScripts-mv3"
+ },
{
"description": "Demonstrates how to use webpack to package npm modules in an extension.",
"javascript_apis": ["runtime.onMessage", "runtime.sendMessage"],
diff --git a/user-script-register/README.md b/user-script-register/README.md
index 2f111d8..27eaa77 100644
--- a/user-script-register/README.md
+++ b/user-script-register/README.md
@@ -1,6 +1,8 @@
# User script registration
-This extension demonstrates the [`browser.userScripts.register()`](https://wiki.developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/userScripts/Register) API.
+This extension demonstrates the [legacy `browser.userScripts.register()`](https://wiki.developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/userScripts_legacy/register) API, available to Manifest Version 2 extensions only.
+
+> NOTE: See [userScripts-mv3](../userScripts-mv3/) for an example of the cross-browser userScripts API for Manifest Version 3.
The extension includes an [API script](https://wiki.developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/user_scripts) (`customUserScriptAPIs.js`) that enables user scripts to make use of `browser.storage.local`.
diff --git a/userScripts-mv3/.eslintrc.json b/userScripts-mv3/.eslintrc.json
new file mode 100644
index 0000000..267df88
--- /dev/null
+++ b/userScripts-mv3/.eslintrc.json
@@ -0,0 +1,10 @@
+{
+ "overrides": [
+ {
+ "files": ["*.mjs"],
+ "parserOptions": {
+ "sourceType": "module"
+ }
+ }
+ ]
+}
diff --git a/userScripts-mv3/README.md b/userScripts-mv3/README.md
new file mode 100644
index 0000000..c734728
--- /dev/null
+++ b/userScripts-mv3/README.md
@@ -0,0 +1,130 @@
+# userScripts-mv3
+
+The extension is an example of a
+[user script manager](https://en.wikipedia.org/wiki/Userscript_manager). It
+The extension is an example of a [user script
+manager](https://en.wikipedia.org/wiki/Userscript_manager). It demonstrates the
+`userScripts` API, the `permissions` API, `optional_permissions`, and Manifest
+Version 3 (MV3).
+and Manifest Version 3 (MV3).
+
+This example demonstrates these aspects of extension development:
+
+- Showing an onboarding UI after installation.
+
+- Designing background scripts that can restart repeatedly with minimal
+ overhead. This is especially relevant to Manifest Version 3.
+
+- Minimizing the overhead of background script startup. This is relevant because
+ Manifest Version 3 extensions use an event-based background context.
+
+- Monitoring grants for an
+ [optional-only](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/optional_permissions#optional-only_permissions)
+ permission (`"userScripts"`), and dynamically registering events and scripts
+ based on its availability.
+
+- Using the `userScripts` API to register, update, and unregister user script
+ code.
+
+- Isolating user scripts in individual execution contexts (`USER_SCRIPT`
+ world), and conditionally exposing custom functions to user scripts.
+
+
+## What it does
+
+After loading, the extension detects the new installation and opens the options
+page embedded in `about:addons`. On the options page:
+
+1. Click "Grant access to userScripts API" to trigger a permission prompt for
+ the "userScripts" permission.
+2. Click "Add new user script" to open a form where a new script can be
+ registered.
+3. Input a user script, by clicking one of the "Example" buttons and input a
+ example from the [userscript_examples](userscript_examples) directory.
+4. Click "Save" to trigger validation and save the script.
+
+If the "userScripts" permission is granted, this schedules the execution of the
+registered user scripts for the websites specified in each user script.
+
+See [userscript_examples](userscript_examples) for examples of user scripts and
+what they do.
+
+If you repeat steps 2-4 for both examples and then visit https://example.com/,
+you should see this behavior:
+
+- Show a dialog containing "This is a demo of a user script".
+- Insert a button with the label "Show user script info", which opens a new tab
+ displaying the extension information.
+
+# What it shows
+
+Showing onboarding UI after installation:
+
+- `background.js` registers the `runtime.onInstalled` listener that calls
+ `runtime.openOptionsPage` after installation.
+
+Designing background scripts that can restart repeatedly with minimal overhead:
+
+- This is particularly relevant to Manifest Version 3, because in MV3
+ background scripts are always non-persistent and can suspend on inactivity.
+- Using `storage.session` to store initialization status, to run expensive
+ initialization only once per browser session.
+- Registering events at the top level to handle events that are triggered while
+ the background script is asleep.
+- Using [dynamic import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import)
+ to initialize optional JavaScript modules on demand.
+
+Monitoring an optional permission (`userScripts`), and dynamically registering
+events and scripts based on its availability:
+events and scripts based on its availability:
+
+- The `userScripts` permission is optional and can be granted by the user from:
+ - the options page (`options.html` + `options.mjs`).
+ - the browser UI (where the user can also revoke the permission). See the
+ Mozilla support article [Manage optional permissions for Firefox extensions](https://support.mozilla.org/en-US/kb/manage-optional-permissions-extensions).
+
+- The `permissions.onAdded` and `permissions.onRemoved` events are used to
+ monitor permission changes and, therefore, the availability of the
+ `userScripts` API.
+
+- When the `userScripts` API is available when `background.js` starts or
+ `permissions.onAdded` detects that permission has been granted,
+ initialization starts (using the `ensureUserScriptsRegistered` function in
+ `background.js`).
+
+- When the `userScripts` API is unavailable when `background.js` starts,
+ the extension cannot use the `userScripts` API until `permissions.onAdded` is
+ triggered. The options page stores user scripts in `storage.local` to enable
+ the user to edit scripts even without the `userScripts` permission.
+
+Using the `userScripts` API to register, update, and unregister code:
+
+- The `applyUserScripts()` function in `background.js` demonstrates how to use
+ the various `userScripts` APIs to register, update, and unregister scripts.
+- `userscript_manager_logic.mjs` contains logic specific to user script
+ managers. See [`userscript_manager_logic.mjs`](userscript_manager_logic.mjs)
+ for comments and the conversion logic from a user script string to the format
+ expected by the `userScripts` API
+ ([RegisteredUserScript](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/userScripts/RegisteredUserScript)).
+
+Isolating user scripts in individual execution contexts (`USER_SCRIPT` world),
+and conditionally exposing custom functions to user scripts:
+
+- Shows the use of `USER_SCRIPT` worlds (with distinct `worldId`) to
+ define sandboxes for scripts to run in (see `registeredUserScript`
+ in `userscript_manager_logic.mjs`).
+
+- Shows the use of `userScripts.configureWorld()` with the `messaging` flag to
+ enable the `runtime.sendMessage()` method in `USER_SCRIPT` worlds.
+
+- Shows the use of `runtime.onUserScriptMessage` and `sender.userScriptWorldId`
+ to detect messages and the script that sent messages.
+
+- Shows how an initial script can use `runtime.sendMessage` to expose custom
+ APIs to user scripts (see `userscript_api.js`).
+
+# Feature availability
+
+The `userScripts` API is available from Firefox 136. In Firefox 134 and 135, the
+functionality is only available if the `extensions.userScripts.mv3.enabled`
+preference is set to `true` at `about:config` before installing the extension.
diff --git a/userScripts-mv3/background.js b/userScripts-mv3/background.js
new file mode 100644
index 0000000..eb0deef
--- /dev/null
+++ b/userScripts-mv3/background.js
@@ -0,0 +1,176 @@
+"use strict";
+
+// This background.js file is responsible for observing the availability of the
+// userScripts API, and registering user scripts when needed.
+//
+// - The runtime.onInstalled event is used to detect new installations, and
+// opens a custom extension UI where the user is asked to grant the
+// "userScripts" permission.
+//
+// - The permissions.onAdded and permissions.onRemoved events detect changes to
+// the "userScripts" permission, whether triggered from the extension UI, or
+// externally (e.g., through browser UI).
+//
+// - The storage.local API is used to store user scripts across extension
+// updates. This is necessary because the userScripts API clears any
+// previously registered scripts when an extension is updated.
+//
+// - The userScripts API manages script registrations with the browser. The
+// applyUserScripts() function in this file demonstrates the relevant aspects
+// to registering and updating user scripts that apply to most extensions
+// that manage user scripts. To keep this file reasonably small, most of the
+// application-specific logic is in userscript_manager_logic.mjs.
+
+function isUserScriptsAPIAvailable() {
+ return !!browser.userScripts;
+}
+var userScriptsAvailableAtStartup = isUserScriptsAPIAvailable();
+
+var managerLogic; // Lazily initialized by ensureManagerLogicLoaded().
+async function ensureManagerLogicLoaded() {
+ if (!managerLogic) {
+ managerLogic = await import("./userscript_manager_logic.mjs");
+ }
+}
+
+browser.runtime.onInstalled.addListener(details => {
+ if (details.reason !== "install") {
+ // Only show the extension's onboarding logic on extension installation,
+ // and not, e.g., on browser or extension updates.
+ return;
+ }
+ if (!isUserScriptsAPIAvailable()) {
+ // The extension needs the "userScripts" permission, but this is not
+ // granted. Open the extension's options_ui page, which implements
+ // onboarding logic, in options.html + options.mjs.
+ browser.runtime.openOptionsPage();
+ }
+});
+
+browser.permissions.onRemoved.addListener(permissions => {
+ if (permissions.permissions.includes("userScripts")) {
+ // Pretend that userScripts is not available, to enable permissions.onAdded
+ // to re-initialize when the permission is restored.
+ userScriptsAvailableAtStartup = false;
+
+ // Clear the cached state, so that ensureUserScriptsRegistered() refreshes
+ // the registered user scripts when the permission is granted again.
+ browser.storage.session.remove("didInitScripts");
+
+ // Note: the "userScripts" namespace is unavailable, so we cannot and
+ // should not try to unregister scripts.
+ }
+});
+
+browser.permissions.onAdded.addListener(permissions => {
+ if (permissions.permissions.includes("userScripts")) {
+ if (userScriptsAvailableAtStartup) {
+ // If background.js woke up to dispatch permissions.onAdded, it has
+ // detected the availability of the userScripts API and immediately
+ // started initialization. Return now to avoid double-initialization.
+ return;
+ }
+ browser.runtime.onUserScriptMessage.addListener(onUserScriptMessage);
+ ensureUserScriptsRegistered();
+ }
+});
+
+// When the user modifies a user script in options.html + options.mjs, the
+// changes are stored in storage.local and this listener is triggered.
+browser.storage.local.onChanged.addListener(changes => {
+ if (changes.savedScripts?.newValue && isUserScriptsAPIAvailable()) {
+ // userScripts API is available and there are changes that can be applied.
+ applyUserScripts(changes.savedScripts.newValue);
+ }
+});
+
+if (userScriptsAvailableAtStartup) {
+ // Register listener immediately if the API is available, in case the
+ // background.js is woken to dispatch the onUserScriptMessage event.
+ browser.runtime.onUserScriptMessage.addListener(onUserScriptMessage);
+ ensureUserScriptsRegistered();
+}
+
+async function onUserScriptMessage(message, sender) {
+ await ensureManagerLogicLoaded();
+ return managerLogic.handleUserScriptMessage(message, sender);
+}
+
+async function ensureUserScriptsRegistered() {
+ let { didInitScripts } = await browser.storage.session.get("didInitScripts");
+ if (didInitScripts) {
+ // The scripts are initialized, e.g., by a (previous) startup of this
+ // background script. Skip expensive initialization.
+ return;
+ }
+ let { savedScripts } = await browser.storage.local.get("savedScripts");
+ savedScripts ||= [];
+ try {
+ await applyUserScripts(savedScripts);
+ } finally {
+ // Set a flag to mark the completion of initialization, to avoid running
+ // this logic again at the next startup of this background.js script.
+ await browser.storage.session.set({ didInitScripts: true });
+ }
+}
+
+async function applyUserScripts(userScriptTexts) {
+ await ensureManagerLogicLoaded();
+ // Note: assumes userScriptTexts to be valid, validated by options.mjs.
+ let scripts = userScriptTexts.map(str => managerLogic.parseUserScript(str));
+
+ // Registering scripts is expensive. Compare the scripts with the old scripts
+ // to ensure that only modified scripts are updated.
+ let oldScripts = await browser.userScripts.getScripts();
+
+ let {
+ scriptIdsToRemove,
+ scriptsToUpdate,
+ scriptsToRegister,
+ } = managerLogic.computeScriptDifferences(oldScripts, scripts);
+
+ // Now, for the changed scripts, apply the changes in this order:
+ // 1. Unregister obsolete scripts.
+ // 2. Reset or configure worlds.
+ // 3. Update and/or register new scripts.
+ // This order is significant: scripts rely on world configurations, and while
+ // running this asynchronous script updating logic, the browser may try to
+ // execute any of the registered scripts when a website loads in a tab or
+ // iframe, unrelated to the extension execution.
+ // To prevent scripts from executing with the wrong world configuration,
+ // worlds are configured before new scripts are registered.
+
+ // 1. Unregister obsolete scripts.
+ if (scriptIdsToRemove.length) {
+ await browser.userScripts.unregister({ worldIds: scriptIdsToRemove });
+ }
+
+ // 2. Reset or configure worlds.
+ if (scripts.some(s => s.worldId)) {
+ // When userscripts need privileged functionality, run them in a sandbox
+ // (USER_SCRIPT world). To offer privileged functionality, we need
+ // a communication channel between the userscript and this privileged side.
+ // Specifying "messaging:true" exposes runtime.sendMessage() these worlds,
+ // which upon invocation triggers the runtime.onUserScriptMessage event.
+ //
+ // Calling configureWorld without a specific worldId sets the default world
+ // configuration, which is inherit by every other USER_SCRIPT world that
+ // does not have a more specific configuration.
+ //
+ // Since every USER_SCRIPT world in this demo extension has the same world
+ // configuration, we can set the default once, without needing to define
+ // world-specific configurations.
+ await browser.userScripts.configureWorld({ messaging: true });
+ } else {
+ // Reset the default world's configuration.
+ await browser.userScripts.resetWorldConfiguration();
+ }
+
+ // 3. Update and/or register new scripts.
+ if (scriptsToUpdate.length) {
+ await browser.userScripts.update(scriptsToUpdate);
+ }
+ if (scriptsToRegister.length) {
+ await browser.userScripts.register(scriptsToRegister);
+ }
+}
diff --git a/userScripts-mv3/manifest.json b/userScripts-mv3/manifest.json
new file mode 100644
index 0000000..f720c98
--- /dev/null
+++ b/userScripts-mv3/manifest.json
@@ -0,0 +1,21 @@
+{
+ "manifest_version": 3,
+ "name": "User Scripts Manager extension",
+ "description": "Demonstrates the userScripts API and optional permission, in MV3.",
+ "version": "0.1",
+ "host_permissions": ["*://*/"],
+ "permissions": ["storage", "unlimitedStorage"],
+ "optional_permissions": ["userScripts"],
+ "background": {
+ "scripts": ["background.js"]
+ },
+ "options_ui": {
+ "page": "options.html"
+ },
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "user-script-manager-example@mozilla.org",
+ "strict_min_version": "134.0"
+ }
+ }
+}
diff --git a/userScripts-mv3/options.css b/userScripts-mv3/options.css
new file mode 100644
index 0000000..b78d068
--- /dev/null
+++ b/userScripts-mv3/options.css
@@ -0,0 +1,5 @@
+#edit_script_dialog .source_text {
+ display: block;
+ width: 80vw;
+ min-height: 10em;
+}
diff --git a/userScripts-mv3/options.html b/userScripts-mv3/options.html
new file mode 100644
index 0000000..85d88a9
--- /dev/null
+++ b/userScripts-mv3/options.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+ This page enables you to create, edit and remove user scripts.
+ To run them, please allow the extension to run user scripts by clicking this button:
+
+
+
+
+
+
+
+
+
+
+
diff --git a/userScripts-mv3/options.mjs b/userScripts-mv3/options.mjs
new file mode 100644
index 0000000..2c5612f
--- /dev/null
+++ b/userScripts-mv3/options.mjs
@@ -0,0 +1,176 @@
+import { parseUserScript } from "./userscript_manager_logic.mjs";
+
+function initializePrefHandlerForUserScriptsPermissions() {
+ const PERM = "userScripts";
+ const button = document.getElementById("grant_userScripts_permission");
+ function renderPermStatus(granted) {
+ if (granted) {
+ button.disabled = true;
+ button.textContent = "userScripts permission has been granted";
+ } else {
+ button.disabled = false;
+ button.textContent = "Grant access to userScripts API";
+ }
+ }
+
+ button.onclick = async () => {
+ button.disabled = true; // Avoid double-click.
+ button.textContent = "Showing userScripts permission request...";
+ let ok = await browser.permissions.request({ permissions: [PERM] });
+ renderPermStatus(ok);
+ };
+
+ browser.permissions.onAdded.addListener(permissions => {
+ if (permissions.permissions.includes(PERM)) {
+ renderPermStatus(true);
+ }
+ });
+
+ browser.permissions.onRemoved.addListener(permissions => {
+ if (permissions.permissions.includes(PERM)) {
+ renderPermStatus(false);
+ }
+ });
+
+ browser.permissions.contains({ permissions: [PERM] }).then(renderPermStatus);
+}
+
+function isValidMatchPattern(str) {
+ // This is a bit stricter than what browsers consider a valid match pattern,
+ // but userscripts usually run on http(s) only.
+ return /^(https?|\*):\/\/(\*|(\*\.)?[^*/]+)\/.*$/.test(str);
+}
+
+/**
+ * Shows the form where the user can edit or create a user script.
+ *
+ * @param {string} userScriptText - Non-empty if editing an existing script.
+ */
+function showEditDialog(userScriptText) {
+ const edit_script_dialog = document.getElementById("edit_script_dialog");
+ const textarea = edit_script_dialog.querySelector("textarea.source_text");
+ const saveButton = edit_script_dialog.querySelector("button.save_button");
+ const removeButton = edit_script_dialog.querySelector("button.remove_button");
+ const outputStatus = edit_script_dialog.querySelector("output");
+
+ textarea.value = userScriptText;
+
+ outputStatus.value = "";
+
+ saveButton.disabled = false;
+ saveButton.onclick = async () => {
+ saveButton.disabled = true;
+ try {
+ let savedScripts =
+ await getScriptsToSaveIfValid(userScriptText, textarea.value);
+ outputStatus.value = "Applying...";
+ await browser.storage.local.set({ savedScripts });
+ await renderCurrentScripts();
+ edit_script_dialog.close();
+ } catch (e) {
+ outputStatus.value = e.message;
+ } finally {
+ saveButton.disabled = false;
+ }
+ };
+
+ removeButton.hidden = !userScriptText;
+ removeButton.onclick = async () => {
+ if (confirm("Do you want to remove this script?")) {
+ let { savedScripts } = await browser.storage.local.get("savedScripts");
+ savedScripts = savedScripts.filter(txt => txt !== userScriptText);
+ await browser.storage.local.set({ savedScripts });
+ await renderCurrentScripts();
+ edit_script_dialog.close();
+ }
+ };
+
+ async function suggestExample(sourceUrl) {
+ if (textarea.value && !confirm("Input is not empty. Proceed to replace?")) {
+ return;
+ }
+ let res = await fetch(sourceUrl);
+ textarea.value = await res.text();
+ }
+
+ edit_script_dialog.querySelector("#sample_privileged").onclick = () => {
+ suggestExample("userscript_examples/privileged.user.js");
+ };
+ edit_script_dialog.querySelector("#sample_unprivileged").onclick = () => {
+ suggestExample("userscript_examples/unprivileged.user.js");
+ };
+
+ edit_script_dialog.showModal();
+}
+
+async function getScriptsToSaveIfValid(oldUserScriptText, newUserScriptText) {
+ let newScript = parseUserScript(newUserScriptText);
+ if (!newScript) {
+ throw new Error("Input is not a user script, missing header");
+ }
+ if (!newScript.matches?.length && !newScript.includeGlobs?.length) {
+ throw new Error("At least one @include or @match must be specified");
+ }
+ for (let pattern of newScript.matches || []) {
+ if (!isValidMatchPattern(pattern)) {
+ throw new Error(`Invalid match pattern: @match ${pattern}`);
+ }
+ }
+ for (let pattern of newScript.excludeMatches || []) {
+ if (!isValidMatchPattern(pattern)) {
+ throw new Error(`Invalid match pattern: @exclude-match ${pattern}`);
+ }
+ }
+ let { savedScripts } = await browser.storage.local.get("savedScripts");
+ savedScripts ||= [];
+ if (oldUserScriptText) {
+ let i = savedScripts.indexOf(oldUserScriptText);
+ if (i === -1) {
+ // This is unexpected, but could happen if the user opened the options
+ // page in a different tab and continued modifying scripts there.
+ throw new Error(
+ "Cannot find old script to replace. Did you modify the script in another tab?"
+ );
+ }
+ savedScripts[i] = newUserScriptText;
+ } else {
+ savedScripts.push(newUserScriptText);
+ }
+
+ // Script IDs must be unique. The storage should contain valid scripts only,
+ // so getting too many IDs implies that the proposed ID was duplicated.
+ let seenIds = savedScripts.map(s => parseUserScript(s).id);
+ if (seenIds.filter(id => id === newScript.id).length > 1) {
+ throw new Error("@namespace + @name must not be used elsewhere");
+ }
+
+ // All valid! Return the scripts to save.
+ return savedScripts;
+}
+
+async function renderCurrentScripts() {
+ const outputList = document.getElementById("list_of_scripts");
+ outputList.querySelector("#add_new").onclick = () => {
+ showEditDialog("");
+ };
+ // Clear any previously rendered results, except for the "#add_new" button.
+ outputList.replaceChildren(outputList.firstElementChild);
+
+ let { savedScripts } = await browser.storage.local.get("savedScripts");
+ savedScripts ||= [];
+ for (let userScriptText of savedScripts) {
+ let registeredUserScript = parseUserScript(userScriptText);
+
+ let editButton = document.createElement("button");
+ editButton.textContent = "Edit";
+ editButton.onclick = () => {
+ showEditDialog(userScriptText);
+ };
+ let li = document.createElement("li");
+ li.append(editButton, `Script ID: ${registeredUserScript.id}`);
+ outputList.append(li);
+ }
+}
+
+initializePrefHandlerForUserScriptsPermissions();
+renderCurrentScripts();
diff --git a/userScripts-mv3/userscript_api.js b/userScripts-mv3/userscript_api.js
new file mode 100644
index 0000000..3815cb8
--- /dev/null
+++ b/userScripts-mv3/userscript_api.js
@@ -0,0 +1,50 @@
+"use strict";
+
+// userscript_api.js defines a single function that generates the global APIs
+// that should be made available to scripts running in this USER_SCRIPT sandbox.
+// This script is scheduled to run before user scripts by the parseUserScript
+// function in userscript_manager_logic.mjs
+
+globalThis.initCustomAPIForUserScripts = grants => {
+ // Allow initialization only once.
+ delete globalThis.initCustomAPIForUserScripts;
+
+ // background.js calls userScripts.configureWorld({ messaging: true }), which
+ // exposes runtime.sendMessage here. When this method is called, the
+ // runtime.onUserScriptMessage listener (in background.js) is called, which
+ // in turn calls handleUserScriptMessage in userscript_manager_logic.mjs.
+ const sendMessage = browser.runtime.sendMessage;
+
+ // Clear access to privileged API to prevent userscripts from communicating
+ // to the privileged backend.
+ globalThis.browser = undefined;
+
+ if (grants.includes("GM_info")) {
+ // Example of an API that retrieves information:
+ // https://www.tampermonkey.net/documentation.php#api:GM_info
+ // https://violentmonkey.github.io/api/gm/#gm_info
+ // https://wiki.greasespot.net/GM.info
+ // NOTE: The following implementation of GM_info is async to demonstrate
+ // how one can retrieve information on demand. The actual GM_info function
+ // as defined by full-featured user script managers is synchronous.
+ globalThis.GM_info = async () => {
+ return sendMessage({ userscript_api_name: "GM_info" });
+ };
+ }
+
+ if (grants.includes("GM_openInTab")) {
+ // Example of an API that sends information:
+ // https://www.tampermonkey.net/documentation.php#api:GM_openInTab
+ // https://violentmonkey.github.io/api/gm/#gm_openintab
+ // https://wiki.greasespot.net/GM.openInTab
+ globalThis.GM_openInTab = async (url) => {
+ await sendMessage({ userscript_api_name: "GM_openInTab", args: [url] });
+ };
+ }
+
+ // TODO: Implement more APIs.
+
+ // After this function returns, the userscript may execute and potentially
+ // change built-in prototypes. To protect against that, make sure to store
+ // any functions that you want to use in a local variable!
+};
diff --git a/userScripts-mv3/userscript_examples/privileged.user.js b/userScripts-mv3/userscript_examples/privileged.user.js
new file mode 100644
index 0000000..59f7e8b
--- /dev/null
+++ b/userScripts-mv3/userscript_examples/privileged.user.js
@@ -0,0 +1,32 @@
+// ==UserScript==
+// @name Demo of privileged user script
+// @description Add button on domains starting with "example" that displays privileged info in a new tab.
+// @include https://example*
+// @include http://example*
+// @grant GM_info
+// @grant GM_openInTab
+// @version 1.2.3
+// ==/UserScript==
+
+// To test:
+// 1. Visit https://example.com/ or http://example.org/
+// 2. Click on the "Show user script info" button.
+// 2. Confirm that a new tab opens that displays the script info.
+
+/* globals GM_info, GM_openInTab */
+
+if (location.pathname === "/display_userscript_result") {
+ document.body.style.whiteSpace = "pre-wrap";
+ document.body.textContent = decodeURIComponent(location.search.slice(1));
+} else {
+ let button = document.createElement("button");
+ button.textContent = "Show user script info";
+ document.body.prepend(button);
+
+ button.onclick = async () => {
+ let info = await GM_info();
+ let text = `Result from user script:\n\n${JSON.stringify(info, null, 4)}`;
+ let url = "https://example.com/display_userscript_result?" + encodeURIComponent(text);
+ GM_openInTab(url);
+ };
+}
diff --git a/userScripts-mv3/userscript_examples/unprivileged.user.js b/userScripts-mv3/userscript_examples/unprivileged.user.js
new file mode 100644
index 0000000..8067e27
--- /dev/null
+++ b/userScripts-mv3/userscript_examples/unprivileged.user.js
@@ -0,0 +1,20 @@
+// ==UserScript==
+// @name Demo of unprivileged user script
+// @description Show dialog on MDN and every URL starting with "https://example".
+// @match https://developer.mozilla.org/*
+// @include https://example*
+// @exclude-match https://example.com/display_userscript_result*
+// @grant none
+// @version 1.2.3
+// ==/UserScript==
+
+// To test:
+// 1. Visit https://example.com/ or https://developer.mozilla.org/
+// 2. Confirm that a dialog shows up.
+
+alert(`This is a demo of a user script, running at ${document.URL}.`);
+
+// This user script should not get access to privileged APIs.
+if (typeof GM_info !== "undefined") {
+ alert("Unexpectedly, GM_info is defined...?");
+}
diff --git a/userScripts-mv3/userscript_manager_logic.mjs b/userScripts-mv3/userscript_manager_logic.mjs
new file mode 100644
index 0000000..8bcba23
--- /dev/null
+++ b/userScripts-mv3/userscript_manager_logic.mjs
@@ -0,0 +1,240 @@
+// A userscript manager runs user-defined scripts (called userscripts) on
+// websites based on the metadata that is encoded in the userscript source text.
+// For history, see https://en.wikipedia.org/wiki/Userscript
+//
+// This file provides a partial implementation of a userscript manager, and
+// exports the following functions that are called by code in background.js:
+//
+// - parseUserScript() - parses userscript source text to a representation of
+// the user script for use with the "browser.userScripts" extension API.
+//
+// - computeScriptDifferences() - compares two representations of a user script
+// and returns whether they have changed. This is used for determining which
+// scripts should be updated, when the actual registrations are compared with
+// the scripts in the extension storage. As script registration is relatively
+// expensive, this enables updating only the scripts that have changed.
+//
+// - handleUserScriptMessage() - Handles browser.runtime.onUserScriptMessage
+// event, which is triggered when a script in the USER_SCRIPT world invokes
+// the browser.runtime.sendMessage() method (see userscript_api.js).
+
+export function parseUserScript(userScriptText) {
+ let metadata = parseMetadataBlock(userScriptText);
+ if (!metadata) {
+ console.warn("Ignoring non-userscript input");
+ return null;
+ }
+
+ // Create object for use with browser.userScripts.register():
+ let registeredUserScript = {
+ // The userScripts API requires each script to have a unique ID that does
+ // not start with "_". The userscript format specifies the identifier of
+ // a script is formed from @namespace and @name:
+ // https://wiki.greasespot.net/Metadata_Block#@name
+ // https://violentmonkey.github.io/api/metadata-block/#name
+ id: JSON.stringify([ metadata.get("@namespace"), metadata.get("@name") ]),
+ js: null, // js is a required array and will be set below.
+ // All of the following fields are optional.
+ allFrames: !metadata.has("@noframes"),
+ matches: metadata.getArray("@match"),
+ excludeMatches: metadata.getArray("@exclude-match"),
+ includeGlobs: metadata.getArray("@include"),
+ excludeGlobs: metadata.getArray("@exclude"),
+ runAt: parseRunAt(metadata.get("@run-at")),
+ world: null, // "MAIN" or "USER_SCRIPT", set below.
+ worldId: null, // Can only be set if world is "USER_SCRIPT", set below.
+ };
+
+ // See https://wiki.greasespot.net/@grant
+ // When "@grant" is used, the userscript requests access to extra APIs that
+ // are not available to regular web pages.
+ let grants = parseGrants(metadata.getArray("@grant"));
+ if (grants.length === 0) {
+ // When the userscript does not request any additional APIs, we only need
+ // to execute one script: the original userscript source code.
+ registeredUserScript.js = [{ code: userScriptText }];
+ // When no additional APIs are granted, it is not strictly necessary to
+ // isolate the code from the web page, and therefore we use the "MAIN"
+ // world.
+ registeredUserScript.world = "MAIN";
+ registeredUserScript.worldId = "";
+ } else {
+ // When the userscript defines "@grant", it requests access to several
+ // userscript-specific APIs. These are not provided by the browser, but
+ // the responsibility of the user script manager extension.
+
+ // See userscript_api.js for an explanation of this logic.
+ registeredUserScript.js = [
+ { file: "userscript_api.js" },
+ // initCustomAPIForUserScripts is defined in userscript_api.js
+ { code: `initCustomAPIForUserScripts(${JSON.stringify(grants)})`},
+ { code: userScriptText },
+ ];
+
+ // If extra APIs are requested, we need to define a sandbox that isolates
+ // the execution environment of the userscript from the web page, or else
+ // the page can try to interfere with the userscript, and at worst abuse
+ // privileged functionality.
+ registeredUserScript.world = "USER_SCRIPT";
+ // To isolate different userscript scripts from each other, we create a
+ // unique world for each user script. This enables us to effectively
+ // implement access control per script.
+ registeredUserScript.worldId = Math.random().toString();
+ }
+ return registeredUserScript;
+}
+
+function parseMetadataBlock(userScriptText) {
+ // Parse userscript metadata block, which is in the following format:
+ // // ==UserScript==
+ // // @key value
+ // // ==/UserScript==
+ // See https://wiki.greasespot.net/Metadata_Block
+ let header = `\n${userScriptText}\n`.split("\n// ==UserScript==\n", 2)[1];
+ if (!header) {
+ console.error("UserScript header start not found");
+ return null;
+ }
+ header = header.split("\n// ==/UserScript==\n")[0];
+ if (!header) {
+ console.error("UserScript header end not found");
+ return null;
+ }
+ let metadata = new Map();
+ for (let line of header.split("\n")) {
+ let match = /^\/\/ (@\S+)(\s+.*)?$/.exec(line);
+ if (!match) {
+ console.warn(`Skipping invalid UserScript header line: ${line}`);
+ continue;
+ }
+ let [, key, value] = match;
+ if (!metadata.has(key)) {
+ metadata.set(key, []);
+ }
+ metadata.get(key).push(value.trim());
+ }
+ return {
+ rawHeader: header,
+ has: key => metadata.has(key),
+ get: key => metadata.get(key)?.[0],
+ getArray: key => metadata.get(key) || [],
+ };
+}
+
+function parseRunAt(runAtFromUserScriptMetadata) {
+ // Transforms some of the supported @run-at values to the values
+ // https://wiki.greasespot.net/Metadata_Block#.40run-at
+ // https://www.tampermonkey.net/documentation.php#meta:run_at
+ // https://violentmonkey.github.io/api/metadata-block/#run-at
+ switch (runAtFromUserScriptMetadata) {
+ case "document-start": return "document_start";
+ case "document-end": return "document_end";
+ case "document-idle": return "document_idle";
+ // Default if unspecified or not recognized. Some userscript managers
+ // support more values, the extension API only recognizes the above three.
+ default: return "document_idle";
+ }
+}
+
+function isSameRegisteredUserScript(oldScript, newScript) {
+ // In general, to test whether two RegisteredUserScript are equal, we have to
+ // compare each property (and if undefined/null, use the default value).
+ //
+ // In this demo, the parseUserScripts function generated all of the script's
+ // properties from an input code string, which is also the last item of the
+ // "js" array. Comparing these is enough to test whether they are the same.
+ return oldScript.js.at(-1).code === newScript.js.at(-1).code;
+}
+
+export function computeScriptDifferences(oldScripts, newScripts) {
+ let scriptIdsToRemove = [];
+ let scriptsToUpdate = [];
+ let scriptsToRegister = [];
+
+ for (let script of oldScripts) {
+ if (!newScripts.some(s => s.id === script.id)) {
+ // old script no longer exists. We should remove it.
+ scriptIdsToRemove.push(script.id);
+ }
+ }
+ for (let script of newScripts) {
+ let oldScript = oldScripts.find(s => s.id === script.id);
+ if (!oldScript) {
+ scriptsToRegister.push(script);
+ } else if (!isSameRegisteredUserScript(script, oldScript)) {
+ // Script was updated, remove old one and register new one.
+ scriptsToUpdate.push(script);
+ } else {
+ // oldScript is kept when we do not update or remove it.
+ }
+ }
+
+ return { scriptIdsToRemove, scriptsToUpdate, scriptsToRegister };
+}
+
+function parseGrants(grantsFromUserScriptMetadata) {
+ // Userscripts may access privileged APIs as defined in:
+ // https://wiki.greasespot.net/@grant
+ // https://violentmonkey.github.io/api/metadata-block/#grant
+ // https://www.tampermonkey.net/documentation.php#meta:grant
+ let grants = [];
+ for (let grant of grantsFromUserScriptMetadata) {
+ if (grant === "none") {
+ // "@grant none" is equivalent to no grants.
+ return [];
+ }
+
+ // Although there are many APIs, we only support a small subset in this
+ // demo. ee handleUserScriptMessage() below and userscript_api.js.
+ if (grant === "GM_info" || grant === "GM_openInTab") {
+ grants.push(grant);
+ } else {
+ console.warn(`@grant ${grant} is not implemented`);
+ }
+ }
+ return grants;
+}
+
+export async function handleUserScriptMessage(message, sender) {
+ // This is the runtime.onUserScriptMessage handler that implements the
+ // privileged functionality to support functionality in userscript_api.js
+
+ // TODO: Validate that the message is allowed.
+ // sender.userScriptWorldId can be used to look up the world and the scripts
+ // that execute within.
+
+ if (message.userscript_api_name === "GM_openInTab") {
+ await browser.tabs.create({ url: message.args[0] });
+ return;
+ }
+
+ if (message.userscript_api_name === "GM_info") {
+ // In parseUserScripts(), each generated script has a unique worldId, so if
+ // the script is still registered, we can discover the script registration.
+ // For simplicity, we extract the original userscript source text from it.
+ let scripts = await browser.userScripts.getScripts();
+ let script = scripts.find(s => s.worldId === sender.userScriptWorldId);
+ let userScriptText = script.js.at(-1).code;
+
+ // Minimal implementation of GM_info, based on:
+ // https://wiki.greasespot.net/GM.info
+ let metadata = parseMetadataBlock(userScriptText);
+ return {
+ script: {
+ description: metadata.get("@description"),
+ excludes: metadata.getArray("@exclude"),
+ includes: metadata.getArray("@include"),
+ matches: metadata.getArray("@match"),
+ name: metadata.get("@name"),
+ namespace: metadata.get("@namespace"),
+ "run-at": metadata.get("@run-at"),
+ version: metadata.get("@version"),
+ },
+ scriptMetaStr: metadata.rawHeader,
+ scriptHandler: "[Name of my Userscript Manager]",
+ version: browser.runtime.getManifest().version,
+ };
+ }
+
+ console.error("Unexpected message", message);
+}