Bug 1535683 - Part 1: Manage extension updates in list/detail views r=aswan,flod
authorMark Striemer <mstriemer@mozilla.com>
Sat, 27 Apr 2019 00:30:33 +0000
changeset 530428 e20e1f19947b455a24d15ba82eee9fd262c6428d
parent 530427 c792b992164dad77f1df06c6bf8bbc9a7b4fb3e6
child 530429 edceab8a8ace306709f93fee5aec3fc70dae3a74
push id11265
push userffxbld-merge
push dateMon, 13 May 2019 10:53:39 +0000
treeherdermozilla-beta@77e0fe8dbdd3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, flod
bugs1535683
milestone68.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 1535683 - Part 1: Manage extension updates in list/detail views r=aswan,flod Differential Revision: https://phabricator.services.mozilla.com/D27137
toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
toolkit/mozapps/extensions/content/aboutaddons.css
toolkit/mozapps/extensions/content/aboutaddons.html
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/content/aboutaddonsCommon.js
toolkit/mozapps/extensions/content/extensions.js
toolkit/mozapps/extensions/content/extensions.xul
toolkit/mozapps/extensions/content/panel-item.css
toolkit/mozapps/extensions/jar.mn
toolkit/mozapps/extensions/test/browser/browser.ini
toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
toolkit/mozapps/extensions/test/browser/browser_html_updates.js
toolkit/themes/shared/icons/update-icon.svg
toolkit/themes/shared/jar.inc.mn
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -366,8 +366,15 @@ addon-name-disabled = { $name } (disable
 # The number of reviews that an add-on has received on AMO.
 # Variables:
 #   $numberOfReviews (number) - The number of reviews received
 addon-detail-reviews-link =
     { $numberOfReviews ->
         [one] { $numberOfReviews } review
        *[other] { $numberOfReviews } reviews
     }
+
+addon-detail-updates-label = Allow automatic updates
+addon-detail-updates-radio-default = Default
+addon-detail-updates-radio-on = On
+addon-detail-updates-radio-off = Off
+addon-detail-update-check-label = Check for Updates
+install-update-button = Update
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -180,36 +180,80 @@ addon-card:not([expanded]) .addon-descri
   height: 16px;
   margin-left: 8px;
 }
 
 .addon-detail-rating > a {
   margin-inline-start: 8px;
 }
 
-button[action="more-options"] {
+.more-options-button {
   min-width: auto;
   min-height: auto;
   width: 24px;
   height: 24px;
   margin: 0;
   -moz-context-properties: fill;
   fill: currentColor;
   background: url("chrome://global/skin/icons/more.svg") no-repeat center center;
+  position: relative;
+}
+
+.more-options-button-badged::after {
+  content: "";
+  display: block;
+  width: 5px;
+  height: 5px;
+  border-radius: 50%;
+  background: var(--blue-50);
+  position: absolute;
+  top: 0;
+}
+
+.more-options-button-badged:-moz-locale-dir(ltr)::after {
+  right: 0;
+}
+
+.more-options-button-badged:-moz-locale-dir(rtl)::after {
+  left: 0;
 }
 
 panel-item[action="remove"] {
   -moz-context-properties: fill;
   fill: currentColor;
   --icon: url("chrome://global/skin/icons/delete.svg");
 }
 
+panel-item[action="install-update"] {
+  --icon: url("chrome://global/skin/icons/update-icon.svg");
+}
+
 panel-item-separator {
   display: block;
   height: 1px;
   background: var(--in-content-box-border-color);
   padding: 0;
   margin: 6px 0;
 }
 
 panel-item-separator[hidden] {
   display: none;
 }
+
+.button-link {
+  min-height: auto;
+  background: none !important;
+  padding: 0;
+  margin: 0;
+  color: var(--in-content-link-color) !important;
+  cursor: pointer;
+  border: none;
+}
+
+.button-link:hover {
+  color: var(--in-content-link-color-hover) !important;
+  text-decoration: underline;
+}
+
+.button-link:active {
+  color: var(--in-content-link-color-active) !important;
+  text-decoration: none;
+}
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -19,20 +19,21 @@
         <img class="card-heading-image">
         <div class="addon-card-collapsed">
           <img class="card-heading-icon addon-icon">
           <div class="card-contents">
             <span class="addon-name"></span>
             <span class="addon-description"></span>
           </div>
           <div class="more-options-menu">
-            <button class="ghost-button" action="more-options"></button>
+            <button class="more-options-button ghost-button" action="more-options"></button>
             <panel-list>
               <panel-item action="toggle-disabled"></panel-item>
               <panel-item data-l10n-id="remove-addon-button" action="remove"></panel-item>
+              <panel-item data-l10n-id="install-update-button" action="install-update" badged></panel-item>
               <panel-item-separator></panel-item-separator>
               <panel-item data-l10n-id="expand-addon-button" action="expand"></panel-item>
             </panel-list>
           </div>
         </div>
       </div>
     </template>
 
@@ -42,16 +43,34 @@
         <label data-l10n-id="detail-contributions-description"></label>
         <button
           class="addon-detail-contribute-button"
           action="contribute"
           data-l10n-id="detail-contributions-button"
           data-l10n-attrs="accesskey">
         </button>
       </div>
+      <div class="addon-detail-row addon-detail-row-updates">
+        <label data-l10n-id="addon-detail-updates-label"></label>
+        <div>
+          <button class="button-link" data-l10n-id="addon-detail-update-check-label" action="update-check" hidden></button>
+          <label>
+            <input type="radio" name="autoupdate" value="1"/>
+            <span data-l10n-id="addon-detail-updates-radio-default"></span>
+          </label>
+          <label>
+            <input type="radio" name="autoupdate" value="2"/>
+            <span data-l10n-id="addon-detail-updates-radio-on"></span>
+          </label>
+          <label>
+            <input type="radio" name="autoupdate" value="0"/>
+            <span data-l10n-id="addon-detail-updates-radio-off"></span>
+          </label>
+        </div>
+      </div>
       <div class="addon-detail-row addon-detail-row-author">
         <label data-l10n-id="addon-detail-author-label"></label>
       </div>
       <div class="addon-detail-row addon-detail-row-version">
         <label data-l10n-id="addon-detail-version-label"></label>
       </div>
       <div class="addon-detail-row addon-detail-row-lastUpdated">
         <label data-l10n-id="addon-detail-last-updated-label"></label>
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -1,27 +1,28 @@
 /* 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/. */
 /* eslint max-len: ["error", 80] */
 /* exported initialize, hide, show */
-/* import-globals-from ../../../content/contentAreaUtils.js */
+/* import-globals-from aboutaddonsCommon.js */
 /* global windowRoot */
 
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
 });
 
 const PLUGIN_ICON_URL = "chrome://global/skin/plugins/pluginGeneric.svg";
 const PERMISSION_MASKS = {
   enable: AddonManager.PERM_CAN_ENABLE,
   disable: AddonManager.PERM_CAN_DISABLE,
   uninstall: AddonManager.PERM_CAN_UNINSTALL,
+  upgrade: AddonManager.PERM_CAN_UPGRADE,
 };
 
 function hasPermission(addon, permission) {
   return !!(addon.permissions & PERMISSION_MASKS[permission]);
 }
 
 /**
  * This function is set in initialize() by the parent about:addons window. It
@@ -240,60 +241,71 @@ class PanelItem extends HTMLElement {
     super();
     this.attachShadow({mode: "open"});
     this.shadowRoot.appendChild(importTemplate("panel-item"));
   }
 }
 customElements.define("panel-item", PanelItem);
 
 class AddonDetails extends HTMLElement {
-  constructor() {
-    super();
-    this.hasConnected = false;
-  }
-
   connectedCallback() {
-    if (!this.hasConnected) {
-      this.hasConnected = true;
+    if (this.children.length == 0) {
       this.render();
-      this.addEventListener("click", this);
     }
   }
 
   setAddon(addon) {
     this.addon = addon;
   }
 
-  handleEvent(e) {
-    if (e.type == "click" && e.target.getAttribute("action") == "contribute") {
-      openURL(this.addon.contributionURL);
+  update() {
+    let {addon} = this;
+
+    // Show the update check button if necessary. The button might not exist if
+    // the add-on doesn't support updates.
+    let updateButton = this.querySelector('[action="update-check"]');
+    if (updateButton) {
+      updateButton.hidden =
+        this.addon.updateInstall || AddonManager.shouldAutoUpdate(this.addon);
+    }
+
+    // Set the value for auto updates.
+    let inputs = this.querySelectorAll(".addon-detail-row-updates input");
+    for (let input of inputs) {
+      input.checked = input.value == addon.applyBackgroundUpdates;
     }
   }
 
   render() {
     let {addon} = this;
     if (!addon) {
       throw new Error("addon-details must be initialized by setAddon");
     }
 
+    this.textContent = "";
     this.appendChild(importTemplate("addon-details"));
 
     // Full description.
     let description = this.querySelector(".addon-detail-description");
     if (addon.getFullDescription) {
       description.appendChild(addon.getFullDescription(document));
     } else if (addon.fullDescription) {
       description.appendChild(nl2br(addon.fullDescription));
     }
 
     // Contribute.
     if (!addon.contributionURL) {
       this.querySelector(".addon-detail-contribute").remove();
     }
 
+    // Auto updates setting.
+    if (!hasPermission(addon, "upgrade")) {
+      this.querySelector(".addon-detail-row-updates").remove();
+    }
+
     // Author.
     let creatorRow = this.querySelector(".addon-detail-row-author");
     if (addon.creator) {
       let creator;
       if (addon.creator.url) {
         creator = document.createElement("a");
         creator.href = addon.creator.url;
         creator.target = "_blank";
@@ -351,68 +363,83 @@ class AddonDetails extends HTMLElement {
       let reviews = ratingRow.querySelector("a");
       reviews.href = addon.reviewURL;
       document.l10n.setAttributes(reviews, "addon-detail-reviews-link", {
         numberOfReviews: addon.reviewCount,
       });
     } else {
       ratingRow.remove();
     }
+
+    this.update();
   }
 }
 customElements.define("addon-details", AddonDetails);
 
 /**
  * A card component for managing an add-on. It should be initialized by setting
  * the add-on with `setAddon()` before being connected to the document.
  *
  *    let card = document.createElement("addon-card");
  *    card.setAddon(addon);
  *    document.body.appendChild(card);
  */
 class AddonCard extends HTMLElement {
-  constructor() {
-    super();
-    this.hasRendered = false;
-  }
-
   connectedCallback() {
     // If we've already rendered we can just update, otherwise render.
-    if (this.hasRendered) {
+    if (this.children.length > 0) {
       this.update();
     } else {
       this.render();
     }
-    this.registerListener();
+    this.registerListeners();
   }
 
   disconnectedCallback() {
-    this.removeListener();
+    this.removeListeners();
   }
 
   get expanded() {
     return this.hasAttribute("expanded");
   }
 
   set expanded(val) {
     if (val) {
       this.setAttribute("expanded", "true");
     } else {
       this.removeAttribute("expanded");
     }
   }
 
+  get updateInstall() {
+    return this._updateInstall;
+  }
+
+  set updateInstall(install) {
+    this._updateInstall = install;
+    if (this.children.length > 0) {
+      this.update();
+    }
+  }
+
   /**
    * Set the add-on for this card. The card will be populated based on the
    * add-on when it is connected to the DOM.
    *
    * @param {AddonWrapper} addon The add-on to use.
    */
   setAddon(addon) {
     this.addon = addon;
+    let install = addon.updateInstall;
+    if (install && install.state == AddonManager.STATE_AVAILABLE) {
+      this.updateInstall = install;
+    }
+    if (this.children.length > 0) {
+      this.render();
+    }
   }
 
   /**
    * Determine which screenshot fits best into the given img element. The img
    * should have a width and height set on it.
    *
    * @param {HTMLImageElement} img The <img> the screenshot is being set on.
    */
@@ -437,36 +464,161 @@ class AddonCard extends HTMLElement {
           }
           return aCloseness < bCloseness ? -1 : 1;
         });
       return screenshots[0];
     }
     return null;
   }
 
-  registerListener() {
-    AddonManager.addAddonListener(this);
+  async handleEvent(e) {
+    let {addon} = this;
+    let action = e.target.getAttribute("action");
+
+    if (e.type == "click") {
+      switch (action) {
+        case "toggle-disabled":
+          if (addon.userDisabled) {
+            await addon.enable();
+          } else {
+            await addon.disable();
+          }
+          if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+            // Refocus the open menu button so it's clear where the focus is.
+            this.querySelector('[action="more-options"]').focus();
+          }
+          break;
+        case "update-check":
+          let listener = {
+            onUpdateAvailable(addon, install) {
+              attachUpdateHandler(install);
+            },
+            onNoUpdateAvailable: () => {
+              this.sendEvent("no-update");
+            },
+          };
+          addon.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+          break;
+        case "install-update":
+          this.updateInstall.install().then(() => {
+            // The card will update with the new add-on when it gets installed.
+            this.sendEvent("update-installed");
+          }, () => {
+            // Update our state if the install is cancelled.
+            this.update();
+            this.sendEvent("update-cancelled");
+          });
+          // Clear the install since it will be removed from the global list of
+          // available updates (whether it succeeds or fails).
+          this.updateInstall = null;
+          break;
+        case "contribute":
+          windowRoot.ownerGlobal.openUILinkIn(addon.contributionURL, "tab", {
+            triggeringPrincipal:
+              Services.scriptSecurityManager.createNullPrincipal({}),
+          });
+          break;
+        case "remove":
+          {
+            this.panel.hide();
+            let response = windowRoot.ownerGlobal.promptRemoveExtension(addon);
+            if (response == 0) {
+              await addon.uninstall();
+              this.sendEvent("remove");
+            } else {
+              this.sendEvent("remove-cancelled");
+            }
+          }
+          break;
+        case "expand":
+          loadViewFn("detail", this.addon.id);
+          break;
+        case "more-options":
+          // Open panel on click from the keyboard.
+          if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+            this.panel.toggle(e);
+          }
+          break;
+      }
+    } else if (e.type == "change") {
+      let {name} = e.target;
+      if (name == "autoupdate") {
+        addon.applyBackgroundUpdates = e.target.value;
+      }
+    } else if (e.type == "mousedown") {
+      // Open panel on mousedown when the mouse is used.
+      if (action == "more-options") {
+        this.panel.toggle(e);
+      }
+    } else if (e.type == "dblclick") {
+      // Don't expand if expanded or a button is double clicked.
+      if (!this.expanded && e.target.tagName != "BUTTON") {
+        loadViewFn("detail", this.addon.id);
+      }
+    }
   }
 
-  removeListener() {
+  get panel() {
+    return this.card.querySelector("panel-list");
+  }
+
+  registerListeners() {
+    AddonManager.addAddonListener(this);
+    AddonManager.addInstallListener(this);
+    this.addEventListener("change", this);
+    this.addEventListener("click", this);
+    this.addEventListener("dblclick", this);
+    this.addEventListener("mousedown", this);
+  }
+
+  removeListeners() {
     AddonManager.removeAddonListener(this);
+    AddonManager.removeInstallListener(this);
+    this.removeEventListener("change", this);
+    this.removeEventListener("click", this);
+    this.removeEventListener("dblclick", this);
+    this.removeEventListener("mousedown", this);
+  }
+
+  onNewInstall(install) {
+    if (install.existingAddon && install.existingAddon.id == this.addon.id) {
+      this.updateInstall = install;
+      this.sendEvent("update-found");
+    }
+  }
+
+  onInstallEnded(install) {
+    if (install.addon.id == this.addon.id) {
+      this.setAddon(install.addon);
+    }
   }
 
   onDisabled(addon) {
     if (addon.id == this.addon.id) {
       this.update();
     }
   }
 
   onEnabled(addon) {
     if (addon.id == this.addon.id) {
       this.update();
     }
   }
 
+  onUpdateModeChanged() {
+    this.update();
+  }
+
+  onPropertyChanged(addon, changed) {
+    if (this.details && addon.id == this.addon.id &&
+        changed.includes("applyBackgroundUpdates")) {
+      this.details.update();
+    }
+  }
+
   /**
    * Update the card's contents based on the previously set add-on. This should
    * be called if there has been a change to the add-on.
    */
   update() {
     let {addon, card} = this;
 
     // Update the icon.
@@ -494,45 +646,57 @@ class AddonCard extends HTMLElement {
     if (addon.isActive) {
       name.textContent = addon.name;
       name.removeAttribute("data-l10n-id");
     } else {
       document.l10n.setAttributes(name, "addon-name-disabled", {
         name: addon.name,
       });
     }
+    name.title = `${addon.name} ${addon.version}`;
 
     // Update description.
     card.querySelector(".addon-description").textContent = addon.description;
 
     // Hide remove button if not allowed.
     let removeButton = card.querySelector('[action="remove"]');
     removeButton.hidden = !hasPermission(addon, "uninstall");
 
     // Set disable label and hide if not allowed.
     let disableButton = card.querySelector('[action="toggle-disabled"]');
     let disableAction = addon.userDisabled ? "enable" : "disable";
     document.l10n.setAttributes(
       disableButton, `${disableAction}-addon-button`);
     disableButton.hidden = !hasPermission(addon, disableAction);
 
+    // Set the update button and badge the menu if there's an update.
+    card.querySelector('[action="install-update"]').hidden =
+      !this.updateInstall;
+    card.querySelector(".more-options-button")
+      .classList.toggle("more-options-button-badged", !!this.updateInstall);
+
     // The separator isn't needed when expanded (nothing under it) or when the
     // remove and disable buttons are hidden (nothing above it).
     let separator = card.querySelector("panel-item-separator");
     separator.hidden = this.expanded ||
       removeButton.hidden && disableButton.hidden;
 
     // Hide the expand button if we're expanded.
     card.querySelector('[action="expand"]').hidden = this.expanded;
 
+    // Update the details if they're shown.
+    if (this.details) {
+      this.details.update();
+    }
+
     this.sendEvent("update");
   }
 
   expand() {
-    if (!this.hasRendered) {
+    if (this.children.length == 0) {
       this.expanded = true;
     } else {
       throw new Error("expand() is only supported before render()");
     }
   }
 
   render() {
     this.textContent = "";
@@ -543,79 +707,29 @@ class AddonCard extends HTMLElement {
     }
 
     this.card = importTemplate("card").firstElementChild;
     this.setAttribute("addon-id", addon.id);
 
     // Set the contents.
     this.update();
 
-    let panel = this.card.querySelector("panel-list");
-    let moreOptionsButton = this.card.querySelector('[action="more-options"]');
-
-    // Open panel on mousedown when the mouse is used.
-    moreOptionsButton.addEventListener("mousedown", (e) => {
-      panel.toggle(e);
-    });
-
-    // Open panel on click from the keyboard.
-    moreOptionsButton.addEventListener("click", (e) => {
-      if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
-        panel.toggle(e);
+    if (this.expanded) {
+      if (!this.details) {
+        this.details = document.createElement("addon-details");
       }
-    });
+      this.details.setAddon(this.addon);
+      this.details.render();
 
-    panel.addEventListener("click", async (e) => {
-      let action = e.target.getAttribute("action");
-      switch (action) {
-        case "toggle-disabled":
-          if (addon.userDisabled) {
-            await addon.enable();
-          } else {
-            await addon.disable();
-          }
-          if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
-            // Refocus the open menu button so it's clear where the focus is.
-            this.querySelector('[action="more-options"]').focus();
-          }
-          break;
-        case "remove":
-          {
-            panel.hide();
-            let response = windowRoot.ownerGlobal.promptRemoveExtension(addon);
-            if (response == 0) {
-              await addon.uninstall();
-              this.sendEvent("remove");
-            } else {
-              this.sendEvent("remove-cancelled");
-            }
-          }
-          break;
-        case "expand":
-          loadViewFn("detail", this.addon.id);
-          break;
-      }
-    });
-
-    if (this.expanded) {
-      let details = document.createElement("addon-details");
-      details.setAddon(this.addon);
-      this.card.appendChild(details);
-    } else {
-      // Expand on double click.
-      this.addEventListener("dblclick", (e) => {
-        // Don't expand if a button is double clicked.
-        if (e.target.tagName != "BUTTON") {
-          loadViewFn("detail", this.addon.id);
-        }
-      });
+      // If we're re-rendering we still need to append the details since the
+      // entire card was emptied at the beginning of the render.
+      this.card.appendChild(this.details);
     }
 
     this.appendChild(this.card);
-    this.hasRendered = true;
   }
 
   sendEvent(name, detail) {
     this.dispatchEvent(new CustomEvent(name, {detail}));
   }
 }
 customElements.define("addon-card", AddonCard);
 
@@ -637,18 +751,21 @@ class AddonList extends HTMLElement {
     super();
     this.sections = [];
   }
 
   async connectedCallback() {
     // Register the listener and get the add-ons, these operations should
     // happpen as close to each other as possible.
     this.registerListener();
-    // Render the initial view.
-    this.render(await this.getAddons());
+    // Don't render again if we were rendered prior to being inserted.
+    if (this.children.length == 0) {
+      // Render the initial view.
+      this.render();
+    }
   }
 
   disconnectedCallback() {
     // Remove content and stop listening until this is connected again.
     this.textContent = "";
     this.removeListener();
   }
 
@@ -823,32 +940,33 @@ class AddonList extends HTMLElement {
         card.render();
         section.appendChild(card);
       }
     }
 
     return section;
   }
 
-  async render(sectionedAddons) {
+  async render() {
     this.textContent = "";
 
+    let sectionedAddons = await this.getAddons();
+
     // Render the sections.
     let frag = document.createDocumentFragment();
 
     for (let i = 0; i < sectionedAddons.length; i++) {
       this.sections[i].node = this.renderSection(sectionedAddons[i], i);
       frag.appendChild(this.sections[i].node);
     }
 
     // Make sure fluent has set all the strings before we render. This will
     // avoid the height changing as strings go from 0 height to having text.
     await document.l10n.translateFragment(frag);
     this.appendChild(frag);
-    this.sendEvent("rendered");
   }
 
   registerListener() {
     AddonManager.addAddonListener(this);
   }
 
   removeListener() {
     AddonManager.removeAddonListener(this);
@@ -858,16 +976,19 @@ class AddonList extends HTMLElement {
     this.updateAddon(addon);
   }
 
   onDisabled(addon) {
     this.updateAddon(addon);
   }
 
   onInstalled(addon) {
+    if (this.querySelector(`addon-card[addon-id="${addon.id}"]`)) {
+      return;
+    }
     this.addAddon(addon);
   }
 
   onUninstalled(addon) {
     this.removeAddon(addon);
   }
 }
 customElements.define("addon-list", AddonList);
@@ -884,40 +1005,40 @@ class ListView {
     list.setSections([{
       headingId: "addons-enabled-heading",
       filterFn: addon => !addon.hidden && addon.isActive,
     }, {
       headingId: "addons-disabled-heading",
       filterFn: addon => !addon.hidden && !addon.isActive,
     }]);
 
-    await new Promise(resolve => {
-      list.addEventListener("rendered", resolve, {once: true});
-
-      this.root.textContent = "";
-      this.root.appendChild(list);
-    });
+    await list.render();
+    this.root.textContent = "";
+    this.root.appendChild(list);
   }
 }
 
 class DetailView {
   constructor({param, root}) {
     this.id = param;
     this.root = root;
   }
 
   async render() {
     let addon = await AddonManager.getAddonByID(this.id);
     let card = document.createElement("addon-card");
-    card.setAddon(addon);
-    card.expand();
 
     // Go back to the list view when the add-on is removed.
     card.addEventListener("remove", () => loadViewFn("list", addon.type));
 
+    card.setAddon(addon);
+    card.expand();
+    await card.render();
+
+    this.root.textContent = "";
     this.root.appendChild(card);
   }
 }
 
 // Generic view management.
 let root = null;
 
 /**
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js
@@ -0,0 +1,66 @@
+/* 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/. */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+/* exported attachUpdateHandler, getBrowserElement */
+
+var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyPreferenceGetter(
+  this, "WEBEXT_PERMISSION_PROMPTS",
+  "extensions.webextPermissionPrompts", false);
+
+ChromeUtils.defineModuleGetter(this, "Extension",
+                               "resource://gre/modules/Extension.jsm");
+
+function getBrowserElement() {
+  return window.docShell.chromeEventHandler;
+}
+
+function attachUpdateHandler(install) {
+  if (!WEBEXT_PERMISSION_PROMPTS) {
+    return;
+  }
+
+  install.promptHandler = (info) => {
+    let oldPerms = info.existingAddon.userPermissions;
+    if (!oldPerms) {
+      // Updating from a legacy add-on, let it proceed
+      return Promise.resolve();
+    }
+
+    let newPerms = info.addon.userPermissions;
+
+    let difference = Extension.comparePermissions(oldPerms, newPerms);
+
+    // If there are no new permissions, just proceed
+    if (difference.origins.length == 0 && difference.permissions.length == 0) {
+      return Promise.resolve();
+    }
+
+    return new Promise((resolve, reject) => {
+      let subject = {
+        wrappedJSObject: {
+          target: getBrowserElement(),
+          info: {
+            type: "update",
+            addon: info.addon,
+            icon: info.addon.icon,
+            // Reference to the related AddonInstall object (used in
+            // AMTelemetry to link the recorded event to the other events from
+            // the same install flow).
+            install,
+            permissions: difference,
+            resolve,
+            reject,
+          },
+        },
+      };
+      Services.obs.notifyObservers(subject, "webextension-permission-prompt");
+    });
+  };
+}
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -1,28 +1,27 @@
 /* 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";
 
 /* import-globals-from ../../../content/contentAreaUtils.js */
+/* import-globals-from aboutaddonsCommon.js */
 /* globals ProcessingInstruction */
 /* exported UPDATES_RELEASENOTES_TRANSFORMFILE, XMLURI_PARSE_ERROR, loadView, gBrowser */
 
 const {DeferredTask} = ChromeUtils.import("resource://gre/modules/DeferredTask.jsm");
 const {AddonManager} = ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
 const {AddonRepository} = ChromeUtils.import("resource://gre/modules/addons/AddonRepository.jsm");
 const {AddonSettings} = ChromeUtils.import("resource://gre/modules/addons/AddonSettings.jsm");
 
 ChromeUtils.defineModuleGetter(this, "AMTelemetry",
                                "resource://gre/modules/AddonManager.jsm");
 ChromeUtils.defineModuleGetter(this, "E10SUtils", "resource://gre/modules/E10SUtils.jsm");
-ChromeUtils.defineModuleGetter(this, "Extension",
-                               "resource://gre/modules/Extension.jsm");
 ChromeUtils.defineModuleGetter(this, "ExtensionParent",
                                "resource://gre/modules/ExtensionParent.jsm");
 ChromeUtils.defineModuleGetter(this, "ExtensionPermissions",
                                "resource://gre/modules/ExtensionPermissions.jsm");
 ChromeUtils.defineModuleGetter(this, "PluralForm",
                                "resource://gre/modules/PluralForm.jsm");
 ChromeUtils.defineModuleGetter(this, "Preferences",
                                "resource://gre/modules/Preferences.jsm");
@@ -385,20 +384,16 @@ function setSearchLabel(type) {
 
 /**
  * Obtain the main DOMWindow for the current context.
  */
 function getMainWindow() {
   return window.docShell.rootTreeItem.domWindow;
 }
 
-function getBrowserElement() {
-  return window.docShell.chromeEventHandler;
-}
-
 /**
  * A wrapper around the HTML5 session history service that allows the browser
  * back/forward controls to work within the manager
  */
 var HTML5History = {
   get index() {
     return window.docShell
                  .QueryInterface(Ci.nsIWebNavigation)
@@ -716,59 +711,16 @@ var gEventManager = {
     this.refreshGlobalWarning();
   },
 
   onUpdateModeChanged() {
     this.refreshAutoUpdateDefault();
   },
 };
 
-function attachUpdateHandler(install) {
-  if (!WEBEXT_PERMISSION_PROMPTS) {
-    return;
-  }
-
-  install.promptHandler = (info) => {
-    let oldPerms = info.existingAddon.userPermissions;
-    if (!oldPerms) {
-      // Updating from a legacy add-on, let it proceed
-      return Promise.resolve();
-    }
-
-    let newPerms = info.addon.userPermissions;
-
-    let difference = Extension.comparePermissions(oldPerms, newPerms);
-
-    // If there are no new permissions, just proceed
-    if (difference.origins.length == 0 && difference.permissions.length == 0) {
-      return Promise.resolve();
-    }
-
-    return new Promise((resolve, reject) => {
-      let subject = {
-        wrappedJSObject: {
-          target: getBrowserElement(),
-          info: {
-            type: "update",
-            addon: info.addon,
-            icon: info.addon.icon,
-            // Reference to the related AddonInstall object (used in AMTelemetry to
-            // link the recorded event to the other events from the same install flow).
-            install,
-            permissions: difference,
-            resolve,
-            reject,
-          },
-        },
-      };
-      Services.obs.notifyObservers(subject, "webextension-permission-prompt");
-    });
-  };
-}
-
 var gViewController = {
   viewPort: null,
   currentViewId: "",
   currentViewObj: null,
   currentViewRequest: 0,
   viewObjects: {},
   viewChangeCallback: null,
   initialViewSelected: false,
--- a/toolkit/mozapps/extensions/content/extensions.xul
+++ b/toolkit/mozapps/extensions/content/extensions.xul
@@ -20,18 +20,19 @@
       role="application" windowtype="Addons:Manager">
 
   <xhtml:link rel="shortcut icon"
               href="chrome://mozapps/skin/extensions/extensionGeneric-16.svg"/>
   <linkset>
     <xhtml:link rel="localization" href="branding/brand.ftl"/>
     <xhtml:link rel="localization" href="toolkit/about/aboutAddons.ftl"/>
   </linkset>
-
+  
   <script src="chrome://global/content/contentAreaUtils.js"/>
+  <script src="chrome://mozapps/content/extensions/aboutaddonsCommon.js"/>
   <script src="chrome://mozapps/content/extensions/extensions.js"/>
 
   <popupset>
     <!-- menu for an addon item -->
     <menupopup id="addonitem-popup">
       <menuitem id="menuitem_showDetails" command="cmd_showItemDetails"
                 default="true" data-l10n-id="cmd-show-details"/>
       <menuitem id="menuitem_enableItem" command="cmd_enableItem"
--- a/toolkit/mozapps/extensions/content/panel-item.css
+++ b/toolkit/mozapps/extensions/content/panel-item.css
@@ -1,24 +1,37 @@
 button {
   color: inherit;
   background-color: transparent;
   background-image: var(--icon);
   background-position: 16px center;
   background-repeat: no-repeat;
   background-size: 16px;
   border: none;
+  position: relative;
   display: block;
   font-size: inherit;
   padding: 4px 40px;
   padding-inline-end: 12px;
   text-align: start;
   width: 100%;
 }
 
+:host([badged]) button::after {
+  content: "";
+  display: block;
+  width: 5px;
+  height: 5px;
+  border-radius: 50%;
+  background: var(--blue-50);
+  position: absolute;
+  top: 4px;
+  left: 28px;
+}
+
 button:focus,
 button:hover {
   background-color: var(--in-content-button-background);
 }
 
 button:hover:active {
   background-color: var(--in-content-button-background-hover);
 }
--- a/toolkit/mozapps/extensions/jar.mn
+++ b/toolkit/mozapps/extensions/jar.mn
@@ -15,12 +15,13 @@ toolkit.jar:
   content/mozapps/extensions/updateinfo.xsl                     (content/updateinfo.xsl)
   content/mozapps/extensions/blocklist.xul                      (content/blocklist.xul)
   content/mozapps/extensions/blocklist.js                       (content/blocklist.js)
   content/mozapps/extensions/pluginPrefs.xul                    (content/pluginPrefs.xul)
   content/mozapps/extensions/pluginPrefs.js                     (content/pluginPrefs.js)
   content/mozapps/extensions/OpenH264-license.txt               (content/OpenH264-license.txt)
   content/mozapps/extensions/aboutaddons.html                   (content/aboutaddons.html)
   content/mozapps/extensions/aboutaddons.js                     (content/aboutaddons.js)
+  content/mozapps/extensions/aboutaddonsCommon.js               (content/aboutaddonsCommon.js)
   content/mozapps/extensions/aboutaddons.css                    (content/aboutaddons.css)
   content/mozapps/extensions/panel-list.css                     (content/panel-list.css)
   content/mozapps/extensions/panel-item.css                     (content/panel-item.css)
 #endif
--- a/toolkit/mozapps/extensions/test/browser/browser.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -72,16 +72,17 @@ skip-if = os == "linux" && !debug # Bug 
 [browser_extension_sideloading_permission.js]
 [browser_file_xpi_no_process_switch.js]
 skip-if = true # Bug 1449071 - Frequent failures
 [browser_globalwarnings.js]
 [browser_gmpProvider.js]
 skip-if = os == 'linux' && !debug # Bug 1398766
 [browser_html_detail_view.js]
 [browser_html_list_view.js]
+[browser_html_updates.js]
 [browser_inlinesettings_browser.js]
 skip-if = os == 'mac' || os == 'linux' # Bug 1483347
 [browser_installssl.js]
 skip-if = verify
 [browser_interaction_telemetry.js]
 [browser_langpack_signing.js]
 [browser_legacy.js]
 [browser_legacy_pre57.js]
--- a/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
@@ -42,16 +42,17 @@ add_task(async function enableHtmlViews(
     fullDescription: "Longer description\nWith brs!",
     type: "extension",
     contributionURL: "http://foo.com",
     averageRating: 4.3,
     reviewCount: 5,
     reviewURL: "http://example.com/reviews",
     homepageURL: "http://example.com/addon1",
     updateDate: new Date("2019-03-07T01:00:00"),
+    applyBackgroundUpdates: AddonManager.AUTOUPDATE_ENABLE,
   }, {
     id: "addon2@mochi.test",
     name: "Test add-on 2",
     creator: {name: "I made it"},
     description: "Short description",
     type: "extension",
   }, {
     id: "theme1@mochi.test",
@@ -232,29 +233,54 @@ add_task(async function testFullDetails(
   is(desc.innerHTML, "Longer description<br>With brs!",
      "The full description replaces newlines with <br>");
 
   let contrib = details.querySelector(".addon-detail-contribute");
   ok(contrib, "The contribution section is visible");
 
   let rows = Array.from(details.querySelectorAll(".addon-detail-row"));
 
-  // The first row is the author.
+  // Auto updates.
   let row = rows.shift();
+  checkLabel(row, "updates");
+  let expectedOptions = [
+    {value: "1", label: "addon-detail-updates-radio-default", checked: false},
+    {value: "2", label: "addon-detail-updates-radio-on", checked: true},
+    {value: "0", label: "addon-detail-updates-radio-off", checked: false},
+  ];
+  let options = row.lastElementChild.querySelectorAll("label");
+  is(options.length, 3, "There are 3 options");
+  for (let i = 0; i < 3; i++) {
+    let option = options[i];
+    is(option.children.length, 2, "There are 2 children for the option");
+    let input = option.firstElementChild;
+    is(input.tagName, "INPUT", "The input is first");
+    let text = option.lastElementChild;
+    is(text.tagName, "SPAN", "The label text is second");
+    let expected = expectedOptions[i];
+    is(input.value, expected.value, "The value is right");
+    is(input.checked, expected.checked, "The checked property is correct");
+    Assert.deepEqual(
+      doc.l10n.getAttributes(text), {id: expected.label},
+      "The label has the right text");
+  }
+
+  // Author.
+  row = rows.shift();
   checkLabel(row, "author");
   let link = row.querySelector("a");
   checkLink(link, "http://example.com/me", "The creator");
 
-  // The version is next.
+  // Version.
   row = rows.shift();
   checkLabel(row, "version");
   let text = row.lastChild;
   is(text.textContent, "3.1", "The version is set");
 
-  // Last updated is next.
+  // Last updated.
   row = rows.shift();
   checkLabel(row, "last-updated");
   text = row.lastChild;
   is(text.textContent, "March 7, 2019", "The last updated date is set");
 
   // Homepage.
   row = rows.shift();
   checkLabel(row, "homepage");
@@ -296,23 +322,29 @@ add_task(async function testMinimalExten
 
   let desc = details.querySelector(".addon-detail-description");
   is(desc.textContent, "", "There is no full description");
 
   let contrib = details.querySelector(".addon-detail-contribute");
   ok(!contrib, "There is no contribution element");
 
   let rows = Array.from(details.querySelectorAll(".addon-detail-row"));
+
+  // Automatic updates.
   let row = rows.shift();
+  checkLabel(row, "updates");
+
+  // Author.
+  row = rows.shift();
   checkLabel(row, "author");
   let text = row.lastChild;
   is(text.textContent, "I made it", "The author is set");
   ok(text instanceof Text, "The author is a text node");
 
-  is(rows.length, 0, "There was only 1 row");
+  is(rows.length, 0, "There are no more rows");
 
   await closeView(win);
 });
 
 add_task(async function testDefaultTheme() {
   let win = await loadInitialView("theme");
   let doc = win.document;
 
@@ -386,16 +418,20 @@ add_task(async function testStaticTheme(
   ok(preview, "There is a preview");
   is(preview.src, "http://example.com/preview.png", "The preview URL is set");
   is(preview.width, "664", "The width is set");
   is(preview.height, "89", "The height is set");
   is(preview.hidden, false, "The preview is visible");
 
   let rows = Array.from(card.querySelectorAll(".addon-detail-row"));
 
+  // Automatic updates.
+  let row = rows.shift();
+  checkLabel(row, "updates");
+
   // Author.
   let author = rows.shift();
   checkLabel(author, "author");
   let text = author.lastChild;
   is(text.textContent, "Artist", "The author is set");
 
   is(rows.length, 0, "There was only 1 row");
 
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
@@ -0,0 +1,271 @@
+/* eslint max-len: ["error", 80] */
+
+const {AddonTestUtils} =
+  ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+
+AddonTestUtils.initMochitest(this);
+
+add_task(async function enableHtmlViews() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.htmlaboutaddons.enabled", true]],
+  });
+});
+
+function loadDetailView(win, id) {
+  let doc = win.document;
+  let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+  let loaded = waitForViewLoad(win);
+  EventUtils.synthesizeMouseAtCenter(card, {clickCount: 1}, win);
+  EventUtils.synthesizeMouseAtCenter(card, {clickCount: 2}, win);
+  return loaded;
+}
+
+add_task(async function testChangeAutoUpdates() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      name: "Test",
+      applications: {gecko: {id: "test@mochi.test"}},
+    },
+    // Use permanent so the add-on can be updated.
+    useAddonManager: "permanent",
+  });
+
+  await extension.startup();
+  let addon = await AddonManager.getAddonByID("test@mochi.test");
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  let getInputs = updateRow => ({
+    default: updatesRow.querySelector('input[value="1"]'),
+    on: updatesRow.querySelector('input[value="2"]'),
+    off: updatesRow.querySelector('input[value="0"]'),
+    checkForUpdate: updatesRow.querySelector('[action="update-check"]'),
+  });
+
+  await loadDetailView(win, "test@mochi.test");
+
+  let card = doc.querySelector('addon-card[addon-id="test@mochi.test"]');
+  ok(card.querySelector("addon-details"), "The card now has details");
+
+  let updatesRow = card.querySelector(".addon-detail-row-updates");
+  let inputs = getInputs(updatesRow);
+  is(addon.applyBackgroundUpdates, 1, "Default is set");
+  ok(inputs.default.checked, "The default option is selected");
+  ok(inputs.checkForUpdate.hidden, "Update check is hidden");
+
+  inputs.on.click();
+  is(addon.applyBackgroundUpdates, 2, "Updates are now enabled");
+  ok(inputs.on.checked, "The on option is selected");
+  ok(inputs.checkForUpdate.hidden, "Update check is hidden");
+
+  inputs.off.click();
+  is(addon.applyBackgroundUpdates, 0, "Updates are now disabled");
+  ok(inputs.off.checked, "The off option is selected");
+  ok(!inputs.checkForUpdate.hidden, "Update check is visible");
+
+  // Go back to the list view and check the details view again.
+  let loaded = waitForViewLoad(win);
+  win.managerWindow.document.getElementById("go-back").click();
+  await loaded;
+
+  // Load the detail view again.
+  await loadDetailView(win, "test@mochi.test");
+
+  card = doc.querySelector('addon-card[addon-id="test@mochi.test"]');
+  updatesRow = card.querySelector(".addon-detail-row-updates");
+  inputs = getInputs(updatesRow);
+
+  ok(inputs.off.checked, "Off is still selected");
+
+  // Disable global updates.
+  let updated = BrowserTestUtils.waitForEvent(card, "update");
+  AddonManager.autoUpdateDefault = false;
+  await updated;
+
+  // Updates are still the same.
+  is(addon.applyBackgroundUpdates, 0, "Updates are now disabled");
+  ok(inputs.off.checked, "The off option is selected");
+  ok(!inputs.checkForUpdate.hidden, "Update check is visible");
+
+  // Check default.
+  inputs.default.click();
+  is(addon.applyBackgroundUpdates, 1, "Default is set");
+  ok(inputs.default.checked, "The default option is selected");
+  ok(!inputs.checkForUpdate.hidden, "Update check is visible");
+
+  inputs.on.click();
+  is(addon.applyBackgroundUpdates, 2, "Updates are now enabled");
+  ok(inputs.on.checked, "The on option is selected");
+  ok(inputs.checkForUpdate.hidden, "Update check is hidden");
+
+  // Enable updates again.
+  updated = BrowserTestUtils.waitForEvent(card, "update");
+  AddonManager.autoUpdateDefault = true;
+  await updated;
+
+  await closeView(win);
+  await extension.unload();
+});
+
+async function setupExtensionWithUpdate() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.checkUpdateSecurity", false]],
+  });
+
+  let server = AddonTestUtils.createHttpServer();
+  let serverHost = `http://localhost:${server.identity.primaryPort}`;
+
+  let baseManifest = {
+    name: "Updates",
+    applications: {
+      gecko: {
+        id: "update@mochi.test",
+        update_url: `${serverHost}/ext-updates.json`,
+      },
+    },
+  };
+
+  let updateXpi = AddonTestUtils.createTempWebExtensionFile({
+    manifest: {
+      ...baseManifest,
+      version: "2",
+    },
+  });
+  server.registerFile("/update-2.xpi", updateXpi);
+  AddonTestUtils.registerJSON(server, "/ext-updates.json", {
+    addons: {
+      "update@mochi.test": {
+        updates: [
+          {version: "2", update_link: `${serverHost}/update-2.xpi`},
+        ],
+      },
+    },
+  });
+
+  return ExtensionTestUtils.loadExtension({
+    manifest: {
+      ...baseManifest,
+      version: "1",
+    },
+    // Use permanent so the add-on can be updated.
+    useAddonManager: "permanent",
+  });
+}
+
+function disableAutoUpdates(card) {
+  // Check button should be hidden.
+  let updateCheckButton = card.querySelector('button[action="update-check"]');
+  ok(updateCheckButton.hidden, "The button is initially hidden");
+
+  // Disable updates, update check button is now visible.
+  card.querySelector('input[name="autoupdate"][value="0"]').click();
+  ok(!updateCheckButton.hidden, "The button is now visible");
+
+  // There shouldn't be an update shown to the user.
+  assertUpdateState(card, false);
+}
+
+function checkForUpdate(card, expected) {
+  let updateCheckButton = card.querySelector('button[action="update-check"]');
+  let updateFound = BrowserTestUtils.waitForEvent(card, expected);
+  updateCheckButton.click();
+  return updateFound;
+}
+
+function installUpdate(card, expected) {
+  // Install the update.
+  let updateInstalled = BrowserTestUtils.waitForEvent(card, expected);
+  let updated = BrowserTestUtils.waitForEvent(card, "update");
+  card.querySelector('panel-item[action="install-update"]').click();
+  return Promise.all([updateInstalled, updated]);
+}
+
+function assertUpdateState(card, shown) {
+  let menuButton = card.querySelector(".more-options-button");
+  ok(menuButton.classList.contains("more-options-button-badged") == shown,
+     "The menu button is badged");
+  let installButton = card.querySelector('panel-item[action="install-update"]');
+  ok(installButton.hidden != shown,
+     `The install button is ${shown ? "hidden" : "shown"}`);
+  let updateCheckButton = card.querySelector('button[action="update-check"]');
+  ok(updateCheckButton.hidden == shown,
+     `The update check button is ${shown ? "hidden" : "shown"}`);
+}
+
+add_task(async function testUpdateAvailable() {
+  let extension = await setupExtensionWithUpdate();
+  await extension.startup();
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  await loadDetailView(win, "update@mochi.test");
+
+  let card = doc.querySelector("addon-card");
+
+  // Disable updates and then check.
+  disableAutoUpdates(card);
+  await checkForUpdate(card, "update-found");
+
+  // There should now be an update.
+  assertUpdateState(card, true);
+
+  // The version was 1.
+  let versionRow = card.querySelector(".addon-detail-row-version");
+  is(versionRow.lastChild.textContent, "1", "The version started as 1");
+
+  await installUpdate(card, "update-installed");
+
+  // The version is now 2.
+  versionRow = card.querySelector(".addon-detail-row-version");
+  is(versionRow.lastChild.textContent, "2", "The version has updated");
+
+  // No update is shown again.
+  assertUpdateState(card, false);
+
+  // Check for updates again, there shouldn't be an update.
+  await checkForUpdate(card, "no-update");
+
+  await closeView(win);
+  await extension.unload();
+});
+
+add_task(async function testUpdateCancelled() {
+  let extension = await setupExtensionWithUpdate();
+  await extension.startup();
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  await loadDetailView(win, "update@mochi.test");
+  let card = doc.querySelector("addon-card");
+
+  // Disable updates and then check.
+  disableAutoUpdates(card);
+  await checkForUpdate(card, "update-found");
+
+  // There should now be an update.
+  assertUpdateState(card, true);
+
+  // The add-on starts as version 1.
+  let versionRow = card.querySelector(".addon-detail-row-version");
+  is(versionRow.lastChild.textContent, "1", "The version started as 1");
+
+  // Force the install to be cancelled.
+  let install = card.updateInstall;
+  ok(install, "There was an install found");
+  install.promptHandler = Promise.reject().catch(() => {});
+
+  await installUpdate(card, "update-cancelled");
+
+  // The add-on is still version 1.
+  versionRow = card.querySelector(".addon-detail-row-version");
+  is(versionRow.lastChild.textContent, "1", "The version hasn't changed");
+
+  // The update has been removed.
+  assertUpdateState(card, false);
+
+  await closeView(win);
+  await extension.unload();
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/icons/update-icon.svg
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
+    <title>update-icon</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M8,0 C3.581722,-2.705415e-16 5.41083001e-16,3.581722 0,8 C-5.41083001e-16,12.418278 3.581722,16 8,16 C12.418278,16 16,12.418278 16,8 C16,5.87826808 15.1571453,3.84343678 13.6568542,2.34314575 C12.1565632,0.842854723 10.1217319,1.2991861e-16 8,0 Z M11.707,6.707 C11.3165001,7.09738194 10.6834999,7.09738194 10.293,6.707 L9,5.414 L9,13 C9,13.5522847 8.55228475,14 8,14 C7.44771525,14 7,13.5522847 7,13 L7,5.414 L5.707,6.707 C5.31462111,7.08597221 4.69091522,7.08055237 4.30518142,6.69481858 C3.91944763,6.30908478 3.91402779,5.68537889 4.293,5.293 L7.293,2.293 C7.68349985,1.90261806 8.31650015,1.90261806 8.707,2.293 L11.707,5.293 C12.0973819,5.68349985 12.0973819,6.31650015 11.707,6.707 Z" id="path-1"></path>
+    </defs>
+    <g id="update-icon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Icons-/-Update">
+            <mask id="mask-2" fill="white">
+                <use xlink:href="#path-1"></use>
+            </mask>
+            <g id="Shape" fill-rule="nonzero"></g>
+            <g id="Color-/-Photon-/-Primary---Grey-90-80%" mask="url(#mask-2)" fill="#0C0C0D" fill-opacity="0.8" fill-rule="evenodd">
+                <rect id="Rectangle" x="0" y="0" width="16" height="16"></rect>
+            </g>
+        </g>
+    </g>
+</svg>
--- a/toolkit/themes/shared/jar.inc.mn
+++ b/toolkit/themes/shared/jar.inc.mn
@@ -49,16 +49,17 @@ toolkit.jar:
   skin/classic/global/pictureinpicture/play.svg            (../../shared/pictureinpicture/play.svg)
   skin/classic/global/icons/resizer.svg                    (../../shared/icons/resizer.svg)
   skin/classic/global/icons/shortcut.svg                   (../../shared/icons/shortcut.svg)
   skin/classic/global/icons/spinner-arrow-down.svg         (../../shared/icons/spinner-arrow-down.svg)
   skin/classic/global/icons/spinner-arrow-up.svg           (../../shared/icons/spinner-arrow-up.svg)
   skin/classic/global/icons/twisty-collapsed.svg           (../../shared/icons/twisty-collapsed.svg)
   skin/classic/global/icons/twisty-collapsed-rtl.svg       (../../shared/icons/twisty-collapsed-rtl.svg)
   skin/classic/global/icons/twisty-expanded.svg            (../../shared/icons/twisty-expanded.svg)
+  skin/classic/global/icons/update-icon.svg                (../../shared/icons/update-icon.svg)
   skin/classic/global/icons/arrow-dropdown-12.svg          (../../shared/icons/arrow-dropdown-12.svg)
   skin/classic/global/icons/arrow-dropdown-16.svg          (../../shared/icons/arrow-dropdown-16.svg)
   skin/classic/global/icons/arrow-up-12.svg                (../../shared/icons/arrow-up-12.svg)
   skin/classic/global/pictureinpicture/unpip.svg           (../../shared/pictureinpicture/unpip.svg)
   skin/classic/global/icons/warning.svg                    (../../shared/icons/warning.svg)
   skin/classic/global/illustrations/about-rights.svg       (../../shared/illustrations/about-rights.svg)
   skin/classic/global/icons/blocked.svg                    (../../shared/incontent-icons/blocked.svg)
   skin/classic/global/illustrations/about-license.svg      (../../shared/illustrations/about-license.svg)