Skip to content

Commit

Permalink
Apply suggestions from review, fix typo in document_idle, rewrap READ…
Browse files Browse the repository at this point in the history
…ME.md
  • Loading branch information
Rob--W committed Jan 15, 2025
1 parent 4c9022b commit 82331d2
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 57 deletions.
39 changes: 26 additions & 13 deletions userScripts-mv3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,31 @@ This demo covers the following aspects to extension development:
- 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.
- 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:
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.
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.
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, then a visit to https://example.com/ should show this behavior:
If you repeat steps 2-4 for both examples, then a visit to https://example.com/
should show 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
Expand All @@ -53,11 +60,12 @@ Showing onboarding UI 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 event pages that can suspend on inactivity.
- 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.
- 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.

Expand All @@ -66,12 +74,17 @@ 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 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.
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 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
Expand Down
78 changes: 39 additions & 39 deletions userScripts-mv3/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,23 @@
// 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 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).
// 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
// 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
// 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.js.

function isUserScriptsAPIAvailable() {
return !!browser.userScripts;
Expand All @@ -34,26 +35,26 @@ async function ensureManagerLogicLoaded() {

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.
// 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 has not been
// granted. Open the extension's options_ui page where we have implemented
// onboarding logic, in options.html + options.mjs
// 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 was not available, so that if the permission is
// restored, that permissions.onAdded will re-initialize.
// Pretend that userScripts is not available, to enable permissions.onAdded
// to re-initialize when the permission is restored.
userScriptsAvailableAtStartup = false;

// Clear cached state, so that ensureUserScriptsRegistered() will refresh
// the registered user scripts if the permissions is granted again.
// 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
Expand All @@ -64,28 +65,28 @@ browser.permissions.onRemoved.addListener(permissions => {
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.
// 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
// 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!
// 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 was awakened to dispatch the onUserScriptMessage event.
// background.js is woken to dispatch the onUserScriptMessage event.
browser.runtime.onUserScriptMessage.addListener(onUserScriptMessage);
ensureUserScriptsRegistered();
}
Expand All @@ -98,57 +99,56 @@ async function onUserScriptMessage(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.
// 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 completion of initialization, to avoid running all of
// 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: assuming userScriptTexts to be valid, validated by options.mjs.
// 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 make sure that we only update scripts that have changed.
// to ensure that only modified scripts are updated.
let oldScripts = await browser.userScripts.getScripts();

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

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

// 2. Reset / configure worlds.
// 2. Reset or 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
// 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.
Expand All @@ -166,7 +166,7 @@ async function applyUserScripts(userScriptTexts) {
await browser.userScripts.resetWorldConfiguration();
}

// 3. Update / register new scripts.
// 3. Update and/or register new scripts.
if (scriptsToUpdate.length) {
await browser.userScripts.update(scriptsToUpdate);
}
Expand Down
2 changes: 1 addition & 1 deletion userScripts-mv3/options.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function isValidMatchPattern(str) {
}

/**
* Shows the form where the user can edit or create a new user script.
* Shows the form where the user can edit or create a user script.
*
* @param {string} userScriptText - Non-empty if editing an existing script.
*/
Expand Down
3 changes: 3 additions & 0 deletions userScripts-mv3/userscript_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ globalThis.initCustomAPIForUserScripts = grants => {
// 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" });
};
Expand Down
8 changes: 4 additions & 4 deletions userScripts-mv3/userscript_manager_logic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ function parseRunAt(runAtFromUserScriptMetadata) {
switch (runAtFromUserScriptMetadata) {
case "document-start": return "document_start";
case "document-end": return "document_end";
case "document_idle": return "document_idle";
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";
Expand All @@ -147,14 +147,14 @@ function isSameRegisteredUserScript(oldScript, newScript) {
}

export function computeScriptDifferences(oldScripts, newScripts) {
let scriptsToRemove = [];
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.
scriptsToRemove.push(script.id);
scriptIdsToRemove.push(script.id);
}
}
for (let script of newScripts) {
Expand All @@ -169,7 +169,7 @@ export function computeScriptDifferences(oldScripts, newScripts) {
}
}

return { scriptsToRemove, scriptsToUpdate, scriptsToRegister };
return { scriptIdsToRemove, scriptsToUpdate, scriptsToRegister };
}

function parseGrants(grantsFromUserScriptMetadata) {
Expand Down

0 comments on commit 82331d2

Please sign in to comment.