Source code
Revision control
Copy as Markdown
Other Tools
/* -*- 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
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.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, {
ASRouter:
// eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
"resource:///modules/asrouter/ASRouter.sys.mjs",
ASRouterDefaultConfig:
// eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
"resource:///modules/asrouter/ASRouterDefaultConfig.sys.mjs",
ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
RemoteSettingsExperimentLoader:
"resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs",
});
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) => {
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
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'"
);
},
};