Bug 1942239 - Add option to explicitly enable incremental origin initialization in...
[gecko.git] / toolkit / components / backgroundtasks / BackgroundTasksUtils.sys.mjs
blob2ee2f085aeea05d56c62d7b998e212af57b9d67f
1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
2  * This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
8 const lazy = {};
10 ChromeUtils.defineLazyGetter(lazy, "log", () => {
11   let { ConsoleAPI } = ChromeUtils.importESModule(
12     "resource://gre/modules/Console.sys.mjs"
13   );
14   let consoleOptions = {
15     // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
16     // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
17     maxLogLevel: "error",
18     maxLogLevelPref: "toolkit.backgroundtasks.loglevel",
19     prefix: "BackgroundTasksUtils",
20   };
21   return new ConsoleAPI(consoleOptions);
22 });
24 XPCOMUtils.defineLazyServiceGetter(
25   lazy,
26   "ProfileService",
27   "@mozilla.org/toolkit/profile-service;1",
28   "nsIToolkitProfileService"
31 ChromeUtils.defineESModuleGetters(lazy, {
32   ASRouter:
33     // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
34     "resource:///modules/asrouter/ASRouter.sys.mjs",
35   ASRouterDefaultConfig:
36     // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
37     "resource:///modules/asrouter/ASRouterDefaultConfig.sys.mjs",
39   ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
41   RemoteSettingsExperimentLoader:
42     "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs",
43 });
45 class CannotLockProfileError extends Error {
46   constructor(message) {
47     super(message);
48     this.name = "CannotLockProfileError";
49   }
52 export var BackgroundTasksUtils = {
53   // Manage our own default profile that can be overridden for testing.  It's
54   // easier to do this here rather than using the profile service itself.
55   _defaultProfileInitialized: false,
56   _defaultProfile: null,
58   getDefaultProfile() {
59     if (!this._defaultProfileInitialized) {
60       this._defaultProfileInitialized = true;
61       // This is all test-only.
62       let defaultProfilePath = Services.env.get(
63         "MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH"
64       );
65       let noDefaultProfile = Services.env.get(
66         "MOZ_BACKGROUNDTASKS_NO_DEFAULT_PROFILE"
67       );
68       if (defaultProfilePath) {
69         lazy.log.info(
70           `getDefaultProfile: using default profile path ${defaultProfilePath}`
71         );
72         var tmpd = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
73         tmpd.initWithPath(defaultProfilePath);
74         // Sadly this writes to `profiles.ini`, but there's little to be done.
75         this._defaultProfile = lazy.ProfileService.createProfile(
76           tmpd,
77           `MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH-${Date.now()}`
78         );
79       } else if (noDefaultProfile) {
80         lazy.log.info(`getDefaultProfile: setting default profile to null`);
81         this._defaultProfile = null;
82       } else {
83         try {
84           lazy.log.info(
85             `getDefaultProfile: using ProfileService.defaultProfile`
86           );
87           this._defaultProfile = lazy.ProfileService.defaultProfile;
88         } catch (e) {}
89       }
90     }
91     return this._defaultProfile;
92   },
94   hasDefaultProfile() {
95     return this.getDefaultProfile() != null;
96   },
98   currentProfileIsDefaultProfile() {
99     let defaultProfile = this.getDefaultProfile();
100     let currentProfile = lazy.ProfileService.currentProfile;
101     // This comparison needs to accommodate null on both sides.
102     let isDefaultProfile = defaultProfile && currentProfile == defaultProfile;
103     return isDefaultProfile;
104   },
106   _throwIfNotLocked(lock) {
107     if (!(lock instanceof Ci.nsIProfileLock)) {
108       throw new Error("Passed lock was not an instance of nsIProfileLock");
109     }
111     try {
112       // In release builds, `.directory` throws NS_ERROR_NOT_INITIALIZED when
113       // unlocked.  In debug builds, `.directory` when the profile is not locked
114       // will crash via `NS_ERROR`.
115       if (lock.directory) {
116         return;
117       }
118     } catch (e) {
119       if (
120         !(
121           e instanceof Ci.nsIException &&
122           e.result == Cr.NS_ERROR_NOT_INITIALIZED
123         )
124       ) {
125         throw e;
126       }
127     }
128     throw new Error("Profile is not locked");
129   },
131   /**
132    * Locks the given profile and provides the path to it to the callback.
133    * The callback should return a promise and once settled the profile is
134    * unlocked and then the promise returned back to the caller of this function.
135    *
136    * @template T
137    * @param {(lock: nsIProfileLock) => Promise<T>} callback
138    * @param {nsIToolkitProfile} [profile] defaults to default profile
139    * @return {Promise<T>}
140    */
141   async withProfileLock(callback, profile = this.getDefaultProfile()) {
142     if (!profile) {
143       throw new Error("No default profile exists");
144     }
146     let lock;
147     try {
148       lock = profile.lock({});
149       lazy.log.info(
150         `withProfileLock: locked profile at ${lock.directory.path}`
151       );
152     } catch (e) {
153       throw new CannotLockProfileError(`Cannot lock profile: ${e}`);
154     }
156     try {
157       // We must await to ensure any logging is displayed after the callback resolves.
158       return await callback(lock);
159     } finally {
160       try {
161         lazy.log.info(
162           `withProfileLock: unlocking profile at ${lock.directory.path}`
163         );
164         lock.unlock();
165         lazy.log.info(`withProfileLock: unlocked profile`);
166       } catch (e) {
167         lazy.log.warn(`withProfileLock: error unlocking profile`, e);
168       }
169     }
170   },
172   /**
173    * Reads the preferences from "prefs.js" out of a profile, optionally
174    * returning only names satisfying a given predicate.
175    *
176    * If no `lock` is given, the default profile is locked and the preferences
177    * read from it.  If `lock` is given, read from the given lock's directory.
178    *
179    * @param {(name: string) => boolean} [predicate] a predicate to filter
180    * preferences by; if not given, all preferences are accepted.
181    * @param {nsIProfileLock} [lock] optional lock to use
182    * @returns {object} with keys that are string preference names and values
183    * that are string|number|boolean preference values.
184    */
185   async readPreferences(predicate = null, lock = null) {
186     if (!lock) {
187       return this.withProfileLock(profileLock =>
188         this.readPreferences(predicate, profileLock)
189       );
190     }
192     this._throwIfNotLocked(lock);
193     lazy.log.info(`readPreferences: profile is locked`);
195     let prefs = {};
196     let addPref = (kind, name, value) => {
197       if (predicate && !predicate(name)) {
198         return;
199       }
200       prefs[name] = value;
201     };
203     // We ignore any "user.js" file, since usage is low and doing otherwise
204     // requires implementing a bit more of `nsIPrefsService` than feels safe.
205     let prefsFile = lock.directory.clone();
206     prefsFile.append("prefs.js");
207     lazy.log.info(`readPreferences: will parse prefs ${prefsFile.path}`);
209     let data = await IOUtils.read(prefsFile.path);
210     lazy.log.debug(
211       `readPreferences: parsing prefs from buffer of length ${data.length}`
212     );
214     Services.prefs.parsePrefsFromBuffer(
215       data,
216       {
217         onStringPref: addPref,
218         onIntPref: addPref,
219         onBoolPref: addPref,
220         onError(message) {
221           // Firefox itself manages "prefs.js", so errors should be infrequent.
222           lazy.log.error(message);
223         },
224       },
225       prefsFile.path
226     );
228     lazy.log.debug(`readPreferences: parsed prefs from buffer`, prefs);
229     return prefs;
230   },
232   /**
233    * Reads the snapshotted Firefox Messaging System targeting out of a profile.
234    *
235    * If no `lock` is given, the default profile is locked and the preferences
236    * read from it.  If `lock` is given, read from the given lock's directory.
237    *
238    * @param {nsIProfileLock} [lock] optional lock to use
239    * @returns {object}
240    */
241   async readFirefoxMessagingSystemTargetingSnapshot(lock = null) {
242     if (!lock) {
243       return this.withProfileLock(profileLock =>
244         this.readFirefoxMessagingSystemTargetingSnapshot(profileLock)
245       );
246     }
248     this._throwIfNotLocked(lock);
250     let snapshotFile = lock.directory.clone();
251     snapshotFile.append("targeting.snapshot.json");
253     lazy.log.info(
254       `readFirefoxMessagingSystemTargetingSnapshot: will read Firefox Messaging ` +
255         `System targeting snapshot from ${snapshotFile.path}`
256     );
258     return IOUtils.readJSON(snapshotFile.path);
259   },
261   /**
262    * Reads the Telemetry Client ID out of a profile.
263    *
264    * If no `lock` is given, the default profile is locked and the preferences
265    * read from it.  If `lock` is given, read from the given lock's directory.
266    *
267    * @param {nsIProfileLock} [lock] optional lock to use
268    * @returns {string}
269    */
270   async readTelemetryClientID(lock = null) {
271     if (!lock) {
272       return this.withProfileLock(profileLock =>
273         this.readTelemetryClientID(profileLock)
274       );
275     }
277     this._throwIfNotLocked(lock);
279     let stateFile = lock.directory.clone();
280     stateFile.append("datareporting");
281     stateFile.append("state.json");
283     lazy.log.info(
284       `readPreferences: will read Telemetry client ID from ${stateFile.path}`
285     );
287     let state = await IOUtils.readJSON(stateFile.path);
289     return state.clientID;
290   },
292   /**
293    * Enable the Nimbus experimentation framework.
294    *
295    * @param {nsICommandLine} commandLine if given, accept command line parameters
296    *                                     like `--url about:studies?...` or
297    *                                     `--url file:path/to.json` to explicitly
298    *                                     opt-on to experiment branches.
299    * @param {object} defaultProfile      snapshot of Firefox Messaging System
300    *                                     targeting from default browsing profile.
301    */
302   async enableNimbus(commandLine, defaultProfile = {}) {
303     try {
304       await lazy.ExperimentManager.onStartup({ defaultProfile });
305     } catch (err) {
306       lazy.log.error("Failed to initialize ExperimentManager:", err);
307       throw err;
308     }
310     try {
311       await lazy.RemoteSettingsExperimentLoader.init({ forceSync: true });
312     } catch (err) {
313       lazy.log.error(
314         "Failed to initialize RemoteSettingsExperimentLoader:",
315         err
316       );
317       throw err;
318     }
320     // Allow manual explicit opt-in to experiment branches to facilitate testing.
321     //
322     // Process command line arguments, like
323     // `--url about:studies?optin_slug=nalexander-ms-test1&optin_branch=treatment-a&optin_collection=nimbus-preview`
324     // or
325     // `--url file:///Users/nalexander/Mozilla/gecko/experiment.json?optin_branch=treatment-a`.
326     let ar;
327     while ((ar = commandLine?.handleFlagWithParam("url", false))) {
328       let uri = commandLine.resolveURI(ar);
329       const params = new URLSearchParams(uri.query);
331       if (uri.schemeIs("about") && uri.filePath == "studies") {
332         // Allow explicit opt-in.  In the future, we might take this pref from
333         // the default browsing profile.
334         Services.prefs.setBoolPref("nimbus.debug", true);
336         const data = {
337           slug: params.get("optin_slug"),
338           branch: params.get("optin_branch"),
339           collection: params.get("optin_collection"),
340         };
341         await lazy.RemoteSettingsExperimentLoader.optInToExperiment(data);
342         lazy.log.info(`Opted in to experiment: ${JSON.stringify(data)}`);
343       }
345       if (uri.schemeIs("file")) {
346         let branchSlug = params.get("optin_branch");
347         let path = decodeURIComponent(uri.filePath);
348         let response = await fetch(uri.spec);
349         let recipe = await response.json();
350         if (recipe.permissions) {
351           // Saved directly from Experimenter, there's a top-level `data`.  Hand
352           // written, that's not the norm.
353           recipe = recipe.data;
354         }
355         let branch = recipe.branches.find(b => b.slug == branchSlug);
357         lazy.ExperimentManager.forceEnroll(recipe, branch);
358         lazy.log.info(`Forced enrollment into: ${path}, branch: ${branchSlug}`);
359       }
360     }
361   },
363   /**
364    * Enable the Firefox Messaging System and, when successfully initialized,
365    * trigger a message with trigger id `backgroundTask`.
366    *
367    * @param {object} defaultProfile - snapshot of Firefox Messaging System
368    *                                  targeting from default browsing profile.
369    */
370   async enableFirefoxMessagingSystem(defaultProfile = {}) {
371     function logArgs(tag, ...args) {
372       lazy.log.debug(`FxMS invoked ${tag}: ${JSON.stringify(args)}`);
373     }
375     let { messageHandler, router, createStorage } =
376       lazy.ASRouterDefaultConfig();
378     if (!router.initialized) {
379       const storage = await createStorage();
380       await router.init({
381         storage,
382         // Background tasks never send legacy telemetry.
383         sendTelemetry: logArgs.bind(null, "sendTelemetry"),
384         dispatchCFRAction: messageHandler.handleCFRAction.bind(messageHandler),
385         // There's no child process involved in background tasks, so swallow all
386         // of these messages.
387         clearChildMessages: logArgs.bind(null, "clearChildMessages"),
388         clearChildProviders: logArgs.bind(null, "clearChildProviders"),
389         updateAdminState: () => {},
390       });
391     }
393     await lazy.ASRouter.waitForInitialized;
395     const triggerId = "backgroundTask";
396     await lazy.ASRouter.sendTriggerMessage({
397       browser: null,
398       id: triggerId,
399       context: {
400         defaultProfile,
401       },
402     });
403     lazy.log.info(
404       "Triggered Firefox Messaging System with trigger id 'backgroundTask'"
405     );
406   },