Bug 353804 - Add update swap handling to UpdateListener and AppUpdater r=mhowell

UpdateListener and AppUpdater need to know when a downloading update is transitioning to being a ready update so they don't prompt the user to restart Firefox while updates are still staging (which would result in them not being installed).

Differential Revision: https://phabricator.services.mozilla.com/D95821
This commit is contained in:
Kirk Steuber 2020-12-18 20:57:22 +00:00
parent 78566e6b04
commit 093323defd
3 changed files with 133 additions and 34 deletions

View File

@ -871,6 +871,7 @@ const listeners = {
"update-downloaded": ["UpdateListener"],
"update-available": ["UpdateListener"],
"update-error": ["UpdateListener"],
"update-swap": ["UpdateListener"],
"gmp-plugin-crash": ["PluginManager"],
"plugin-crashed": ["PluginManager"],
},

View File

@ -46,9 +46,12 @@ class AppUpdater {
"nsIUpdateManager"
);
this.QueryInterface = ChromeUtils.generateQI([
"nsIObserver",
"nsIProgressEventSink",
"nsIRequestObserver",
"nsISupportsWeakReference",
]);
Services.obs.addObserver(this, "update-swap", /* ownsWeak */ true);
}
/**
@ -389,32 +392,41 @@ class AppUpdater {
_awaitStagingComplete() {
let observer = (aSubject, aTopic, aData) => {
// Update the UI when the background updater is finished
let status = aData;
if (
status == "applied" ||
status == "applied-service" ||
status == "pending" ||
status == "pending-service" ||
status == "pending-elevate"
) {
// If the update is successfully applied, or if the updater has
// fallen back to non-staged updates, show the "Restart to Update"
// button.
this._setStatus(AppUpdater.STATUS.READY_FOR_RESTART);
} else if (status == "failed") {
// Background update has failed, let's show the UI responsible for
// prompting the user to update manually.
this._setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
} else if (status == "downloading") {
// We've fallen back to downloading the complete update because the
// partial update failed to get staged in the background.
// Therefore we need to keep our observer.
this._setupDownloadListener();
return;
switch (aTopic) {
case "update-staged":
let status = aData;
if (
status == "applied" ||
status == "applied-service" ||
status == "pending" ||
status == "pending-service" ||
status == "pending-elevate"
) {
// If the update is successfully applied, or if the updater has
// fallen back to non-staged updates, show the "Restart to Update"
// button.
this._setStatus(AppUpdater.STATUS.READY_FOR_RESTART);
} else if (status == "failed") {
// Background update has failed, let's show the UI responsible for
// prompting the user to update manually.
this._setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
} else if (status == "downloading") {
// We've fallen back to downloading the complete update because the
// partial update failed to get staged in the background.
// Therefore we need to keep our observer.
this._setupDownloadListener();
return;
}
break;
case "update-error":
this._setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
break;
}
Services.obs.removeObserver(observer, "update-staged");
Services.obs.removeObserver(observer, "update-error");
};
Services.obs.addObserver(observer, "update-staged");
Services.obs.addObserver(observer, "update-error");
}
/**
@ -489,6 +501,43 @@ class AppUpdater {
}
return status;
}
observe(subject, topic, status) {
switch (topic) {
case "update-swap":
this._handleUpdateSwap();
break;
}
}
_handleUpdateSwap() {
// This function exists to deal with the fact that we support handling 2
// updates at once: a ready update and a downloading update. But AppUpdater
// only ever really considers a single update at a time.
// We see an update swap just when the downloading update has finished
// downloading and is being swapped into UpdateManager.readyUpdate. At this
// point, we are in one of two states. Either:
// a) The update that is being swapped in is the update that this
// AppUpdater has already been tracking, or
// b) We've been tracking the ready update. Now that the downloading
// update is about to be swapped into the place of the ready update, we
// need to switch over to tracking the new update.
if (
this._status == AppUpdater.STATUS.DOWNLOADING ||
this._status == AppUpdater.STATUS.STAGING
) {
// We are already tracking the correct update.
return;
}
if (this.updateStagingEnabled) {
this._setStatus(AppUpdater.STATUS.STAGING);
this._awaitStagingComplete();
} else {
this._setStatus(AppUpdater.STATUS.DOWNLOADING);
this._awaitDownloadComplete();
}
}
}
AppUpdater.STATUS = {

View File

@ -33,6 +33,13 @@ const PREF_APP_UPDATE_UNSUPPORTED_URL = "app.update.unsupported.url";
var UpdateListener = {
timeouts: [],
restartDoorhangerShown: false,
// Once a restart badge/doorhanger is scheduled, these store the time that
// they were scheduled at (as milliseconds elapsed since the UNIX epoch). This
// allows us to resume the badge/doorhanger timers rather than restarting
// them from the beginning when a new update comes along.
updateFirstReadyTime: null,
get badgeWaitTime() {
return Services.prefs.getIntPref("app.update.badgeWaitTime", 4 * 24 * 3600); // 4 days
},
@ -53,6 +60,12 @@ var UpdateListener = {
},
reset() {
this.clearPendingAndActiveNotifications();
this.restartDoorhangerShown = false;
this.updateFirstReadyTime = null;
},
clearPendingAndActiveNotifications() {
AppMenuNotifications.removeNotification(/^update-/);
this.clearCallbacks();
},
@ -166,6 +179,9 @@ var UpdateListener = {
let notification = AppUpdateService.isOtherInstanceHandlingUpdates
? "other-instance"
: "restart";
if (!dismissed) {
this.restartDoorhangerShown = true;
}
this.showUpdateNotification(notification, true, dismissed, () =>
this.requestRestart()
);
@ -260,8 +276,22 @@ var UpdateListener = {
case "success":
this.clearCallbacks();
let badgeWaitTimeMs = this.badgeWaitTime * 1000;
let doorhangerWaitTimeMs = update.promptWaitTime * 1000;
let initialBadgeWaitTimeMs = this.badgeWaitTime * 1000;
let initialDoorhangerWaitTimeMs = update.promptWaitTime * 1000;
let now = Date.now();
if (!this.updateFirstReadyTime) {
this.updateFirstReadyTime = now;
}
let badgeWaitTimeMs = Math.max(
0,
this.updateFirstReadyTime + initialBadgeWaitTimeMs - now
);
let doorhangerWaitTimeMs = Math.max(
0,
this.updateFirstReadyTime + initialDoorhangerWaitTimeMs - now
);
if (badgeWaitTimeMs < doorhangerWaitTimeMs) {
this.addTimeout(badgeWaitTimeMs, () => {
@ -270,17 +300,19 @@ var UpdateListener = {
this.showRestartNotification(update, true);
}
// doorhangerWaitTimeMs is relative to when we initially received
// the event. Since we've already waited badgeWaitTimeMs, subtract
// that from doorhangerWaitTimeMs.
let remainingTime = doorhangerWaitTimeMs - badgeWaitTimeMs;
this.addTimeout(remainingTime, () => {
this.showRestartNotification(update, false);
});
if (!this.restartDoorhangerShown) {
// doorhangerWaitTimeMs is relative to when we initially received
// the event. Since we've already waited badgeWaitTimeMs, subtract
// that from doorhangerWaitTimeMs.
let remainingTime = doorhangerWaitTimeMs - badgeWaitTimeMs;
this.addTimeout(remainingTime, () => {
this.showRestartNotification(update, false);
});
}
});
} else {
this.addTimeout(doorhangerWaitTimeMs, () => {
this.showRestartNotification(update, false);
this.showRestartNotification(update, this.restartDoorhangerShown);
});
}
break;
@ -292,7 +324,6 @@ var UpdateListener = {
case "show-prompt":
// If an update is available and the app.update.auto preference is
// false, then show an update available doorhanger.
this.clearCallbacks();
this.showUpdateAvailableNotification(update, false);
break;
case "cant-apply":
@ -312,11 +343,26 @@ var UpdateListener = {
this.showUpdateDownloadingNotification();
break;
case "idle":
this.reset();
this.clearPendingAndActiveNotifications();
break;
}
},
handleUpdateSwap() {
// This function is called because we just finished downloading an update
// (possibly) when another update was already ready.
// At some point, we may want to have some sort of intermediate
// notification to display here so that the badge doesn't just disappear.
// Currently, this function just hides update notifications and clears
// the callback timers so that notifications will not be shown. We want to
// clear the restart notification so the user doesn't try to restart to
// update during staging. We want to clear any other notifications too,
// since none of them make sense to display now.
// Our observer will fire again when the update is either ready to install
// or an error has been encountered.
this.clearPendingAndActiveNotifications();
},
observe(subject, topic, status) {
let update = subject && subject.QueryInterface(Ci.nsIUpdate);
@ -342,6 +388,9 @@ var UpdateListener = {
case "update-error":
this.handleUpdateError(update, status);
break;
case "update-swap":
this.handleUpdateSwap();
break;
}
},
};