fune/toolkit/components/backgroundtasks/BackgroundTasksUtils.jsm
Nick Alexander 7e300e8d78 Bug 1784117 - Lift default profile targeting to Nimbus in background tasks. r=barret
This was oversight.  In background tasks, I took some care to provide
the snapshot of the default profile's targeting state to the Firefox
Messaging System, but it also needs to be provided to Nimbus to filter
experiments directly.

For expedience, I provide an extra targeting context object to
`ExperimentManager.onStartup`.

The targeting contexts for Nimbus proper and Firefox Messaging System
are somewhat independent; that's why the triggering context remains in
the invocation of `sendTriggerMessage`.

Depends on D154308

Differential Revision: https://phabricator.services.mozilla.com/D154309
2022-08-11 17:39:53 +00:00

408 lines
13 KiB
JavaScript

/* -*- 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/. */
var EXPORTED_SYMBOLS = ["BackgroundTasksUtils"];
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const lazy = {};
XPCOMUtils.defineLazyGetter(lazy, "log", () => {
let { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm");
let consoleOptions = {
// tip: set maxLogLevel to "debug" and use log.debug() to create detailed
// messages during development. See LOG_LEVELS in Console.jsm 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"
);
XPCOMUtils.defineLazyModuleGetters(lazy, {
ASRouter: "resource://activity-stream/lib/ASRouter.jsm",
ASRouterDefaultConfig:
"resource://activity-stream/lib/ASRouterDefaultConfig.jsm",
ExperimentManager: "resource://nimbus/lib/ExperimentManager.jsm",
RemoteSettingsExperimentLoader:
"resource://nimbus/lib/RemoteSettingsExperimentLoader.jsm",
});
class CannotLockProfileError extends Error {
constructor(message) {
super(message);
this.name = "CannotLockProfileError";
}
}
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.
const env = Cc["@mozilla.org/process/environment;1"].getService(
Ci.nsIEnvironment
);
let defaultProfilePath = env.get(
"MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH"
);
let noDefaultProfile = 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 {string}
*/
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();
} 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'"
);
},
};