tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

remote-settings.sys.mjs (29983B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      6 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      7 
      8 const lazy = {};
      9 
     10 ChromeUtils.defineESModuleGetters(lazy, {
     11  ClientEnvironmentBase:
     12    "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
     13  Database: "resource://services-settings/Database.sys.mjs",
     14  FilterExpressions:
     15    "resource://gre/modules/components-utils/FilterExpressions.sys.mjs",
     16  pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs",
     17  RemoteSettingsClient:
     18    "resource://services-settings/RemoteSettingsClient.sys.mjs",
     19  SyncHistory: "resource://services-settings/SyncHistory.sys.mjs",
     20  UptakeTelemetry: "resource://services-common/uptake-telemetry.sys.mjs",
     21  Utils: "resource://services-settings/Utils.sys.mjs",
     22 });
     23 
     24 const PREF_SETTINGS_BRANCH = "services.settings.";
     25 const PREF_SETTINGS_SERVER_BACKOFF = "server.backoff";
     26 const PREF_SETTINGS_LAST_UPDATE = "last_update_seconds";
     27 const PREF_SETTINGS_LAST_ETAG = "last_etag";
     28 const PREF_SETTINGS_CLOCK_SKEW_SECONDS = "clock_skew_seconds";
     29 const PREF_SETTINGS_SYNC_HISTORY_SIZE = "sync_history_size";
     30 const PREF_SETTINGS_SYNC_HISTORY_ERROR_THRESHOLD =
     31  "sync_history_error_threshold";
     32 
     33 // Telemetry identifiers.
     34 const TELEMETRY_COMPONENT = "Remotesettings";
     35 const TELEMETRY_SOURCE_POLL = "settings-changes-monitoring";
     36 const TELEMETRY_SOURCE_SYNC = "settings-sync";
     37 
     38 // Push broadcast id.
     39 const BROADCAST_ID = "remote-settings/monitor_changes";
     40 
     41 // Signer to be used when not specified (see Ci.nsIContentSignatureVerifier).
     42 const DEFAULT_SIGNER = "remote-settings.content-signature.mozilla.org";
     43 const SIGNERS_BY_BUCKET = {
     44  "security-state": "onecrl.content-signature.mozilla.org",
     45  "security-state-preview": "onecrl.content-signature.mozilla.org",
     46  // All the other buckets use the default signer.
     47  // This mapping would have to be modified if a consumer relies on
     48  // changesets bundles and leverages a specific bucket and signer.
     49  // This is very (very) unlikely though.
     50 };
     51 
     52 ChromeUtils.defineLazyGetter(lazy, "gPrefs", () => {
     53  return Services.prefs.getBranch(PREF_SETTINGS_BRANCH);
     54 });
     55 ChromeUtils.defineLazyGetter(lazy, "console", () => lazy.Utils.log);
     56 
     57 ChromeUtils.defineLazyGetter(lazy, "gSyncHistory", () => {
     58  const prefSize = lazy.gPrefs.getIntPref(PREF_SETTINGS_SYNC_HISTORY_SIZE, 100);
     59  const size = Math.min(Math.max(prefSize, 1000), 10);
     60  return new lazy.SyncHistory(TELEMETRY_SOURCE_SYNC, { size });
     61 });
     62 
     63 XPCOMUtils.defineLazyPreferenceGetter(
     64  lazy,
     65  "gPrefBrokenSyncThreshold",
     66  PREF_SETTINGS_BRANCH + PREF_SETTINGS_SYNC_HISTORY_ERROR_THRESHOLD,
     67  10
     68 );
     69 
     70 XPCOMUtils.defineLazyPreferenceGetter(
     71  lazy,
     72  "gPrefDestroyBrokenEnabled",
     73  PREF_SETTINGS_BRANCH + "destroy_broken_db_enabled",
     74  true
     75 );
     76 
     77 /**
     78 * cacheProxy returns an object Proxy that will memoize properties of the target.
     79 *
     80 * @param {object} target the object to wrap.
     81 * @returns {Proxy}
     82 */
     83 function cacheProxy(target) {
     84  const cache = new Map();
     85  return new Proxy(target, {
     86    get(innerTarget, prop) {
     87      if (!cache.has(prop)) {
     88        cache.set(prop, innerTarget[prop]);
     89      }
     90      return cache.get(prop);
     91    },
     92  });
     93 }
     94 
     95 class JexlFilter {
     96  constructor(environment, collectionName) {
     97    this._environment = environment;
     98    this._collectionName = collectionName;
     99    this._cachedResultForExpression = new Map();
    100    this._context = {
    101      env: environment,
    102    };
    103  }
    104 
    105  /**
    106   * Default entry filtering function, in charge of excluding remote settings entries
    107   * where the JEXL expression evaluates into a falsy value.
    108   *
    109   * @param {object} entry The Remote Settings entry to be excluded or kept.
    110   * @returns {?object} the entry or null if excluded.
    111   */
    112  async filterEntry(entry) {
    113    const { filter_expression } = entry;
    114    if (!filter_expression) {
    115      return entry;
    116    }
    117    let result = this._cachedResultForExpression.get(filter_expression);
    118    if (result === undefined) {
    119      try {
    120        result = Boolean(
    121          await lazy.FilterExpressions.eval(filter_expression, this._context)
    122        );
    123      } catch (e) {
    124        console.error(
    125          e,
    126          "Full expression: " + filter_expression,
    127          this._collectionName
    128        );
    129      }
    130      this._cachedResultForExpression.set(filter_expression, result);
    131    }
    132    return result ? entry : null;
    133  }
    134 }
    135 
    136 /**
    137 * Creates the default entry filter, in charge of excluding remote settings entries
    138 * where the JEXL expression evaluates into a falsy value.
    139 *
    140 * @param {ClientEnvironment} environment Information about version, language, platform etc.
    141 * @param {string}            collectionName
    142 *    Which collection includes this entry. This is used for error reporting.
    143 * @returns {RemoteSettingsEntryFilter} The entry filter.
    144 */
    145 export async function jexlFilterCreator(environment, collectionName) {
    146  const cachedEnvironment = cacheProxy(environment);
    147  return new JexlFilter(cachedEnvironment, collectionName);
    148 }
    149 
    150 function remoteSettingsFunction() {
    151  const _clients = new Map();
    152  let _invalidatePolling = false;
    153  let _initialized = false;
    154 
    155  // If not explicitly specified, use the default signer.
    156  const defaultOptions = {
    157    signerName: DEFAULT_SIGNER,
    158    filterCreator: jexlFilterCreator,
    159  };
    160 
    161  /**
    162   * RemoteSettings constructor.
    163   *
    164   * @param {string} collectionName The remote settings identifier
    165   * @param {object} options Advanced options
    166   * @returns {RemoteSettingsClient} An instance of a Remote Settings client.
    167   */
    168  const remoteSettings = function (collectionName, options) {
    169    // Get or instantiate a remote settings client.
    170    if (!_clients.has(collectionName)) {
    171      // Register a new client!
    172      const c = new lazy.RemoteSettingsClient(collectionName, {
    173        ...defaultOptions,
    174        ...options,
    175      });
    176      // Store instance for later call.
    177      _clients.set(collectionName, c);
    178      // Invalidate the polling status, since we want the new collection to
    179      // be taken into account.
    180      _invalidatePolling = true;
    181      lazy.console.debug(`Instantiated new client ${c.identifier}`);
    182    }
    183    return _clients.get(collectionName);
    184  };
    185 
    186  /**
    187   * Internal helper to retrieve existing instances of clients or new instances
    188   * with default options if possible, or `null` if bucket/collection are unknown.
    189   */
    190  async function _client(bucketName, collectionName) {
    191    // Check if a client was registered for this bucket/collection. Potentially
    192    // with some specific options like signer, filter function etc.
    193    const client = _clients.get(collectionName);
    194    if (client && client.bucketName == bucketName) {
    195      return client;
    196    }
    197    // There was no client registered for this collection, but it's the main bucket,
    198    // therefore we can instantiate a client with the default options.
    199    // So if we have a local database or if we ship a JSON dump, then it means that
    200    // this client is known but it was not registered yet (eg. calling module not "imported" yet).
    201    if (
    202      bucketName ==
    203      lazy.Utils.actualBucketName(AppConstants.REMOTE_SETTINGS_DEFAULT_BUCKET)
    204    ) {
    205      const c = new lazy.RemoteSettingsClient(collectionName, defaultOptions);
    206      const [dbExists, localDump] = await Promise.all([
    207        lazy.Utils.hasLocalData(c),
    208        lazy.Utils.hasLocalDump(bucketName, collectionName),
    209      ]);
    210      if (dbExists || localDump) {
    211        return c;
    212      }
    213    }
    214    // Else, we cannot return a client instance because we are not able to synchronize data in specific buckets.
    215    // Mainly because we cannot guess which `signerName` has to be used for example.
    216    // And we don't want to synchronize data for collections in the main bucket that are
    217    // completely unknown (ie. no database and no JSON dump).
    218    lazy.console.debug(`No known client for ${bucketName}/${collectionName}`);
    219    return null;
    220  }
    221 
    222  /**
    223   * Internal helper that checks all registered remote settings clients for the presence
    224   * of a newer local data dump. If a local dump is found and its last modified timestamp
    225   * is more recent than the client's current data, imports the dump by triggering a sync.
    226   * Notifies observers if any data was imported from a dump.
    227   *
    228   * @param {string} [trigger="manual"] - The reason or source for triggering the import (e.g., "manual", "startup").
    229   * @returns {Promise<void>} Resolves when the import process is complete.
    230   * @private
    231   */
    232  async function _maybeImportFromLocalDump(trigger = "manual") {
    233    let importedFromDump = false;
    234    for (const client of _clients.values()) {
    235      const hasLocalDump = await lazy.Utils.hasLocalDump(
    236        client.bucketName,
    237        client.collectionName
    238      );
    239      if (hasLocalDump) {
    240        const lastModified = await client.getLastModified();
    241        const lastModifiedDump = await lazy.Utils.getLocalDumpLastModified(
    242          client.bucketName,
    243          client.collectionName
    244        );
    245        if (lastModified < lastModifiedDump) {
    246          await client.maybeSync(lastModifiedDump, {
    247            loadDump: true,
    248            trigger,
    249          });
    250          importedFromDump = true;
    251        }
    252      }
    253    }
    254    if (importedFromDump) {
    255      Services.obs.notifyObservers(null, "remote-settings:changes-poll-end");
    256    }
    257  }
    258 
    259  /**
    260   * Helper to introspect the synchronization history and determine whether it is
    261   * consistently failing and thus, broken.
    262   *
    263   * @returns {bool} true if broken.
    264   */
    265  async function isSynchronizationBroken() {
    266    // The minimum number of errors is customizable, but with a maximum.
    267    const threshold = Math.min(lazy.gPrefBrokenSyncThreshold, 20);
    268    // Read history of synchronization past statuses.
    269    const pastEntries = await lazy.gSyncHistory.list();
    270    const lastSuccessIdx = pastEntries.findIndex(
    271      e => e.status == lazy.UptakeTelemetry.STATUS.SUCCESS
    272    );
    273    return (
    274      // Only errors since last success.
    275      lastSuccessIdx >= threshold ||
    276      // Or only errors with a minimum number of history entries.
    277      (lastSuccessIdx < 0 && pastEntries.length >= threshold)
    278    );
    279  }
    280 
    281  /**
    282   * Pulls the startup changesets bundle if enabled.
    283   *
    284   * This function downloads and verifies a bundle of changesets for collections that sync
    285   * data right on startup. In order to include a new collection in this bundle, add the
    286   * `"startup"` flag in its metadata (see mozilla-services/remote-settings-permissions#524).
    287   * If the bundle is already being processed by a client, it waits for the ongoing process
    288   * to complete.
    289   *
    290   * @async
    291   * @function pullStartupBundle
    292   * @memberof remoteSettings
    293   * @returns {Promise<Array<string>>} A promise that resolves to an array of imported collections identifiers.
    294   *
    295   * @throws {Error} If the signature of any bundled changeset is invalid.
    296   */
    297  remoteSettings.pullStartupBundle = async () => {
    298    if (lazy.Utils.shouldSkipRemoteActivityDueToTests) {
    299      return [];
    300    }
    301 
    302    if (remoteSettings._ongoingExtractBundlePromise) {
    303      return await remoteSettings._ongoingExtractBundlePromise;
    304    }
    305 
    306    const startedAt = new Date();
    307    let extractedAt;
    308    remoteSettings._ongoingExtractBundlePromise = (async () => {
    309      lazy.console.info("Download Remote Settings startup changesets bundle.");
    310 
    311      let changesets;
    312      try {
    313        changesets = await lazy.Utils.fetchChangesetsBundle();
    314      } catch (e) {
    315        lazy.console.error(
    316          `Remote Settings startup changesets bundle could not be extracted (${e})`
    317        );
    318        return [];
    319      }
    320 
    321      extractedAt = new Date();
    322      const pulled = [];
    323      for (const changeset of changesets) {
    324        const bucket = lazy.Utils.actualBucketName(changeset.metadata.bucket);
    325        const collection = changeset.metadata.id;
    326        const identifier = `${bucket}/${collection}`;
    327 
    328        if (pulled.includes(identifier)) {
    329          // The startup bundles contain both main and preview changesets.
    330          // Importing both increases complexity down the line, and brings no value.
    331          // On preview mode, this will skip main, and vice-versa.
    332          continue;
    333        }
    334 
    335        const { metadata, timestamp, changes: records } = changeset;
    336 
    337        const signerName = SIGNERS_BY_BUCKET[bucket] || DEFAULT_SIGNER;
    338        const client = RemoteSettings(collection, {
    339          bucketName: bucket,
    340          signerName,
    341        });
    342        if (client.verifySignature) {
    343          lazy.console.debug(
    344            `${identifier}: Verify signature of bundled changeset`
    345          );
    346          try {
    347            await client.validateCollectionSignature(
    348              records,
    349              timestamp,
    350              metadata
    351            );
    352          } catch (e) {
    353            // Bundle content is not valid. Skip import.
    354            lazy.console.error(
    355              `${identifier}: Signature of bundled changeset is invalid: ${e}.`
    356            );
    357            continue;
    358          }
    359        }
    360        // Only import changes if the signature succeeds.
    361        await client.db.importChanges(metadata, timestamp, records, {
    362          clear: true,
    363        });
    364        lazy.console.debug(`${identifier} imported from changesets bundle`);
    365        pulled.push(identifier);
    366      }
    367      return pulled;
    368    })();
    369    const pulled = await RemoteSettings._ongoingExtractBundlePromise;
    370    const durationMilliseconds = new Date() - startedAt;
    371    const downloadMilliseconds = extractedAt - startedAt;
    372    const extractMilliseconds = durationMilliseconds - downloadMilliseconds;
    373    lazy.console.info(
    374      `Import of changesets bundle done (duration=${durationMilliseconds}ms, download=${downloadMilliseconds}ms, extraction=${extractMilliseconds}ms)`
    375    );
    376    return pulled;
    377  };
    378 
    379  /**
    380   * Main polling method, called by the ping mechanism.
    381   *
    382   * @param {object} options
    383 .  * @param {Object} options.expectedTimestamp (optional) The expected timestamp to be received — used by servers for cache busting.
    384   * @param {string} options.trigger           (optional) label to identify what triggered this sync (eg. ``"timer"``, default: `"manual"`)
    385   * @param {bool}   options.full              (optional) Ignore last polling status and fetch all changes (default: `false`)
    386   * @returns {Promise} or throws error if something goes wrong.
    387   */
    388  remoteSettings.pollChanges = async ({
    389    expectedTimestamp,
    390    trigger = "manual",
    391    full = false,
    392  } = {}) => {
    393    if (AppConstants.BASE_BROWSER_VERSION) {
    394      // Called multiple times on GeckoView due to bug 1730026
    395      if (_initialized) {
    396        return;
    397      }
    398      _initialized = true;
    399      _maybeImportFromLocalDump(trigger);
    400      return;
    401    }
    402 
    403    if (lazy.Utils.shouldSkipRemoteActivityDueToTests) {
    404      return;
    405    }
    406    // When running in full mode, we ignore last polling status.
    407    if (full) {
    408      lazy.gPrefs.clearUserPref(PREF_SETTINGS_SERVER_BACKOFF);
    409      lazy.gPrefs.clearUserPref(PREF_SETTINGS_LAST_UPDATE);
    410      lazy.gPrefs.clearUserPref(PREF_SETTINGS_LAST_ETAG);
    411    }
    412 
    413    let pollTelemetryArgs = {
    414      source: TELEMETRY_SOURCE_POLL,
    415      trigger,
    416    };
    417 
    418    if (lazy.Utils.isOffline) {
    419      lazy.console.info("Network is offline. Give up.");
    420      await lazy.UptakeTelemetry.report(
    421        TELEMETRY_COMPONENT,
    422        lazy.UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR,
    423        pollTelemetryArgs
    424      );
    425      return;
    426    }
    427 
    428    const startedAt = new Date();
    429 
    430    // Check if the server backoff time is elapsed.
    431    if (lazy.gPrefs.prefHasUserValue(PREF_SETTINGS_SERVER_BACKOFF)) {
    432      const backoffReleaseTime = lazy.gPrefs.getStringPref(
    433        PREF_SETTINGS_SERVER_BACKOFF
    434      );
    435      const remainingMilliseconds =
    436        parseInt(backoffReleaseTime, 10) - Date.now();
    437      if (remainingMilliseconds > 0) {
    438        // Backoff time has not elapsed yet.
    439        await lazy.UptakeTelemetry.report(
    440          TELEMETRY_COMPONENT,
    441          lazy.UptakeTelemetry.STATUS.BACKOFF,
    442          pollTelemetryArgs
    443        );
    444        throw new Error(
    445          `Server is asking clients to back off; retry in ${Math.ceil(
    446            remainingMilliseconds / 1000
    447          )}s.`
    448        );
    449      } else {
    450        lazy.gPrefs.clearUserPref(PREF_SETTINGS_SERVER_BACKOFF);
    451      }
    452    }
    453 
    454    // When triggered from the daily timer, we try to recover a broken
    455    // sync state by destroying the local DB completely and retrying from scratch.
    456    if (
    457      lazy.gPrefDestroyBrokenEnabled &&
    458      trigger == "timer" &&
    459      (await isSynchronizationBroken())
    460    ) {
    461      // We don't want to destroy the local DB if the failures are related to
    462      // network or server errors though.
    463      const lastStatus = await lazy.gSyncHistory.last();
    464      const lastErrorClass =
    465        lazy.RemoteSettingsClient[lastStatus?.infos?.errorName] || Error;
    466      const isLocalError = !(
    467        lastErrorClass.prototype instanceof lazy.RemoteSettingsClient.APIError
    468      );
    469      if (isLocalError) {
    470        console.warn(
    471          "Synchronization has failed consistently. Destroy database."
    472        );
    473        // Clear the last ETag to refetch everything.
    474        lazy.gPrefs.clearUserPref(PREF_SETTINGS_LAST_ETAG);
    475        // Clear the history, to avoid re-destroying several times in a row.
    476        await lazy.gSyncHistory.clear().catch(error => console.error(error));
    477        // Delete the whole IndexedDB database.
    478        await lazy.Database.destroy().catch(error => console.error(error));
    479      } else {
    480        console.warn(
    481          `Synchronization is broken, but last error is ${lastStatus}`
    482        );
    483      }
    484    }
    485 
    486    lazy.console.info(`Start polling for changes (trigger=${trigger})`);
    487    Services.obs.notifyObservers(
    488      null,
    489      "remote-settings:changes-poll-start",
    490      JSON.stringify({ expectedTimestamp })
    491    );
    492 
    493    // Do we have the latest version already?
    494    // Every time we register a new client, we have to fetch the whole list again.
    495    const lastEtag = _invalidatePolling
    496      ? ""
    497      : lazy.gPrefs.getStringPref(PREF_SETTINGS_LAST_ETAG, "");
    498 
    499    let pollResult;
    500    try {
    501      pollResult = await lazy.Utils.fetchLatestChanges(lazy.Utils.SERVER_URL, {
    502        expectedTimestamp,
    503        lastEtag,
    504      });
    505    } catch (e) {
    506      // Report polling error to Uptake Telemetry.
    507      let reportStatus;
    508      if (/JSON\.parse/.test(e.message)) {
    509        reportStatus = lazy.UptakeTelemetry.STATUS.PARSE_ERROR;
    510      } else if (/content-type/.test(e.message)) {
    511        reportStatus = lazy.UptakeTelemetry.STATUS.CONTENT_ERROR;
    512      } else if (/Server/.test(e.message)) {
    513        reportStatus = lazy.UptakeTelemetry.STATUS.SERVER_ERROR;
    514        // If the server replied with bad request, clear the last ETag
    515        // value to unblock the next run of synchronization.
    516        lazy.gPrefs.clearUserPref(PREF_SETTINGS_LAST_ETAG);
    517      } else if (/Timeout/.test(e.message)) {
    518        reportStatus = lazy.UptakeTelemetry.STATUS.TIMEOUT_ERROR;
    519      } else if (/NetworkError/.test(e.message)) {
    520        reportStatus = lazy.UptakeTelemetry.STATUS.NETWORK_ERROR;
    521      } else {
    522        reportStatus = lazy.UptakeTelemetry.STATUS.UNKNOWN_ERROR;
    523      }
    524      await lazy.UptakeTelemetry.report(
    525        TELEMETRY_COMPONENT,
    526        reportStatus,
    527        pollTelemetryArgs
    528      );
    529      // No need to go further.
    530      throw new Error(`Polling for changes failed: ${e.message}.`);
    531    }
    532 
    533    const {
    534      serverTimeMillis,
    535      changes,
    536      currentEtag,
    537      backoffSeconds,
    538      ageSeconds,
    539    } = pollResult;
    540 
    541    // Report age of server data in Telemetry.
    542    pollTelemetryArgs = { age: ageSeconds, ...pollTelemetryArgs };
    543 
    544    // Report polling success to Uptake Telemetry.
    545    const reportStatus =
    546      changes.length === 0
    547        ? lazy.UptakeTelemetry.STATUS.UP_TO_DATE
    548        : lazy.UptakeTelemetry.STATUS.SUCCESS;
    549    await lazy.UptakeTelemetry.report(
    550      TELEMETRY_COMPONENT,
    551      reportStatus,
    552      pollTelemetryArgs
    553    );
    554 
    555    // Check if the server asked the clients to back off (for next poll).
    556    if (backoffSeconds) {
    557      lazy.console.info(
    558        "Server asks clients to backoff for ${backoffSeconds} seconds"
    559      );
    560      const backoffReleaseTime = Date.now() + backoffSeconds * 1000;
    561      lazy.gPrefs.setStringPref(
    562        PREF_SETTINGS_SERVER_BACKOFF,
    563        backoffReleaseTime
    564      );
    565    }
    566 
    567    // Record new update time and the difference between local and server time.
    568    // Negative clockDifference means local time is behind server time
    569    // by the absolute of that value in seconds (positive means it's ahead)
    570    const clockDifference = Math.floor((Date.now() - serverTimeMillis) / 1000);
    571    lazy.gPrefs.setIntPref(PREF_SETTINGS_CLOCK_SKEW_SECONDS, clockDifference);
    572    const checkedServerTimeInSeconds = Math.round(serverTimeMillis / 1000);
    573    lazy.gPrefs.setIntPref(
    574      PREF_SETTINGS_LAST_UPDATE,
    575      checkedServerTimeInSeconds
    576    );
    577 
    578    // Iterate through the collections version info and initiate a synchronization
    579    // on the related remote settings clients.
    580    let firstError;
    581    for (const change of changes) {
    582      const { bucket, collection, last_modified } = change;
    583 
    584      const client = await _client(bucket, collection);
    585      if (!client) {
    586        // This collection has no associated client (eg. preview, other platform...)
    587        continue;
    588      }
    589      // Start synchronization! It will be a no-op if the specified `lastModified` equals
    590      // the one in the local database.
    591      try {
    592        await client.maybeSync(last_modified, { trigger });
    593 
    594        // Save last time this client was successfully synced.
    595        Services.prefs.setIntPref(
    596          client.lastCheckTimePref,
    597          checkedServerTimeInSeconds
    598        );
    599      } catch (e) {
    600        lazy.console.error(e);
    601        if (!firstError) {
    602          firstError = e;
    603          firstError.details = change;
    604        }
    605      }
    606    }
    607 
    608    // Polling is done.
    609    _invalidatePolling = false;
    610 
    611    // Report total synchronization duration to Telemetry.
    612    const durationMilliseconds = new Date() - startedAt;
    613    const syncTelemetryArgs = {
    614      source: TELEMETRY_SOURCE_SYNC,
    615      duration: durationMilliseconds,
    616      timestamp: `${currentEtag}`,
    617      trigger,
    618    };
    619 
    620    if (firstError) {
    621      // Report the global synchronization failure. Individual uptake reports will also have been sent for each collection.
    622      const status = lazy.UptakeTelemetry.STATUS.SYNC_ERROR;
    623      await lazy.UptakeTelemetry.report(
    624        TELEMETRY_COMPONENT,
    625        status,
    626        syncTelemetryArgs
    627      );
    628      // Keep track of sync failure in history.
    629      await lazy.gSyncHistory
    630        .store(currentEtag, status, {
    631          expectedTimestamp,
    632          errorName: firstError.name,
    633        })
    634        .catch(error => console.error(error));
    635      // Notify potential observers of the error.
    636      Services.obs.notifyObservers(
    637        { wrappedJSObject: { error: firstError } },
    638        "remote-settings:sync-error"
    639      );
    640 
    641      // If synchronization has been consistently failing, send a specific signal.
    642      // See https://bugzilla.mozilla.org/show_bug.cgi?id=1729400
    643      // and https://bugzilla.mozilla.org/show_bug.cgi?id=1658597
    644      if (await isSynchronizationBroken()) {
    645        await lazy.UptakeTelemetry.report(
    646          TELEMETRY_COMPONENT,
    647          lazy.UptakeTelemetry.STATUS.SYNC_BROKEN_ERROR,
    648          syncTelemetryArgs
    649        );
    650 
    651        Services.obs.notifyObservers(
    652          { wrappedJSObject: { error: firstError } },
    653          "remote-settings:broken-sync-error"
    654        );
    655      }
    656 
    657      // Rethrow the first observed error
    658      throw firstError;
    659    }
    660 
    661    // Save current Etag for next poll.
    662    lazy.gPrefs.setStringPref(PREF_SETTINGS_LAST_ETAG, currentEtag);
    663 
    664    // Report the global synchronization success.
    665    const status = lazy.UptakeTelemetry.STATUS.SUCCESS;
    666    await lazy.UptakeTelemetry.report(
    667      TELEMETRY_COMPONENT,
    668      status,
    669      syncTelemetryArgs
    670    );
    671    // Keep track of sync success in history.
    672    await lazy.gSyncHistory
    673      .store(currentEtag, status)
    674      .catch(error => console.error(error));
    675 
    676    lazy.console.info(
    677      `Polling for changes done (duration=${durationMilliseconds}ms)`
    678    );
    679    Services.obs.notifyObservers(null, "remote-settings:changes-poll-end");
    680  };
    681 
    682  /**
    683   * Enables or disables preview mode.
    684   *
    685   * When enabled, all existing and future clients will pull data from
    686   * the `*-preview` buckets. This allows developers and QA to test their
    687   * changes before publishing them for all clients.
    688   */
    689  remoteSettings.enablePreviewMode = enabled => {
    690    // Set the flag for future clients.
    691    lazy.Utils.enablePreviewMode(enabled);
    692    // Enable it on existing clients.
    693    for (const client of _clients.values()) {
    694      client.refreshBucketName();
    695    }
    696  };
    697 
    698  /**
    699   * Returns an object with polling status information and the list of
    700   * known remote settings collections.
    701   *
    702   * @param {object} options
    703   * @param {boolean?} options.localOnly (optional) If set to `true`, do not contact the server.
    704   */
    705  remoteSettings.inspect = async (options = {}) => {
    706    const { localOnly = false } = options;
    707 
    708    let changes = [];
    709    let serverTimestamp = null;
    710    if (!localOnly) {
    711      // Make sure we fetch the latest server info, use a random cache bust value.
    712      const randomCacheBust = 99990000 + Math.floor(Math.random() * 9999);
    713      ({ changes, currentEtag: serverTimestamp } =
    714        await lazy.Utils.fetchLatestChanges(lazy.Utils.SERVER_URL, {
    715          expected: randomCacheBust,
    716        }));
    717    }
    718    const collections = await Promise.all(
    719      changes.map(async change => {
    720        const { bucket, collection, last_modified: serverTimestamp } = change;
    721        const client = await _client(bucket, collection);
    722        if (!client) {
    723          return null;
    724        }
    725        const localTimestamp = await client.getLastModified();
    726        const lastCheck = Services.prefs.getIntPref(
    727          client.lastCheckTimePref,
    728          0
    729        );
    730        return {
    731          bucket,
    732          collection,
    733          localTimestamp,
    734          serverTimestamp,
    735          lastCheck,
    736          signerName: client.signerName,
    737        };
    738      })
    739    );
    740 
    741    // Turn the JEXL context object into a simple object that can be
    742    // serialized into JSON.
    743    // Here we only select the fields that are shared between clients
    744    // implementations (application-services and Gecko).
    745    const jexlContext = {
    746      ...["channel", "version", "locale", "country", "formFactor"].reduce(
    747        (acc, key) => {
    748          acc[key] = lazy.ClientEnvironmentBase[key];
    749          return acc;
    750        },
    751        {}
    752      ),
    753      os: ["name", "version"].reduce((acc, key) => {
    754        acc[key] = lazy.ClientEnvironmentBase.os?.[key];
    755        return acc;
    756      }, {}),
    757      appinfo: ["ID", "OS"].reduce((acc, key) => {
    758        acc[key] = lazy.ClientEnvironmentBase.appinfo?.[key];
    759        return acc;
    760      }, {}),
    761    };
    762 
    763    return {
    764      serverURL: lazy.Utils.SERVER_URL,
    765      pollingEndpoint: lazy.Utils.SERVER_URL + lazy.Utils.CHANGES_PATH,
    766      serverTimestamp,
    767      localTimestamp: lazy.gPrefs.getStringPref(PREF_SETTINGS_LAST_ETAG, null),
    768      lastCheck: lazy.gPrefs.getIntPref(PREF_SETTINGS_LAST_UPDATE, 0),
    769      mainBucket: lazy.Utils.actualBucketName(
    770        AppConstants.REMOTE_SETTINGS_DEFAULT_BUCKET
    771      ),
    772      defaultSigner: DEFAULT_SIGNER,
    773      previewMode: lazy.Utils.PREVIEW_MODE,
    774      collections: collections.filter(c => !!c),
    775      history: {
    776        [TELEMETRY_SOURCE_SYNC]: await lazy.gSyncHistory.list(),
    777      },
    778      isSynchronizationBroken: await isSynchronizationBroken(),
    779      jexlContext,
    780    };
    781  };
    782 
    783  /**
    784   * Delete all local data, of every collection.
    785   */
    786  remoteSettings.clearAll = async () => {
    787    const { collections } = await remoteSettings.inspect();
    788    await Promise.all(
    789      collections.map(async ({ collection }) => {
    790        const client = RemoteSettings(collection);
    791        // Delete all potential attachments.
    792        await client.attachments.deleteAll();
    793        // Delete local data.
    794        await client.db.clear();
    795        // Remove status pref.
    796        Services.prefs.clearUserPref(client.lastCheckTimePref);
    797      })
    798    );
    799  };
    800 
    801  /**
    802   * Startup function called from nsBrowserGlue.
    803   */
    804  remoteSettings.init = () => {
    805    lazy.console.info("Initialize Remote Settings");
    806    // Hook the Push broadcast and RemoteSettings polling.
    807    // When we start on a new profile there will be no ETag stored.
    808    // Use an arbitrary ETag that is guaranteed not to occur.
    809    // This will trigger a broadcast message but that's fine because we
    810    // will check the changes on each collection and retrieve only the
    811    // changes (e.g. nothing if we have a dump with the same data).
    812    const currentVersion = lazy.gPrefs.getStringPref(
    813      PREF_SETTINGS_LAST_ETAG,
    814      '"0"'
    815    );
    816 
    817    const moduleInfo = {
    818      moduleURI: import.meta.url,
    819      symbolName: "remoteSettingsBroadcastHandler",
    820    };
    821    lazy.pushBroadcastService.addListener(
    822      BROADCAST_ID,
    823      currentVersion,
    824      moduleInfo
    825    );
    826  };
    827 
    828  return remoteSettings;
    829 }
    830 
    831 export var RemoteSettings = remoteSettingsFunction();
    832 
    833 export var remoteSettingsBroadcastHandler = {
    834  async receivedBroadcastMessage(version, broadcastID, context) {
    835    const { phase } = context;
    836    const isStartup = [
    837      lazy.pushBroadcastService.PHASES.HELLO,
    838      lazy.pushBroadcastService.PHASES.REGISTER,
    839    ].includes(phase);
    840 
    841    lazy.console.info(
    842      `Push notification received (version=${version} phase=${phase})`
    843    );
    844 
    845    return RemoteSettings.pollChanges({
    846      expectedTimestamp: version.replace('"', ""),
    847      trigger: isStartup ? "startup" : "broadcast",
    848    });
    849  },
    850 };