Bug 1498967 - Display restart message in Add-Ons Manager when changing legacy extensions. r=mkmelin
☠☠ backed out by e51fe5c93b88 ☠ ☠
authorGeoff Lankow <geoff@darktrojan.net>
Tue, 23 Oct 2018 21:07:33 +1300
changeset 32691 09209cd0234390b24f1fe072ea9a5d0357a4ec84
parent 32690 1fdc6763b5472cf29564e8c257bf84ab7402b55a
child 32692 8f2aaabd3130c106bd569bd7ed4186d434f9f97e
push id2343
push userclokep@gmail.com
push dateMon, 10 Dec 2018 21:37:21 +0000
treeherdercomm-beta@a0750c375f71 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmkmelin
bugs1498967
Bug 1498967 - Display restart message in Add-Ons Manager when changing legacy extensions. r=mkmelin
common/src/extensionSupport.jsm
mail/base/content/aboutAddonsExtra.js
mail/base/content/extensions.xml
mail/components/extensions/parent/ext-legacy.js
mail/locales/en-US/chrome/messenger/extensionsOverlay.properties
--- a/common/src/extensionSupport.jsm
+++ b/common/src/extensionSupport.jsm
@@ -1,31 +1,78 @@
 /* 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/. */
 
 /**
- * Helper functions for use by entensions that should ease them plug
+ * Helper functions for use by extensions that should ease them plug
  * into the application.
  */
 
 this.EXPORTED_SYMBOLS = ["ExtensionSupport"];
 
+ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 // ChromeUtils.import("resource://gre/modules/Deprecated.jsm") - needed for warning.
 ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
 
 var { fixIterator } = ChromeUtils.import("resource:///modules/iteratorUtils.jsm", null);
 ChromeUtils.import("resource:///modules/IOUtils.js");
 
 var extensionHooks = new Map();
+var legacyExtensions = new Map();
 var openWindowList;
 
 var ExtensionSupport = {
-  loadedLegacyExtensions: new Set(),
+  /**
+   * A Map-like object which tracks legacy extension status. The "has" method
+   * returns only active extensions for compatibility with existing code.
+   */
+  loadedLegacyExtensions: {
+    set(id, state) {
+      legacyExtensions.set(id, state);
+    },
+    get(id) {
+      return legacyExtensions.get(id);
+    },
+    has(id) {
+      if (!legacyExtensions.has(id))
+        return false;
+
+      let state = legacyExtensions.get(id);
+      return !["install", "enable"].includes(state.pendingOperation);
+    },
+    hasAnyState(id) {
+      return legacyExtensions.has(id);
+    },
+    _maybeDelete(id, newPendingOperation) {
+      if (!legacyExtensions.has(id))
+        return;
+
+      let state = legacyExtensions.get(id);
+      if (state.pendingOperation == "enable" && newPendingOperation == "disable") {
+        legacyExtensions.delete(id);
+        this.notifyObservers(state);
+      } else if (state.pendingOperation == "install" && newPendingOperation == "uninstall") {
+        legacyExtensions.delete(id);
+        this.notifyObservers(state);
+      }
+    },
+    notifyObservers(state) {
+      let wrappedState = { wrappedJSObject: state };
+      Services.obs.notifyObservers(wrappedState, "legacy-addon-status-changed");
+    },
+    // AddonListener
+    onDisabled(ev) {
+      this._maybeDelete(ev.id, "disable");
+    },
+    onUninstalled(ev) {
+      this._maybeDelete(ev.id, "uninstall");
+    },
+  },
 
   loadAddonPrefs(addonFile) {
     function setPref(preferDefault, name, value) {
       let branch = preferDefault ? Services.prefs.getDefaultBranch("") : Services.prefs.getBranch("");
 
       if (typeof value == "boolean") {
         branch.setBoolPref(name, value);
       } else if (typeof value == "string") {
@@ -302,8 +349,10 @@ var ExtensionSupport = {
       }
     }
   },
 
   get registeredWindowListenerCount() {
     return extensionHooks.size;
   },
 };
+
+AddonManager.addAddonListener(ExtensionSupport.loadedLegacyExtensions);
--- a/mail/base/content/aboutAddonsExtra.js
+++ b/mail/base/content/aboutAddonsExtra.js
@@ -121,60 +121,132 @@ window.sortElements = function(aElements
 if (window.gViewController.currentViewObj == window.gListView) {
   window.sortList(window.gListView._listBox, ["uiState", "name"], true);
 }
 
 gDetailView._oldDetailUpdateState = gDetailView.updateState;
 gDetailView.updateState = function() {
   this._oldDetailUpdateState();
 
-  let pending = this._addon.pendingOperations;
-
-  let warningContainer = document.getElementById("warning-container");
-  let warning = document.getElementById("detail-warning");
-  let warningLink = document.getElementById("detail-warning-link");
   let restartButton = document.getElementById("restart-btn");
   let undoButton = document.getElementById("undo-btn");
 
   if (ExtensionSupport.loadedLegacyExtensions.has(this._addon.id)) {
     this.node.setAttribute("active", "true");
-    this.node.removeAttribute("pending");
   }
 
-  if (ExtensionSupport.loadedLegacyExtensions.has(this._addon.id) &&
-      (this._addon.userDisabled || pending & AddonManager.PENDING_UNINSTALL)) {
-    this.node.setAttribute("notification", "warning");
+  if (ExtensionSupport.loadedLegacyExtensions.hasAnyState(this._addon.id, true)) {
+    let { stringName, undoCommand, version } = getTrueState(this._addon, "gDetailView._addon");
+
+    if (stringName) {
+      this.node.setAttribute("notification", "warning");
+      this.node.removeAttribute("pending");
 
-    let stringName = this._addon.userDisabled ? "warnLegacyDisable" : "warnLegacyUninstall";
-    warning.textContent = gStrings.mailExt.formatStringFromName(
-      stringName, [this._addon.name, gStrings.brandShortName], 2
-    );
+      let warningContainer = document.getElementById("warning-container");
+      let warning = document.getElementById("detail-warning");
+      document.getElementById("detail-warning-link").hidden = true;
+      warning.textContent = gStrings.mailExt.formatStringFromName(
+        stringName, [this._addon.name, gStrings.brandShortName], 2
+      );
 
-    warningLink.hidden = true;
+      if (version) {
+        document.getElementById("detail-version").value = version;
+      }
 
-    if (!restartButton) {
-      restartButton = document.createElement("button");
-      restartButton.id = "restart-btn";
-      restartButton.className = "button-link restart-btn";
-      restartButton.setAttribute("label", gStrings.mailExt.GetStringFromName("warnLegacyRestartButton"));
-      restartButton.setAttribute("oncommand", "BrowserUtils.restartApplication()");
-      warningContainer.insertBefore(restartButton, warningContainer.lastElementChild);
+      if (!restartButton) {
+        restartButton = document.createElement("button");
+        restartButton.id = "restart-btn";
+        restartButton.className = "button-link restart-btn";
+        restartButton.setAttribute(
+          "label", gStrings.mailExt.GetStringFromName("warnLegacyRestartButton")
+        );
+        restartButton.setAttribute("oncommand", "BrowserUtils.restartApplication()");
+        warningContainer.insertBefore(restartButton, warningContainer.lastElementChild);
+      }
+      restartButton.hidden = false;
+      if (undoCommand) {
+        if (!undoButton) {
+          undoButton = document.createElement("button");
+          undoButton.className = "button-link undo-btn";
+          undoButton.setAttribute(
+            "label", gStrings.mailExt.GetStringFromName("warnLegacyUndoButton")
+          );
+          // We shouldn't really attach non-anonymous content to anonymous content, but we can.
+          warningContainer.insertBefore(undoButton, warningContainer.lastElementChild);
+        }
+        undoButton.setAttribute("oncommand", undoCommand);
+        undoButton.hidden = false;
+      } else if (undoButton) {
+        undoButton.hidden = true;
+      }
+      return;
     }
-    restartButton.hidden = false;
+  }
 
-    if (!undoButton) {
-      undoButton = document.createElement("button");
-      undoButton.id = "undo-btn";
-      undoButton.className = "button-link undo-btn";
-      undoButton.setAttribute("label", gStrings.mailExt.GetStringFromName("warnLegacyUndoButton"));
-      warningContainer.insertBefore(undoButton, warningContainer.lastElementChild);
-    }
-    if (this._addon.userDisabled) {
-      undoButton.setAttribute("oncommand", "gDetailView._addon.enable()");
-    } else {
-      undoButton.setAttribute("oncommand", "gDetailView._addon.cancelUninstall()");
-    }
-    undoButton.hidden = false;
-  } else if (restartButton) { // If one exists, so does the other.
+  if (restartButton) {
     restartButton.hidden = true;
+  }
+  if (undoButton) {
     undoButton.hidden = true;
   }
 };
+
+/**
+ * Update the UI when things change.
+ */
+function statusChangedObserver(subject, topic, data) {
+  let { id } = subject.wrappedJSObject;
+
+  if (gViewController.currentViewObj == gListView) {
+    let listItem = gListView.getListItemForID(id);
+    if (listItem) {
+      setTimeout(() => listItem._updateState());
+    }
+  } else if (gViewController.currentViewObj == gDetailView) {
+    setTimeout(() => gDetailView.updateState());
+  }
+}
+Services.obs.addObserver(statusChangedObserver, "legacy-addon-status-changed");
+window.addEventListener("unload", () => {
+  Services.obs.removeObserver(statusChangedObserver, "legacy-addon-status-changed");
+});
+
+/**
+ * The true status of legacy extensions, which AddonManager doesn't know
+ * about because it thinks all extensions are restartless.
+ *
+ * @return An object of three properties:
+ *         stringName: a string to display to the user, from extensionsOverlay.properties.
+ *         undoCommand: code to run, should the user want to return to the previous state.
+ *         version: the current version of the extension.
+ */
+function getTrueState(addon, addonRef) {
+  let state = ExtensionSupport.loadedLegacyExtensions.get(addon.id);
+  let returnObject = {};
+
+  if (addon.pendingOperations & AddonManager.PENDING_UNINSTALL &&
+      ExtensionSupport.loadedLegacyExtensions.has(addon.id)) {
+    returnObject.stringName = "warnLegacyUninstall";
+    returnObject.undoCommand = `${addonRef}.cancelUninstall()`;
+
+  } else if (state.pendingOperation == "install") {
+    returnObject.stringName = "warnLegacyInstall";
+    returnObject.undoCommand = `${addonRef}.uninstall()`;
+
+  } else if (addon.userDisabled) {
+    returnObject.stringName = "warnLegacyDisable";
+    returnObject.undoCommand = `${addonRef}.enable()`;
+
+  } else if (state.pendingOperation == "enable") {
+    returnObject.stringName = "warnLegacyEnable";
+    returnObject.undoCommand = `${addonRef}.disable()`;
+
+  } else if (state.pendingOperation == "upgrade") {
+    returnObject.stringName = "warnLegacyUpgrade";
+    returnObject.version = state.version;
+
+  } else if (state.pendingOperation == "downgrade") {
+    returnObject.stringName = "warnLegacyDowngrade";
+    returnObject.version = state.version;
+  }
+
+  return returnObject;
+}
--- a/mail/base/content/extensions.xml
+++ b/mail/base/content/extensions.xml
@@ -18,51 +18,65 @@
       ]]></constructor>
       <destructor><![CDATA[
         ExtensionParent.apiManager.off("startup", this._updateState);
         ExtensionParent.apiManager.off("shutdown", this._updateState);
       ]]></destructor>
       <method name="_updateState">
         <body><![CDATA[
           this.__proto__.__proto__._updateState.call(this);
-          let pending = this.mAddon.pendingOperations;
           let undoButton = this._warningContainer.querySelector("button.undo-btn");
 
           if (ExtensionSupport.loadedLegacyExtensions.has(this.mAddon.id)) {
             this.setAttribute("active", "true");
-            this.removeAttribute("pending");
           }
 
-          if (
-            ExtensionSupport.loadedLegacyExtensions.has(this.mAddon.id) &&
-            (this.mAddon.userDisabled || pending & AddonManager.PENDING_UNINSTALL)
-          ) {
-            this.setAttribute("notification", "warning");
-            let stringName = this.mAddon.userDisabled ? "warnLegacyDisable" : "warnLegacyUninstall";
-            this._warning.textContent = gStrings.mailExt.formatStringFromName(stringName, [this.mAddon.name, gStrings.brandShortName], 2);
+          if (ExtensionSupport.loadedLegacyExtensions.hasAnyState(this.mAddon.id, true)) {
+            let {
+              stringName,
+              undoCommand,
+            } = getTrueState(this.mAddon, "document.getBindingParent(this).mAddon");
+
+            if (stringName) {
+              this.setAttribute("notification", "warning");
+              this.removeAttribute("pending");
 
-            this._warningLink.hidden = true;
+              this._warningLink.hidden = true;
+              this._warning.textContent = gStrings.mailExt.formatStringFromName(
+                stringName, [this.mAddon.name, gStrings.brandShortName], 2
+              );
 
-            this._warningBtn.label = gStrings.mailExt.GetStringFromName("warnLegacyRestartButton");
-            this._warningBtn.setAttribute("oncommand", "BrowserUtils.restartApplication()");
-            this._warningBtn.hidden = false;
+              this._warningBtn.label = gStrings.mailExt.GetStringFromName(
+                "warnLegacyRestartButton"
+              );
+              this._warningBtn.setAttribute("oncommand", "BrowserUtils.restartApplication()");
+              this._warningBtn.hidden = false;
 
-            if (!undoButton) {
-              undoButton = document.createElement("button");
-              undoButton.className = "button-link undo-btn";
-              undoButton.setAttribute("label", gStrings.mailExt.GetStringFromName("warnLegacyUndoButton"));
-              // We shouldn't really attach non-anonymous content to anonymous content, but we can.
-              this._warningContainer.insertBefore(undoButton, this._warningContainer.lastElementChild);
+              if (undoCommand) {
+                if (!undoButton) {
+                  undoButton = document.createElement("button");
+                  undoButton.className = "button-link undo-btn";
+                  undoButton.setAttribute(
+                    "label", gStrings.mailExt.GetStringFromName("warnLegacyUndoButton")
+                  );
+                  // We shouldn't really attach non-anonymous content to anonymous content,
+                  // but we can.
+                  this._warningContainer.insertBefore(
+                    undoButton, this._warningContainer.lastElementChild
+                  );
+                }
+                undoButton.setAttribute("oncommand", undoCommand);
+                undoButton.hidden = false;
+              } else if (undoButton) {
+                undoButton.hidden = true;
+              }
+              return;
             }
-            if (this.mAddon.userDisabled) {
-              undoButton.setAttribute("oncommand", "document.getBindingParent(this).mAddon.enable()");
-            } else {
-              undoButton.setAttribute("oncommand", "document.getBindingParent(this).mAddon.cancelUninstall()");
-            }
-            undoButton.hidden = false;
-          } else if (undoButton) {
+          }
+
+          if (undoButton) {
             undoButton.hidden = true;
           }
         ]]></body>
       </method>
     </implementation>
   </binding>
 </bindings>
--- a/mail/components/extensions/parent/ext-legacy.js
+++ b/mail/components/extensions/parent/ext-legacy.js
@@ -15,22 +15,42 @@ this.legacy = class extends ExtensionAPI
     if (this.extension.manifest.legacy) {
       await this.register();
     }
   }
 
   async register() {
     this.extension.legacyLoaded = true;
 
+    let state = {
+      id: this.extension.id,
+      pendingOperation: null,
+      version: this.extension.version,
+    };
     if (ExtensionSupport.loadedLegacyExtensions.has(this.extension.id)) {
-      console.log(`Legacy WebExtension ${this.extension.id} has already been loaded in this run, refusing to do so again. Please restart`);
+      state = ExtensionSupport.loadedLegacyExtensions.get(this.extension.id);
+      let versionComparison = Services.vc.compare(this.extension.version, state.version);
+      if (versionComparison > 0) {
+        state.pendingOperation = "upgrade";
+        ExtensionSupport.loadedLegacyExtensions.notifyObservers(state);
+      } else if (versionComparison < 0) {
+        state.pendingOperation = "downgrade";
+        ExtensionSupport.loadedLegacyExtensions.notifyObservers(state);
+      }
+      console.log(`Legacy WebExtension ${this.extension.id} has already been loaded in this run, refusing to do so again. Please restart.`);
       return;
     }
-    ExtensionSupport.loadedLegacyExtensions.add(this.extension.id);
 
+    ExtensionSupport.loadedLegacyExtensions.set(this.extension.id, state);
+    if (["ADDON_INSTALL", "ADDON_ENABLE"].includes(this.extension.startupReason)) {
+      state.pendingOperation = this.extension.startupReason.substring(6).toLowerCase();
+      console.log(`Legacy WebExtension ${this.extension.id} loading for other reason than startup (${this.extension.startupReason}), refusing to load immediately.`);
+      ExtensionSupport.loadedLegacyExtensions.notifyObservers(state);
+      return;
+    }
 
     let extensionRoot;
     if (this.extension.rootURI instanceof Ci.nsIJARURI) {
       extensionRoot = this.extension.rootURI.JARFile.QueryInterface(Ci.nsIFileURL).file;
       console.log("Loading packed extension from", extensionRoot.path);
     } else {
       extensionRoot = this.extension.rootURI.QueryInterface(Ci.nsIFileURL).file;
       console.log("Loading unpacked extension from", extensionRoot.path);
--- a/mail/locales/en-US/chrome/messenger/extensionsOverlay.properties
+++ b/mail/locales/en-US/chrome/messenger/extensionsOverlay.properties
@@ -5,14 +5,18 @@
 cmdBackTooltip=Go back one page
 cmdForwardTooltip=Go forward one page
 
 # LOCALIZATION NOTE (legacyInfo):
 # #1 is the application name, #2 is the application version
 legacyInfo=Legacy extensions must be updated to be compatible with #1 #2.
 legacyLearnMore=Learn moreā€¦
 
-#LOCALIZATION NOTE (notification.disable) %1$S is the add-on name, %2$S is brand name
+# LOCALIZATION NOTE (warnLegacyUpgrade, warnLegacyDowngrade, warnLegacyEnable, warnLegacyDisable, warnLegacyInstall, warnLegacyUninstall)
+# %1$S is the add-on name, %2$S is brand name
+warnLegacyUpgrade=%1$S will be upgraded after you restart %2$S.
+warnLegacyDowngrade=%1$S will be downgraded after you restart %2$S.
+warnLegacyEnable=%1$S will be enabled after you restart %2$S.
 warnLegacyDisable=%1$S will be disabled after you restart %2$S.
-#LOCALIZATION NOTE (notification.uninstall) %1$S is the add-on name, %2$S is brand name
+warnLegacyInstall=%1$S will be installed after you restart %2$S.
 warnLegacyUninstall=%1$S will be uninstalled after you restart %2$S.
 warnLegacyRestartButton=Restart
 warnLegacyUndoButton=Undo