Bug 1746711 Part 2: Ensure the enqueued surface has a color space. r=gfx-reviewers...
[gecko.git] / services / settings / remote-settings.js
blob43075973515acaee178be7b0266c3e61e647ef5f
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/. */
5 /* global __URI__ */
7 "use strict";
9 var EXPORTED_SYMBOLS = [
10   "RemoteSettings",
11   "jexlFilterFunc",
12   "remoteSettingsBroadcastHandler",
15 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
16 const { XPCOMUtils } = ChromeUtils.import(
17   "resource://gre/modules/XPCOMUtils.jsm"
20 XPCOMUtils.defineLazyModuleGetters(this, {
21   UptakeTelemetry: "resource://services-common/uptake-telemetry.js",
22   pushBroadcastService: "resource://gre/modules/PushBroadcastService.jsm",
23   RemoteSettingsClient: "resource://services-settings/RemoteSettingsClient.jsm",
24   Utils: "resource://services-settings/Utils.jsm",
25   FilterExpressions:
26     "resource://gre/modules/components-utils/FilterExpressions.jsm",
27   RemoteSettingsWorker: "resource://services-settings/RemoteSettingsWorker.jsm",
28 });
30 XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
32 const PREF_SETTINGS_DEFAULT_BUCKET = "services.settings.default_bucket";
33 const PREF_SETTINGS_BRANCH = "services.settings.";
34 const PREF_SETTINGS_DEFAULT_SIGNER = "default_signer";
35 const PREF_SETTINGS_SERVER_BACKOFF = "server.backoff";
36 const PREF_SETTINGS_LAST_UPDATE = "last_update_seconds";
37 const PREF_SETTINGS_LAST_ETAG = "last_etag";
38 const PREF_SETTINGS_CLOCK_SKEW_SECONDS = "clock_skew_seconds";
40 // Telemetry identifiers.
41 const TELEMETRY_COMPONENT = "remotesettings";
42 const TELEMETRY_SOURCE_POLL = "settings-changes-monitoring";
43 const TELEMETRY_SOURCE_SYNC = "settings-sync";
45 // Push broadcast id.
46 const BROADCAST_ID = "remote-settings/monitor_changes";
48 // Signer to be used when not specified (see Ci.nsIContentSignatureVerifier).
49 const DEFAULT_SIGNER = "remote-settings.content-signature.mozilla.org";
51 XPCOMUtils.defineLazyGetter(this, "gPrefs", () => {
52   return Services.prefs.getBranch(PREF_SETTINGS_BRANCH);
53 });
54 XPCOMUtils.defineLazyGetter(this, "console", () => Utils.log);
56 /**
57  * Default entry filtering function, in charge of excluding remote settings entries
58  * where the JEXL expression evaluates into a falsy value.
59  * @param {Object}            entry       The Remote Settings entry to be excluded or kept.
60  * @param {ClientEnvironment} environment Information about version, language, platform etc.
61  * @returns {?Object} the entry or null if excluded.
62  */
63 async function jexlFilterFunc(entry, environment) {
64   const { filter_expression } = entry;
65   if (!filter_expression) {
66     return entry;
67   }
68   let result;
69   try {
70     const context = {
71       env: environment,
72     };
73     result = await FilterExpressions.eval(filter_expression, context);
74   } catch (e) {
75     Cu.reportError(e);
76   }
77   return result ? entry : null;
80 function remoteSettingsFunction() {
81   const _clients = new Map();
82   let _invalidatePolling = false;
84   // If not explicitly specified, use the default signer.
85   const defaultOptions = {
86     bucketNamePref: PREF_SETTINGS_DEFAULT_BUCKET,
87     signerName: DEFAULT_SIGNER,
88     filterFunc: jexlFilterFunc,
89   };
91   /**
92    * RemoteSettings constructor.
93    *
94    * @param {String} collectionName The remote settings identifier
95    * @param {Object} options Advanced options
96    * @returns {RemoteSettingsClient} An instance of a Remote Settings client.
97    */
98   const remoteSettings = function(collectionName, options) {
99     // Get or instantiate a remote settings client.
100     if (!_clients.has(collectionName)) {
101       // Register a new client!
102       const c = new RemoteSettingsClient(collectionName, {
103         ...defaultOptions,
104         ...options,
105       });
106       // Store instance for later call.
107       _clients.set(collectionName, c);
108       // Invalidate the polling status, since we want the new collection to
109       // be taken into account.
110       _invalidatePolling = true;
111       console.debug(`Instantiated new client ${c.identifier}`);
112     }
113     return _clients.get(collectionName);
114   };
116   /**
117    * Internal helper to retrieve existing instances of clients or new instances
118    * with default options if possible, or `null` if bucket/collection are unknown.
119    */
120   async function _client(bucketName, collectionName) {
121     // Check if a client was registered for this bucket/collection. Potentially
122     // with some specific options like signer, filter function etc.
123     const client = _clients.get(collectionName);
124     if (client && client.bucketName == bucketName) {
125       return client;
126     }
127     // There was no client registered for this collection, but it's the main bucket,
128     // therefore we can instantiate a client with the default options.
129     // So if we have a local database or if we ship a JSON dump, then it means that
130     // this client is known but it was not registered yet (eg. calling module not "imported" yet).
131     if (
132       bucketName == Services.prefs.getCharPref(PREF_SETTINGS_DEFAULT_BUCKET)
133     ) {
134       const c = new RemoteSettingsClient(collectionName, defaultOptions);
135       const [dbExists, localDump] = await Promise.all([
136         Utils.hasLocalData(c),
137         Utils.hasLocalDump(bucketName, collectionName),
138       ]);
139       if (dbExists || localDump) {
140         return c;
141       }
142     }
143     // Else, we cannot return a client insttance because we are not able to synchronize data in specific buckets.
144     // Mainly because we cannot guess which `signerName` has to be used for example.
145     // And we don't want to synchronize data for collections in the main bucket that are
146     // completely unknown (ie. no database and no JSON dump).
147     console.debug(`No known client for ${bucketName}/${collectionName}`);
148     return null;
149   }
151   /**
152    * Main polling method, called by the ping mechanism.
153    *
154    * @param {Object} options
155 .  * @param {Object} options.expectedTimestamp (optional) The expected timestamp to be received — used by servers for cache busting.
156    * @param {string} options.trigger           (optional) label to identify what triggered this sync (eg. ``"timer"``, default: `"manual"`)
157    * @param {bool}   options.full              (optional) Ignore last polling status and fetch all changes (default: `false`)
158    * @returns {Promise} or throws error if something goes wrong.
159    */
160   remoteSettings.pollChanges = async ({
161     expectedTimestamp,
162     trigger = "manual",
163     full = false,
164   } = {}) => {
165     // When running in full mode, we ignore last polling status.
166     if (full) {
167       gPrefs.clearUserPref(PREF_SETTINGS_SERVER_BACKOFF);
168       gPrefs.clearUserPref(PREF_SETTINGS_LAST_UPDATE);
169       gPrefs.clearUserPref(PREF_SETTINGS_LAST_ETAG);
170     }
172     let pollTelemetryArgs = {
173       source: TELEMETRY_SOURCE_POLL,
174       trigger,
175     };
177     if (Utils.isOffline) {
178       console.info("Network is offline. Give up.");
179       await UptakeTelemetry.report(
180         TELEMETRY_COMPONENT,
181         UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR,
182         pollTelemetryArgs
183       );
184       return;
185     }
187     const startedAt = new Date();
189     // Check if the server backoff time is elapsed.
190     if (gPrefs.prefHasUserValue(PREF_SETTINGS_SERVER_BACKOFF)) {
191       const backoffReleaseTime = gPrefs.getCharPref(
192         PREF_SETTINGS_SERVER_BACKOFF
193       );
194       const remainingMilliseconds =
195         parseInt(backoffReleaseTime, 10) - Date.now();
196       if (remainingMilliseconds > 0) {
197         // Backoff time has not elapsed yet.
198         await UptakeTelemetry.report(
199           TELEMETRY_COMPONENT,
200           UptakeTelemetry.STATUS.BACKOFF,
201           pollTelemetryArgs
202         );
203         throw new Error(
204           `Server is asking clients to back off; retry in ${Math.ceil(
205             remainingMilliseconds / 1000
206           )}s.`
207         );
208       } else {
209         gPrefs.clearUserPref(PREF_SETTINGS_SERVER_BACKOFF);
210       }
211     }
213     console.info("Start polling for changes");
214     Services.obs.notifyObservers(
215       null,
216       "remote-settings:changes-poll-start",
217       JSON.stringify({ expectedTimestamp })
218     );
220     // Do we have the latest version already?
221     // Every time we register a new client, we have to fetch the whole list again.
222     const lastEtag = _invalidatePolling
223       ? ""
224       : gPrefs.getCharPref(PREF_SETTINGS_LAST_ETAG, "");
226     let pollResult;
227     try {
228       pollResult = await Utils.fetchLatestChanges(Utils.SERVER_URL, {
229         expectedTimestamp,
230         lastEtag,
231       });
232     } catch (e) {
233       // Report polling error to Uptake Telemetry.
234       let reportStatus;
235       if (/JSON\.parse/.test(e.message)) {
236         reportStatus = UptakeTelemetry.STATUS.PARSE_ERROR;
237       } else if (/content-type/.test(e.message)) {
238         reportStatus = UptakeTelemetry.STATUS.CONTENT_ERROR;
239       } else if (/Server/.test(e.message)) {
240         reportStatus = UptakeTelemetry.STATUS.SERVER_ERROR;
241       } else if (/Timeout/.test(e.message)) {
242         reportStatus = UptakeTelemetry.STATUS.TIMEOUT_ERROR;
243       } else if (/NetworkError/.test(e.message)) {
244         reportStatus = UptakeTelemetry.STATUS.NETWORK_ERROR;
245       } else {
246         reportStatus = UptakeTelemetry.STATUS.UNKNOWN_ERROR;
247       }
248       await UptakeTelemetry.report(
249         TELEMETRY_COMPONENT,
250         reportStatus,
251         pollTelemetryArgs
252       );
253       // No need to go further.
254       throw new Error(`Polling for changes failed: ${e.message}.`);
255     }
257     const {
258       serverTimeMillis,
259       changes,
260       currentEtag,
261       backoffSeconds,
262       ageSeconds,
263     } = pollResult;
265     // Report age of server data in Telemetry.
266     pollTelemetryArgs = { age: ageSeconds, ...pollTelemetryArgs };
268     // Report polling success to Uptake Telemetry.
269     const reportStatus =
270       changes.length === 0
271         ? UptakeTelemetry.STATUS.UP_TO_DATE
272         : UptakeTelemetry.STATUS.SUCCESS;
273     await UptakeTelemetry.report(
274       TELEMETRY_COMPONENT,
275       reportStatus,
276       pollTelemetryArgs
277     );
279     // Check if the server asked the clients to back off (for next poll).
280     if (backoffSeconds) {
281       console.info(
282         "Server asks clients to backoff for ${backoffSeconds} seconds"
283       );
284       const backoffReleaseTime = Date.now() + backoffSeconds * 1000;
285       gPrefs.setCharPref(PREF_SETTINGS_SERVER_BACKOFF, backoffReleaseTime);
286     }
288     // Record new update time and the difference between local and server time.
289     // Negative clockDifference means local time is behind server time
290     // by the absolute of that value in seconds (positive means it's ahead)
291     const clockDifference = Math.floor((Date.now() - serverTimeMillis) / 1000);
292     gPrefs.setIntPref(PREF_SETTINGS_CLOCK_SKEW_SECONDS, clockDifference);
293     const checkedServerTimeInSeconds = Math.round(serverTimeMillis / 1000);
294     gPrefs.setIntPref(PREF_SETTINGS_LAST_UPDATE, checkedServerTimeInSeconds);
296     // Iterate through the collections version info and initiate a synchronization
297     // on the related remote settings clients.
298     let firstError;
299     for (const change of changes) {
300       const { bucket, collection, last_modified } = change;
302       const client = await _client(bucket, collection);
303       if (!client) {
304         // This collection has no associated client (eg. preview, other platform...)
305         continue;
306       }
307       // Start synchronization! It will be a no-op if the specified `lastModified` equals
308       // the one in the local database.
309       try {
310         await client.maybeSync(last_modified, { trigger });
312         // Save last time this client was successfully synced.
313         Services.prefs.setIntPref(
314           client.lastCheckTimePref,
315           checkedServerTimeInSeconds
316         );
317       } catch (e) {
318         console.error(e);
319         if (!firstError) {
320           firstError = e;
321           firstError.details = change;
322         }
323       }
324     }
326     // Polling is done.
327     _invalidatePolling = false;
329     // Report total synchronization duration to Telemetry.
330     const durationMilliseconds = new Date() - startedAt;
331     const syncTelemetryArgs = {
332       source: TELEMETRY_SOURCE_SYNC,
333       duration: durationMilliseconds,
334       timestamp: `${currentEtag}`,
335       trigger,
336     };
338     if (firstError) {
339       // Report the global synchronization failure. Individual uptake reports will also have been sent for each collection.
340       await UptakeTelemetry.report(
341         TELEMETRY_COMPONENT,
342         UptakeTelemetry.STATUS.SYNC_ERROR,
343         syncTelemetryArgs
344       );
345       // Notify potential observers of the error.
346       Services.obs.notifyObservers(
347         { wrappedJSObject: { error: firstError } },
348         "remote-settings:sync-error"
349       );
350       // Rethrow the first observed error
351       throw firstError;
352     }
354     // Save current Etag for next poll.
355     if (currentEtag) {
356       gPrefs.setCharPref(PREF_SETTINGS_LAST_ETAG, currentEtag);
357     }
359     // Report the global synchronization success.
360     await UptakeTelemetry.report(
361       TELEMETRY_COMPONENT,
362       UptakeTelemetry.STATUS.SUCCESS,
363       syncTelemetryArgs
364     );
366     console.info("Polling for changes done");
367     Services.obs.notifyObservers(null, "remote-settings:changes-poll-end");
368   };
370   /**
371    * Returns an object with polling status information and the list of
372    * known remote settings collections.
373    */
374   remoteSettings.inspect = async () => {
375     const {
376       changes,
377       currentEtag: serverTimestamp,
378     } = await Utils.fetchLatestChanges(Utils.SERVER_URL);
380     const collections = await Promise.all(
381       changes.map(async change => {
382         const { bucket, collection, last_modified: serverTimestamp } = change;
383         const client = await _client(bucket, collection);
384         if (!client) {
385           return null;
386         }
387         const localTimestamp = await client.getLastModified();
388         const lastCheck = Services.prefs.getIntPref(
389           client.lastCheckTimePref,
390           0
391         );
392         return {
393           bucket,
394           collection,
395           localTimestamp,
396           serverTimestamp,
397           lastCheck,
398           signerName: client.signerName,
399         };
400       })
401     );
403     return {
404       serverURL: Utils.SERVER_URL,
405       pollingEndpoint: Utils.SERVER_URL + Utils.CHANGES_PATH,
406       serverTimestamp,
407       localTimestamp: gPrefs.getCharPref(PREF_SETTINGS_LAST_ETAG, null),
408       lastCheck: gPrefs.getIntPref(PREF_SETTINGS_LAST_UPDATE, 0),
409       mainBucket: Services.prefs.getCharPref(PREF_SETTINGS_DEFAULT_BUCKET),
410       defaultSigner: DEFAULT_SIGNER,
411       collections: collections.filter(c => !!c),
412     };
413   };
415   /**
416    * Delete all local data, of every collection.
417    */
418   remoteSettings.clearAll = async () => {
419     const { collections } = await remoteSettings.inspect();
420     await Promise.all(
421       collections.map(async ({ collection }) => {
422         const client = RemoteSettings(collection);
423         // Delete all potential attachments.
424         await client.attachments.deleteAll();
425         // Delete local data.
426         await client.db.clear();
427         // Remove status pref.
428         Services.prefs.clearUserPref(client.lastCheckTimePref);
429       })
430     );
431   };
433   /**
434    * Startup function called from nsBrowserGlue.
435    */
436   remoteSettings.init = () => {
437     console.info("Initialize Remote Settings");
438     // Hook the Push broadcast and RemoteSettings polling.
439     // When we start on a new profile there will be no ETag stored.
440     // Use an arbitrary ETag that is guaranteed not to occur.
441     // This will trigger a broadcast message but that's fine because we
442     // will check the changes on each collection and retrieve only the
443     // changes (e.g. nothing if we have a dump with the same data).
444     const currentVersion = gPrefs.getStringPref(PREF_SETTINGS_LAST_ETAG, '"0"');
445     const moduleInfo = {
446       moduleURI: __URI__,
447       symbolName: "remoteSettingsBroadcastHandler",
448     };
449     pushBroadcastService.addListener(BROADCAST_ID, currentVersion, moduleInfo);
450   };
452   return remoteSettings;
455 var RemoteSettings = remoteSettingsFunction();
457 var remoteSettingsBroadcastHandler = {
458   async receivedBroadcastMessage(version, broadcastID, context) {
459     const { phase } = context;
460     const isStartup = [
461       pushBroadcastService.PHASES.HELLO,
462       pushBroadcastService.PHASES.REGISTER,
463     ].includes(phase);
465     console.info(
466       `Push notification received (version=${version} phase=${phase})`
467     );
469     return RemoteSettings.pollChanges({
470       expectedTimestamp: version,
471       trigger: isStartup ? "startup" : "broadcast",
472     });
473   },