summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/ExtensionScriptingStore.sys.mjs')
-rw-r--r--toolkit/components/extensions/ExtensionScriptingStore.sys.mjs351
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;
+ },
+};