|
| 1 | +"use strict"; |
| 2 | + |
| 3 | +// This background.js file is responsible for observing the availability of the |
| 4 | +// userScripts API, and registering user scripts when needed. |
| 5 | +// |
| 6 | +// - The runtime.onInstalled event is used to detect new installations, and |
| 7 | +// opens a custom extension UI where the user is asked to grant the |
| 8 | +// "userScripts" permission. |
| 9 | +// |
| 10 | +// - The permissions.onAdded and permissions.onRemoved events detect changes to |
| 11 | +// the "userScripts" permission, whether triggered from the extension UI, or |
| 12 | +// externally (e.g., through browser UI). |
| 13 | +// |
| 14 | +// - The storage.local API is used to store user scripts across extension |
| 15 | +// updates. This is necessary because the userScripts API clears any |
| 16 | +// previously registered scripts when an extension is updated. |
| 17 | +// |
| 18 | +// - The userScripts API manages script registrations with the browser. The |
| 19 | +// applyUserScripts() function in this file demonstrates the relevant aspects |
| 20 | +// to registering and updating user scripts that apply to most extensions |
| 21 | +// that manage user scripts. To keep this file reasonably small, most of the |
| 22 | +// application-specific logic is in userscript_manager_logic.mjs. |
| 23 | + |
| 24 | +function isUserScriptsAPIAvailable() { |
| 25 | + return !!browser.userScripts; |
| 26 | +} |
| 27 | +var userScriptsAvailableAtStartup = isUserScriptsAPIAvailable(); |
| 28 | + |
| 29 | +var managerLogic; // Lazily initialized by ensureManagerLogicLoaded(). |
| 30 | +async function ensureManagerLogicLoaded() { |
| 31 | + if (!managerLogic) { |
| 32 | + managerLogic = await import("./userscript_manager_logic.mjs"); |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +browser.runtime.onInstalled.addListener(details => { |
| 37 | + if (details.reason !== "install") { |
| 38 | + // Only show the extension's onboarding logic on extension installation, |
| 39 | + // and not, e.g., on browser or extension updates. |
| 40 | + return; |
| 41 | + } |
| 42 | + if (!isUserScriptsAPIAvailable()) { |
| 43 | + // The extension needs the "userScripts" permission, but this is not |
| 44 | + // granted. Open the extension's options_ui page, which implements |
| 45 | + // onboarding logic, in options.html + options.mjs. |
| 46 | + browser.runtime.openOptionsPage(); |
| 47 | + } |
| 48 | +}); |
| 49 | + |
| 50 | +browser.permissions.onRemoved.addListener(permissions => { |
| 51 | + if (permissions.permissions.includes("userScripts")) { |
| 52 | + // Pretend that userScripts is not available, to enable permissions.onAdded |
| 53 | + // to re-initialize when the permission is restored. |
| 54 | + userScriptsAvailableAtStartup = false; |
| 55 | + |
| 56 | + // Clear the cached state, so that ensureUserScriptsRegistered() refreshes |
| 57 | + // the registered user scripts when the permission is granted again. |
| 58 | + browser.storage.session.remove("didInitScripts"); |
| 59 | + |
| 60 | + // Note: the "userScripts" namespace is unavailable, so we cannot and |
| 61 | + // should not try to unregister scripts. |
| 62 | + } |
| 63 | +}); |
| 64 | + |
| 65 | +browser.permissions.onAdded.addListener(permissions => { |
| 66 | + if (permissions.permissions.includes("userScripts")) { |
| 67 | + if (userScriptsAvailableAtStartup) { |
| 68 | + // If background.js woke up to dispatch permissions.onAdded, it has |
| 69 | + // detected the availability of the userScripts API and immediately |
| 70 | + // started initialization. Return now to avoid double-initialization. |
| 71 | + return; |
| 72 | + } |
| 73 | + browser.runtime.onUserScriptMessage.addListener(onUserScriptMessage); |
| 74 | + ensureUserScriptsRegistered(); |
| 75 | + } |
| 76 | +}); |
| 77 | + |
| 78 | +// When the user modifies a user script in options.html + options.mjs, the |
| 79 | +// changes are stored in storage.local and this listener is triggered. |
| 80 | +browser.storage.local.onChanged.addListener(changes => { |
| 81 | + if (changes.savedScripts?.newValue && isUserScriptsAPIAvailable()) { |
| 82 | + // userScripts API is available and there are changes that can be applied. |
| 83 | + applyUserScripts(changes.savedScripts.newValue); |
| 84 | + } |
| 85 | +}); |
| 86 | + |
| 87 | +if (userScriptsAvailableAtStartup) { |
| 88 | + // Register listener immediately if the API is available, in case the |
| 89 | + // background.js is woken to dispatch the onUserScriptMessage event. |
| 90 | + browser.runtime.onUserScriptMessage.addListener(onUserScriptMessage); |
| 91 | + ensureUserScriptsRegistered(); |
| 92 | +} |
| 93 | + |
| 94 | +async function onUserScriptMessage(message, sender) { |
| 95 | + await ensureManagerLogicLoaded(); |
| 96 | + return managerLogic.handleUserScriptMessage(message, sender); |
| 97 | +} |
| 98 | + |
| 99 | +async function ensureUserScriptsRegistered() { |
| 100 | + let { didInitScripts } = await browser.storage.session.get("didInitScripts"); |
| 101 | + if (didInitScripts) { |
| 102 | + // The scripts are initialized, e.g., by a (previous) startup of this |
| 103 | + // background script. Skip expensive initialization. |
| 104 | + return; |
| 105 | + } |
| 106 | + let { savedScripts } = await browser.storage.local.get("savedScripts"); |
| 107 | + savedScripts ||= []; |
| 108 | + try { |
| 109 | + await applyUserScripts(savedScripts); |
| 110 | + } finally { |
| 111 | + // Set a flag to mark the completion of initialization, to avoid running |
| 112 | + // this logic again at the next startup of this background.js script. |
| 113 | + await browser.storage.session.set({ didInitScripts: true }); |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +async function applyUserScripts(userScriptTexts) { |
| 118 | + await ensureManagerLogicLoaded(); |
| 119 | + // Note: assumes userScriptTexts to be valid, validated by options.mjs. |
| 120 | + let scripts = userScriptTexts.map(str => managerLogic.parseUserScript(str)); |
| 121 | + |
| 122 | + // Registering scripts is expensive. Compare the scripts with the old scripts |
| 123 | + // to ensure that only modified scripts are updated. |
| 124 | + let oldScripts = await browser.userScripts.getScripts(); |
| 125 | + |
| 126 | + let { |
| 127 | + scriptIdsToRemove, |
| 128 | + scriptsToUpdate, |
| 129 | + scriptsToRegister, |
| 130 | + } = managerLogic.computeScriptDifferences(oldScripts, scripts); |
| 131 | + |
| 132 | + // Now, for the changed scripts, apply the changes in this order: |
| 133 | + // 1. Unregister obsolete scripts. |
| 134 | + // 2. Reset or configure worlds. |
| 135 | + // 3. Update and/or register new scripts. |
| 136 | + // This order is significant: scripts rely on world configurations, and while |
| 137 | + // running this asynchronous script updating logic, the browser may try to |
| 138 | + // execute any of the registered scripts when a website loads in a tab or |
| 139 | + // iframe, unrelated to the extension execution. |
| 140 | + // To prevent scripts from executing with the wrong world configuration, |
| 141 | + // worlds are configured before new scripts are registered. |
| 142 | + |
| 143 | + // 1. Unregister obsolete scripts. |
| 144 | + if (scriptIdsToRemove.length) { |
| 145 | + await browser.userScripts.unregister({ worldIds: scriptIdsToRemove }); |
| 146 | + } |
| 147 | + |
| 148 | + // 2. Reset or configure worlds. |
| 149 | + if (scripts.some(s => s.worldId)) { |
| 150 | + // When userscripts need privileged functionality, run them in a sandbox |
| 151 | + // (USER_SCRIPT world). To offer privileged functionality, we need |
| 152 | + // a communication channel between the userscript and this privileged side. |
| 153 | + // Specifying "messaging:true" exposes runtime.sendMessage() these worlds, |
| 154 | + // which upon invocation triggers the runtime.onUserScriptMessage event. |
| 155 | + // |
| 156 | + // Calling configureWorld without a specific worldId sets the default world |
| 157 | + // configuration, which is inherit by every other USER_SCRIPT world that |
| 158 | + // does not have a more specific configuration. |
| 159 | + // |
| 160 | + // Since every USER_SCRIPT world in this demo extension has the same world |
| 161 | + // configuration, we can set the default once, without needing to define |
| 162 | + // world-specific configurations. |
| 163 | + await browser.userScripts.configureWorld({ messaging: true }); |
| 164 | + } else { |
| 165 | + // Reset the default world's configuration. |
| 166 | + await browser.userScripts.resetWorldConfiguration(); |
| 167 | + } |
| 168 | + |
| 169 | + // 3. Update and/or register new scripts. |
| 170 | + if (scriptsToUpdate.length) { |
| 171 | + await browser.userScripts.update(scriptsToUpdate); |
| 172 | + } |
| 173 | + if (scriptsToRegister.length) { |
| 174 | + await browser.userScripts.register(scriptsToRegister); |
| 175 | + } |
| 176 | +} |
0 commit comments