-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
Rob--W
wants to merge
1
commit into
mdn:main
Choose a base branch
from
Rob--W:userScripts-mv3-sample
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 theextensions.userScripts.mv3.enabled
preference. I may update it to 135 or 136 once we ship it by default on release.