summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/ExtensionStorage.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/ExtensionStorage.sys.mjs')
-rw-r--r--toolkit/components/extensions/ExtensionStorage.sys.mjs573
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..c508b730bc
--- /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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+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<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<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 {value} 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;
+ },
+};
+
+XPCOMUtils.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 = {};
+ 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);
+ }
+ },
+};