Add example using MV3 userScripts API (#576)

This commit is contained in:
Rob Wu
2025-02-24 00:13:40 +01:00
committed by GitHub
parent 2ecc2198b3
commit d1116e32e9
13 changed files with 918 additions and 2 deletions

View File

@@ -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"],

View File

@@ -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`.

View File

@@ -0,0 +1,10 @@
{
"overrides": [
{
"files": ["*.mjs"],
"parserOptions": {
"sourceType": "module"
}
}
]
}

130
userScripts-mv3/README.md Normal file
View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,5 @@
#edit_script_dialog .source_text {
display: block;
width: 80vw;
min-height: 10em;
}

View File

@@ -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>

176
userScripts-mv3/options.mjs Normal file
View File

@@ -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();

View File

@@ -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!
};

View File

@@ -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);
};
}

View File

@@ -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...?");
}

View File

@@ -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);
}