diff options
Diffstat (limited to 'toolkit/mozapps/update/BackgroundUpdate.sys.mjs')
-rw-r--r-- | toolkit/mozapps/update/BackgroundUpdate.sys.mjs | 1045 |
1 files changed, 1045 insertions, 0 deletions
diff --git a/toolkit/mozapps/update/BackgroundUpdate.sys.mjs b/toolkit/mozapps/update/BackgroundUpdate.sys.mjs new file mode 100644 index 0000000000..28d0fc8538 --- /dev/null +++ b/toolkit/mozapps/update/BackgroundUpdate.sys.mjs @@ -0,0 +1,1045 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* 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 { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + ASRouterTargeting: + // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit + "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs", + ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + TaskScheduler: "resource://gre/modules/TaskScheduler.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + // tip: set maxLogLevel to "debug" and use log.debug() to create detailed + // messages during development. See LOG_LEVELS in Console.sys.mjs for details. + maxLogLevel: "error", + maxLogLevelPref: "app.update.background.loglevel", + prefix: "BackgroundUpdate", + }; + return new ConsoleAPI(consoleOptions); +}); + +ChromeUtils.defineLazyGetter(lazy, "localization", () => { + return new Localization( + ["branding/brand.ftl", "toolkit/updates/backgroundupdate.ftl"], + true + ); +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + idleService: ["@mozilla.org/widget/useridleservice;1", "nsIUserIdleService"], + UpdateService: [ + "@mozilla.org/updates/update-service;1", + "nsIApplicationUpdateService", + ], +}); + +// We may want to change the definition of the task over time. When we do this, +// we need to remove and re-register the task. We will make sure this happens +// by storing the installed version number of the task to a pref and comparing +// that version number to the current version. If they aren't equal, we know +// that we have to re-register the task. +const TASK_DEF_CURRENT_VERSION = 4; +const TASK_INSTALLED_VERSION_PREF = + "app.update.background.lastInstalledTaskVersion"; + +// This returns the version of the task naming scheme being used which +// is different from the task version used for the task definition. +function taskNameVersion(taskVersion) { + if (AppConstants.platform != "win" || taskVersion < 4) { + return 1; + } + return 2; +} + +async function deleteTasksInRange(installedVersion, currentVersion) { + for ( + let taskVersion = installedVersion; + taskVersion <= currentVersion; + taskVersion++ + ) { + await lazy.TaskScheduler.deleteTask(this.taskId, { + nameVersion: taskNameVersion(taskVersion), + }); + } +} + +export var BackgroundUpdate = { + QueryInterface: ChromeUtils.generateQI([ + "nsINamed", + "nsIObserver", + "nsITimerCallback", + ]), + name: "BackgroundUpdate", + + _initialized: false, + + get taskId() { + let taskId = "backgroundupdate"; + if (AppConstants.platform == "win") { + // In the future, we might lift this to TaskScheduler Win impl, so that + // all tasks associated with this installation look consistent in the + // Windows Task Scheduler UI. + taskId = `${AppConstants.MOZ_APP_DISPLAYNAME_DO_NOT_USE} Background Update`; + } + return taskId; + }, + + /** + * Whether this installation has an App and a GRE omnijar. + * + * Installations without an omnijar are generally developer builds and should not be updated. + * + * @returns {boolean} - true if this installation has an App and a GRE omnijar. + */ + async _hasOmnijar() { + const appOmniJar = PathUtils.join( + Services.dirsvc.get("XCurProcD", Ci.nsIFile).path, + AppConstants.OMNIJAR_NAME + ); + const greOmniJar = PathUtils.join( + Services.dirsvc.get("GreD", Ci.nsIFile).path, + AppConstants.OMNIJAR_NAME + ); + + let bothExist = + (await IOUtils.exists(appOmniJar)) && (await IOUtils.exists(greOmniJar)); + + return bothExist; + }, + + _force() { + // We want to allow developers and testers to monkey with the system. + return Services.prefs.getBoolPref("app.update.background.force", false); + }, + + /** + * Check eligibility criteria determining if this installation should be updated using the + * background updater. + * + * These reasons should not factor in transient reasons, for example if there are currently multiple + * Firefox instances running. + * + * Both the browser proper and the backgroundupdate background task invoke this function, so avoid + * using profile specifics here. Profile specifics that the background task specifically sources + * from the default profile are available here. + * + * @returns [string] - descriptions of failed criteria; empty if all criteria were met. + */ + async _reasonsToNotUpdateInstallation() { + let SLUG = "_reasonsToNotUpdateInstallation"; + let reasons = []; + + lazy.log.debug(`${SLUG}: checking app.update.auto`); + let updateAuto = await lazy.UpdateUtils.getAppUpdateAutoEnabled(); + if (!updateAuto) { + reasons.push(this.REASON.NO_APP_UPDATE_AUTO); + } + + lazy.log.debug(`${SLUG}: checking app.update.background.enabled`); + let updateBackground = await lazy.UpdateUtils.readUpdateConfigSetting( + "app.update.background.enabled" + ); + if (!updateBackground) { + reasons.push(this.REASON.NO_APP_UPDATE_BACKGROUND_ENABLED); + } + + const bts = + "@mozilla.org/backgroundtasks;1" in Cc && + Cc["@mozilla.org/backgroundtasks;1"].getService(Ci.nsIBackgroundTasks); + + lazy.log.debug(`${SLUG}: checking for MOZ_BACKGROUNDTASKS`); + if (!AppConstants.MOZ_BACKGROUNDTASKS || !bts) { + reasons.push(this.REASON.NO_MOZ_BACKGROUNDTASKS); + } + + // The methods exposed by the update service named like `canX` answer the + // question "can I do action X RIGHT NOW", where-as we want the variants + // named like `canUsuallyX` to answer the question "can I usually do X, now + // and in the future". + let updateService = Cc["@mozilla.org/updates/update-service;1"].getService( + Ci.nsIApplicationUpdateService + ); + + lazy.log.debug( + `${SLUG}: checking that updates are not disabled by policy, testing ` + + `configuration, or abnormal runtime environment` + ); + if (!updateService.canUsuallyCheckForUpdates) { + reasons.push(this.REASON.CANNOT_USUALLY_CHECK); + } + + lazy.log.debug( + `${SLUG}: checking that we can make progress: updates can stage and/or apply` + ); + if ( + !updateService.canUsuallyStageUpdates && + !updateService.canUsuallyApplyUpdates + ) { + reasons.push(this.REASON.CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY); + } + + lazy.log.debug( + `${SLUG}: checking that we are on a supported OS (currently only Windows)` + ); + if (AppConstants.platform != "win") { + reasons.push(this.REASON.UNSUPPORTED_OS); + } + + if (AppConstants.platform == "win") { + lazy.log.debug(`${SLUG}: checking that we can usually use Windows BITS`); + if (!updateService.canUsuallyUseBits) { + // There's no technical reason to require BITS, but the experience + // without BITS will be worse because the background tasks will run + // while downloading, consuming valuable resources. + reasons.push(this.REASON.WINDOWS_CANNOT_USUALLY_USE_BITS); + } + + // Historically the background update process assumed the Mozilla + // Maintenance Service was available and could update this installation. + // We want to handle unelevated installations where this is not the case, + // and for flexibility we are rolling this out behind a Nimbus feature. + lazy.log.debug( + `${SLUG}: checking that the Mozilla Maintenance Service Registry key exists, ` + + `or that the unelevated installs are permitted` + ); + let serviceRegKeyExists = false; + try { + serviceRegKeyExists = Cc["@mozilla.org/updates/update-processor;1"] + .createInstance(Ci.nsIUpdateProcessor) + .getServiceRegKeyExists(); + } catch (ex) { + lazy.log.error( + `${SLUG}: Failed to check for Maintenance Service Registry Key: ${ex}` + ); + } + + if (!serviceRegKeyExists) { + // A Nimbus rollout sets this preference and allows users with + // unelevated installations to update in the background. For that to + // work we use the setPref function to toggle a preference, because the + // value for Nimbus is currently not readable in a backgroundtask. The + // preference serves in that case as our communication channel. + let allowUnelevated = await Services.prefs.getBoolPref( + "app.update.background.allowUpdatesForUnelevatedInstallations" + ); + + if (!allowUnelevated) { + // With the nimbus feature disabled and without the registry key we + // do not want to attempt an update for unelevated installations. + reasons.push(this.REASON.SERVICE_REGISTRY_KEY_MISSING); + } else { + // We record in telemetry, that the service registry key is missing + // and the experiment is enabled. This is the first time that the + // Nimbus feature could impact Firefox behaviour. + lazy.NimbusFeatures.backgroundUpdate.recordExposureEvent(); + lazy.log.debug( + `${SLUG}: ` + + "expermiment active: trying to update unelevated installations." + ); + + // Now check if the path is writable. If not we are dealing with an + // elevated installation and cannot update it without the service for + // which the registry key is missing at this point. + if (!updateService.isAppBaseDirWritable) { + reasons.push(this.REASON.SERVICE_REGISTRY_KEY_MISSING); + reasons.push(this.REASON.APPBASEDIR_NOT_WRITABLE); + } + } + } + } + + lazy.log.debug(`${SLUG}: checking that this installation has an omnijar`); + if (!(await this._hasOmnijar())) { + reasons.push(this.REASON.NO_OMNIJAR); + } + + if (updateService.manualUpdateOnly) { + reasons.push(this.REASON.MANUAL_UPDATE_ONLY); + } + + this._recordGleanMetrics(reasons); + + return reasons; + }, + + /** + * Check if this particular profile should schedule tasks to update this installation using the + * background updater. + * + * Only the browser proper should invoke this function, not background tasks, so this is the place + * to use profile specifics. + * + * @returns [string] - descriptions of failed criteria; empty if all criteria were met. + */ + async _reasonsToNotScheduleUpdates() { + let SLUG = "_reasonsToNotScheduleUpdates"; + let reasons = []; + + const bts = + "@mozilla.org/backgroundtasks;1" in Cc && + Cc["@mozilla.org/backgroundtasks;1"].getService(Ci.nsIBackgroundTasks); + + if (bts && bts.isBackgroundTaskMode) { + throw new Components.Exception( + `Not available in --backgroundtask mode`, + Cr.NS_ERROR_NOT_AVAILABLE + ); + } + + // No default profile happens under xpcshell but also when running local + // builds. It's unexpected in the wild so we track it separately. + if (!lazy.BackgroundTasksUtils.hasDefaultProfile()) { + reasons.push(this.REASON.NO_DEFAULT_PROFILE_EXISTS); + } + + if (!lazy.BackgroundTasksUtils.currentProfileIsDefaultProfile()) { + reasons.push(this.REASON.NOT_DEFAULT_PROFILE); + } + + lazy.log.debug(`${SLUG}: checking app.update.langpack.enabled`); + let updateLangpack = Services.prefs.getBoolPref( + "app.update.langpack.enabled", + true + ); + if (updateLangpack) { + lazy.log.debug( + `${SLUG}: app.update.langpack.enabled=true, checking that no langpacks are installed` + ); + + let langpacks = await lazy.AddonManager.getAddonsByTypes(["locale"]); + lazy.log.debug(`${langpacks.length} langpacks installed`); + if (langpacks.length) { + reasons.push(this.REASON.LANGPACK_INSTALLED); + } + } + + this._recordGleanMetrics(reasons); + + return reasons; + }, + + /** + * Register a background update task. + * + * @param {string} [taskId] + * The task identifier; defaults to the platform-specific background update task ID. + * @return {object} non-null if the background task was registered. + */ + async _registerBackgroundUpdateTask(taskId = this.taskId) { + let binary = Services.dirsvc.get("XREExeF", Ci.nsIFile); + let args = [ + "--MOZ_LOG", + // Note: `maxsize:1` means 1Mb total size, trimmed to 512kb on overflow. + "sync,prependheader,timestamp,append,maxsize:1,Dump:5", + "--MOZ_LOG_FILE", + // The full path might hit command line length limits, but also makes it + // much easier to find the relevant log file when starting from the + // Windows Task Scheduler UI. + PathUtils.join( + Services.dirsvc.get("UpdRootD", Ci.nsIFile).path, + "backgroundupdate.moz_log" + ), + "--backgroundtask", + "backgroundupdate", + ]; + + let workingDirectory = Services.dirsvc.get("UpdRootD", Ci.nsIFile).path; + await IOUtils.makeDirectory(workingDirectory, { ignoreExisting: true }); + + let description = await lazy.localization.formatValue( + "backgroundupdate-task-description" + ); + + // Let the task run for a maximum of 20 minutes before the task scheduler + // stops it. + let executionTimeoutSec = 20 * 60; + + let result = await lazy.TaskScheduler.registerTask( + taskId, + binary.path, + // Keep this default in sync with the preference in firefox.js. + Services.prefs.getIntPref("app.update.background.interval", 60 * 60 * 7), + { + workingDirectory, + args, + description, + executionTimeoutSec, + } + ); + + Services.prefs.setIntPref( + TASK_INSTALLED_VERSION_PREF, + TASK_DEF_CURRENT_VERSION + ); + + return result; + }, + + /** + * Background Update is controlled by the per-installation pref + * "app.update.background.enabled". When Background Update was still in the + * experimental phase, the default value of this pref may have been changed. + * Now that the feature has been rolled out, we need to make sure that the + * desired default value is restored. + */ + async ensureExperimentToRolloutTransitionPerformed() { + if (!lazy.UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED) { + return; + } + const transitionPerformedPref = "app.update.background.rolledout"; + if (Services.prefs.getBoolPref(transitionPerformedPref, false)) { + // writeUpdateConfigSetting serializes access to the config file. Because + // of this, we can safely return here without worrying about another call + // to this function that might still be in progress. + return; + } + Services.prefs.setBoolPref(transitionPerformedPref, true); + + const defaultValue = + lazy.UpdateUtils.PER_INSTALLATION_PREFS["app.update.background.enabled"] + .defaultValue; + await lazy.UpdateUtils.writeUpdateConfigSetting( + "app.update.background.enabled", + defaultValue, + { setDefaultOnly: true } + ); + + // To be thorough, remove any traces of the pref that used to control the + // default value that we just set. We don't want any users to have the + // impression that that pref is still useful. + Services.prefs.clearUserPref("app.update.background.scheduling.enabled"); + }, + + observe(subject, topic, data) { + let whatChanged; + switch (topic) { + case "idle-daily": + this._snapshot.saveSoon(); + return; + + case "user-interaction-active": + this._startTargetingSnapshottingTimer(); + Services.obs.removeObserver(this, "idle-daily"); + Services.obs.removeObserver(this, "user-interaction-active"); + lazy.log.debug( + `observe: ${topic}; started targeting snapshotting timer` + ); + return; + + case "nsPref:changed": + whatChanged = `per-profile pref ${data}`; + break; + + case "auto-update-config-change": + whatChanged = `per-installation pref app.update.auto`; + break; + + case "background-update-config-change": + whatChanged = `per-installation pref app.update.background.enabled`; + break; + + case "nimbus-update": + whatChanged = `Nimbus ${data}`; + break; + } + + lazy.log.debug( + `observe: ${whatChanged} may have changed; invoking maybeScheduleBackgroundUpdateTask` + ); + this.maybeScheduleBackgroundUpdateTask(); + }, + + /** + * Maybe schedule (or unschedule) background tasks using OS-level task scheduling mechanisms. + * + * @return {boolean} true if a task is now scheduled, false otherwise. + */ + async maybeScheduleBackgroundUpdateTask() { + let SLUG = "maybeScheduleBackgroundUpdateTask"; + + await this.ensureExperimentToRolloutTransitionPerformed(); + + lazy.log.info( + `${SLUG}: checking eligibility before scheduling background update task` + ); + + // datetime with an empty parameter records 'now' + Glean.backgroundUpdate.timeLastUpdateScheduled.set(); + + let previousEnabled; + let successfullyReadPrevious; + try { + previousEnabled = await lazy.TaskScheduler.taskExists(this.taskId); + successfullyReadPrevious = true; + } catch (ex) { + successfullyReadPrevious = false; + } + + const previousReasons = Services.prefs.getCharPref( + "app.update.background.previous.reasons", + null + ); + + if (!this._initialized) { + Services.obs.addObserver(this, "auto-update-config-change"); + Services.obs.addObserver(this, "background-update-config-change"); + + // Witness when our own prefs change. + Services.prefs.addObserver("app.update.background.force", this); + Services.prefs.addObserver("app.update.background.interval", this); + lazy.NimbusFeatures.backgroundUpdate.onUpdate((event, reason) => { + this.observe(null, "nimbus-update", reason); + }); + + // Witness when the langpack updating feature is changed. + Services.prefs.addObserver("app.update.langpack.enabled", this); + + // Witness when langpacks come and go. + const onAddonEvent = async addon => { + if (addon.type != "locale") { + return; + } + lazy.log.debug( + `${SLUG}: langpacks may have changed; invoking maybeScheduleBackgroundUpdateTask` + ); + // No need to await this promise. + this.maybeScheduleBackgroundUpdateTask(); + }; + const addonsListener = { + onEnabled: onAddonEvent, + onDisabled: onAddonEvent, + onInstalled: onAddonEvent, + onUninstalled: onAddonEvent, + }; + lazy.AddonManager.addAddonListener(addonsListener); + + this._initialized = true; + } + + lazy.log.debug( + `${SLUG}: checking for reasons to not update this installation` + ); + let reasons = await this._reasonsToNotUpdateInstallation(); + + lazy.log.debug( + `${SLUG}: checking for reasons to not schedule background updates with this profile` + ); + let moreReasons = await this._reasonsToNotScheduleUpdates(); + reasons.push(...moreReasons); + + let enabled = !reasons.length; + + if (this._force()) { + // We want to allow developers and testers to monkey with the system. + lazy.log.debug( + `${SLUG}: app.update.background.force=true, ignoring reasons: ${JSON.stringify( + reasons + )}` + ); + reasons = []; + enabled = true; + } + + let updatePreviousPrefs = () => { + if (reasons.length) { + Services.prefs.setCharPref( + "app.update.background.previous.reasons", + JSON.stringify(reasons) + ); + } else { + Services.prefs.clearUserPref("app.update.background.previous.reasons"); + } + }; + + try { + // Interacting with `TaskScheduler.jsm` can throw, so we'll catch. + if (!enabled) { + lazy.log.info( + `${SLUG}: not scheduling background update: '${JSON.stringify( + reasons + )}'` + ); + + if (!successfullyReadPrevious || previousEnabled) { + let installedVersion = Services.prefs.getIntPref( + TASK_INSTALLED_VERSION_PREF, + TASK_DEF_CURRENT_VERSION + ); + await deleteTasksInRange(installedVersion, TASK_DEF_CURRENT_VERSION); + lazy.log.debug( + `${SLUG}: witnessed falling (enabled -> disabled) edge; deleted task ${this.taskId}.` + ); + } + + updatePreviousPrefs(); + + return false; + } + + if (successfullyReadPrevious && previousEnabled) { + let taskInstalledVersion = Services.prefs.getIntPref( + TASK_INSTALLED_VERSION_PREF, + 1 + ); + if (taskInstalledVersion == TASK_DEF_CURRENT_VERSION) { + lazy.log.info( + `${SLUG}: background update was previously enabled; not registering task.` + ); + + return true; + } + lazy.log.info( + `${SLUG}: Detected task version change from ` + + `${taskInstalledVersion} to ${TASK_DEF_CURRENT_VERSION}. ` + + `Removing task so the new version can be registered` + ); + try { + let installedVersion = Services.prefs.getIntPref( + TASK_INSTALLED_VERSION_PREF, + TASK_DEF_CURRENT_VERSION + ); + await deleteTasksInRange(installedVersion, TASK_DEF_CURRENT_VERSION); + } catch (e) { + lazy.log.error(`${SLUG}: Error removing old task: ${e}`); + } + try { + // When the update directory was moved, we migrated the old contents + // to the new location. This can potentially happen in a background + // task. However, we also need to re-register the background task + // with the task scheduler in order to update the MOZ_LOG_FILE value + // to point to the new location. If the task runs before Firefox has + // a chance to re-register the task, the log file may be recreated in + // the old location. In practice, this would be unusual, because + // MOZ_LOG_FILE will not create the parent directories necessary to + // put a log file in the specified location. But just to be safe, + // we'll do some cleanup when we re-register the task to make sure + // that no log file is hanging around in the old location. + let oldUpdateDir = Services.dirsvc.get( + "OldUpdRootD", + Ci.nsIFile + ).path; + let oldLog = PathUtils.join(oldUpdateDir, "backgroundupdate.moz_log"); + + if (await IOUtils.exists(oldLog)) { + try { + await IOUtils.remove(oldLog); + // We may have created some directories in order to put this log + // file in this location. Clean them up if they are empty. + // + // Potentially removes "C:\ProgramData\Mozilla\updates\<hash>" + await IOUtils.remove(oldUpdateDir); + // Potentially removes "C:\ProgramData\Mozilla\updates" + await IOUtils.remove(PathUtils.parent(oldUpdateDir)); + // Potentially removes "C:\ProgramData\Mozilla" + await IOUtils.remove(PathUtils.parent(oldUpdateDir, 2)); + } catch (e) { + if ( + !( + DOMException.isInstance(e) && + e.name === "OperationError" && + e.message.includes( + "Could not remove the non-empty directory at" + ) + ) + ) { + throw e; + } + } + } + } catch (ex) { + lazy.log.warn( + `${SLUG}: Ignoring error encountered attempting to remove stale log file: ${ex}` + ); + } + } + + lazy.log.info( + `${SLUG}: background update was previously disabled for reasons: '${previousReasons}'` + ); + + await this._registerBackgroundUpdateTask(this.taskId); + lazy.log.info( + `${SLUG}: witnessed rising (disabled -> enabled) edge; registered task ${this.taskId}` + ); + + updatePreviousPrefs(); + + return true; + } catch (e) { + lazy.log.error( + `${SLUG}: exiting after uncaught exception in maybeScheduleBackgroundUpdateTask!`, + e + ); + + return false; + } + }, + + /** + * Record parts of the update environment for our custom Glean ping. + * + * This is just like the Telemetry Environment, but pared down to what we're + * likely to use in background update-specific analyses. + * + * Right now this is only for use in the background update task, but after Bug + * 1703313 (migrating AUS telemetry to be Glean-aware) we might use it more + * generally. + */ + async recordUpdateEnvironment() { + try { + Glean.update.serviceEnabled.set( + Services.prefs.getBoolPref("app.update.service.enabled", false) + ); + } catch (e) { + // It's fine if some or all of these are missing. + } + + // In the background update task, this should always be enabled, but let's + // find out if there's an error in the system. + Glean.update.autoDownload.set( + await lazy.UpdateUtils.getAppUpdateAutoEnabled() + ); + Glean.update.backgroundUpdate.set( + await lazy.UpdateUtils.readUpdateConfigSetting( + "app.update.background.enabled" + ) + ); + + Glean.update.channel.set(lazy.UpdateUtils.UpdateChannel); + Glean.update.enabled.set( + !Services.policies || Services.policies.isAllowed("appUpdate") + ); + + Glean.update.canUsuallyApplyUpdates.set( + lazy.UpdateService.canUsuallyApplyUpdates + ); + Glean.update.canUsuallyCheckForUpdates.set( + lazy.UpdateService.canUsuallyCheckForUpdates + ); + Glean.update.canUsuallyStageUpdates.set( + lazy.UpdateService.canUsuallyStageUpdates + ); + Glean.update.canUsuallyUseBits.set(lazy.UpdateService.canUsuallyUseBits); + }, + + /** + * Schedule periodic snapshotting of the Firefox Messaging System + * targeting configuration. + * + * The background update task will target messages based on the + * latest snapshot of the default profile's targeting configuration. + */ + async scheduleFirefoxMessagingSystemTargetingSnapshotting() { + let SLUG = "scheduleFirefoxMessagingSystemTargetingSnapshotting"; + let path = PathUtils.join(PathUtils.profileDir, "targeting.snapshot.json"); + + let snapshot = new lazy.JSONFile({ + beforeSave: async () => { + if (Services.startup.shuttingDown) { + // Collecting targeting information can be slow and cause shutdown + // crashes. Just write what we have in that case. During shutdown, + // the regular log apparatus is not available, so use `dump`. + if (lazy.log.shouldLog("debug")) { + dump( + `${SLUG}: shutting down, so not updating Firefox Messaging System targeting information from beforeSave\n` + ); + } + return; + } + + lazy.log.debug( + `${SLUG}: preparing to write Firefox Messaging System targeting information to ${path}` + ); + + // Merge latest data into existing data. This data may be partial, due + // to runtime errors and abbreviated collections, especially when + // shutting down. We accept the risk of incomplete or even internally + // inconsistent data: it's generally better to have stale data (and + // potentially target a user as they appeared in the past) than to block + // shutdown for more accurate results. An alternate approach would be + // to restrict the targeting data collected, but it's hard to + // distinguish expensive collection operations and the system loses + // flexibility when restrictions of this type are added. + let latestData = await lazy.ASRouterTargeting.getEnvironmentSnapshot({ + targets: [ + lazy.ExperimentManager.createTargetingContext(), + lazy.ASRouterTargeting.Environment, + ], + }); + // We expect to always have data, but: belt-and-braces. + if (snapshot?.data?.environment) { + Object.assign(snapshot.data.environment, latestData.environment); + } else { + snapshot.data = latestData; + } + }, + path, + }); + + // We don't `load`, since we don't care about reading existing (now stale) + // data. + snapshot.data = await lazy.ASRouterTargeting.getEnvironmentSnapshot( + lazy.ASRouterTargeting.Environment, + lazy.ExperimentManager.createTargetingContext() + ); + + // Persist. + snapshot.saveSoon(); + + this._snapshot = snapshot; + + // Continue persisting periodically. `JSONFile.sys.mjs` will also persist one + // last time before shutdown. + // Hold a reference to prevent GC. + this._targetingSnapshottingTimer = Cc[ + "@mozilla.org/timer;1" + ].createInstance(Ci.nsITimer); + // By default, snapshot Firefox Messaging System targeting for use by the + // background update task every 60 minutes. + this._targetingSnapshottingTimerIntervalSec = Services.prefs.getIntPref( + "app.update.background.messaging.targeting.snapshot.intervalSec", + 3600 + ); + this._startTargetingSnapshottingTimer(); + }, + + // nsITimerCallback + notify() { + const SLUG = "_targetingSnapshottingTimerCallback"; + + if (Services.startup.shuttingDown) { + // Collecting targeting information can be slow and cause shutdown + // crashes, so if we're shutting down, don't try to collect. During + // shutdown, the regular log apparatus is not available, so use `dump`. + if (lazy.log.shouldLog("debug")) { + dump( + `${SLUG}: shutting down, so not updating Firefox Messaging System targeting information from timer\n` + ); + } + return; + } + + this._snapshot.saveSoon(); + + if ( + lazy.idleService.idleTime > + this._targetingSnapshottingTimerIntervalSec * 1000 + ) { + lazy.log.debug( + `${SLUG}: idle time longer than interval, adding observers` + ); + Services.obs.addObserver(this, "idle-daily"); + Services.obs.addObserver(this, "user-interaction-active"); + } else { + lazy.log.debug(`${SLUG}: restarting timer`); + this._startTargetingSnapshottingTimer(); + } + }, + + _startTargetingSnapshottingTimer() { + this._targetingSnapshottingTimer.initWithCallback( + this, + this._targetingSnapshottingTimerIntervalSec * 1000, + Ci.nsITimer.TYPE_ONE_SHOT + ); + }, + + /** + * Reads the snapshotted Firefox Messaging System targeting out of a profile. + * Collects background update specific telemetry. Never throws. + * + * If no `lock` is given, the default profile is locked and the preferences + * read from it. If `lock` is given, read from the given lock's directory. + * + * @param {nsIProfileLock} [lock] optional lock to use + * @returns {object} possibly empty targeting snapshot. + */ + async readFirefoxMessagingSystemTargetingSnapshot(lock = null) { + let SLUG = "readFirefoxMessagingSystemTargetingSnapshot"; + + let defaultProfileTargetingSnapshot = {}; + + Glean.backgroundUpdate.targetingExists.set(false); + Glean.backgroundUpdate.targetingException.set(true); + try { + defaultProfileTargetingSnapshot = + await lazy.BackgroundTasksUtils.readFirefoxMessagingSystemTargetingSnapshot( + lock + ); + Glean.backgroundUpdate.targetingExists.set(true); + Glean.backgroundUpdate.targetingException.set(false); + + if (defaultProfileTargetingSnapshot?.version) { + Glean.backgroundUpdate.targetingVersion.set( + defaultProfileTargetingSnapshot.version + ); + } + + let environment = defaultProfileTargetingSnapshot?.environment; + if (environment) { + if (environment.firefoxVersion) { + Glean.backgroundUpdate.targetingEnvFirefoxVersion.set( + environment.firefoxVersion + ); + } + if (environment.currentDate) { + Glean.backgroundUpdate.targetingEnvCurrentDate.set( + // Glean date times are provided in nanoseconds, `getTime()` yields + // milliseconds (after the Unix epoch). + new Date(environment.currentDate).getTime() * 1000 + ); + } + if (environment.profileAgeCreated) { + Glean.backgroundUpdate.targetingEnvProfileAge.set( + // Glean date times are provided in nanoseconds, `profileAgeCreated` + // is in milliseconds (after the Unix epoch). + environment.profileAgeCreated * 1000 + ); + } + + // Experiment details. + let activeExperiments = ( + environment.activeExperiments || [] + ).toSorted(); + let activeRollouts = (environment.activeRollouts || []).toSorted(); + let previousExperiments = ( + environment.previousExperiments || [] + ).toSorted(); + let previousRollouts = (environment.previousRollouts || []).toSorted(); + + // Add default profile experiments to background task profile Glean experiments. + for (let slug of Object.keys(environment.enrollmentsMap || [])) { + let branch = environment.enrollmentsMap[slug]; + let source = "defaultProfile"; + + // Experiments have type "nimbus-nimbus", rollouts type "nimbus-rollout". + let type; + if ( + activeExperiments.includes(slug) || + previousExperiments.includes(slug) + ) { + type = "nimbus-nimbus"; + } else if ( + activeRollouts.includes(slug) || + previousRollouts.includes(slug) + ) { + type = "nimbus-rollout"; + } else { + // This shouldn't happen, but it's not worth failing. + lazy.log.warn( + `${SLUG}: enrollment not recognized as experiment or rollout: '${slug}'` + ); + type = "nimbus-unexpected"; + } + + let extras = { type, source }; + Services.fog.setExperimentActive(slug, branch, extras); + + if ( + previousExperiments.includes(slug) || + previousRollouts.includes(slug) + ) { + Services.fog.setExperimentInactive(slug, branch, extras); + } + } + } + } catch (f) { + if (DOMException.isInstance(f) && f.name === "NotFoundError") { + Glean.backgroundUpdate.targetingException.set(false); + lazy.log.info(`${SLUG}: no default profile targeting snapshot exists`); + } else { + lazy.log.warn( + `${SLUG}: ignoring exception reading default profile targeting snapshot`, + f + ); + } + } + + return defaultProfileTargetingSnapshot; + }, + + /** + * Local helper function to record all reasons why the background updater is + * not used with Glean. This function will only track the first 20 reasons. + * It is also fault tolerant and will only display debug messages if the + * metric cannot be recorded for any reason. + * + * @param {array of strings} [reasons] + * a list of BackgroundUpdate.REASON values (=> string) + */ + async _recordGleanMetrics(reasons) { + // Record Glean metrics with all the reasons why the update was impossible. + for (const [key, value] of Object.entries(this.REASON)) { + if (reasons.includes(value)) { + try { + // `testGetValue` throws a `DataError` in case + // of `InvalidOverflow` and other outstanding errors. + Glean.backgroundUpdate.reasonsToNotUpdate.testGetValue(); + Glean.backgroundUpdate.reasonsToNotUpdate.add(key); + } catch (e) { + // Debug print an error message and break the loop to avoid Glean + // messages on the console would otherwise be caused by the add(). + lazy.log.debug("Error recording reasonsToNotUpdate"); + console.log("Error recording reasonsToNotUpdate"); + break; + } + } + } + }, +}; + +BackgroundUpdate.REASON = { + CANNOT_USUALLY_CHECK: + "cannot usually check for updates due to policy, testing configuration, or runtime environment", + CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY: + "updates cannot usually stage and cannot usually apply", + LANGPACK_INSTALLED: + "app.update.langpack.enabled=true and at least one langpack is installed", + MANUAL_UPDATE_ONLY: "the ManualAppUpdateOnly policy is enabled", + NO_DEFAULT_PROFILE_EXISTS: "no default profile exists", + NOT_DEFAULT_PROFILE: "not default profile", + NO_APP_UPDATE_AUTO: "app.update.auto=false", + NO_APP_UPDATE_BACKGROUND_ENABLED: "app.update.background.enabled=false", + NO_MOZ_BACKGROUNDTASKS: "MOZ_BACKGROUNDTASKS=0", + NO_OMNIJAR: "no omnijar", + SERVICE_REGISTRY_KEY_MISSING: + "the maintenance service registry key is not present", + UNSUPPORTED_OS: "unsupported OS", + WINDOWS_CANNOT_USUALLY_USE_BITS: "on Windows but cannot usually use BITS", + APPBASEDIR_NOT_WRITABLE: "the base directory is not writable", +}; + +/** + * Specific exit codes for `--backgroundtask backgroundupdate`. + * + * These help distinguish common failure cases. In particular, they distinguish + * "default profile does not exist" from "default profile cannot be locked" from + * more general errors reading from the default profile. + */ +BackgroundUpdate.EXIT_CODE = { + ...EXIT_CODE, + // We clone the other exit codes simply so we can use one object for all the codes. + DEFAULT_PROFILE_DOES_NOT_EXIST: 11, + DEFAULT_PROFILE_CANNOT_BE_LOCKED: 12, + DEFAULT_PROFILE_CANNOT_BE_READ: 13, + // Another instance is running. + OTHER_INSTANCE: 21, +}; |