Source code

Revision control

Copy as Markdown

Other Tools

/* -*- 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;
/** @type {Lazy} */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
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 = {
/** @type {Map<string, Promise<typeof lazy.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 {BaseContext} 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) {
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);
}
},
};