forked from mirrors/gecko-dev
		
	
		
			
				
	
	
		
			348 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			348 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* -*- 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/. */
 | |
| "use strict";
 | |
| 
 | |
| const { ExtensionUtils } = ChromeUtils.import(
 | |
|   "resource://gre/modules/ExtensionUtils.jsm"
 | |
| );
 | |
| 
 | |
| const { ExtensionParent } = ChromeUtils.import(
 | |
|   "resource://gre/modules/ExtensionParent.jsm"
 | |
| );
 | |
| 
 | |
| const { XPCOMUtils } = ChromeUtils.import(
 | |
|   "resource://gre/modules/XPCOMUtils.jsm"
 | |
| );
 | |
| 
 | |
| const { StartupCache } = ExtensionParent;
 | |
| 
 | |
| const lazy = {};
 | |
| 
 | |
| XPCOMUtils.defineLazyModuleGetters(lazy, {
 | |
|   FileUtils: "resource://gre/modules/FileUtils.jsm",
 | |
|   KeyValueService: "resource://gre/modules/kvstore.jsm",
 | |
| });
 | |
| 
 | |
| 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 {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 {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}
 | |
|  */
 | |
| 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,
 | |
|       matchAboutBlank: true,
 | |
|       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}
 | |
|  */
 | |
| 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;
 | |
| };
 | |
| 
 | |
| 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(),
 | |
|         ([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;
 | |
|   },
 | |
| };
 | |
| 
 | |
| var EXPORTED_SYMBOLS = [
 | |
|   "ExtensionScriptingStore",
 | |
|   "makeInternalContentScript",
 | |
|   "makePublicContentScript",
 | |
| ];
 | 
