1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
6 import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
11 ChromeUtils.defineESModuleGetters(lazy, {
12 AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
13 NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
16 XPCOMUtils.defineLazyServiceGetter(
19 "@mozilla.org/updates/update-service;1",
20 "nsIApplicationUpdateService"
23 XPCOMUtils.defineLazyServiceGetter(
26 "@mozilla.org/updates/update-manager;1",
30 const PREF_APP_UPDATE_UNSUPPORTED_URL = "app.update.unsupported.url";
31 const PREF_APP_UPDATE_SUPPRESS_PROMPTS = "app.update.suppressPrompts";
33 XPCOMUtils.defineLazyPreferenceGetter(
36 PREF_APP_UPDATE_SUPPRESS_PROMPTS,
40 // Setup the hamburger button badges for updates.
41 export var UpdateListener = {
44 restartDoorhangerShown: false,
46 // Once a restart badge/doorhanger is scheduled, these store the time that
47 // they were scheduled at (as milliseconds elapsed since the UNIX epoch). This
48 // allows us to resume the badge/doorhanger timers rather than restarting
49 // them from the beginning when a new update comes along.
50 updateFirstReadyTime: null,
52 // If PREF_APP_UPDATE_SUPPRESS_PROMPTS is true, we'll dispatch a notification
53 // prompt 14 days from the last build time, or 7 days from the last update
54 // time; whichever is sooner. It's hardcoded here to make sure update prompts
55 // can't be suppressed permanently without knowledge of the consequences.
56 promptDelayMsFromBuild: 14 * 24 * 60 * 60 * 1000, // 14 days
58 promptDelayMsFromUpdate: 7 * 24 * 60 * 60 * 1000, // 7 days
60 // If the last update time or current build time is more than 1 day in the
61 // future, it has probably been manipulated and should be distrusted.
62 promptMaxFutureVariation: 24 * 60 * 60 * 1000, // 1 day
66 availablePromptScheduled: false,
69 return Services.prefs.getIntPref("app.update.badgeWaitTime", 4 * 24 * 3600); // 4 days
72 get suppressedPromptDelay() {
73 // Return the time (in milliseconds) after which a suppressed prompt should
74 // be shown. Either 14 days from the last build time, or 7 days from the
75 // last update time; whichever comes sooner. If build time is not available
76 // and valid, schedule according to update time instead. If neither is
77 // available and valid, schedule the prompt for right now. Times are checked
78 // against the current time, since if the OS time is correct and nothing has
79 // been manipulated, the build time and update time will always be in the
80 // past. If the build time or update time is an hour in the future, it could
81 // just be a timezone issue. But if it is more than 24 hours in the future,
82 // it's probably due to attempted manipulation.
84 let buildId = AppConstants.MOZ_BUILDID;
88 buildId.slice(4, 6) - 1,
91 buildId.slice(10, 12),
94 let updateTime = lazy.UpdateManager.getUpdateAt(0)?.installDate ?? 0;
95 // Check that update/build times are at most 24 hours after now.
96 if (buildTime - now > this.promptMaxFutureVariation) {
99 if (updateTime - now > this.promptMaxFutureVariation) {
102 let promptTime = now;
103 // If both times are available, choose the sooner.
104 if (updateTime && buildTime) {
105 promptTime = Math.min(
106 buildTime + this.promptDelayMsFromBuild,
107 updateTime + this.promptDelayMsFromUpdate
109 } else if (updateTime || buildTime) {
110 // When the update time is missing, this installation was probably just
111 // installed and hasn't been updated yet. Ideally, we would instead set
112 // promptTime to installTime + this.promptDelayMsFromUpdate. But it's
113 // easier to get the build time than the install time. And on Nightly, the
114 // times ought to be fairly close together anyways.
115 promptTime = (updateTime || buildTime) + this.promptDelayMsFromUpdate;
117 return promptTime - now;
120 maybeShowUnsupportedNotification() {
121 // Persist the unsupported notification across sessions. If at some point an
122 // update is found this pref is cleared and the notification won't be shown.
123 let url = Services.prefs.getCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, null);
125 this.showUpdateNotification(
127 win => this.openUnsupportedUpdateUrl(win, url),
135 this.clearPendingAndActiveNotifications();
136 this.restartDoorhangerShown = false;
137 this.updateFirstReadyTime = null;
140 clearPendingAndActiveNotifications() {
141 lazy.AppMenuNotifications.removeNotification(/^update-/);
142 this.clearCallbacks();
146 this.timeouts.forEach(t => clearTimeout(t));
148 this.availablePromptScheduled = false;
151 addTimeout(time, callback) {
154 this.clearCallbacks();
161 let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
164 Services.obs.notifyObservers(
166 "quit-application-requested",
170 if (!cancelQuit.data) {
171 Services.startup.quit(
172 Services.startup.eAttemptQuit | Services.startup.eRestart
177 openManualUpdateUrl(win) {
178 let manualUpdateUrl = Services.urlFormatter.formatURLPref(
179 "app.update.url.manual"
181 win.openURL(manualUpdateUrl);
184 openUnsupportedUpdateUrl(win, detailsURL) {
185 win.openURL(detailsURL);
188 getReleaseNotesUrl(update) {
190 // Release notes are enabled by default for EN locales only, but can be
191 // enabled for other locales within an experiment.
193 !Services.locale.appLocaleAsBCP47.startsWith("en") &&
194 !lazy.NimbusFeatures.updatePrompt.getVariable("showReleaseNotesLink")
198 // The release notes URL is set in the pref app.releaseNotesURL.prompt,
199 // but can also be overridden by an experiment.
200 let url = lazy.NimbusFeatures.updatePrompt.getVariable("releaseNotesURL");
202 let versionString = update.appVersion;
203 switch (update.channel) {
206 versionString += "beta";
209 url = Services.urlFormatter.formatURL(
210 url.replace("%VERSION%", versionString)
219 showUpdateNotification(type, mainAction, mainActionDismiss, options = {}) {
220 const addTelemetry = id => {
221 // No telemetry for the "downloading" state.
222 if (type !== "downloading") {
223 // Histogram category labels can't have dashes in them.
224 let telemetryType = type.replaceAll("-", "");
225 Services.telemetry.getHistogramById(id).add(telemetryType);
229 callback(win, fromDoorhanger) {
230 if (fromDoorhanger) {
231 addTelemetry("UPDATE_NOTIFICATION_MAIN_ACTION_DOORHANGER");
233 addTelemetry("UPDATE_NOTIFICATION_MAIN_ACTION_MENU");
237 dismiss: mainActionDismiss,
240 let secondaryAction = {
242 addTelemetry("UPDATE_NOTIFICATION_DISMISSED");
246 lazy.AppMenuNotifications.showNotification(
252 if (options.dismissed) {
253 addTelemetry("UPDATE_NOTIFICATION_BADGE_SHOWN");
255 addTelemetry("UPDATE_NOTIFICATION_SHOWN");
259 showRestartNotification(update, dismissed) {
260 let notification = lazy.AppUpdateService.isOtherInstanceHandlingUpdates
264 this.restartDoorhangerShown = true;
266 this.showUpdateNotification(
268 () => this.requestRestart(),
274 showUpdateAvailableNotification(update, dismissed) {
275 let learnMoreURL = this.getReleaseNotesUrl(update);
276 this.showUpdateNotification(
278 // This is asynchronous, but we are just going to kick it off.
279 () => lazy.AppUpdateService.downloadUpdate(update, true),
281 { dismissed, learnMoreURL }
283 lazy.NimbusFeatures.updatePrompt.recordExposureEvent({ once: true });
286 showManualUpdateNotification(update, dismissed) {
287 let learnMoreURL = this.getReleaseNotesUrl(update);
288 this.showUpdateNotification(
290 win => this.openManualUpdateUrl(win),
292 { dismissed, learnMoreURL }
294 lazy.NimbusFeatures.updatePrompt.recordExposureEvent({ once: true });
297 showUnsupportedUpdateNotification(update, dismissed) {
298 if (!update || !update.detailsURL) {
300 "The update for an unsupported notification must have a " +
301 "detailsURL attribute."
305 let url = update.detailsURL;
307 url != Services.prefs.getCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, null)
309 Services.prefs.setCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, url);
310 this.showUpdateNotification(
312 win => this.openUnsupportedUpdateUrl(win, url),
319 showUpdateDownloadingNotification() {
320 this.showUpdateNotification(
322 // The user clicked on the "Downloading update" app menu item.
323 // Code in browser/components/customizableui/content/panelUI.js
324 // receives the following notification and opens the about dialog.
325 () => Services.obs.notifyObservers(null, "show-update-progress"),
331 scheduleUpdateAvailableNotification(update) {
332 // Show a badge/banner-only notification immediately.
333 this.showUpdateAvailableNotification(update, true);
334 // Track the latest update, since we will almost certainly have a new update
335 // 7 days from now. In a common scenario, update 1 triggers the timer.
336 // Updates 2, 3, 4, and 5 come without opening a prompt, since one is
337 // already scheduled. Then, the timer ends and the prompt that was triggered
338 // by update 1 is opened. But rather than downloading update 1, of course,
339 // it will download update 5, the latest update.
340 this.latestUpdate = update;
341 // Only schedule one doorhanger at a time. If we don't, then a new
342 // doorhanger would be scheduled at least once per day. If the user
343 // downloads the first update, we don't want to keep alerting them.
344 if (!this.availablePromptScheduled) {
345 this.addTimeout(Math.max(0, this.suppressedPromptDelay), () => {
346 // If we downloaded or installed an update via the badge or banner
347 // while the timer was running, bail out of showing the doorhanger.
349 lazy.UpdateManager.downloadingUpdate ||
350 lazy.UpdateManager.readyUpdate
354 this.showUpdateAvailableNotification(this.latestUpdate, false);
356 this.availablePromptScheduled = true;
360 handleUpdateError(update, status) {
362 case "download-attempt-failed":
363 this.clearCallbacks();
364 this.showUpdateAvailableNotification(update, false);
366 case "download-attempts-exceeded":
367 this.clearCallbacks();
368 this.showManualUpdateNotification(update, false);
370 case "elevation-attempt-failed":
371 this.clearCallbacks();
372 this.showRestartNotification(false);
374 case "elevation-attempts-exceeded":
375 this.clearCallbacks();
376 this.showManualUpdateNotification(update, false);
378 case "check-attempts-exceeded":
381 // Background update has failed, let's show the UI responsible for
382 // prompting the user to update manually.
383 this.clearCallbacks();
384 this.showManualUpdateNotification(update, false);
389 handleUpdateStagedOrDownloaded(update, status) {
393 case "applied-service":
394 case "pending-service":
395 case "pending-elevate":
397 this.clearCallbacks();
399 let initialBadgeWaitTimeMs = this.badgeWaitTime * 1000;
400 let initialDoorhangerWaitTimeMs = update.promptWaitTime * 1000;
401 let now = Date.now();
403 if (!this.updateFirstReadyTime) {
404 this.updateFirstReadyTime = now;
407 let badgeWaitTimeMs = Math.max(
409 this.updateFirstReadyTime + initialBadgeWaitTimeMs - now
411 let doorhangerWaitTimeMs = Math.max(
413 this.updateFirstReadyTime + initialDoorhangerWaitTimeMs - now
416 // On Nightly only, permit disabling doorhangers for update restart
417 // notifications by setting PREF_APP_UPDATE_SUPPRESS_PROMPTS
418 if (AppConstants.NIGHTLY_BUILD && lazy.SUPPRESS_PROMPTS) {
419 this.showRestartNotification(update, true);
420 } else if (badgeWaitTimeMs < doorhangerWaitTimeMs) {
421 this.addTimeout(badgeWaitTimeMs, () => {
422 // Skip the badge if we're waiting for another instance.
423 if (!lazy.AppUpdateService.isOtherInstanceHandlingUpdates) {
424 this.showRestartNotification(update, true);
427 if (!this.restartDoorhangerShown) {
428 // doorhangerWaitTimeMs is relative to when we initially received
429 // the event. Since we've already waited badgeWaitTimeMs, subtract
430 // that from doorhangerWaitTimeMs.
431 let remainingTime = doorhangerWaitTimeMs - badgeWaitTimeMs;
432 this.addTimeout(remainingTime, () => {
433 this.showRestartNotification(update, false);
438 this.addTimeout(doorhangerWaitTimeMs, () => {
439 this.showRestartNotification(update, this.restartDoorhangerShown);
446 handleUpdateAvailable(update, status) {
449 // If an update is available, show an update available doorhanger unless
450 // PREF_APP_UPDATE_SUPPRESS_PROMPTS is true (only on Nightly).
451 if (AppConstants.NIGHTLY_BUILD && lazy.SUPPRESS_PROMPTS) {
452 this.scheduleUpdateAvailableNotification(update);
454 this.showUpdateAvailableNotification(update, false);
458 this.clearCallbacks();
459 this.showManualUpdateNotification(update, false);
462 this.clearCallbacks();
463 this.showUnsupportedUpdateNotification(update, false);
468 handleUpdateDownloading(status) {
471 this.showUpdateDownloadingNotification();
474 this.clearPendingAndActiveNotifications();
480 // This function is called because we just finished downloading an update
481 // (possibly) when another update was already ready.
482 // At some point, we may want to have some sort of intermediate
483 // notification to display here so that the badge doesn't just disappear.
484 // Currently, this function just hides update notifications and clears
485 // the callback timers so that notifications will not be shown. We want to
486 // clear the restart notification so the user doesn't try to restart to
487 // update during staging. We want to clear any other notifications too,
488 // since none of them make sense to display now.
489 // Our observer will fire again when the update is either ready to install
490 // or an error has been encountered.
491 this.clearPendingAndActiveNotifications();
494 observe(subject, topic, status) {
495 let update = subject && subject.QueryInterface(Ci.nsIUpdate);
498 case "update-available":
499 if (status != "unsupported") {
500 // An update check has found an update so clear the unsupported pref
501 // in case it is set.
502 Services.prefs.clearUserPref(PREF_APP_UPDATE_UNSUPPORTED_URL);
504 this.handleUpdateAvailable(update, status);
506 case "update-downloading":
507 this.handleUpdateDownloading(status);
509 case "update-staged":
510 case "update-downloaded":
511 // An update check has found an update and downloaded / staged the
512 // update so clear the unsupported pref in case it is set.
513 Services.prefs.clearUserPref(PREF_APP_UPDATE_UNSUPPORTED_URL);
514 this.handleUpdateStagedOrDownloaded(update, status);
517 this.handleUpdateError(update, status);
520 this.handleUpdateSwap();