diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/mozapps/update/UpdateService.sys.mjs | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | toolkit/mozapps/update/UpdateService.sys.mjs | 7229 |
1 files changed, 7229 insertions, 0 deletions
diff --git a/toolkit/mozapps/update/UpdateService.sys.mjs b/toolkit/mozapps/update/UpdateService.sys.mjs new file mode 100644 index 0000000000..5e9caf090e --- /dev/null +++ b/toolkit/mozapps/update/UpdateService.sys.mjs @@ -0,0 +1,7229 @@ +/* -*- Mode: javascript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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 { AUSTLMY } from "resource://gre/modules/UpdateTelemetry.sys.mjs"; + +import { + Bits, + BitsRequest, + BitsUnknownError, + BitsVerificationError, +} from "resource://gre/modules/Bits.sys.mjs"; +import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + CertUtils: "resource://gre/modules/CertUtils.sys.mjs", + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", + WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs", + ctypes: "resource://gre/modules/ctypes.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "AUS", + "@mozilla.org/updates/update-service;1", + "nsIApplicationUpdateService" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "UM", + "@mozilla.org/updates/update-manager;1", + "nsIUpdateManager" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "CheckSvc", + "@mozilla.org/updates/update-checker;1", + "nsIUpdateChecker" +); + +if (AppConstants.ENABLE_WEBDRIVER) { + XPCOMUtils.defineLazyServiceGetter( + lazy, + "Marionette", + "@mozilla.org/remote/marionette;1", + "nsIMarionette" + ); + + XPCOMUtils.defineLazyServiceGetter( + lazy, + "RemoteAgent", + "@mozilla.org/remote/agent;1", + "nsIRemoteAgent" + ); +} else { + lazy.Marionette = { running: false }; + lazy.RemoteAgent = { running: false }; +} + +const UPDATESERVICE_CID = Components.ID( + "{B3C290A6-3943-4B89-8BBE-C01EB7B3B311}" +); + +const PREF_APP_UPDATE_ALTUPDATEDIRPATH = "app.update.altUpdateDirPath"; +const PREF_APP_UPDATE_BACKGROUNDERRORS = "app.update.backgroundErrors"; +const PREF_APP_UPDATE_BACKGROUNDMAXERRORS = "app.update.backgroundMaxErrors"; +const PREF_APP_UPDATE_BITS_ENABLED = "app.update.BITS.enabled"; +const PREF_APP_UPDATE_CANCELATIONS = "app.update.cancelations"; +const PREF_APP_UPDATE_CANCELATIONS_OSX = "app.update.cancelations.osx"; +const PREF_APP_UPDATE_CANCELATIONS_OSX_MAX = "app.update.cancelations.osx.max"; +const PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_ENABLED = + "app.update.checkOnlyInstance.enabled"; +const PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_INTERVAL = + "app.update.checkOnlyInstance.interval"; +const PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_TIMEOUT = + "app.update.checkOnlyInstance.timeout"; +const PREF_APP_UPDATE_DISABLEDFORTESTING = "app.update.disabledForTesting"; +const PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS = "app.update.download.attempts"; +const PREF_APP_UPDATE_DOWNLOAD_MAXATTEMPTS = "app.update.download.maxAttempts"; +const PREF_APP_UPDATE_ELEVATE_NEVER = "app.update.elevate.never"; +const PREF_APP_UPDATE_ELEVATE_VERSION = "app.update.elevate.version"; +const PREF_APP_UPDATE_ELEVATE_ATTEMPTS = "app.update.elevate.attempts"; +const PREF_APP_UPDATE_ELEVATE_MAXATTEMPTS = "app.update.elevate.maxAttempts"; +const PREF_APP_UPDATE_LANGPACK_ENABLED = "app.update.langpack.enabled"; +const PREF_APP_UPDATE_LANGPACK_TIMEOUT = "app.update.langpack.timeout"; +const PREF_APP_UPDATE_LOG = "app.update.log"; +const PREF_APP_UPDATE_LOG_FILE = "app.update.log.file"; +const PREF_APP_UPDATE_NOTIFYDURINGDOWNLOAD = "app.update.notifyDuringDownload"; +const PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED = + "app.update.noWindowAutoRestart.enabled"; +const PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_DELAY_MS = + "app.update.noWindowAutoRestart.delayMs"; +const PREF_APP_UPDATE_PROMPTWAITTIME = "app.update.promptWaitTime"; +const PREF_APP_UPDATE_SERVICE_ENABLED = "app.update.service.enabled"; +const PREF_APP_UPDATE_SERVICE_ERRORS = "app.update.service.errors"; +const PREF_APP_UPDATE_SERVICE_MAXERRORS = "app.update.service.maxErrors"; +const PREF_APP_UPDATE_SOCKET_MAXERRORS = "app.update.socket.maxErrors"; +const PREF_APP_UPDATE_SOCKET_RETRYTIMEOUT = "app.update.socket.retryTimeout"; +const PREF_APP_UPDATE_STAGING_ENABLED = "app.update.staging.enabled"; +const PREF_APP_UPDATE_URL_DETAILS = "app.update.url.details"; +const PREF_NETWORK_PROXY_TYPE = "network.proxy.type"; + +const URI_BRAND_PROPERTIES = "chrome://branding/locale/brand.properties"; +const URI_UPDATE_NS = "http://www.mozilla.org/2005/app-update"; +const URI_UPDATES_PROPERTIES = + "chrome://mozapps/locale/update/updates.properties"; + +const KEY_EXECUTABLE = "XREExeF"; +const KEY_PROFILE_DIR = "ProfD"; +const KEY_UPDROOT = "UpdRootD"; +const KEY_OLD_UPDROOT = "OldUpdRootD"; + +const DIR_UPDATES = "updates"; +const DIR_UPDATE_READY = "0"; +const DIR_UPDATE_DOWNLOADING = "downloading"; + +const FILE_ACTIVE_UPDATE_XML = "active-update.xml"; +const FILE_BACKUP_UPDATE_LOG = "backup-update.log"; +const FILE_BT_RESULT = "bt.result"; +const FILE_LAST_UPDATE_LOG = "last-update.log"; +const FILE_UPDATES_XML = "updates.xml"; +const FILE_UPDATE_LOG = "update.log"; +const FILE_UPDATE_MAR = "update.mar"; +const FILE_UPDATE_STATUS = "update.status"; +const FILE_UPDATE_TEST = "update.test"; +const FILE_UPDATE_VERSION = "update.version"; +const FILE_UPDATE_MESSAGES = "update_messages.log"; + +const STATE_NONE = "null"; +const STATE_DOWNLOADING = "downloading"; +const STATE_PENDING = "pending"; +const STATE_PENDING_SERVICE = "pending-service"; +const STATE_PENDING_ELEVATE = "pending-elevate"; +const STATE_APPLYING = "applying"; +const STATE_APPLIED = "applied"; +const STATE_APPLIED_SERVICE = "applied-service"; +const STATE_SUCCEEDED = "succeeded"; +const STATE_DOWNLOAD_FAILED = "download-failed"; +const STATE_FAILED = "failed"; + +// BITS will keep retrying a download after transient errors, unless this much +// time has passed since there has been download progress. +// Similarly to ...POLL_RATE_MS below, we are much more aggressive when the user +// is watching the download progress. +const BITS_IDLE_NO_PROGRESS_TIMEOUT_SECS = 3600; // 1 hour +const BITS_ACTIVE_NO_PROGRESS_TIMEOUT_SECS = 5; + +// These value control how frequently we get updates from the BITS client on +// the progress made downloading. The difference between the two is that the +// active interval is the one used when the user is watching. The idle interval +// is the one used when no one is watching. +const BITS_IDLE_POLL_RATE_MS = 1000; +const BITS_ACTIVE_POLL_RATE_MS = 200; + +// The values below used by this code are from common/updatererrors.h +const WRITE_ERROR = 7; +const ELEVATION_CANCELED = 9; +const SERVICE_UPDATER_COULD_NOT_BE_STARTED = 24; +const SERVICE_NOT_ENOUGH_COMMAND_LINE_ARGS = 25; +const SERVICE_UPDATER_SIGN_ERROR = 26; +const SERVICE_UPDATER_COMPARE_ERROR = 27; +const SERVICE_UPDATER_IDENTITY_ERROR = 28; +const SERVICE_STILL_APPLYING_ON_SUCCESS = 29; +const SERVICE_STILL_APPLYING_ON_FAILURE = 30; +const SERVICE_UPDATER_NOT_FIXED_DRIVE = 31; +const SERVICE_COULD_NOT_LOCK_UPDATER = 32; +const SERVICE_INSTALLDIR_ERROR = 33; +const WRITE_ERROR_ACCESS_DENIED = 35; +const WRITE_ERROR_CALLBACK_APP = 37; +const UNEXPECTED_STAGING_ERROR = 43; +const DELETE_ERROR_STAGING_LOCK_FILE = 44; +const SERVICE_COULD_NOT_COPY_UPDATER = 49; +const SERVICE_STILL_APPLYING_TERMINATED = 50; +const SERVICE_STILL_APPLYING_NO_EXIT_CODE = 51; +const SERVICE_COULD_NOT_IMPERSONATE = 58; +const WRITE_ERROR_FILE_COPY = 61; +const WRITE_ERROR_DELETE_FILE = 62; +const WRITE_ERROR_OPEN_PATCH_FILE = 63; +const WRITE_ERROR_PATCH_FILE = 64; +const WRITE_ERROR_APPLY_DIR_PATH = 65; +const WRITE_ERROR_CALLBACK_PATH = 66; +const WRITE_ERROR_FILE_ACCESS_DENIED = 67; +const WRITE_ERROR_DIR_ACCESS_DENIED = 68; +const WRITE_ERROR_DELETE_BACKUP = 69; +const WRITE_ERROR_EXTRACT = 70; + +// Error codes 80 through 99 are reserved for UpdateService.jsm and are not +// defined in common/updatererrors.h +const ERR_UPDATER_CRASHED = 89; +const ERR_OLDER_VERSION_OR_SAME_BUILD = 90; +const ERR_UPDATE_STATE_NONE = 91; +const ERR_CHANNEL_CHANGE = 92; +const INVALID_UPDATER_STATE_CODE = 98; +const INVALID_UPDATER_STATUS_CODE = 99; + +const SILENT_UPDATE_NEEDED_ELEVATION_ERROR = 105; +const WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION = 106; + +// Array of write errors to simplify checks for write errors +const WRITE_ERRORS = [ + WRITE_ERROR, + WRITE_ERROR_ACCESS_DENIED, + WRITE_ERROR_CALLBACK_APP, + WRITE_ERROR_FILE_COPY, + WRITE_ERROR_DELETE_FILE, + WRITE_ERROR_OPEN_PATCH_FILE, + WRITE_ERROR_PATCH_FILE, + WRITE_ERROR_APPLY_DIR_PATH, + WRITE_ERROR_CALLBACK_PATH, + WRITE_ERROR_FILE_ACCESS_DENIED, + WRITE_ERROR_DIR_ACCESS_DENIED, + WRITE_ERROR_DELETE_BACKUP, + WRITE_ERROR_EXTRACT, + WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION, +]; + +// Array of write errors to simplify checks for service errors +const SERVICE_ERRORS = [ + SERVICE_UPDATER_COULD_NOT_BE_STARTED, + SERVICE_NOT_ENOUGH_COMMAND_LINE_ARGS, + SERVICE_UPDATER_SIGN_ERROR, + SERVICE_UPDATER_COMPARE_ERROR, + SERVICE_UPDATER_IDENTITY_ERROR, + SERVICE_STILL_APPLYING_ON_SUCCESS, + SERVICE_STILL_APPLYING_ON_FAILURE, + SERVICE_UPDATER_NOT_FIXED_DRIVE, + SERVICE_COULD_NOT_LOCK_UPDATER, + SERVICE_INSTALLDIR_ERROR, + SERVICE_COULD_NOT_COPY_UPDATER, + SERVICE_STILL_APPLYING_TERMINATED, + SERVICE_STILL_APPLYING_NO_EXIT_CODE, + SERVICE_COULD_NOT_IMPERSONATE, +]; + +// Custom update error codes +const BACKGROUNDCHECK_MULTIPLE_FAILURES = 110; +const NETWORK_ERROR_OFFLINE = 111; + +// Error codes should be < 1000. Errors above 1000 represent http status codes +const HTTP_ERROR_OFFSET = 1000; + +// The is an HRESULT error that may be returned from the BITS interface +// indicating that access was denied. Often, this error code is returned when +// attempting to access a job created by a different user. +const HRESULT_E_ACCESSDENIED = -2147024891; + +const DOWNLOAD_CHUNK_SIZE = 300000; // bytes + +// The number of consecutive failures when updating using the service before +// setting the app.update.service.enabled preference to false. +const DEFAULT_SERVICE_MAX_ERRORS = 10; + +// The number of consecutive socket errors to allow before falling back to +// downloading a different MAR file or failing if already downloading the full. +const DEFAULT_SOCKET_MAX_ERRORS = 10; + +// The number of milliseconds to wait before retrying a connection error. +const DEFAULT_SOCKET_RETRYTIMEOUT = 2000; + +// Default maximum number of elevation cancelations per update version before +// giving up. +const DEFAULT_CANCELATIONS_OSX_MAX = 3; + +// This maps app IDs to their respective notification topic which signals when +// the application's user interface has been displayed. +const APPID_TO_TOPIC = { + // Firefox + "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}": "sessionstore-windows-restored", + // SeaMonkey + "{92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}": "sessionstore-windows-restored", + // Thunderbird + "{3550f703-e582-4d05-9a08-453d09bdfdc6}": "mail-startup-done", +}; + +// The interval for the update xml write deferred task. +const XML_SAVER_INTERVAL_MS = 200; + +// How long after a patch has downloaded should we wait for language packs to +// update before proceeding anyway. +const LANGPACK_UPDATE_DEFAULT_TIMEOUT = 300000; + +// Interval between rechecks for other instances after the initial check finds +// at least one other instance. +const ONLY_INSTANCE_CHECK_DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +// Wait this long after detecting that another instance is running (having been +// polling that entire time) before giving up and applying the update anyway. +const ONLY_INSTANCE_CHECK_DEFAULT_TIMEOUT_MS = 6 * 60 * 60 * 1000; // 6 hours + +// The other instance check timeout can be overridden via a pref, but we limit +// that value to this so that the pref can't effectively disable the feature. +const ONLY_INSTANCE_CHECK_MAX_TIMEOUT_MS = 2 * 24 * 60 * 60 * 1000; // 2 days + +// Values to use when polling for staging. See `pollForStagingEnd` for more +// details. +const STAGING_POLLING_MIN_INTERVAL_MS = 15 * 1000; // 15 seconds +const STAGING_POLLING_MAX_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes +const STAGING_POLLING_ATTEMPTS_PER_INTERVAL = 5; +const STAGING_POLLING_MAX_DURATION_MS = 1 * 60 * 60 * 1000; // 1 hour + +var gUpdateMutexHandle = null; +// This is the file stream used for the log file. +var gLogfileOutputStream; +// This value will be set to true if it appears that BITS is being used by +// another user to download updates. We don't really want two users using BITS +// at once. Computers with many users (ex: a school computer), should not end +// up with dozens of BITS jobs. +var gBITSInUseByAnotherUser = false; +// The update service can be invoked as part of a standalone headless background +// task. In this context, when the background task kicks off an update +// download, we don't want it to move on to staging. As soon as the download has +// kicked off, the task begins shutting down and, even if the the download +// completes incredibly quickly, we don't want staging to begin while we are +// shutting down. That isn't a well tested scenario and it's possible that it +// could leave us in a bad state. +let gOnlyDownloadUpdatesThisSession = false; +// This will be the backing for `nsIApplicationUpdateService.currentState` +var gUpdateState = Ci.nsIApplicationUpdateService.STATE_IDLE; + +/** + * Simple container and constructor for a Promise and its resolve function. + */ +class SelfContainedPromise { + constructor() { + this.promise = new Promise(resolve => { + this.resolve = resolve; + }); + } +} + +// This will contain a `SelfContainedPromise` that will be used to back +// `nsIApplicationUpdateService.stateTransition`. +var gStateTransitionPromise = new SelfContainedPromise(); + +XPCOMUtils.defineLazyGetter(lazy, "gLogEnabled", function aus_gLogEnabled() { + return ( + Services.prefs.getBoolPref(PREF_APP_UPDATE_LOG, false) || + Services.prefs.getBoolPref(PREF_APP_UPDATE_LOG_FILE, false) + ); +}); + +XPCOMUtils.defineLazyGetter( + lazy, + "gLogfileEnabled", + function aus_gLogfileEnabled() { + return Services.prefs.getBoolPref(PREF_APP_UPDATE_LOG_FILE, false); + } +); + +XPCOMUtils.defineLazyGetter( + lazy, + "gUpdateBundle", + function aus_gUpdateBundle() { + return Services.strings.createBundle(URI_UPDATES_PROPERTIES); + } +); + +/** + * gIsBackgroundTaskMode will be true if Firefox is currently running as a + * background task. Otherwise it will be false. + */ +XPCOMUtils.defineLazyGetter( + lazy, + "gIsBackgroundTaskMode", + function aus_gCurrentlyRunningAsBackgroundTask() { + if (!("@mozilla.org/backgroundtasks;1" in Cc)) { + return false; + } + const bts = Cc["@mozilla.org/backgroundtasks;1"].getService( + Ci.nsIBackgroundTasks + ); + if (!bts) { + return false; + } + return bts.isBackgroundTaskMode; + } +); + +/** + * Changes `nsIApplicationUpdateService.currentState` and causes + * `nsIApplicationUpdateService.stateTransition` to resolve. + */ +function transitionState(newState) { + if (newState == gUpdateState) { + LOG("transitionState - Not transitioning state because it isn't changing."); + return; + } + LOG( + `transitionState - "${lazy.AUS.getStateName(gUpdateState)}" -> ` + + `"${lazy.AUS.getStateName(newState)}".` + ); + gUpdateState = newState; + // Assign the new Promise before we resolve the old one just to make sure that + // anything that runs as a result of `resolve` doesn't end up waiting on the + // Promise that already resolved. + let oldStateTransitionPromise = gStateTransitionPromise; + gStateTransitionPromise = new SelfContainedPromise(); + oldStateTransitionPromise.resolve(); +} + +/** + * When a plain JS object is passed through xpconnect the other side sees a + * wrapped version of the object instead of the real object. Since these two + * objects are different they act as different keys for Map and WeakMap. However + * xpconnect gives us a way to get the underlying JS object from the wrapper so + * this function returns the JS object regardless of whether passed the JS + * object or its wrapper for use in places where it is unclear which one you + * have. + */ +function unwrap(obj) { + return obj.wrappedJSObject ?? obj; +} + +/** + * When an update starts to download (and if the feature is enabled) the add-ons + * manager starts downloading updated language packs for the new application + * version. A promise is used to track whether those updates are complete so the + * front-end is only notified that an application update is ready once the + * language pack updates have been staged. + * + * In order to be able to access that promise from various places in the update + * service they are cached in this map using the nsIUpdate object as a weak + * owner. Note that the key should always be the result of calling the above + * unwrap function on the nsIUpdate to ensure a consistent object is used as the + * key. + * + * When the language packs finish staging the nsIUpdate entriy is removed from + * this map so if the entry is still there then language pack updates are in + * progress. + */ +const LangPackUpdates = new WeakMap(); + +/** + * When we're polling to see if other running instances of the application have + * exited, there's no need to ever start polling again in parallel. To prevent + * doing that, we keep track of the promise that resolves when polling completes + * and return that if a second simultaneous poll is requested, so that the + * multiple callers end up waiting for the same promise to resolve. + */ +let gOtherInstancePollPromise; + +/** + * Query the update sync manager to see if another instance of this same + * installation of this application is currently running, under the context of + * any operating system user (not just the current one). + * This function immediately returns the current, instantaneous status of any + * other instances. + * + * @return true if at least one other instance is running, false if not + */ +function isOtherInstanceRunning(callback) { + const checkEnabled = Services.prefs.getBoolPref( + PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_ENABLED, + true + ); + if (!checkEnabled) { + LOG("isOtherInstanceRunning - disabled by pref, skipping check"); + return false; + } + + try { + let syncManager = Cc[ + "@mozilla.org/updates/update-sync-manager;1" + ].getService(Ci.nsIUpdateSyncManager); + return syncManager.isOtherInstanceRunning(); + } catch (ex) { + LOG(`isOtherInstanceRunning - sync manager failed with exception: ${ex}`); + return false; + } +} + +/** + * Query the update sync manager to see if another instance of this same + * installation of this application is currently running, under the context of + * any operating system user (not just the one running this instance). + * This function polls for the status of other instances continually + * (asynchronously) until either none exist or a timeout expires. + * + * @return a Promise that resolves with false if at any point during polling no + * other instances can be found, or resolves with true if the timeout + * expires when other instances are still running + */ +function waitForOtherInstances() { + // If we're already in the middle of a poll, reuse it rather than start again. + if (gOtherInstancePollPromise) { + return gOtherInstancePollPromise; + } + + let timeout = Services.prefs.getIntPref( + PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_TIMEOUT, + ONLY_INSTANCE_CHECK_DEFAULT_TIMEOUT_MS + ); + // Don't allow the pref to set a super high timeout and break this feature. + if (timeout > ONLY_INSTANCE_CHECK_MAX_TIMEOUT_MS) { + timeout = ONLY_INSTANCE_CHECK_MAX_TIMEOUT_MS; + } + + let interval = Services.prefs.getIntPref( + PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_INTERVAL, + ONLY_INSTANCE_CHECK_DEFAULT_POLL_INTERVAL_MS + ); + // Don't allow an interval longer than the timeout. + interval = Math.min(interval, timeout); + + let iterations = 0; + const maxIterations = Math.ceil(timeout / interval); + + gOtherInstancePollPromise = new Promise(function (resolve, reject) { + let poll = function () { + iterations++; + if (!isOtherInstanceRunning()) { + LOG("waitForOtherInstances - no other instances found, exiting"); + resolve(false); + gOtherInstancePollPromise = undefined; + } else if (iterations >= maxIterations) { + LOG( + "waitForOtherInstances - timeout expired while other instances " + + "are still running" + ); + resolve(true); + gOtherInstancePollPromise = undefined; + } else if (iterations + 1 == maxIterations && timeout % interval != 0) { + // In case timeout isn't a multiple of interval, set the next timeout + // for the remainder of the time rather than for the usual interval. + lazy.setTimeout(poll, timeout % interval); + } else { + lazy.setTimeout(poll, interval); + } + }; + + LOG("waitForOtherInstances - beginning polling"); + poll(); + }); + + return gOtherInstancePollPromise; +} + +/** + * Tests to make sure that we can write to a given directory. + * + * @param updateTestFile a test file in the directory that needs to be tested. + * @param createDirectory whether a test directory should be created. + * @throws if we don't have right access to the directory. + */ +function testWriteAccess(updateTestFile, createDirectory) { + const NORMAL_FILE_TYPE = Ci.nsIFile.NORMAL_FILE_TYPE; + const DIRECTORY_TYPE = Ci.nsIFile.DIRECTORY_TYPE; + if (updateTestFile.exists()) { + updateTestFile.remove(false); + } + updateTestFile.create( + createDirectory ? DIRECTORY_TYPE : NORMAL_FILE_TYPE, + createDirectory ? FileUtils.PERMS_DIRECTORY : FileUtils.PERMS_FILE + ); + updateTestFile.remove(false); +} + +/** + * Windows only function that closes a Win32 handle. + * + * @param handle The handle to close + */ +function closeHandle(handle) { + if (handle) { + let lib = lazy.ctypes.open("kernel32.dll"); + let CloseHandle = lib.declare( + "CloseHandle", + lazy.ctypes.winapi_abi, + lazy.ctypes.int32_t /* success */, + lazy.ctypes.void_t.ptr + ); /* handle */ + CloseHandle(handle); + lib.close(); + } +} + +/** + * Windows only function that creates a mutex. + * + * @param aName + * The name for the mutex. + * @param aAllowExisting + * If false the function will close the handle and return null. + * @return The Win32 handle to the mutex. + */ +function createMutex(aName, aAllowExisting = true) { + if (AppConstants.platform != "win") { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + const INITIAL_OWN = 1; + const ERROR_ALREADY_EXISTS = 0xb7; + let lib = lazy.ctypes.open("kernel32.dll"); + let CreateMutexW = lib.declare( + "CreateMutexW", + lazy.ctypes.winapi_abi, + lazy.ctypes.void_t.ptr /* return handle */, + lazy.ctypes.void_t.ptr /* security attributes */, + lazy.ctypes.int32_t /* initial owner */, + lazy.ctypes.char16_t.ptr + ); /* name */ + + let handle = CreateMutexW(null, INITIAL_OWN, aName); + let alreadyExists = lazy.ctypes.winLastError == ERROR_ALREADY_EXISTS; + if (handle && !handle.isNull() && !aAllowExisting && alreadyExists) { + closeHandle(handle); + handle = null; + } + lib.close(); + + if (handle && handle.isNull()) { + handle = null; + } + + return handle; +} + +/** + * Windows only function that determines a unique mutex name for the + * installation. + * + * @param aGlobal + * true if the function should return a global mutex. A global mutex is + * valid across different sessions. + * @return Global mutex path + */ +function getPerInstallationMutexName(aGlobal = true) { + if (AppConstants.platform != "win") { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA1); + + let exeFile = Services.dirsvc.get(KEY_EXECUTABLE, Ci.nsIFile); + + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + var data = converter.convertToByteArray(exeFile.path.toLowerCase()); + + hasher.update(data, data.length); + return ( + (aGlobal ? "Global\\" : "") + "MozillaUpdateMutex-" + hasher.finish(true) + ); +} + +/** + * Whether or not the current instance has the update mutex. The update mutex + * gives protection against 2 applications from the same installation updating: + * 1) Running multiple profiles from the same installation path + * 2) Two applications running in 2 different user sessions from the same path + * + * @return true if this instance holds the update mutex + */ +function hasUpdateMutex() { + if (AppConstants.platform != "win") { + return true; + } + if (!gUpdateMutexHandle) { + gUpdateMutexHandle = createMutex(getPerInstallationMutexName(true), false); + } + return !!gUpdateMutexHandle; +} + +/** + * Determines whether or not all descendants of a directory are writeable. + * Note: Does not check the root directory itself for writeability. + * + * @return true if all descendants are writeable, false otherwise + */ +function areDirectoryEntriesWriteable(aDir) { + let items = aDir.directoryEntries; + while (items.hasMoreElements()) { + let item = items.nextFile; + if (!item.isWritable()) { + LOG("areDirectoryEntriesWriteable - unable to write to " + item.path); + return false; + } + if (item.isDirectory() && !areDirectoryEntriesWriteable(item)) { + return false; + } + } + return true; +} + +/** + * OSX only function to determine if the user requires elevation to be able to + * write to the application bundle. + * + * @return true if elevation is required, false otherwise + */ +function getElevationRequired() { + if (AppConstants.platform != "macosx") { + return false; + } + + try { + // Recursively check that the application bundle (and its descendants) can + // be written to. + LOG( + "getElevationRequired - recursively testing write access on " + + getInstallDirRoot().path + ); + if ( + !getInstallDirRoot().isWritable() || + !areDirectoryEntriesWriteable(getInstallDirRoot()) + ) { + LOG( + "getElevationRequired - unable to write to application bundle, " + + "elevation required" + ); + return true; + } + } catch (ex) { + LOG( + "getElevationRequired - unable to write to application bundle, " + + "elevation required. Exception: " + + ex + ); + return true; + } + LOG( + "getElevationRequired - able to write to application bundle, elevation " + + "not required" + ); + return false; +} + +/** + * A promise that resolves when language packs are downloading or if no language + * packs are being downloaded. + */ +function promiseLangPacksUpdated(update) { + let promise = LangPackUpdates.get(unwrap(update)); + if (promise) { + LOG( + "promiseLangPacksUpdated - waiting for language pack updates to stage." + ); + return promise; + } + + // In case callers rely on a promise just return an already resolved promise. + return Promise.resolve(); +} + +/** + * Determines whether or not an update can be applied. This is always true on + * Windows when the service is used. On Mac OS X and Linux, if the user has + * write access to the update directory this will return true because on OSX we + * offer users the option to perform an elevated update when necessary and on + * Linux the update directory is located in the application directory. + * + * @return true if an update can be applied, false otherwise + */ +function getCanApplyUpdates() { + try { + // Check if it is possible to write to the update directory so clients won't + // repeatedly try to apply an update without the ability to complete the + // update process which requires write access to the update directory. + let updateTestFile = getUpdateFile([FILE_UPDATE_TEST]); + LOG("getCanApplyUpdates - testing write access " + updateTestFile.path); + testWriteAccess(updateTestFile, false); + } catch (e) { + LOG( + "getCanApplyUpdates - unable to apply updates without write " + + "access to the update directory. Exception: " + + e + ); + return false; + } + + if (AppConstants.platform == "macosx") { + LOG( + "getCanApplyUpdates - bypass the write since elevation can be used " + + "on Mac OS X" + ); + return true; + } + + if (shouldUseService()) { + LOG( + "getCanApplyUpdates - bypass the write checks because the Windows " + + "Maintenance Service can be used" + ); + return true; + } + + try { + if (AppConstants.platform == "win") { + // On Windows when the maintenance service isn't used updates can still be + // performed in a location requiring admin privileges by the client + // accepting a UAC prompt from an elevation request made by the updater. + // Whether the client can elevate (e.g. has a split token) is determined + // in nsXULAppInfo::GetUserCanElevate which is located in nsAppRunner.cpp. + let userCanElevate = Services.appinfo.QueryInterface( + Ci.nsIWinAppHelper + ).userCanElevate; + if (lazy.gIsBackgroundTaskMode) { + LOG( + "getCanApplyUpdates - in background task mode, assuming user can't elevate" + ); + userCanElevate = false; + } + if (!userCanElevate) { + // if we're unable to create the test file this will throw an exception. + let appDirTestFile = getAppBaseDir(); + appDirTestFile.append(FILE_UPDATE_TEST); + LOG("getCanApplyUpdates - testing write access " + appDirTestFile.path); + if (appDirTestFile.exists()) { + appDirTestFile.remove(false); + } + appDirTestFile.create( + Ci.nsIFile.NORMAL_FILE_TYPE, + FileUtils.PERMS_FILE + ); + appDirTestFile.remove(false); + } + } + } catch (e) { + LOG("getCanApplyUpdates - unable to apply updates. Exception: " + e); + // No write access to the installation directory + return false; + } + + LOG("getCanApplyUpdates - able to apply updates"); + return true; +} + +/** + * Whether or not the application can stage an update for the current session. + * These checks are only performed once per session due to using a lazy getter. + * + * @return true if updates can be staged for this session. + */ +XPCOMUtils.defineLazyGetter( + lazy, + "gCanStageUpdatesSession", + function aus_gCSUS() { + if (getElevationRequired()) { + LOG( + "gCanStageUpdatesSession - unable to stage updates because elevation " + + "is required." + ); + return false; + } + + try { + let updateTestFile; + if (AppConstants.platform == "macosx") { + updateTestFile = getUpdateFile([FILE_UPDATE_TEST]); + } else { + updateTestFile = getInstallDirRoot(); + updateTestFile.append(FILE_UPDATE_TEST); + } + LOG( + "gCanStageUpdatesSession - testing write access " + updateTestFile.path + ); + testWriteAccess(updateTestFile, true); + if (AppConstants.platform != "macosx") { + // On all platforms except Mac, we need to test the parent directory as + // well, as we need to be able to move files in that directory during the + // replacing step. + updateTestFile = getInstallDirRoot().parent; + updateTestFile.append(FILE_UPDATE_TEST); + LOG( + "gCanStageUpdatesSession - testing write access " + + updateTestFile.path + ); + updateTestFile.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + updateTestFile.remove(false); + } + } catch (e) { + LOG("gCanStageUpdatesSession - unable to stage updates. Exception: " + e); + // No write privileges + return false; + } + + LOG("gCanStageUpdatesSession - able to stage updates"); + return true; + } +); + +/** + * Whether or not the application can stage an update. + * + * @param {boolean} [transient] Whether transient factors such as the update + * mutex should be considered. + * @return true if updates can be staged. + */ +function getCanStageUpdates(transient = true) { + // If staging updates are disabled, then just bail out! + if (!Services.prefs.getBoolPref(PREF_APP_UPDATE_STAGING_ENABLED, false)) { + LOG( + "getCanStageUpdates - staging updates is disabled by preference " + + PREF_APP_UPDATE_STAGING_ENABLED + ); + return false; + } + + if (AppConstants.platform == "win" && shouldUseService()) { + // No need to perform directory write checks, the maintenance service will + // be able to write to all directories. + LOG("getCanStageUpdates - able to stage updates using the service"); + return true; + } + + if (transient && !hasUpdateMutex()) { + LOG( + "getCanStageUpdates - unable to apply updates because another " + + "instance of the application is already handling updates for this " + + "installation." + ); + return false; + } + + return lazy.gCanStageUpdatesSession; +} + +/* + * Whether or not the application can use BITS to download updates. + * + * @param {boolean} [transient] Whether transient factors such as the update + * mutex should be considered. + * @return A string with one of these values: + * CanUseBits + * NoBits_NotWindows + * NoBits_FeatureOff + * NoBits_Pref + * NoBits_Proxy + * NoBits_OtherUser + * These strings are directly compatible with the categories for + * UPDATE_CAN_USE_BITS_EXTERNAL and UPDATE_CAN_USE_BITS_NOTIFY telemetry + * probes. If this function is made to return other values, they should + * also be added to the labels lists for those probes in Histograms.json + */ +function getCanUseBits(transient = true) { + if (AppConstants.platform != "win") { + LOG("getCanUseBits - Not using BITS because this is not Windows"); + return "NoBits_NotWindows"; + } + if (!AppConstants.MOZ_BITS_DOWNLOAD) { + LOG("getCanUseBits - Not using BITS because the feature is disabled"); + return "NoBits_FeatureOff"; + } + + if (!Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED, true)) { + LOG("getCanUseBits - Not using BITS. Disabled by pref."); + return "NoBits_Pref"; + } + // Firefox support for passing proxies to BITS is still rudimentary. + // For now, disable BITS support on configurations that are not using the + // standard system proxy. + let defaultProxy = Ci.nsIProtocolProxyService.PROXYCONFIG_SYSTEM; + if ( + Services.prefs.getIntPref(PREF_NETWORK_PROXY_TYPE, defaultProxy) != + defaultProxy && + !Cu.isInAutomation + ) { + LOG("getCanUseBits - Not using BITS because of proxy usage"); + return "NoBits_Proxy"; + } + if (transient && gBITSInUseByAnotherUser) { + LOG("getCanUseBits - Not using BITS. Already in use by another user"); + return "NoBits_OtherUser"; + } + LOG("getCanUseBits - BITS can be used to download updates"); + return "CanUseBits"; +} + +/** + * Logs a string to the error console. If enabled, also logs to the update + * messages file. + * @param string + * The string to write to the error console. + */ +function LOG(string) { + if (lazy.gLogEnabled) { + dump("*** AUS:SVC " + string + "\n"); + if (!Cu.isInAutomation) { + Services.console.logStringMessage("AUS:SVC " + string); + } + + if (lazy.gLogfileEnabled) { + if (!gLogfileOutputStream) { + let logfile = Services.dirsvc.get(KEY_PROFILE_DIR, Ci.nsIFile); + logfile.append(FILE_UPDATE_MESSAGES); + gLogfileOutputStream = FileUtils.openAtomicFileOutputStream(logfile); + } + + try { + let encoded = new TextEncoder().encode(string + "\n"); + gLogfileOutputStream.write(encoded, encoded.length); + gLogfileOutputStream.flush(); + } catch (e) { + dump("*** AUS:SVC Unable to write to messages file: " + e + "\n"); + Services.console.logStringMessage( + "AUS:SVC Unable to write to messages file: " + e + ); + } + } + } +} + +/** + * Gets the specified directory at the specified hierarchy under the + * update root directory and creates it if it doesn't exist. + * @param pathArray + * An array of path components to locate beneath the directory + * specified by |key| + * @return nsIFile object for the location specified. + */ +function getUpdateDirCreate(pathArray) { + if (Cu.isInAutomation) { + // This allows tests to use an alternate updates directory so they can test + // startup behavior. + const MAGIC_TEST_ROOT_PREFIX = "<test-root>"; + const PREF_TEST_ROOT = "mochitest.testRoot"; + let alternatePath = Services.prefs.getCharPref( + PREF_APP_UPDATE_ALTUPDATEDIRPATH, + null + ); + if (alternatePath && alternatePath.startsWith(MAGIC_TEST_ROOT_PREFIX)) { + let testRoot = Services.prefs.getCharPref(PREF_TEST_ROOT); + let relativePath = alternatePath.substring(MAGIC_TEST_ROOT_PREFIX.length); + if (AppConstants.platform == "win") { + relativePath = relativePath.replace(/\//g, "\\"); + } + alternatePath = testRoot + relativePath; + let updateDir = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + updateDir.initWithPath(alternatePath); + for (let i = 0; i < pathArray.length; ++i) { + updateDir.append(pathArray[i]); + } + return updateDir; + } + } + + return FileUtils.getDir(KEY_UPDROOT, pathArray, true); +} + +/** + * Gets the application base directory. + * + * @return nsIFile object for the application base directory. + */ +function getAppBaseDir() { + return Services.dirsvc.get(KEY_EXECUTABLE, Ci.nsIFile).parent; +} + +/** + * Gets the root of the installation directory which is the application + * bundle directory on Mac OS X and the location of the application binary + * on all other platforms. + * + * @return nsIFile object for the directory + */ +function getInstallDirRoot() { + let dir = getAppBaseDir(); + if (AppConstants.platform == "macosx") { + // On macOS, the executable is stored under Contents/MacOS. + dir = dir.parent.parent; + } + return dir; +} + +/** + * Gets the file at the specified hierarchy under the update root directory. + * @param pathArray + * An array of path components to locate beneath the directory + * specified by |key|. The last item in this array must be the + * leaf name of a file. + * @return nsIFile object for the file specified. The file is NOT created + * if it does not exist, however all required directories along + * the way are. + */ +function getUpdateFile(pathArray) { + let file = getUpdateDirCreate(pathArray.slice(0, -1)); + file.append(pathArray[pathArray.length - 1]); + return file; +} + +/** + * This function is designed to let us slightly clean up the mapping between + * strings and error codes. So that instead of having: + * check_error-2147500036=Connection aborted + * check_error-2152398850=Connection aborted + * We can have: + * check_error-connection_aborted=Connection aborted + * And map both of those error codes to it. + */ +function maybeMapErrorCode(code) { + switch (code) { + case Cr.NS_BINDING_ABORTED: + case Cr.NS_ERROR_ABORT: + return "connection_aborted"; + } + return code; +} + +/** + * Returns human readable status text from the updates.properties bundle + * based on an error code + * @param code + * The error code to look up human readable status text for + * @param defaultCode + * The default code to look up should human readable status text + * not exist for |code| + * @return A human readable status text string + */ +function getStatusTextFromCode(code, defaultCode) { + code = maybeMapErrorCode(code); + + let reason; + try { + reason = lazy.gUpdateBundle.GetStringFromName("check_error-" + code); + LOG( + "getStatusTextFromCode - transfer error: " + reason + ", code: " + code + ); + } catch (e) { + defaultCode = maybeMapErrorCode(defaultCode); + + // Use the default reason + reason = lazy.gUpdateBundle.GetStringFromName("check_error-" + defaultCode); + LOG( + "getStatusTextFromCode - transfer error: " + + reason + + ", default code: " + + defaultCode + ); + } + return reason; +} + +/** + * Get the Ready Update directory. This is the directory that an update + * should reside in after download has completed but before it has been + * installed and cleaned up. + * @return The ready updates directory, as a nsIFile object + */ +function getReadyUpdateDir() { + return getUpdateDirCreate([DIR_UPDATES, DIR_UPDATE_READY]); +} + +/** + * Get the Downloading Update directory. This is the directory that an update + * should reside in during download. Once download is completed, it will be + * moved to the Ready Update directory. + * @return The downloading update directory, as a nsIFile object + */ +function getDownloadingUpdateDir() { + return getUpdateDirCreate([DIR_UPDATES, DIR_UPDATE_DOWNLOADING]); +} + +/** + * Reads the update state from the update.status file in the specified + * directory. + * @param dir + * The dir to look for an update.status file in + * @return The status value of the update. + */ +function readStatusFile(dir) { + let statusFile = dir.clone(); + statusFile.append(FILE_UPDATE_STATUS); + let status = readStringFromFile(statusFile) || STATE_NONE; + LOG("readStatusFile - status: " + status + ", path: " + statusFile.path); + return status; +} + +/** + * Reads the binary transparency result file from the given directory. + * Removes the file if it is present (so don't call this twice and expect a + * result the second time). + * @param dir + * The dir to look for an update.bt file in + * @return A error code from verifying binary transparency information or null + * if the file was not present (indicating there was no error). + */ +function readBinaryTransparencyResult(dir) { + let binaryTransparencyResultFile = dir.clone(); + binaryTransparencyResultFile.append(FILE_BT_RESULT); + let result = readStringFromFile(binaryTransparencyResultFile); + LOG( + "readBinaryTransparencyResult - result: " + + result + + ", path: " + + binaryTransparencyResultFile.path + ); + // If result is non-null, the file exists. We should remove it to avoid + // double-reporting this result. + if (result) { + binaryTransparencyResultFile.remove(false); + } + return result; +} + +/** + * Writes the current update operation/state to a file in the patch + * directory, indicating to the patching system that operations need + * to be performed. + * @param dir + * The patch directory where the update.status file should be + * written. + * @param state + * The state value to write. + */ +function writeStatusFile(dir, state) { + let statusFile = dir.clone(); + statusFile.append(FILE_UPDATE_STATUS); + writeStringToFile(statusFile, state); +} + +/** + * Writes the update's application version to a file in the patch directory. If + * the update doesn't provide application version information via the + * appVersion attribute the string "null" will be written to the file. + * This value is compared during startup (in nsUpdateDriver.cpp) to determine if + * the update should be applied. Note that this won't provide protection from + * downgrade of the application for the nightly user case where the application + * version doesn't change. + * @param dir + * The patch directory where the update.version file should be + * written. + * @param version + * The version value to write. Will be the string "null" when the + * update doesn't provide the appVersion attribute in the update xml. + */ +function writeVersionFile(dir, version) { + let versionFile = dir.clone(); + versionFile.append(FILE_UPDATE_VERSION); + writeStringToFile(versionFile, version); +} + +/** + * Determines if the service should be used to attempt an update + * or not. + * + * @return true if the service should be used for updates. + */ +function shouldUseService() { + // This function will return true if the mantenance service should be used if + // all of the following conditions are met: + // 1) This build was done with the maintenance service enabled + // 2) The maintenance service is installed + // 3) The pref for using the service is enabled + if ( + !AppConstants.MOZ_MAINTENANCE_SERVICE || + !isServiceInstalled() || + !Services.prefs.getBoolPref(PREF_APP_UPDATE_SERVICE_ENABLED, false) + ) { + LOG("shouldUseService - returning false"); + return false; + } + + LOG("shouldUseService - returning true"); + return true; +} + +/** + * Determines if the service is is installed. + * + * @return true if the service is installed. + */ +function isServiceInstalled() { + if (!AppConstants.MOZ_MAINTENANCE_SERVICE || AppConstants.platform != "win") { + LOG("isServiceInstalled - returning false"); + return false; + } + + let installed = 0; + try { + let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + wrk.open( + wrk.ROOT_KEY_LOCAL_MACHINE, + "SOFTWARE\\Mozilla\\MaintenanceService", + wrk.ACCESS_READ | wrk.WOW64_64 + ); + installed = wrk.readIntValue("Installed"); + wrk.close(); + } catch (e) {} + installed = installed == 1; // convert to bool + LOG("isServiceInstalled - returning " + installed); + return installed; +} + +/** + * Gets the appropriate pending update state. Returns STATE_PENDING_SERVICE, + * STATE_PENDING_ELEVATE, or STATE_PENDING. + */ +function getBestPendingState() { + if (shouldUseService()) { + return STATE_PENDING_SERVICE; + } else if (getElevationRequired()) { + return STATE_PENDING_ELEVATE; + } + return STATE_PENDING; +} + +/** + * Removes the contents of the ready update directory and rotates the update + * logs when present. If the update.log exists in the patch directory this will + * move the last-update.log if it exists to backup-update.log in the parent + * directory of the patch directory and then move the update.log in the patch + * directory to last-update.log in the parent directory of the patch directory. + * + * @param aRemovePatchFiles (optional, defaults to true) + * When true the update's patch directory contents are removed. + */ +function cleanUpReadyUpdateDir(aRemovePatchFiles = true) { + let updateDir; + try { + updateDir = getReadyUpdateDir(); + } catch (e) { + LOG( + "cleanUpReadyUpdateDir - unable to get the updates patch directory. " + + "Exception: " + + e + ); + return; + } + + // Preserve the last update log file for debugging purposes. + let updateLogFile = updateDir.clone(); + updateLogFile.append(FILE_UPDATE_LOG); + if (updateLogFile.exists()) { + let dir = updateDir.parent; + let logFile = dir.clone(); + logFile.append(FILE_LAST_UPDATE_LOG); + if (logFile.exists()) { + try { + logFile.moveTo(dir, FILE_BACKUP_UPDATE_LOG); + } catch (e) { + LOG( + "cleanUpReadyUpdateDir - failed to rename file " + + logFile.path + + " to " + + FILE_BACKUP_UPDATE_LOG + ); + } + } + + try { + updateLogFile.moveTo(dir, FILE_LAST_UPDATE_LOG); + } catch (e) { + LOG( + "cleanUpReadyUpdateDir - failed to rename file " + + updateLogFile.path + + " to " + + FILE_LAST_UPDATE_LOG + ); + } + } + + if (aRemovePatchFiles) { + let dirEntries = updateDir.directoryEntries; + while (dirEntries.hasMoreElements()) { + let file = dirEntries.nextFile; + // Now, recursively remove this file. The recursive removal is needed for + // Mac OSX because this directory will contain a copy of updater.app, + // which is itself a directory and the MozUpdater directory on platforms + // other than Windows. + try { + file.remove(true); + } catch (e) { + LOG("cleanUpReadyUpdateDir - failed to remove file " + file.path); + } + } + } +} + +/** + * Removes the contents of the update download directory. + * + */ +function cleanUpDownloadingUpdateDir() { + let updateDir; + try { + updateDir = getDownloadingUpdateDir(); + } catch (e) { + LOG( + "cleanUpDownloadUpdatesDir - unable to get the updates patch " + + "directory. Exception: " + + e + ); + return; + } + + let dirEntries = updateDir.directoryEntries; + while (dirEntries.hasMoreElements()) { + let file = dirEntries.nextFile; + // Now, recursively remove this file. + try { + file.remove(true); + } catch (e) { + LOG("cleanUpDownloadUpdatesDir - failed to remove file " + file.path); + } + } +} + +/** + * Clean up the updates list and the directory that contains the update that + * is ready to be installed. + * + * Note - This function causes a state transition to either STATE_DOWNLOADING + * or STATE_NONE, depending on whether an update download is in progress. + */ +function cleanupReadyUpdate() { + // Move the update from the Active Update list into the Past Updates list. + if (lazy.UM.readyUpdate) { + LOG("cleanupReadyUpdate - Clearing readyUpdate"); + lazy.UM.addUpdateToHistory(lazy.UM.readyUpdate); + lazy.UM.readyUpdate = null; + } + lazy.UM.saveUpdates(); + + let readyUpdateDir = getReadyUpdateDir(); + let shouldSetDownloadingStatus = + lazy.UM.downloadingUpdate || + readStatusFile(readyUpdateDir) == STATE_DOWNLOADING; + + // Now trash the ready update directory, since we're done with it + cleanUpReadyUpdateDir(); + + // We need to handle two similar cases here. + // The first is where we clean up the ready updates directory while we are in + // the downloading state. In this case, we remove the update.status file that + // says we are downloading, even though we should remain in that state. + // The second case is when we clean up a ready update, but there is also a + // downloading update (in which case the update status file's state will + // reflect the state of the ready update, not the downloading one). In that + // case, instead of reverting to STATE_NONE (which is what we do by removing + // the status file), we should set our state to downloading. + if (shouldSetDownloadingStatus) { + LOG("cleanupReadyUpdate - Transitioning back to downloading state."); + transitionState(Ci.nsIApplicationUpdateService.STATE_DOWNLOADING); + writeStatusFile(readyUpdateDir, STATE_DOWNLOADING); + } +} + +/** + * Clean up updates list and the directory that the currently downloading update + * is downloaded to. + * + * Note - This function may cause a state transition. If the current state is + * STATE_DOWNLOADING, this will cause it to change to STATE_NONE. + */ +function cleanupDownloadingUpdate() { + // Move the update from the Active Update list into the Past Updates list. + if (lazy.UM.downloadingUpdate) { + LOG("cleanupDownloadingUpdate - Clearing downloadingUpdate."); + lazy.UM.addUpdateToHistory(lazy.UM.downloadingUpdate); + lazy.UM.downloadingUpdate = null; + } + lazy.UM.saveUpdates(); + + // Now trash the update download directory, since we're done with it + cleanUpDownloadingUpdateDir(); + + // If the update status file says we are downloading, we should remove that + // too, since we aren't doing that anymore. + let readyUpdateDir = getReadyUpdateDir(); + let status = readStatusFile(readyUpdateDir); + if (status == STATE_DOWNLOADING) { + let statusFile = readyUpdateDir.clone(); + statusFile.append(FILE_UPDATE_STATUS); + statusFile.remove(); + } +} + +/** + * Clean up updates list, the ready update directory, and the downloading update + * directory. + * + * This is more efficient than calling + * cleanupReadyUpdate(); + * cleanupDownloadingUpdate(); + * because those need some special handling of the update status file to make + * sure that, for example, cleaning up a ready update doesn't make us forget + * that we are downloading an update. When we cleanup both updates, we don't + * need to worry about things like that. + * + * Note - This function causes a state transition to STATE_NONE. + */ +function cleanupActiveUpdates() { + // Move the update from the Active Update list into the Past Updates list. + if (lazy.UM.readyUpdate) { + LOG("cleanupActiveUpdates - Clearing readyUpdate"); + lazy.UM.addUpdateToHistory(lazy.UM.readyUpdate); + lazy.UM.readyUpdate = null; + } + if (lazy.UM.downloadingUpdate) { + LOG("cleanupActiveUpdates - Clearing downloadingUpdate."); + lazy.UM.addUpdateToHistory(lazy.UM.downloadingUpdate); + lazy.UM.downloadingUpdate = null; + } + lazy.UM.saveUpdates(); + + // Now trash both active update directories, since we're done with them + cleanUpReadyUpdateDir(); + cleanUpDownloadingUpdateDir(); +} + +/** + * Writes a string of text to a file. A newline will be appended to the data + * written to the file. This function only works with ASCII text. + * @param file An nsIFile indicating what file to write to. + * @param text A string containing the text to write to the file. + * @return true on success, false on failure. + */ +function writeStringToFile(file, text) { + try { + let fos = FileUtils.openSafeFileOutputStream(file); + text += "\n"; + fos.write(text, text.length); + FileUtils.closeSafeFileOutputStream(fos); + } catch (e) { + LOG(`writeStringToFile - Failed to write to file: "${file}". Error: ${e}"`); + return false; + } + return true; +} + +function readStringFromInputStream(inputStream) { + var sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(inputStream); + var text = sis.read(sis.available()); + sis.close(); + if (text && text[text.length - 1] == "\n") { + text = text.slice(0, -1); + } + return text; +} + +/** + * Reads a string of text from a file. A trailing newline will be removed + * before the result is returned. This function only works with ASCII text. + */ +function readStringFromFile(file) { + if (!file.exists()) { + LOG("readStringFromFile - file doesn't exist: " + file.path); + return null; + } + var fis = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + fis.init(file, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0); + return readStringFromInputStream(fis); +} + +/** + * Attempts to recover from an update error. If successful, `true` will be + * returned and AUS.currentState will be transitioned. + */ +function handleUpdateFailure(update) { + if (WRITE_ERRORS.includes(update.errorCode)) { + LOG( + "handleUpdateFailure - Failure is a write error. Setting state to pending" + ); + writeStatusFile(getReadyUpdateDir(), (update.state = STATE_PENDING)); + transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING); + return true; + } + + if (update.errorCode == SILENT_UPDATE_NEEDED_ELEVATION_ERROR) { + // There's no need to count attempts and escalate: it's expected that the + // background update task will try to update and fail due to required + // elevation repeatedly if, for example, the maintenance service is not + // available (or not functioning) and the installation requires privileges + // to update. + + let bestState = getBestPendingState(); + LOG( + "handleUpdateFailure - witnessed SILENT_UPDATE_NEEDED_ELEVATION_ERROR, " + + "returning to " + + bestState + ); + writeStatusFile(getReadyUpdateDir(), (update.state = bestState)); + + transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING); + // Return true to indicate a recoverable error. + return true; + } + + if (update.errorCode == ELEVATION_CANCELED) { + let elevationAttempts = Services.prefs.getIntPref( + PREF_APP_UPDATE_ELEVATE_ATTEMPTS, + 0 + ); + elevationAttempts++; + Services.prefs.setIntPref( + PREF_APP_UPDATE_ELEVATE_ATTEMPTS, + elevationAttempts + ); + let maxAttempts = Math.min( + Services.prefs.getIntPref(PREF_APP_UPDATE_ELEVATE_MAXATTEMPTS, 2), + 10 + ); + + if (elevationAttempts > maxAttempts) { + LOG( + "handleUpdateFailure - notifying observers of error. " + + "topic: update-error, status: elevation-attempts-exceeded" + ); + Services.obs.notifyObservers( + update, + "update-error", + "elevation-attempts-exceeded" + ); + } else { + LOG( + "handleUpdateFailure - notifying observers of error. " + + "topic: update-error, status: elevation-attempt-failed" + ); + Services.obs.notifyObservers( + update, + "update-error", + "elevation-attempt-failed" + ); + } + + let cancelations = Services.prefs.getIntPref( + PREF_APP_UPDATE_CANCELATIONS, + 0 + ); + cancelations++; + Services.prefs.setIntPref(PREF_APP_UPDATE_CANCELATIONS, cancelations); + if (AppConstants.platform == "macosx") { + let osxCancelations = Services.prefs.getIntPref( + PREF_APP_UPDATE_CANCELATIONS_OSX, + 0 + ); + osxCancelations++; + Services.prefs.setIntPref( + PREF_APP_UPDATE_CANCELATIONS_OSX, + osxCancelations + ); + let maxCancels = Services.prefs.getIntPref( + PREF_APP_UPDATE_CANCELATIONS_OSX_MAX, + DEFAULT_CANCELATIONS_OSX_MAX + ); + // Prevent the preference from setting a value greater than 5. + maxCancels = Math.min(maxCancels, 5); + if (osxCancelations >= maxCancels) { + LOG( + "handleUpdateFailure - Too many OSX cancellations. Cleaning up " + + "ready update." + ); + cleanupReadyUpdate(); + return false; + } + LOG( + `handleUpdateFailure - OSX cancellation. Trying again by setting ` + + `status to "${STATE_PENDING_ELEVATE}".` + ); + writeStatusFile( + getReadyUpdateDir(), + (update.state = STATE_PENDING_ELEVATE) + ); + update.statusText = + lazy.gUpdateBundle.GetStringFromName("elevationFailure"); + } else { + LOG( + "handleUpdateFailure - Failure because elevation was cancelled. " + + "again by setting status to pending." + ); + writeStatusFile(getReadyUpdateDir(), (update.state = STATE_PENDING)); + } + transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING); + return true; + } + + if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS)) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS); + } + if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS_OSX)) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS_OSX); + } + + if (SERVICE_ERRORS.includes(update.errorCode)) { + var failCount = Services.prefs.getIntPref( + PREF_APP_UPDATE_SERVICE_ERRORS, + 0 + ); + var maxFail = Services.prefs.getIntPref( + PREF_APP_UPDATE_SERVICE_MAXERRORS, + DEFAULT_SERVICE_MAX_ERRORS + ); + // Prevent the preference from setting a value greater than 10. + maxFail = Math.min(maxFail, 10); + // As a safety, when the service reaches maximum failures, it will + // disable itself and fallback to using the normal update mechanism + // without the service. + if (failCount >= maxFail) { + Services.prefs.setBoolPref(PREF_APP_UPDATE_SERVICE_ENABLED, false); + Services.prefs.clearUserPref(PREF_APP_UPDATE_SERVICE_ERRORS); + } else { + failCount++; + Services.prefs.setIntPref(PREF_APP_UPDATE_SERVICE_ERRORS, failCount); + } + + LOG( + "handleUpdateFailure - Got a service error. Try to update without the " + + "service by setting the state to pending." + ); + writeStatusFile(getReadyUpdateDir(), (update.state = STATE_PENDING)); + transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING); + return true; + } + + if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_SERVICE_ERRORS)) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_SERVICE_ERRORS); + } + + return false; +} + +/** + * Return the first UpdatePatch with the given type. + * @param update + * A nsIUpdate object to search through for a patch of the desired + * type. + * @param patch_type + * The type of the patch ("complete" or "partial") + * @return A nsIUpdatePatch object matching the type specified + */ +function getPatchOfType(update, patch_type) { + for (var i = 0; i < update.patchCount; ++i) { + var patch = update.getPatchAt(i); + if (patch && patch.type == patch_type) { + return patch; + } + } + return null; +} + +/** + * Fall back to downloading a complete update in case an update has failed. + * + * This will transition `AUS.currentState` to `STATE_DOWNLOADING` if there is + * another patch to download, or `STATE_IDLE` if there is not. + */ +async function handleFallbackToCompleteUpdate() { + // If we failed to install an update, we need to fall back to a complete + // update. If the install directory has been modified, more partial updates + // will fail for the same reason. Since we only download partial updates + // while there is already an update downloaded, we don't have to check the + // downloading update, we can be confident that we are not downloading the + // right thing at the moment. + + // The downloading update will be newer than the ready update, so use that + // update, if it exists. + let update = lazy.UM.downloadingUpdate || lazy.UM.readyUpdate; + if (!update) { + LOG( + "handleFallbackToCompleteUpdate - Unable to find an update to fall " + + "back to." + ); + return; + } + + LOG( + "handleFallbackToCompleteUpdate - Cleaning up active updates in " + + "preparation of falling back to complete update." + ); + await lazy.AUS.stopDownload(); + cleanupActiveUpdates(); + + if (!update.selectedPatch) { + // If we don't have a partial patch selected but a partial is available, + // _selectPatch() will download that instead of the complete patch. + let patch = getPatchOfType(update, "partial"); + if (patch) { + patch.selected = true; + } + } + + update.statusText = lazy.gUpdateBundle.GetStringFromName("patchApplyFailure"); + var oldType = update.selectedPatch ? update.selectedPatch.type : "complete"; + if (update.selectedPatch && oldType == "partial" && update.patchCount == 2) { + // Partial patch application failed, try downloading the complete + // update in the background instead. + LOG( + "handleFallbackToCompleteUpdate - install of partial patch " + + "failed, downloading complete patch" + ); + var success = await lazy.AUS.downloadUpdate(update); + if (!success) { + LOG( + "handleFallbackToCompleteUpdate - Starting complete patch download " + + "failed. Cleaning up downloading patch." + ); + cleanupDownloadingUpdate(); + } + } else { + LOG( + "handleFallbackToCompleteUpdate - install of complete or " + + "only one patch offered failed. Notifying observers. topic: " + + "update-error, status: unknown, " + + "update.patchCount: " + + update.patchCount + + ", " + + "oldType: " + + oldType + ); + transitionState(Ci.nsIApplicationUpdateService.STATE_IDLE); + Services.obs.notifyObservers(update, "update-error", "unknown"); + } +} + +function pingStateAndStatusCodes(aUpdate, aStartup, aStatus) { + let patchType = AUSTLMY.PATCH_UNKNOWN; + if (aUpdate && aUpdate.selectedPatch && aUpdate.selectedPatch.type) { + if (aUpdate.selectedPatch.type == "complete") { + patchType = AUSTLMY.PATCH_COMPLETE; + } else if (aUpdate.selectedPatch.type == "partial") { + patchType = AUSTLMY.PATCH_PARTIAL; + } + } + + let suffix = patchType + "_" + (aStartup ? AUSTLMY.STARTUP : AUSTLMY.STAGE); + let stateCode = 0; + let parts = aStatus.split(":"); + if (parts.length) { + switch (parts[0]) { + case STATE_NONE: + stateCode = 2; + break; + case STATE_DOWNLOADING: + stateCode = 3; + break; + case STATE_PENDING: + stateCode = 4; + break; + case STATE_PENDING_SERVICE: + stateCode = 5; + break; + case STATE_APPLYING: + stateCode = 6; + break; + case STATE_APPLIED: + stateCode = 7; + break; + case STATE_APPLIED_SERVICE: + stateCode = 9; + break; + case STATE_SUCCEEDED: + stateCode = 10; + break; + case STATE_DOWNLOAD_FAILED: + stateCode = 11; + break; + case STATE_FAILED: + stateCode = 12; + break; + case STATE_PENDING_ELEVATE: + stateCode = 13; + break; + // Note: Do not use stateCode 14 here. It is defined in + // UpdateTelemetry.jsm + default: + stateCode = 1; + } + + if (parts.length > 1) { + let statusErrorCode = INVALID_UPDATER_STATE_CODE; + if (parts[0] == STATE_FAILED) { + statusErrorCode = parseInt(parts[1]) || INVALID_UPDATER_STATUS_CODE; + } + AUSTLMY.pingStatusErrorCode(suffix, statusErrorCode); + } + } + let binaryTransparencyResult = readBinaryTransparencyResult( + getReadyUpdateDir() + ); + if (binaryTransparencyResult) { + AUSTLMY.pingBinaryTransparencyResult( + suffix, + parseInt(binaryTransparencyResult) + ); + } + AUSTLMY.pingStateCode(suffix, stateCode); +} + +/** + * This returns true if the passed update is the same version or older than the + * version and build ID values passed. Otherwise it returns false. + */ +function updateIsAtLeastAsOldAs(update, version, buildID) { + if (!update || !update.appVersion || !update.buildID) { + return false; + } + let versionComparison = Services.vc.compare(update.appVersion, version); + return ( + versionComparison < 0 || + (versionComparison == 0 && update.buildID == buildID) + ); +} + +/** + * This returns true if the passed update is the same version or older than + * currently installed Firefox version. + */ +function updateIsAtLeastAsOldAsCurrentVersion(update) { + return updateIsAtLeastAsOldAs( + update, + Services.appinfo.version, + Services.appinfo.appBuildID + ); +} + +/** + * This returns true if the passed update is the same version or older than + * the update that we have already downloaded (UpdateManager.readyUpdate). + * Returns false if no update has already been downloaded. + */ +function updateIsAtLeastAsOldAsReadyUpdate(update) { + if ( + !lazy.UM.readyUpdate || + !lazy.UM.readyUpdate.appVersion || + !lazy.UM.readyUpdate.buildID + ) { + return false; + } + return updateIsAtLeastAsOldAs( + update, + lazy.UM.readyUpdate.appVersion, + lazy.UM.readyUpdate.buildID + ); +} + +/** + * This function determines whether the error represented by the passed error + * code could potentially be recovered from or bypassed by updating without + * using the Maintenance Service (i.e. by showing a UAC prompt). + * We don't really want to show a UAC prompt, but it's preferable over the + * manual update doorhanger. So this function effectively distinguishes between + * which of those we should do if update staging failed. (The updater + * automatically falls back if the Maintenance Services fails, so this function + * doesn't handle that case) + * + * @param An integer error code from the update.status file. Should be one of + * the codes enumerated in updatererrors.h. + * @returns true if the code represents a Maintenance Service specific error. + * Otherwise, false. + */ +function isServiceSpecificErrorCode(errorCode) { + return ( + (errorCode >= 24 && errorCode <= 33) || (errorCode >= 49 && errorCode <= 58) + ); +} + +/** + * This function determines whether the error represented by the passed error + * code is the result of the updater failing to allocate memory. This is + * relevant when staging because, since Firefox is also running, we may not be + * able to allocate much memory. Thus, if we fail to stage an update, we may + * succeed at updating without staging. + * + * @param An integer error code from the update.status file. Should be one of + * the codes enumerated in updatererrors.h. + * @returns true if the code represents a memory allocation error. + * Otherwise, false. + */ +function isMemoryAllocationErrorCode(errorCode) { + return errorCode >= 10 && errorCode <= 14; +} + +/** + * Normally when staging, `nsUpdateProcessor::WaitForProcess` waits for the + * staging process to complete by watching for its PID to terminate. + * However, there are less ideal situations. Notably, we might start the browser + * and find that update staging appears to already be in-progress. If that + * happens, we really want to pick up the update process from STATE_STAGING, + * but we don't really have any way of keeping an eye on the staging process + * other than to just poll the status file. + * + * Like `nsUpdateProcessor`, this calls `nsIUpdateManager.refreshUpdateStatus` + * after polling completes (regardless of result). + * + * It is also important to keep in mind that the updater might have crashed + * during staging, meaning that the status file will never change, no matter how + * long we keep polling. So we need to set an upper bound on how long we are + * willing to poll for. + * + * There are three situations that we want to avoid. + * (1) We don't want to set the poll interval too long. A user might be watching + * the user interface and waiting to restart to install the update. A long poll + * interval will cause them to have to wait longer than necessary. Especially + * since the expected total staging time is not that long. + * (2) We don't want to give up polling too early and give up on an update that + * will ultimately succeed. + * (3) We don't want to use a rapid polling interval over a long duration. + * + * To avoid these situations, we will start with a short polling interval, but + * will increase it the longer that we have to wait. Then if we hit the upper + * bound of polling, we will give up. + */ +function pollForStagingEnd() { + let pollingIntervalMs = STAGING_POLLING_MIN_INTERVAL_MS; + // Number of times to poll before increasing the polling interval. + let pollAttemptsAtIntervalRemaining = STAGING_POLLING_ATTEMPTS_PER_INTERVAL; + let timeElapsedMs = 0; + + let pollingFn = () => { + pollAttemptsAtIntervalRemaining -= 1; + // This isn't a perfectly accurate way of keeping time, but it does nicely + // sidestep dealing with issues of (non)monotonic time. + timeElapsedMs += pollingIntervalMs; + + if (timeElapsedMs >= STAGING_POLLING_MAX_DURATION_MS) { + lazy.UM.refreshUpdateStatus(); + return; + } + + if (readStatusFile(getReadyUpdateDir()) != STATE_APPLYING) { + lazy.UM.refreshUpdateStatus(); + return; + } + + if (pollAttemptsAtIntervalRemaining <= 0) { + pollingIntervalMs = Math.min( + pollingIntervalMs * 2, + STAGING_POLLING_MAX_INTERVAL_MS + ); + pollAttemptsAtIntervalRemaining = STAGING_POLLING_ATTEMPTS_PER_INTERVAL; + } + + lazy.setTimeout(pollingFn, pollingIntervalMs); + }; + + lazy.setTimeout(pollingFn, pollingIntervalMs); +} + +/** + * Update Patch + * @param patch + * A <patch> element to initialize this object with + * @throws if patch has a size of 0 + * @constructor + */ +function UpdatePatch(patch) { + this._properties = {}; + this.errorCode = 0; + this.finalURL = null; + this.state = STATE_NONE; + + for (let i = 0; i < patch.attributes.length; ++i) { + var attr = patch.attributes.item(i); + // If an undefined value is saved to the xml file it will be a string when + // it is read from the xml file. + if (attr.value == "undefined") { + continue; + } + switch (attr.name) { + case "xmlns": + // Don't save the XML namespace. + break; + case "selected": + this.selected = attr.value == "true"; + break; + case "size": + if (0 == parseInt(attr.value)) { + LOG("UpdatePatch:init - 0-sized patch!"); + throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE); + } + this[attr.name] = attr.value; + break; + case "errorCode": + if (attr.value) { + let val = parseInt(attr.value); + // This will evaluate to false if the value is 0 but that's ok since + // this.errorCode is set to the default of 0 above. + if (val) { + this.errorCode = val; + } + } + break; + case "finalURL": + case "state": + case "type": + case "URL": + this[attr.name] = attr.value; + break; + default: + if (!this._attrNames.includes(attr.name)) { + // Set nsIPropertyBag properties that were read from the xml file. + this.setProperty(attr.name, attr.value); + } + break; + } + } +} +UpdatePatch.prototype = { + // nsIUpdatePatch attribute names used to prevent nsIWritablePropertyBag from + // over writing nsIUpdatePatch attributes. + _attrNames: [ + "errorCode", + "finalURL", + "selected", + "size", + "state", + "type", + "URL", + ], + + /** + * See nsIUpdateService.idl + */ + serialize: function UpdatePatch_serialize(updates) { + var patch = updates.createElementNS(URI_UPDATE_NS, "patch"); + patch.setAttribute("size", this.size); + patch.setAttribute("type", this.type); + patch.setAttribute("URL", this.URL); + // Don't write an errorCode if it evaluates to false since 0 is the same as + // no error code. + if (this.errorCode) { + patch.setAttribute("errorCode", this.errorCode); + } + // finalURL is not available until after the download has started + if (this.finalURL) { + patch.setAttribute("finalURL", this.finalURL); + } + // The selected patch is the only patch that should have this attribute. + if (this.selected) { + patch.setAttribute("selected", this.selected); + } + if (this.state != STATE_NONE) { + patch.setAttribute("state", this.state); + } + + for (let [name, value] of Object.entries(this._properties)) { + if (value.present && !this._attrNames.includes(name)) { + patch.setAttribute(name, value.data); + } + } + return patch; + }, + + /** + * See nsIWritablePropertyBag.idl + */ + setProperty: function UpdatePatch_setProperty(name, value) { + if (this._attrNames.includes(name)) { + throw Components.Exception( + "Illegal value '" + + name + + "' (attribute exists on nsIUpdatePatch) " + + "when calling method: [nsIWritablePropertyBag::setProperty]", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + this._properties[name] = { data: value, present: true }; + }, + + /** + * See nsIWritablePropertyBag.idl + */ + deleteProperty: function UpdatePatch_deleteProperty(name) { + if (this._attrNames.includes(name)) { + throw Components.Exception( + "Illegal value '" + + name + + "' (attribute exists on nsIUpdatePatch) " + + "when calling method: [nsIWritablePropertyBag::deleteProperty]", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + if (name in this._properties) { + this._properties[name].present = false; + } else { + throw Components.Exception("", Cr.NS_ERROR_FAILURE); + } + }, + + /** + * See nsIPropertyBag.idl + * + * Note: this only contains the nsIPropertyBag name / value pairs and not the + * nsIUpdatePatch name / value pairs. + */ + get enumerator() { + return this.enumerate(); + }, + + *enumerate() { + // An nsISupportsInterfacePointer is used so creating an array using + // Array.from will retain the QueryInterface for nsIProperty. + let ip = Cc["@mozilla.org/supports-interface-pointer;1"].createInstance( + Ci.nsISupportsInterfacePointer + ); + let qi = ChromeUtils.generateQI(["nsIProperty"]); + for (let [name, value] of Object.entries(this._properties)) { + if (value.present && !this._attrNames.includes(name)) { + // The nsIPropertyBag enumerator returns a nsISimpleEnumerator whose + // elements are nsIProperty objects. Calling QueryInterface for + // nsIProperty on the object doesn't return to the caller an object that + // is already queried to nsIProperty but do it just in case it is fixed + // at some point. + ip.data = { name, value: value.data, QueryInterface: qi }; + yield ip.data.QueryInterface(Ci.nsIProperty); + } + } + }, + + /** + * See nsIPropertyBag.idl + * + * Note: returns null instead of throwing when the property doesn't exist to + * simplify code and to silence warnings in debug builds. + */ + getProperty: function UpdatePatch_getProperty(name) { + if (this._attrNames.includes(name)) { + throw Components.Exception( + "Illegal value '" + + name + + "' (attribute exists on nsIUpdatePatch) " + + "when calling method: [nsIWritablePropertyBag::getProperty]", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + if (name in this._properties && this._properties[name].present) { + return this._properties[name].data; + } + return null; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIUpdatePatch", + "nsIPropertyBag", + "nsIWritablePropertyBag", + ]), +}; + +/** + * Update + * Implements nsIUpdate + * @param update + * An <update> element to initialize this object with + * @throws if the update contains no patches + * @constructor + */ +function Update(update) { + this._patches = []; + this._properties = {}; + this.isCompleteUpdate = false; + this.channel = "default"; + this.promptWaitTime = Services.prefs.getIntPref( + PREF_APP_UPDATE_PROMPTWAITTIME, + 43200 + ); + this.unsupported = false; + + // Null <update>, assume this is a message container and do no + // further initialization + if (!update) { + return; + } + + for (let i = 0; i < update.childNodes.length; ++i) { + let patchElement = update.childNodes.item(i); + if ( + patchElement.nodeType != patchElement.ELEMENT_NODE || + patchElement.localName != "patch" + ) { + continue; + } + + let patch; + try { + patch = new UpdatePatch(patchElement); + } catch (e) { + continue; + } + this._patches.push(patch); + } + + if (!this._patches.length && !update.hasAttribute("unsupported")) { + throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE); + } + + // Set the installDate value with the current time. If the update has an + // installDate attribute this will be replaced with that value if it doesn't + // equal 0. + this.installDate = new Date().getTime(); + this.patchCount = this._patches.length; + + for (let i = 0; i < update.attributes.length; ++i) { + let attr = update.attributes.item(i); + if (attr.name == "xmlns" || attr.value == "undefined") { + // Don't save the XML namespace or undefined values. + // If an undefined value is saved to the xml file it will be a string when + // it is read from the xml file. + continue; + } else if (attr.name == "detailsURL") { + this.detailsURL = attr.value; + } else if (attr.name == "installDate" && attr.value) { + let val = parseInt(attr.value); + if (val) { + this.installDate = val; + } + } else if (attr.name == "errorCode" && attr.value) { + let val = parseInt(attr.value); + if (val) { + // Set the value of |_errorCode| instead of |errorCode| since + // selectedPatch won't be available at this point and normally the + // nsIUpdatePatch will provide the errorCode. + this._errorCode = val; + } + } else if (attr.name == "isCompleteUpdate") { + this.isCompleteUpdate = attr.value == "true"; + } else if (attr.name == "promptWaitTime") { + if (!isNaN(attr.value)) { + this.promptWaitTime = parseInt(attr.value); + } + } else if (attr.name == "unsupported") { + this.unsupported = attr.value == "true"; + } else { + switch (attr.name) { + case "appVersion": + case "buildID": + case "channel": + case "displayVersion": + case "elevationFailure": + case "name": + case "previousAppVersion": + case "serviceURL": + case "statusText": + case "type": + this[attr.name] = attr.value; + break; + default: + if (!this._attrNames.includes(attr.name)) { + // Set nsIPropertyBag properties that were read from the xml file. + this.setProperty(attr.name, attr.value); + } + break; + } + } + } + + if (!this.previousAppVersion) { + this.previousAppVersion = Services.appinfo.version; + } + + if (!this.elevationFailure) { + this.elevationFailure = false; + } + + if (!this.detailsURL) { + try { + // Try using a default details URL supplied by the distribution + // if the update XML does not supply one. + this.detailsURL = Services.urlFormatter.formatURLPref( + PREF_APP_UPDATE_URL_DETAILS + ); + } catch (e) { + this.detailsURL = ""; + } + } + + if (!this.displayVersion) { + this.displayVersion = this.appVersion; + } + + if (!this.name) { + // When the update doesn't provide a name fallback to using + // "<App Name> <Update App Version>" + let brandBundle = Services.strings.createBundle(URI_BRAND_PROPERTIES); + let appName = brandBundle.GetStringFromName("brandShortName"); + this.name = lazy.gUpdateBundle.formatStringFromName("updateName", [ + appName, + this.displayVersion, + ]); + } +} +Update.prototype = { + // nsIUpdate attribute names used to prevent nsIWritablePropertyBag from over + // writing nsIUpdate attributes. + _attrNames: [ + "appVersion", + "buildID", + "channel", + "detailsURL", + "displayVersion", + "elevationFailure", + "errorCode", + "installDate", + "isCompleteUpdate", + "name", + "previousAppVersion", + "promptWaitTime", + "serviceURL", + "state", + "statusText", + "type", + "unsupported", + ], + + /** + * See nsIUpdateService.idl + */ + getPatchAt: function Update_getPatchAt(index) { + return this._patches[index]; + }, + + /** + * See nsIUpdateService.idl + * + * We use a copy of the state cached on this object in |_state| only when + * there is no selected patch, i.e. in the case when we could not load + * active updates from the update manager for some reason but still have + * the update.status file to work with. + */ + _state: "", + get state() { + if (this.selectedPatch) { + return this.selectedPatch.state; + } + return this._state; + }, + set state(state) { + if (this.selectedPatch) { + this.selectedPatch.state = state; + } + this._state = state; + }, + + /** + * See nsIUpdateService.idl + * + * We use a copy of the errorCode cached on this object in |_errorCode| only + * when there is no selected patch, i.e. in the case when we could not load + * active updates from the update manager for some reason but still have + * the update.status file to work with. + */ + _errorCode: 0, + get errorCode() { + if (this.selectedPatch) { + return this.selectedPatch.errorCode; + } + return this._errorCode; + }, + set errorCode(errorCode) { + if (this.selectedPatch) { + this.selectedPatch.errorCode = errorCode; + } + this._errorCode = errorCode; + }, + + /** + * See nsIUpdateService.idl + */ + get selectedPatch() { + for (let i = 0; i < this.patchCount; ++i) { + if (this._patches[i].selected) { + return this._patches[i]; + } + } + return null; + }, + + /** + * See nsIUpdateService.idl + */ + serialize: function Update_serialize(updates) { + // If appVersion isn't defined just return null. This happens when cleaning + // up invalid updates (e.g. incorrect channel). + if (!this.appVersion) { + return null; + } + let update = updates.createElementNS(URI_UPDATE_NS, "update"); + update.setAttribute("appVersion", this.appVersion); + update.setAttribute("buildID", this.buildID); + update.setAttribute("channel", this.channel); + update.setAttribute("detailsURL", this.detailsURL); + update.setAttribute("displayVersion", this.displayVersion); + update.setAttribute("installDate", this.installDate); + update.setAttribute("isCompleteUpdate", this.isCompleteUpdate); + update.setAttribute("name", this.name); + update.setAttribute("previousAppVersion", this.previousAppVersion); + update.setAttribute("promptWaitTime", this.promptWaitTime); + update.setAttribute("serviceURL", this.serviceURL); + update.setAttribute("type", this.type); + + if (this.statusText) { + update.setAttribute("statusText", this.statusText); + } + if (this.unsupported) { + update.setAttribute("unsupported", this.unsupported); + } + if (this.elevationFailure) { + update.setAttribute("elevationFailure", this.elevationFailure); + } + + for (let [name, value] of Object.entries(this._properties)) { + if (value.present && !this._attrNames.includes(name)) { + update.setAttribute(name, value.data); + } + } + + for (let i = 0; i < this.patchCount; ++i) { + update.appendChild(this.getPatchAt(i).serialize(updates)); + } + + updates.documentElement.appendChild(update); + return update; + }, + + /** + * See nsIWritablePropertyBag.idl + */ + setProperty: function Update_setProperty(name, value) { + if (this._attrNames.includes(name)) { + throw Components.Exception( + "Illegal value '" + + name + + "' (attribute exists on nsIUpdate) " + + "when calling method: [nsIWritablePropertyBag::setProperty]", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + this._properties[name] = { data: value, present: true }; + }, + + /** + * See nsIWritablePropertyBag.idl + */ + deleteProperty: function Update_deleteProperty(name) { + if (this._attrNames.includes(name)) { + throw Components.Exception( + "Illegal value '" + + name + + "' (attribute exists on nsIUpdate) " + + "when calling method: [nsIWritablePropertyBag::deleteProperty]", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + if (name in this._properties) { + this._properties[name].present = false; + } else { + throw Components.Exception("", Cr.NS_ERROR_FAILURE); + } + }, + + /** + * See nsIPropertyBag.idl + * + * Note: this only contains the nsIPropertyBag name value / pairs and not the + * nsIUpdate name / value pairs. + */ + get enumerator() { + return this.enumerate(); + }, + + *enumerate() { + // An nsISupportsInterfacePointer is used so creating an array using + // Array.from will retain the QueryInterface for nsIProperty. + let ip = Cc["@mozilla.org/supports-interface-pointer;1"].createInstance( + Ci.nsISupportsInterfacePointer + ); + let qi = ChromeUtils.generateQI(["nsIProperty"]); + for (let [name, value] of Object.entries(this._properties)) { + if (value.present && !this._attrNames.includes(name)) { + // The nsIPropertyBag enumerator returns a nsISimpleEnumerator whose + // elements are nsIProperty objects. Calling QueryInterface for + // nsIProperty on the object doesn't return to the caller an object that + // is already queried to nsIProperty but do it just in case it is fixed + // at some point. + ip.data = { name, value: value.data, QueryInterface: qi }; + yield ip.data.QueryInterface(Ci.nsIProperty); + } + } + }, + + /** + * See nsIPropertyBag.idl + * Note: returns null instead of throwing when the property doesn't exist to + * simplify code and to silence warnings in debug builds. + */ + getProperty: function Update_getProperty(name) { + if (this._attrNames.includes(name)) { + throw Components.Exception( + "Illegal value '" + + name + + "' (attribute exists on nsIUpdate) " + + "when calling method: [nsIWritablePropertyBag::getProperty]", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + if (name in this._properties && this._properties[name].present) { + return this._properties[name].data; + } + return null; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIUpdate", + "nsIPropertyBag", + "nsIWritablePropertyBag", + ]), +}; + +/** + * UpdateService + * A Service for managing the discovery and installation of software updates. + * @constructor + */ +export function UpdateService() { + LOG("Creating UpdateService"); + // The observor notification to shut down the service must be before + // profile-before-change since nsIUpdateManager uses profile-before-change + // to shutdown and write the update xml files. + Services.obs.addObserver(this, "quit-application"); + // This one call observes PREF_APP_UPDATE_LOG and PREF_APP_UPDATE_LOG_FILE + Services.prefs.addObserver(PREF_APP_UPDATE_LOG, this); + + this._logStatus(); +} + +UpdateService.prototype = { + /** + * The downloader we are using to download updates. There is only ever one of + * these. + */ + _downloader: null, + + /** + * Whether or not the service registered the "online" observer. + */ + _registeredOnlineObserver: false, + + /** + * The current number of consecutive socket errors + */ + _consecutiveSocketErrors: 0, + + /** + * A timer used to retry socket errors + */ + _retryTimer: null, + + /** + * Whether or not a background update check was initiated by the + * application update timer notification. + */ + _isNotify: true, + + /** + * Handle Observer Service notifications + * @param subject + * The subject of the notification + * @param topic + * The notification name + * @param data + * Additional data + */ + observe: async function AUS_observe(subject, topic, data) { + switch (topic) { + case "post-update-processing": + // This pref was not cleared out of profiles after it stopped being used + // (Bug 1420514), so clear it out on the next update to avoid confusion + // regarding its use. + Services.prefs.clearUserPref("app.update.enabled"); + Services.prefs.clearUserPref("app.update.BITS.inTrialGroup"); + + // Background tasks do not notify any delayed startup notifications. + if ( + !lazy.gIsBackgroundTaskMode && + Services.appinfo.ID in APPID_TO_TOPIC + ) { + // Delay post-update processing to ensure that possible update + // dialogs are shown in front of the app window, if possible. + // See bug 311614. + Services.obs.addObserver(this, APPID_TO_TOPIC[Services.appinfo.ID]); + break; + } + // intentional fallthrough + case "sessionstore-windows-restored": + case "mail-startup-done": + // Background tasks do not notify any delayed startup notifications. + if ( + !lazy.gIsBackgroundTaskMode && + Services.appinfo.ID in APPID_TO_TOPIC + ) { + Services.obs.removeObserver( + this, + APPID_TO_TOPIC[Services.appinfo.ID] + ); + } + // intentional fallthrough + case "test-post-update-processing": + // Clean up any extant updates + await this._postUpdateProcessing(); + break; + case "network:offline-status-changed": + await this._offlineStatusChanged(data); + break; + case "nsPref:changed": + if (data == PREF_APP_UPDATE_LOG || data == PREF_APP_UPDATE_LOG_FILE) { + lazy.gLogEnabled; // Assigning this before it is lazy-loaded is an error. + lazy.gLogEnabled = + Services.prefs.getBoolPref(PREF_APP_UPDATE_LOG, false) || + Services.prefs.getBoolPref(PREF_APP_UPDATE_LOG_FILE, false); + } + if (data == PREF_APP_UPDATE_LOG_FILE) { + lazy.gLogfileEnabled; // Assigning this before it is lazy-loaded is an + // error. + lazy.gLogfileEnabled = Services.prefs.getBoolPref( + PREF_APP_UPDATE_LOG_FILE, + false + ); + if (lazy.gLogfileEnabled) { + this._logStatus(); + } + } + break; + case "quit-application": + Services.obs.removeObserver(this, topic); + Services.prefs.removeObserver(PREF_APP_UPDATE_LOG, this); + + if (AppConstants.platform == "win" && gUpdateMutexHandle) { + // If we hold the update mutex, let it go! + // The OS would clean this up sometime after shutdown, + // but that would have no guarantee on timing. + closeHandle(gUpdateMutexHandle); + gUpdateMutexHandle = null; + } + if (this._retryTimer) { + this._retryTimer.cancel(); + } + + // When downloading an update with nsIIncrementalDownload the download + // is stopped when the quit-application observer notification is + // received and networking hasn't started to shutdown. The download will + // be resumed the next time the application starts. Downloads using + // Windows BITS are not stopped since they don't require Firefox to be + // running to perform the download. + if (this._downloader) { + if (this._downloader.usingBits) { + await this._downloader.cleanup(); + } else { + // stopDownload() calls _downloader.cleanup() + await this.stopDownload(); + } + } + // Prevent leaking the downloader (bug 454964) + this._downloader = null; + // In case any update checks are in progress. + lazy.CheckSvc.stopAllChecks(); + + if (gLogfileOutputStream) { + gLogfileOutputStream.close(); + } + break; + case "test-close-handle-update-mutex": + if (Cu.isInAutomation) { + if (AppConstants.platform == "win" && gUpdateMutexHandle) { + LOG("UpdateService:observe - closing mutex handle for testing"); + closeHandle(gUpdateMutexHandle); + gUpdateMutexHandle = null; + } + } + break; + } + }, + + /** + * The following needs to happen during the post-update-processing + * notification from nsUpdateServiceStub.js: + * 1. post update processing + * 2. resume of a download that was in progress during a previous session + * 3. start of a complete update download after the failure to apply a partial + * update + */ + + /** + * Perform post-processing on updates lingering in the updates directory + * from a previous application session - either report install failures (and + * optionally attempt to fetch a different version if appropriate) or + * notify the user of install success. + */ + /* eslint-disable-next-line complexity */ + _postUpdateProcessing: async function AUS__postUpdateProcessing() { + if (this.disabled) { + // This function is a point when we can potentially enter the update + // system, even with update disabled. Make sure that we do not continue + // because update code can have side effects that are visible to the user + // and give the impression that updates are enabled. For example, if we + // can't write to the update directory, we might complain to the user that + // update is broken and they should reinstall. + return; + } + if (!this.canCheckForUpdates) { + LOG( + "UpdateService:_postUpdateProcessing - unable to check for " + + "updates... returning early" + ); + return; + } + let status = readStatusFile(getReadyUpdateDir()); + LOG(`UpdateService:_postUpdateProcessing - status = "${status}"`); + + if (!this.canApplyUpdates) { + LOG( + "UpdateService:_postUpdateProcessing - unable to apply " + + "updates... returning early" + ); + if (hasUpdateMutex()) { + // If the update is present in the update directory somehow, + // it would prevent us from notifying the user of further updates. + LOG( + "UpdateService:_postUpdateProcessing - Cleaning up active updates." + ); + cleanupActiveUpdates(); + } + return; + } + + let updates = []; + if (lazy.UM.readyUpdate) { + updates.push(lazy.UM.readyUpdate); + } + if (lazy.UM.downloadingUpdate) { + updates.push(lazy.UM.downloadingUpdate); + } + + if (status == STATE_NONE) { + // A status of STATE_NONE in _postUpdateProcessing means that the + // update.status file is present but there isn't an update in progress. + // This isn't an expected state, so if we find ourselves in it, we want + // to just clean things up to go back to a good state. + LOG( + "UpdateService:_postUpdateProcessing - Cleaning up unexpected state." + ); + if (!updates.length) { + updates.push(new Update(null)); + } + for (let update of updates) { + update.state = STATE_FAILED; + update.errorCode = ERR_UPDATE_STATE_NONE; + update.statusText = + lazy.gUpdateBundle.GetStringFromName("statusFailed"); + } + let newStatus = STATE_FAILED + ": " + ERR_UPDATE_STATE_NONE; + pingStateAndStatusCodes(updates[0], true, newStatus); + cleanupActiveUpdates(); + return; + } + + let channelChanged = updates => { + for (let update of updates) { + if (update.channel != lazy.UpdateUtils.UpdateChannel) { + return true; + } + } + return false; + }; + if (channelChanged(updates)) { + let channel = lazy.UM.readyUpdate + ? lazy.UM.readyUpdate.channel + : lazy.UM.downloadingUpdate.channel; + LOG( + "UpdateService:_postUpdateProcessing - update channel is " + + "different than application's channel, removing update. update " + + "channel: " + + channel + + ", expected channel: " + + lazy.UpdateUtils.UpdateChannel + ); + // User switched channels, clear out the old active updates and remove + // partial downloads + for (let update of updates) { + update.state = STATE_FAILED; + update.errorCode = ERR_CHANNEL_CHANGE; + update.statusText = + lazy.gUpdateBundle.GetStringFromName("statusFailed"); + } + let newStatus = STATE_FAILED + ": " + ERR_CHANNEL_CHANGE; + pingStateAndStatusCodes(updates[0], true, newStatus); + cleanupActiveUpdates(); + return; + } + + // Handle the case when the update is the same or older than the current + // version and nsUpdateDriver.cpp skipped updating due to the version being + // older than the current version. This also handles the general case when + // an update is for an older version or the same version and same build ID. + if ( + status == STATE_PENDING || + status == STATE_PENDING_SERVICE || + status == STATE_APPLIED || + status == STATE_APPLIED_SERVICE || + status == STATE_PENDING_ELEVATE || + status == STATE_DOWNLOADING + ) { + let tooOldUpdate; + if ( + updateIsAtLeastAsOldAs( + lazy.UM.readyUpdate, + Services.appinfo.version, + Services.appinfo.appBuildID + ) + ) { + tooOldUpdate = lazy.UM.readyUpdate; + } else if ( + updateIsAtLeastAsOldAs( + lazy.UM.downloadingUpdate, + Services.appinfo.version, + Services.appinfo.appBuildID + ) + ) { + tooOldUpdate = lazy.UM.downloadingUpdate; + } + if (tooOldUpdate) { + LOG( + "UpdateService:_postUpdateProcessing - removing update for older " + + "application version or same application version with same build " + + "ID. update application version: " + + tooOldUpdate.appVersion + + ", " + + "application version: " + + Services.appinfo.version + + ", update " + + "build ID: " + + tooOldUpdate.buildID + + ", application build ID: " + + Services.appinfo.appBuildID + ); + tooOldUpdate.state = STATE_FAILED; + tooOldUpdate.statusText = + lazy.gUpdateBundle.GetStringFromName("statusFailed"); + tooOldUpdate.errorCode = ERR_OLDER_VERSION_OR_SAME_BUILD; + // This could be split out to report telemetry for each case. + let newStatus = STATE_FAILED + ": " + ERR_OLDER_VERSION_OR_SAME_BUILD; + pingStateAndStatusCodes(tooOldUpdate, true, newStatus); + // Cleanup both updates regardless of which one is too old. It's + // exceedingly unlikely that a user could end up in a state where one + // update is acceptable and the other isn't. And it makes this function + // considerably more complex to try to deal with that possibility. + cleanupActiveUpdates(); + return; + } + } + + pingStateAndStatusCodes( + status == STATE_DOWNLOADING + ? lazy.UM.downloadingUpdate + : lazy.UM.readyUpdate, + true, + status + ); + if (lazy.UM.downloadingUpdate || status == STATE_DOWNLOADING) { + if (status == STATE_SUCCEEDED) { + // If we successfully installed an update while we were downloading + // another update, the downloading update is now a partial MAR for + // a version that is no longer installed. We know that it's a partial + // MAR without checking because we currently only download partial MARs + // when an update has already been downloaded. + LOG( + "UpdateService:_postUpdateProcessing - removing downloading patch " + + "because we installed a different patch before it finished" + + "downloading." + ); + cleanupDownloadingUpdate(); + } else { + // Attempt to resume download + if (lazy.UM.downloadingUpdate) { + LOG( + "UpdateService:_postUpdateProcessing - resuming patch found in " + + "downloading state" + ); + let success = await this.downloadUpdate(lazy.UM.downloadingUpdate); + if (!success) { + LOG( + "UpdateService:_postUpdateProcessing - Failed to resume patch. " + + "Cleaning up downloading update." + ); + cleanupDownloadingUpdate(); + } + } else { + LOG( + "UpdateService:_postUpdateProcessing - Warning: found " + + "downloading state, but no downloading patch. Cleaning up " + + "active updates." + ); + // Put ourselves back in a good state. + cleanupActiveUpdates(); + } + if (status == STATE_DOWNLOADING) { + // Done dealing with the downloading update, and there is no ready + // update, so return early. + return; + } + } + } + + let update = lazy.UM.readyUpdate; + + if (status == STATE_APPLYING) { + // This indicates that the background updater service is in either of the + // following two states: + // 1. It is in the process of applying an update in the background, and + // we just happen to be racing against that. + // 2. It has failed to apply an update for some reason, and we hit this + // case because the updater process has set the update status to + // applying, but has never finished. + // In order to differentiate between these two states, we look at the + // state field of the update object. If it's "pending", then we know + // that this is the first time we're hitting this case, so we switch + // that state to "applying" and we just wait and hope for the best. + // If it's "applying", we know that we've already been here once, so + // we really want to start from a clean state. + if ( + update && + (update.state == STATE_PENDING || update.state == STATE_PENDING_SERVICE) + ) { + LOG( + "UpdateService:_postUpdateProcessing - patch found in applying " + + "state for the first time" + ); + update.state = STATE_APPLYING; + lazy.UM.saveUpdates(); + transitionState(Ci.nsIApplicationUpdateService.STATE_STAGING); + pollForStagingEnd(); + } else { + // We get here even if we don't have an update object + LOG( + "UpdateService:_postUpdateProcessing - patch found in applying " + + "state for the second time. Cleaning up ready update." + ); + cleanupReadyUpdate(); + } + return; + } + + if (!update) { + if (status != STATE_SUCCEEDED) { + LOG( + "UpdateService:_postUpdateProcessing - previous patch failed " + + "and no patch available. Cleaning up ready update." + ); + cleanupReadyUpdate(); + return; + } + LOG( + "UpdateService:_postUpdateProcessing - Update data missing. Creating " + + "an empty update object." + ); + update = new Update(null); + } + + let parts = status.split(":"); + update.state = parts[0]; + LOG( + `UpdateService:_postUpdateProcessing - Setting update's state from ` + + `the status file (="${update.state}")` + ); + if (update.state == STATE_FAILED && parts[1]) { + update.errorCode = parseInt(parts[1]); + LOG( + `UpdateService:_postUpdateProcessing - Setting update's errorCode ` + + `from the status file (="${update.errorCode}")` + ); + } + + if (status != STATE_SUCCEEDED) { + // Rotate the update logs so the update log isn't removed. By passing + // false the patch directory won't be removed. + cleanUpReadyUpdateDir(false); + } + + if (status == STATE_SUCCEEDED) { + if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS)) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS); + } + update.statusText = + lazy.gUpdateBundle.GetStringFromName("installSuccess"); + + // The only time that update is not a reference to readyUpdate is when + // readyUpdate is null. + if (!lazy.UM.readyUpdate) { + LOG( + "UpdateService:_postUpdateProcessing - Assigning successful update " + + "readyUpdate before cleaning it up." + ); + lazy.UM.readyUpdate = update; + } + + // Done with this update. Clean it up. + LOG( + "UpdateService:_postUpdateProcessing - Cleaning up successful ready " + + "update." + ); + cleanupReadyUpdate(); + + Services.prefs.setIntPref(PREF_APP_UPDATE_ELEVATE_ATTEMPTS, 0); + } else if (status == STATE_PENDING_ELEVATE) { + // In case the active-update.xml file is deleted. + if (!update) { + LOG( + "UpdateService:_postUpdateProcessing - status is pending-elevate " + + "but there isn't a ready update, removing update" + ); + cleanupReadyUpdate(); + } else { + transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING); + if (Services.startup.wasSilentlyStarted) { + // This check _should_ be unnecessary since we should not silently + // restart if state == pending-elevate. But the update elevation + // dialog is a way that we could potentially show UI on startup, even + // with no windows open. Which we really do not want to do on a silent + // restart. + // So this is defense in depth. + LOG( + "UpdateService:_postUpdateProcessing - status is " + + "pending-elevate, but this is a silent startup, so the " + + "elevation window has been suppressed." + ); + } else { + LOG( + "UpdateService:_postUpdateProcessing - status is " + + "pending-elevate. Showing Update elevation dialog." + ); + let uri = "chrome://mozapps/content/update/updateElevation.xhtml"; + let features = + "chrome,centerscreen,resizable=no,titlebar,toolbar=no,dialog=no"; + Services.ww.openWindow(null, uri, "Update:Elevation", features, null); + } + } + } else { + // If there was an I/O error it is assumed that the patch is not invalid + // and it is set to pending so an attempt to apply it again will happen + // when the application is restarted. + if (update.state == STATE_FAILED && update.errorCode) { + LOG( + "UpdateService:_postUpdateProcessing - Attempting handleUpdateFailure" + ); + if (handleUpdateFailure(update)) { + LOG( + "UpdateService:_postUpdateProcessing - handleUpdateFailure success." + ); + return; + } + } + + LOG( + "UpdateService:_postUpdateProcessing - Attempting to fall back to a " + + "complete update." + ); + // Something went wrong with the patch application process. + await handleFallbackToCompleteUpdate(); + } + }, + + /** + * Register an observer when the network comes online, so we can short-circuit + * the app.update.interval when there isn't connectivity + */ + _registerOnlineObserver: function AUS__registerOnlineObserver() { + if (this._registeredOnlineObserver) { + LOG( + "UpdateService:_registerOnlineObserver - observer already registered" + ); + return; + } + + LOG( + "UpdateService:_registerOnlineObserver - waiting for the network to " + + "be online, then forcing another check" + ); + + Services.obs.addObserver(this, "network:offline-status-changed"); + this._registeredOnlineObserver = true; + }, + + /** + * Called from the network:offline-status-changed observer. + */ + _offlineStatusChanged: async function AUS__offlineStatusChanged(status) { + if (status !== "online") { + return; + } + + Services.obs.removeObserver(this, "network:offline-status-changed"); + this._registeredOnlineObserver = false; + + LOG( + "UpdateService:_offlineStatusChanged - network is online, forcing " + + "another background check" + ); + + // the background checker is contained in notify + await this._attemptResume(); + }, + + /** + * See nsIUpdateService.idl + */ + onCheckComplete: async function AUS_onCheckComplete(result) { + if (result.succeeded) { + await this._selectAndInstallUpdate(result.updates); + return; + } + + if (!result.checksAllowed) { + LOG("UpdateService:onCheckComplete - checks not allowed"); + return; + } + + // On failure, result.updates is guaranteed to have exactly one update + // containing error information. + let update = result.updates[0]; + + LOG( + "UpdateService:onCheckComplete - error during background update. error " + + "code: " + + update.errorCode + + ", status text: " + + update.statusText + ); + + if (update.errorCode == NETWORK_ERROR_OFFLINE) { + // Register an online observer to try again + this._registerOnlineObserver(); + if (this._pingSuffix) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_OFFLINE); + } + return; + } + + // Send the error code to telemetry + AUSTLMY.pingCheckExError(this._pingSuffix, update.errorCode); + update.errorCode = BACKGROUNDCHECK_MULTIPLE_FAILURES; + let errCount = Services.prefs.getIntPref( + PREF_APP_UPDATE_BACKGROUNDERRORS, + 0 + ); + + // If we already have an update ready, we don't want to worry the user over + // update check failures. As far as the user knows, the update status is + // the status of the ready update. We don't want to confuse them by saying + // that an update check failed. + if (lazy.UM.readyUpdate) { + LOG( + "UpdateService:onCheckComplete - Ignoring error because another " + + "update is ready." + ); + return; + } + + errCount++; + Services.prefs.setIntPref(PREF_APP_UPDATE_BACKGROUNDERRORS, errCount); + // Don't allow the preference to set a value greater than 20 for max errors. + let maxErrors = Math.min( + Services.prefs.getIntPref(PREF_APP_UPDATE_BACKGROUNDMAXERRORS, 10), + 20 + ); + + if (errCount >= maxErrors) { + LOG( + "UpdateService:onCheckComplete - notifying observers of error. " + + "topic: update-error, status: check-attempts-exceeded" + ); + Services.obs.notifyObservers( + update, + "update-error", + "check-attempts-exceeded" + ); + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_GENERAL_ERROR_PROMPT); + } else { + LOG( + "UpdateService:onCheckComplete - notifying observers of error. " + + "topic: update-error, status: check-attempt-failed" + ); + Services.obs.notifyObservers( + update, + "update-error", + "check-attempt-failed" + ); + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_GENERAL_ERROR_SILENT); + } + }, + + /** + * Called when a connection should be resumed + */ + _attemptResume: async function AUS_attemptResume() { + LOG("UpdateService:_attemptResume"); + // If a download is in progress and we aren't already downloading it, then + // resume it. + if (this.isDownloading) { + // There is nothing to resume. We are already downloading. + LOG("UpdateService:_attemptResume - already downloading."); + return; + } + if ( + this._downloader && + this._downloader._patch && + this._downloader._patch.state == STATE_DOWNLOADING && + this._downloader._update + ) { + LOG( + "UpdateService:_attemptResume - _patch.state: " + + this._downloader._patch.state + ); + let success = await this.downloadUpdate(this._downloader._update); + LOG("UpdateService:_attemptResume - downloadUpdate success: " + success); + if (!success) { + LOG( + "UpdateService:_attemptResume - Resuming download failed. Cleaning " + + "up downloading update." + ); + cleanupDownloadingUpdate(); + } + return; + } + + // Kick off an update check + (async () => { + let check = lazy.CheckSvc.checkForUpdates(lazy.CheckSvc.BACKGROUND_CHECK); + await this.onCheckComplete(await check.result); + })(); + }, + + /** + * Notified when a timer fires + * @param timer + * The timer that fired + */ + notify: function AUS_notify(timer) { + this._checkForBackgroundUpdates(true); + }, + + /** + * See nsIUpdateService.idl + */ + checkForBackgroundUpdates: function AUS_checkForBackgroundUpdates() { + return this._checkForBackgroundUpdates(false); + }, + + // The suffix used for background update check telemetry histogram ID's. + get _pingSuffix() { + if (lazy.UM.readyUpdate) { + // Once an update has been downloaded, all later updates will be reported + // to telemetry as subsequent updates. We move the first update into + // readyUpdate as soon as the download is complete, so any update checks + // after readyUpdate is no longer null are subsequent update checks. + return AUSTLMY.SUBSEQUENT; + } + return this._isNotify ? AUSTLMY.NOTIFY : AUSTLMY.EXTERNAL; + }, + + /** + * Checks for updates in the background. + * @param isNotify + * Whether or not a background update check was initiated by the + * application update timer notification. + */ + _checkForBackgroundUpdates: function AUS__checkForBackgroundUpdates( + isNotify + ) { + if (!this.disabled && AppConstants.NIGHTLY_BUILD) { + // Scalar ID: update.suppress_prompts + AUSTLMY.pingSuppressPrompts(); + } + if (this.disabled || this.manualUpdateOnly) { + // Return immediately if we are disabled by policy. Otherwise, just the + // telemetry we try to collect below can potentially trigger a restart + // prompt if the update directory isn't writable. And we shouldn't be + // telling the user about update failures if update is disabled. + // See Bug 1599590. + // Note that we exit unconditionally here if we are only doing manual + // update checks, because manual update checking uses a completely + // different code path (AppUpdater.jsm creates its own nsIUpdateChecker), + // bypassing this function completely. + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_DISABLED_BY_POLICY); + return false; + } + + this._isNotify = isNotify; + + // Histogram IDs: + // UPDATE_PING_COUNT_EXTERNAL + // UPDATE_PING_COUNT_NOTIFY + // UPDATE_PING_COUNT_SUBSEQUENT + AUSTLMY.pingGeneric("UPDATE_PING_COUNT_" + this._pingSuffix, true, false); + + // Histogram IDs: + // UPDATE_UNABLE_TO_APPLY_EXTERNAL + // UPDATE_UNABLE_TO_APPLY_NOTIFY + // UPDATE_UNABLE_TO_APPLY_SUBSEQUENT + AUSTLMY.pingGeneric( + "UPDATE_UNABLE_TO_APPLY_" + this._pingSuffix, + getCanApplyUpdates(), + true + ); + // Histogram IDs: + // UPDATE_CANNOT_STAGE_EXTERNAL + // UPDATE_CANNOT_STAGE_NOTIFY + // UPDATE_CANNOT_STAGE_SUBSEQUENT + AUSTLMY.pingGeneric( + "UPDATE_CANNOT_STAGE_" + this._pingSuffix, + getCanStageUpdates(), + true + ); + if (AppConstants.platform == "win") { + // Histogram IDs: + // UPDATE_CAN_USE_BITS_EXTERNAL + // UPDATE_CAN_USE_BITS_NOTIFY + // UPDATE_CAN_USE_BITS_SUBSEQUENT + AUSTLMY.pingGeneric( + "UPDATE_CAN_USE_BITS_" + this._pingSuffix, + getCanUseBits() + ); + } + // Histogram IDs: + // UPDATE_INVALID_LASTUPDATETIME_EXTERNAL + // UPDATE_INVALID_LASTUPDATETIME_NOTIFY + // UPDATE_INVALID_LASTUPDATETIME_SUBSEQUENT + // UPDATE_LAST_NOTIFY_INTERVAL_DAYS_EXTERNAL + // UPDATE_LAST_NOTIFY_INTERVAL_DAYS_NOTIFY + // UPDATE_LAST_NOTIFY_INTERVAL_DAYS_SUBSEQUENT + AUSTLMY.pingLastUpdateTime(this._pingSuffix); + // Histogram IDs: + // UPDATE_NOT_PREF_UPDATE_AUTO_EXTERNAL + // UPDATE_NOT_PREF_UPDATE_AUTO_NOTIFY + // UPDATE_NOT_PREF_UPDATE_AUTO_SUBSEQUENT + lazy.UpdateUtils.getAppUpdateAutoEnabled().then(enabled => { + AUSTLMY.pingGeneric( + "UPDATE_NOT_PREF_UPDATE_AUTO_" + this._pingSuffix, + enabled, + true + ); + }); + // Histogram IDs: + // UPDATE_NOT_PREF_UPDATE_STAGING_ENABLED_EXTERNAL + // UPDATE_NOT_PREF_UPDATE_STAGING_ENABLED_NOTIFY + // UPDATE_NOT_PREF_UPDATE_STAGING_ENABLED_SUBSEQUENT + AUSTLMY.pingBoolPref( + "UPDATE_NOT_PREF_UPDATE_STAGING_ENABLED_" + this._pingSuffix, + PREF_APP_UPDATE_STAGING_ENABLED, + true, + true + ); + if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { + // Histogram IDs: + // UPDATE_PREF_UPDATE_CANCELATIONS_EXTERNAL + // UPDATE_PREF_UPDATE_CANCELATIONS_NOTIFY + // UPDATE_PREF_UPDATE_CANCELATIONS_SUBSEQUENT + AUSTLMY.pingIntPref( + "UPDATE_PREF_UPDATE_CANCELATIONS_" + this._pingSuffix, + PREF_APP_UPDATE_CANCELATIONS, + 0, + 0 + ); + } + if (AppConstants.MOZ_MAINTENANCE_SERVICE) { + // Histogram IDs: + // UPDATE_NOT_PREF_UPDATE_SERVICE_ENABLED_EXTERNAL + // UPDATE_NOT_PREF_UPDATE_SERVICE_ENABLED_NOTIFY + // UPDATE_NOT_PREF_UPDATE_SERVICE_ENABLED_SUBSEQUENT + AUSTLMY.pingBoolPref( + "UPDATE_NOT_PREF_UPDATE_SERVICE_ENABLED_" + this._pingSuffix, + PREF_APP_UPDATE_SERVICE_ENABLED, + true + ); + // Histogram IDs: + // UPDATE_PREF_SERVICE_ERRORS_EXTERNAL + // UPDATE_PREF_SERVICE_ERRORS_NOTIFY + // UPDATE_PREF_SERVICE_ERRORS_SUBSEQUENT + AUSTLMY.pingIntPref( + "UPDATE_PREF_SERVICE_ERRORS_" + this._pingSuffix, + PREF_APP_UPDATE_SERVICE_ERRORS, + 0, + 0 + ); + if (AppConstants.platform == "win") { + // Histogram IDs: + // UPDATE_SERVICE_INSTALLED_EXTERNAL + // UPDATE_SERVICE_INSTALLED_NOTIFY + // UPDATE_SERVICE_INSTALLED_SUBSEQUENT + // UPDATE_SERVICE_MANUALLY_UNINSTALLED_EXTERNAL + // UPDATE_SERVICE_MANUALLY_UNINSTALLED_NOTIFY + // UPDATE_SERVICE_MANUALLY_UNINSTALLED_SUBSEQUENT + AUSTLMY.pingServiceInstallStatus( + this._pingSuffix, + isServiceInstalled() + ); + } + } + + // If a download is in progress or the patch has been staged do nothing. + if (this.isDownloading) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_IS_DOWNLOADING); + return false; + } + + // Once we have downloaded a complete update, do not download further + // updates until the complete update is installed. This is important, + // because if we fall back from a partial update to a complete update, + // it might be because of changes to the patch directory (which would cause + // a failure to apply any partial MAR). So we really don't want to replace + // a downloaded complete update with a downloaded partial update. And we + // do not currently download complete updates if there is already a + // readyUpdate available. + if ( + lazy.UM.readyUpdate && + lazy.UM.readyUpdate.selectedPatch && + lazy.UM.readyUpdate.selectedPatch.type == "complete" + ) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_IS_DOWNLOADED); + return false; + } + + // If we start downloading an update while the readyUpdate is staging, we + // run the risk of eventually wanting to overwrite readyUpdate with the + // downloadingUpdate while the readyUpdate is still staging. Then we would + // have to have a weird intermediate state where the downloadingUpdate has + // finished downloading, but can't be moved yet. It's simpler to just not + // start a new update if the old one is still staging. + if (this.currentState == Ci.nsIApplicationUpdateService.STATE_STAGING) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_IS_DOWNLOADED); + return false; + } + + // Asynchronously kick off update checking + (async () => { + let validUpdateURL = true; + try { + await lazy.CheckSvc.getUpdateURL(lazy.CheckSvc.BACKGROUND_CHECK); + } catch (e) { + validUpdateURL = false; + } + + // The following checks are done here so they can be differentiated from + // foreground checks. + if (!lazy.UpdateUtils.OSVersion) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_NO_OS_VERSION); + } else if (!lazy.UpdateUtils.ABI) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_NO_OS_ABI); + } else if (!validUpdateURL) { + AUSTLMY.pingCheckCode( + this._pingSuffix, + AUSTLMY.CHK_INVALID_DEFAULT_URL + ); + } else if (!hasUpdateMutex()) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_NO_MUTEX); + } else if (isOtherInstanceRunning()) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_OTHER_INSTANCE); + } else if (!this.canCheckForUpdates) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_UNABLE_TO_CHECK); + } + + let check = lazy.CheckSvc.checkForUpdates(lazy.CheckSvc.BACKGROUND_CHECK); + await this.onCheckComplete(await check.result); + })(); + + return true; + }, + + /** + * Determine the update from the specified updates that should be offered. + * If both valid major and minor updates are available the minor update will + * be offered. + * @param updates + * An array of available nsIUpdate items + * @return The nsIUpdate to offer. + */ + selectUpdate: function AUS_selectUpdate(updates) { + if (!updates.length) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_NO_UPDATE_FOUND); + return null; + } + + // The ping for unsupported is sent after the call to showPrompt. + if (updates.length == 1 && updates[0].unsupported) { + return updates[0]; + } + + // Choose the newest of the available minor and major updates. + var majorUpdate = null; + var minorUpdate = null; + var vc = Services.vc; + let lastCheckCode = AUSTLMY.CHK_NO_COMPAT_UPDATE_FOUND; + + updates.forEach(function (aUpdate) { + // Ignore updates for older versions of the application and updates for + // the same version of the application with the same build ID. + if (updateIsAtLeastAsOldAsCurrentVersion(aUpdate)) { + LOG( + "UpdateService:selectUpdate - skipping update because the " + + "update's application version is not greater than the current " + + "application version" + ); + lastCheckCode = AUSTLMY.CHK_UPDATE_PREVIOUS_VERSION; + return; + } + + if (updateIsAtLeastAsOldAsReadyUpdate(aUpdate)) { + LOG( + "UpdateService:selectUpdate - skipping update because the " + + "update's application version is not greater than that of the " + + "currently downloaded update" + ); + lastCheckCode = AUSTLMY.CHK_UPDATE_PREVIOUS_VERSION; + return; + } + + if (lazy.UM.readyUpdate && !getPatchOfType(aUpdate, "partial")) { + LOG( + "UpdateService:selectUpdate - skipping update because no partial " + + "patch is available and an update has already been downloaded." + ); + lastCheckCode = AUSTLMY.CHK_NO_PARTIAL_PATCH; + return; + } + + switch (aUpdate.type) { + case "major": + if (!majorUpdate) { + majorUpdate = aUpdate; + } else if ( + vc.compare(majorUpdate.appVersion, aUpdate.appVersion) <= 0 + ) { + majorUpdate = aUpdate; + } + break; + case "minor": + if (!minorUpdate) { + minorUpdate = aUpdate; + } else if ( + vc.compare(minorUpdate.appVersion, aUpdate.appVersion) <= 0 + ) { + minorUpdate = aUpdate; + } + break; + default: + LOG( + "UpdateService:selectUpdate - skipping unknown update type: " + + aUpdate.type + ); + lastCheckCode = AUSTLMY.CHK_UPDATE_INVALID_TYPE; + break; + } + }); + + let update = minorUpdate || majorUpdate; + if (AppConstants.platform == "macosx" && update) { + if (getElevationRequired()) { + let installAttemptVersion = Services.prefs.getCharPref( + PREF_APP_UPDATE_ELEVATE_VERSION, + null + ); + if (vc.compare(installAttemptVersion, update.appVersion) != 0) { + Services.prefs.setCharPref( + PREF_APP_UPDATE_ELEVATE_VERSION, + update.appVersion + ); + if ( + Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS_OSX) + ) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS_OSX); + } + if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_ELEVATE_NEVER)) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_ELEVATE_NEVER); + } + } else { + let numCancels = Services.prefs.getIntPref( + PREF_APP_UPDATE_CANCELATIONS_OSX, + 0 + ); + let rejectedVersion = Services.prefs.getCharPref( + PREF_APP_UPDATE_ELEVATE_NEVER, + "" + ); + let maxCancels = Services.prefs.getIntPref( + PREF_APP_UPDATE_CANCELATIONS_OSX_MAX, + DEFAULT_CANCELATIONS_OSX_MAX + ); + if (numCancels >= maxCancels) { + LOG( + "UpdateService:selectUpdate - the user requires elevation to " + + "install this update, but the user has exceeded the max " + + "number of elevation attempts." + ); + update.elevationFailure = true; + AUSTLMY.pingCheckCode( + this._pingSuffix, + AUSTLMY.CHK_ELEVATION_DISABLED_FOR_VERSION + ); + } else if (vc.compare(rejectedVersion, update.appVersion) == 0) { + LOG( + "UpdateService:selectUpdate - the user requires elevation to " + + "install this update, but elevation is disabled for this " + + "version." + ); + update.elevationFailure = true; + AUSTLMY.pingCheckCode( + this._pingSuffix, + AUSTLMY.CHK_ELEVATION_OPTOUT_FOR_VERSION + ); + } else { + LOG( + "UpdateService:selectUpdate - the user requires elevation to " + + "install the update." + ); + } + } + } else { + // Clear elevation-related prefs since they no longer apply (the user + // may have gained write access to the Firefox directory or an update + // was executed with a different profile). + if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_ELEVATE_VERSION)) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_ELEVATE_VERSION); + } + if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS_OSX)) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS_OSX); + } + if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_ELEVATE_NEVER)) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_ELEVATE_NEVER); + } + } + } else if (!update) { + AUSTLMY.pingCheckCode(this._pingSuffix, lastCheckCode); + } + + return update; + }, + + /** + * Determine which of the specified updates should be installed and begin the + * download/installation process or notify the user about the update. + * @param updates + * An array of available updates + */ + _selectAndInstallUpdate: async function AUS__selectAndInstallUpdate(updates) { + // Return early if there's an active update. The user is already aware and + // is downloading or performed some user action to prevent notification. + if (lazy.UM.downloadingUpdate) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_HAS_ACTIVEUPDATE); + return; + } + + if (this.disabled) { + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_DISABLED_BY_POLICY); + LOG( + "UpdateService:_selectAndInstallUpdate - not prompting because " + + "update is disabled" + ); + return; + } + + var update = this.selectUpdate(updates); + if (!update || update.elevationFailure) { + return; + } + + if (update.unsupported) { + LOG( + "UpdateService:_selectAndInstallUpdate - update not supported for " + + "this system. Notifying observers. topic: update-available, " + + "status: unsupported" + ); + Services.obs.notifyObservers(update, "update-available", "unsupported"); + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_UNSUPPORTED); + return; + } + + if (!getCanApplyUpdates()) { + LOG( + "UpdateService:_selectAndInstallUpdate - the user is unable to " + + "apply updates... prompting. Notifying observers. " + + "topic: update-available, status: cant-apply" + ); + Services.obs.notifyObservers(null, "update-available", "cant-apply"); + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_UNABLE_TO_APPLY); + return; + } + + /** + * From this point on there are two possible outcomes: + * 1. download and install the update automatically + * 2. notify the user about the availability of an update + * + * Notes: + * a) if the app.update.auto preference is false then automatic download and + * install is disabled and the user will be notified. + * + * If the update when it is first read does not have an appVersion attribute + * the following deprecated behavior will occur: + * Update Type Outcome + * Major Notify + * Minor Auto Install + */ + let updateAuto = await lazy.UpdateUtils.getAppUpdateAutoEnabled(); + if (!updateAuto) { + LOG( + "UpdateService:_selectAndInstallUpdate - prompting because silent " + + "install is disabled. Notifying observers. topic: update-available, " + + "status: show-prompt" + ); + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_SHOWPROMPT_PREF); + Services.obs.notifyObservers(update, "update-available", "show-prompt"); + return; + } + + LOG("UpdateService:_selectAndInstallUpdate - download the update"); + let success = await this.downloadUpdate(update); + if (!success && !this.isDownloading) { + LOG( + "UpdateService:_selectAndInstallUpdate - Failed to start downloading " + + "update. Cleaning up downloading update." + ); + cleanupDownloadingUpdate(); + } + AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_DOWNLOAD_UPDATE); + }, + + get disabledForTesting() { + return ( + (Cu.isInAutomation || + lazy.Marionette.running || + lazy.RemoteAgent.running) && + Services.prefs.getBoolPref(PREF_APP_UPDATE_DISABLEDFORTESTING, false) + ); + }, + + /** + * See nsIUpdateService.idl + */ + get disabled() { + return ( + (Services.policies && !Services.policies.isAllowed("appUpdate")) || + this.disabledForTesting || + Services.sysinfo.getProperty("isPackagedApp") + ); + }, + + /** + * See nsIUpdateService.idl + */ + get manualUpdateOnly() { + return ( + Services.policies && !Services.policies.isAllowed("autoAppUpdateChecking") + ); + }, + + /** + * See nsIUpdateService.idl + */ + get canUsuallyCheckForUpdates() { + if (this.disabled) { + LOG( + "UpdateService.canUsuallyCheckForUpdates - unable to automatically check " + + "for updates, the option has been disabled by the administrator." + ); + return false; + } + + // If we don't know the binary platform we're updating, we can't update. + if (!lazy.UpdateUtils.ABI) { + LOG( + "UpdateService.canUsuallyCheckForUpdates - unable to check for updates, " + + "unknown ABI" + ); + return false; + } + + // If we don't know the OS version we're updating, we can't update. + if (!lazy.UpdateUtils.OSVersion) { + LOG( + "UpdateService.canUsuallyCheckForUpdates - unable to check for updates, " + + "unknown OS version" + ); + return false; + } + + LOG("UpdateService.canUsuallyCheckForUpdates - able to check for updates"); + return true; + }, + + /** + * See nsIUpdateService.idl + */ + get canCheckForUpdates() { + if (!this.canUsuallyCheckForUpdates) { + return false; + } + + if (!hasUpdateMutex()) { + LOG( + "UpdateService.canCheckForUpdates - unable to check for updates, " + + "unable to acquire update mutex" + ); + return false; + } + + if (isOtherInstanceRunning()) { + // This doesn't block update checks, but we will have to wait until either + // the other instance is gone or we time out waiting for it. + LOG( + "UpdateService.canCheckForUpdates - another instance is holding the " + + "lock, will need to wait for it prior to checking for updates" + ); + } + + LOG("UpdateService.canCheckForUpdates - able to check for updates"); + return true; + }, + + /** + * See nsIUpdateService.idl + */ + get elevationRequired() { + return getElevationRequired(); + }, + + /** + * See nsIUpdateService.idl + */ + get canUsuallyApplyUpdates() { + return getCanApplyUpdates(); + }, + + /** + * See nsIUpdateService.idl + */ + get canApplyUpdates() { + return ( + this.canUsuallyApplyUpdates && + hasUpdateMutex() && + !isOtherInstanceRunning() + ); + }, + + /** + * See nsIUpdateService.idl + */ + get canUsuallyStageUpdates() { + return getCanStageUpdates(false); + }, + + /** + * See nsIUpdateService.idl + */ + get canStageUpdates() { + return getCanStageUpdates(); + }, + + /** + * See nsIUpdateService.idl + */ + get canUsuallyUseBits() { + return getCanUseBits(false) == "CanUseBits"; + }, + + /** + * See nsIUpdateService.idl + */ + get canUseBits() { + return getCanUseBits() == "CanUseBits"; + }, + + /** + * See nsIUpdateService.idl + */ + get isOtherInstanceHandlingUpdates() { + return !hasUpdateMutex() || isOtherInstanceRunning(); + }, + + /** + * A set of download listeners to be notified by this._downloader when it + * receives nsIRequestObserver or nsIProgressEventSink method calls. + * + * These are stored on the UpdateService rather than on the Downloader, + * because they ought to persist across multiple Downloader instances. + */ + _downloadListeners: new Set(), + + /** + * See nsIUpdateService.idl + */ + addDownloadListener: function AUS_addDownloadListener(listener) { + let oldSize = this._downloadListeners.size; + this._downloadListeners.add(listener); + + if (this._downloadListeners.size == oldSize) { + LOG( + "UpdateService:addDownloadListener - Warning: Didn't add duplicate " + + "listener" + ); + return; + } + + if (this._downloader) { + this._downloader.onDownloadListenerAdded(); + } + }, + + /** + * See nsIUpdateService.idl + */ + removeDownloadListener: function AUS_removeDownloadListener(listener) { + let elementRemoved = this._downloadListeners.delete(listener); + + if (!elementRemoved) { + LOG( + "UpdateService:removeDownloadListener - Warning: Didn't remove " + + "non-existent listener" + ); + return; + } + + if (this._downloader) { + this._downloader.onDownloadListenerRemoved(); + } + }, + + /** + * Returns a boolean indicating whether there are any download listeners + */ + get hasDownloadListeners() { + return !!this._downloadListeners.length; + }, + + /* + * Calls the provided function once with each download listener that is + * currently registered. + */ + forEachDownloadListener: function AUS_forEachDownloadListener(fn) { + // Make a shallow copy in case listeners remove themselves. + let listeners = new Set(this._downloadListeners); + listeners.forEach(fn); + }, + + /** + * See nsIUpdateService.idl + */ + downloadUpdate: async function AUS_downloadUpdate(update) { + if (!update) { + throw Components.Exception("", Cr.NS_ERROR_NULL_POINTER); + } + + // Don't download the update if the update's version is less than the + // current application's version or the update's version is the same as the + // application's version and the build ID is the same as the application's + // build ID. If we already have an update ready, we want to apply those + // same checks against the version of the ready update, so that we don't + // download an update that isn't newer than the one we already have. + if (updateIsAtLeastAsOldAsCurrentVersion(update)) { + LOG( + "UpdateService:downloadUpdate - Skipping download of update since " + + "it is for an earlier or same application version and build ID.\n" + + "current application version: " + + Services.appinfo.version + + "\n" + + "update application version : " + + update.appVersion + + "\n" + + "current build ID: " + + Services.appinfo.appBuildID + + "\n" + + "update build ID : " + + update.buildID + ); + return false; + } + if (updateIsAtLeastAsOldAsReadyUpdate(update)) { + LOG( + "UpdateService:downloadUpdate - not downloading update because the " + + "update that's already been downloaded is the same version or " + + "newer.\n" + + "currently downloaded update application version: " + + lazy.UM.readyUpdate.appVersion + + "\n" + + "available update application version : " + + update.appVersion + + "\n" + + "currently downloaded update build ID: " + + lazy.UM.readyUpdate.buildID + + "\n" + + "available update build ID : " + + update.buildID + ); + return false; + } + + // If a download request is in progress vs. a download ready to resume + if (this.isDownloading) { + if (update.isCompleteUpdate == this._downloader.isCompleteUpdate) { + LOG( + "UpdateService:downloadUpdate - no support for downloading more " + + "than one update at a time" + ); + return true; + } + this._downloader.cancel(); + } + this._downloader = new Downloader(this); + return this._downloader.downloadUpdate(update); + }, + + /** + * See nsIUpdateService.idl + */ + stopDownload: async function AUS_stopDownload() { + if (this.isDownloading) { + await this._downloader.cancel(); + } else if (this._retryTimer) { + // Download status is still considered as 'downloading' during retry. + // We need to cancel both retry and download at this stage. + this._retryTimer.cancel(); + this._retryTimer = null; + if (this._downloader) { + await this._downloader.cancel(); + } + } + if (this._downloader) { + await this._downloader.cleanup(); + } + this._downloader = null; + }, + + /** + * Note that this is different from checking if `currentState` is + * `STATE_DOWNLOADING` because if we are downloading a second update, this + * will be `true` while `currentState` will be `STATE_PENDING`. + */ + get isDownloading() { + return this._downloader && this._downloader.isBusy; + }, + + _logStatus: function AUS__logStatus() { + if (!lazy.gLogEnabled) { + return; + } + if (this.disabled) { + LOG("Current UpdateService status: disabled"); + // Return early if UpdateService is disabled by policy. Otherwise some of + // the getters we call to display status information may discover that the + // update directory is not writable, which automatically results in the + // permissions being fixed. Which we shouldn't really be doing if update + // is disabled by policy. + return; + } + LOG("Logging current UpdateService status:"); + // These getters print their own logging + this.canCheckForUpdates; + this.canApplyUpdates; + this.canStageUpdates; + LOG("Elevation required: " + this.elevationRequired); + LOG( + "Other instance of the application currently running: " + + this.isOtherInstanceHandlingUpdates + ); + LOG("Downloading: " + !!this.isDownloading); + if (this._downloader && this._downloader.isBusy) { + LOG("Downloading complete update: " + this._downloader.isCompleteUpdate); + LOG("Downloader using BITS: " + this._downloader.usingBits); + if (this._downloader._patch) { + // This will print its own logging + this._downloader._canUseBits(this._downloader._patch); + + // Downloader calls QueryInterface(Ci.nsIWritablePropertyBag) on + // its _patch member as soon as it is assigned, so no need to do so + // again here. + let bitsResult = this._downloader._patch.getProperty("bitsResult"); + if (bitsResult != null) { + LOG("Patch BITS result: " + bitsResult); + } + let internalResult = + this._downloader._patch.getProperty("internalResult"); + if (internalResult != null) { + LOG("Patch nsIIncrementalDownload result: " + internalResult); + } + } + } + LOG("End of UpdateService status"); + }, + + /** + * See nsIUpdateService.idl + */ + get onlyDownloadUpdatesThisSession() { + return gOnlyDownloadUpdatesThisSession; + }, + + /** + * See nsIUpdateService.idl + */ + set onlyDownloadUpdatesThisSession(newValue) { + gOnlyDownloadUpdatesThisSession = newValue; + }, + + /** + * See nsIUpdateService.idl + */ + getStateName(state) { + switch (state) { + case Ci.nsIApplicationUpdateService.STATE_IDLE: + return "STATE_IDLE"; + case Ci.nsIApplicationUpdateService.STATE_DOWNLOADING: + return "STATE_DOWNLOADING"; + case Ci.nsIApplicationUpdateService.STATE_STAGING: + return "STATE_STAGING"; + case Ci.nsIApplicationUpdateService.STATE_PENDING: + return "STATE_PENDING"; + case Ci.nsIApplicationUpdateService.STATE_SWAP: + return "STATE_SWAP"; + } + return `[unknown update state: ${state}]`; + }, + + /** + * See nsIUpdateService.idl + */ + get currentState() { + return gUpdateState; + }, + + /** + * See nsIUpdateService.idl + */ + get stateTransition() { + return gStateTransitionPromise.promise; + }, + + classID: UPDATESERVICE_CID, + + QueryInterface: ChromeUtils.generateQI([ + "nsIApplicationUpdateService", + "nsITimerCallback", + "nsIObserver", + ]), +}; + +/** + * A service to manage active and past updates. + * @constructor + */ +export function UpdateManager() { + // Load the active-update.xml file to see if there is an active update. + let activeUpdates = this._loadXMLFileIntoArray(FILE_ACTIVE_UPDATE_XML); + if (activeUpdates.length) { + // Set the active update directly on the var used to cache the value. + this._readyUpdate = activeUpdates[0]; + if (activeUpdates.length >= 2) { + this._downloadingUpdate = activeUpdates[1]; + } + let status = readStatusFile(getReadyUpdateDir()); + LOG(`UpdateManager:UpdateManager - status = "${status}"`); + // This check is performed here since UpdateService:_postUpdateProcessing + // won't be called when there isn't an update.status file. + if (status == STATE_NONE) { + // Under some edgecases such as Windows system restore the + // active-update.xml will contain a pending update without the status + // file. To recover from this situation clean the updates dir and move + // the active update to the update history. + LOG( + "UpdateManager:UpdateManager - Found update data with no status " + + "file. Cleaning up..." + ); + this._readyUpdate.state = STATE_FAILED; + this._readyUpdate.errorCode = ERR_UPDATE_STATE_NONE; + this._readyUpdate.statusText = + lazy.gUpdateBundle.GetStringFromName("statusFailed"); + let newStatus = STATE_FAILED + ": " + ERR_UPDATE_STATE_NONE; + pingStateAndStatusCodes(this._readyUpdate, true, newStatus); + this.addUpdateToHistory(this._readyUpdate); + this._readyUpdate = null; + this.saveUpdates(); + cleanUpReadyUpdateDir(); + cleanUpDownloadingUpdateDir(); + } else if (status == STATE_DOWNLOADING) { + // The first update we read out of activeUpdates may not be the ready + // update, it may be the downloading update. + if (this._downloadingUpdate) { + // If the first update we read is a downloading update, it's + // unexpected to have read another active update. That would seem to + // indicate that we were downloading two updates at once, which we don't + // do. + LOG( + "UpdateManager:UpdateManager - Warning: Found and discarded a " + + "second downloading update." + ); + } + this._downloadingUpdate = this._readyUpdate; + this._readyUpdate = null; + } + } + + LOG( + "UpdateManager:UpdateManager - Initialized downloadingUpdate to " + + this._downloadingUpdate + ); + if (this._downloadingUpdate) { + LOG( + "UpdateManager:UpdateManager - Initialized downloadingUpdate state to " + + this._downloadingUpdate.state + ); + } + LOG( + "UpdateManager:UpdateManager - Initialized readyUpdate to " + + this._readyUpdate + ); + if (this._readyUpdate) { + LOG( + "UpdateManager:UpdateManager - Initialized readyUpdate state to " + + this._readyUpdate.state + ); + } +} + +UpdateManager.prototype = { + /** + * The nsIUpdate object for the update that has been downloaded. + */ + _readyUpdate: null, + + /** + * The nsIUpdate object for the update currently being downloaded. + */ + _downloadingUpdate: null, + + /** + * Whether the update history stored in _updates has changed since it was + * loaded. + */ + _updatesDirty: false, + + /** + * See nsIObserver.idl + */ + observe: function UM_observe(subject, topic, data) { + // Hack to be able to run and cleanup tests by reloading the update data. + if (topic == "um-reload-update-data") { + if (!Cu.isInAutomation) { + return; + } + LOG("UpdateManager:observe - Reloading update data."); + if (this._updatesXMLSaver) { + this._updatesXMLSaver.disarm(); + } + + let updates = []; + this._updatesDirty = true; + this._readyUpdate = null; + this._downloadingUpdate = null; + transitionState(Ci.nsIApplicationUpdateService.STATE_IDLE); + if (data != "skip-files") { + let activeUpdates = this._loadXMLFileIntoArray(FILE_ACTIVE_UPDATE_XML); + if (activeUpdates.length) { + this._readyUpdate = activeUpdates[0]; + if (activeUpdates.length >= 2) { + this._downloadingUpdate = activeUpdates[1]; + } + let status = readStatusFile(getReadyUpdateDir()); + LOG(`UpdateManager:observe - Got status = ${status}`); + if (status == STATE_DOWNLOADING) { + this._downloadingUpdate = this._readyUpdate; + this._readyUpdate = null; + transitionState(Ci.nsIApplicationUpdateService.STATE_DOWNLOADING); + } else if ( + [ + STATE_PENDING, + STATE_PENDING_SERVICE, + STATE_PENDING_ELEVATE, + STATE_APPLIED, + STATE_APPLIED_SERVICE, + ].includes(status) + ) { + transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING); + } + } + updates = this._loadXMLFileIntoArray(FILE_UPDATES_XML); + } + this._updatesCache = updates; + + LOG( + "UpdateManager:observe - Reloaded downloadingUpdate as " + + this._downloadingUpdate + ); + if (this._downloadingUpdate) { + LOG( + "UpdateManager:observe - Reloaded downloadingUpdate state as " + + this._downloadingUpdate.state + ); + } + LOG( + "UpdateManager:observe - Reloaded readyUpdate as " + this._readyUpdate + ); + if (this._readyUpdate) { + LOG( + "UpdateManager:observe - Reloaded readyUpdate state as " + + this._readyUpdate.state + ); + } + } + }, + + /** + * Loads an updates.xml formatted file into an array of nsIUpdate items. + * @param fileName + * The file name in the updates directory to load. + * @return The array of nsIUpdate items held in the file. + */ + _loadXMLFileIntoArray: function UM__loadXMLFileIntoArray(fileName) { + let updates = []; + let file = getUpdateFile([fileName]); + if (!file.exists()) { + LOG( + "UpdateManager:_loadXMLFileIntoArray - XML file does not exist. " + + "path: " + + file.path + ); + return updates; + } + + // Open the active-update.xml file with both read and write access so + // opening it will fail if it isn't possible to also write to the file. When + // opening it fails it means that it isn't possible to update and the code + // below will return early without loading the active-update.xml. This will + // also make it so notifications to update manually will still be shown. + let mode = + fileName == FILE_ACTIVE_UPDATE_XML + ? FileUtils.MODE_RDWR + : FileUtils.MODE_RDONLY; + let fileStream = Cc[ + "@mozilla.org/network/file-input-stream;1" + ].createInstance(Ci.nsIFileInputStream); + try { + fileStream.init(file, mode, FileUtils.PERMS_FILE, 0); + } catch (e) { + LOG( + "UpdateManager:_loadXMLFileIntoArray - error initializing file " + + "stream. Exception: " + + e + ); + return updates; + } + try { + var parser = new DOMParser(); + var doc = parser.parseFromStream( + fileStream, + "UTF-8", + fileStream.available(), + "text/xml" + ); + + var updateCount = doc.documentElement.childNodes.length; + for (var i = 0; i < updateCount; ++i) { + var updateElement = doc.documentElement.childNodes.item(i); + if ( + updateElement.nodeType != updateElement.ELEMENT_NODE || + updateElement.localName != "update" + ) { + continue; + } + + let update; + try { + update = new Update(updateElement); + } catch (e) { + LOG("UpdateManager:_loadXMLFileIntoArray - invalid update"); + continue; + } + updates.push(update); + } + } catch (ex) { + LOG( + "UpdateManager:_loadXMLFileIntoArray - error constructing update " + + "list. Exception: " + + ex + ); + } + fileStream.close(); + if (!updates.length) { + LOG( + "UpdateManager:_loadXMLFileIntoArray - update xml file " + + fileName + + " exists but doesn't contain any updates" + ); + // The file exists but doesn't contain any updates so remove it. + try { + file.remove(false); + } catch (e) { + LOG( + "UpdateManager:_loadXMLFileIntoArray - error removing " + + fileName + + " file. Exception: " + + e + ); + } + } + return updates; + }, + + /** + * Loads the update history from the updates.xml file into a cache. + */ + _getUpdates() { + if (!this._updatesCache) { + this._updatesCache = this._loadXMLFileIntoArray(FILE_UPDATES_XML); + } + return this._updatesCache; + }, + + /** + * See nsIUpdateService.idl + */ + getUpdateAt: function UM_getUpdateAt(aIndex) { + return this._getUpdates()[aIndex]; + }, + + /** + * See nsIUpdateService.idl + */ + getUpdateCount() { + return this._getUpdates().length; + }, + + /** + * See nsIUpdateService.idl + */ + get readyUpdate() { + return this._readyUpdate; + }, + set readyUpdate(aUpdate) { + this._readyUpdate = aUpdate; + }, + + /** + * See nsIUpdateService.idl + */ + get downloadingUpdate() { + return this._downloadingUpdate; + }, + set downloadingUpdate(aUpdate) { + this._downloadingUpdate = aUpdate; + }, + + /** + * See nsIUpdateService.idl + */ + addUpdateToHistory(aUpdate) { + this._updatesDirty = true; + let updates = this._getUpdates(); + updates.unshift(aUpdate); + // Limit the update history to 10 updates. + updates.splice(10); + }, + + /** + * Serializes an array of updates to an XML file or removes the file if the + * array length is 0. + * @param updates + * An array of nsIUpdate objects + * @param fileName + * The file name in the updates directory to write to. + * @return true on success, false on error + */ + _writeUpdatesToXMLFile: async function UM__writeUpdatesToXMLFile( + updates, + fileName + ) { + let file; + try { + file = getUpdateFile([fileName]); + } catch (e) { + LOG( + "UpdateManager:_writeUpdatesToXMLFile - Unable to get XML file - " + + "Exception: " + + e + ); + return false; + } + if (!updates.length) { + LOG( + "UpdateManager:_writeUpdatesToXMLFile - no updates to write. " + + "removing file: " + + file.path + ); + try { + await IOUtils.remove(file.path); + } catch (e) { + LOG( + "UpdateManager:_writeUpdatesToXMLFile - Delete file exception: " + e + ); + return false; + } + return true; + } + + const EMPTY_UPDATES_DOCUMENT_OPEN = + '<?xml version="1.0"?><updates xmlns="' + URI_UPDATE_NS + '">'; + const EMPTY_UPDATES_DOCUMENT_CLOSE = "</updates>"; + try { + var parser = new DOMParser(); + var doc = parser.parseFromString( + EMPTY_UPDATES_DOCUMENT_OPEN + EMPTY_UPDATES_DOCUMENT_CLOSE, + "text/xml" + ); + + for (var i = 0; i < updates.length; ++i) { + doc.documentElement.appendChild(updates[i].serialize(doc)); + } + + var xml = + EMPTY_UPDATES_DOCUMENT_OPEN + + doc.documentElement.innerHTML + + EMPTY_UPDATES_DOCUMENT_CLOSE; + // If the destination file existed and is removed while the following is + // being performed the copy of the tmp file to the destination file will + // fail. + await IOUtils.writeUTF8(file.path, xml, { + tmpPath: file.path + ".tmp", + }); + await IOUtils.setPermissions(file.path, FileUtils.PERMS_FILE); + } catch (e) { + LOG("UpdateManager:_writeUpdatesToXMLFile - Exception: " + e); + return false; + } + return true; + }, + + _updatesXMLSaver: null, + _updatesXMLSaverCallback: null, + /** + * See nsIUpdateService.idl + */ + saveUpdates: function UM_saveUpdates() { + if (!this._updatesXMLSaver) { + this._updatesXMLSaverCallback = () => this._updatesXMLSaver.finalize(); + + this._updatesXMLSaver = new lazy.DeferredTask( + () => this._saveUpdatesXML(), + XML_SAVER_INTERVAL_MS + ); + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "UpdateManager: writing update xml data", + this._updatesXMLSaverCallback + ); + } else { + this._updatesXMLSaver.disarm(); + } + + this._updatesXMLSaver.arm(); + }, + + /** + * Saves the active-updates.xml and updates.xml when the updates history has + * been modified files. + */ + _saveUpdatesXML: function UM__saveUpdatesXML() { + // This mechanism for how we store the updates might seem a bit odd, since, + // if only one update is stored, we don't know if it's the ready update or + // the downloading update. However, we can determine which it is by reading + // update.status. If we read STATE_DOWNLOADING, it must be a downloading + // update and otherwise it's a ready update. This method has the additional + // advantage of requiring no migration from when we used to only store a + // single active update. + let updates = []; + if (this._readyUpdate) { + updates.push(this._readyUpdate); + } + if (this._downloadingUpdate) { + updates.push(this._downloadingUpdate); + } + + // The active update stored in the active-update.xml file will change during + // the lifetime of an active update and the file should always be updated + // when saveUpdates is called. + let promises = []; + promises[0] = this._writeUpdatesToXMLFile(updates, FILE_ACTIVE_UPDATE_XML); + // The update history stored in the updates.xml file should only need to be + // updated when an active update has been added to it in which case + // |_updatesDirty| will be true. + if (this._updatesDirty) { + this._updatesDirty = false; + promises[1] = this._writeUpdatesToXMLFile( + this._getUpdates(), + FILE_UPDATES_XML + ); + } + return Promise.all(promises); + }, + + /** + * See nsIUpdateService.idl + */ + refreshUpdateStatus: async function UM_refreshUpdateStatus() { + try { + LOG("UpdateManager:refreshUpdateStatus - Staging done."); + + var update = this._readyUpdate; + if (!update) { + LOG("UpdateManager:refreshUpdateStatus - Missing ready update?"); + return; + } + + var status = readStatusFile(getReadyUpdateDir()); + pingStateAndStatusCodes(update, false, status); + LOG(`UpdateManager:refreshUpdateStatus - status = ${status}`); + + let parts = status.split(":"); + update.state = parts[0]; + if (update.state == STATE_APPLYING) { + LOG( + "UpdateManager:refreshUpdateStatus - Staging appears to have crashed." + ); + update.state = STATE_FAILED; + update.errorCode = ERR_UPDATER_CRASHED; + } else if (update.state == STATE_FAILED) { + LOG("UpdateManager:refreshUpdateStatus - Staging failed."); + if (parts[1]) { + update.errorCode = parseInt(parts[1]) || INVALID_UPDATER_STATUS_CODE; + } else { + update.errorCode = INVALID_UPDATER_STATUS_CODE; + } + } + + // Rotate the update logs so the update log isn't removed if a complete + // update is downloaded. By passing false the patch directory won't be + // removed. + cleanUpReadyUpdateDir(false); + + if (update.state == STATE_FAILED) { + let isMemError = isMemoryAllocationErrorCode(update.errorCode); + if ( + update.errorCode == DELETE_ERROR_STAGING_LOCK_FILE || + update.errorCode == UNEXPECTED_STAGING_ERROR || + isMemError + ) { + update.state = getBestPendingState(); + writeStatusFile(getReadyUpdateDir(), update.state); + if (isMemError) { + LOG( + `UpdateManager:refreshUpdateStatus - Updater failed to ` + + `allocate enough memory to successfully stage. Setting ` + + `status to "${update.state}"` + ); + } else { + LOG( + `UpdateManager:refreshUpdateStatus - Unexpected staging error. ` + + `Setting status to "${update.state}"` + ); + } + } else if (isServiceSpecificErrorCode(update.errorCode)) { + // Sometimes when staging, we might encounter an error that is + // specific to the Maintenance Service. If this happens, we should try + // to update without the Service. + LOG( + `UpdateManager:refreshUpdateStatus - Encountered service ` + + `specific error code: ${update.errorCode}. Will try installing ` + + `update without the Maintenance Service. Setting state to pending` + ); + update.state = STATE_PENDING; + writeStatusFile(getReadyUpdateDir(), update.state); + } else { + LOG( + "UpdateManager:refreshUpdateStatus - Attempting handleUpdateFailure" + ); + if (!handleUpdateFailure(update)) { + LOG( + "UpdateManager:refreshUpdateStatus - handleUpdateFailure " + + "failed. Attempting to fall back to complete update." + ); + await handleFallbackToCompleteUpdate(); + } + } + } + if (update.state == STATE_APPLIED && shouldUseService()) { + LOG( + `UpdateManager:refreshUpdateStatus - Staging successful. ` + + `Setting status to "${STATE_APPLIED_SERVICE}"` + ); + writeStatusFile( + getReadyUpdateDir(), + (update.state = STATE_APPLIED_SERVICE) + ); + } + + // Now that the active update's properties have been updated write the + // active-update.xml to disk. Since there have been no changes to the + // update history the updates.xml will not be written to disk. + this.saveUpdates(); + + // Send an observer notification which the app update doorhanger uses to + // display a restart notification after any langpacks have staged. + await promiseLangPacksUpdated(update); + + if ( + update.state == STATE_APPLIED || + update.state == STATE_APPLIED_SERVICE || + update.state == STATE_PENDING || + update.state == STATE_PENDING_SERVICE || + update.state == STATE_PENDING_ELEVATE + ) { + LOG("UpdateManager:refreshUpdateStatus - Setting state STATE_PENDING"); + transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING); + } + + LOG( + "UpdateManager:refreshUpdateStatus - Notifying observers that " + + "the update was staged. topic: update-staged, status: " + + update.state + ); + Services.obs.notifyObservers(update, "update-staged", update.state); + } finally { + // This function being called is the one thing that tells us that staging + // is done so be very sure that we don't exit it leaving the current + // state at STATE_STAGING. + // The only cases where we haven't already done a state transition are + // error cases, so if another state isn't set, assume that we hit an error + // and aborted the update. + if ( + lazy.AUS.currentState == Ci.nsIApplicationUpdateService.STATE_STAGING + ) { + LOG("UpdateManager:refreshUpdateStatus - Setting state STATE_IDLE"); + transitionState(Ci.nsIApplicationUpdateService.STATE_IDLE); + } + } + }, + + /** + * See nsIUpdateService.idl + */ + elevationOptedIn: function UM_elevationOptedIn() { + // The user has been been made aware that the update requires elevation. + let update = this._readyUpdate; + if (!update) { + return; + } + let status = readStatusFile(getReadyUpdateDir()); + let parts = status.split(":"); + update.state = parts[0]; + if (update.state == STATE_PENDING_ELEVATE) { + LOG("UpdateManager:elevationOptedIn - Setting state to pending."); + // Proceed with the pending update. + // Note: STATE_PENDING_ELEVATE stands for "pending user's approval to + // proceed with an elevated update". As long as we see this state, we will + // notify the user of the availability of an update that requires + // elevation. |elevationOptedIn| (this function) is called when the user + // gives us approval to proceed, so we want to switch to STATE_PENDING. + // The updater then detects whether or not elevation is required and + // displays the elevation prompt if necessary. This last step does not + // depend on the state in the status file. + writeStatusFile(getReadyUpdateDir(), STATE_PENDING); + } else { + LOG("UpdateManager:elevationOptedIn - Not in pending-elevate state."); + } + }, + + /** + * See nsIUpdateService.idl + */ + cleanupDownloadingUpdate: function UM_cleanupDownloadingUpdate() { + LOG( + "UpdateManager:cleanupDownloadingUpdate - cleaning up downloading update." + ); + cleanupDownloadingUpdate(); + }, + + /** + * See nsIUpdateService.idl + */ + cleanupReadyUpdate: function UM_cleanupReadyUpdate() { + LOG("UpdateManager:cleanupReadyUpdate - cleaning up ready update."); + cleanupReadyUpdate(); + }, + + /** + * See nsIUpdateService.idl + */ + doInstallCleanup: async function UM_doInstallCleanup(isUninstall) { + LOG("UpdateManager:doInstallCleanup - cleaning up"); + let completionPromises = []; + + const delete_or_log = path => + IOUtils.remove(path).catch(ex => + console.error(`Failed to delete ${path}`, ex) + ); + + for (const key of [KEY_OLD_UPDROOT, KEY_UPDROOT]) { + const root = Services.dirsvc.get(key, Ci.nsIFile); + + const activeUpdateXml = root.clone(); + activeUpdateXml.append(FILE_ACTIVE_UPDATE_XML); + completionPromises.push(delete_or_log(activeUpdateXml.path)); + + const downloadingMar = root.clone(); + downloadingMar.append(DIR_UPDATES); + downloadingMar.append(DIR_UPDATE_DOWNLOADING); + downloadingMar.append(FILE_UPDATE_MAR); + completionPromises.push(delete_or_log(downloadingMar.path)); + + const readyDir = root.clone(); + readyDir.append(DIR_UPDATES); + readyDir.append(DIR_UPDATE_READY); + const readyMar = readyDir.clone(); + readyMar.append(FILE_UPDATE_MAR); + completionPromises.push(delete_or_log(readyMar.path)); + const readyStatus = readyDir.clone(); + readyStatus.append(FILE_UPDATE_STATUS); + completionPromises.push(delete_or_log(readyStatus.path)); + const versionFile = readyDir.clone(); + versionFile.append(FILE_UPDATE_VERSION); + completionPromises.push(delete_or_log(versionFile.path)); + } + + return Promise.allSettled(completionPromises); + }, + + /** + * See nsIUpdateService.idl + */ + doUninstallCleanup: async function UM_doUninstallCleanup(isUninstall) { + LOG("UpdateManager:doUninstallCleanup - cleaning up."); + let completionPromises = []; + + completionPromises.push( + IOUtils.remove(Services.dirsvc.get(KEY_UPDROOT, Ci.nsIFile).path, { + recursive: true, + }).catch(ex => console.error("Failed to remove update directory", ex)) + ); + completionPromises.push( + IOUtils.remove(Services.dirsvc.get(KEY_OLD_UPDROOT, Ci.nsIFile).path, { + recursive: true, + }).catch(ex => console.error("Failed to remove old update directory", ex)) + ); + + return Promise.allSettled(completionPromises); + }, + + classID: Components.ID("{093C2356-4843-4C65-8709-D7DBCBBE7DFB}"), + QueryInterface: ChromeUtils.generateQI(["nsIUpdateManager", "nsIObserver"]), +}; + +/** + * CheckerService + * Provides an interface for checking for new updates. When more checks are + * made while an equivalent check is already in-progress, they will be coalesced + * into a single update check request. + */ +export class CheckerService { + #nextUpdateCheckId = 1; + + // Most of the update checking data is looked up via a "request key". This + // allows us to lookup the request key for a particular check id, since + // multiple checks can correspond to a single request. + // When a check is cancelled or completed, it will be removed from this + // object. + #requestKeyByCheckId = {}; + + // This object will relate request keys to update check data objects. The + // format of the update check data objects is defined by + // #makeUpdateCheckDataObject, below. + // When an update request is cancelled (by all of the corresponding update + // checks being cancelled) or completed, its key will be removed from this + // object. + #updateCheckData = {}; + + #makeUpdateCheckDataObject(type, promise) { + return { type, promise, request: null }; + } + + /** + * Indicates whether the passed parameter is one of the valid enumerated + * values that indicates a type of update check. + */ + #validUpdateCheckType(checkType) { + return [ + Ci.nsIUpdateChecker.BACKGROUND_CHECK, + Ci.nsIUpdateChecker.FOREGROUND_CHECK, + ].includes(checkType); + } + + #getCanMigrate() { + if (AppConstants.platform != "win") { + return false; + } + + // The first element of the array is whether the build target is 32 or 64 + // bit and the third element of the array is whether the client's Windows OS + // system processor is 32 or 64 bit. + let aryABI = lazy.UpdateUtils.ABI.split("-"); + if (aryABI[0] != "x86" || aryABI[2] != "x64") { + return false; + } + + let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + + let regPath = + "SOFTWARE\\Mozilla\\" + Services.appinfo.name + "\\32to64DidMigrate"; + let regValHKCU = lazy.WindowsRegistry.readRegKey( + wrk.ROOT_KEY_CURRENT_USER, + regPath, + "Never", + wrk.WOW64_32 + ); + let regValHKLM = lazy.WindowsRegistry.readRegKey( + wrk.ROOT_KEY_LOCAL_MACHINE, + regPath, + "Never", + wrk.WOW64_32 + ); + // The Never registry key value allows configuring a system to never migrate + // any of the installations. + if (regValHKCU === 1 || regValHKLM === 1) { + LOG( + "CheckerService:#getCanMigrate - all installations should not be " + + "migrated" + ); + return false; + } + + let appBaseDirPath = getAppBaseDir().path; + regValHKCU = lazy.WindowsRegistry.readRegKey( + wrk.ROOT_KEY_CURRENT_USER, + regPath, + appBaseDirPath, + wrk.WOW64_32 + ); + regValHKLM = lazy.WindowsRegistry.readRegKey( + wrk.ROOT_KEY_LOCAL_MACHINE, + regPath, + appBaseDirPath, + wrk.WOW64_32 + ); + // When the registry value is 1 for the installation directory path value + // name then the installation has already been migrated once or the system + // was configured to not migrate that installation. + if (regValHKCU === 1 || regValHKLM === 1) { + LOG( + "CheckerService:#getCanMigrate - this installation should not be " + + "migrated" + ); + return false; + } + + // When the registry value is 0 for the installation directory path value + // name then the installation has updated to Firefox 56 and can be migrated. + if (regValHKCU === 0 || regValHKLM === 0) { + LOG("CheckerService:#getCanMigrate - this installation can be migrated"); + return true; + } + + LOG( + "CheckerService:#getCanMigrate - no registry entries for this " + + "installation" + ); + return false; + } + + /** + * See nsIUpdateService.idl + */ + async getUpdateURL(checkType) { + LOG("CheckerService:getUpdateURL - checkType: " + checkType); + if (!this.#validUpdateCheckType(checkType)) { + LOG("CheckerService:getUpdateURL - Invalid checkType"); + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + let url = Services.appinfo.updateURL; + let updatePin; + + if (Services.policies) { + let policies = Services.policies.getActivePolicies(); + if (policies) { + if ("AppUpdateURL" in policies) { + url = policies.AppUpdateURL.toString(); + } + if ("AppUpdatePin" in policies) { + updatePin = policies.AppUpdatePin; + + // Scalar ID: update.version_pin + AUSTLMY.pingPinPolicy(updatePin); + } + } + } + + if (!url) { + LOG("CheckerService:getUpdateURL - update URL not defined"); + return null; + } + + url = await lazy.UpdateUtils.formatUpdateURL(url); + + if (checkType == Ci.nsIUpdateChecker.FOREGROUND_CHECK) { + url += (url.includes("?") ? "&" : "?") + "force=1"; + } + + if (this.#getCanMigrate()) { + url += (url.includes("?") ? "&" : "?") + "mig64=1"; + } + + if (updatePin) { + url += + (url.includes("?") ? "&" : "?") + + "pin=" + + encodeURIComponent(updatePin); + } + + LOG("CheckerService:getUpdateURL - update URL: " + url); + return url; + } + + /** + * See nsIUpdateService.idl + */ + + checkForUpdates(checkType) { + LOG("CheckerService:checkForUpdates - checkType: " + checkType); + if (!this.#validUpdateCheckType(checkType)) { + LOG("CheckerService:checkForUpdates - Invalid checkType"); + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + let checkId = this.#nextUpdateCheckId; + this.#nextUpdateCheckId += 1; + + // `checkType == FOREGROUND_CHECK`` can override `canCheckForUpdates`. But + // nothing should override enterprise policies. + if (lazy.AUS.disabled) { + LOG("CheckerService:checkForUpdates - disabled by policy"); + return this.#getChecksNotAllowedObject(checkId); + } + if ( + checkType == Ci.nsIUpdateChecker.BACKGROUND_CHECK && + !lazy.AUS.canCheckForUpdates + ) { + LOG("CheckerService:checkForUpdates - !canCheckForUpdates"); + return this.#getChecksNotAllowedObject(checkId); + } + + // We want to combine simultaneous requests, but only ones that are + // equivalent. If, say, one of them uses the force parameter and one + // doesn't, we want those two requests to remain separate. This key will + // allow us to map equivalent requests together. It is also the key that we + // use to lookup the update check data in this.#updateCheckData. + let requestKey = checkType; + + if (requestKey in this.#updateCheckData) { + LOG( + `CheckerService:checkForUpdates - Connecting check id ${checkId} to ` + + `existing check request.` + ); + } else { + LOG( + `CheckerService:checkForUpdates - Making new check request for check ` + + `id ${checkId}.` + ); + this.#updateCheckData[requestKey] = this.#makeUpdateCheckDataObject( + checkType, + this.#updateCheck(checkType, requestKey) + ); + } + + this.#requestKeyByCheckId[checkId] = requestKey; + + return { + id: checkId, + result: this.#updateCheckData[requestKey].promise, + QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheck"]), + }; + } + + #getChecksNotAllowedObject(checkId) { + return { + id: checkId, + result: Promise.resolve( + Object.freeze({ + checksAllowed: false, + succeeded: false, + request: null, + updates: [], + QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheckResult"]), + }) + ), + QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheck"]), + }; + } + + async #updateCheck(checkType, requestKey) { + await waitForOtherInstances(); + + let url; + try { + url = await this.getUpdateURL(checkType); + } catch (ex) {} + + if (!url) { + LOG("CheckerService:#updateCheck - !url"); + return this.#getCheckFailedObject("update_url_not_available"); + } + + let request = new XMLHttpRequest(); + request.open("GET", url, true); + request.channel.notificationCallbacks = new lazy.CertUtils.BadCertHandler( + false + ); + // Prevent the request from reading from the cache. + request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + // Prevent the request from writing to the cache. + request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + // Disable cutting edge features, like TLS 1.3, where middleboxes might + // brick us + request.channel.QueryInterface( + Ci.nsIHttpChannelInternal + ).beConservative = true; + + request.overrideMimeType("text/xml"); + // The Cache-Control header is only interpreted by proxies and the + // final destination. It does not help if a resource is already + // cached locally. + request.setRequestHeader("Cache-Control", "no-cache"); + // HTTP/1.0 servers might not implement Cache-Control and + // might only implement Pragma: no-cache + request.setRequestHeader("Pragma", "no-cache"); + + const UPDATE_CHECK_LOAD_SUCCESS = 1; + const UPDATE_CHECK_LOAD_ERROR = 2; + const UPDATE_CHECK_CANCELLED = 3; + + let result = await new Promise(resolve => { + // It's important that nothing potentially asynchronous happens between + // checking if the request has been cancelled and starting the request. + // If an update check cancellation happens before dispatching the request + // and we end up dispatching it anyways, we will never call cancel on the + // request later and the cancellation effectively won't happen. + if (!(requestKey in this.#updateCheckData)) { + LOG( + "CheckerService:#updateCheck - check was cancelled before request " + + "was able to start" + ); + resolve(UPDATE_CHECK_CANCELLED); + return; + } + + let onLoad = event => { + request.removeEventListener("load", onLoad); + LOG("CheckerService:#updateCheck - request got 'load' event"); + resolve(UPDATE_CHECK_LOAD_SUCCESS); + }; + request.addEventListener("load", onLoad); + let onError = event => { + request.removeEventListener("error", onLoad); + LOG("CheckerService:#updateCheck - request got 'error' event"); + resolve(UPDATE_CHECK_LOAD_ERROR); + }; + request.addEventListener("error", onError); + + LOG("CheckerService:#updateCheck - sending request to: " + url); + request.send(null); + this.#updateCheckData[requestKey].request = request; + }); + + // Remove all entries for this request key. This marks the request and the + // associated check ids as no longer in-progress. + delete this.#updateCheckData[requestKey]; + for (const checkId of Object.keys(this.#requestKeyByCheckId)) { + if (this.#requestKeyByCheckId[checkId] == requestKey) { + delete this.#requestKeyByCheckId[checkId]; + } + } + + if (result == UPDATE_CHECK_CANCELLED) { + return this.#getCheckFailedObject(Cr.NS_BINDING_ABORTED); + } + + if (result == UPDATE_CHECK_LOAD_ERROR) { + let status = this.#getChannelStatus(request); + LOG("CheckerService:#updateCheck - Failed. request.status: " + status); + + // Set MitM pref. + try { + let secInfo = request.channel.securityInfo; + if (secInfo.serverCert && secInfo.serverCert.issuerName) { + Services.prefs.setStringPref( + "security.pki.mitm_canary_issuer", + secInfo.serverCert.issuerName + ); + } + } catch (e) { + LOG("CheckerService:#updateCheck - Getting secInfo failed."); + } + + return this.#getCheckFailedObject(status, 404, request); + } + + LOG("CheckerService:#updateCheck - request completed downloading document"); + Services.prefs.clearUserPref("security.pki.mitm_canary_issuer"); + // Check whether there is a mitm, i.e. check whether the root cert is + // built-in or not. + try { + let sslStatus = request.channel.securityInfo; + if (sslStatus && sslStatus.succeededCertChain) { + let rootCert = null; + // The root cert is the last cert in the chain. + if (sslStatus.succeededCertChain.length) { + rootCert = + sslStatus.succeededCertChain[ + sslStatus.succeededCertChain.length - 1 + ]; + } + if (rootCert) { + Services.prefs.setBoolPref( + "security.pki.mitm_detected", + !rootCert.isBuiltInRoot + ); + } + } + } catch (e) { + LOG("CheckerService:#updateCheck - Getting sslStatus failed."); + } + + let updates; + try { + // Analyze the resulting DOM and determine the set of updates. + updates = this.#parseUpdates(request); + } catch (e) { + LOG( + "CheckerService:#updateCheck - there was a problem checking for " + + "updates. Exception: " + + e + ); + let status = this.#getChannelStatus(request); + // If we can't find an error string specific to this status code, + // just use the 200 message from above, which means everything + // "looks" fine but there was probably an XML error or a bogus file. + return this.#getCheckFailedObject(status, 200, request); + } + + LOG( + "CheckerService:#updateCheck - number of updates available: " + + updates.length + ); + + if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_BACKGROUNDERRORS)) { + Services.prefs.clearUserPref(PREF_APP_UPDATE_BACKGROUNDERRORS); + } + + return Object.freeze({ + checksAllowed: true, + succeeded: true, + request, + updates, + QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheckResult"]), + }); + } + + /** + * @param errorCode + * The error code to include in the return value. If possible, we + * will get the update status text based on this error code. + * @param defaultCode + * Optional. The error code to use to get the status text if there + * isn't status text available for `errorCode`. + * @param request + * The XMLHttpRequest used to check for updates. Or null, if one was + * never constructed. + * @returns An nsIUpdateCheckResult object indicating an error, using the + * error data passed to this function. + */ + #getCheckFailedObject( + errorCode, + defaultCode = Cr.NS_BINDING_FAILED, + request = null + ) { + let update = new Update(null); + update.errorCode = errorCode; + update.statusText = getStatusTextFromCode(errorCode, defaultCode); + + if (errorCode == Cr.NS_ERROR_OFFLINE) { + // We use a separate constant here because nsIUpdate.errorCode is signed + update.errorCode = NETWORK_ERROR_OFFLINE; + } else if (this.#isHttpStatusCode(errorCode)) { + update.errorCode = HTTP_ERROR_OFFSET + errorCode; + } + + return Object.freeze({ + checksAllowed: true, + succeeded: false, + request, + updates: [update], + QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheckResult"]), + }); + } + + /** + * Returns the status code for the XMLHttpRequest + */ + #getChannelStatus(request) { + var status = 0; + try { + status = request.status; + } catch (e) {} + + if (status == 0) { + status = request.channel.QueryInterface(Ci.nsIRequest).status; + } + return status; + } + + #isHttpStatusCode(status) { + return status >= 100 && status <= 599; + } + + /** + * @param request + * The XMLHttpRequest that successfully loaded the update XML. + * @returns An array of 0 or more nsIUpdate objects describing the available + * updates. + * @throws If the XML document element node name is not updates. + */ + #parseUpdates(request) { + let updatesElement = request.responseXML.documentElement; + if (!updatesElement) { + LOG("CheckerService:#parseUpdates - empty updates document?!"); + return []; + } + + if (updatesElement.nodeName != "updates") { + LOG("CheckerService:#parseUpdates - unexpected node name!"); + throw new Error( + "Unexpected node name, expected: updates, got: " + + updatesElement.nodeName + ); + } + + let updates = []; + for (const updateElement of updatesElement.childNodes) { + if ( + updateElement.nodeType != updateElement.ELEMENT_NODE || + updateElement.localName != "update" + ) { + continue; + } + + let update; + try { + update = new Update(updateElement); + } catch (e) { + LOG("CheckerService:#parseUpdates - invalid <update/>, ignoring..."); + continue; + } + update.serviceURL = request.responseURL; + update.channel = lazy.UpdateUtils.UpdateChannel; + updates.push(update); + } + + return updates; + } + + /** + * See nsIUpdateService.idl + */ + stopCheck(checkId) { + if (!(checkId in this.#requestKeyByCheckId)) { + LOG(`CheckerService:stopCheck - Non-existent check id ${checkId}`); + return; + } + LOG(`CheckerService:stopCheck - Cancelling check id ${checkId}`); + let requestKey = this.#requestKeyByCheckId[checkId]; + delete this.#requestKeyByCheckId[checkId]; + if (Object.values(this.#requestKeyByCheckId).includes(requestKey)) { + LOG( + `CheckerService:stopCheck - Not actually cancelling request because ` + + `other check id's depend on it.` + ); + } else { + LOG( + `CheckerService:stopCheck - This is the last check using this ` + + `request. Cancelling the request now.` + ); + let request = this.#updateCheckData[requestKey].request; + delete this.#updateCheckData[requestKey]; + if (request) { + LOG(`CheckerService:stopCheck - Aborting XMLHttpRequest`); + request.abort(); + } else { + LOG( + `CheckerService:stopCheck - Not aborting XMLHttpRequest. It ` + + `doesn't appear to have started yet.` + ); + } + } + } + + /** + * See nsIUpdateService.idl + */ + stopAllChecks() { + LOG("CheckerService:stopAllChecks - stopping all checks."); + for (const checkId of Object.keys(this.#requestKeyByCheckId)) { + this.stopCheck(checkId); + } + } + + classID = Components.ID("{898CDC9B-E43F-422F-9CC4-2F6291B415A3}"); + QueryInterface = ChromeUtils.generateQI(["nsIUpdateChecker"]); +} + +/** + * Manages the download of updates + * @param background + * Whether or not this downloader is operating in background + * update mode. + * @param updateService + * The update service that created this downloader. + * @constructor + */ +function Downloader(updateService) { + LOG("Creating Downloader"); + this.updateService = updateService; +} +Downloader.prototype = { + /** + * The nsIUpdatePatch that we are downloading + */ + _patch: null, + + /** + * The nsIUpdate that we are downloading + */ + _update: null, + + /** + * The nsIRequest object handling the download. + */ + _request: null, + + /** + * Whether or not the update being downloaded is a complete replacement of + * the user's existing installation or a patch representing the difference + * between the new version and the previous version. + */ + isCompleteUpdate: null, + + /** + * We get the nsIRequest from nsIBITS asynchronously. When downloadUpdate has + * been called, but this._request is not yet valid, _pendingRequest will be + * a promise that will resolve when this._request has been set. + */ + _pendingRequest: null, + + /** + * When using BITS, cancel actions happen asynchronously. This variable + * keeps track of any cancel action that is in-progress. + * If the cancel action fails, this will be set back to null so that the + * action can be attempted again. But if the cancel action succeeds, the + * resolved promise will remain stored in this variable to prevent cancel + * from being called twice (which, for BITS, is an error). + */ + _cancelPromise: null, + + /** + * BITS receives progress notifications slowly, unless a user is watching. + * This tracks what frequency notifications are happening at. + * + * This is needed because BITS downloads are started asynchronously. + * Specifically, this is needed to prevent a situation where the download is + * still starting (Downloader._pendingRequest has not resolved) when the first + * observer registers itself. Without this variable, there is no way of + * knowing whether the download was started as Active or Idle and, therefore, + * we don't know if we need to start Active mode when _pendingRequest + * resolves. + */ + _bitsActiveNotifications: false, + + /** + * This is a function that when called will stop the update process from + * waiting for language pack updates. This is for safety to ensure that a + * problem in the add-ons manager doesn't delay updates by much. + */ + _langPackTimeout: null, + + /** + * If gOnlyDownloadUpdatesThisSession is true, we prevent the update process + * from progressing past the downloading stage. If the download finishes, + * pretend that it hasn't in order to keep the current update in the + * "downloading" state. + */ + _pretendingDownloadIsNotDone: false, + + /** + * Cancels the active download. + * + * For a BITS download, this will cancel and remove the download job. For + * an nsIIncrementalDownload, this will stop the download, but leaves the + * data around to allow the transfer to be resumed later. + */ + cancel: async function Downloader_cancel(cancelError) { + LOG("Downloader: cancel"); + if (cancelError === undefined) { + cancelError = Cr.NS_BINDING_ABORTED; + } + if (this.usingBits) { + // If a cancel action is already in progress, just return when that + // promise resolved. Trying to cancel the same request twice is an error. + if (this._cancelPromise) { + await this._cancelPromise; + return; + } + + if (this._pendingRequest) { + await this._pendingRequest; + } + if (this._patch.getProperty("bitsId") != null) { + // Make sure that we don't try to resume this download after it was + // cancelled. + this._patch.deleteProperty("bitsId"); + } + try { + this._cancelPromise = this._request.cancelAsync(cancelError); + await this._cancelPromise; + } catch (e) { + // On success, we will not set the cancel promise to null because + // we want to prevent two cancellations of the same request. But + // retrying after a failed cancel is not an error, so we will set the + // cancel promise to null in the failure case. + this._cancelPromise = null; + throw e; + } + } else if (this._request && this._request instanceof Ci.nsIRequest) { + // Normally, cancelling an nsIIncrementalDownload results in it stopping + // the download but leaving the downloaded data so that we can resume the + // download later. If we've already finished the download, there is no + // transfer to stop. + // Note that this differs from the BITS case. Cancelling a BITS job, even + // when the transfer has completed, results in all data being deleted. + // Therefore, even if the transfer has completed, cancelling a BITS job + // has effects that we must not skip. + if (this._pretendingDownloadIsNotDone) { + LOG( + "Downloader: cancel - Ignoring cancel request of finished download" + ); + } else { + this._request.cancel(cancelError); + } + } + }, + + /** + * Verify the downloaded file. We assume that the download is complete at + * this point. + */ + _verifyDownload: function Downloader__verifyDownload() { + LOG("Downloader:_verifyDownload called"); + if (!this._request) { + AUSTLMY.pingDownloadCode( + this.isCompleteUpdate, + AUSTLMY.DWNLD_ERR_VERIFY_NO_REQUEST + ); + return false; + } + + let destination = getDownloadingUpdateDir(); + destination.append(FILE_UPDATE_MAR); + + // Ensure that the file size matches the expected file size. + if (destination.fileSize != this._patch.size) { + LOG("Downloader:_verifyDownload downloaded size != expected size."); + AUSTLMY.pingDownloadCode( + this.isCompleteUpdate, + AUSTLMY.DWNLD_ERR_VERIFY_PATCH_SIZE_NOT_EQUAL + ); + return false; + } + + LOG("Downloader:_verifyDownload downloaded size == expected size."); + return true; + }, + + /** + * Select the patch to use given the current state of updateDir and the given + * set of update patches. + * @param update + * A nsIUpdate object to select a patch from + * @param updateDir + * A nsIFile representing the update directory + * @return A nsIUpdatePatch object to download + */ + _selectPatch: function Downloader__selectPatch(update, updateDir) { + // Given an update to download, we will always try to download the patch + // for a partial update over the patch for a full update. + + // Look to see if any of the patches in the Update object has been + // pre-selected for download, otherwise we must figure out which one + // to select ourselves. + var selectedPatch = update.selectedPatch; + + var state = selectedPatch ? selectedPatch.state : STATE_NONE; + + // If this is a patch that we know about, then select it. If it is a patch + // that we do not know about, then remove it and use our default logic. + var useComplete = false; + if (selectedPatch) { + LOG( + "Downloader:_selectPatch - found existing patch with state: " + state + ); + if (state == STATE_DOWNLOADING) { + LOG("Downloader:_selectPatch - resuming download"); + return selectedPatch; + } + if ( + state == STATE_PENDING || + state == STATE_PENDING_SERVICE || + state == STATE_PENDING_ELEVATE || + state == STATE_APPLIED || + state == STATE_APPLIED_SERVICE + ) { + LOG("Downloader:_selectPatch - already downloaded"); + return null; + } + + // When downloading the patch failed using BITS, there hasn't been an + // attempt to download the patch using the internal application download + // mechanism, and an attempt to stage or apply the patch hasn't failed + // which indicates that a different patch should be downloaded since + // re-downloading the same patch with the internal application download + // mechanism will likely also fail when trying to stage or apply it then + // try to download the same patch using the internal application download + // mechanism. + selectedPatch.QueryInterface(Ci.nsIWritablePropertyBag); + if ( + selectedPatch.getProperty("bitsResult") != null && + selectedPatch.getProperty("internalResult") == null && + !selectedPatch.errorCode + ) { + LOG( + "Downloader:_selectPatch - Falling back to non-BITS download " + + "mechanism for the same patch due to existing BITS result: " + + selectedPatch.getProperty("bitsResult") + ); + return selectedPatch; + } + + if (update && selectedPatch.type == "complete") { + // This is a pretty fatal error. Just bail. + LOG("Downloader:_selectPatch - failed to apply complete patch!"); + cleanupDownloadingUpdate(); + return null; + } + + // Something went wrong when we tried to apply the previous patch. + // Try the complete patch next time. + useComplete = true; + selectedPatch = null; + } + + // If we were not able to discover an update from a previous download, we + // select the best patch from the given set. + var partialPatch = getPatchOfType(update, "partial"); + if (!useComplete) { + selectedPatch = partialPatch; + } + if (!selectedPatch) { + if (lazy.UM.readyUpdate) { + // If we already have a ready update, we download partials only. + LOG( + "Downloader:_selectPatch - not selecting a complete patch because " + + "this is not the first download of the session" + ); + return null; + } + + if (partialPatch) { + partialPatch.selected = false; + } + selectedPatch = getPatchOfType(update, "complete"); + } + + // if update only contains a partial patch, selectedPatch == null here if + // the partial patch has been attempted and fails and we're trying to get a + // complete patch + if (selectedPatch) { + selectedPatch.selected = true; + update.isCompleteUpdate = selectedPatch.type == "complete"; + } + + LOG( + "Downloader:_selectPatch - Patch selected. Assigning update to " + + "downloadingUpdate." + ); + lazy.UM.downloadingUpdate = update; + + return selectedPatch; + }, + + /** + * Whether or not the user wants to be notified that an update is being + * downloaded. + */ + get _notifyDuringDownload() { + return Services.prefs.getBoolPref( + PREF_APP_UPDATE_NOTIFYDURINGDOWNLOAD, + false + ); + }, + + _notifyDownloadStatusObservers: + function Downloader_notifyDownloadStatusObservers() { + if (this._notifyDuringDownload) { + let status = this.updateService.isDownloading ? "downloading" : "idle"; + Services.obs.notifyObservers( + this._update, + "update-downloading", + status + ); + } + }, + + /** + * Whether or not we are currently downloading something. + */ + get isBusy() { + return this._request != null || this._pendingRequest != null; + }, + + get usingBits() { + return this._pendingRequest != null || this._request instanceof BitsRequest; + }, + + /** + * Returns true if the specified patch can be downloaded with BITS. + */ + _canUseBits: function Downloader__canUseBits(patch) { + if (getCanUseBits() != "CanUseBits") { + // This will have printed its own logging. No need to print more. + return false; + } + // Regardless of success or failure, don't download the same patch with BITS + // twice. + if (patch.getProperty("bitsResult") != null) { + LOG( + "Downloader:_canUseBits - Not using BITS because it was already tried" + ); + return false; + } + LOG("Downloader:_canUseBits - Patch is able to use BITS download"); + return true; + }, + + /** + * Instruct the add-ons manager to start downloading language pack updates in + * preparation for the current update. + */ + _startLangPackUpdates: function Downloader__startLangPackUpdates() { + if (!Services.prefs.getBoolPref(PREF_APP_UPDATE_LANGPACK_ENABLED, false)) { + return; + } + + // A promise that we can resolve at some point to time out the language pack + // update process. + let timeoutPromise = new Promise(resolve => { + this._langPackTimeout = resolve; + }); + + let update = unwrap(this._update); + + let existing = LangPackUpdates.get(update); + if (existing) { + // We have already started staging lang packs for this update, no need to + // do it again. + return; + } + + // Note that we don't care about success or failure here, either way we will + // continue with the update process. + let langPackPromise = lazy.AddonManager.stageLangpacksForAppUpdate( + update.appVersion, + update.appVersion + ) + .catch(error => { + LOG( + `Add-ons manager threw exception while updating language packs: ${error}` + ); + }) + .finally(() => { + this._langPackTimeout = null; + + if (TelemetryStopwatch.running("UPDATE_LANGPACK_OVERTIME", update)) { + TelemetryStopwatch.finish("UPDATE_LANGPACK_OVERTIME", update); + } + }); + + LangPackUpdates.set( + update, + Promise.race([langPackPromise, timeoutPromise]) + ); + }, + + /** + * Download and stage the given update. + * @param update + * A nsIUpdate object to download a patch for. Cannot be null. + */ + downloadUpdate: async function Downloader_downloadUpdate(update) { + LOG("UpdateService:downloadUpdate"); + if (!update) { + AUSTLMY.pingDownloadCode(undefined, AUSTLMY.DWNLD_ERR_NO_UPDATE); + throw Components.Exception("", Cr.NS_ERROR_NULL_POINTER); + } + + var updateDir = getDownloadingUpdateDir(); + + this._update = update; + + // This function may return null, which indicates that there are no patches + // to download. + this._patch = this._selectPatch(update, updateDir); + if (!this._patch) { + LOG("Downloader:downloadUpdate - no patch to download"); + AUSTLMY.pingDownloadCode(undefined, AUSTLMY.DWNLD_ERR_NO_UPDATE_PATCH); + return false; + } + // The update and the patch implement nsIWritablePropertyBag. Expose that + // interface immediately after a patch is assigned so that + // this.(_patch|_update).(get|set)Property can always safely be called. + this._update.QueryInterface(Ci.nsIWritablePropertyBag); + this._patch.QueryInterface(Ci.nsIWritablePropertyBag); + + if ( + this._update.getProperty("disableBackgroundUpdates") != null && + lazy.gIsBackgroundTaskMode + ) { + LOG( + "Downloader:downloadUpdate - Background update disabled by update " + + "advertisement" + ); + return false; + } + + this.isCompleteUpdate = this._patch.type == "complete"; + + let canUseBits = false; + // Allow the advertised update to disable BITS. + if (this._update.getProperty("disableBITS") != null) { + LOG( + "Downloader:downloadUpdate - BITS downloads disabled by update " + + "advertisement" + ); + } else { + canUseBits = this._canUseBits(this._patch); + } + + if (!canUseBits) { + this._pendingRequest = null; + + let patchFile = updateDir.clone(); + patchFile.append(FILE_UPDATE_MAR); + + if (lazy.gIsBackgroundTaskMode) { + // We don't normally run a background update if we can't use BITS, but + // this branch is possible because we do fall back from BITS failures by + // attempting an internal download. + // If this happens, we are just going to need to wait for interactive + // Firefox to download the update. We don't, however, want to be in the + // "downloading" state when interactive Firefox runs because we want to + // download the newest update available which, at that point, may not be + // the one that we are currently trying to download. + // However, we can't just unconditionally clobber the current update + // because interactive Firefox might already be part way through an + // internal update download, and we definitely don't want to interrupt + // that. + let readyUpdateDir = getReadyUpdateDir(); + let status = readStatusFile(readyUpdateDir); + // nsIIncrementalDownload doesn't use an intermediate download location + // for partially downloaded files. If we have started an update + // download with it, it will be available at its ultimate location. + if (!(status == STATE_DOWNLOADING && patchFile.exists())) { + LOG( + "Downloader:downloadUpdate - Can't download with internal " + + "downloader from a background task. Cleaning up downloading " + + "update." + ); + cleanupDownloadingUpdate(); + } + return false; + } + + // The interval is 0 since there is no need to throttle downloads. + let interval = 0; + + LOG( + "Downloader:downloadUpdate - Starting nsIIncrementalDownload with " + + "url: " + + this._patch.URL + + ", path: " + + patchFile.path + + ", interval: " + + interval + ); + let uri = Services.io.newURI(this._patch.URL); + + this._request = Cc[ + "@mozilla.org/network/incremental-download;1" + ].createInstance(Ci.nsIIncrementalDownload); + this._request.init(uri, patchFile, DOWNLOAD_CHUNK_SIZE, interval); + this._request.start(this, null); + } else { + let noProgressTimeout = BITS_IDLE_NO_PROGRESS_TIMEOUT_SECS; + let monitorInterval = BITS_IDLE_POLL_RATE_MS; + this._bitsActiveNotifications = false; + // The monitor's timeout should be much greater than the longest monitor + // poll interval. If the timeout is too short, delay in the pipe to the + // update agent might cause BITS to falsely report an error, causing an + // unnecessary fallback to nsIIncrementalDownload. + let monitorTimeout = Math.max(10 * monitorInterval, 10 * 60 * 1000); + if (this.hasDownloadListeners) { + noProgressTimeout = BITS_ACTIVE_NO_PROGRESS_TIMEOUT_SECS; + monitorInterval = BITS_ACTIVE_POLL_RATE_MS; + this._bitsActiveNotifications = true; + } + + let updateRootDir = FileUtils.getDir(KEY_UPDROOT, [], true); + let jobName = "MozillaUpdate " + updateRootDir.leafName; + let updatePath = updateDir.path; + if (!Bits.initialized) { + Bits.init(jobName, updatePath, monitorTimeout); + } + + this._cancelPromise = null; + + let bitsId = this._patch.getProperty("bitsId"); + if (bitsId) { + LOG( + "Downloader:downloadUpdate - Connecting to in-progress download. " + + "BITS ID: " + + bitsId + ); + + this._pendingRequest = Bits.monitorDownload( + bitsId, + monitorInterval, + this, + null + ); + } else { + LOG( + "Downloader:downloadUpdate - Starting BITS download with url: " + + this._patch.URL + + ", updateDir: " + + updatePath + + ", filename: " + + FILE_UPDATE_MAR + ); + + this._pendingRequest = Bits.startDownload( + this._patch.URL, + FILE_UPDATE_MAR, + Ci.nsIBits.PROXY_PRECONFIG, + noProgressTimeout, + monitorInterval, + this, + null + ); + } + let request; + try { + request = await this._pendingRequest; + } catch (error) { + if ( + (error.type == Ci.nsIBits.ERROR_TYPE_FAILED_TO_GET_BITS_JOB || + error.type == Ci.nsIBits.ERROR_TYPE_FAILED_TO_CONNECT_TO_BCM) && + error.action == Ci.nsIBits.ERROR_ACTION_MONITOR_DOWNLOAD && + error.stage == Ci.nsIBits.ERROR_STAGE_BITS_CLIENT && + error.codeType == Ci.nsIBits.ERROR_CODE_TYPE_HRESULT && + error.code == HRESULT_E_ACCESSDENIED + ) { + LOG( + "Downloader:downloadUpdate - Failed to connect to existing " + + "BITS job. It is likely owned by another user." + ); + // This isn't really a failure code since the BITS job may be working + // just fine on another account, so convert this to a code that + // indicates that. This will make it easier to identify in telemetry. + error.type = Ci.nsIBits.ERROR_TYPE_ACCESS_DENIED_EXPECTED; + error.codeType = Ci.nsIBits.ERROR_CODE_TYPE_NONE; + error.code = null; + // When we detect this situation, disable BITS until Firefox shuts + // down. There are a couple of reasons for this. First, without any + // kind of flag, we enter an infinite loop here where we keep trying + // BITS over and over again (normally setting bitsResult prevents + // this, but we don't know the result of the BITS job, so we don't + // want to set that). Second, since we are trying to update, this + // process must have the update mutex. We don't ever give up the + // update mutex, so even if the other user starts Firefox, they will + // not complete the BITS job while this Firefox instance is around. + gBITSInUseByAnotherUser = true; + } else { + this._patch.setProperty("bitsResult", Cr.NS_ERROR_FAILURE); + lazy.UM.saveUpdates(); + + LOG( + "Downloader:downloadUpdate - Failed to start to BITS job. " + + "Error: " + + error + ); + } + + this._pendingRequest = null; + + AUSTLMY.pingBitsError(this.isCompleteUpdate, error); + + // Try download again with nsIIncrementalDownload + return this.downloadUpdate(this._update); + } + + this._request = request; + this._patch.setProperty("bitsId", request.bitsId); + + LOG( + "Downloader:downloadUpdate - BITS download running. BITS ID: " + + request.bitsId + ); + + if (this.hasDownloadListeners) { + this._maybeStartActiveNotifications(); + } else { + this._maybeStopActiveNotifications(); + } + + lazy.UM.saveUpdates(); + this._pendingRequest = null; + } + + if (!lazy.UM.readyUpdate) { + LOG("Downloader:downloadUpdate - Setting status to downloading"); + writeStatusFile(getReadyUpdateDir(), STATE_DOWNLOADING); + } + if (this._patch.state != STATE_DOWNLOADING) { + LOG("Downloader:downloadUpdate - Setting state to downloading"); + this._patch.state = STATE_DOWNLOADING; + lazy.UM.saveUpdates(); + } + + // If we are downloading a second update, we don't change the state until + // STATE_SWAP. + if (lazy.AUS.currentState == Ci.nsIApplicationUpdateService.STATE_PENDING) { + LOG( + "Downloader:downloadUpdate - not setting state because download is " + + "already pending." + ); + } else { + LOG( + "Downloader:downloadUpdate - setting currentState to STATE_DOWNLOADING" + ); + transitionState(Ci.nsIApplicationUpdateService.STATE_DOWNLOADING); + } + + this._startLangPackUpdates(); + + this._notifyDownloadStatusObservers(); + + return true; + }, + + /** + * This is run when a download listener is added. + */ + onDownloadListenerAdded: function Downloader_onDownloadListenerAdded() { + // Increase the status update frequency when someone starts listening + this._maybeStartActiveNotifications(); + }, + + /** + * This is run when a download listener is removed. + */ + onDownloadListenerRemoved: function Downloader_onDownloadListenerRemoved() { + // Decrease the status update frequency when no one is listening + if (!this.hasDownloadListeners) { + this._maybeStopActiveNotifications(); + } + }, + + get hasDownloadListeners() { + return this.updateService.hasDownloadListeners; + }, + + /** + * This speeds up BITS progress notifications in response to a user watching + * the notifications. + */ + _maybeStartActiveNotifications: + async function Downloader__maybeStartActiveNotifications() { + if ( + this.usingBits && + !this._bitsActiveNotifications && + this.hasDownloadListeners && + this._request + ) { + LOG( + "Downloader:_maybeStartActiveNotifications - Starting active " + + "notifications" + ); + this._bitsActiveNotifications = true; + await Promise.all([ + this._request + .setNoProgressTimeout(BITS_ACTIVE_NO_PROGRESS_TIMEOUT_SECS) + .catch(error => { + LOG( + "Downloader:_maybeStartActiveNotifications - Failed to set " + + "no progress timeout. Error: " + + error + ); + }), + this._request + .changeMonitorInterval(BITS_ACTIVE_POLL_RATE_MS) + .catch(error => { + LOG( + "Downloader:_maybeStartActiveNotifications - Failed to increase " + + "status update frequency. Error: " + + error + ); + }), + ]); + } + }, + + /** + * This slows down BITS progress notifications in response to a user no longer + * watching the notifications. + */ + _maybeStopActiveNotifications: + async function Downloader__maybeStopActiveNotifications() { + if ( + this.usingBits && + this._bitsActiveNotifications && + !this.hasDownloadListeners && + this._request + ) { + LOG( + "Downloader:_maybeStopActiveNotifications - Stopping active " + + "notifications" + ); + this._bitsActiveNotifications = false; + await Promise.all([ + this._request + .setNoProgressTimeout(BITS_IDLE_NO_PROGRESS_TIMEOUT_SECS) + .catch(error => { + LOG( + "Downloader:_maybeStopActiveNotifications - Failed to set " + + "no progress timeout: " + + error + ); + }), + this._request + .changeMonitorInterval(BITS_IDLE_POLL_RATE_MS) + .catch(error => { + LOG( + "Downloader:_maybeStopActiveNotifications - Failed to decrease " + + "status update frequency: " + + error + ); + }), + ]); + } + }, + + /** + * When the async request begins + * @param request + * The nsIRequest object for the transfer + */ + onStartRequest: function Downloader_onStartRequest(request) { + if (this.usingBits) { + LOG("Downloader:onStartRequest"); + } else { + LOG( + "Downloader:onStartRequest - original URI spec: " + + request.URI.spec + + ", final URI spec: " + + request.finalURI.spec + ); + // Set finalURL in onStartRequest if it is different. + if (this._patch.finalURL != request.finalURI.spec) { + this._patch.finalURL = request.finalURI.spec; + lazy.UM.saveUpdates(); + } + } + + this.updateService.forEachDownloadListener(listener => { + listener.onStartRequest(request); + }); + }, + + /** + * When new data has been downloaded + * @param request + * The nsIRequest object for the transfer + * @param progress + * The current number of bytes transferred + * @param maxProgress + * The total number of bytes that must be transferred + */ + onProgress: function Downloader_onProgress(request, progress, maxProgress) { + LOG("Downloader:onProgress - progress: " + progress + "/" + maxProgress); + + if (progress > this._patch.size) { + LOG( + "Downloader:onProgress - progress: " + + progress + + " is higher than patch size: " + + this._patch.size + ); + AUSTLMY.pingDownloadCode( + this.isCompleteUpdate, + AUSTLMY.DWNLD_ERR_PATCH_SIZE_LARGER + ); + this.cancel(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Wait until the transfer has started (progress > 0) to verify maxProgress + // so that we don't check it before it is available (in which case, -1 would + // have been passed). + if (progress > 0 && maxProgress != this._patch.size) { + LOG( + "Downloader:onProgress - maxProgress: " + + maxProgress + + " is not equal to expected patch size: " + + this._patch.size + ); + AUSTLMY.pingDownloadCode( + this.isCompleteUpdate, + AUSTLMY.DWNLD_ERR_PATCH_SIZE_NOT_EQUAL + ); + this.cancel(Cr.NS_ERROR_UNEXPECTED); + return; + } + + this.updateService.forEachDownloadListener(listener => { + if (listener instanceof Ci.nsIProgressEventSink) { + listener.onProgress(request, progress, maxProgress); + } + }); + this.updateService._consecutiveSocketErrors = 0; + }, + + /** + * When we have new status text + * @param request + * The nsIRequest object for the transfer + * @param status + * A status code + * @param statusText + * Human readable version of |status| + */ + onStatus: function Downloader_onStatus(request, status, statusText) { + LOG( + "Downloader:onStatus - status: " + status + ", statusText: " + statusText + ); + + this.updateService.forEachDownloadListener(listener => { + if (listener instanceof Ci.nsIProgressEventSink) { + listener.onStatus(request, status, statusText); + } + }); + }, + + /** + * When data transfer ceases + * @param request + * The nsIRequest object for the transfer + * @param status + * Status code containing the reason for the cessation. + */ + /* eslint-disable-next-line complexity */ + onStopRequest: async function Downloader_onStopRequest(request, status) { + if (gOnlyDownloadUpdatesThisSession) { + LOG( + "Downloader:onStopRequest - End of update download detected and " + + "ignored because we are restricted to update downloads this " + + "session. We will continue with this update next session." + ); + // In order to keep the update from progressing past the downloading + // stage, we will pretend that the download is still going. + // A lot of this work is done for us by just not setting this._request to + // null, which usually signals that the transfer has completed. + this._pretendingDownloadIsNotDone = true; + // This notification is currently used only for testing. + Services.obs.notifyObservers(null, "update-download-restriction-hit"); + return; + } + + if (!this.usingBits) { + LOG( + "Downloader:onStopRequest - downloader: nsIIncrementalDownload, " + + "original URI spec: " + + request.URI.spec + + ", final URI spec: " + + request.finalURI.spec + + ", status: " + + status + ); + } else { + LOG("Downloader:onStopRequest - downloader: BITS, status: " + status); + } + + let bitsCompletionError; + if (this.usingBits) { + if (Components.isSuccessCode(status)) { + try { + await request.complete(); + } catch (e) { + LOG( + "Downloader:onStopRequest - Unable to complete BITS download: " + e + ); + status = Cr.NS_ERROR_FAILURE; + bitsCompletionError = e; + } + } else { + // BITS jobs that failed to complete should still have cancel called on + // them to remove the job. + try { + await this.cancel(); + } catch (e) { + // This will fail if the job stopped because it was cancelled. + // Even if this is a "real" error, there isn't really anything to do + // about it, and it's not really a big problem. It just means that the + // BITS job will stay around until it is removed automatically + // (default of 90 days). + } + } + } + + var state = this._patch.state; + var shouldShowPrompt = false; + var shouldRegisterOnlineObserver = false; + var shouldRetrySoon = false; + var deleteActiveUpdate = false; + let migratedToReadyUpdate = false; + let nonDownloadFailure = false; + var retryTimeout = Services.prefs.getIntPref( + PREF_APP_UPDATE_SOCKET_RETRYTIMEOUT, + DEFAULT_SOCKET_RETRYTIMEOUT + ); + // Prevent the preference from setting a value greater than 10000. + retryTimeout = Math.min(retryTimeout, 10000); + var maxFail = Services.prefs.getIntPref( + PREF_APP_UPDATE_SOCKET_MAXERRORS, + DEFAULT_SOCKET_MAX_ERRORS + ); + // Prevent the preference from setting a value greater than 20. + maxFail = Math.min(maxFail, 20); + LOG( + "Downloader:onStopRequest - status: " + + status + + ", " + + "current fail: " + + this.updateService._consecutiveSocketErrors + + ", " + + "max fail: " + + maxFail + + ", " + + "retryTimeout: " + + retryTimeout + ); + if (Components.isSuccessCode(status)) { + if (this._verifyDownload()) { + AUSTLMY.pingDownloadCode(this.isCompleteUpdate, AUSTLMY.DWNLD_SUCCESS); + + LOG( + "Downloader:onStopRequest - Clearing readyUpdate in preparation of " + + "moving downloadingUpdate into readyUpdate." + ); + + // Clear out any old update before we notify anyone about the new one. + // It will be invalid in a moment anyways when we call + // `cleanUpReadyUpdateDir()`. + lazy.UM.readyUpdate = null; + + // We're about to clobber the ready update so we can replace it with the + // downloading update that just finished. We need to let observers know + // about this. + if ( + lazy.AUS.currentState == Ci.nsIApplicationUpdateService.STATE_PENDING + ) { + transitionState(Ci.nsIApplicationUpdateService.STATE_SWAP); + } + Services.obs.notifyObservers(this._update, "update-swap"); + + // Swap the downloading update into the ready update directory. + cleanUpReadyUpdateDir(); + let downloadedMar = getDownloadingUpdateDir(); + downloadedMar.append(FILE_UPDATE_MAR); + let readyDir = getReadyUpdateDir(); + try { + downloadedMar.moveTo(readyDir, FILE_UPDATE_MAR); + migratedToReadyUpdate = true; + } catch (e) { + migratedToReadyUpdate = false; + } + + if (migratedToReadyUpdate) { + AUSTLMY.pingMoveResult(AUSTLMY.MOVE_RESULT_SUCCESS); + state = getBestPendingState(); + shouldShowPrompt = !getCanStageUpdates(); + + // Tell the updater.exe we're ready to apply. + LOG( + `Downloader:onStopRequest - Ready to apply. Setting state to ` + + `"${state}".` + ); + writeStatusFile(getReadyUpdateDir(), state); + writeVersionFile(getReadyUpdateDir(), this._update.appVersion); + this._update.installDate = new Date().getTime(); + this._update.statusText = + lazy.gUpdateBundle.GetStringFromName("installPending"); + Services.prefs.setIntPref(PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS, 0); + } else { + LOG( + "Downloader:onStopRequest - failed to move the downloading " + + "update to the ready update directory." + ); + AUSTLMY.pingMoveResult(AUSTLMY.MOVE_RESULT_UNKNOWN_FAILURE); + + state = STATE_DOWNLOAD_FAILED; + status = Cr.NS_ERROR_FILE_COPY_OR_MOVE_FAILED; + + const mfCode = "move_failed"; + let message = getStatusTextFromCode(mfCode, mfCode); + this._update.statusText = message; + + nonDownloadFailure = true; + deleteActiveUpdate = true; + + cleanUpDownloadingUpdateDir(); + } + } else { + LOG("Downloader:onStopRequest - download verification failed"); + state = STATE_DOWNLOAD_FAILED; + status = Cr.NS_ERROR_CORRUPTED_CONTENT; + + // Yes, this code is a string. + const vfCode = "verification_failed"; + var message = getStatusTextFromCode(vfCode, vfCode); + this._update.statusText = message; + + if (this._update.isCompleteUpdate || this._update.patchCount != 2) { + LOG("Downloader:onStopRequest - No alternative patch to try"); + deleteActiveUpdate = true; + } + + // Destroy the updates directory, since we're done with it. + cleanUpDownloadingUpdateDir(); + } + } else if (status == Cr.NS_ERROR_OFFLINE) { + // Register an online observer to try again. + // The online observer will continue the incremental download by + // calling downloadUpdate on the active update which continues + // downloading the file from where it was. + LOG("Downloader:onStopRequest - offline, register online observer: true"); + AUSTLMY.pingDownloadCode( + this.isCompleteUpdate, + AUSTLMY.DWNLD_RETRY_OFFLINE + ); + shouldRegisterOnlineObserver = true; + deleteActiveUpdate = false; + + // Each of NS_ERROR_NET_TIMEOUT, ERROR_CONNECTION_REFUSED, + // NS_ERROR_NET_RESET and NS_ERROR_DOCUMENT_NOT_CACHED can be returned + // when disconnecting the internet while a download of a MAR is in + // progress. There may be others but I have not encountered them during + // testing. + } else if ( + (status == Cr.NS_ERROR_NET_TIMEOUT || + status == Cr.NS_ERROR_CONNECTION_REFUSED || + status == Cr.NS_ERROR_NET_RESET || + status == Cr.NS_ERROR_DOCUMENT_NOT_CACHED) && + this.updateService._consecutiveSocketErrors < maxFail + ) { + LOG("Downloader:onStopRequest - socket error, shouldRetrySoon: true"); + let dwnldCode = AUSTLMY.DWNLD_RETRY_CONNECTION_REFUSED; + if (status == Cr.NS_ERROR_NET_TIMEOUT) { + dwnldCode = AUSTLMY.DWNLD_RETRY_NET_TIMEOUT; + } else if (status == Cr.NS_ERROR_NET_RESET) { + dwnldCode = AUSTLMY.DWNLD_RETRY_NET_RESET; + } else if (status == Cr.NS_ERROR_DOCUMENT_NOT_CACHED) { + dwnldCode = AUSTLMY.DWNLD_ERR_DOCUMENT_NOT_CACHED; + } + AUSTLMY.pingDownloadCode(this.isCompleteUpdate, dwnldCode); + shouldRetrySoon = true; + deleteActiveUpdate = false; + } else if (status != Cr.NS_BINDING_ABORTED && status != Cr.NS_ERROR_ABORT) { + if ( + status == Cr.NS_ERROR_FILE_ACCESS_DENIED || + status == Cr.NS_ERROR_FILE_READ_ONLY + ) { + LOG("Downloader:onStopRequest - permission error"); + nonDownloadFailure = true; + } else { + LOG("Downloader:onStopRequest - non-verification failure"); + } + + let dwnldCode = AUSTLMY.DWNLD_ERR_BINDING_ABORTED; + if (status == Cr.NS_ERROR_ABORT) { + dwnldCode = AUSTLMY.DWNLD_ERR_ABORT; + } + AUSTLMY.pingDownloadCode(this.isCompleteUpdate, dwnldCode); + + // Some sort of other failure, log this in the |statusText| property + state = STATE_DOWNLOAD_FAILED; + + // XXXben - if |request| (The Incremental Download) provided a means + // for accessing the http channel we could do more here. + + this._update.statusText = getStatusTextFromCode( + status, + Cr.NS_BINDING_FAILED + ); + + // Destroy the updates directory, since we're done with it. + cleanUpDownloadingUpdateDir(); + + deleteActiveUpdate = true; + } + if (!this.usingBits) { + LOG(`Downloader:onStopRequest - Setting internalResult to ${status}`); + this._patch.setProperty("internalResult", status); + } else { + LOG(`Downloader:onStopRequest - Setting bitsResult to ${status}`); + this._patch.setProperty("bitsResult", status); + + // If we failed when using BITS, we want to override the retry decision + // since we need to retry with nsIncrementalDownload before we give up. + // However, if the download was cancelled, don't retry. If the transfer + // was cancelled, we don't want it to restart on its own. + if ( + !Components.isSuccessCode(status) && + status != Cr.NS_BINDING_ABORTED && + status != Cr.NS_ERROR_ABORT + ) { + deleteActiveUpdate = false; + shouldRetrySoon = true; + } + + // Send BITS Telemetry + if (Components.isSuccessCode(status)) { + AUSTLMY.pingBitsSuccess(this.isCompleteUpdate); + } else { + let error; + if (bitsCompletionError) { + error = bitsCompletionError; + } else if (status == Cr.NS_ERROR_CORRUPTED_CONTENT) { + error = new BitsVerificationError(); + } else { + error = request.transferError; + if (!error) { + error = new BitsUnknownError(); + } + } + AUSTLMY.pingBitsError(this.isCompleteUpdate, error); + } + } + + LOG("Downloader:onStopRequest - setting state to: " + state); + if (this._patch.state != state) { + this._patch.state = state; + } + if (deleteActiveUpdate) { + LOG("Downloader:onStopRequest - Clearing downloadingUpdate."); + this._update.installDate = new Date().getTime(); + lazy.UM.addUpdateToHistory(lazy.UM.downloadingUpdate); + lazy.UM.downloadingUpdate = null; + } else if ( + lazy.UM.downloadingUpdate && + lazy.UM.downloadingUpdate.state != state + ) { + lazy.UM.downloadingUpdate.state = state; + } + if (migratedToReadyUpdate) { + LOG( + "Downloader:onStopRequest - Moving downloadingUpdate into readyUpdate" + ); + lazy.UM.readyUpdate = lazy.UM.downloadingUpdate; + lazy.UM.downloadingUpdate = null; + } + lazy.UM.saveUpdates(); + + // Only notify listeners about the stopped state if we + // aren't handling an internal retry. + if (!shouldRetrySoon && !shouldRegisterOnlineObserver) { + this.updateService.forEachDownloadListener(listener => { + listener.onStopRequest(request, status); + }); + } + + this._request = null; + + // This notification must happen after _request is set to null so that + // the correct this.updateService.isDownloading value is available in + // _notifyDownloadStatusObservers(). + this._notifyDownloadStatusObservers(); + + if (state == STATE_DOWNLOAD_FAILED) { + var allFailed = true; + // Don't bother retrying the download if we got an error that isn't + // download related. + if (!nonDownloadFailure) { + // If we haven't already, attempt to download without BITS + if (request instanceof BitsRequest) { + LOG( + "Downloader:onStopRequest - BITS download failed. Falling back " + + "to nsIIncrementalDownload" + ); + let success = await this.downloadUpdate(this._update); + if (!success) { + LOG( + "Downloader:onStopRequest - Failed to fall back to " + + "nsIIncrementalDownload. Cleaning up downloading update." + ); + cleanupDownloadingUpdate(); + } else { + allFailed = false; + } + } + + // Check if there is a complete update patch that can be downloaded. + if ( + allFailed && + !this._update.isCompleteUpdate && + this._update.patchCount == 2 + ) { + LOG( + "Downloader:onStopRequest - verification of patch failed, " + + "downloading complete update patch" + ); + this._update.isCompleteUpdate = true; + let success = await this.downloadUpdate(this._update); + + if (!success) { + LOG( + "Downloader:onStopRequest - Failed to fall back to complete " + + "patch. Cleaning up downloading update." + ); + cleanupDownloadingUpdate(); + } else { + allFailed = false; + } + } + } + + if (allFailed) { + let downloadAttempts = Services.prefs.getIntPref( + PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS, + 0 + ); + downloadAttempts++; + Services.prefs.setIntPref( + PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS, + downloadAttempts + ); + let maxAttempts = Math.min( + Services.prefs.getIntPref(PREF_APP_UPDATE_DOWNLOAD_MAXATTEMPTS, 2), + 10 + ); + + transitionState(Ci.nsIApplicationUpdateService.STATE_IDLE); + + if (downloadAttempts > maxAttempts) { + LOG( + "Downloader:onStopRequest - notifying observers of error. " + + "topic: update-error, status: download-attempts-exceeded, " + + "downloadAttempts: " + + downloadAttempts + + " " + + "maxAttempts: " + + maxAttempts + ); + Services.obs.notifyObservers( + this._update, + "update-error", + "download-attempts-exceeded" + ); + } else { + this._update.selectedPatch.selected = false; + LOG( + "Downloader:onStopRequest - notifying observers of error. " + + "topic: update-error, status: download-attempt-failed" + ); + Services.obs.notifyObservers( + this._update, + "update-error", + "download-attempt-failed" + ); + } + // We don't care about language pack updates now. + this._langPackTimeout = null; + LangPackUpdates.delete(unwrap(this._update)); + + // Prevent leaking the update object (bug 454964). + this._update = null; + + // allFailed indicates that we didn't (successfully) call downloadUpdate + // to try to download a different MAR. In this case, this Downloader + // is no longer being used. + this.updateService._downloader = null; + } + // A complete download has been initiated or the failure was handled. + return; + } + + // If the download has succeeded or failed, we are done with this Downloader + // object. However, in some cases (ex: network disconnection), we will + // attempt to resume using this same Downloader. + if (state != STATE_DOWNLOADING) { + this.updateService._downloader = null; + } + + if ( + state == STATE_PENDING || + state == STATE_PENDING_SERVICE || + state == STATE_PENDING_ELEVATE + ) { + if (getCanStageUpdates()) { + LOG( + "Downloader:onStopRequest - attempting to stage update: " + + this._update.name + ); + // Stage the update + let stagingStarted = true; + try { + Cc["@mozilla.org/updates/update-processor;1"] + .createInstance(Ci.nsIUpdateProcessor) + .processUpdate(); + } catch (e) { + LOG( + "Downloader:onStopRequest - failed to stage update. Exception: " + e + ); + stagingStarted = false; + } + if (stagingStarted) { + transitionState(Ci.nsIApplicationUpdateService.STATE_STAGING); + } else { + // Fail gracefully in case the application does not support the update + // processor service. + shouldShowPrompt = true; + } + } + } + + // If we're still waiting on language pack updates then run a timer to time + // out the attempt after an appropriate amount of time. + if (this._langPackTimeout) { + // Start a timer to measure how much longer it takes for the language + // packs to stage. + TelemetryStopwatch.start( + "UPDATE_LANGPACK_OVERTIME", + unwrap(this._update), + { inSeconds: true } + ); + + lazy.setTimeout( + this._langPackTimeout, + Services.prefs.getIntPref( + PREF_APP_UPDATE_LANGPACK_TIMEOUT, + LANGPACK_UPDATE_DEFAULT_TIMEOUT + ) + ); + } + + // Do this after *everything* else, since it will likely cause the app + // to shut down. + if (shouldShowPrompt) { + // Wait for language packs to stage before showing any prompt to restart. + let update = this._update; + promiseLangPacksUpdated(update).then(() => { + LOG( + "Downloader:onStopRequest - Notifying observers that " + + "an update was downloaded. topic: update-downloaded, status: " + + update.state + ); + transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING); + Services.obs.notifyObservers(update, "update-downloaded", update.state); + }); + } + + if (shouldRegisterOnlineObserver) { + LOG("Downloader:onStopRequest - Registering online observer"); + this.updateService._registerOnlineObserver(); + } else if (shouldRetrySoon) { + LOG("Downloader:onStopRequest - Retrying soon"); + this.updateService._consecutiveSocketErrors++; + if (this.updateService._retryTimer) { + this.updateService._retryTimer.cancel(); + } + this.updateService._retryTimer = Cc[ + "@mozilla.org/timer;1" + ].createInstance(Ci.nsITimer); + this.updateService._retryTimer.initWithCallback( + async () => { + await this.updateService._attemptResume(); + }, + retryTimeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } else { + // Prevent leaking the update object (bug 454964) + this._update = null; + } + }, + + /** + * This function should be called when shutting down so that resources get + * freed properly. + */ + cleanup: async function Downloader_cleanup() { + if (this.usingBits) { + if (this._pendingRequest) { + await this._pendingRequest; + } + this._request.shutdown(); + } + }, + + /** + * See nsIInterfaceRequestor.idl + */ + getInterface: function Downloader_getInterface(iid) { + // The network request may require proxy authentication, so provide the + // default nsIAuthPrompt if requested. + if (iid.equals(Ci.nsIAuthPrompt)) { + var prompt = + Cc["@mozilla.org/network/default-auth-prompt;1"].createInstance(); + return prompt.QueryInterface(iid); + } + throw Components.Exception("", Cr.NS_NOINTERFACE); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIRequestObserver", + "nsIProgressEventSink", + "nsIInterfaceRequestor", + ]), +}; + +// On macOS, all browser windows can be closed without Firefox exiting. If it +// is left in this state for a while and an update is pending, we should restart +// Firefox on our own to apply the update. This class will do that +// automatically. +class RestartOnLastWindowClosed { + #enabled = false; + #hasShutdown = false; + + #restartTimer = null; + #restartTimerExpired = false; + + constructor() { + this.#maybeEnableOrDisable(); + + Services.prefs.addObserver( + PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED, + this + ); + Services.obs.addObserver(this, "quit-application"); + } + + shutdown() { + LOG("RestartOnLastWindowClosed.shutdown - Shutting down"); + this.#hasShutdown = true; + + Services.prefs.removeObserver( + PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED, + this + ); + Services.obs.removeObserver(this, "quit-application"); + + this.#maybeEnableOrDisable(); + } + + get shouldEnable() { + if (AppConstants.platform != "macosx") { + return false; + } + if (this.#hasShutdown) { + return false; + } + return Services.prefs.getBoolPref( + PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED, + false + ); + } + + get enabled() { + return this.#enabled; + } + + observe(subject, topic, data) { + switch (topic) { + case "nsPref:changed": + if (data == PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED) { + this.#maybeEnableOrDisable(); + } + break; + case "quit-application": + this.shutdown(); + break; + case "domwindowclosed": + this.#onWindowClose(); + break; + case "domwindowopened": + this.#onWindowOpen(); + break; + case "update-downloaded": + case "update-staged": + this.#onUpdateReady(data); + break; + } + } + + // Returns true if any windows are open. Otherwise, false. + #windowsAreOpen() { + // eslint-disable-next-line no-unused-vars + for (const win of Services.wm.getEnumerator(null)) { + return true; + } + return false; + } + + // Enables or disables this class's functionality based on the value of + // this.shouldEnable. Does nothing if the class is already in the right state + // (i.e. if the class should be enabled and already is, or should be disabled + // and already is). + #maybeEnableOrDisable() { + if (this.shouldEnable) { + if (this.#enabled) { + return; + } + LOG("RestartOnLastWindowClosed.#maybeEnableOrDisable - Enabling"); + + Services.obs.addObserver(this, "domwindowclosed"); + Services.obs.addObserver(this, "domwindowopened"); + Services.obs.addObserver(this, "update-downloaded"); + Services.obs.addObserver(this, "update-staged"); + + this.#restartTimer = null; + this.#restartTimerExpired = false; + + this.#enabled = true; + + // Synchronize with external state. + this.#onWindowClose(); + } else { + if (!this.#enabled) { + return; + } + LOG("RestartOnLastWindowClosed.#maybeEnableOrDisable - Disabling"); + + Services.obs.removeObserver(this, "domwindowclosed"); + Services.obs.removeObserver(this, "domwindowopened"); + Services.obs.removeObserver(this, "update-downloaded"); + Services.obs.removeObserver(this, "update-staged"); + + this.#enabled = false; + + if (this.#restartTimer) { + this.#restartTimer.cancel(); + } + this.#restartTimer = null; + } + } + + // Note: Since we keep track of the update state even when this class is + // disabled, this function will run even in that case. + #onUpdateReady(updateState) { + // Note that we do not count pending-elevate as a ready state, because we + // cannot silently restart in that state. + if ( + [ + STATE_APPLIED, + STATE_PENDING, + STATE_APPLIED_SERVICE, + STATE_PENDING_SERVICE, + ].includes(updateState) + ) { + if (this.#enabled) { + LOG("RestartOnLastWindowClosed.#onUpdateReady - update ready"); + this.#maybeRestartBrowser(); + } + } else if (this.#enabled) { + LOG( + `RestartOnLastWindowClosed.#onUpdateReady - Not counting update as ` + + `ready because the state is ${updateState}` + ); + } + } + + #onWindowClose() { + if (!this.#windowsAreOpen()) { + this.#onLastWindowClose(); + } + } + + #onLastWindowClose() { + if (this.#restartTimer || this.#restartTimerExpired) { + LOG( + "RestartOnLastWindowClosed.#onLastWindowClose - Restart timer is " + + "either already running or has already expired" + ); + return; + } + + let timeout = Services.prefs.getIntPref( + PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_DELAY_MS, + 5 * 60 * 1000 + ); + + LOG( + "RestartOnLastWindowClosed.#onLastWindowClose - Last window closed. " + + "Starting restart timer" + ); + this.#restartTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this.#restartTimer.initWithCallback( + () => this.#onRestartTimerExpire(), + timeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + + #onWindowOpen() { + if (this.#restartTimer) { + LOG( + "RestartOnLastWindowClosed.#onWindowOpen - Window opened. Cancelling " + + "restart timer." + ); + this.#restartTimer.cancel(); + } + this.#restartTimer = null; + this.#restartTimerExpired = false; + } + + #onRestartTimerExpire() { + LOG("RestartOnLastWindowClosed.#onRestartTimerExpire - Timer Expired"); + + this.#restartTimer = null; + this.#restartTimerExpired = true; + this.#maybeRestartBrowser(); + } + + #maybeRestartBrowser() { + if (!this.#restartTimerExpired) { + LOG( + "RestartOnLastWindowClosed.#maybeRestartBrowser - Still waiting for " + + "all windows to be closed and restartTimer to expire. " + + "(not restarting)" + ); + return; + } + + if (lazy.AUS.currentState != Ci.nsIApplicationUpdateService.STATE_PENDING) { + LOG( + "RestartOnLastWindowClosed.#maybeRestartBrowser - No update ready. " + + "(not restarting)" + ); + return; + } + + if (getElevationRequired()) { + // We check for STATE_PENDING_ELEVATE elsewhere, but this is actually + // different from that because it is technically possible that the user + // gave permission to elevate, but we haven't actually elevated yet. + // This is a bit of a corner case. We only call elevationOptedIn() right + // before we restart to apply the update immediately. But it is possible + // that something could stop the browser from shutting down. + LOG( + "RestartOnLastWindowClosed.#maybeRestartBrowser - This update will " + + "require user elevation (not restarting)" + ); + return; + } + + if (this.#windowsAreOpen()) { + LOG( + "RestartOnLastWindowClosed.#maybeRestartBrowser - Window " + + "unexpectedly still open! (not restarting)" + ); + return; + } + + if (!this.shouldEnable) { + LOG( + "RestartOnLastWindowClosed.#maybeRestartBrowser - Unexpectedly " + + "attempted to restart when RestartOnLastWindowClosed ought to be " + + "disabled! (not restarting)" + ); + return; + } + + LOG("RestartOnLastWindowClosed.#maybeRestartBrowser - Restarting now"); + Services.telemetry.scalarAdd("update.no_window_auto_restarts", 1); + Services.startup.quit( + Ci.nsIAppStartup.eAttemptQuit | + Ci.nsIAppStartup.eRestart | + Ci.nsIAppStartup.eSilently + ); + } +} +// Nothing actually uses this variable at the moment, but let's make sure that +// we hold the reference to the RestartOnLastWindowClosed instance somewhere. +// eslint-disable-next-line no-unused-vars +let restartOnLastWindowClosed = new RestartOnLastWindowClosed(); |