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 };