Source code

Revision control

Copy as Markdown

Other Tools

/* 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";
ChromeUtils.defineESModuleGetters(this, {
ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs",
ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
});
// 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 context.childManager
.callParentAsyncFunction("storage.local.JSONFileBackend.get", [
serialize(keys),
])
.then(deserialize);
},
set(items) {
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, 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) {
function serialize(name, anonymizedName, value) {
return ExtensionStorage.serialize(
`set/${context.extension.id}/${name}`,
`set/${context.extension.id}/${anonymizedName}`,
value
);
}
return measureOp(
ExtensionTelemetry.storageLocalSetIdb,
context.extension,
async () => {
const db = await getDB();
const changes = await db.set(items, {
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
);
// onChangedName is "storage.onChanged", "storage.sync.onChanged", etc.
function makeOnChangedEventTarget(onChangedName) {
return new EventManager({
context,
name: onChangedName,
// Parent event already resets idle if needed, no need to do it here.
resetIdleOnEvent: false,
register: fire => {
let onChanged = (data, area) => {
let changes = new context.cloneScope.Object();
for (let [key, value] of Object.entries(data)) {
changes[key] = deserialize(value);
}
if (area) {
// storage.onChanged includes the area.
fire.raw(changes, area);
} else {
// StorageArea.onChanged doesn't include the area.
fire.raw(changes);
}
};
let parent = context.childManager.getParentEvent(onChangedName);
parent.addListener(onChanged);
return () => {
parent.removeListener(onChanged);
};
},
}).api();
}
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.sys.mjs. 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 = {
onChanged: makeOnChangedEventTarget("storage.local.onChanged"),
};
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.
try {
const result =
await context.childManager.callParentAsyncFunction(
"storage.local.callMethodInParentProcess",
[method, args]
);
return result;
} catch (err) {
// Just return the rejection as is, the error has been normalized in the
// parent process by callMethodInParentProcess and the original error
// logged in the browser console.
return Promise.reject(err);
}
}
// 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) {
throw ExtensionStorageIDB.normalizeStorageError({
error: err,
extensionId: extension.id,
storageMethod: method,
});
}
};
}
return {
storage: {
local,
session: {
async get(keys) {
return deserialize(
await context.childManager.callParentAsyncFunction(
"storage.session.get",
[serialize(keys)]
)
);
},
set(items) {
return context.childManager.callParentAsyncFunction(
"storage.session.set",
[serialize(items), { callerLocation: context.getCaller() }]
);
},
onChanged: makeOnChangedEventTarget("storage.session.onChanged"),
},
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]
);
},
onChanged: makeOnChangedEventTarget("storage.sync.onChanged"),
},
managed: {
get(keys) {
return context.childManager
.callParentAsyncFunction("storage.managed.get", [serialize(keys)])
.then(deserialize);
},
set() {
return Promise.reject({ message: "storage.managed is read-only" });
},
remove() {
return Promise.reject({ message: "storage.managed is read-only" });
},
clear() {
return Promise.reject({ message: "storage.managed is read-only" });
},
onChanged: makeOnChangedEventTarget("storage.managed.onChanged"),
},
onChanged: makeOnChangedEventTarget("storage.onChanged"),
},
};
}
};