Skip to content

Instantly share code, notes, and snippets.

@biancadanforth
Last active March 15, 2019 00:19
Show Gist options
  • Save biancadanforth/fb6aaae07084512a594a8098c971807e to your computer and use it in GitHub Desktop.
Save biancadanforth/fb6aaae07084512a594a8098c971807e to your computer and use it in GitHub Desktop.
diff --git a/devtools/client/locales/en-US/storage.properties b/devtools/client/locales/en-US/storage.properties
--- a/devtools/client/locales/en-US/storage.properties
+++ b/devtools/client/locales/en-US/storage.properties
@@ -31,6 +31,9 @@ tree.labels.localStorage=Local Storage
tree.labels.sessionStorage=Session Storage
tree.labels.indexedDB=Indexed DB
tree.labels.Cache=Cache Storage
+# TODO: Figure out how to structure tree for the 3 different types of extension
+# storage (browser.storage.local, .managed, .sync).
+tree.labels.extensionStorage=Extension Storage
# LOCALIZATION NOTE (table.headers.*.*):
# These strings are the header names of the columns in the Storage Table for
@@ -67,6 +70,10 @@ table.headers.indexedDB.keyPath2=Key Pat
table.headers.indexedDB.autoIncrement=Auto Increment
table.headers.indexedDB.indexes=Indexes
+# TODO: Should name=Key?
+table.headers.extensionStorage.name=Name
+table.headers.extensionStorage.value=Value
+
# LOCALIZATION NOTE (label.expires.session):
# This string is displayed in the expires column when the cookie is Session
# Cookie
diff --git a/devtools/server/actors/storage.js b/devtools/server/actors/storage.js
--- a/devtools/server/actors/storage.js
+++ b/devtools/server/actors/storage.js
@@ -12,6 +12,8 @@ const Services = require("Services");
const defer = require("devtools/shared/defer");
const {isWindowIncluded} = require("devtools/shared/layout/utils");
const specs = require("devtools/shared/specs/storage");
+const {ExtensionProcessScript} = require("resource://gre/modules/ExtensionProcessScript.jsm");
+const {ExtensionStorageIDB} = require("resource://gre/modules/ExtensionStorageIDB.jsm");
const CHROME_ENABLED_PREF = "devtools.chrome.enabled";
const REMOTE_ENABLED_PREF = "devtools.debugger.remote-enabled";
@@ -1298,6 +1300,270 @@ StorageActors.createActor({
observationTopics: ["dom-storage2-changed", "dom-private-storage2-changed"],
}, getObjectForLocalOrSessionStorage("sessionStorage"));
+/**
+* The Extension Storage actor.
+*/
+StorageActors.createActor({
+ typeName: "extensionStorage",
+}, {
+ initialize(storageActor) {
+ protocol.Actor.prototype.initialize.call(this, null);
+
+ this.storageActor = storageActor;
+
+ this.populateStoresForHosts();
+
+ this.addExtensionStorageListeners();
+
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
+ this.storageActor.on("window-ready", this.onWindowReady);
+ this.storageActor.on("window-destroyed", this.onWindowDestroyed);
+ },
+
+ destroy() {
+ this.removeExtensionStorageListeners();
+
+ this.storageActor.off("window-ready", this.onWindowReady);
+ this.storageActor.off("window-destroyed", this.onWindowDestroyed);
+
+ this.hostVsStores.clear();
+
+ protocol.Actor.prototype.destroy.call(this);
+
+ this.storageActor = null;
+ },
+
+ /**
+ * Ensures this.hostVsStores stays up-to-date and passes the change on
+ * to update the view.
+ */
+ onStorageChange(changes, areaName) {
+ if (areaName !== "local") {
+ return;
+ }
+
+ for (const key in changes) {
+ const storageChange = changes[key];
+ const {newValue, oldValue} = storageChange;
+
+ // TODO: Not sure what the best way is to get the current host.
+ let host = null;
+ for (const window of this.windows) {
+ host = this.getHostName(window.location);
+ if (host) {
+ break;
+ }
+ }
+
+ let action = null;
+ const storeMap = this.hostVsStores.get(host);
+
+ if (typeof newValue === "undefined") {
+ action = "deleted";
+ storeMap.delete(key);
+ } else if (typeof oldValue === "undefined") {
+ action = "added";
+ storeMap.set(key, newValue);
+ } else {
+ action = "changed";
+ storeMap.set(key, newValue);
+ }
+
+ this.storageActor.update(action, this.typeName, {[host]: [key]});
+ }
+ },
+
+ // TODO: Don't use WE APIs directly
+ addExtensionStorageListeners() {
+ this.onStorageChange = this.onStorageChange.bind(this);
+ for (const window of this.windows) {
+ Cu.waiveXrays(window).browser.storage.onChanged.addListener(this.onStorageChange);
+ }
+ },
+
+ // TODO: Don't use WE APIs directly
+ removeExtensionStorageListeners() {
+ for (const window of this.windows) {
+ Cu.waiveXrays(window).browser.storage.onChanged.removeListener(
+ this.onStorageChange
+ );
+ }
+ },
+
+ /**
+ * This method is overriden and left blank as for extensionStorage, this operation
+ * cannot be performed synchronously. Thus, the preListStores method exists to
+ * do the same task asynchronously.
+ */
+ populateStoresForHosts() {},
+
+ /**
+ * Purpose of this method is same as populateStoresForHosts but this is async.
+ * This exact same operation cannot be performed in populateStoresForHosts
+ * method, as that method is called in initialize method of the actor, which
+ * cannot be asynchronous.
+ */
+ async preListStores() {
+ this.hostVsStores = new Map();
+
+ // TODO: Don’t use `window` for everything, as an extension may not have a
+ // `window` available (e.g. has no background script, and no other scripts
+ // are running at a given moment).
+ for (const window of this.windows) {
+ const host = this.getHostName(window.location);
+ if (host) {
+ await this.populateStoresForHost(host, window);
+ }
+ }
+ },
+
+ async populateStoreMap(storagePrincipal, storeMap) {
+ const db = await ExtensionStorageIDB.open(storagePrincipal);
+ const data = await db.get();
+
+ for (const [key, value] of Object.entries(data)) {
+ storeMap.set(key, value);
+ }
+ return storeMap;
+ },
+
+ /**
+ * This method asynchronously reads the browser.storage.local data for the window
+ * (if it's an extension page window and the related extension has the storage
+ * permission) and caches this data into this.hostVsStores.
+ */
+ async populateStoresForHost(host, window) {
+ if (this.hostVsStores.has(host)) {
+ return;
+ }
+
+ let storeMap = new Map();
+ const principal = this.getPrincipal(window);
+ const { addonId } = principal;
+ // TODO: Get extension object from the parent instead?
+ const extension = ExtensionProcessScript.getExtensionChild(addonId);
+
+ if (extension && extension.hasPermission("storage")) {
+ const isUsingIDBBackend = extension.getSharedData("storageIDBBackend");
+ if (isUsingIDBBackend === null) {
+ // Extension has not yet migrated to the IDB backend; trigger migration
+
+ // TODO: Create own context object rather than getting from the view?
+ let context;
+ const {views} = extension;
+ for (const view of views) {
+ context = view.childManager.context;
+ if (context) {
+ break;
+ }
+ }
+
+ if (context) {
+ const {
+ backendEnabled,
+ storagePrincipal,
+ } = await ExtensionStorageIDB.selectBackend(context);
+
+ if (backendEnabled) {
+ storeMap = await this.populateStoreMap(storagePrincipal, storeMap);
+ }
+ }
+ } else if (isUsingIDBBackend) {
+ const storagePrincipal = extension.getSharedData("storageIDBPrincipal");
+
+ storeMap = await this.populateStoreMap(storagePrincipal, storeMap);
+ }
+ }
+
+ this.hostVsStores.set(host, storeMap);
+ },
+
+ getValuesForHost(host, name) {
+ if (name) {
+ return [{name, value: this.hostVsStores.get(host).get(name)}];
+ }
+
+ const result = [];
+ for (const [key, value] of Array.from(this.hostVsStores.get(host).entries())) {
+ result.push({name: key, value});
+ }
+ return result;
+ },
+
+ /**
+ * This method converts the values returned by getValuesForHost into a StoreObject
+ * (currently based off of this same method for IndexedDB).
+ */
+ toStoreObject({name, value}) {
+ if (!{name, value}) {
+ return null;
+ }
+
+ // Stringify adds redundant quotes to strings
+ if (typeof value !== "string") {
+ // Not all possible values are stringifiable (e.g. functions)
+ value = JSON.stringify(value) || "Object";
+ }
+
+ // FIXME: Bug 1318029 - Due to a bug that is thrown whenever a
+ // LongStringActor string reaches DebuggerServer.LONG_STRING_LENGTH we need
+ // to trim the value. When the bug is fixed we should stop trimming the
+ // string here.
+ const maxLength = DebuggerServer.LONG_STRING_LENGTH - 1;
+ if (value.length > maxLength) {
+ value = value.substr(0, maxLength);
+ }
+
+ return {
+ name,
+ value: new LongStringActor(this.conn, value || ""),
+ };
+ },
+
+ getFields() {
+ return [
+ // name needs to be editable for the addItem case, where a temporary key-value
+ // pair is created that can later be edited via editItem.
+ { name: "name", editable: true },
+ { name: "value", editable: true },
+ ];
+ },
+
+ // TODO: Web content storage inspector currently breaks (changing storage from a
+ // content script doesn't work). Because Cu.unwaiveXrays etc. don't exist. Easiest
+ // fix is not to show anything here if the host looks like an extension id.
+
+ // TODO: Don't use WE APIs directly
+ async addItem(guid, host) {
+ const win = this.storageActor.getWindowFromHost(host);
+ await Cu.waiveXrays(win).browser.storage.local.set({[guid]: DEFAULT_VALUE});
+ },
+
+ // TODO: Don't use WE APIs directly
+ async editItem({host, field, items, oldValue}) {
+ const win = this.storageActor.getWindowFromHost(host);
+ const {name, value} = items;
+ // If the name changed, remove the previous entry in storage by the old name first
+ if (field === "name") {
+ await Cu.waiveXrays(win).browser.storage.local.remove(oldValue);
+ }
+ await Cu.waiveXrays(win).browser.storage.local.set({[name]: value});
+ },
+
+ // TODO: Don't use WE APIs directly
+ async removeItem(host, name) {
+ const win = this.storageActor.getWindowFromHost(host);
+ await Cu.waiveXrays(win).browser.storage.local.remove(name);
+ },
+
+ // TODO: Don't use WE APIs directly
+ async removeAll(host) {
+ const win = this.storageActor.getWindowFromHost(host);
+ await Cu.waiveXrays(win).browser.storage.local.clear();
+ },
+});
+
StorageActors.createActor({
typeName: "Cache",
}, {
diff --git a/devtools/shared/specs/storage.js b/devtools/shared/specs/storage.js
--- a/devtools/shared/specs/storage.js
+++ b/devtools/shared/specs/storage.js
@@ -163,6 +163,42 @@ createStorageSpec({
methods: storageMethods,
});
+// TODO: create a new storeObjectType, since extension storage is less
+// restrictive than localStorage/sessionStorage and more restrictive than
+// IndexedDB.
+// Extension store object
+types.addDictType("extensionobject", {
+ name: "nullable:string",
+ value: "nullable:longstring",
+});
+
+// Array of Extension Storage store objects
+types.addDictType("extensionstoreobject", {
+ total: "number",
+ offset: "number",
+ data: "array:nullable:extensionobject",
+});
+
+// TODO: Do we want a different spec for each extension storage type?
+// (local, managed, sync)
+createStorageSpec({
+ typeName: "extensionStorage",
+ storeObjectType: "extensionstoreobject",
+ methods: {
+ // TODO: May want to define storageMethods differently compared to localStorage
+ // and sessionStorage...compare to IDB spec as it is more similar.
+ ...storageMethods,
+ addExtensionStorageListeners: {
+ request: {},
+ response: {},
+ },
+ removeExtensionStorageListeners: {
+ request: {},
+ response: {},
+ },
+ },
+});
+
types.addDictType("cacheobject", {
"url": "string",
"status": "string",
@biancadanforth
Copy link
Author

biancadanforth commented Mar 4, 2019

Revision 1/2:

Creates a storage spec and actor based on mratcliffe's WIP for a similar bug and referencing the DevTools docs heavily.

There is effectively a base "class" of Storage Actor with default methods which can be overwritten/extended by an override object(example), which is the second argument passed into StorageActors.createActor. There are other methods that are called and expected to be present on the class that do not exist on the default class like getFields.

So far, I am able to get the extension's background page window object (via the extension object) from this new storage actor's populateStoresForHost method, but I have not been able to access any data from the extension's storage yet.

@biancadanforth
Copy link
Author

biancadanforth commented Mar 4, 2019

Revision 3/4:

Accesses browser.storage.local data for the extension after waiving Xrays and puts it into the Storage tab UI for the extension under the extensionStorage category.

Note: getValuesForHost returns an Array of arrays where each element array is a 2-tuple: [key, value] pair in the extension’s storage.
E.g. for my test extension, which has a storage object like (see test WE): { catgifs: true, rainbows: unicorns }, it returns [ [ “catgifs”, true], [“rainbows”, “unicorns”] ].

TODO

(for the first milestone of getting browser.storage.local working in the DevTools Storage panel)

  • Update the view so that it refreshes and updates automatically when the extension changes the stored data
  • Use a different storeObject spec (possibly create a new one?) than "storagestoreobject", since unlike window.localStorage and window.sessionStorage, extension storage values do not have to be strings.
  • To get the storage object, use the implementation of browser.storage.get instead of the API itself
  • Get the extension object directly from the parent process instead of via ExtensionProcessScript.getExtensionChild()

@biancadanforth
Copy link
Author

biancadanforth commented Mar 6, 2019

Revision 5

  • Added an override for the initialize and destroy methods of the storage actor to add/remove storage listeners, respectively. The onStorageChange method now fires any time extension storage is changed. This still relies on using WE APIs directly.
  • Added addItem, editItem, removeItem and removeAll methods which are called when the user modifies the storage object in the Storage tab. Except editItem, none of these methods are correctly updating the view, though they are correctly updating the extension's storage object. These also still rely on using WE APIs directly.

TODO

Same as above.

@biancadanforth
Copy link
Author

Revision 6

Now displays an object literal as a value in the Storage tab for the extension storage.

  • Add a new storageObjectType called "extensionstoreobject" based off of the IndexedDB storageObjectType.
  • Update the storageActor.toStoreObject method to map more closely to IndexedDB's method of the same name, which stringifies the object and turns it into a long string in the same way.

To help facilitate this, I extended a test storage web extension I'd already made to add the same data to IndexedDB as to extension local storage (browser.storage.local), and I compared the output in the Storage tab until they looked the same (or similar).

@biancadanforth
Copy link
Author

Revision 7

Progress on updating the view on storage changes

  • addItem, removeItem and removeAll now correctly update the view by calling this.storageActor.update.

TODO

  • Fix: editItem is failing to update the "Data" column in the Storage tab.
  • Make sure view is updated when the extension itself updates storage.

@biancadanforth
Copy link
Author

biancadanforth commented Mar 9, 2019

Revision 8/9

View is updating correctly! (AFAICT)

...For any changes to extension local storage either by the extension or by the developer in the addon Storage inspector UI. Thanks to mratcliffe for his help debugging the editItem method and helping me to update the onStorageChange method (was previously a stub).

viewUpdatingCorrectly

@biancadanforth
Copy link
Author

biancadanforth commented Mar 13, 2019

Revision 10

Notes:

  • With my test WE which immediately puts a couple items into browser.storage.local from a background script, when running with web-ext run (and pointing to my local Nightly build with my patch), the extension must be reloaded once before opening the storage inspector to ensure the migration to the IndexedDB backend (which happens lazily) has occurred.
  • The IDB backend is used by default in Nightly and Beta without needing to set the pref extensions.webextensions.ExtensionStorageIDB.enabled to true.

@biancadanforth
Copy link
Author

biancadanforth commented Mar 15, 2019

Revision 11

  • Now correctly displays the test WE's storage items in the Storage Inspector without having to reload the extension by triggering a migration if one has not yet occurred. Does this without relying on message passing between the parent and child processes (i.e. does not need this.conn.setupInParent or similar)!

Notes:

  • Just like all previous patches, none of these work for the case that the extension does not have a background page, as we are still relying on window objects (e.g. Cu.waiveXrays(window).browser.storage...) in places. Hopefully this will change with my next revision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment