Bug 1573201 - Add limited write support for extension storage.local data in addon debugger r=miker,rpl
authorBianca Danforth <bdanforth@mozilla.com>
Fri, 22 Nov 2019 20:09:59 +0000
changeset 503570 dedca1647cfe5f15b57580b8224b32022f9373e3
parent 503569 7fcdfe9a24e467bd95219eaa331aa13c1575796a
child 503571 26f2d8e9421848df3da185b8de316e1576f59a17
push id101394
push usercbrindusan@mozilla.com
push dateSat, 23 Nov 2019 14:37:54 +0000
treeherderautoland@dedca1647cfe [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmiker, rpl
bugs1573201, 1542038, 1542039
milestone72.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1573201 - Add limited write support for extension storage.local data in addon debugger r=miker,rpl * Update the extensionStorage actor to enable some writing to extension storage.local through the Storage panel client. * All values in the client are displayed as strings, so the actor must stringify them before sending them to the client and parse them when receiving changes from the client. For this reason, there is currently limited write support. * Item values that are JSONifiable (numbers, strings, booleans, object literals, arrays and null) can be edited from the Storage panel. * Object literals and arrays are only editable if their values are JSONifiable, with a maximum nested depth of 2 (e.g. an object with a nested object is editable, provided the nested object contains only primitive values). Object literals' keys must also be strings to be editable. * Non-JSONifiable values cannot be edited, and will be represented by "{}" in most cases in the panel, though some non-JSONifiable values (undefined, Date, and BigInt) will be displayed as more readable strings. * Some modifications are a little more complex, requiring an IndexedDB transaction. This will be handled in a separate patch. * Item names cannot be edited from the Storage panel. * New items cannot be added from the Storage panel. * Any item can be removed. * All items can be removed at once. * In-line comments referencing Bugs 1542038 and 1542039 indicate places where the implementation may differ for local storage versus the other storage areas in the actor. * The parseItemValue method used in the client was moved to a shared directory, so that the actor could parse string values from the client in its editItem method. Differential Revision: https://phabricator.services.mozilla.com/D34416
.eslintignore
devtools/client/shared/vendor/JSON5_LICENSE
devtools/client/shared/vendor/JSON5_UPGRADING.md
devtools/client/shared/vendor/json5.js
devtools/client/shared/vendor/moz.build
devtools/client/shared/vendor/stringvalidator/UPDATING.md
devtools/client/shared/vendor/stringvalidator/moz.build
devtools/client/shared/vendor/stringvalidator/tests/unit/head_stringvalidator.js
devtools/client/shared/vendor/stringvalidator/tests/unit/test_sanitizers.js
devtools/client/shared/vendor/stringvalidator/tests/unit/test_validators.js
devtools/client/shared/vendor/stringvalidator/tests/unit/xpcshell.ini
devtools/client/shared/vendor/stringvalidator/util/assert.js
devtools/client/shared/vendor/stringvalidator/util/moz.build
devtools/client/shared/vendor/stringvalidator/validator.js
devtools/client/shared/widgets/TableWidget.js
devtools/client/storage/test/browser.ini
devtools/client/storage/test/browser_storage_webext_storage_local.js
devtools/client/storage/test/head.js
devtools/client/storage/ui.js
devtools/server/actors/storage.js
devtools/server/tests/unit/test_extension_storage_actor.js
devtools/shared/moz.build
devtools/shared/specs/storage.js
devtools/shared/storage/moz.build
devtools/shared/storage/utils.js
devtools/shared/storage/vendor/JSON5_LICENSE
devtools/shared/storage/vendor/JSON5_UPGRADING.md
devtools/shared/storage/vendor/json5.js
devtools/shared/storage/vendor/moz.build
devtools/shared/storage/vendor/stringvalidator/UPDATING.md
devtools/shared/storage/vendor/stringvalidator/moz.build
devtools/shared/storage/vendor/stringvalidator/tests/unit/head_stringvalidator.js
devtools/shared/storage/vendor/stringvalidator/tests/unit/test_sanitizers.js
devtools/shared/storage/vendor/stringvalidator/tests/unit/test_validators.js
devtools/shared/storage/vendor/stringvalidator/tests/unit/xpcshell.ini
devtools/shared/storage/vendor/stringvalidator/util/assert.js
devtools/shared/storage/vendor/stringvalidator/util/moz.build
devtools/shared/storage/vendor/stringvalidator/validator.js
--- a/.eslintignore
+++ b/.eslintignore
@@ -101,16 +101,17 @@ devtools/client/webconsole/test/node/fix
 
 # Ignore devtools third-party libs
 devtools/shared/jsbeautify/
 devtools/shared/acorn/
 devtools/shared/node-properties/
 devtools/shared/pretty-fast/
 devtools/shared/sourcemap/
 devtools/shared/sprintfjs/
+devtools/shared/storage/vendor/*
 devtools/shared/qrcode/decoder/
 devtools/shared/qrcode/encoder/
 devtools/client/inspector/markup/test/lib_*
 devtools/client/jsonview/lib/require.js
 devtools/client/shared/demangle.js
 devtools/client/shared/source-map/
 devtools/client/shared/vendor/
 devtools/client/shared/sourceeditor/codemirror/*.js
--- a/devtools/client/shared/vendor/moz.build
+++ b/devtools/client/shared/vendor/moz.build
@@ -1,22 +1,17 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
-DIRS += [
-    'stringvalidator',
-]
-
 DevToolsModules(
     'fluent-react.js',
     'immutable.js',
-    'json5.js',
     'jszip.js',
     'lodash.js',
     'react-dom-factories.js',
     'react-dom-server.js',
     'react-dom-test-utils.js',
     'react-dom.js',
     'react-prop-types.js',
     'react-redux.js',
--- a/devtools/client/shared/widgets/TableWidget.js
+++ b/devtools/client/shared/widgets/TableWidget.js
@@ -609,22 +609,24 @@ TableWidget.prototype = {
     for (const [name, column] of this.columns) {
       if (!editableColumns.includes(name)) {
         column.column.setAttribute("readonly", "");
       }
     }
 
     if (this._editableFieldsEngine) {
       this._editableFieldsEngine.selectors = selectors;
+      this._editableFieldsEngine.items = this.items;
     } else {
       this._editableFieldsEngine = new EditableFieldsEngine({
         root: this.tbody,
         onTab: this.onEditorTab,
         onTriggerEvent: "dblclick",
         selectors: selectors,
+        items: this.items,
       });
 
       this._editableFieldsEngine.on("change", this.onChange);
       this._editableFieldsEngine.on("destroyed", this.onEditorDestroyed);
 
       this.on(EVENTS.ROW_REMOVED, this.onRowRemoved);
       this.on(EVENTS.TABLE_CLEARED, this._editableFieldsEngine.cancelEdit);
 
@@ -1745,16 +1747,17 @@ function EditableFieldsEngine(options) {
   if (!Array.isArray(options.selectors)) {
     options.selectors = [options.selectors];
   }
 
   this.root = options.root;
   this.selectors = options.selectors;
   this.onTab = options.onTab;
   this.onTriggerEvent = options.onTriggerEvent || "dblclick";
+  this.items = options.items;
 
   this.edit = this.edit.bind(this);
   this.cancelEdit = this.cancelEdit.bind(this);
   this.destroy = this.destroy.bind(this);
 
   this.onTrigger = this.onTrigger.bind(this);
   this.root.addEventListener(this.onTriggerEvent, this.onTrigger);
 }
@@ -1833,16 +1836,24 @@ EditableFieldsEngine.prototype = {
    * @param  {Node} target
    *         Dom node to be edited.
    */
   edit: function(target) {
     if (!target) {
       return;
     }
 
+    // Some item names and values are not parsable by the client or server so should not be
+    // editable.
+    const name = target.getAttribute("data-id");
+    const item = this.items.get(name);
+    if ("isValueEditable" in item && !item.isValueEditable) {
+      return;
+    }
+
     target.scrollIntoView(false);
     target.focus();
 
     if (!target.matches(this.selectors.join(","))) {
       return;
     }
 
     // If we are actively editing something complete the edit first.
--- a/devtools/client/storage/test/browser.ini
+++ b/devtools/client/storage/test/browser.ini
@@ -85,8 +85,9 @@ fail-if = fission
 [browser_storage_sessionstorage_edit.js]
 [browser_storage_sidebar.js]
 fail-if = fission
 [browser_storage_sidebar_parsetree.js]
 [browser_storage_sidebar_toggle.js]
 fail-if = fission
 [browser_storage_sidebar_update.js]
 [browser_storage_values.js]
+[browser_storage_webext_storage_local.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_webext_storage_local.js
@@ -0,0 +1,245 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* globals browser BigInt */
+
+"use strict";
+
+loader.lazyRequireGetter(
+  this,
+  "DebuggerServer",
+  "devtools/server/debugger-server",
+  true
+);
+loader.lazyRequireGetter(
+  this,
+  "DebuggerClient",
+  "devtools/shared/client/debugger-client",
+  true
+);
+
+const { Toolbox } = require("devtools/client/framework/toolbox");
+
+/**
+ * Initialize and connect a DebuggerServer and DebuggerClient. Note: This test
+ * does not use TargetFactory, so it has to set up the DebuggerServer and
+ * DebuggerClient on its own.
+ * @return {Promise} Resolves with an instance of the DebuggerClient class
+ */
+async function setupLocalDebuggerServerAndClient() {
+  DebuggerServer.init();
+  DebuggerServer.registerAllActors();
+  const client = new DebuggerClient(DebuggerServer.connectPipe());
+  await client.connect();
+  return client;
+}
+
+/**
+ * Set up and optionally open the `about:debugging` toolbox for a given extension.
+ * @param {String} id - The id for the extension to be targeted by the toolbox.
+ * @param {Object} options - Configuration options with various optional fields:
+ *   - {Boolean} openToolbox - If true, open the toolbox
+ * @return {Promise} Resolves with a web extension actor target object and the toolbox
+ * and storage objects when the toolbox has been setup
+ */
+async function setupExtensionDebuggingToolbox(id, options = {}) {
+  const { openToolbox = false } = options;
+
+  const client = await setupLocalDebuggerServerAndClient();
+  const front = await client.mainRoot.getAddon({ id });
+  const target = await front.getTarget();
+  target.shouldCloseClient = true;
+
+  let toolbox;
+  let storage;
+  if (openToolbox) {
+    const res = await openStoragePanel(null, target, Toolbox.HostType.WINDOW);
+    ({ toolbox, storage } = res);
+  }
+
+  return { target, toolbox, storage };
+}
+
+add_task(async function set_enable_extensionStorage_pref() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["devtools.storage.extensionStorage.enabled", true]],
+  });
+});
+
+/**
+ * Since storage item values are represented in the client as strings in textboxes, not all
+ * JavaScript object types supported by the WE storage local API and its IndexedDB backend
+ * can be successfully stringified for display in the table much less parsed correctly when
+ * the user tries to edit a value in the panel. This test is expected to change over time
+ * as more and more value types are supported.
+ */
+add_task(
+  async function test_extension_toolbox_only_supported_values_editable() {
+    async function background() {
+      browser.test.onMessage.addListener(async (msg, ...args) => {
+        switch (msg) {
+          case "storage-local-set":
+            await browser.storage.local.set(args[0]);
+            break;
+          case "storage-local-get": {
+            const items = await browser.storage.local.get(args[0]);
+            for (const [key, val] of Object.entries(items)) {
+              browser.test.assertTrue(
+                val === args[1],
+                `New value ${val} is set for key ${key}.`
+              );
+            }
+            break;
+          }
+          case "storage-local-fireOnChanged": {
+            const listener = () => {
+              browser.storage.onChanged.removeListener(listener);
+              browser.test.sendMessage("storage-local-onChanged");
+            };
+            browser.storage.onChanged.addListener(listener);
+            // Call an API method implemented in the parent process
+            // to ensure that the listener has been registered
+            // in the main process before the test proceeds.
+            await browser.runtime.getPlatformInfo();
+            break;
+          }
+          default:
+            browser.test.fail(`Unexpected test message: ${msg}`);
+        }
+
+        browser.test.sendMessage(`${msg}:done`);
+      });
+      browser.test.sendMessage("extension-origin", window.location.origin);
+    }
+    const extension = ExtensionTestUtils.loadExtension({
+      manifest: {
+        permissions: ["storage"],
+      },
+      background,
+      useAddonManager: "temporary",
+    });
+
+    await extension.startup();
+
+    const host = await extension.awaitMessage("extension-origin");
+
+    const itemsSupported = {
+      arr: [1, 2],
+      bool: true,
+      null: null,
+      num: 4,
+      obj: { a: 123 },
+      str: "hi",
+      // Nested objects or arrays at most 2 levels deep should be editable
+      nestedArr: [
+        {
+          a: "b",
+        },
+        "c",
+      ],
+      nestedObj: {
+        a: [1, 2],
+        b: 3,
+      },
+    };
+
+    const itemsUnsupported = {
+      arrBuffer: new ArrayBuffer(8),
+      bigint: BigInt(1),
+      blob: new Blob(
+        [
+          JSON.stringify(
+            {
+              hello: "world",
+            },
+            null,
+            2
+          ),
+        ],
+        {
+          type: "application/json",
+        }
+      ),
+      date: new Date(0),
+      map: new Map().set("a", "b"),
+      regexp: /regexp/,
+      set: new Set().add(1).add("a"),
+      undef: undefined,
+      // Arrays and object literals with non-JSONifiable values should not be editable
+      arrWithMap: [1, new Map().set("a", 1)],
+      objWithArrayBuffer: { a: new ArrayBuffer(8) },
+      // Nested objects or arrays more than 2 levels deep should not be editable
+      deepNestedArr: [[{ a: "b" }, 3], 4],
+      deepNestedObj: {
+        a: {
+          b: [1, 2],
+        },
+      },
+    };
+
+    info("Add storage items from the extension");
+    const allItems = { ...itemsSupported, ...itemsUnsupported };
+    extension.sendMessage("storage-local-fireOnChanged");
+    await extension.awaitMessage("storage-local-fireOnChanged:done");
+    extension.sendMessage("storage-local-set", allItems);
+    info(
+      "Wait for the extension to add storage items and receive the 'onChanged' event"
+    );
+    await extension.awaitMessage("storage-local-set:done");
+    await extension.awaitMessage("storage-local-onChanged");
+
+    info("Open the addon toolbox storage panel");
+    const { target } = await setupExtensionDebuggingToolbox(extension.id, {
+      openToolbox: true,
+    });
+
+    await selectTreeItem(["extensionStorage", host]);
+
+    info("Verify that value types supported by the storage actor are editable");
+    let validate = true;
+    const newValue = "anotherValue";
+    const supportedIds = Object.keys(itemsSupported);
+    for (const id of supportedIds) {
+      startCellEdit(id, "value", newValue);
+      await editCell(id, "value", newValue, validate);
+    }
+
+    info("Verify that associated values have been changed in the extension");
+    extension.sendMessage(
+      "storage-local-get",
+      Object.keys(itemsSupported),
+      newValue
+    );
+    await extension.awaitMessage("storage-local-get:done");
+
+    info(
+      "Verify that value types not supported by the storage actor are uneditable"
+    );
+    const expectedValStrings = {
+      arrBuffer: "{}",
+      bigint: "1n",
+      blob: "{}",
+      date: "1970-01-01T00:00:00.000Z",
+      map: "{}",
+      regexp: "{}",
+      set: "{}",
+      undef: "undefined",
+      arrWithMap: "[1,{}]",
+      objWithArrayBuffer: '{"a":{}}',
+      deepNestedArr: '[[{"a":"b"},3],4]',
+      deepNestedObj: '{"a":{"b":[1,2]}}',
+    };
+    validate = false;
+    for (const id of Object.keys(itemsUnsupported)) {
+      startCellEdit(id, "value", validate);
+      checkCellUneditable(id, "value");
+      checkCell(id, "value", expectedValStrings[id]);
+    }
+
+    info("Shut down the test");
+    await gDevTools.closeToolbox(target);
+    await extension.unload();
+    await target.destroy();
+  }
+);
--- a/devtools/client/storage/test/head.js
+++ b/devtools/client/storage/test/head.js
@@ -126,22 +126,26 @@ async function openTabAndSetupStorage(ur
   return openStoragePanel();
 }
 
 /**
  * Open the toolbox, with the storage tool visible.
  *
  * @param cb {Function} Optional callback, if you don't want to use the returned
  *                      promise
+ * @param target {Object} Optional, the target for the toolbox; defaults to a tab target
+ * @param hostType {Toolbox.HostType} Optional, type of host that will host the toolbox
  *
  * @return {Promise} a promise that resolves when the storage inspector is ready
  */
-var openStoragePanel = async function(cb) {
+var openStoragePanel = async function(cb, target, hostType) {
   info("Opening the storage inspector");
-  const target = await TargetFactory.forTab(gBrowser.selectedTab);
+  if (!target) {
+    target = await TargetFactory.forTab(gBrowser.selectedTab);
+  }
 
   let storage, toolbox;
 
   // Checking if the toolbox and the storage are already loaded
   // The storage-updated event should only be waited for if the storage
   // isn't loaded yet
   toolbox = gDevTools.getToolbox(target);
   if (toolbox) {
@@ -158,17 +162,17 @@ var openStoragePanel = async function(cb
       return {
         toolbox: toolbox,
         storage: storage,
       };
     }
   }
 
   info("Opening the toolbox");
-  toolbox = await gDevTools.showToolbox(target, "storage");
+  toolbox = await gDevTools.showToolbox(target, "storage", hostType);
   storage = toolbox.getPanel("storage");
   gPanelWindow = storage.panelWindow;
   gUI = storage.UI;
   gToolbox = toolbox;
 
   // The table animation flash causes some timeouts on Linux debug tests,
   // so we disable it
   gUI.animationsEnabled = false;
@@ -810,16 +814,38 @@ function checkCell(id, column, expected)
   is(
     getCellValue(id, column),
     expected,
     column + " column has the right value for " + id
   );
 }
 
 /**
+ * Check that a cell is not in edit mode.
+ *
+ * @param {String} id
+ *        The uniqueId of the row.
+ * @param {String} column
+ *        The id of the column
+ */
+function checkCellUneditable(id, column) {
+  const row = getRowCells(id, true);
+  const cell = row[column];
+
+  const editableFieldsEngine = gUI.table._editableFieldsEngine;
+  const textbox = editableFieldsEngine.textbox;
+
+  // When a field is being edited, the cell is hidden, and the textbox is made visible.
+  ok(
+    !cell.hidden && textbox.hidden,
+    `The cell located in column ${column} and row ${id} is not editable.`
+  );
+}
+
+/**
  * Show or hide a column.
  *
  * @param  {String} id
  *         The uniqueId of the given column.
  * @param  {Boolean} state
  *         true = show, false = hide
  */
 function showColumn(id, state) {
@@ -879,20 +905,20 @@ async function typeWithTerminator(str, t
 
   const changeExpected = str !== textbox.value;
 
   if (!changeExpected) {
     return editableFieldsEngine.currentTarget.getAttribute("data-id");
   }
 
   info("Typing " + str);
-  EventUtils.sendString(str);
+  EventUtils.sendString(str, gPanelWindow);
 
   info("Pressing " + terminator);
-  EventUtils.synthesizeKey(terminator);
+  EventUtils.synthesizeKey(terminator, null, gPanelWindow);
 
   if (validate) {
     info("Validating results... waiting for ROW_EDIT event.");
     const uniqueId = await gUI.table.once(TableWidget.EVENTS.ROW_EDIT);
 
     checkCell(uniqueId, colName, str);
     return uniqueId;
   }
--- a/devtools/client/storage/ui.js
+++ b/devtools/client/storage/ui.js
@@ -2,16 +2,17 @@
  * 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";
 
 const EventEmitter = require("devtools/shared/event-emitter");
 const { LocalizationHelper, ELLIPSIS } = require("devtools/shared/l10n");
 const KeyShortcuts = require("devtools/client/shared/key-shortcuts");
+const { parseItemValue } = require("devtools/shared/storage/utils");
 const { KeyCodes } = require("devtools/client/shared/keycodes");
 const { getUnicodeHostname } = require("devtools/client/shared/unicode-url");
 
 // GUID to be used as a separator in compound keys. This must match the same
 // constant in devtools/server/actors/storage.js,
 // devtools/client/storage/test/head.js and
 // devtools/server/tests/browser/head.js
 const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}";
@@ -28,22 +29,16 @@ loader.lazyRequireGetter(
   "devtools/client/shared/widgets/TableWidget",
   true
 );
 loader.lazyImporter(
   this,
   "VariablesView",
   "resource://devtools/client/shared/widgets/VariablesView.jsm"
 );
-loader.lazyRequireGetter(
-  this,
-  "validator",
-  "devtools/client/shared/vendor/stringvalidator/validator"
-);
-loader.lazyRequireGetter(this, "JSON5", "devtools/client/shared/vendor/json5");
 
 /**
  * Localization convenience methods.
  */
 const STORAGE_STRINGS = "devtools/client/locales/storage.properties";
 const L10N = new LocalizationHelper(STORAGE_STRINGS, true);
 
 const GENERIC_VARIABLES_VIEW_SETTINGS = {
@@ -71,17 +66,16 @@ const COOKIE_KEY_MAP = {
   isSecure: "Secure",
   isHttpOnly: "HttpOnly",
   creationTime: "CreationTime",
   lastAccessed: "LastAccessed",
   sameSite: "SameSite",
 };
 
 const SAFE_HOSTS_PREFIXES_REGEX = /^(about:|https?:|file:|moz-extension:)/;
-const MATH_REGEX = /(?:(?:^|[-+_*/])(?:\s*-?\d+(\.\d+)?(?:[eE][+-]?\d+)?\s*))+$/;
 
 // Maximum length of item name to show in context menu label - will be
 // trimmed with ellipsis if it's longer.
 const ITEM_NAME_MAX_LENGTH = 32;
 
 /**
  * StorageUI is controls and builds the UI of the Storage Inspector.
  *
@@ -829,16 +823,17 @@ class StorageUI {
       }
     }
   }
 
   /**
    * Populates the selected entry from the table in the sidebar for a more
    * detailed view.
    */
+  /* eslint-disable-next-line */
   async updateObjectSidebar() {
     const item = this.table.selectedRow;
     let value;
 
     // Get the string value (async action) and the update the UI synchronously.
     if (item && item.name && item.valueActor) {
       value = await item.valueActor.string();
     }
@@ -867,17 +862,20 @@ class StorageUI {
 
     if (value) {
       const itemVar = mainScope.addItem(item.name + "", {}, { relaxed: true });
 
       // The main area where the value will be displayed
       itemVar.setGrip(value);
 
       // May be the item value is a json or a key value pair itself
-      this.parseItemValue(item.name, value);
+      const obj = parseItemValue(value);
+      if (typeof obj === "object") {
+        this.populateSidebar(item.name, obj);
+      }
 
       // By default the item name and value are shown. If this is the only
       // information available, then nothing else is to be displayed.
       const itemProps = Object.keys(item);
       if (itemProps.length > 3) {
         // Display any other information other than the item name and value
         // which may be available.
         const rawObject = Object.create(null);
@@ -901,17 +899,20 @@ class StorageUI {
       // Case when displaying IndexedDB db/object store properties.
       for (const key in item) {
         const column = this.table.columns.get(key);
         if (column && column.private) {
           continue;
         }
 
         mainScope.addItem(key, {}, true).setGrip(item[key]);
-        this.parseItemValue(key, item[key]);
+        const obj = parseItemValue(item[key]);
+        if (typeof obj === "object") {
+          this.populateSidebar(item.name, obj);
+        }
       }
     }
 
     this.emit("sidebar-updated");
   }
 
   /**
    * Gets a readable label from the hostname. If the hostname is a Punycode
@@ -938,169 +939,38 @@ class StorageUI {
     } catch (_) {
       // Skip decoding for a host which doesn't include a domain name, simply
       // consider them to be readable.
     }
     return host;
   }
 
   /**
-   * Tries to parse a string value into either a json or a key-value separated
-   * object and populates the sidebar with the parsed value. The value can also
-   * be a key separated array.
+   * Populates the sidebar with a parsed object.
    *
-   * @param {string} name
-   *        The key corresponding to the `value` string in the object
-   * @param {string} originalValue
-   *        The string to be parsed into an object
+   * @param {object} obj - Either a json or a key-value separated object or a
+   * key separated array
    */
-  parseItemValue(name, originalValue) {
-    // Find if value is URLEncoded ie
-    let decodedValue = "";
-    try {
-      decodedValue = decodeURIComponent(originalValue);
-    } catch (e) {
-      // Unable to decode, nothing to do
-    }
-    const value =
-      decodedValue && decodedValue !== originalValue
-        ? decodedValue
-        : originalValue;
-
-    if (!this._shouldParse(value)) {
-      return;
-    }
-
-    let obj = null;
-    try {
-      obj = JSON5.parse(value);
-    } catch (ex) {
-      obj = null;
-    }
-
-    if (!obj && value) {
-      obj = this._extractKeyValPairs(value);
-    }
-
-    // return if obj is null, or same as value, or just a string.
-    if (!obj || obj === value || typeof obj === "string") {
-      return;
-    }
-
+  populateSidebar(name, obj) {
     const jsonObject = Object.create(null);
     const view = this.view;
     jsonObject[name] = obj;
     const valueScope =
       view.getScopeAtIndex(1) ||
       view.addScope(L10N.getStr("storage.parsedValue.label"));
     valueScope.expanded = true;
     const jsonVar = valueScope.addItem("", Object.create(null), {
       relaxed: true,
     });
     jsonVar.expanded = true;
     jsonVar.twisty = true;
     jsonVar.populate(jsonObject, { expanded: true });
   }
 
   /**
-   * Tries to parse a string into an object on the basis of key-value pairs,
-   * separated by various separators. If failed, tries to parse for single
-   * separator separated values to form an array.
-   *
-   * @param {string} value
-   *        The string to be parsed into an object or array
-   */
-  _extractKeyValPairs(value) {
-    const makeObject = (keySep, pairSep) => {
-      const object = {};
-      for (const pair of value.split(pairSep)) {
-        const [key, val] = pair.split(keySep);
-        object[key] = val;
-      }
-      return object;
-    };
-
-    // Possible separators.
-    const separators = ["=", ":", "~", "#", "&", "\\*", ",", "\\."];
-    // Testing for object
-    for (let i = 0; i < separators.length; i++) {
-      const kv = separators[i];
-      for (let j = 0; j < separators.length; j++) {
-        if (i == j) {
-          continue;
-        }
-        const p = separators[j];
-        const word = `[^${kv}${p}]*`;
-        const keyValue = `${word}${kv}${word}`;
-        const keyValueList = `${keyValue}(${p}${keyValue})*`;
-        const regex = new RegExp(`^${keyValueList}$`);
-        if (
-          value.match &&
-          value.match(regex) &&
-          value.includes(kv) &&
-          (value.includes(p) || value.split(kv).length == 2)
-        ) {
-          return makeObject(kv, p);
-        }
-      }
-    }
-    // Testing for array
-    for (const p of separators) {
-      const word = `[^${p}]*`;
-      const wordList = `(${word}${p})+${word}`;
-      const regex = new RegExp(`^${wordList}$`);
-
-      if (regex.test(value)) {
-        const pNoBackslash = p.replace(/\\*/g, "");
-        return value.split(pNoBackslash);
-      }
-    }
-    return null;
-  }
-
-  /**
-   * Check whether the value string represents something that should be
-   * displayed as text. If so then it shouldn't be parsed into a tree.
-   *
-   * @param  {String} value
-   *         The value to be parsed.
-   */
-  _shouldParse(value) {
-    const validators = [
-      "isBase64",
-      "isBoolean",
-      "isCurrency",
-      "isDataURI",
-      "isEmail",
-      "isFQDN",
-      "isHexColor",
-      "isIP",
-      "isISO8601",
-      "isMACAddress",
-      "isSemVer",
-      "isURL",
-    ];
-
-    // Check for minus calculations e.g. 8-3 because otherwise 5 will be displayed.
-    if (MATH_REGEX.test(value)) {
-      return false;
-    }
-
-    // Check for any other types that shouldn't be parsed.
-    for (const test of validators) {
-      if (validator[test](value)) {
-        return false;
-      }
-    }
-
-    // Seems like this is data that should be parsed.
-    return true;
-  }
-
-  /**
    * Select handler for the storage tree. Fetches details of the selected item
    * from the storage details and populates the storage tree.
    *
    * @param {array} item
    *        An array of ids which represent the location of the selected item in
    *        the storage tree
    */
   async onHostSelect(item) {
--- a/devtools/server/actors/storage.js
+++ b/devtools/server/actors/storage.js
@@ -7,16 +7,17 @@
 const { Cc, Ci, Cu, CC } = require("chrome");
 const protocol = require("devtools/shared/protocol");
 const { LongStringActor } = require("devtools/server/actors/string");
 const { DebuggerServer } = require("devtools/server/debugger-server");
 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 { parseItemValue } = require("devtools/shared/storage/utils");
 loader.lazyGetter(this, "ExtensionProcessScript", () => {
   return require("resource://gre/modules/ExtensionProcessScript.jsm")
     .ExtensionProcessScript;
 });
 loader.lazyGetter(this, "ExtensionStorageIDB", () => {
   return require("resource://gre/modules/ExtensionStorageIDB.jsm")
     .ExtensionStorageIDB;
 });
@@ -1400,16 +1401,130 @@ const extensionStorageHelpers = {
   unresolvedPromises: new Map(),
   // Map of addonId => onStorageChange listeners in the parent process. Each addon toolbox targets
   // a single addon, and multiple addon toolboxes could be open at the same time.
   onChangedParentListeners: new Map(),
   // Set of onStorageChange listeners in the extension child process. Each addon toolbox will create
   // a separate extensionStorage actor targeting that addon. The addonId is passed into the listener,
   // so that changes propagate only if the storage actor has a matching addonId.
   onChangedChildListeners: new Set(),
+  /**
+   * Editing is supported only for serializable types. Examples of unserializable
+   * types include Map, Set and ArrayBuffer.
+   */
+  isEditable(value) {
+    // Bug 1542038: the managed storage area is never editable
+    for (const { test } of Object.values(this.supportedTypes)) {
+      if (test(value)) {
+        return true;
+      }
+    }
+    return false;
+  },
+  isPrimitive(value) {
+    const primitiveValueTypes = ["string", "number", "boolean"];
+    return primitiveValueTypes.includes(typeof value) || value === null;
+  },
+  isObjectLiteral(value) {
+    return (
+      value &&
+      typeof value === "object" &&
+      Cu.getClassName(value, true) === "Object"
+    );
+  },
+  // Nested arrays or object literals are only editable 2 levels deep
+  isArrayOrObjectLiteralEditable(obj) {
+    const topLevelValuesArr = Array.isArray(obj) ? obj : Object.values(obj);
+    if (
+      topLevelValuesArr.some(
+        value =>
+          !this.isPrimitive(value) &&
+          !Array.isArray(value) &&
+          !this.isObjectLiteral(value)
+      )
+    ) {
+      // At least one value is too complex to parse
+      return false;
+    }
+    const arrayOrObjects = topLevelValuesArr.filter(
+      value => Array.isArray(value) || this.isObjectLiteral(value)
+    );
+    if (arrayOrObjects.length === 0) {
+      // All top level values are primitives
+      return true;
+    }
+
+    // One or more top level values was an array or object literal.
+    // All of these top level values must themselves have only primitive values
+    // for the object to be editable
+    for (const nestedObj of arrayOrObjects) {
+      const secondLevelValuesArr = Array.isArray(nestedObj)
+        ? nestedObj
+        : Object.values(nestedObj);
+      if (secondLevelValuesArr.some(value => !this.isPrimitive(value))) {
+        return false;
+      }
+    }
+    return true;
+  },
+  typesFromString: {
+    // Helper methods to parse string values in editItem
+    jsonifiable: {
+      test(str) {
+        try {
+          JSON.parse(str);
+        } catch (e) {
+          return false;
+        }
+        return true;
+      },
+      parse(str) {
+        return JSON.parse(str);
+      },
+    },
+  },
+  supportedTypes: {
+    // Helper methods to determine the value type of an item in isEditable
+    array: {
+      test(value) {
+        if (Array.isArray(value)) {
+          return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
+        }
+        return false;
+      },
+    },
+    boolean: {
+      test(value) {
+        return typeof value === "boolean";
+      },
+    },
+    null: {
+      test(value) {
+        return value === null;
+      },
+    },
+    number: {
+      test(value) {
+        return typeof value === "number";
+      },
+    },
+    object: {
+      test(value) {
+        if (extensionStorageHelpers.isObjectLiteral(value)) {
+          return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
+        }
+        return false;
+      },
+    },
+    string: {
+      test(value) {
+        return typeof value === "string";
+      },
+    },
+  },
 
   // Sets the parent process message manager
   setPpmm(ppmm) {
     this.ppmm = ppmm;
   },
 
   // A promise in the main process has resolved, and we need to pass the return value(s)
   // back to the child process
@@ -1769,21 +1884,24 @@ if (Services.prefs.getBoolPref(EXTENSION
         const db = await ExtensionStorageIDB.open(storagePrincipal);
         this.dbConnectionForHost.set(host, db);
         const data = await db.get();
 
         for (const [key, value] of Object.entries(data)) {
           storeMap.set(key, value);
         }
 
-        // Show the storage actor in the add-on storage inspector even when there
-        // is no extension page currently open
-        const storageData = {};
-        storageData[host] = this.getNamesForHost(host);
-        this.storageActor.update("added", this.typeName, storageData);
+        if (this.storageActor.parentActor.fallbackWindow) {
+          // Show the storage actor in the add-on storage inspector even when there
+          // is no extension page currently open
+          // This strategy may need to change depending on the outcome of Bug 1597900
+          const storageData = {};
+          storageData[host] = this.getNamesForHost(host);
+          this.storageActor.update("added", this.typeName, storageData);
+        }
       },
 
       async getStoragePrincipal(addonId) {
         const {
           backendEnabled,
           storagePrincipal,
         } = await this.setupStorageInParent(addonId);
 
@@ -1810,70 +1928,146 @@ if (Services.prefs.getBoolPref(EXTENSION
         )) {
           result.push({ name: key, value });
         }
         return result;
       },
 
       /**
        * Converts a storage item to an "extensionobject" as defined in
-       * devtools/shared/specs/storage.js
+       * devtools/shared/specs/storage.js. Behavior largely mirrors the "indexedDB" storage actor,
+       * except where it would throw an unhandled error (i.e. for a `BigInt` or `undefined`
+       * `item.value`).
        * @param {Object} item - The storage item to convert
        * @param {String} item.name - The storage item key
        * @param {*} item.value - The storage item value
        * @return {extensionobject}
        */
       toStoreObject(item) {
         if (!item) {
           return null;
         }
 
-        const { name, value } = item;
-
-        let newValue;
-        if (typeof value === "string") {
-          newValue = value;
-        } else {
-          try {
-            newValue = JSON.stringify(value) || String(value);
-          } catch (error) {
-            // throws for bigint
-            newValue = String(value);
-          }
-
-          // JavaScript objects that are not JSON stringifiable will be represented
-          // by the string "Object"
-          if (newValue === "{}") {
-            newValue = "Object";
-          }
+        let { name, value } = item;
+        let isValueEditable = extensionStorageHelpers.isEditable(value);
+
+        // `JSON.stringify()` throws for `BigInt`, adds extra quotes to strings and `Date` strings,
+        // and doesn't modify `undefined`.
+        switch (typeof value) {
+          case "bigint":
+            value = `${value.toString()}n`;
+            break;
+          case "string":
+            break;
+          case "undefined":
+            value = "undefined";
+            break;
+          default:
+            value = JSON.stringify(value);
+            if (
+              // can't use `instanceof` across frame boundaries
+              Object.prototype.toString.call(item.value) === "[object Date]"
+            ) {
+              value = JSON.parse(value);
+            }
         }
 
         // 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 (newValue.length > maxLength) {
-          newValue = newValue.substr(0, maxLength);
+        if (value.length > maxLength) {
+          value = value.substr(0, maxLength);
+          isValueEditable = false;
         }
 
         return {
           name,
-          value: new LongStringActor(this.conn, newValue || ""),
+          value: new LongStringActor(this.conn, value),
           area: "local", // Bug 1542038, 1542039: set the correct storage area
+          isValueEditable,
         };
       },
 
       getFields() {
         return [
           { name: "name", editable: false },
-          { name: "value", editable: false },
+          { name: "value", editable: true },
           { name: "area", editable: false },
+          { name: "isValueEditable", editable: false, private: true },
         ];
       },
+
+      onItemUpdated(action, host, names) {
+        this.storageActor.update(action, this.typeName, {
+          [host]: names,
+        });
+      },
+
+      async editItem({ host, field, items, oldValue }) {
+        const db = this.dbConnectionForHost.get(host);
+        if (!db) {
+          return;
+        }
+
+        const { name, value } = items;
+
+        let parsedValue = parseItemValue(value);
+        if (parsedValue === value) {
+          const { typesFromString } = extensionStorageHelpers;
+          for (const { test, parse } of Object.values(typesFromString)) {
+            if (test(value)) {
+              parsedValue = parse(value);
+              break;
+            }
+          }
+        }
+        const changes = await db.set({ [name]: parsedValue });
+        this.fireOnChangedExtensionEvent(host, changes);
+
+        this.onItemUpdated("changed", host, [name]);
+      },
+
+      async removeItem(host, name) {
+        const db = this.dbConnectionForHost.get(host);
+        if (!db) {
+          return;
+        }
+
+        const changes = await db.remove(name);
+        this.fireOnChangedExtensionEvent(host, changes);
+
+        this.onItemUpdated("deleted", host, [name]);
+      },
+
+      async removeAll(host) {
+        const db = this.dbConnectionForHost.get(host);
+        if (!db) {
+          return;
+        }
+
+        const changes = await db.clear();
+        this.fireOnChangedExtensionEvent(host, changes);
+
+        this.onItemUpdated("cleared", host, []);
+      },
+
+      /**
+       * Let the extension know that storage data has been changed by the user from
+       * the storage inspector.
+       */
+      fireOnChangedExtensionEvent(host, changes) {
+        // Bug 1542038, 1542039: Which message to send depends on the storage area
+        const uuid = new URL(host).host;
+        Services.cpmm.sendAsyncMessage(
+          `Extension:StorageLocalOnChanged:${uuid}`,
+          changes
+        );
+      },
     }
   );
 }
 
 StorageActors.createActor(
   {
     typeName: "Cache",
   },
--- a/devtools/server/tests/unit/test_extension_storage_actor.js
+++ b/devtools/server/tests/unit/test_extension_storage_actor.js
@@ -92,17 +92,17 @@ async function startupExtension(extConfi
  */
 async function openAddonStoragePanel(id) {
   const { target } = await setupExtensionDebugging(id);
 
   const storageFront = await target.getFront("storage");
   const stores = await storageFront.listStores();
   const extensionStorage = stores.extensionStorage || null;
 
-  return { target, extensionStorage };
+  return { target, extensionStorage, storageFront };
 }
 
 /**
  * Builds the extension configuration object passed into ExtensionTestUtils.loadExtension
  *
  * @param {Object} options - Options, if any, to add to the configuration
  * @param {Function} options.background - A function comprising the test extension's
  * background script if provided
@@ -136,23 +136,23 @@ async function extensionScriptWithMessag
     if (fireOnChanged) {
       // Do not fire it again until explicitly requested again using the "storage-local-fireOnChanged" test message.
       fireOnChanged = false;
       browser.test.sendMessage("storage-local-onChanged");
     }
   });
 
   browser.test.onMessage.addListener(async (msg, ...args) => {
-    let value = null;
+    let item = null;
     switch (msg) {
       case "storage-local-set":
         await browser.storage.local.set(args[0]);
         break;
       case "storage-local-get":
-        value = (await browser.storage.local.get(args[0]))[args[0]];
+        item = await browser.storage.local.get(args[0]);
         break;
       case "storage-local-remove":
         await browser.storage.local.remove(args[0]);
         break;
       case "storage-local-clear":
         await browser.storage.local.clear();
         break;
       case "storage-local-fireOnChanged": {
@@ -161,17 +161,17 @@ async function extensionScriptWithMessag
         fireOnChanged = true;
         // Do not fire fireOnChanged:done.
         return;
       }
       default:
         browser.test.fail(`Unexpected test message: ${msg}`);
     }
 
-    browser.test.sendMessage(`${msg}:done`, value);
+    browser.test.sendMessage(`${msg}:done`, item);
   });
   browser.test.sendMessage("extension-origin", window.location.origin);
 }
 
 /**
  * Shared files for a test extension that has no background page but adds storage
  * items via a transient extension page in a tab
  */
@@ -326,29 +326,60 @@ add_task(async function test_panel_live_
     "Confirming items added by extension match items in extensionStorage store"
   );
   const bulkStorageObjects = [];
   for (const [name, value] of Object.entries(bulkStorageItems)) {
     bulkStorageObjects.push({
       area: "local",
       name,
       value: { str: String(value) },
+      isValueEditable: true,
     });
   }
   data = (await extensionStorage.getStoreObjects(host)).data;
   Assert.deepEqual(
     data,
     [
       ...bulkStorageObjects,
-      { area: "local", name: "a", value: { str: "123" } },
-      { area: "local", name: "b", value: { str: "[4,5]" } },
-      { area: "local", name: "c", value: { str: '{"d":678}' } },
-      { area: "local", name: "d", value: { str: "true" } },
-      { area: "local", name: "e", value: { str: "hi" } },
-      { area: "local", name: "f", value: { str: "null" } },
+      {
+        area: "local",
+        name: "a",
+        value: { str: "123" },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "b",
+        value: { str: "[4,5]" },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "c",
+        value: { str: '{"d":678}' },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "d",
+        value: { str: "true" },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "e",
+        value: { str: "hi" },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "f",
+        value: { str: "null" },
+        isValueEditable: true,
+      },
     ],
     "Got the expected results on populated storage.local"
   );
 
   info("Waiting for extension to edit a few storage item values");
   extension.sendMessage("storage-local-fireOnChanged");
   extension.sendMessage("storage-local-set", {
     a: ["c", "d"],
@@ -361,22 +392,52 @@ add_task(async function test_panel_live_
   info(
     "Confirming items edited by extension match items in extensionStorage store"
   );
   data = (await extensionStorage.getStoreObjects(host)).data;
   Assert.deepEqual(
     data,
     [
       ...bulkStorageObjects,
-      { area: "local", name: "a", value: { str: '["c","d"]' } },
-      { area: "local", name: "b", value: { str: "456" } },
-      { area: "local", name: "c", value: { str: "false" } },
-      { area: "local", name: "d", value: { str: "true" } },
-      { area: "local", name: "e", value: { str: "hi" } },
-      { area: "local", name: "f", value: { str: "null" } },
+      {
+        area: "local",
+        name: "a",
+        value: { str: '["c","d"]' },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "b",
+        value: { str: "456" },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "c",
+        value: { str: "false" },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "d",
+        value: { str: "true" },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "e",
+        value: { str: "hi" },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "f",
+        value: { str: "null" },
+        isValueEditable: true,
+      },
     ],
     "Got the expected results on populated storage.local"
   );
 
   info("Waiting for extension to remove a few storage item values");
   extension.sendMessage("storage-local-fireOnChanged");
   extension.sendMessage("storage-local-remove", ["d", "e", "f"]);
   await extension.awaitMessage("storage-local-remove:done");
@@ -385,19 +446,34 @@ add_task(async function test_panel_live_
   info(
     "Confirming items removed by extension were removed in extensionStorage store"
   );
   data = (await extensionStorage.getStoreObjects(host)).data;
   Assert.deepEqual(
     data,
     [
       ...bulkStorageObjects,
-      { area: "local", name: "a", value: { str: '["c","d"]' } },
-      { area: "local", name: "b", value: { str: "456" } },
-      { area: "local", name: "c", value: { str: "false" } },
+      {
+        area: "local",
+        name: "a",
+        value: { str: '["c","d"]' },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "b",
+        value: { str: "456" },
+        isValueEditable: true,
+      },
+      {
+        area: "local",
+        name: "c",
+        value: { str: "false" },
+        isValueEditable: true,
+      },
     ],
     "Got the expected results on populated storage.local"
   );
 
   info("Waiting for extension to remove all remaining storage items");
   extension.sendMessage("storage-local-fireOnChanged");
   extension.sendMessage("storage-local-clear");
   await extension.awaitMessage("storage-local-clear:done");
@@ -441,17 +517,24 @@ add_task(
 
     const { target, extensionStorage } = await openAddonStoragePanel(
       extension.id
     );
 
     const { data } = await extensionStorage.getStoreObjects(host);
     Assert.deepEqual(
       data,
-      [{ area: "local", name: "a", value: { str: "123" } }],
+      [
+        {
+          area: "local",
+          name: "a",
+          value: { str: "123" },
+          isValueEditable: true,
+        },
+      ],
       "Got the expected results on populated storage.local"
     );
 
     await contentPage.close();
     await shutdown(extension, target);
   }
 );
 
@@ -483,34 +566,40 @@ add_task(async function test_panel_data_
 
   const { target, extensionStorage } = await openAddonStoragePanel(
     extension.id
   );
 
   const { data } = await extensionStorage.getStoreObjects(host);
   Assert.deepEqual(
     data,
-    [{ area: "local", name: "a", value: { str: "123" } }],
+    [
+      {
+        area: "local",
+        name: "a",
+        value: { str: "123" },
+        isValueEditable: true,
+      },
+    ],
     "Got the expected results on populated storage.local"
   );
 
   await shutdown(extension, target);
 });
 
 /**
  * Test case: No bg page. Storage panel live updates when a transient page adds an item.
  * - Load extension with no background page.
  * - Open the add-on storage panel.
  * - With the storage panel still open, open an extension page in a new tab that adds an
  *   item.
- * - Assert:
- *   - The data in the storage panel should live update to match the item added by the
- *     extension.
- *   - If an extension page adds the same data again, the data in the storage panel should
- *     not change.
+ * - The data in the storage panel should live update to match the item added by the
+ *   extension.
+ * - If an extension page adds the same data again, the data in the storage panel should
+ *   not change.
  */
 add_task(
   async function test_panel_data_live_updates_for_extension_without_bg_page() {
     const extension = await startupExtension(
       getExtensionConfig({ files: ext_no_bg.files })
     );
 
     const { target, extensionStorage } = await openAddonStoragePanel(
@@ -536,38 +625,292 @@ add_task(
     extension.sendMessage("storage-local-fireOnChanged");
     extension.sendMessage("storage-local-set", { a: 123 });
     await extension.awaitMessage("storage-local-set:done");
     await extension.awaitMessage("storage-local-onChanged");
 
     data = (await extensionStorage.getStoreObjects(host)).data;
     Assert.deepEqual(
       data,
-      [{ area: "local", name: "a", value: { str: "123" } }],
+      [
+        {
+          area: "local",
+          name: "a",
+          value: { str: "123" },
+          isValueEditable: true,
+        },
+      ],
       "Got the expected results on populated storage.local"
     );
 
     extension.sendMessage("storage-local-fireOnChanged");
     extension.sendMessage("storage-local-set", { a: 123 });
     await extension.awaitMessage("storage-local-set:done");
     await extension.awaitMessage("storage-local-onChanged");
 
     data = (await extensionStorage.getStoreObjects(host)).data;
     Assert.deepEqual(
       data,
-      [{ area: "local", name: "a", value: { str: "123" } }],
+      [
+        {
+          area: "local",
+          name: "a",
+          value: { str: "123" },
+          isValueEditable: true,
+        },
+      ],
       "The results are unchanged when an extension page adds duplicate items"
     );
 
     await contentPage.close();
     await shutdown(extension, target);
   }
 );
 
 /**
+ * Test case: Bg page adds item while storage panel is open. Panel edits item's value.
+ * - Load extension with background page.
+ * - Open the add-on storage panel.
+ * - With the storage panel still open, add item from the background page.
+ * - Edit the value of the item in the storage panel
+ * - The data in the storage panel should match the item added by the extension.
+ * - The storage actor is correctly parsing and setting the string representation of
+ *     the value in the storage local database when the item's value is edited in the
+ *     storage panel
+ */
+add_task(
+  async function test_editing_items_in_panel_parses_supported_values_correctly() {
+    const extension = await startupExtension(
+      getExtensionConfig({ background: extensionScriptWithMessageListener })
+    );
+
+    const host = await extension.awaitMessage("extension-origin");
+
+    const { target, extensionStorage } = await openAddonStoragePanel(
+      extension.id
+    );
+
+    const oldItem = { a: 123 };
+    const key = Object.keys(oldItem)[0];
+    const oldValue = oldItem[key];
+    // A tuple representing information for a new value entered into the panel for oldItem:
+    // [
+    //   value,
+    //   editItem string representation of value,
+    //   toStoreObject string representation of value,
+    // ]
+    const valueInfo = [
+      [true, "true", "true"],
+      ["hi", "hi", "hi"],
+      [456, "456", "456"],
+      [{ b: 789 }, "{b: 789}", '{"b":789}'],
+      [[1, 2, 3], "[1, 2, 3]", "[1,2,3]"],
+      [null, "null", "null"],
+    ];
+    for (const [value, editItemValueStr, toStoreObjectValueStr] of valueInfo) {
+      info("Setting a storage item through the extension");
+      extension.sendMessage("storage-local-fireOnChanged");
+      extension.sendMessage("storage-local-set", oldItem);
+      await extension.awaitMessage("storage-local-set:done");
+      await extension.awaitMessage("storage-local-onChanged");
+
+      info(
+        "Editing the storage item in the panel with a new value of a different type"
+      );
+      // When the user edits an item in the panel, they are entering a string into a
+      // textbox. This string is parsed by the storage actor's editItem method.
+      await extensionStorage.editItem({
+        host,
+        field: "value",
+        items: { name: key, value: editItemValueStr },
+        oldValue,
+      });
+
+      info(
+        "Verifying item in the storage actor matches the item edited in the panel"
+      );
+      const { data } = await extensionStorage.getStoreObjects(host);
+      Assert.deepEqual(
+        data,
+        [
+          {
+            area: "local",
+            name: key,
+            value: { str: toStoreObjectValueStr },
+            isValueEditable: true,
+          },
+        ],
+        "Got the expected results on populated storage.local"
+      );
+
+      // The view layer is separate from the database layer; therefore while values are
+      // stringified (via toStoreObject) for display in the client, the value (and its type)
+      // in the database is unchanged.
+      info(
+        "Verifying the expected new value matches the value fetched in the extension"
+      );
+      extension.sendMessage("storage-local-get", key);
+      const extItem = await extension.awaitMessage("storage-local-get:done");
+      Assert.deepEqual(
+        value,
+        extItem[key],
+        `The string value ${editItemValueStr} was correctly parsed to ${value}`
+      );
+    }
+
+    await shutdown(extension, target);
+  }
+);
+
+/**
+ * Test case: Modifying storage items from the panel update extension storage local data.
+ * - Load extension with background page.
+ * - Open the add-on storage panel. From the panel:
+ *   - Edit the value of a storage item,
+ *   - Remove a storage item,
+ *   - Remove all of the storage items,
+ * - For each modification, the storage data retrieved by the extension should match the
+ *   data in the panel.
+ */
+add_task(
+  async function test_modifying_items_in_panel_updates_extension_storage_data() {
+    const extension = await startupExtension(
+      getExtensionConfig({ background: extensionScriptWithMessageListener })
+    );
+
+    const host = await extension.awaitMessage("extension-origin");
+
+    const {
+      target,
+      extensionStorage,
+      storageFront,
+    } = await openAddonStoragePanel(extension.id);
+
+    const DEFAULT_VALUE = "value"; // global in devtools/server/actors/storage.js
+    let items = {
+      guid_1: DEFAULT_VALUE,
+      guid_2: DEFAULT_VALUE,
+      guid_3: DEFAULT_VALUE,
+    };
+
+    info("Adding storage items from the extension");
+    let storesUpdate = storageFront.once("stores-update");
+    extension.sendMessage("storage-local-set", items);
+    await extension.awaitMessage("storage-local-set:done");
+
+    info("Waiting for the storage actor to emit a 'stores-update' event");
+    let data = await storesUpdate;
+    Assert.deepEqual(
+      {
+        added: {
+          extensionStorage: {
+            [host]: ["guid_1", "guid_2", "guid_3"],
+          },
+        },
+      },
+      data,
+      "The change data from the storage actor's 'stores-update' event matches the changes made in the client."
+    );
+
+    info("Waiting for panel to edit some items");
+    storesUpdate = storageFront.once("stores-update");
+    await extensionStorage.editItem({
+      host,
+      field: "value",
+      items: { name: "guid_1", value: "anotherValue" },
+      DEFAULT_VALUE,
+    });
+
+    info("Waiting for the storage actor to emit a 'stores-update' event");
+    data = await storesUpdate;
+    Assert.deepEqual(
+      {
+        changed: {
+          extensionStorage: {
+            [host]: ["guid_1"],
+          },
+        },
+      },
+      data,
+      "The change data from the storage actor's 'stores-update' event matches the changes made in the client."
+    );
+
+    items = {
+      guid_1: "anotherValue",
+      guid_2: DEFAULT_VALUE,
+      guid_3: DEFAULT_VALUE,
+    };
+    extension.sendMessage("storage-local-get", Object.keys(items));
+    let extItems = await extension.awaitMessage("storage-local-get:done");
+    Assert.deepEqual(
+      items,
+      extItems,
+      `The storage items in the extension match the items in the panel`
+    );
+
+    info("Waiting for panel to remove an item");
+    storesUpdate = storageFront.once("stores-update");
+    await extensionStorage.removeItem(host, "guid_3");
+
+    info("Waiting for the storage actor to emit a 'stores-update' event");
+    data = await storesUpdate;
+    Assert.deepEqual(
+      {
+        deleted: {
+          extensionStorage: {
+            [host]: ["guid_3"],
+          },
+        },
+      },
+      data,
+      "The change data from the storage actor's 'stores-update' event matches the changes made in the client."
+    );
+
+    items = {
+      guid_1: "anotherValue",
+      guid_2: DEFAULT_VALUE,
+    };
+    extension.sendMessage("storage-local-get", Object.keys(items));
+    extItems = await extension.awaitMessage("storage-local-get:done");
+    Assert.deepEqual(
+      items,
+      extItems,
+      `The storage items in the extension match the items in the panel`
+    );
+
+    info("Waiting for panel to remove all items");
+    const storesCleared = storageFront.once("stores-cleared");
+    await extensionStorage.removeAll(host);
+
+    info("Waiting for the storage actor to emit a 'stores-cleared' event");
+    data = await storesCleared;
+    Assert.deepEqual(
+      {
+        extensionStorage: {
+          [host]: [],
+        },
+      },
+      data,
+      "The change data from the storage actor's 'stores-cleared' event matches the changes made in the client."
+    );
+
+    items = {};
+    extension.sendMessage("storage-local-get", Object.keys(items));
+    extItems = await extension.awaitMessage("storage-local-get:done");
+    Assert.deepEqual(
+      items,
+      extItems,
+      `The storage items in the extension match the items in the panel`
+    );
+
+    await shutdown(extension, target);
+  }
+);
+
+/**
  * Test case: Storage panel shows extension storage data added prior to extension startup
  * - Load extension that adds a storage item
  * - Uninstall the extension
  * - Reinstall the extension
  * - Open the add-on storage panel.
  * - The data in the storage panel should match the data added the first time the extension
  *   was installed
  * Related test case: Storage panel shows extension storage data when an extension that has
@@ -607,32 +950,49 @@ add_task(
 
     const { target, extensionStorage } = await openAddonStoragePanel(
       extension.id
     );
 
     let { data } = await extensionStorage.getStoreObjects(host);
     Assert.deepEqual(
       data,
-      [{ area: "local", name: "a", value: { str: "123" } }],
+      [
+        {
+          area: "local",
+          name: "a",
+          value: { str: "123" },
+          isValueEditable: true,
+        },
+      ],
       "Got the expected results on populated storage.local"
     );
 
     // Related test case
     extension.sendMessage("storage-local-fireOnChanged");
     extension.sendMessage("storage-local-set", { b: 456 });
     await extension.awaitMessage("storage-local-set:done");
     await extension.awaitMessage("storage-local-onChanged");
 
     data = (await extensionStorage.getStoreObjects(host)).data;
     Assert.deepEqual(
       data,
       [
-        { area: "local", name: "a", value: { str: "123" } },
-        { area: "local", name: "b", value: { str: "456" } },
+        {
+          area: "local",
+          name: "a",
+          value: { str: "123" },
+          isValueEditable: true,
+        },
+        {
+          area: "local",
+          name: "b",
+          value: { str: "456" },
+          isValueEditable: true,
+        },
       ],
       "Got the expected results on populated storage.local"
     );
 
     Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, false);
     Services.prefs.setBoolPref(LEAVE_UUID_PREF, false);
 
     await shutdown(extension, target);
@@ -696,17 +1056,24 @@ add_task(async function test_panel_live_
     })
   );
 
   await extension.awaitMessage("extension-origin");
 
   const { data } = await extensionStorage.getStoreObjects(host);
   Assert.deepEqual(
     data,
-    [{ area: "local", name: "a", value: { str: "123" } }],
+    [
+      {
+        area: "local",
+        name: "a",
+        value: { str: "123" },
+        isValueEditable: true,
+      },
+    ],
     "Got the expected results on populated storage.local"
   );
 
   await shutdown(extension, target);
 });
 
 /**
  * Test case: Transient page adds an item to storage. With storage panel open,
@@ -767,17 +1134,24 @@ add_task(async function test_panel_live_
       manifest,
       files: ext_no_bg.files,
     })
   );
 
   const { data } = await extensionStorage.getStoreObjects(host);
   Assert.deepEqual(
     data,
-    [{ area: "local", name: "a", value: { str: "123" } }],
+    [
+      {
+        area: "local",
+        name: "a",
+        value: { str: "123" },
+        isValueEditable: true,
+      },
+    ],
     "Got the expected results on populated storage.local"
   );
 
   await shutdown(extension, target);
 });
 
 /**
  * Test case: Bg page auto adds item(s). With storage panel open, reload extension.
@@ -830,18 +1204,84 @@ add_task(
     );
 
     await extension.awaitMessage("extension-origin");
 
     const { data } = await extensionStorage.getStoreObjects(host);
     Assert.deepEqual(
       data,
       [
-        { area: "local", name: "a", value: { str: '{"b":123}' } },
-        { area: "local", name: "c", value: { str: '{"d":456}' } },
+        {
+          area: "local",
+          name: "a",
+          value: { str: '{"b":123}' },
+          isValueEditable: true,
+        },
+        {
+          area: "local",
+          name: "c",
+          value: { str: '{"d":456}' },
+          isValueEditable: true,
+        },
+      ],
+      "Got the expected results on populated storage.local"
+    );
+
+    await shutdown(extension, target);
+  }
+);
+
+/**
+ * Test case: Bg page adds one storage.local item and one storage.sync item.
+ * - Load extension with background page that automatically adds two storage items on startup.
+ * - Open the add-on storage panel.
+ * - Assert that only the storage.local item is shown in the panel.
+ */
+add_task(
+  async function test_panel_data_only_updates_for_storage_local_changes() {
+    async function background() {
+      await browser.storage.local.set({ a: { b: 123 } });
+      await browser.storage.sync.set({ c: { d: 456 } });
+      browser.test.sendMessage("extension-origin", window.location.origin);
+    }
+
+    // Using the storage.sync API requires a non-temporary extension ID, see Bug 1323228.
+    const EXTENSION_ID =
+      "test_panel_data_only_updates_for_storage_local_changes@xpcshell.mozilla.org";
+    const manifest = {
+      applications: {
+        gecko: {
+          id: EXTENSION_ID,
+        },
+      },
+    };
+
+    info("Loading and starting extension");
+    const extension = await startupExtension(
+      getExtensionConfig({ manifest, background })
+    );
+
+    info("Waiting for message from test extension");
+    const host = await extension.awaitMessage("extension-origin");
+
+    info("Opening storage panel");
+    const { target, extensionStorage } = await openAddonStoragePanel(
+      extension.id
+    );
+
+    const { data } = await extensionStorage.getStoreObjects(host);
+    Assert.deepEqual(
+      data,
+      [
+        {
+          area: "local",
+          name: "a",
+          value: { str: '{"b":123}' },
+          isValueEditable: true,
+        },
       ],
       "Got the expected results on populated storage.local"
     );
 
     await shutdown(extension, target);
   }
 );
 
--- a/devtools/shared/moz.build
+++ b/devtools/shared/moz.build
@@ -24,16 +24,17 @@ DIRS += [
     'platform',
     'protocol',
     'qrcode',
     'resources',
     'screenshot',
     'security',
     'sprintfjs',
     'specs',
+    'storage',
     'transport',
     'webconsole',
     'worker',
 ]
 
 if CONFIG['MOZ_BUILD_APP'] != 'mobile/android':
     BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini']
 
--- a/devtools/shared/specs/storage.js
+++ b/devtools/shared/specs/storage.js
@@ -165,28 +165,38 @@ createStorageSpec({
   typeName: "sessionStorage",
   storeObjectType: "storagestoreobject",
   methods: storageMethods,
 });
 
 types.addDictType("extensionobject", {
   name: "nullable:string",
   value: "nullable:longstring",
+  area: "string",
+  isValueEditable: "boolean",
 });
 
 types.addDictType("extensionstoreobject", {
   total: "number",
   offset: "number",
   data: "array:nullable:extensionobject",
 });
 
 createStorageSpec({
   typeName: "extensionStorage",
   storeObjectType: "extensionstoreobject",
-  methods: {},
+  // Same as storageMethods except for addItem
+  methods: Object.assign({}, editRemoveMethods, {
+    removeAll: {
+      request: {
+        host: Arg(0, "string"),
+      },
+      response: {},
+    },
+  }),
 });
 
 types.addDictType("cacheobject", {
   url: "string",
   status: "string",
 });
 
 // Array of Cache store objects
new file mode 100644
--- /dev/null
+++ b/devtools/shared/storage/moz.build
@@ -0,0 +1,13 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+    'vendor'
+]
+
+DevToolsModules(
+    'utils.js'
+)
new file mode 100644
--- /dev/null
+++ b/devtools/shared/storage/utils.js
@@ -0,0 +1,156 @@
+/* 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";
+
+loader.lazyRequireGetter(
+  this,
+  "validator",
+  "devtools/shared/storage/vendor/stringvalidator/validator"
+);
+loader.lazyRequireGetter(this, "JSON5", "devtools/shared/storage/vendor/json5");
+
+const MATH_REGEX = /(?:(?:^|[-+_*/])(?:\s*-?\d+(\.\d+)?(?:[eE][+-]?\d+)?\s*))+$/;
+
+/**
+ * Tries to parse a string into an object on the basis of key-value pairs,
+ * separated by various separators. If failed, tries to parse for single
+ * separator separated values to form an array.
+ *
+ * @param {string} value
+ *        The string to be parsed into an object or array
+ */
+function _extractKeyValPairs(value) {
+  const makeObject = (keySep, pairSep) => {
+    const object = {};
+    for (const pair of value.split(pairSep)) {
+      const [key, val] = pair.split(keySep);
+      object[key] = val;
+    }
+    return object;
+  };
+
+  // Possible separators.
+  const separators = ["=", ":", "~", "#", "&", "\\*", ",", "\\."];
+  // Testing for object
+  for (let i = 0; i < separators.length; i++) {
+    const kv = separators[i];
+    for (let j = 0; j < separators.length; j++) {
+      if (i == j) {
+        continue;
+      }
+      const p = separators[j];
+      const word = `[^${kv}${p}]*`;
+      const keyValue = `${word}${kv}${word}`;
+      const keyValueList = `${keyValue}(${p}${keyValue})*`;
+      const regex = new RegExp(`^${keyValueList}$`);
+      if (
+        value.match &&
+        value.match(regex) &&
+        value.includes(kv) &&
+        (value.includes(p) || value.split(kv).length == 2)
+      ) {
+        return makeObject(kv, p);
+      }
+    }
+  }
+  // Testing for array
+  for (const p of separators) {
+    const word = `[^${p}]*`;
+    const wordList = `(${word}${p})+${word}`;
+    const regex = new RegExp(`^${wordList}$`);
+
+    if (regex.test(value)) {
+      const pNoBackslash = p.replace(/\\*/g, "");
+      return value.split(pNoBackslash);
+    }
+  }
+  return null;
+}
+
+/**
+ * Check whether the value string represents something that should be
+ * displayed as text. If so then it shouldn't be parsed into a tree.
+ *
+ * @param  {String} value
+ *         The value to be parsed.
+ */
+function _shouldParse(value) {
+  const validators = [
+    "isBase64",
+    "isBoolean",
+    "isCurrency",
+    "isDataURI",
+    "isEmail",
+    "isFQDN",
+    "isHexColor",
+    "isIP",
+    "isISO8601",
+    "isMACAddress",
+    "isSemVer",
+    "isURL",
+  ];
+
+  // Check for minus calculations e.g. 8-3 because otherwise 5 will be displayed.
+  if (MATH_REGEX.test(value)) {
+    return false;
+  }
+
+  // Check for any other types that shouldn't be parsed.
+  for (const test of validators) {
+    if (validator[test](value)) {
+      return false;
+    }
+  }
+
+  // Seems like this is data that should be parsed.
+  return true;
+}
+
+/**
+ * Tries to parse a string value into either a json or a key-value separated
+ * object. The value can also be a key separated array.
+ *
+ * @param {string} originalValue
+ *        The string to be parsed into an object
+ */
+function parseItemValue(originalValue) {
+  // Find if value is URLEncoded ie
+  let decodedValue = "";
+  try {
+    decodedValue = decodeURIComponent(originalValue);
+  } catch (e) {
+    // Unable to decode, nothing to do
+  }
+  const value =
+    decodedValue && decodedValue !== originalValue
+      ? decodedValue
+      : originalValue;
+
+  if (!_shouldParse(value)) {
+    return value;
+  }
+
+  let obj = null;
+  try {
+    obj = JSON5.parse(value);
+  } catch (ex) {
+    obj = null;
+  }
+
+  if (!obj && value) {
+    obj = _extractKeyValPairs(value);
+  }
+
+  // return if obj is null, or same as value, or just a string.
+  if (!obj || obj === value || typeof obj === "string") {
+    return value;
+  }
+
+  // If we got this far, originalValue is an object literal or array,
+  // and we have successfully parsed it
+  return obj;
+}
+
+exports.parseItemValue = parseItemValue;
rename from devtools/client/shared/vendor/JSON5_LICENSE
rename to devtools/shared/storage/vendor/JSON5_LICENSE
rename from devtools/client/shared/vendor/JSON5_UPGRADING.md
rename to devtools/shared/storage/vendor/JSON5_UPGRADING.md
--- a/devtools/client/shared/vendor/JSON5_UPGRADING.md
+++ b/devtools/shared/storage/vendor/JSON5_UPGRADING.md
@@ -12,17 +12,17 @@ cd json5
 git checkout v2.1.0 # checkout the right version tag
 ```
 
 ## Building
 
 ```bash
 npm install
 npm run build
-cp dist/index.js <gecko-dev>/devtools/client/shared/vendor/json5.js
+cp dist/index.js <gecko-dev>/devtools/shared/storage/vendor/json5.js
 ```
 
 ## Patching json5
 
 - open `json5.js`
 - Add the version number to the top of the file:
   ```
   /**
rename from devtools/client/shared/vendor/json5.js
rename to devtools/shared/storage/vendor/json5.js
new file mode 100644
--- /dev/null
+++ b/devtools/shared/storage/vendor/moz.build
@@ -0,0 +1,13 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+    'stringvalidator',
+]
+
+DevToolsModules(
+    'json5.js',
+)
\ No newline at end of file
rename from devtools/client/shared/vendor/stringvalidator/UPDATING.md
rename to devtools/shared/storage/vendor/stringvalidator/UPDATING.md
rename from devtools/client/shared/vendor/stringvalidator/moz.build
rename to devtools/shared/storage/vendor/stringvalidator/moz.build
rename from devtools/client/shared/vendor/stringvalidator/tests/unit/head_stringvalidator.js
rename to devtools/shared/storage/vendor/stringvalidator/tests/unit/head_stringvalidator.js
--- a/devtools/client/shared/vendor/stringvalidator/tests/unit/head_stringvalidator.js
+++ b/devtools/shared/storage/vendor/stringvalidator/tests/unit/head_stringvalidator.js
@@ -1,13 +1,13 @@
 "use strict";
 
 const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm");
 
-this.validator = require("devtools/client/shared/vendor/stringvalidator/validator");
+this.validator = require("devtools/shared/storage/vendor/stringvalidator/validator");
 
 function describe(suite, testFunc) {
   info(`\n                            Test suite: ${suite}`.toUpperCase());
   testFunc();
 }
 
 function it(description, testFunc) {
   info(`\n                              - ${description}:\n`.toUpperCase());
rename from devtools/client/shared/vendor/stringvalidator/tests/unit/test_sanitizers.js
rename to devtools/shared/storage/vendor/stringvalidator/tests/unit/test_sanitizers.js
rename from devtools/client/shared/vendor/stringvalidator/tests/unit/test_validators.js
rename to devtools/shared/storage/vendor/stringvalidator/tests/unit/test_validators.js
--- a/devtools/client/shared/vendor/stringvalidator/tests/unit/test_validators.js
+++ b/devtools/shared/storage/vendor/stringvalidator/tests/unit/test_validators.js
@@ -1,17 +1,17 @@
 /*
  * Copyright 2013 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.md or:
  * http://opensource.org/licenses/BSD-2-Clause
  */
 
  "use strict";
 
-var assert = require('devtools/client/shared/vendor/stringvalidator/util/assert').assert;
+var assert = require('devtools/shared/storage/vendor/stringvalidator/util/assert').assert;
 
 function test(options) {
   var args = options.args || [];
   args.unshift(null);
   if (options.valid) {
     options.valid.forEach(function (valid) {
       args[0] = valid;
 
rename from devtools/client/shared/vendor/stringvalidator/tests/unit/xpcshell.ini
rename to devtools/shared/storage/vendor/stringvalidator/tests/unit/xpcshell.ini
rename from devtools/client/shared/vendor/stringvalidator/util/assert.js
rename to devtools/shared/storage/vendor/stringvalidator/util/assert.js
rename from devtools/client/shared/vendor/stringvalidator/util/moz.build
rename to devtools/shared/storage/vendor/stringvalidator/util/moz.build
rename from devtools/client/shared/vendor/stringvalidator/validator.js
rename to devtools/shared/storage/vendor/stringvalidator/validator.js