summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/update/UpdateListener.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/update/UpdateListener.sys.mjs')
-rw-r--r--toolkit/mozapps/update/UpdateListener.sys.mjs476
1 files changed, 476 insertions, 0 deletions
diff --git a/toolkit/mozapps/update/UpdateListener.sys.mjs b/toolkit/mozapps/update/UpdateListener.sys.mjs
new file mode 100644
index 0000000000..c0c244e2b0
--- /dev/null
+++ b/toolkit/mozapps/update/UpdateListener.sys.mjs
@@ -0,0 +1,476 @@
+/* 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/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "AppUpdateService",
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "UpdateManager",
+ "@mozilla.org/updates/update-manager;1",
+ "nsIUpdateManager"
+);
+
+const PREF_APP_UPDATE_UNSUPPORTED_URL = "app.update.unsupported.url";
+const PREF_APP_UPDATE_SUPPRESS_PROMPTS = "app.update.suppressPrompts";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "SUPPRESS_PROMPTS",
+ PREF_APP_UPDATE_SUPPRESS_PROMPTS,
+ false
+);
+
+// Setup the hamburger button badges for updates.
+export 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,
+
+ // If PREF_APP_UPDATE_SUPPRESS_PROMPTS is true, we'll dispatch a notification
+ // prompt 14 days from the last build time, or 7 days from the last update
+ // time; whichever is sooner. It's hardcoded here to make sure update prompts
+ // can't be suppressed permanently without knowledge of the consequences.
+ promptDelayMsFromBuild: 14 * 24 * 60 * 60 * 1000, // 14 days
+
+ promptDelayMsFromUpdate: 7 * 24 * 60 * 60 * 1000, // 7 days
+
+ // If the last update time or current build time is more than 1 day in the
+ // future, it has probably been manipulated and should be distrusted.
+ promptMaxFutureVariation: 24 * 60 * 60 * 1000, // 1 day
+
+ latestUpdate: null,
+
+ availablePromptScheduled: false,
+
+ get badgeWaitTime() {
+ return Services.prefs.getIntPref("app.update.badgeWaitTime", 4 * 24 * 3600); // 4 days
+ },
+
+ get suppressedPromptDelay() {
+ // Return the time (in milliseconds) after which a suppressed prompt should
+ // be shown. Either 14 days from the last build time, or 7 days from the
+ // last update time; whichever comes sooner. If build time is not available
+ // and valid, schedule according to update time instead. If neither is
+ // available and valid, schedule the prompt for right now. Times are checked
+ // against the current time, since if the OS time is correct and nothing has
+ // been manipulated, the build time and update time will always be in the
+ // past. If the build time or update time is an hour in the future, it could
+ // just be a timezone issue. But if it is more than 24 hours in the future,
+ // it's probably due to attempted manipulation.
+ let now = Date.now();
+ let buildId = AppConstants.MOZ_BUILDID;
+ let buildTime =
+ new Date(
+ buildId.slice(0, 4),
+ buildId.slice(4, 6) - 1,
+ buildId.slice(6, 8),
+ buildId.slice(8, 10),
+ buildId.slice(10, 12),
+ buildId.slice(12, 14)
+ ).getTime() ?? 0;
+ let updateTime = lazy.UpdateManager.getUpdateAt(0)?.installDate ?? 0;
+ // Check that update/build times are at most 24 hours after now.
+ if (buildTime - now > this.promptMaxFutureVariation) {
+ buildTime = 0;
+ }
+ if (updateTime - now > this.promptMaxFutureVariation) {
+ updateTime = 0;
+ }
+ let promptTime = now;
+ // If both times are available, choose the sooner.
+ if (updateTime && buildTime) {
+ promptTime = Math.min(
+ buildTime + this.promptDelayMsFromBuild,
+ updateTime + this.promptDelayMsFromUpdate
+ );
+ } else if (updateTime || buildTime) {
+ // When the update time is missing, this installation was probably just
+ // installed and hasn't been updated yet. Ideally, we would instead set
+ // promptTime to installTime + this.promptDelayMsFromUpdate. But it's
+ // easier to get the build time than the install time. And on Nightly, the
+ // times ought to be fairly close together anyways.
+ promptTime = (updateTime || buildTime) + this.promptDelayMsFromUpdate;
+ }
+ return promptTime - now;
+ },
+
+ maybeShowUnsupportedNotification() {
+ // Persist the unsupported notification across sessions. If at some point an
+ // update is found this pref is cleared and the notification won't be shown.
+ let url = Services.prefs.getCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, null);
+ if (url) {
+ this.showUpdateNotification("unsupported", true, true, win =>
+ this.openUnsupportedUpdateUrl(win, url)
+ );
+ }
+ },
+
+ reset() {
+ this.clearPendingAndActiveNotifications();
+ this.restartDoorhangerShown = false;
+ this.updateFirstReadyTime = null;
+ },
+
+ clearPendingAndActiveNotifications() {
+ lazy.AppMenuNotifications.removeNotification(/^update-/);
+ this.clearCallbacks();
+ },
+
+ clearCallbacks() {
+ this.timeouts.forEach(t => clearTimeout(t));
+ this.timeouts = [];
+ this.availablePromptScheduled = false;
+ },
+
+ addTimeout(time, callback) {
+ this.timeouts.push(
+ setTimeout(() => {
+ this.clearCallbacks();
+ callback();
+ }, time)
+ );
+ },
+
+ requestRestart() {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ if (!cancelQuit.data) {
+ Services.startup.quit(
+ Services.startup.eAttemptQuit | Services.startup.eRestart
+ );
+ }
+ },
+
+ openManualUpdateUrl(win) {
+ let manualUpdateUrl = Services.urlFormatter.formatURLPref(
+ "app.update.url.manual"
+ );
+ win.openURL(manualUpdateUrl);
+ },
+
+ openUnsupportedUpdateUrl(win, detailsURL) {
+ win.openURL(detailsURL);
+ },
+
+ showUpdateNotification(
+ type,
+ mainActionDismiss,
+ dismissed,
+ mainAction,
+ beforeShowDoorhanger
+ ) {
+ const addTelemetry = id => {
+ // No telemetry for the "downloading" state.
+ if (type !== "downloading") {
+ // Histogram category labels can't have dashes in them.
+ let telemetryType = type.replaceAll("-", "");
+ Services.telemetry.getHistogramById(id).add(telemetryType);
+ }
+ };
+ let action = {
+ callback(win, fromDoorhanger) {
+ if (fromDoorhanger) {
+ addTelemetry("UPDATE_NOTIFICATION_MAIN_ACTION_DOORHANGER");
+ } else {
+ addTelemetry("UPDATE_NOTIFICATION_MAIN_ACTION_MENU");
+ }
+ mainAction(win);
+ },
+ dismiss: mainActionDismiss,
+ };
+
+ let secondaryAction = {
+ callback() {
+ addTelemetry("UPDATE_NOTIFICATION_DISMISSED");
+ },
+ dismiss: true,
+ };
+ lazy.AppMenuNotifications.showNotification(
+ "update-" + type,
+ action,
+ secondaryAction,
+ { dismissed, beforeShowDoorhanger }
+ );
+ if (dismissed) {
+ addTelemetry("UPDATE_NOTIFICATION_BADGE_SHOWN");
+ } else {
+ addTelemetry("UPDATE_NOTIFICATION_SHOWN");
+ }
+ },
+
+ showRestartNotification(update, dismissed) {
+ let notification = lazy.AppUpdateService.isOtherInstanceHandlingUpdates
+ ? "other-instance"
+ : "restart";
+ if (!dismissed) {
+ this.restartDoorhangerShown = true;
+ }
+ this.showUpdateNotification(notification, true, dismissed, () =>
+ this.requestRestart()
+ );
+ },
+
+ showUpdateAvailableNotification(update, dismissed) {
+ this.showUpdateNotification("available", false, dismissed, () => {
+ // This is asynchronous, but we are just going to kick it off.
+ lazy.AppUpdateService.downloadUpdate(update, true);
+ });
+ },
+
+ showManualUpdateNotification(update, dismissed) {
+ this.showUpdateNotification("manual", false, dismissed, win =>
+ this.openManualUpdateUrl(win)
+ );
+ },
+
+ showUnsupportedUpdateNotification(update, dismissed) {
+ if (!update || !update.detailsURL) {
+ console.error(
+ "The update for an unsupported notification must have a " +
+ "detailsURL attribute."
+ );
+ return;
+ }
+ let url = update.detailsURL;
+ if (
+ url != Services.prefs.getCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, null)
+ ) {
+ Services.prefs.setCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, url);
+ this.showUpdateNotification("unsupported", true, dismissed, win =>
+ this.openUnsupportedUpdateUrl(win, url)
+ );
+ }
+ },
+
+ showUpdateDownloadingNotification() {
+ this.showUpdateNotification("downloading", true, true, () => {
+ // The user clicked on the "Downloading update" app menu item.
+ // Code in browser/components/customizableui/content/panelUI.js
+ // receives the following notification and opens the about dialog.
+ Services.obs.notifyObservers(null, "show-update-progress");
+ });
+ },
+
+ scheduleUpdateAvailableNotification(update) {
+ // Show a badge/banner-only notification immediately.
+ this.showUpdateAvailableNotification(update, true);
+ // Track the latest update, since we will almost certainly have a new update
+ // 7 days from now. In a common scenario, update 1 triggers the timer.
+ // Updates 2, 3, 4, and 5 come without opening a prompt, since one is
+ // already scheduled. Then, the timer ends and the prompt that was triggered
+ // by update 1 is opened. But rather than downloading update 1, of course,
+ // it will download update 5, the latest update.
+ this.latestUpdate = update;
+ // Only schedule one doorhanger at a time. If we don't, then a new
+ // doorhanger would be scheduled at least once per day. If the user
+ // downloads the first update, we don't want to keep alerting them.
+ if (!this.availablePromptScheduled) {
+ this.addTimeout(Math.max(0, this.suppressedPromptDelay), () => {
+ // If we downloaded or installed an update via the badge or banner
+ // while the timer was running, bail out of showing the doorhanger.
+ if (
+ lazy.UpdateManager.downloadingUpdate ||
+ lazy.UpdateManager.readyUpdate
+ ) {
+ return;
+ }
+ this.showUpdateAvailableNotification(this.latestUpdate, false);
+ });
+ this.availablePromptScheduled = true;
+ }
+ },
+
+ handleUpdateError(update, status) {
+ switch (status) {
+ case "download-attempt-failed":
+ this.clearCallbacks();
+ this.showUpdateAvailableNotification(update, false);
+ break;
+ case "download-attempts-exceeded":
+ this.clearCallbacks();
+ this.showManualUpdateNotification(update, false);
+ break;
+ case "elevation-attempt-failed":
+ this.clearCallbacks();
+ this.showRestartNotification(false);
+ break;
+ case "elevation-attempts-exceeded":
+ this.clearCallbacks();
+ this.showManualUpdateNotification(update, false);
+ break;
+ case "check-attempts-exceeded":
+ case "unknown":
+ case "bad-perms":
+ // Background update has failed, let's show the UI responsible for
+ // prompting the user to update manually.
+ this.clearCallbacks();
+ this.showManualUpdateNotification(update, false);
+ break;
+ }
+ },
+
+ handleUpdateStagedOrDownloaded(update, status) {
+ switch (status) {
+ case "applied":
+ case "pending":
+ case "applied-service":
+ case "pending-service":
+ case "pending-elevate":
+ case "success":
+ this.clearCallbacks();
+
+ 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
+ );
+
+ // On Nightly only, permit disabling doorhangers for update restart
+ // notifications by setting PREF_APP_UPDATE_SUPPRESS_PROMPTS
+ if (AppConstants.NIGHTLY_BUILD && lazy.SUPPRESS_PROMPTS) {
+ this.showRestartNotification(update, true);
+ } else if (badgeWaitTimeMs < doorhangerWaitTimeMs) {
+ this.addTimeout(badgeWaitTimeMs, () => {
+ // Skip the badge if we're waiting for another instance.
+ if (!lazy.AppUpdateService.isOtherInstanceHandlingUpdates) {
+ this.showRestartNotification(update, true);
+ }
+
+ 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, this.restartDoorhangerShown);
+ });
+ }
+ break;
+ }
+ },
+
+ handleUpdateAvailable(update, status) {
+ switch (status) {
+ case "show-prompt":
+ // If an update is available, show an update available doorhanger unless
+ // PREF_APP_UPDATE_SUPPRESS_PROMPTS is true (only on Nightly).
+ if (AppConstants.NIGHTLY_BUILD && lazy.SUPPRESS_PROMPTS) {
+ this.scheduleUpdateAvailableNotification(update);
+ } else {
+ this.showUpdateAvailableNotification(update, false);
+ }
+ break;
+ case "cant-apply":
+ this.clearCallbacks();
+ this.showManualUpdateNotification(update, false);
+ break;
+ case "unsupported":
+ this.clearCallbacks();
+ this.showUnsupportedUpdateNotification(update, false);
+ break;
+ }
+ },
+
+ handleUpdateDownloading(status) {
+ switch (status) {
+ case "downloading":
+ this.showUpdateDownloadingNotification();
+ break;
+ case "idle":
+ 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);
+
+ switch (topic) {
+ case "update-available":
+ if (status != "unsupported") {
+ // An update check has found an update so clear the unsupported pref
+ // in case it is set.
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_UNSUPPORTED_URL);
+ }
+ this.handleUpdateAvailable(update, status);
+ break;
+ case "update-downloading":
+ this.handleUpdateDownloading(status);
+ break;
+ case "update-staged":
+ case "update-downloaded":
+ // An update check has found an update and downloaded / staged the
+ // update so clear the unsupported pref in case it is set.
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_UNSUPPORTED_URL);
+ this.handleUpdateStagedOrDownloaded(update, status);
+ break;
+ case "update-error":
+ this.handleUpdateError(update, status);
+ break;
+ case "update-swap":
+ this.handleUpdateSwap();
+ break;
+ }
+ },
+};