diff options
Diffstat (limited to 'toolkit/components/extensions/ExtensionScriptingStore.sys.mjs')
-rw-r--r-- | toolkit/components/extensions/ExtensionScriptingStore.sys.mjs | 351 |
1 files changed, 351 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs b/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs new file mode 100644 index 0000000000..444af8e41f --- /dev/null +++ b/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs @@ -0,0 +1,351 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const { StartupCache } = ExtensionParent; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + KeyValueService: "resource://gre/modules/kvstore.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "matchAboutBlankDefaultFalse", + "extensions.scripting.matchAboutBlankDefaultFalse", + false +); + +class Store { + async _init() { + const { path: storePath } = lazy.FileUtils.getDir("ProfD", [ + "extension-store", + ]); + // Make sure the folder exists. + await IOUtils.makeDirectory(storePath, { ignoreExisting: true }); + this._store = await lazy.KeyValueService.getOrCreate( + storePath, + "scripting-contentScripts" + ); + } + + lazyInit() { + if (!this._initPromise) { + this._initPromise = this._init(); + } + + return this._initPromise; + } + + /** + * Returns all the stored scripts for a given extension (ID). + * + * @param {string} extensionId An extension ID + * @returns {Promise<Array>} An array of scripts + */ + async getAll(extensionId) { + await this.lazyInit(); + const pairs = await this.getByExtensionId(extensionId); + + return pairs.map(([_, script]) => script); + } + + /** + * Writes all the scripts provided for a given extension (ID) to the internal + * store (which is eventually stored on disk). + * + * We store each script of an extension as a key/value pair where the key is + * `<extensionId>/<scriptId>` and the value is the corresponding script + * details as a JSON string. + * + * The format on disk should look like this one: + * + * ``` + * { + * "@extension-id/script-1": {"id: "script-1", <other props>}, + * "@extension-id/script-2": {"id: "script-2", <other props>} + * } + * ``` + * + * @param {string} extensionId An extension ID + * @param {Array} scripts An array of scripts to store on disk + */ + async writeMany(extensionId, scripts) { + await this.lazyInit(); + + return this._store.writeMany( + scripts.map(script => [ + `${extensionId}/${script.id}`, + JSON.stringify(script), + ]) + ); + } + + /** + * Deletes all the stored scripts for a given extension (ID). + * + * @param {string} extensionId An extension ID + */ + async deleteAll(extensionId) { + await this.lazyInit(); + const pairs = await this.getByExtensionId(extensionId); + + return Promise.all(pairs.map(([key, _]) => this._store.delete(key))); + } + + /** + * Returns an array of key/script pairs from the internal store belonging to + * the given extension (ID). + * + * The data returned by this method should look like this (assuming we have + * two scripts named `script-1` and `script-2` for the extension with ID + * `@extension-id`): + * + * ``` + * [ + * ["@extension-id/script-1", {"id: "script-1", <other props>}], + * ["@extension-id/script-2", {"id: "script-2", <other props>}] + * ] + * ``` + * + * @param {string} extensionId An extension ID + * @returns {Promise<Array>} An array of key/script pairs + */ + async getByExtensionId(extensionId) { + await this.lazyInit(); + + const entries = []; + // Retrieve all the scripts registered for the given extension ID by + // enumerating all keys that are stored in a lexical order. + const enumerator = await this._store.enumerate( + `${extensionId}/`, // from_key (inclusive) + `${extensionId}0` // to_key (exclusive) + ); + + while (enumerator.hasMoreElements()) { + const { key, value } = enumerator.getNext(); + entries.push([key, JSON.parse(value)]); + } + + return entries; + } +} + +const store = new Store(); + +/** + * Given an extension and some content script options, this function returns + * the content script representation we use internally, which is an object with + * a `scriptId` and a nested object containing `options`. These (internal) + * objects are shared with all content processes using IPC/sharedData. + * + * This function can optionally prepend the extension's base URL to the CSS and + * JS paths, which is needed when we load internal scripts from the scripting + * store (because the UUID in the base URL changes). + * + * @param {Extension} extension + * The extension that owns the content script. + * @param {object} options + * Content script options. + * @param {boolean} prependBaseURL + * Whether to prepend JS and CSS paths with the extension's base URL. + * + * @returns {object} + */ +export const makeInternalContentScript = ( + extension, + options, + prependBaseURL = false +) => { + let cssPaths = options.css || []; + let jsPaths = options.js || []; + + if (prependBaseURL) { + cssPaths = cssPaths.map(css => `${extension.baseURL}${css}`); + jsPaths = jsPaths.map(js => `${extension.baseURL}${js}`); + } + + return { + scriptId: ExtensionUtils.getUniqueId(), + options: { + // We need to store the user-supplied script ID for persisted scripts. + id: options.id, + allFrames: options.allFrames || false, + // Although this flag defaults to true with MV3, it is not with MV2. + // Check permissions at runtime since we aren't checking permissions + // upfront. + checkPermissions: true, + cssPaths, + excludeMatches: options.excludeMatches, + jsPaths, + // TODO(Bug 1853411): revert the short-term workaround special casing + // webcompat extension id once it is not necessary anymore. + matchAboutBlank: lazy.matchAboutBlankDefaultFalse + ? false // If the hidden pref is set, then forcefully set matchAboutBlank to false + : extension.id !== "webcompat@mozilla.org", + matches: options.matches, + originAttributesPatterns: null, + persistAcrossSessions: options.persistAcrossSessions, + runAt: options.runAt || "document_idle", + }, + }; +}; + +/** + * Given an internal content script registered with the "scripting" API (and an + * extension), this function returns a new object that matches the public + * "scripting" API. + * + * This function is primarily in `scripting.getRegisteredContentScripts()`. + * + * @param {Extension} extension + * The extension that owns the content script. + * @param {object} internalScript + * An internal script (see also: `makeInternalContentScript()`). + * + * @returns {object} + */ +export const makePublicContentScript = (extension, internalScript) => { + let script = { + id: internalScript.id, + allFrames: internalScript.allFrames, + matches: internalScript.matches, + runAt: internalScript.runAt, + persistAcrossSessions: internalScript.persistAcrossSessions, + }; + + if (internalScript.cssPaths.length) { + script.css = internalScript.cssPaths.map(cssPath => + cssPath.replace(extension.baseURL, "") + ); + } + + if (internalScript.excludeMatches?.length) { + script.excludeMatches = internalScript.excludeMatches; + } + + if (internalScript.jsPaths.length) { + script.js = internalScript.jsPaths.map(jsPath => + jsPath.replace(extension.baseURL, "") + ); + } + + return script; +}; + +export const ExtensionScriptingStore = { + async initExtension(extension) { + let scripts; + + // On downgrades/upgrades (and re-installation on top of an existing one), + // we do clear any previously stored scripts and return earlier. + switch (extension.startupReason) { + case "ADDON_INSTALL": + case "ADDON_UPGRADE": + case "ADDON_DOWNGRADE": + // On extension upgrades/downgrades the StartupCache data for the + // extension would already be cleared, and so we set the hasPersistedScripts + // flag here just to avoid having to check that (by loading the rkv store data) + // on the next startup. + StartupCache.general.set( + [extension.id, extension.version, "scripting", "hasPersistedScripts"], + false + ); + store.deleteAll(extension.id); + return; + } + + const hasPersistedScripts = await StartupCache.get( + extension, + ["scripting", "hasPersistedScripts"], + async () => { + scripts = await store.getAll(extension.id); + return !!scripts.length; + } + ); + + if (!hasPersistedScripts) { + return; + } + + // Load the scripts from the storage, then convert them to their internal + // representation and add them to the extension's registered scripts. + scripts ??= await store.getAll(extension.id); + + scripts.forEach(script => { + const { scriptId, options } = makeInternalContentScript( + extension, + script, + true /* prepend the css/js paths with the extension base URL */ + ); + extension.registeredContentScripts.set(scriptId, options); + }); + }, + + getInitialScriptIdsMap(extension) { + // This returns the current map of public script IDs to internal IDs. + // `extension.registeredContentScripts` is initialized in `initExtension`, + // which may be updated later via the scripting API. In practice, the map + // of script IDs is retrieved before any scripting API method is exposed, + // so the return value always matches the initial result from + // `initExtension`. + return new Map( + Array.from(extension.registeredContentScripts.entries()) + .filter( + // Filter out entries without an options.id property, which are the + // ones registered through the contentScripts API namespace where the + // id attribute is not allowed, while it is mandatory for the + // scripting API namespace. + ([_id, options]) => options.id?.length + ) + .map(([scriptId, options]) => [options.id, scriptId]) + ); + }, + + async persistAll(extension) { + // We only persist the scripts that should be persisted and we convert each + // script to their "public" representation before storing them. This is + // because we don't want to deal with data migrations if we ever want to + // change the internal representation (the "public" representation is less + // likely to change because it is bound to the public scripting API). + const scripts = Array.from(extension.registeredContentScripts.values()) + .filter(options => options.persistAcrossSessions) + .map(options => makePublicContentScript(extension, options)); + + // We want to replace all the scripts for the extension so we should delete + // the existing ones first, and then write the new ones. + // + // TODO: Bug 1783131 - Implement individual updates without requiring all + // data to be erased and written. + await store.deleteAll(extension.id); + await store.writeMany(extension.id, scripts); + StartupCache.general.set( + [extension.id, extension.version, "scripting", "hasPersistedScripts"], + !!scripts.length + ); + }, + + // Delete all the persisted scripts for the given extension (id). + // + // NOTE: to be only used on addon uninstall, the extension entry in the StartupCache + // is expected to also be fully cleared as part of handling the addon uninstall. + async clearOnUninstall(extensionId) { + await store.deleteAll(extensionId); + }, + + // As its name implies, don't use this method for anything but an easy access + // to the internal store for testing purposes. + _getStoreForTesting() { + return store; + }, +}; |