"use strict"; ChromeUtils.defineModuleGetter(this, "ExtensionStorage", "resource://gre/modules/ExtensionStorage.jsm"); ChromeUtils.defineModuleGetter(this, "ExtensionStorageIDB", "resource://gre/modules/ExtensionStorageIDB.jsm"); ChromeUtils.defineModuleGetter(this, "ExtensionTelemetry", "resource://gre/modules/ExtensionTelemetry.jsm"); ChromeUtils.defineModuleGetter(this, "Services", "resource://gre/modules/Services.jsm"); // Wrap a storage operation in a TelemetryStopWatch. async function measureOp(telemetryMetric, extension, fn) { const stopwatchKey = {}; telemetryMetric.stopwatchStart(extension, stopwatchKey); try { let result = await fn(); telemetryMetric.stopwatchFinish(extension, stopwatchKey); return result; } catch (err) { telemetryMetric.stopwatchCancel(extension, stopwatchKey); throw err; } } this.storage = class extends ExtensionAPI { getLocalFileBackend(context, {deserialize, serialize}) { return { get(keys) { return measureOp(ExtensionTelemetry.storageLocalGetJSON, context.extension, () => { return context.childManager.callParentAsyncFunction( "storage.local.JSONFileBackend.get", [serialize(keys)]).then(deserialize); }); }, set(items) { return measureOp(ExtensionTelemetry.storageLocalSetJSON, context.extension, () => { return context.childManager.callParentAsyncFunction( "storage.local.JSONFileBackend.set", [serialize(items)]); }); }, remove(keys) { return context.childManager.callParentAsyncFunction( "storage.local.JSONFileBackend.remove", [serialize(keys)]); }, clear() { return context.childManager.callParentAsyncFunction( "storage.local.JSONFileBackend.clear", []); }, }; } getLocalIDBBackend(context, {fireOnChanged, serialize, storagePrincipal}) { let dbPromise; async function getDB() { if (dbPromise) { return dbPromise; } const persisted = context.extension.hasPermission("unlimitedStorage"); dbPromise = ExtensionStorageIDB.open(storagePrincipal, persisted).catch(err => { // Reset the cached promise if it has been rejected, so that the next // API call is going to retry to open the DB. dbPromise = null; throw err; }); return dbPromise; } return { get(keys) { return measureOp(ExtensionTelemetry.storageLocalGetIDB, context.extension, async () => { const db = await getDB(); return db.get(keys); }); }, set(items) { return measureOp(ExtensionTelemetry.storageLocalSetIDB, context.extension, async () => { const db = await getDB(); const changes = await db.set(items, { serialize: ExtensionStorage.serialize, }); if (changes) { fireOnChanged(changes); } }); }, async remove(keys) { const db = await getDB(); const changes = await db.remove(keys); if (changes) { fireOnChanged(changes); } }, async clear() { const db = await getDB(); const changes = await db.clear(context.extension); if (changes) { fireOnChanged(changes); } }, }; } getAPI(context) { const {extension} = context; const serialize = ExtensionStorage.serializeForContext.bind(null, context); const deserialize = ExtensionStorage.deserializeForContext.bind(null, context); function sanitize(items) { // The schema validator already takes care of arrays (which are only allowed // to contain strings). Strings and null are safe values. if (typeof items != "object" || items === null || Array.isArray(items)) { return items; } // If we got here, then `items` is an object generated by `ObjectType`'s // `normalize` method from Schemas.jsm. The object returned by `normalize` // lives in this compartment, while the values live in compartment of // `context.contentWindow`. The `sanitize` method runs with the principal // of `context`, so we cannot just use `ExtensionStorage.sanitize` because // it is not allowed to access properties of `items`. // So we enumerate all properties and sanitize each value individually. let sanitized = {}; for (let [key, value] of Object.entries(items)) { sanitized[key] = ExtensionStorage.sanitize(value, context); } return sanitized; } function fireOnChanged(changes) { // This call is used (by the storage.local API methods for the IndexedDB backend) to fire a storage.onChanged event, // it uses the underlying message manager since the child context (or its ProxyContentParent counterpart // running in the main process) may be gone by the time we call this, and so we can't use the childManager // abstractions (e.g. callParentAsyncFunction or callParentFunctionNoReturn). Services.cpmm.sendAsyncMessage(`Extension:StorageLocalOnChanged:${extension.uuid}`, changes); } // If the selected backend for the extension is not known yet, we have to lazily detect it // by asking to the main process (as soon as the storage.local API has been accessed for // the first time). const getStorageLocalBackend = async () => { const { backendEnabled, storagePrincipal, } = await ExtensionStorageIDB.selectBackend(context); if (!backendEnabled) { return this.getLocalFileBackend(context, {deserialize, serialize}); } return this.getLocalIDBBackend(context, { storagePrincipal, fireOnChanged, serialize, }); }; // Synchronously select the backend if it is already known. let selectedBackend; const useStorageIDBBackend = extension.getSharedData("storageIDBBackend"); if (useStorageIDBBackend === false) { selectedBackend = this.getLocalFileBackend(context, {deserialize, serialize}); } else if (useStorageIDBBackend === true) { selectedBackend = this.getLocalIDBBackend(context, { storagePrincipal: extension.getSharedData("storageIDBPrincipal"), fireOnChanged, serialize, }); } let promiseStorageLocalBackend; // Generate the backend-agnostic local API wrapped methods. const local = {}; for (let method of ["get", "set", "remove", "clear"]) { local[method] = async function(...args) { try { // Discover the selected backend if it is not known yet. if (!selectedBackend) { if (!promiseStorageLocalBackend) { promiseStorageLocalBackend = getStorageLocalBackend().catch(err => { // Clear the cached promise if it has been rejected. promiseStorageLocalBackend = null; throw err; }); } // If the storage.local method is not 'get' (which doesn't change any of the stored data), // fall back to call the method in the parent process, so that it can be completed even // if this context has been destroyed in the meantime. if (method !== "get") { // Let the outer try to catch rejections returned by the backend methods. const result = await context.childManager.callParentAsyncFunction( "storage.local.callMethodInParentProcess", [method, args]); return result; } // Get the selected backend and cache it for the next API calls from this context. selectedBackend = await promiseStorageLocalBackend; } // Let the outer try to catch rejections returned by the backend methods. const result = await selectedBackend[method](...args); return result; } catch (err) { // Ensure that the error we throw is converted into an ExtensionError // (e.g. DataCloneError instances raised from the internal IndexedDB // operation have to be converted to be accessible to the extension code). throw new ExtensionUtils.ExtensionError(String(err)); } }; } return { storage: { local, sync: { get(keys) { keys = sanitize(keys); return context.childManager.callParentAsyncFunction("storage.sync.get", [ keys, ]); }, set(items) { items = sanitize(items); return context.childManager.callParentAsyncFunction("storage.sync.set", [ items, ]); }, }, managed: { get(keys) { return context.childManager.callParentAsyncFunction("storage.managed.get", [ serialize(keys), ]).then(deserialize); }, set(items) { return Promise.reject({message: "storage.managed is read-only"}); }, remove(keys) { return Promise.reject({message: "storage.managed is read-only"}); }, clear() { return Promise.reject({message: "storage.managed is read-only"}); }, }, onChanged: new EventManager({ context, name: "storage.onChanged", register: fire => { let onChanged = (data, area) => { let changes = new context.cloneScope.Object(); for (let [key, value] of Object.entries(data)) { changes[key] = deserialize(value); } fire.raw(changes, area); }; let parent = context.childManager.getParentEvent("storage.onChanged"); parent.addListener(onChanged); return () => { parent.removeListener(onChanged); }; }, }).api(), }, }; } };