summaryrefslogtreecommitdiffstats
path: root/toolkit/components/backgroundtasks/BackgroundTasksUtils.sys.mjs
blob: d2a65569f4ea8c544827208395962607a0f16874 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

XPCOMUtils.defineLazyGetter(lazy, "log", () => {
  let { ConsoleAPI } = ChromeUtils.importESModule(
    "resource://gre/modules/Console.sys.mjs"
  );
  let consoleOptions = {
    // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
    // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
    maxLogLevel: "error",
    maxLogLevelPref: "toolkit.backgroundtasks.loglevel",
    prefix: "BackgroundTasksUtils",
  };
  return new ConsoleAPI(consoleOptions);
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "ProfileService",
  "@mozilla.org/toolkit/profile-service;1",
  "nsIToolkitProfileService"
);

ChromeUtils.defineESModuleGetters(lazy, {
  ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",

  RemoteSettingsExperimentLoader:
    "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs",
});

XPCOMUtils.defineLazyModuleGetters(lazy, {
  ASRouter: "resource://activity-stream/lib/ASRouter.jsm",

  ASRouterDefaultConfig:
    "resource://activity-stream/lib/ASRouterDefaultConfig.jsm",
});

class CannotLockProfileError extends Error {
  constructor(message) {
    super(message);
    this.name = "CannotLockProfileError";
  }
}

export var BackgroundTasksUtils = {
  // Manage our own default profile that can be overridden for testing.  It's
  // easier to do this here rather than using the profile service itself.
  _defaultProfileInitialized: false,
  _defaultProfile: null,

  getDefaultProfile() {
    if (!this._defaultProfileInitialized) {
      this._defaultProfileInitialized = true;
      // This is all test-only.
      let defaultProfilePath = Services.env.get(
        "MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH"
      );
      let noDefaultProfile = Services.env.get(
        "MOZ_BACKGROUNDTASKS_NO_DEFAULT_PROFILE"
      );
      if (defaultProfilePath) {
        lazy.log.info(
          `getDefaultProfile: using default profile path ${defaultProfilePath}`
        );
        var tmpd = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
        tmpd.initWithPath(defaultProfilePath);
        // Sadly this writes to `profiles.ini`, but there's little to be done.
        this._defaultProfile = lazy.ProfileService.createProfile(
          tmpd,
          `MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH-${Date.now()}`
        );
      } else if (noDefaultProfile) {
        lazy.log.info(`getDefaultProfile: setting default profile to null`);
        this._defaultProfile = null;
      } else {
        try {
          lazy.log.info(
            `getDefaultProfile: using ProfileService.defaultProfile`
          );
          this._defaultProfile = lazy.ProfileService.defaultProfile;
        } catch (e) {}
      }
    }
    return this._defaultProfile;
  },

  hasDefaultProfile() {
    return this.getDefaultProfile() != null;
  },

  currentProfileIsDefaultProfile() {
    let defaultProfile = this.getDefaultProfile();
    let currentProfile = lazy.ProfileService.currentProfile;
    // This comparison needs to accommodate null on both sides.
    let isDefaultProfile = defaultProfile && currentProfile == defaultProfile;
    return isDefaultProfile;
  },

  _throwIfNotLocked(lock) {
    if (!(lock instanceof Ci.nsIProfileLock)) {
      throw new Error("Passed lock was not an instance of nsIProfileLock");
    }

    try {
      // In release builds, `.directory` throws NS_ERROR_NOT_INITIALIZED when
      // unlocked.  In debug builds, `.directory` when the profile is not locked
      // will crash via `NS_ERROR`.
      if (lock.directory) {
        return;
      }
    } catch (e) {
      if (
        !(
          e instanceof Ci.nsIException &&
          e.result == Cr.NS_ERROR_NOT_INITIALIZED
        )
      ) {
        throw e;
      }
    }
    throw new Error("Profile is not locked");
  },

  /**
   * Locks the given profile and provides the path to it to the callback.
   * The callback should return a promise and once settled the profile is
   * unlocked and then the promise returned back to the caller of this function.
   *
   * @template T
   * @param {(lock: nsIProfileLock) => Promise<T>} callback
   * @param {nsIToolkitProfile} [profile] defaults to default profile
   * @return {Promise<T>}
   */
  async withProfileLock(callback, profile = this.getDefaultProfile()) {
    if (!profile) {
      throw new Error("No default profile exists");
    }

    let lock;
    try {
      lock = profile.lock({});
      lazy.log.info(
        `withProfileLock: locked profile at ${lock.directory.path}`
      );
    } catch (e) {
      throw new CannotLockProfileError(`Cannot lock profile: ${e}`);
    }

    try {
      // We must await to ensure any logging is displayed after the callback resolves.
      return await callback(lock);
    } finally {
      try {
        lazy.log.info(
          `withProfileLock: unlocking profile at ${lock.directory.path}`
        );
        lock.unlock();
        lazy.log.info(`withProfileLock: unlocked profile`);
      } catch (e) {
        lazy.log.warn(`withProfileLock: error unlocking profile`, e);
      }
    }
  },

  /**
   * Reads the preferences from "prefs.js" out of a profile, optionally
   * returning only names satisfying a given predicate.
   *
   * If no `lock` is given, the default profile is locked and the preferences
   * read from it.  If `lock` is given, read from the given lock's directory.
   *
   * @param {(name: string) => boolean} [predicate] a predicate to filter
   * preferences by; if not given, all preferences are accepted.
   * @param {nsIProfileLock} [lock] optional lock to use
   * @returns {object} with keys that are string preference names and values
   * that are string|number|boolean preference values.
   */
  async readPreferences(predicate = null, lock = null) {
    if (!lock) {
      return this.withProfileLock(profileLock =>
        this.readPreferences(predicate, profileLock)
      );
    }

    this._throwIfNotLocked(lock);
    lazy.log.info(`readPreferences: profile is locked`);

    let prefs = {};
    let addPref = (kind, name, value, sticky, locked) => {
      if (predicate && !predicate(name)) {
        return;
      }
      prefs[name] = value;
    };

    // We ignore any "user.js" file, since usage is low and doing otherwise
    // requires implementing a bit more of `nsIPrefsService` than feels safe.
    let prefsFile = lock.directory.clone();
    prefsFile.append("prefs.js");
    lazy.log.info(`readPreferences: will parse prefs ${prefsFile.path}`);

    let data = await IOUtils.read(prefsFile.path);
    lazy.log.debug(
      `readPreferences: parsing prefs from buffer of length ${data.length}`
    );

    Services.prefs.parsePrefsFromBuffer(
      data,
      {
        onStringPref: addPref,
        onIntPref: addPref,
        onBoolPref: addPref,
        onError(message) {
          // Firefox itself manages "prefs.js", so errors should be infrequent.
          lazy.log.error(message);
        },
      },
      prefsFile.path
    );

    lazy.log.debug(`readPreferences: parsed prefs from buffer`, prefs);
    return prefs;
  },

  /**
   * Reads the snapshotted Firefox Messaging System targeting out of a profile.
   *
   * If no `lock` is given, the default profile is locked and the preferences
   * read from it.  If `lock` is given, read from the given lock's directory.
   *
   * @param {nsIProfileLock} [lock] optional lock to use
   * @returns {object}
   */
  async readFirefoxMessagingSystemTargetingSnapshot(lock = null) {
    if (!lock) {
      return this.withProfileLock(profileLock =>
        this.readFirefoxMessagingSystemTargetingSnapshot(profileLock)
      );
    }

    this._throwIfNotLocked(lock);

    let snapshotFile = lock.directory.clone();
    snapshotFile.append("targeting.snapshot.json");

    lazy.log.info(
      `readFirefoxMessagingSystemTargetingSnapshot: will read Firefox Messaging ` +
        `System targeting snapshot from ${snapshotFile.path}`
    );

    return IOUtils.readJSON(snapshotFile.path);
  },

  /**
   * Reads the Telemetry Client ID out of a profile.
   *
   * If no `lock` is given, the default profile is locked and the preferences
   * read from it.  If `lock` is given, read from the given lock's directory.
   *
   * @param {nsIProfileLock} [lock] optional lock to use
   * @returns {string}
   */
  async readTelemetryClientID(lock = null) {
    if (!lock) {
      return this.withProfileLock(profileLock =>
        this.readTelemetryClientID(profileLock)
      );
    }

    this._throwIfNotLocked(lock);

    let stateFile = lock.directory.clone();
    stateFile.append("datareporting");
    stateFile.append("state.json");

    lazy.log.info(
      `readPreferences: will read Telemetry client ID from ${stateFile.path}`
    );

    let state = await IOUtils.readJSON(stateFile.path);

    return state.clientID;
  },

  /**
   * Enable the Nimbus experimentation framework.
   *
   * @param {nsICommandLine} commandLine if given, accept command line parameters
   *                                     like `--url about:studies?...` or
   *                                     `--url file:path/to.json` to explicitly
   *                                     opt-on to experiment branches.
   * @param {object} defaultProfile      snapshot of Firefox Messaging System
   *                                     targeting from default browsing profile.
   */
  async enableNimbus(commandLine, defaultProfile = {}) {
    try {
      await lazy.ExperimentManager.onStartup({ defaultProfile });
    } catch (err) {
      lazy.log.error("Failed to initialize ExperimentManager:", err);
      throw err;
    }

    try {
      await lazy.RemoteSettingsExperimentLoader.init({ forceSync: true });
    } catch (err) {
      lazy.log.error(
        "Failed to initialize RemoteSettingsExperimentLoader:",
        err
      );
      throw err;
    }

    // Allow manual explicit opt-in to experiment branches to facilitate testing.
    //
    // Process command line arguments, like
    // `--url about:studies?optin_slug=nalexander-ms-test1&optin_branch=treatment-a&optin_collection=nimbus-preview`
    // or
    // `--url file:///Users/nalexander/Mozilla/gecko/experiment.json?optin_branch=treatment-a`.
    let ar;
    while ((ar = commandLine?.handleFlagWithParam("url", false))) {
      let uri = commandLine.resolveURI(ar);
      const params = new URLSearchParams(uri.query);

      if (uri.schemeIs("about") && uri.filePath == "studies") {
        // Allow explicit opt-in.  In the future, we might take this pref from
        // the default browsing profile.
        Services.prefs.setBoolPref("nimbus.debug", true);

        const data = {
          slug: params.get("optin_slug"),
          branch: params.get("optin_branch"),
          collection: params.get("optin_collection"),
        };
        await lazy.RemoteSettingsExperimentLoader.optInToExperiment(data);
        lazy.log.info(`Opted in to experiment: ${JSON.stringify(data)}`);
      }

      if (uri.schemeIs("file")) {
        let branchSlug = params.get("optin_branch");
        let path = decodeURIComponent(uri.filePath);
        let response = await fetch(uri.spec);
        let recipe = await response.json();
        if (recipe.permissions) {
          // Saved directly from Experimenter, there's a top-level `data`.  Hand
          // written, that's not the norm.
          recipe = recipe.data;
        }
        let branch = recipe.branches.find(b => b.slug == branchSlug);

        lazy.ExperimentManager.forceEnroll(recipe, branch);
        lazy.log.info(`Forced enrollment into: ${path}, branch: ${branchSlug}`);
      }
    }
  },

  /**
   * Enable the Firefox Messaging System and, when successfully initialized,
   * trigger a message with trigger id `backgroundTask`.
   *
   * @param {object} defaultProfile - snapshot of Firefox Messaging System
   *                                  targeting from default browsing profile.
   */
  async enableFirefoxMessagingSystem(defaultProfile = {}) {
    function logArgs(tag, ...args) {
      lazy.log.debug(`FxMS invoked ${tag}: ${JSON.stringify(args)}`);
    }

    let { messageHandler, router, createStorage } =
      lazy.ASRouterDefaultConfig();

    if (!router.initialized) {
      const storage = await createStorage();
      await router.init({
        storage,
        // Background tasks never send legacy telemetry.
        sendTelemetry: logArgs.bind(null, "sendTelemetry"),
        dispatchCFRAction: messageHandler.handleCFRAction.bind(messageHandler),
        // There's no child process involved in background tasks, so swallow all
        // of these messages.
        clearChildMessages: logArgs.bind(null, "clearChildMessages"),
        clearChildProviders: logArgs.bind(null, "clearChildProviders"),
        updateAdminState: () => {},
      });
    }

    await lazy.ASRouter.waitForInitialized;

    const triggerId = "backgroundTask";
    await lazy.ASRouter.sendTriggerMessage({
      browser: null,
      id: triggerId,
      context: {
        defaultProfile,
      },
    });
    lazy.log.info(
      "Triggered Firefox Messaging System with trigger id 'backgroundTask'"
    );
  },
};