Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add example using MV3 userScripts API #576

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -567,14 +567,36 @@
"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 - deprecated).",
"javascript_apis": [
"userScripts.register",
"runtime.onMessage",
"runtime.sendMessage"
],
"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"],
Expand Down
116 changes: 116 additions & 0 deletions userScripts-mv3/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# userScripts-mv3

A user script manager, demonstrating the userScripts API, the permissions API,
`optional_permissions`, and Manifest Version 3 (MV3).
The extension is an example of a
[user script manager](https://en.wikipedia.org/wiki/Userscript_manager).

This covers the following aspects to extension development:

- Showing 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, which is especially
relevant because event pages .

- Monitoring an optional (userScripts) permission, and dynamically registering
events and scripts based on its availability.

- Using the `userScripts` API to register, update and unregister code.

- Isolating user scripts in their own execution context (`USER_SCRIPT` world),
and conditionally exposing custom functions to user scripts.


## What it does

This extension is an example of a [user script manager](https://en.wikipedia.org/wiki/Userscript_manager)

After loading the extension, the extension detects the new installation and
opens the options page embedded in `about:addons`. On the options page:

1. You can click on the "Grant access to userScripts API" button to trigger a
permission prompt for the "userScripts" permission.
2. Click on the "Add new user script" button to open a form where a new script
can be registered.
3. Input a user script. E.g. by clicking one of the two "Example" buttons to
input examples from the [userscript_examples](userscript_examples) directory.
4. Click on the "Save" button to trigger validation and save the script.

If the "userScripts" permission was granted, this will schedule the execution
of the registered user scripts for the websites as specified by the 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, then a visit to https://example.com/
should show the following 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 especially relevant to Manifest Version 3, because background scripts
are always non-persistent event pages, which 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 were triggered
while the background script was 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 (userScripts) permission, and dynamically registering
events and scripts based on its availability:

- The `userScripts` permission is optional and can be granted by the user via
the options page (`options.html` + `options.mjs`). The permission can also
be granted/revoked via browser UI, by the user, as documented at
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 the (un)availability of the `userScripts` API.

- When the `userScripts` API is available at the startup of `background.js`,
and when the permission detected via `permissions.onAdded`, the initialization
starts (via the `ensureUserScriptsRegistered` function in `background.js`).

- When the `userScripts` API is unavailable at the startup of `background.js`,
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 one 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.js](userscript_manager_logic.js) for
comments and the conversion logic from a user script string to the format as
expected by the userScripts API (RegisteredUserScript).

Isolating user scripts in their own execution context (`USER_SCRIPT` world),
and conditionally exposing custom functions to user scripts:

- Shows the use of multiple `USER_SCRIPT` worlds (with distinct `worldId`) to
define separate 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`).
176 changes: 176 additions & 0 deletions userScripts-mv3/background.js
Original file line number Diff line number Diff line change
@@ -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, to open
// 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/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.js

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 extension's onboarding logic on extension installation, and
// not e.g. on browser update or extension updates.
return;
}
if (!isUserScriptsAPIAvailable()) {
// The extension needs the "userScripts" permission, but this has not been
// granted. Open the extension's options_ui page where we have implemented
// onboarding logic, in options.html + options.mjs
browser.runtime.openOptionsPage();
}
});

browser.permissions.onRemoved.addListener(permissions => {
if (permissions.permissions.includes("userScripts")) {
// Pretend that userScripts was not available, so that if the permission is
// restored, that permissions.onAdded will re-initialize.
userScriptsAvailableAtStartup = false;

// Clear cached state, so that ensureUserScriptsRegistered() will refresh
// the registered user scripts if the permissions 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, then we
// would already have detected the availability of the userScripts API
// and 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 we can apply!
applyUserScripts(changes.savedScripts.newValue);
}
});

if (userScriptsAvailableAtStartup) {
// Register listener immediately if the API is available, in case the
// background.js was awakened 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 has already been initialized, e.g. at 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 completion of initialization, to avoid running all of
// 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: assuming 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 make sure that we only update scripts that have changed.
let oldScripts = await browser.userScripts.getScripts();

let {
scriptsToRemove,
scriptsToUpdate,
scriptsToRegister,
} = managerLogic.computeScriptDifferences(oldScripts, scripts);

// Now we have computed the changed scripts, apply the changes in this order:
// 1. Unregister obsolete scripts.
// 2. Reset / configure worlds.
// 3. Update / 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 loaded 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 (scriptsToRemove.length) {
let worldIds = scriptsToRemove.map(s => s.id);
await browser.userScripts.unregister({ worldIds });
}

// 2. Reset / configure worlds.
if (scripts.some(s => s.worldId)) {
// When a userscripts need privileged functionality, we 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 / register new scripts.
if (scriptsToUpdate.length) {
await browser.userScripts.update(scriptsToUpdate);
}
if (scriptsToRegister.length) {
await browser.userScripts.register(scriptsToRegister);
}
}
21 changes: 21 additions & 0 deletions userScripts-mv3/manifest.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"strict_min_version": "134.0a1"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I put strict_min_version 134 because this is the first version of Firefox where the API landed, behind the extensions.userScripts.mv3.enabled preference. I may update it to 135 or 136 once we ship it by default on release.

}
}
}
5 changes: 5 additions & 0 deletions userScripts-mv3/options.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#edit_script_dialog .source_text {
display: block;
width: 80vw;
min-height: 10em;
}
32 changes: 32 additions & 0 deletions userScripts-mv3/options.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width"> <!-- mobile-friendly -->
<meta name="color-scheme" content="dark light"><!-- Dark theme support -->
<link rel="stylesheet" type="text/css" href="options.css">
</head>
<body>
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:
<button id="grant_userScripts_permission"></button>

<dialog id="edit_script_dialog">
<div>
Please input a user script and save it.<br>
<button id="sample_unprivileged">Example: Unprivileged user script</button>
<button id="sample_privileged">Example: Privileged user script</button>
</div>
<textarea class="source_text"></textarea>
<button class="save_button">Save</button>
<button class="remove_button">Remove</button>
<output class="validation_status"></output>
</dialog>

<ul id="list_of_scripts">
<li><button id="add_new">Add new user script</button></li>
</ul>

<script src="options.mjs" type="module"></script>
</body>
</html>
Loading
Loading