diff options
Diffstat (limited to 'toolkit/components/extensions/ExtensionStorage.sys.mjs')
-rw-r--r-- | toolkit/components/extensions/ExtensionStorage.sys.mjs | 573 |
1 files changed, 573 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionStorage.sys.mjs b/toolkit/components/extensions/ExtensionStorage.sys.mjs new file mode 100644 index 0000000000..4155fbaa24 --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorage.sys.mjs @@ -0,0 +1,573 @@ +/* -*- 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"; + +const { DefaultWeakMap, ExtensionError } = ExtensionUtils; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", +}); + +function isStructuredCloneHolder(value) { + return ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "StructuredCloneHolder" + ); +} + +class SerializeableMap extends Map { + toJSON() { + let result = {}; + for (let [key, value] of this) { + if (isStructuredCloneHolder(value)) { + value = value.deserialize(globalThis); + this.set(key, value); + } + + result[key] = value; + } + return result; + } + + /** + * Like toJSON, but attempts to serialize every value separately, and + * elides any which fail to serialize. Should only be used if initial + * JSON serialization fails. + * + * @returns {object} + */ + toJSONSafe() { + let result = {}; + for (let [key, value] of this) { + try { + void JSON.stringify(value); + + result[key] = value; + } catch (e) { + Cu.reportError( + new Error(`Failed to serialize browser.storage key "${key}": ${e}`) + ); + } + } + return result; + } +} + +/** + * Serializes an arbitrary value into a StructuredCloneHolder, if + * appropriate. Existing StructuredCloneHolders are returned unchanged. + * Non-object values are also returned unchanged. Anything else is + * serialized, and a new StructuredCloneHolder returned. + * + * This allows us to avoid a second structured clone operation after + * sending a storage value across a message manager, before cloning it + * into an extension scope. + * + * @param {string} name + * A debugging name for the value, which will appear in the + * StructuredCloneHolder's about:memory path. + * @param {string?} anonymizedName + * An anonymized version of `name`, to be used in anonymized memory + * reports. If `null`, then `name` will be used instead. + * @param {StructuredCloneHolder|*} value + * A value to serialize. + * @returns {*} + */ +function serialize(name, anonymizedName, value) { + if (value && typeof value === "object" && !isStructuredCloneHolder(value)) { + return new StructuredCloneHolder(name, anonymizedName, value); + } + return value; +} + +export var ExtensionStorage = { + // Map<extension-id, Promise<JSONFile>> + jsonFilePromises: new Map(), + + listeners: new Map(), + + /** + * Asynchronously reads the storage file for the given extension ID + * and returns a Promise for its initialized JSONFile object. + * + * @param {string} extensionId + * The ID of the extension for which to return a file. + * @returns {Promise<InstanceType<Lazy['JSONFile']>>} + */ + async _readFile(extensionId) { + await IOUtils.makeDirectory(this.getExtensionDir(extensionId)); + + let jsonFile = new lazy.JSONFile({ + path: this.getStorageFile(extensionId), + }); + await jsonFile.load(); + + jsonFile.data = this._serializableMap(jsonFile.data); + return jsonFile; + }, + + _serializableMap(data) { + return new SerializeableMap(Object.entries(data)); + }, + + /** + * Returns a Promise for initialized JSONFile instance for the + * extension's storage file. + * + * @param {string} extensionId + * The ID of the extension for which to return a file. + * @returns {Promise<InstanceType<Lazy['JSONFile']>>} + */ + getFile(extensionId) { + let promise = this.jsonFilePromises.get(extensionId); + if (!promise) { + promise = this._readFile(extensionId); + this.jsonFilePromises.set(extensionId, promise); + } + return promise; + }, + + /** + * Clear the cached jsonFilePromise for a given extensionId + * (used by ExtensionStorageIDB to free the jsonFile once the data migration + * has been completed). + * + * @param {string} extensionId + * The ID of the extension for which to return a file. + */ + async clearCachedFile(extensionId) { + let promise = this.jsonFilePromises.get(extensionId); + if (promise) { + this.jsonFilePromises.delete(extensionId); + await promise.then(jsonFile => jsonFile.finalize()); + } + }, + + /** + * Sanitizes the given value, and returns a JSON-compatible + * representation of it, based on the privileges of the given global. + * + * @param {any} value + * The value to sanitize. + * @param {Context} context + * The extension context in which to sanitize the value + * @returns {value} + * The sanitized value. + */ + sanitize(value, context) { + let json = context.jsonStringify(value === undefined ? null : value); + if (json == undefined) { + throw new ExtensionError( + "DataCloneError: The object could not be cloned." + ); + } + return JSON.parse(json); + }, + + /** + * Returns the path to the storage directory within the profile for + * the given extension ID. + * + * @param {string} extensionId + * The ID of the extension for which to return a directory path. + * @returns {string} + */ + getExtensionDir(extensionId) { + return PathUtils.join(this.extensionDir, extensionId); + }, + + /** + * Returns the path to the JSON storage file for the given extension + * ID. + * + * @param {string} extensionId + * The ID of the extension for which to return a file path. + * @returns {string} + */ + getStorageFile(extensionId) { + return PathUtils.join(this.extensionDir, extensionId, "storage.js"); + }, + + /** + * Asynchronously sets the values of the given storage items for the + * given extension. + * + * @param {string} extensionId + * The ID of the extension for which to set storage values. + * @param {object} items + * The storage items to set. For each property in the object, + * the storage value for that property is set to its value in + * said object. Any values which are StructuredCloneHolder + * instances are deserialized before being stored. + * @returns {Promise<void>} + */ + async set(extensionId, items) { + let jsonFile = await this.getFile(extensionId); + + let changes = {}; + for (let prop in items) { + let item = items[prop]; + changes[prop] = { + oldValue: serialize( + `set/${extensionId}/old/${prop}`, + `set/${extensionId}/old/<anonymized>`, + jsonFile.data.get(prop) + ), + newValue: serialize( + `set/${extensionId}/new/${prop}`, + `set/${extensionId}/new/<anonymized>`, + item + ), + }; + jsonFile.data.set(prop, item); + } + + this.notifyListeners(extensionId, changes); + + jsonFile.saveSoon(); + return null; + }, + + /** + * Asynchronously removes the given storage items for the given + * extension ID. + * + * @param {string} extensionId + * The ID of the extension for which to remove storage values. + * @param {Array<string>} items + * A list of storage items to remove. + * @returns {Promise<void>} + */ + async remove(extensionId, items) { + let jsonFile = await this.getFile(extensionId); + + let changed = false; + let changes = {}; + + for (let prop of [].concat(items)) { + if (jsonFile.data.has(prop)) { + changes[prop] = { + oldValue: serialize( + `remove/${extensionId}/${prop}`, + `remove/${extensionId}/<anonymized>`, + jsonFile.data.get(prop) + ), + }; + jsonFile.data.delete(prop); + changed = true; + } + } + + if (changed) { + this.notifyListeners(extensionId, changes); + jsonFile.saveSoon(); + } + return null; + }, + + /** + * Asynchronously clears all storage entries for the given extension + * ID. + * + * @param {string} extensionId + * The ID of the extension for which to clear storage. + * @param {object} options + * @param {boolean} [options.shouldNotifyListeners = true] + * Whether or not collect and send the changes to the listeners, + * used when the extension data is being cleared on uninstall. + * @returns {Promise<void>} + */ + async clear(extensionId, { shouldNotifyListeners = true } = {}) { + let jsonFile = await this.getFile(extensionId); + + let changed = false; + let changes = {}; + + for (let [prop, oldValue] of jsonFile.data.entries()) { + if (shouldNotifyListeners) { + changes[prop] = { + oldValue: serialize( + `clear/${extensionId}/${prop}`, + `clear/${extensionId}/<anonymized>`, + oldValue + ), + }; + } + + jsonFile.data.delete(prop); + changed = true; + } + + if (changed) { + if (shouldNotifyListeners) { + this.notifyListeners(extensionId, changes); + } + + jsonFile.saveSoon(); + } + return null; + }, + + /** + * Asynchronously retrieves the values for the given storage items for + * the given extension ID. + * + * @param {string} extensionId + * The ID of the extension for which to get storage values. + * @param {Array<string>|object|null} [keys] + * The storage items to get. If an array, the value of each key + * in the array is returned. If null, the values of all items + * are returned. If an object, the value for each key in the + * object is returned, or that key's value if the item is not + * set. + * @returns {Promise<object>} + * An object which a property for each requested key, + * containing that key's storage value. Values are + * StructuredCloneHolder objects which can be deserialized to + * the original storage value. + */ + async get(extensionId, keys) { + let jsonFile = await this.getFile(extensionId); + return this._filterProperties(extensionId, jsonFile.data, keys); + }, + + async _filterProperties(extensionId, data, keys) { + let result = {}; + if (keys === null) { + Object.assign(result, data.toJSON()); + } else if (typeof keys == "object" && !Array.isArray(keys)) { + for (let prop in keys) { + if (data.has(prop)) { + result[prop] = serialize( + `filterProperties/${extensionId}/${prop}`, + `filterProperties/${extensionId}/<anonymized>`, + data.get(prop) + ); + } else { + result[prop] = keys[prop]; + } + } + } else { + for (let prop of [].concat(keys)) { + if (data.has(prop)) { + result[prop] = serialize( + `filterProperties/${extensionId}/${prop}`, + `filterProperties/${extensionId}/<anonymized>`, + data.get(prop) + ); + } + } + } + + return result; + }, + + addOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId) || new Set(); + listeners.add(listener); + this.listeners.set(extensionId, listeners); + }, + + removeOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId); + listeners.delete(listener); + }, + + notifyListeners(extensionId, changes) { + let listeners = this.listeners.get(extensionId); + if (listeners) { + for (let listener of listeners) { + listener(changes); + } + } + }, + + init() { + if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) { + return; + } + Services.obs.addObserver(this, "extension-invalidate-storage-cache"); + Services.obs.addObserver(this, "xpcom-shutdown"); + }, + + observe(subject, topic, data) { + if (topic == "xpcom-shutdown") { + Services.obs.removeObserver(this, "extension-invalidate-storage-cache"); + Services.obs.removeObserver(this, "xpcom-shutdown"); + } else if (topic == "extension-invalidate-storage-cache") { + for (let promise of this.jsonFilePromises.values()) { + promise.then(jsonFile => { + jsonFile.finalize(); + }); + } + this.jsonFilePromises.clear(); + } + }, + + // Serializes an arbitrary value into a StructuredCloneHolder, if appropriate. + serialize, + + /** + * Serializes the given storage items for transporting between processes. + * + * @param {BaseContext} context + * The context to use for the created StructuredCloneHolder + * objects. + * @param {Array<string>|object} items + * The items to serialize. If an object is provided, its + * values are serialized to StructuredCloneHolder objects. + * Otherwise, it is returned as-is. + * @returns {Array<string>|object} + */ + serializeForContext(context, items) { + if (items && typeof items === "object" && !Array.isArray(items)) { + let result = {}; + for (let [key, value] of Object.entries(items)) { + try { + result[key] = new StructuredCloneHolder( + `serializeForContext/${context.extension.id}`, + null, + value, + context.cloneScope + ); + } catch (e) { + throw new ExtensionError(String(e)); + } + } + return result; + } + return items; + }, + + /** + * Deserializes the given storage items into the given extension context. + * + * @param {BaseContext} context + * The context to use to deserialize the StructuredCloneHolder objects. + * @param {object} items + * The items to deserialize. Any property of the object which + * is a StructuredCloneHolder instance is deserialized into + * the extension scope. Any other object is cloned into the + * extension scope directly. + * @returns {object} + */ + deserializeForContext(context, items) { + let result = new context.cloneScope.Object(); + for (let [key, value] of Object.entries(items)) { + if ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "StructuredCloneHolder" + ) { + value = value.deserialize(context.cloneScope, true); + } else { + value = Cu.cloneInto(value, context.cloneScope); + } + result[key] = value; + } + return result; + }, +}; + +ChromeUtils.defineLazyGetter(ExtensionStorage, "extensionDir", () => + PathUtils.join(PathUtils.profileDir, "browser-extension-data") +); + +ExtensionStorage.init(); + +export var extensionStorageSession = { + /** @type {WeakMap<Extension, Map<string, any>>} */ + buckets: new DefaultWeakMap(_extension => new Map()), + + /** @type {WeakMap<Extension, Set<callback>>} */ + listeners: new DefaultWeakMap(_extension => new Set()), + + /** + * @param {Extension} extension + * @param {null | undefined | string | string[] | object} items + * Schema normalization ensures items are normalized to one of above types. + */ + get(extension, items) { + let bucket = this.buckets.get(extension); + + let result = {}; + /** @type {Iterable<string>} */ + let keys = []; + + if (!items) { + keys = bucket.keys(); + } else if (typeof items !== "object" || Array.isArray(items)) { + keys = [].concat(items); + } else { + keys = Object.keys(items); + result = items; + } + + for (let prop of keys) { + if (bucket.has(prop)) { + result[prop] = bucket.get(prop); + } + } + return result; + }, + + set(extension, items) { + let bucket = this.buckets.get(extension); + + let changes = {}; + for (let [key, value] of Object.entries(items)) { + changes[key] = { + oldValue: bucket.get(key), + newValue: value, + }; + bucket.set(key, value); + } + this.notifyListeners(extension, changes); + }, + + remove(extension, keys) { + let bucket = this.buckets.get(extension); + let changes = {}; + for (let k of [].concat(keys)) { + if (bucket.has(k)) { + changes[k] = { oldValue: bucket.get(k) }; + bucket.delete(k); + } + } + this.notifyListeners(extension, changes); + }, + + clear(extension) { + let bucket = this.buckets.get(extension); + let changes = {}; + for (let k of bucket.keys()) { + changes[k] = { oldValue: bucket.get(k) }; + } + bucket.clear(); + this.notifyListeners(extension, changes); + }, + + registerListener(extension, listener) { + this.listeners.get(extension).add(listener); + return () => { + this.listeners.get(extension).delete(listener); + }; + }, + + notifyListeners(extension, changes) { + if (!Object.keys(changes).length) { + return; + } + for (let listener of this.listeners.get(extension)) { + lazy.ExtensionCommon.runSafeSyncWithoutClone(listener, changes); + } + }, +}; |