/* 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"; var EXPORTED_SYMBOLS = ["UpdateListener"]; const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const { clearTimeout, setTimeout } = ChromeUtils.importESModule( "resource://gre/modules/Timer.sys.mjs" ); const { XPCOMUtils } = ChromeUtils.importESModule( "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. 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; } }, };