diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/mozapps/update/UpdateListener.sys.mjs | 476 |
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; + } + }, +}; |