diff options
Diffstat (limited to 'devtools/server/actors/resources/storage/extension-storage.js')
-rw-r--r-- | devtools/server/actors/resources/storage/extension-storage.js | 491 |
1 files changed, 491 insertions, 0 deletions
diff --git a/devtools/server/actors/resources/storage/extension-storage.js b/devtools/server/actors/resources/storage/extension-storage.js new file mode 100644 index 0000000000..d14d3320c7 --- /dev/null +++ b/devtools/server/actors/resources/storage/extension-storage.js @@ -0,0 +1,491 @@ +/* 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 { + BaseStorageActor, +} = require("resource://devtools/server/actors/resources/storage/index.js"); +const { + parseItemValue, +} = require("resource://devtools/shared/storage/utils.js"); +const { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); +// Use loadInDevToolsLoader: false for these extension modules, because these +// are singletons with shared state, and we must not create a new instance if a +// dedicated loader was used to load this module. +loader.lazyGetter(this, "ExtensionParent", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs", + { loadInDevToolsLoader: false } + ).ExtensionParent; +}); +loader.lazyGetter(this, "ExtensionProcessScript", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + { loadInDevToolsLoader: false } + ).ExtensionProcessScript; +}); +loader.lazyGetter(this, "ExtensionStorageIDB", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs", + { loadInDevToolsLoader: false } + ).ExtensionStorageIDB; +}); + +/** + * The Extension Storage actor. + */ +class ExtensionStorageActor extends BaseStorageActor { + constructor(storageActor) { + super(storageActor, "extensionStorage"); + + this.addonId = this.storageActor.parentActor.addonId; + + // Retrieve the base moz-extension url for the extension + // (and also remove the final '/' from it). + this.extensionHostURL = this.getExtensionPolicy().getURL().slice(0, -1); + + // Map<host, ExtensionStorageIDB db connection> + // Bug 1542038, 1542039: Each storage area will need its own + // dbConnectionForHost, as they each have different storage backends. + // Anywhere dbConnectionForHost is used, we need to know the storage + // area to access the correct database. + this.dbConnectionForHost = new Map(); + + this.onExtensionStartup = this.onExtensionStartup.bind(this); + + this.onStorageChange = this.onStorageChange.bind(this); + } + + getExtensionPolicy() { + return WebExtensionPolicy.getByID(this.addonId); + } + + destroy() { + ExtensionStorageIDB.removeOnChangedListener( + this.addonId, + this.onStorageChange + ); + ExtensionParent.apiManager.off("startup", this.onExtensionStartup); + + super.destroy(); + } + + /** + * We need to override this method as we ignore BaseStorageActor's hosts + * and only care about the extension host. + */ + async populateStoresForHosts() { + // Ensure the actor's target is an extension and it is enabled + if (!this.addonId || !this.getExtensionPolicy()) { + return; + } + + // Subscribe a listener for event notifications from the WE storage API when + // storage local data has been changed by the extension, and keep track of the + // listener to remove it when the debugger is being disconnected. + ExtensionStorageIDB.addOnChangedListener( + this.addonId, + this.onStorageChange + ); + + try { + // Make sure the extension storage APIs have been loaded, + // otherwise the DevTools storage panel would not be updated + // automatically when the extension storage data is being changed + // if the parent ext-storage.js module wasn't already loaded + // (See Bug 1802929). + const { extension } = WebExtensionPolicy.getByID(this.addonId); + await extension.apiManager.asyncGetAPI("storage", extension); + // Also watch for addon reload in order to also do that + // on next addon startup, otherwise we may also miss updates + ExtensionParent.apiManager.on("startup", this.onExtensionStartup); + } catch (e) { + console.error( + "Exception while trying to initialize webext storage API", + e + ); + } + + await this.populateStoresForHost(this.extensionHostURL); + } + + /** + * AddonManager listener used to force instantiating storage API + * implementation in the parent process so that it forward content process + * messages to ExtensionStorageIDB. + * + * Without this, we may miss storage updated after the addon reload. + */ + async onExtensionStartup(_evtName, extension) { + if (extension.id != this.addonId) { + return; + } + await extension.apiManager.asyncGetAPI("storage", extension); + } + + /** + * This method asynchronously reads the storage data for the target extension + * and caches this data into this.hostVsStores. + * @param {String} host - the hostname for the extension + */ + async populateStoresForHost(host) { + if (host !== this.extensionHostURL) { + return; + } + + const extension = ExtensionProcessScript.getExtensionChild(this.addonId); + if (!extension || !extension.hasPermission("storage")) { + return; + } + + // Make sure storeMap is defined and set in this.hostVsStores before subscribing + // a storage onChanged listener in the parent process + const storeMap = new Map(); + this.hostVsStores.set(host, storeMap); + + const storagePrincipal = await this.getStoragePrincipal(); + + if (!storagePrincipal) { + // This could happen if the extension fails to be migrated to the + // IndexedDB backend + return; + } + + const db = await ExtensionStorageIDB.open(storagePrincipal); + this.dbConnectionForHost.set(host, db); + const data = await db.get(); + + for (const [key, value] of Object.entries(data)) { + storeMap.set(key, value); + } + + if (this.storageActor.parentActor.fallbackWindow) { + // Show the storage actor in the add-on storage inspector even when there + // is no extension page currently open + // This strategy may need to change depending on the outcome of Bug 1597900 + const storageData = {}; + storageData[host] = this.getNamesForHost(host); + this.storageActor.update("added", this.typeName, storageData); + } + } + /** + * This fires when the extension changes storage data while the storage + * inspector is open. Ensures this.hostVsStores stays up-to-date and + * passes the changes on to update the client. + */ + onStorageChange(changes) { + const host = this.extensionHostURL; + const storeMap = this.hostVsStores.get(host); + + function isStructuredCloneHolder(value) { + return ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "StructuredCloneHolder" + ); + } + + for (const key in changes) { + const storageChange = changes[key]; + let { newValue, oldValue } = storageChange; + if (isStructuredCloneHolder(newValue)) { + newValue = newValue.deserialize(this); + } + if (isStructuredCloneHolder(oldValue)) { + oldValue = oldValue.deserialize(this); + } + + let action; + if (typeof newValue === "undefined") { + action = "deleted"; + storeMap.delete(key); + } else if (typeof oldValue === "undefined") { + action = "added"; + storeMap.set(key, newValue); + } else { + action = "changed"; + storeMap.set(key, newValue); + } + + this.storageActor.update(action, this.typeName, { [host]: [key] }); + } + } + + async getStoragePrincipal() { + const { extension } = this.getExtensionPolicy(); + const { backendEnabled, storagePrincipal } = + await ExtensionStorageIDB.selectBackend({ extension }); + + if (!backendEnabled) { + // IDB backend disabled; give up. + return null; + } + + // Received as a StructuredCloneHolder, so we need to deserialize + return storagePrincipal.deserialize(this, true); + } + + getValuesForHost(host, name) { + const result = []; + + if (!this.hostVsStores.has(host)) { + return result; + } + + if (name) { + return [{ name, value: this.hostVsStores.get(host).get(name) }]; + } + + for (const [key, value] of Array.from( + this.hostVsStores.get(host).entries() + )) { + result.push({ name: key, value }); + } + return result; + } + + /** + * Converts a storage item to an "extensionobject" as defined in + * devtools/shared/specs/storage.js. Behavior largely mirrors the "indexedDB" storage actor, + * except where it would throw an unhandled error (i.e. for a `BigInt` or `undefined` + * `item.value`). + * @param {Object} item - The storage item to convert + * @param {String} item.name - The storage item key + * @param {*} item.value - The storage item value + * @return {extensionobject} + */ + toStoreObject(item) { + if (!item) { + return null; + } + + let { name, value } = item; + const isValueEditable = extensionStorageHelpers.isEditable(value); + + // `JSON.stringify()` throws for `BigInt`, adds extra quotes to strings and `Date` strings, + // and doesn't modify `undefined`. + switch (typeof value) { + case "bigint": + value = `${value.toString()}n`; + break; + case "string": + break; + case "undefined": + value = "undefined"; + break; + default: + value = JSON.stringify(value); + if ( + // can't use `instanceof` across frame boundaries + Object.prototype.toString.call(item.value) === "[object Date]" + ) { + value = JSON.parse(value); + } + } + + return { + name, + value: new LongStringActor(this.conn, value), + area: "local", // Bug 1542038, 1542039: set the correct storage area + isValueEditable, + }; + } + + getFields() { + return [ + { name: "name", editable: false }, + { name: "value", editable: true }, + { name: "area", editable: false }, + { name: "isValueEditable", editable: false, private: true }, + ]; + } + + onItemUpdated(action, host, names) { + this.storageActor.update(action, this.typeName, { + [host]: names, + }); + } + + async editItem({ host, field, items, oldValue }) { + const db = this.dbConnectionForHost.get(host); + if (!db) { + return; + } + + const { name, value } = items; + + let parsedValue = parseItemValue(value); + if (parsedValue === value) { + const { typesFromString } = extensionStorageHelpers; + for (const { test, parse } of Object.values(typesFromString)) { + if (test(value)) { + parsedValue = parse(value); + break; + } + } + } + const changes = await db.set({ [name]: parsedValue }); + this.fireOnChangedExtensionEvent(host, changes); + + this.onItemUpdated("changed", host, [name]); + } + + async removeItem(host, name) { + const db = this.dbConnectionForHost.get(host); + if (!db) { + return; + } + + const changes = await db.remove(name); + this.fireOnChangedExtensionEvent(host, changes); + + this.onItemUpdated("deleted", host, [name]); + } + + async removeAll(host) { + const db = this.dbConnectionForHost.get(host); + if (!db) { + return; + } + + const changes = await db.clear(); + this.fireOnChangedExtensionEvent(host, changes); + + this.onItemUpdated("cleared", host, []); + } + + /** + * Let the extension know that storage data has been changed by the user from + * the storage inspector. + */ + fireOnChangedExtensionEvent(host, changes) { + // Bug 1542038, 1542039: Which message to send depends on the storage area + const uuid = new URL(host).host; + Services.cpmm.sendAsyncMessage( + `Extension:StorageLocalOnChanged:${uuid}`, + changes + ); + } +} +exports.ExtensionStorageActor = ExtensionStorageActor; + +const extensionStorageHelpers = { + /** + * Editing is supported only for serializable types. Examples of unserializable + * types include Map, Set and ArrayBuffer. + */ + isEditable(value) { + // Bug 1542038: the managed storage area is never editable + for (const { test } of Object.values(this.supportedTypes)) { + if (test(value)) { + return true; + } + } + return false; + }, + isPrimitive(value) { + const primitiveValueTypes = ["string", "number", "boolean"]; + return primitiveValueTypes.includes(typeof value) || value === null; + }, + isObjectLiteral(value) { + return ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "Object" + ); + }, + // Nested arrays or object literals are only editable 2 levels deep + isArrayOrObjectLiteralEditable(obj) { + const topLevelValuesArr = Array.isArray(obj) ? obj : Object.values(obj); + if ( + topLevelValuesArr.some( + value => + !this.isPrimitive(value) && + !Array.isArray(value) && + !this.isObjectLiteral(value) + ) + ) { + // At least one value is too complex to parse + return false; + } + const arrayOrObjects = topLevelValuesArr.filter( + value => Array.isArray(value) || this.isObjectLiteral(value) + ); + if (arrayOrObjects.length === 0) { + // All top level values are primitives + return true; + } + + // One or more top level values was an array or object literal. + // All of these top level values must themselves have only primitive values + // for the object to be editable + for (const nestedObj of arrayOrObjects) { + const secondLevelValuesArr = Array.isArray(nestedObj) + ? nestedObj + : Object.values(nestedObj); + if (secondLevelValuesArr.some(value => !this.isPrimitive(value))) { + return false; + } + } + return true; + }, + typesFromString: { + // Helper methods to parse string values in editItem + jsonifiable: { + test(str) { + try { + JSON.parse(str); + } catch (e) { + return false; + } + return true; + }, + parse(str) { + return JSON.parse(str); + }, + }, + }, + supportedTypes: { + // Helper methods to determine the value type of an item in isEditable + array: { + test(value) { + if (Array.isArray(value)) { + return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value); + } + return false; + }, + }, + boolean: { + test(value) { + return typeof value === "boolean"; + }, + }, + null: { + test(value) { + return value === null; + }, + }, + number: { + test(value) { + return typeof value === "number"; + }, + }, + object: { + test(value) { + if (extensionStorageHelpers.isObjectLiteral(value)) { + return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value); + } + return false; + }, + }, + string: { + test(value) { + return typeof value === "string"; + }, + }, + }, +}; |