Bug 1349896 - Part 2: Implement devtools.panels.themeName API property, f?rpl draft
authorBob Silverberg <bsilverberg@mozilla.com>
Thu, 11 May 2017 08:54:02 -0400
changeset 583034 69ad2716e7ad487ac66d568da91eff99abe0ccc9
parent 583033 a692c944a95092f74e7bb9f38f3e630dcb720bf0
child 629949 af4edc9daf016d2bfd87f5c48d3c484981b60d41
push id60278
push userbmo:bob.silverberg@gmail.com
push dateTue, 23 May 2017 16:25:10 +0000
bugs1349896
milestone55.0a1
Bug 1349896 - Part 2: Implement devtools.panels.themeName API property, f?rpl MozReview-Commit-ID: 8uN2VBIyYyt
browser/components/extensions/ext-c-devtools-panels.js
browser/components/extensions/ext-devtools.js
browser/components/extensions/schemas/devtools_panels.json
browser/components/extensions/test/browser/browser_ext_devtools_panel.js
toolkit/components/extensions/ExtensionChildDevToolsUtils.jsm
toolkit/components/extensions/ExtensionPageChild.jsm
toolkit/components/extensions/moz.build
--- a/browser/components/extensions/ext-c-devtools-panels.js
+++ b/browser/components/extensions/ext-c-devtools-panels.js
@@ -1,14 +1,18 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
+Cu.import("resource://gre/modules/Services.jsm");
+
 XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
                                   "resource://gre/modules/EventEmitter.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionChildDevToolsUtils",
+                                  "resource://gre/modules/ExtensionChildDevToolsUtils.jsm");
 
 var {
   promiseDocumentLoaded,
 } = ExtensionUtils;
 
 /**
  * Represents an addon devtools panel in the child process.
  *
@@ -127,29 +131,44 @@ class ChildDevToolsPanel extends EventEm
 
     this._panelContext = null;
     this.context = null;
   }
 }
 
 this.devtools_panels = class extends ExtensionAPI {
   getAPI(context) {
+    const themeChangeObserver = ExtensionChildDevToolsUtils.getThemeChangeObserver();
+
     return {
       devtools: {
         panels: {
           create(title, icon, url) {
             return context.cloneScope.Promise.resolve().then(async () => {
               const panelId = await context.childManager.callParentAsyncFunction(
                 "devtools.panels.create", [title, icon, url]);
 
               const devtoolsPanel = new ChildDevToolsPanel(context, {id: panelId});
 
               const devtoolsPanelAPI = Cu.cloneInto(devtoolsPanel.api(),
                                                     context.cloneScope,
                                                     {cloneFunctions: true});
               return devtoolsPanelAPI;
             });
           },
+          get themeName() {
+            return themeChangeObserver.themeName;
+          },
+          onThemeChanged: new SingletonEventManager(
+            context, "devtools.panels.onThemeChanged", fire => {
+              const listener = (eventName, themeName) => {
+                fire.async(themeName);
+              };
+              themeChangeObserver.on("themeChanged", listener);
+              return () => {
+                themeChangeObserver.off("themeChanged", listener);
+              };
+            }).api(),
         },
       },
     };
   }
 };
--- a/browser/components/extensions/ext-devtools.js
+++ b/browser/components/extensions/ext-devtools.js
@@ -133,16 +133,17 @@ class DevToolsPage extends HiddenExtensi
 
         this.resolveTopLevelContext(context);
       }
     });
 
     extensions.emit("extension-browser-inserted", this.browser, {
       devtoolsToolboxInfo: {
         inspectedWindowTabId: getTargetTabIdForToolbox(this.toolbox),
+        themeName: gDevTools.getTheme(),
       },
     });
 
     this.browser.loadURI(this.url);
 
     await this.waitForTopLevelContext;
   }
 
@@ -195,39 +196,53 @@ class DevToolsPageDefinition {
 
     this.url = url;
     this.extension = extension;
 
     // Map[TabTarget -> DevToolsPage]
     this.devtoolsPageForTarget = new Map();
   }
 
+  onThemeChanged(evt, themeName) {
+    Services.ppmm.broadcastAsyncMessage("Extension:DevToolsThemeChanged", {themeName});
+  }
+
   buildForToolbox(toolbox) {
     if (this.devtoolsPageForTarget.has(toolbox.target)) {
       return Promise.reject(new Error("DevtoolsPage has been already created for this toolbox"));
     }
 
     const devtoolsPage = new DevToolsPage(this.extension, {
       toolbox, url: this.url, devToolsPageDefinition: this,
     });
+
+    // If this is the first DevToolsPage, subscribe to the theme-changed event
+    if (this.devtoolsPageForTarget.size === 0) {
+      gDevTools.on("theme-changed", this.onThemeChanged);
+    }
     this.devtoolsPageForTarget.set(toolbox.target, devtoolsPage);
 
     return devtoolsPage.build();
   }
 
   shutdownForTarget(target) {
     if (this.devtoolsPageForTarget.has(target)) {
       const devtoolsPage = this.devtoolsPageForTarget.get(target);
       devtoolsPage.close();
 
       // `devtoolsPage.close()` should remove the instance from the map,
       // raise an exception if it is still there.
       if (this.devtoolsPageForTarget.has(target)) {
         throw new Error(`Leaked DevToolsPage instance for target "${target.toString()}"`);
       }
+
+      // If this was the last DevToolsPage, unsubscribe from the theme-changed event
+      if (this.devtoolsPageForTarget.size === 0) {
+        gDevTools.off("theme-changed", this.onThemeChanged);
+      }
     }
   }
 
   forgetForTarget(target) {
     this.devtoolsPageForTarget.delete(target);
   }
 
   shutdown() {
--- a/browser/components/extensions/schemas/devtools_panels.json
+++ b/browser/components/extensions/schemas/devtools_panels.json
@@ -311,16 +311,20 @@
     "properties": {
       "elements": {
         "$ref": "ElementsPanel",
         "description": "Elements panel."
       },
       "sources": {
         "$ref": "SourcesPanel",
         "description": "Sources panel."
+      },
+      "themeName": {
+        "type": "string",
+        "description": "The name of the current devtools theme."
       }
     },
     "functions": [
       {
         "name": "create",
         "type": "function",
         "description": "Creates an extension panel.",
         "async": "callback",
@@ -397,11 +401,25 @@
           {
             "name": "callback",
             "type": "function",
             "optional": true,
             "description": "A function that is called when the resource has been successfully loaded."
           }
         ]
       }
+    ],
+    "events": [
+      {
+        "name": "onThemeChanged",
+        "type": "function",
+        "description": "Fired when the devtools theme changes.",
+        "parameters": [
+          {
+            "name": "themeName",
+            "type": "string",
+            "description": "The name of the current devtools theme."
+          }
+        ]
+      }
     ]
   }
 ]
--- a/browser/components/extensions/test/browser/browser_ext_devtools_panel.js
+++ b/browser/components/extensions/test/browser/browser_ext_devtools_panel.js
@@ -1,23 +1,104 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetter(this, "devtools",
                                   "resource://devtools/shared/Loader.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "gDevTools",
                                   "resource://devtools/client/framework/gDevTools.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
+                                  "resource://gre/modules/Preferences.jsm");
+
+const DEVTOOLS_THEME_PREF = "devtools.theme";
 
 /**
  * This test file ensures that:
  *
- * - ensures that devtools.panel.create is able to create a devtools panel
+ * - devtools.panels.themeName returns the correct value,
+ *   both from a page and a panel.
+ * - devtools.panels.onThemeChanged fires for theme changes,
+ *   both from a page and a panel.
+ * - devtools.panels.create is able to create a devtools panel.
  */
 
+async function switchTheme(theme) {
+  const waitforThemeChanged = new Promise(resolve => gDevTools.once("theme-changed", resolve));
+  Preferences.set(DEVTOOLS_THEME_PREF, theme);
+  await waitforThemeChanged;
+}
+
+async function testThemeSwitching(extension, locations = ["page"]) {
+  for (let newTheme of ["dark", "light"]) {
+    await switchTheme(newTheme);
+    for (let location of locations) {
+      is(await extension.awaitMessage(`devtools_theme_changed_${location}`),
+        newTheme,
+        `The onThemeChanged event listener fired for the ${location}.`);
+      is(await extension.awaitMessage(`current_theme_${location}`),
+        newTheme,
+        `The current theme is reported as expected for the ${location}.`);
+    }
+  }
+}
+
+add_task(async function test_theme_name_no_panel() {
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+
+  async function devtools_page() {
+    browser.devtools.panels.onThemeChanged.addListener(themeName => {
+      browser.test.sendMessage("devtools_theme_changed_page", themeName);
+      browser.test.sendMessage("current_theme_page", browser.devtools.panels.themeName);
+    });
+
+    browser.test.sendMessage("initial_theme", browser.devtools.panels.themeName);
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      devtools_page: "devtools_page.html",
+    },
+    files: {
+      "devtools_page.html": `<!DOCTYPE html>
+      <html>
+       <head>
+         <meta charset="utf-8">
+       </head>
+       <body>
+         <script src="devtools_page.js"></script>
+       </body>
+      </html>`,
+      "devtools_page.js": devtools_page,
+    },
+  });
+
+  // Ensure that the initial value of the devtools theme is "light".
+  await SpecialPowers.pushPrefEnv({set: [[DEVTOOLS_THEME_PREF, "light"]]});
+
+  await extension.startup();
+
+  let target = devtools.TargetFactory.forTab(tab);
+  await gDevTools.showToolbox(target, "webconsole");
+  info("developer toolbox opened");
+
+  is(await extension.awaitMessage("initial_theme"),
+    "light",
+    "The initial theme is reported as expected.");
+
+  await testThemeSwitching(extension);
+
+  await gDevTools.closeToolbox(target);
+  await target.destroy();
+
+  await extension.unload();
+
+  await BrowserTestUtils.removeTab(tab);
+});
+
 add_task(async function test_devtools_page_panels_create() {
   let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
 
   async function devtools_page() {
     const result = {
       devtoolsPageTabId: browser.devtools.inspectedWindow.tabId,
       panelCreated: 0,
       panelShown: 0,
@@ -41,31 +122,44 @@ add_task(async function test_devtools_pa
       });
 
       panel.onHidden.addListener(() => {
         result.panelHidden++;
 
         browser.test.sendMessage("devtools_panel_hidden", result);
       });
 
+      browser.devtools.panels.onThemeChanged.addListener(themeName => {
+        browser.test.sendMessage("devtools_theme_changed_page", themeName);
+        browser.test.sendMessage("current_theme_page", browser.devtools.panels.themeName);
+      });
+
       browser.test.sendMessage("devtools_panel_created");
+      browser.test.sendMessage("initial_theme_page", browser.devtools.panels.themeName);
     } catch (err) {
       // Make the test able to fail fast when it is going to be a failure.
       browser.test.sendMessage("devtools_panel_created");
       throw err;
     }
   }
 
   function devtools_panel() {
     // Set a property in the global and check that it is defined
     // and accessible from the devtools_page when the panel.onShown
     // event has been received.
     window.TEST_PANEL_GLOBAL = "test_panel_global";
+
+    browser.devtools.panels.onThemeChanged.addListener(themeName => {
+      browser.test.sendMessage("devtools_theme_changed_panel", themeName);
+      browser.test.sendMessage("current_theme_panel", browser.devtools.panels.themeName);
+    });
+
     browser.test.sendMessage("devtools_panel_inspectedWindow_tabId",
                              browser.devtools.inspectedWindow.tabId);
+    browser.test.sendMessage("initial_theme_panel", browser.devtools.panels.themeName);
   }
 
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       devtools_page: "devtools_page.html",
     },
     files: {
       "devtools_page.html": `<!DOCTYPE html>
@@ -87,39 +181,56 @@ add_task(async function test_devtools_pa
          DEVTOOLS PANEL
          <script src="devtools_panel.js"></script>
        </body>
       </html>`,
       "devtools_panel.js": devtools_panel,
     },
   });
 
+  registerCleanupFunction(function() {
+    Preferences.reset(DEVTOOLS_THEME_PREF);
+  });
+
+  // Ensure that the initial value of the devtools theme is "light".
+  Preferences.set(DEVTOOLS_THEME_PREF, "light");
+
   await extension.startup();
 
   let target = devtools.TargetFactory.forTab(tab);
 
   const toolbox = await gDevTools.showToolbox(target, "webconsole");
   info("developer toolbox opened");
 
   await extension.awaitMessage("devtools_panel_created");
+  is(await extension.awaitMessage("initial_theme_page"),
+    "light",
+    "The initial theme is reported as expected from a devtools page.");
 
   const toolboxAdditionalTools = toolbox.getAdditionalTools();
 
   is(toolboxAdditionalTools.length, 1,
      "Got the expected number of toolbox specific panel registered.");
 
+  await testThemeSwitching(extension);
+
   const panelId = toolboxAdditionalTools[0].id;
 
   await gDevTools.showToolbox(target, panelId);
   const {devtoolsPageTabId} = await extension.awaitMessage("devtools_panel_shown");
   const devtoolsPanelTabId = await extension.awaitMessage("devtools_panel_inspectedWindow_tabId");
   is(devtoolsPanelTabId, devtoolsPageTabId,
      "Got the same devtools.inspectedWindow.tabId from devtools page and panel");
+  is(await extension.awaitMessage("initial_theme_panel"),
+    "light",
+    "The initial theme is reported as expected from a devtools panel.");
   info("Addon Devtools Panel shown");
 
+  await testThemeSwitching(extension, ["page", "panel"]);
+
   await gDevTools.showToolbox(target, "webconsole");
   const results = await extension.awaitMessage("devtools_panel_hidden");
   info("Addon Devtools Panel hidden");
 
   is(results.panelCreated, 1, "devtools.panel.create callback has been called once");
   is(results.panelShown, 1, "panel.onShown listener has been called once");
   is(results.panelHidden, 1, "panel.onHidden listener has been called once");
 
@@ -165,31 +276,33 @@ add_task(async function test_devtools_pa
      "The tool has been added on visibilityswitch set to true");
   is(toolbox.visibleAdditionalTools.filter(toolId => toolId == panelId).length, 1,
      "The tool is visible on visibilityswitch set to true");
 
   // Test devtools panel is loaded correctly after being toggled and
   // devtools panel events has been fired as expected.
   await gDevTools.showToolbox(target, panelId);
   await extension.awaitMessage("devtools_panel_shown");
+  is(await extension.awaitMessage("initial_theme_panel"),
+    "light",
+    "The initial theme is reported as expected from a devtools panel.");
   info("Addon Devtools Panel shown - after visibilityswitch toggled");
 
   info("Wait until the Addon Devtools Panel has been loaded - after visibilityswitch toggled");
   const panelTabIdAfterToggle = await extension.awaitMessage("devtools_panel_inspectedWindow_tabId");
   is(panelTabIdAfterToggle, devtoolsPageTabId,
      "Got the same devtools.inspectedWindow.tabId from devtools panel after visibility toggled");
 
   await gDevTools.showToolbox(target, "webconsole");
   const toolToggledResults = await extension.awaitMessage("devtools_panel_hidden");
   info("Addon Devtools Panel hidden - after visibilityswitch toggled");
 
   is(toolToggledResults.panelCreated, 1, "devtools.panel.create callback has been called once");
   is(toolToggledResults.panelShown, 3, "panel.onShown listener has been called three times");
   is(toolToggledResults.panelHidden, 3, "panel.onHidden listener has been called three times");
 
   await gDevTools.closeToolbox(target);
-
   await target.destroy();
 
   await extension.unload();
 
   await BrowserTestUtils.removeTab(tab);
 });
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionChildDevToolsUtils.jsm
@@ -0,0 +1,116 @@
+/* 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/. */
+
+/**
+ * @fileOverview
+ * This module contains utilities for interacting with DevTools
+ * from the child process.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["ExtensionChildDevToolsUtils"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
+                                  "resource://gre/modules/EventEmitter.jsm");
+
+// Create a variable to hold the cached ThemeChangeObserver which does not
+// get created until a devtools context has been created.
+let themeChangeObserver;
+
+/**
+ * An observer that watches for changes to the devtools theme and provides
+ * that information to the devtools.panels.themeName API property, as well as
+ * emits events for the devtools.panels.onThemeChanged event. It also caches
+ * the current value of devtools.themeName.
+ */
+class ThemeChangeObserver extends EventEmitter {
+  constructor(themeName, onDestroyed) {
+    super();
+    this.themeName = themeName;
+    this.onDestroyed = onDestroyed;
+    this.contexts = new Set();
+
+    Services.cpmm.addMessageListener("Extension:DevToolsThemeChanged", this);
+  }
+
+  addContext(context) {
+    if (this.contexts.has(context)) {
+      throw new Error(
+        "addContext on the ThemeChangeObserver was called more than once" +
+        " for the context.");
+    }
+
+    context.callOnClose({
+      close: () => this.onContextClosed(context),
+    });
+
+    this.contexts.add(context);
+  }
+
+  onContextClosed(context) {
+    this.contexts.delete(context);
+
+    if (this.contexts.size === 0) {
+      this.destroy();
+    }
+  }
+
+  onThemeChanged(themeName) {
+    // Update the cached themeName and emit an event for the API.
+    this.themeName = themeName;
+    this.emit("themeChanged", themeName);
+  }
+
+  receiveMessage({name, data}) {
+    if (name === "Extension:DevToolsThemeChanged") {
+      this.onThemeChanged(data.themeName);
+    }
+  }
+
+  destroy() {
+    Services.cpmm.removeMessageListener("Extension:DevToolsThemeChanged", this);
+    this.onDestroyed();
+    this.onDestroyed = null;
+    this.contexts.clear();
+    this.contexts = null;
+  }
+}
+
+this.ExtensionChildDevToolsUtils = {
+  /**
+   * Creates an cached instance of the ThemeChangeObserver class and
+   * initializes it with the current themeName. This cached instance is
+   * destroyed when all of the contexts added to it are closed.
+   *
+   * @param {string} themeName The name of the current devtools theme.
+   * @param {DevToolsContextChild} context The newly created devtools page context.
+   */
+  initThemeChangeObserver(themeName, context) {
+    if (!themeChangeObserver) {
+      themeChangeObserver = new ThemeChangeObserver(
+        themeName,
+        function() { themeChangeObserver = null; }
+      );
+    }
+    themeChangeObserver.addContext(context);
+  },
+
+  /**
+   * Returns the cached instance of ThemeChangeObserver.
+   *
+   * @returns {ThemeChangeObserver} The cached instance of ThemeChangeObserver.
+   */
+  getThemeChangeObserver() {
+    if (!themeChangeObserver) {
+      throw new Error("A ThemeChangeObserver must be created before being retrieved.");
+    }
+    return themeChangeObserver;
+  },
+};
--- a/toolkit/components/extensions/ExtensionPageChild.jsm
+++ b/toolkit/components/extensions/ExtensionPageChild.jsm
@@ -16,16 +16,18 @@ this.EXPORTED_SYMBOLS = ["ExtensionPageC
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionChildDevToolsUtils",
+                                  "resource://gre/modules/ExtensionChildDevToolsUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
                                   "resource://gre/modules/ExtensionManagement.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames",
                                   "resource://gre/modules/WebNavigationFrames.jsm");
 
 const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";
@@ -259,16 +261,18 @@ class DevToolsContextChild extends Exten
    * @param {string} params.viewType One of "devtools_page" or "devtools_panel".
    * @param {object} [params.devtoolsToolboxInfo] This devtools toolbox's information,
    *   used if viewType is "devtools_page" or "devtools_panel".
    */
   constructor(extension, params) {
     super(extension, Object.assign(params, {envType: "devtools_child"}));
 
     this.devtoolsToolboxInfo = params.devtoolsToolboxInfo;
+    ExtensionChildDevToolsUtils.initThemeChangeObserver(
+      params.devtoolsToolboxInfo.themeName, this);
 
     this.extension.devtoolsViews.add(this);
   }
 
   unload() {
     super.unload();
     this.extension.devtoolsViews.delete(this);
   }
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -6,16 +6,17 @@
 
 with Files('**'):
     BUG_COMPONENT = ('Toolkit', 'WebExtensions: General')
 
 EXTRA_JS_MODULES += [
     'Extension.jsm',
     'ExtensionAPI.jsm',
     'ExtensionChild.jsm',
+    'ExtensionChildDevToolsUtils.jsm',
     'ExtensionCommon.jsm',
     'ExtensionContent.jsm',
     'ExtensionManagement.jsm',
     'ExtensionPageChild.jsm',
     'ExtensionParent.jsm',
     'ExtensionPermissions.jsm',
     'ExtensionPreferencesManager.jsm',
     'ExtensionSettingsStore.jsm',