Bug 1874684 - Part 17: Fix uninitialised variable warnings from clang-tidy. r=allstarschh
[gecko.git] / toolkit / mozapps / update / UpdateListener.sys.mjs
blob355402ce49fbf23e8f44cc284caee10065df2723
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";
9 const lazy = {};
11 ChromeUtils.defineESModuleGetters(lazy, {
12   AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
13   NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
14 });
16 XPCOMUtils.defineLazyServiceGetter(
17   lazy,
18   "AppUpdateService",
19   "@mozilla.org/updates/update-service;1",
20   "nsIApplicationUpdateService"
23 XPCOMUtils.defineLazyServiceGetter(
24   lazy,
25   "UpdateManager",
26   "@mozilla.org/updates/update-manager;1",
27   "nsIUpdateManager"
30 const PREF_APP_UPDATE_UNSUPPORTED_URL = "app.update.unsupported.url";
31 const PREF_APP_UPDATE_SUPPRESS_PROMPTS = "app.update.suppressPrompts";
33 XPCOMUtils.defineLazyPreferenceGetter(
34   lazy,
35   "SUPPRESS_PROMPTS",
36   PREF_APP_UPDATE_SUPPRESS_PROMPTS,
37   false
40 // Setup the hamburger button badges for updates.
41 export var UpdateListener = {
42   timeouts: [],
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
64   latestUpdate: null,
66   availablePromptScheduled: false,
68   get badgeWaitTime() {
69     return Services.prefs.getIntPref("app.update.badgeWaitTime", 4 * 24 * 3600); // 4 days
70   },
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.
83     let now = Date.now();
84     let buildId = AppConstants.MOZ_BUILDID;
85     let buildTime =
86       new Date(
87         buildId.slice(0, 4),
88         buildId.slice(4, 6) - 1,
89         buildId.slice(6, 8),
90         buildId.slice(8, 10),
91         buildId.slice(10, 12),
92         buildId.slice(12, 14)
93       ).getTime() ?? 0;
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) {
97       buildTime = 0;
98     }
99     if (updateTime - now > this.promptMaxFutureVariation) {
100       updateTime = 0;
101     }
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
108       );
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;
116     }
117     return promptTime - now;
118   },
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);
124     if (url) {
125       this.showUpdateNotification(
126         "unsupported",
127         win => this.openUnsupportedUpdateUrl(win, url),
128         true,
129         { dismissed: true }
130       );
131     }
132   },
134   reset() {
135     this.clearPendingAndActiveNotifications();
136     this.restartDoorhangerShown = false;
137     this.updateFirstReadyTime = null;
138   },
140   clearPendingAndActiveNotifications() {
141     lazy.AppMenuNotifications.removeNotification(/^update-/);
142     this.clearCallbacks();
143   },
145   clearCallbacks() {
146     this.timeouts.forEach(t => clearTimeout(t));
147     this.timeouts = [];
148     this.availablePromptScheduled = false;
149   },
151   addTimeout(time, callback) {
152     this.timeouts.push(
153       setTimeout(() => {
154         this.clearCallbacks();
155         callback();
156       }, time)
157     );
158   },
160   requestRestart() {
161     let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
162       Ci.nsISupportsPRBool
163     );
164     Services.obs.notifyObservers(
165       cancelQuit,
166       "quit-application-requested",
167       "restart"
168     );
170     if (!cancelQuit.data) {
171       Services.startup.quit(
172         Services.startup.eAttemptQuit | Services.startup.eRestart
173       );
174     }
175   },
177   openManualUpdateUrl(win) {
178     let manualUpdateUrl = Services.urlFormatter.formatURLPref(
179       "app.update.url.manual"
180     );
181     win.openURL(manualUpdateUrl);
182   },
184   openUnsupportedUpdateUrl(win, detailsURL) {
185     win.openURL(detailsURL);
186   },
188   getReleaseNotesUrl(update) {
189     try {
190       // Release notes are enabled by default for EN locales only, but can be
191       // enabled for other locales within an experiment.
192       if (
193         !Services.locale.appLocaleAsBCP47.startsWith("en") &&
194         !lazy.NimbusFeatures.updatePrompt.getVariable("showReleaseNotesLink")
195       ) {
196         return null;
197       }
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");
201       if (url) {
202         let versionString = update.appVersion;
203         switch (update.channel) {
204           case "aurora":
205           case "beta":
206             versionString += "beta";
207             break;
208         }
209         url = Services.urlFormatter.formatURL(
210           url.replace("%VERSION%", versionString)
211         );
212       }
213       return url || null;
214     } catch (error) {
215       return null;
216     }
217   },
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);
226       }
227     };
228     let action = {
229       callback(win, fromDoorhanger) {
230         if (fromDoorhanger) {
231           addTelemetry("UPDATE_NOTIFICATION_MAIN_ACTION_DOORHANGER");
232         } else {
233           addTelemetry("UPDATE_NOTIFICATION_MAIN_ACTION_MENU");
234         }
235         mainAction(win);
236       },
237       dismiss: mainActionDismiss,
238     };
240     let secondaryAction = {
241       callback() {
242         addTelemetry("UPDATE_NOTIFICATION_DISMISSED");
243       },
244       dismiss: true,
245     };
246     lazy.AppMenuNotifications.showNotification(
247       "update-" + type,
248       action,
249       secondaryAction,
250       options
251     );
252     if (options.dismissed) {
253       addTelemetry("UPDATE_NOTIFICATION_BADGE_SHOWN");
254     } else {
255       addTelemetry("UPDATE_NOTIFICATION_SHOWN");
256     }
257   },
259   showRestartNotification(update, dismissed) {
260     let notification = lazy.AppUpdateService.isOtherInstanceHandlingUpdates
261       ? "other-instance"
262       : "restart";
263     if (!dismissed) {
264       this.restartDoorhangerShown = true;
265     }
266     this.showUpdateNotification(
267       notification,
268       () => this.requestRestart(),
269       true,
270       { dismissed }
271     );
272   },
274   showUpdateAvailableNotification(update, dismissed) {
275     let learnMoreURL = this.getReleaseNotesUrl(update);
276     this.showUpdateNotification(
277       "available",
278       // This is asynchronous, but we are just going to kick it off.
279       () => lazy.AppUpdateService.downloadUpdate(update, true),
280       false,
281       { dismissed, learnMoreURL }
282     );
283     lazy.NimbusFeatures.updatePrompt.recordExposureEvent({ once: true });
284   },
286   showManualUpdateNotification(update, dismissed) {
287     let learnMoreURL = this.getReleaseNotesUrl(update);
288     this.showUpdateNotification(
289       "manual",
290       win => this.openManualUpdateUrl(win),
291       false,
292       { dismissed, learnMoreURL }
293     );
294     lazy.NimbusFeatures.updatePrompt.recordExposureEvent({ once: true });
295   },
297   showUnsupportedUpdateNotification(update, dismissed) {
298     if (!update || !update.detailsURL) {
299       console.error(
300         "The update for an unsupported notification must have a " +
301           "detailsURL attribute."
302       );
303       return;
304     }
305     let url = update.detailsURL;
306     if (
307       url != Services.prefs.getCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, null)
308     ) {
309       Services.prefs.setCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, url);
310       this.showUpdateNotification(
311         "unsupported",
312         win => this.openUnsupportedUpdateUrl(win, url),
313         true,
314         { dismissed }
315       );
316     }
317   },
319   showUpdateDownloadingNotification() {
320     this.showUpdateNotification(
321       "downloading",
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"),
326       true,
327       { dismissed: true }
328     );
329   },
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.
348         if (
349           lazy.UpdateManager.downloadingUpdate ||
350           lazy.UpdateManager.readyUpdate
351         ) {
352           return;
353         }
354         this.showUpdateAvailableNotification(this.latestUpdate, false);
355       });
356       this.availablePromptScheduled = true;
357     }
358   },
360   handleUpdateError(update, status) {
361     switch (status) {
362       case "download-attempt-failed":
363         this.clearCallbacks();
364         this.showUpdateAvailableNotification(update, false);
365         break;
366       case "download-attempts-exceeded":
367         this.clearCallbacks();
368         this.showManualUpdateNotification(update, false);
369         break;
370       case "elevation-attempt-failed":
371         this.clearCallbacks();
372         this.showRestartNotification(false);
373         break;
374       case "elevation-attempts-exceeded":
375         this.clearCallbacks();
376         this.showManualUpdateNotification(update, false);
377         break;
378       case "check-attempts-exceeded":
379       case "unknown":
380       case "bad-perms":
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);
385         break;
386     }
387   },
389   handleUpdateStagedOrDownloaded(update, status) {
390     switch (status) {
391       case "applied":
392       case "pending":
393       case "applied-service":
394       case "pending-service":
395       case "pending-elevate":
396       case "success":
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;
405         }
407         let badgeWaitTimeMs = Math.max(
408           0,
409           this.updateFirstReadyTime + initialBadgeWaitTimeMs - now
410         );
411         let doorhangerWaitTimeMs = Math.max(
412           0,
413           this.updateFirstReadyTime + initialDoorhangerWaitTimeMs - now
414         );
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);
425             }
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);
434               });
435             }
436           });
437         } else {
438           this.addTimeout(doorhangerWaitTimeMs, () => {
439             this.showRestartNotification(update, this.restartDoorhangerShown);
440           });
441         }
442         break;
443     }
444   },
446   handleUpdateAvailable(update, status) {
447     switch (status) {
448       case "show-prompt":
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);
453         } else {
454           this.showUpdateAvailableNotification(update, false);
455         }
456         break;
457       case "cant-apply":
458         this.clearCallbacks();
459         this.showManualUpdateNotification(update, false);
460         break;
461       case "unsupported":
462         this.clearCallbacks();
463         this.showUnsupportedUpdateNotification(update, false);
464         break;
465     }
466   },
468   handleUpdateDownloading(status) {
469     switch (status) {
470       case "downloading":
471         this.showUpdateDownloadingNotification();
472         break;
473       case "idle":
474         this.clearPendingAndActiveNotifications();
475         break;
476     }
477   },
479   handleUpdateSwap() {
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();
492   },
494   observe(subject, topic, status) {
495     let update = subject && subject.QueryInterface(Ci.nsIUpdate);
497     switch (topic) {
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);
503         }
504         this.handleUpdateAvailable(update, status);
505         break;
506       case "update-downloading":
507         this.handleUpdateDownloading(status);
508         break;
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);
515         break;
516       case "update-error":
517         this.handleUpdateError(update, status);
518         break;
519       case "update-swap":
520         this.handleUpdateSwap();
521         break;
522     }
523   },