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: + + + +
+ Please input a user script and save it.
+ + +
+ + + + +
+ + + + + + 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); +}