diff options
Diffstat (limited to '')
20 files changed, 4243 insertions, 0 deletions
diff --git a/toolkit/components/normandy/lib/ActionsManager.sys.mjs b/toolkit/components/normandy/lib/ActionsManager.sys.mjs new file mode 100644 index 0000000000..6f811613af --- /dev/null +++ b/toolkit/components/normandy/lib/ActionsManager.sys.mjs @@ -0,0 +1,100 @@ +/* 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 { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonRollbackAction: + "resource://normandy/actions/AddonRollbackAction.sys.mjs", + AddonRolloutAction: "resource://normandy/actions/AddonRolloutAction.sys.mjs", + BaseAction: "resource://normandy/actions/BaseAction.sys.mjs", + BranchedAddonStudyAction: + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs", + ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.sys.mjs", + MessagingExperimentAction: + "resource://normandy/actions/MessagingExperimentAction.sys.mjs", + PreferenceExperimentAction: + "resource://normandy/actions/PreferenceExperimentAction.sys.mjs", + PreferenceRollbackAction: + "resource://normandy/actions/PreferenceRollbackAction.sys.mjs", + PreferenceRolloutAction: + "resource://normandy/actions/PreferenceRolloutAction.sys.mjs", + ShowHeartbeatAction: + "resource://normandy/actions/ShowHeartbeatAction.sys.mjs", + Uptake: "resource://normandy/lib/Uptake.sys.mjs", +}); + +const log = LogManager.getLogger("recipe-runner"); + +/** + * A class to manage the actions that recipes can use in Normandy. + */ +export class ActionsManager { + constructor() { + this.finalized = false; + + this.localActions = {}; + for (const [name, Constructor] of Object.entries( + ActionsManager.actionConstructors + )) { + this.localActions[name] = new Constructor(); + } + } + + static actionConstructors = { + "addon-rollback": lazy.AddonRollbackAction, + "addon-rollout": lazy.AddonRolloutAction, + "branched-addon-study": lazy.BranchedAddonStudyAction, + "console-log": lazy.ConsoleLogAction, + "messaging-experiment": lazy.MessagingExperimentAction, + "multi-preference-experiment": lazy.PreferenceExperimentAction, + "preference-rollback": lazy.PreferenceRollbackAction, + "preference-rollout": lazy.PreferenceRolloutAction, + "show-heartbeat": lazy.ShowHeartbeatAction, + }; + + static getCapabilities() { + // Prefix each action name with "action." to turn it into a capability name. + let capabilities = new Set(); + for (const actionName of Object.keys(ActionsManager.actionConstructors)) { + capabilities.add(`action.${actionName}`); + } + return capabilities; + } + + async processRecipe(recipe, suitability) { + let actionName = recipe.action; + + if (actionName in this.localActions) { + log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`); + const action = this.localActions[actionName]; + await action.processRecipe(recipe, suitability); + + // If the recipe doesn't have matching capabilities, then a missing action + // is expected. In this case, don't send an error + } else if ( + suitability !== lazy.BaseAction.suitability.CAPABILITIES_MISMATCH + ) { + log.error( + `Could not execute recipe ${recipe.name}:`, + `Action ${recipe.action} is either missing or invalid.` + ); + await lazy.Uptake.reportRecipe(recipe, lazy.Uptake.RECIPE_INVALID_ACTION); + } + } + + async finalize(options) { + if (this.finalized) { + throw new Error("ActionsManager has already been finalized"); + } + this.finalized = true; + + // Finalize local actions + for (const action of Object.values(this.localActions)) { + action.finalize(options); + } + } +} diff --git a/toolkit/components/normandy/lib/AddonRollouts.sys.mjs b/toolkit/components/normandy/lib/AddonRollouts.sys.mjs new file mode 100644 index 0000000000..6bfc2a70a7 --- /dev/null +++ b/toolkit/components/normandy/lib/AddonRollouts.sys.mjs @@ -0,0 +1,224 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +/** + * AddonRollouts store info about an active or expired addon rollouts. + * @typedef {object} AddonRollout + * @property {int} recipeId + * The ID of the recipe. + * @property {string} slug + * Unique slug of the rollout. + * @property {string} state + * The current state of the rollout: "active", or "rolled-back". + * Active means that Normandy is actively managing therollout. Rolled-back + * means that the rollout was previously active, but has been rolled back for + * this user. + * @property {int} extensionApiId + * The ID used to look up the extension in Normandy's API. + * @property {string} addonId + * The add-on ID for this particular rollout. + * @property {string} addonVersion + * The rollout add-on version number + * @property {string} xpiUrl + * URL that the add-on was installed from. + * @property {string} xpiHash + * The hash of the XPI file. + * @property {string} xpiHashAlgorithm + * The algorithm used to hash the XPI file. + * @property {string} enrollmentId + * A random ID generated at time of enrollment. It should be included on all + * telemetry related to this rollout. It should not be re-used by other + * rollouts, or any other purpose. May be null on old rollouts. + */ + +const DB_NAME = "normandy-addon-rollout"; +const STORE_NAME = "addon-rollouts"; +const DB_OPTIONS = { version: 1 }; + +/** + * Create a new connection to the database. + */ +function openDatabase() { + return lazy.IndexedDB.open(DB_NAME, DB_OPTIONS, db => { + db.createObjectStore(STORE_NAME, { + keyPath: "slug", + }); + }); +} + +/** + * Cache the database connection so that it is shared among multiple operations. + */ +let databasePromise; +function getDatabase() { + if (!databasePromise) { + databasePromise = openDatabase(); + } + return databasePromise; +} + +/** + * Get a transaction for interacting with the rollout store. + * + * @param {IDBDatabase} db + * @param {String} mode Either "readonly" or "readwrite" + * + * NOTE: Methods on the store returned by this function MUST be called + * synchronously, otherwise the transaction with the store will expire. + * This is why the helper takes a database as an argument; if we fetched the + * database in the helper directly, the helper would be async and the + * transaction would expire before methods on the store were called. + */ +function getStore(db, mode) { + if (!mode) { + throw new Error("mode is required"); + } + return db.objectStore(STORE_NAME, mode); +} + +export const AddonRollouts = { + STATE_ACTIVE: "active", + STATE_ROLLED_BACK: "rolled-back", + + async init() { + for (const rollout of await this.getAllActive()) { + lazy.TelemetryEnvironment.setExperimentActive( + rollout.slug, + rollout.state, + { + type: "normandy-addonrollout", + } + ); + } + }, + + /** When Telemetry is disabled, clear all identifiers from the stored rollouts. */ + async onTelemetryDisabled() { + const rollouts = await this.getAll(); + for (const rollout of rollouts) { + rollout.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER; + } + await this.updateMany(rollouts); + }, + + /** + * Add a new rollout + * @param {AddonRollout} rollout + */ + async add(rollout) { + const db = await getDatabase(); + return getStore(db, "readwrite").add(rollout); + }, + + /** + * Update an existing rollout + * @param {AddonRollout} rollout + * @throws If a matching rollout does not exist. + */ + async update(rollout) { + if (!(await this.has(rollout.slug))) { + throw new Error( + `Tried to update ${rollout.slug}, but it doesn't already exist.` + ); + } + const db = await getDatabase(); + return getStore(db, "readwrite").put(rollout); + }, + + /** + * Update many existing rollouts. More efficient than calling `update` many + * times in a row. + * @param {Array<PreferenceRollout>} rollouts + * @throws If any of the passed rollouts have a slug that doesn't exist in the database already. + */ + async updateMany(rollouts) { + // Don't touch the database if there is nothing to do + if (!rollouts.length) { + return; + } + + // Both of the below operations use .map() instead of a normal loop becaues + // once we get the object store, we can't let it expire by spinning the + // event loop. This approach queues up all the interactions with the store + // immediately, preventing it from expiring too soon. + + const db = await getDatabase(); + let store = await getStore(db, "readonly"); + await Promise.all( + rollouts.map(async ({ slug }) => { + let existingRollout = await store.get(slug); + if (!existingRollout) { + throw new Error(`Tried to update ${slug}, but it doesn't exist.`); + } + }) + ); + + // awaiting spun the event loop, so the store is now invalid. Get a new + // store. This is also a chance to get it in readwrite mode. + store = await getStore(db, "readwrite"); + await Promise.all(rollouts.map(rollout => store.put(rollout))); + }, + + /** + * Test whether there is a rollout in storage with the given slug. + * @param {string} slug + * @returns {Promise<boolean>} + */ + async has(slug) { + const db = await getDatabase(); + const rollout = await getStore(db, "readonly").get(slug); + return !!rollout; + }, + + /** + * Get a rollout by slug + * @param {string} slug + */ + async get(slug) { + const db = await getDatabase(); + return getStore(db, "readonly").get(slug); + }, + + /** Get all rollouts in the database. */ + async getAll() { + const db = await getDatabase(); + return getStore(db, "readonly").getAll(); + }, + + /** Get all rollouts in the "active" state. */ + async getAllActive() { + const rollouts = await this.getAll(); + return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE); + }, + + /** + * Test wrapper that temporarily replaces the stored rollout data with fake + * data for testing. + */ + withTestMock() { + return function (testFunction) { + return async function inner(...args) { + let db = await getDatabase(); + const oldData = await getStore(db, "readonly").getAll(); + await getStore(db, "readwrite").clear(); + try { + await testFunction(...args); + } finally { + db = await getDatabase(); + await getStore(db, "readwrite").clear(); + const store = getStore(db, "readwrite"); + await Promise.all(oldData.map(d => store.add(d))); + } + }; + }; + }, +}; diff --git a/toolkit/components/normandy/lib/AddonStudies.sys.mjs b/toolkit/components/normandy/lib/AddonStudies.sys.mjs new file mode 100644 index 0000000000..233bd4b715 --- /dev/null +++ b/toolkit/components/normandy/lib/AddonStudies.sys.mjs @@ -0,0 +1,485 @@ +/* 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/. */ + +/** + * @typedef {Object} Study + * @property {Number} recipeId + * ID of the recipe that created the study. Used as the primary key of the + * study. + * @property {Number} slug + * String code used to identify the study for use in Telemetry and logging. + * @property {string} userFacingName + * Name of the study to show to the user + * @property {string} userFacingDescription + * Description of the study and its intent. + * @property {string} branch + * The branch the user is enrolled in + * @property {boolean} active + * Is the study still running? + * @property {string} addonId + * Add-on ID for this particular study. + * @property {string} addonUrl + * URL that the study add-on was installed from. + * @property {string} addonVersion + * Study add-on version number + * @property {int} extensionApiId + * The ID used to look up the extension in Normandy's API. + * @property {string} extensionHash + * The hash of the XPI file. + * @property {string} extensionHashAlgorithm + * The algorithm used to hash the XPI file. + * @property {Date} studyStartDate + * Date when the study was started. + * @property {Date|null} studyEndDate + * Date when the study was ended. + * @property {Date|null} temporaryErrorDeadline + * Date of when temporary errors with this experiment should no longer be + * considered temporary. After this point, further errors will result in + * unenrollment. + * @property {string} enrollmentId + * A random ID generated at time of enrollment. It should be included on all + * telemetry related to this study. It should not be re-used by other studies, + * or any other purpose. May be null on old study. + */ + +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + BranchedAddonStudyAction: + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs", + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +const DB_NAME = "shield"; +const STORE_NAME = "addon-studies"; +const VERSION_STORE_NAME = "addon-studies-version"; +const DB_VERSION = 2; +const STUDY_ENDED_TOPIC = "shield-study-ended"; +const log = LogManager.getLogger("addon-studies"); + +/** + * Create a new connection to the database. + */ +function openDatabase() { + return lazy.IndexedDB.open(DB_NAME, DB_VERSION, async (db, event) => { + if (event.oldVersion < 1) { + db.createObjectStore(STORE_NAME, { + keyPath: "recipeId", + }); + } + + if (event.oldVersion < 2) { + db.createObjectStore(VERSION_STORE_NAME); + } + }); +} + +/** + * Cache the database connection so that it is shared among multiple operations. + */ +let databasePromise; +async function getDatabase() { + if (!databasePromise) { + databasePromise = openDatabase(); + } + return databasePromise; +} + +/** + * Get a transaction for interacting with the study store. + * + * @param {IDBDatabase} db + * @param {String} mode Either "readonly" or "readwrite" + * + * NOTE: Methods on the store returned by this function MUST be called + * synchronously, otherwise the transaction with the store will expire. + * This is why the helper takes a database as an argument; if we fetched the + * database in the helper directly, the helper would be async and the + * transaction would expire before methods on the store were called. + */ +function getStore(db, mode) { + if (!mode) { + throw new Error("mode is required"); + } + return db.objectStore(STORE_NAME, mode); +} + +export var AddonStudies = { + /** + * Test wrapper that temporarily replaces the stored studies with the given + * ones. The original stored studies are restored upon completion. + * + * This is defined here instead of in test code since it needs to access the + * getDatabase, which we don't expose to avoid outside modules relying on the + * type of storage used for studies. + * + * @param {Array} [addonStudies=[]] + */ + withStudies(addonStudies = []) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const oldStudies = await AddonStudies.getAll(); + let db = await getDatabase(); + await AddonStudies.clear(); + const store = getStore(db, "readwrite"); + await Promise.all(addonStudies.map(study => store.add(study))); + + try { + await testFunction({ ...args, addonStudies }); + } finally { + db = await getDatabase(); + await AddonStudies.clear(); + const store = getStore(db, "readwrite"); + await Promise.all(oldStudies.map(study => store.add(study))); + } + }; + }; + }, + + async init() { + for (const study of await this.getAllActive()) { + // If an active study's add-on has been removed since we last ran, stop it. + const addon = await lazy.AddonManager.getAddonByID(study.addonId); + if (!addon) { + await this.markAsEnded(study, "uninstalled-sideload"); + continue; + } + + // Otherwise mark that study as active in Telemetry + lazy.TelemetryEnvironment.setExperimentActive(study.slug, study.branch, { + type: "normandy-addonstudy", + enrollmentId: + study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + } + + // Listen for add-on uninstalls so we can stop the corresponding studies. + lazy.AddonManager.addAddonListener(this); + lazy.CleanupManager.addCleanupHandler(() => { + lazy.AddonManager.removeAddonListener(this); + }); + }, + + /** When Telemetry is disabled, clear all identifiers from the stored studies. */ + async onTelemetryDisabled() { + const studies = await this.getAll(); + for (const study of studies) { + study.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER; + } + await this.updateMany(studies); + }, + + /** + * These migrations should only be called from `NormandyMigrations.jsm` and + * tests. + */ + migrations: { + /** + * Change from "name" and "description" to "slug", "userFacingName", + * and "userFacingDescription". + */ + async migration01AddonStudyFieldsToSlugAndUserFacingFields() { + const db = await getDatabase(); + const studies = await db.objectStore(STORE_NAME, "readonly").getAll(); + + // If there are no studies, stop here to avoid opening the DB again. + if (studies.length === 0) { + return; + } + + // Object stores expire after `await`, so this method accumulates a bunch of + // promises, and then awaits them at the end. + const writePromises = []; + const objectStore = db.objectStore(STORE_NAME, "readwrite"); + + for (const study of studies) { + // use existing name as slug + if (!study.slug) { + study.slug = study.name; + } + + // Rename `name` and `description` as `userFacingName` and `userFacingDescription` + if (study.name && !study.userFacingName) { + study.userFacingName = study.name; + } + delete study.name; + if (study.description && !study.userFacingDescription) { + study.userFacingDescription = study.description; + } + delete study.description; + + // Specify that existing recipes don't have branches + if (!study.branch) { + study.branch = AddonStudies.NO_BRANCHES_MARKER; + } + + writePromises.push(objectStore.put(study)); + } + + await Promise.all(writePromises); + }, + + async migration02RemoveOldAddonStudyAction() { + const studies = await AddonStudies.getAllActive({ + branched: AddonStudies.FILTER_NOT_BRANCHED, + }); + if (!studies.length) { + return; + } + const action = new lazy.BranchedAddonStudyAction(); + for (const study of studies) { + try { + await action.unenroll( + study.recipeId, + "migration-removing-unbranched-action" + ); + } catch (e) { + log.error( + `Stopping add-on study ${study.slug} during migration failed: ${e}` + ); + } + } + }, + }, + + /** + * If a study add-on is uninstalled, mark the study as having ended. + * @param {Addon} addon + */ + async onUninstalled(addon) { + const activeStudies = (await this.getAll()).filter(study => study.active); + const matchingStudy = activeStudies.find( + study => study.addonId === addon.id + ); + if (matchingStudy) { + await this.markAsEnded(matchingStudy, "uninstalled"); + } + }, + + /** + * Remove all stored studies. + */ + async clear() { + const db = await getDatabase(); + await getStore(db, "readwrite").clear(); + }, + + /** + * Test whether there is a study in storage for the given recipe ID. + * @param {Number} recipeId + * @returns {Boolean} + */ + async has(recipeId) { + const db = await getDatabase(); + const study = await getStore(db, "readonly").get(recipeId); + return !!study; + }, + + /** + * Fetch a study from storage. + * @param {Number} recipeId + * @return {Study} The requested study, or null if none with that ID exist. + */ + async get(recipeId) { + const db = await getDatabase(); + return getStore(db, "readonly").get(recipeId); + }, + + FILTER_BRANCHED_ONLY: Symbol("FILTER_BRANCHED_ONLY"), + FILTER_NOT_BRANCHED: Symbol("FILTER_NOT_BRANCHED"), + FILTER_ALL: Symbol("FILTER_ALL"), + + /** + * Fetch all studies in storage. + * @return {Array<Study>} + */ + async getAll({ branched = AddonStudies.FILTER_ALL } = {}) { + const db = await getDatabase(); + let results = await getStore(db, "readonly").getAll(); + + if (branched == AddonStudies.FILTER_BRANCHED_ONLY) { + results = results.filter( + study => study.branch != AddonStudies.NO_BRANCHES_MARKER + ); + } else if (branched == AddonStudies.FILTER_NOT_BRANCHED) { + results = results.filter( + study => study.branch == AddonStudies.NO_BRANCHES_MARKER + ); + } + return results; + }, + + /** + * Fetch all studies in storage. + * @return {Array<Study>} + */ + async getAllActive(options) { + return (await this.getAll(options)).filter(study => study.active); + }, + + /** + * Add a study to storage. + * @return {Promise<void, Error>} Resolves when the study is stored, or rejects with an error. + */ + async add(study) { + const db = await getDatabase(); + return getStore(db, "readwrite").add(study); + }, + + /** + * Update a study in storage. + * @return {Promise<void, Error>} Resolves when the study is updated, or rejects with an error. + */ + async update(study) { + const db = await getDatabase(); + return getStore(db, "readwrite").put(study); + }, + + /** + * Update many existing studies. More efficient than calling `update` many + * times in a row. + * @param {Array<AddonStudy>} studies + * @throws If any of the passed studies have a slug that doesn't exist in the database already. + */ + async updateMany(studies) { + // Don't touch the database if there is nothing to do + if (!studies.length) { + return; + } + + // Both of the below operations use .map() instead of a normal loop becaues + // once we get the object store, we can't let it expire by spinning the + // event loop. This approach queues up all the interactions with the store + // immediately, preventing it from expiring too soon. + + const db = await getDatabase(); + let store = await getStore(db, "readonly"); + await Promise.all( + studies.map(async ({ recipeId }) => { + let existingStudy = await store.get(recipeId); + if (!existingStudy) { + throw new Error( + `Tried to update addon study ${recipeId}, but it doesn't exist.` + ); + } + }) + ); + + // awaiting spun the event loop, so the store is now invalid. Get a new + // store. This is also a chance to get it in readwrite mode. + store = await getStore(db, "readwrite"); + await Promise.all(studies.map(study => store.put(study))); + }, + + /** + * Remove a study from storage + * @param recipeId The recipeId of the study to delete + * @return {Promise<void, Error>} Resolves when the study is deleted, or rejects with an error. + */ + async delete(recipeId) { + const db = await getDatabase(); + return getStore(db, "readwrite").delete(recipeId); + }, + + /** + * Mark a study object as having ended. Modifies the study in-place. + * @param {IDBDatabase} db + * @param {Study} study + * @param {String} reason Why the study is ending. + */ + async markAsEnded(study, reason = "unknown") { + if (reason === "unknown") { + log.warn(`Study ${study.slug} ending for unknown reason.`); + } + + study.active = false; + study.temporaryErrorDeadline = null; + study.studyEndDate = new Date(); + const db = await getDatabase(); + await getStore(db, "readwrite").put(study); + + Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC, `${study.recipeId}`); + lazy.TelemetryEvents.sendEvent("unenroll", "addon_study", study.slug, { + addonId: study.addonId || AddonStudies.NO_ADDON_MARKER, + addonVersion: study.addonVersion || AddonStudies.NO_ADDON_MARKER, + reason, + branch: study.branch, + enrollmentId: + study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + lazy.TelemetryEnvironment.setExperimentInactive(study.slug); + + await this.callUnenrollListeners(study.addonId, reason); + }, + + // Maps extension id -> Set(callbacks) + _unenrollListeners: new Map(), + + /** + * Register a callback to be invoked when a given study ends. + * + * @param {string} id The extension id + * @param {function} listener The callback + */ + addUnenrollListener(id, listener) { + let listeners = this._unenrollListeners.get(id); + if (!listeners) { + listeners = new Set(); + this._unenrollListeners.set(id, listeners); + } + listeners.add(listener); + }, + + /** + * Unregister a callback to be invoked when a given study ends. + * + * @param {string} id The extension id + * @param {function} listener The callback + */ + removeUnenrollListener(id, listener) { + let listeners = this._unenrollListeners.get(id); + if (listeners) { + listeners.delete(listener); + } + }, + + /** + * Invoke the unenroll callback (if any) for the given extension + * + * @param {string} id The extension id + * @param {string} reason Why the study is ending + * + * @returns {Promise} A Promise resolved after the unenroll listener + * (if any) has finished its unenroll tasks. + */ + async callUnenrollListeners(id, reason) { + let callbacks = this._unenrollListeners.get(id) || []; + + async function callCallback(cb, reason) { + try { + await cb(reason); + } catch (err) { + console.error(err); + } + } + + let promises = []; + for (let callback of callbacks) { + promises.push(callCallback(callback, reason)); + } + + // Wait for all the promises to be settled. This won't throw even if some of + // the listeners fail. + await Promise.all(promises); + }, +}; + +AddonStudies.NO_BRANCHES_MARKER = "__NO_BRANCHES__"; +AddonStudies.NO_ADDON_MARKER = "__NO_ADDON__"; diff --git a/toolkit/components/normandy/lib/CleanupManager.sys.mjs b/toolkit/components/normandy/lib/CleanupManager.sys.mjs new file mode 100644 index 0000000000..9101f0f63c --- /dev/null +++ b/toolkit/components/normandy/lib/CleanupManager.sys.mjs @@ -0,0 +1,49 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", +}); + +class CleanupManagerClass { + constructor() { + this.handlers = new Set(); + this.cleanupPromise = null; + } + + addCleanupHandler(handler) { + this.handlers.add(handler); + } + + removeCleanupHandler(handler) { + this.handlers.delete(handler); + } + + async cleanup() { + if (this.cleanupPromise === null) { + this.cleanupPromise = (async () => { + for (const handler of this.handlers) { + try { + await handler(); + } catch (ex) { + console.error(ex); + } + } + })(); + + // Block shutdown to ensure any cleanup tasks that write data are + // finished. + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "ShieldRecipeClient: Cleaning up", + this.cleanupPromise + ); + } + + return this.cleanupPromise; + } +} + +export var CleanupManager = new CleanupManagerClass(); diff --git a/toolkit/components/normandy/lib/ClientEnvironment.sys.mjs b/toolkit/components/normandy/lib/ClientEnvironment.sys.mjs new file mode 100644 index 0000000000..16645dd3b2 --- /dev/null +++ b/toolkit/components/normandy/lib/ClientEnvironment.sys.mjs @@ -0,0 +1,123 @@ +/* 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 { ClientEnvironmentBase } from "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonRollouts: "resource://normandy/lib/AddonRollouts.sys.mjs", + AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs", + NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs", + PreferenceExperiments: + "resource://normandy/lib/PreferenceExperiments.sys.mjs", + PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.sys.mjs", +}); + +// Cached API request for client attributes that are determined by the Normandy +// service. +let _classifyRequest = null; + +export class ClientEnvironment extends ClientEnvironmentBase { + /** + * Fetches information about the client that is calculated on the server, + * like geolocation and the current time. + * + * The server request is made lazily and is cached for the entire browser + * session. + */ + static async getClientClassification() { + if (!_classifyRequest) { + _classifyRequest = lazy.NormandyApi.classifyClient(); + } + return _classifyRequest; + } + + static clearClassifyCache() { + _classifyRequest = null; + } + + /** + * Test wrapper that mocks the server request for classifying the client. + * @param {Object} data Fake server data to use + * @param {Function} testFunction Test function to execute while mock data is in effect. + */ + static withMockClassify(data, testFunction) { + return async function inner() { + const oldRequest = _classifyRequest; + _classifyRequest = Promise.resolve(data); + await testFunction(); + _classifyRequest = oldRequest; + }; + } + + static get userId() { + return ClientEnvironment.randomizationId; + } + + static get country() { + return (async () => { + const { country } = await ClientEnvironment.getClientClassification(); + return country; + })(); + } + + static get request_time() { + return (async () => { + const { request_time } = + await ClientEnvironment.getClientClassification(); + return request_time; + })(); + } + + static get experiments() { + return (async () => { + const names = { all: [], active: [], expired: [] }; + + for (const { + slug, + expired, + } of await lazy.PreferenceExperiments.getAll()) { + names.all.push(slug); + if (expired) { + names.expired.push(slug); + } else { + names.active.push(slug); + } + } + + return names; + })(); + } + + static get studies() { + return (async () => { + const rv = { pref: {}, addon: {} }; + for (const prefStudy of await lazy.PreferenceExperiments.getAll()) { + rv.pref[prefStudy.slug] = prefStudy; + } + for (const addonStudy of await lazy.AddonStudies.getAll()) { + rv.addon[addonStudy.slug] = addonStudy; + } + return rv; + })(); + } + + static get rollouts() { + return (async () => { + const rv = { pref: {}, addon: {} }; + for (const prefRollout of await lazy.PreferenceRollouts.getAll()) { + rv.pref[prefRollout.slug] = prefRollout; + } + for (const addonRollout of await lazy.AddonRollouts.getAll()) { + rv.addon[addonRollout.slug] = addonRollout; + } + return rv; + })(); + } + + static get isFirstRun() { + return Services.prefs.getBoolPref("app.normandy.first_run", true); + } +} diff --git a/toolkit/components/normandy/lib/EventEmitter.sys.mjs b/toolkit/components/normandy/lib/EventEmitter.sys.mjs new file mode 100644 index 0000000000..551fc4d6b2 --- /dev/null +++ b/toolkit/components/normandy/lib/EventEmitter.sys.mjs @@ -0,0 +1,59 @@ +/* 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 { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const log = LogManager.getLogger("event-emitter"); + +export var EventEmitter = function () { + const listeners = {}; + + return { + emit(eventName, event) { + // Fire events async + Promise.resolve().then(() => { + if (!(eventName in listeners)) { + log.debug( + `EventEmitter: Event fired with no listeners: ${eventName}` + ); + return; + } + // Clone callbacks array to avoid problems with mutation while iterating + const callbacks = Array.from(listeners[eventName]); + for (const cb of callbacks) { + // Clone event so it can't by modified by the handler + let eventToPass = event; + if (typeof event === "object") { + eventToPass = Object.assign({}, event); + } + cb(eventToPass); + } + }); + }, + + on(eventName, callback) { + if (!(eventName in listeners)) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + + off(eventName, callback) { + if (eventName in listeners) { + const index = listeners[eventName].indexOf(callback); + if (index !== -1) { + listeners[eventName].splice(index, 1); + } + } + }, + + once(eventName, callback) { + const inner = event => { + callback(event); + this.off(eventName, inner); + }; + this.on(eventName, inner); + }, + }; +}; diff --git a/toolkit/components/normandy/lib/Heartbeat.sys.mjs b/toolkit/components/normandy/lib/Heartbeat.sys.mjs new file mode 100644 index 0000000000..fa66844acb --- /dev/null +++ b/toolkit/components/normandy/lib/Heartbeat.sys.mjs @@ -0,0 +1,381 @@ +/* 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 { Preferences } from "resource://gre/modules/Preferences.sys.mjs"; +import { TelemetryController } from "resource://gre/modules/TelemetryController.sys.mjs"; +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { CleanupManager } from "resource://normandy/lib/CleanupManager.sys.mjs"; +import { EventEmitter } from "resource://normandy/lib/EventEmitter.sys.mjs"; +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration"; +const NOTIFICATION_TIME = 3000; +const HEARTBEAT_CSS_URI = Services.io.newURI( + "resource://normandy/skin/shared/Heartbeat.css" +); +const log = LogManager.getLogger("heartbeat"); +const windowsWithInjectedCss = new WeakSet(); +let anyWindowsWithInjectedCss = false; + +// Add cleanup handler for CSS injected into windows by Heartbeat +CleanupManager.addCleanupHandler(() => { + if (anyWindowsWithInjectedCss) { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (windowsWithInjectedCss.has(window)) { + const utils = window.windowUtils; + utils.removeSheet(HEARTBEAT_CSS_URI, window.AGENT_SHEET); + windowsWithInjectedCss.delete(window); + } + } + } +}); + +/** + * Show the Heartbeat UI to request user feedback. + * + * @param chromeWindow + * The chrome window that the heartbeat notification is displayed in. + * @param {Object} options Options object. + * @param {String} options.message + * The message, or question, to display on the notification. + * @param {String} options.thanksMessage + * The thank you message to display after user votes. + * @param {String} options.flowId + * An identifier for this rating flow. Please note that this is only used to + * identify the notification box. + * @param {String} [options.engagementButtonLabel=null] + * The text of the engagement button to use instead of stars. If this is null + * or invalid, rating stars are used. + * @param {String} [options.learnMoreMessage=null] + * The label of the learn more link. No link will be shown if this is null. + * @param {String} [options.learnMoreUrl=null] + * The learn more URL to open when clicking on the learn more link. No learn more + * will be shown if this is an invalid URL. + * @param {String} [options.surveyId] + * An ID for the survey, reflected in the Telemetry ping. + * @param {Number} [options.surveyVersion] + * Survey's version number, reflected in the Telemetry ping. + * @param {boolean} [options.testing] + * Whether this is a test survey, reflected in the Telemetry ping. + * @param {String} [options.postAnswerURL=null] + * The url to visit after the user answers the question. + */ +export var Heartbeat = class { + constructor(chromeWindow, options) { + if (typeof options.flowId !== "string") { + throw new Error( + `flowId must be a string, but got ${JSON.stringify( + options.flowId + )}, a ${typeof options.flowId}` + ); + } + + if (!options.flowId) { + throw new Error("flowId must not be an empty string"); + } + + if (typeof options.message !== "string") { + throw new Error( + `message must be a string, but got ${JSON.stringify( + options.message + )}, a ${typeof options.message}` + ); + } + + if (!options.message) { + throw new Error("message must not be an empty string"); + } + + if (options.postAnswerUrl) { + options.postAnswerUrl = new URL(options.postAnswerUrl); + } else { + options.postAnswerUrl = null; + } + + if (options.learnMoreUrl) { + try { + options.learnMoreUrl = new URL(options.learnMoreUrl); + } catch (e) { + options.learnMoreUrl = null; + } + } + + this.chromeWindow = chromeWindow; + this.eventEmitter = new EventEmitter(); + this.options = options; + this.surveyResults = {}; + this.buttons = []; + + if (!windowsWithInjectedCss.has(chromeWindow)) { + windowsWithInjectedCss.add(chromeWindow); + const utils = chromeWindow.windowUtils; + utils.loadSheet(HEARTBEAT_CSS_URI, chromeWindow.AGENT_SHEET); + anyWindowsWithInjectedCss = true; + } + + // so event handlers are consistent + this.handleWindowClosed = this.handleWindowClosed.bind(this); + this.close = this.close.bind(this); + + // Add Learn More Link + if (this.options.learnMoreMessage && this.options.learnMoreUrl) { + this.buttons.push({ + link: this.options.learnMoreUrl.toString(), + label: this.options.learnMoreMessage, + callback: () => { + this.maybeNotifyHeartbeat("LearnMore"); + return true; + }, + }); + } + + if (this.options.engagementButtonLabel) { + this.buttons.push({ + label: this.options.engagementButtonLabel, + callback: () => { + // Let the consumer know user engaged. + this.maybeNotifyHeartbeat("Engaged"); + + this.userEngaged({ + type: "button", + flowId: this.options.flowId, + }); + + // Return true so that the notification bar doesn't close itself since + // we have a thank you message to show. + return true; + }, + }); + } + + this.notificationBox = this.chromeWindow.gNotificationBox; + this.notice = this.notificationBox.appendNotification( + "heartbeat-" + this.options.flowId, + { + label: this.options.message, + image: "resource://normandy/skin/shared/heartbeat-icon.svg", + priority: this.notificationBox.PRIORITY_SYSTEM, + eventCallback: eventType => { + if (eventType !== "removed") { + return; + } + this.maybeNotifyHeartbeat("NotificationClosed"); + }, + }, + this.buttons + ); + this.notice.classList.add("heartbeat"); + this.notice.messageText.classList.add("heartbeat"); + + // Build the heartbeat stars + if (!this.options.engagementButtonLabel) { + const numStars = this.options.engagementButtonLabel ? 0 : 5; + this.ratingContainer = this.chromeWindow.document.createElement("span"); + this.ratingContainer.id = "star-rating-container"; + + for (let i = 0; i < numStars; i++) { + // create a star rating element + const ratingElement = + this.chromeWindow.document.createXULElement("toolbarbutton"); + + // style it + const starIndex = numStars - i; + ratingElement.className = "plain star-x"; + ratingElement.id = "star" + starIndex; + ratingElement.setAttribute("data-score", starIndex); + + // Add the click handler + ratingElement.addEventListener("click", ev => { + const rating = parseInt(ev.target.getAttribute("data-score")); + this.maybeNotifyHeartbeat("Voted", { score: rating }); + this.userEngaged({ + type: "stars", + score: rating, + flowId: this.options.flowId, + }); + }); + + this.ratingContainer.appendChild(ratingElement); + } + + this.notice.buttonContainer.append(this.ratingContainer); + } + + // Let the consumer know the notification was shown. + this.maybeNotifyHeartbeat("NotificationOffered"); + this.chromeWindow.addEventListener( + "SSWindowClosing", + this.handleWindowClosed + ); + + const surveyDuration = Preferences.get(PREF_SURVEY_DURATION, 300) * 1000; + this.surveyEndTimer = setTimeout(() => { + this.maybeNotifyHeartbeat("SurveyExpired"); + this.close(); + }, surveyDuration); + + CleanupManager.addCleanupHandler(this.close); + } + + maybeNotifyHeartbeat(name, data = {}) { + if (this.pingSent) { + log.warn( + "Heartbeat event received after Telemetry ping sent. name:", + name, + "data:", + data + ); + return; + } + + const timestamp = Date.now(); + let sendPing = false; + let cleanup = false; + + const phases = { + NotificationOffered: () => { + this.surveyResults.flowId = this.options.flowId; + this.surveyResults.offeredTS = timestamp; + }, + LearnMore: () => { + if (!this.surveyResults.learnMoreTS) { + this.surveyResults.learnMoreTS = timestamp; + } + }, + Engaged: () => { + this.surveyResults.engagedTS = timestamp; + }, + Voted: () => { + this.surveyResults.votedTS = timestamp; + this.surveyResults.score = data.score; + }, + SurveyExpired: () => { + this.surveyResults.expiredTS = timestamp; + }, + NotificationClosed: () => { + this.surveyResults.closedTS = timestamp; + cleanup = true; + sendPing = true; + }, + WindowClosed: () => { + this.surveyResults.windowClosedTS = timestamp; + cleanup = true; + sendPing = true; + }, + default: () => { + log.error("Unrecognized Heartbeat event:", name); + }, + }; + + (phases[name] || phases.default)(); + + data.timestamp = timestamp; + data.flowId = this.options.flowId; + this.eventEmitter.emit(name, data); + + if (sendPing) { + // Send the ping to Telemetry + const payload = Object.assign({ version: 1 }, this.surveyResults); + for (const meta of ["surveyId", "surveyVersion", "testing"]) { + if (this.options.hasOwnProperty(meta)) { + payload[meta] = this.options[meta]; + } + } + + log.debug("Sending telemetry"); + TelemetryController.submitExternalPing("heartbeat", payload, { + addClientId: true, + addEnvironment: true, + }); + + // only for testing + this.eventEmitter.emit("TelemetrySent", payload); + + // Survey is complete, clear out the expiry timer & survey configuration + this.endTimerIfPresent("surveyEndTimer"); + + this.pingSent = true; + this.surveyResults = null; + } + + if (cleanup) { + this.cleanup(); + } + } + + userEngaged(engagementParams) { + // Make the heartbeat icon pulse twice + this.notice.label = this.options.thanksMessage; + this.notice.messageImage.classList.remove("pulse-onshow"); + this.notice.messageImage.classList.add("pulse-twice"); + + // Remove the custom contents of the notice and the buttons + if (this.ratingContainer) { + this.ratingContainer.remove(); + } + for (let button of this.notice.buttonContainer.querySelectorAll("button")) { + button.remove(); + } + + // Open the engagement tab if we have a valid engagement URL. + if (this.options.postAnswerUrl) { + for (const key in engagementParams) { + this.options.postAnswerUrl.searchParams.append( + key, + engagementParams[key] + ); + } + // Open the engagement URL in a new tab. + let { gBrowser } = this.chromeWindow; + gBrowser.selectedTab = gBrowser.addWebTab( + this.options.postAnswerUrl.toString(), + { + triggeringPrincipal: + Services.scriptSecurityManager.createNullPrincipal({}), + } + ); + } + + this.endTimerIfPresent("surveyEndTimer"); + + this.engagementCloseTimer = setTimeout( + () => this.close(), + NOTIFICATION_TIME + ); + } + + endTimerIfPresent(timerName) { + if (this[timerName]) { + clearTimeout(this[timerName]); + this[timerName] = null; + } + } + + handleWindowClosed() { + this.maybeNotifyHeartbeat("WindowClosed"); + } + + close() { + this.notificationBox.removeNotification(this.notice); + } + + cleanup() { + // Kill the timers which might call things after we've cleaned up: + this.endTimerIfPresent("surveyEndTimer"); + this.endTimerIfPresent("engagementCloseTimer"); + // remove listeners + this.chromeWindow.removeEventListener( + "SSWindowClosing", + this.handleWindowClosed + ); + // remove references for garbage collection + this.chromeWindow = null; + this.notificationBox = null; + this.notice = null; + this.ratingContainer = null; + this.eventEmitter = null; + // Ensure we don't re-enter and release the CleanupManager's reference to us: + CleanupManager.removeCleanupHandler(this.close); + } +}; diff --git a/toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs b/toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs new file mode 100644 index 0000000000..501c9f70af --- /dev/null +++ b/toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs @@ -0,0 +1,48 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +const FEATURE_ID = "legacyHeartbeat"; + +/** + * A bridge between Nimbus and Normandy's Heartbeat implementation. + */ +export const LegacyHeartbeat = { + getHeartbeatRecipe() { + const survey = lazy.NimbusFeatures.legacyHeartbeat.getVariable("survey"); + + if (typeof survey == "undefined") { + return null; + } + + let isRollout = false; + let enrollmentData = lazy.ExperimentAPI.getExperimentMetaData({ + featureId: FEATURE_ID, + }); + + if (!enrollmentData) { + enrollmentData = lazy.ExperimentAPI.getRolloutMetaData({ + featureId: FEATURE_ID, + }); + isRollout = true; + } + + return { + id: `nimbus:${enrollmentData.slug}`, + name: `Nimbus legacyHeartbeat ${isRollout ? "rollout" : "experiment"} ${ + enrollmentData.slug + }`, + action: "show-heartbeat", + arguments: survey, + capabilities: ["action.show-heartbeat"], + filter_expression: "true", + use_only_baseline_capabilities: true, + }; + }, +}; diff --git a/toolkit/components/normandy/lib/LogManager.sys.mjs b/toolkit/components/normandy/lib/LogManager.sys.mjs new file mode 100644 index 0000000000..9c1fd0f8fa --- /dev/null +++ b/toolkit/components/normandy/lib/LogManager.sys.mjs @@ -0,0 +1,34 @@ +/* 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 { Log } from "resource://gre/modules/Log.sys.mjs"; + +const ROOT_LOGGER_NAME = "app.normandy"; +let rootLogger = null; + +export var LogManager = { + /** + * Configure the root logger for the Recipe Client. Must be called at + * least once before using any loggers created via getLogger. + * @param {Number} loggingLevel + * Logging level to use as defined in Log.sys.mjs + */ + configure(loggingLevel) { + if (!rootLogger) { + rootLogger = Log.repository.getLogger(ROOT_LOGGER_NAME); + rootLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); + } + rootLogger.level = loggingLevel; + }, + + /** + * Obtain a named logger with the recipe client logger as its parent. + * @param {String} name + * Name of the logger to obtain. + * @return {Logger} + */ + getLogger(name) { + return Log.repository.getLogger(`${ROOT_LOGGER_NAME}.${name}`); + }, +}; diff --git a/toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs b/toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs new file mode 100644 index 0000000000..cf57662b2d --- /dev/null +++ b/toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs @@ -0,0 +1,112 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", +}); + +export const NormandyAddonManager = { + async downloadAndInstall({ + createError, + extensionDetails, + applyNormandyChanges, + undoNormandyChanges, + onInstallStarted, + reportError, + }) { + const { extension_id, hash, hash_algorithm, version, xpi } = + extensionDetails; + + const downloadDeferred = lazy.PromiseUtils.defer(); + const installDeferred = lazy.PromiseUtils.defer(); + + const install = await lazy.AddonManager.getInstallForURL(xpi, { + hash: `${hash_algorithm}:${hash}`, + telemetryInfo: { source: "internal" }, + }); + + const listener = { + onInstallStarted(cbInstall) { + const versionMatches = cbInstall.addon.version === version; + const idMatches = cbInstall.addon.id === extension_id; + + if (!versionMatches || !idMatches) { + installDeferred.reject(createError("metadata-mismatch")); + return false; // cancel the installation, server metadata does not match downloaded add-on + } + + if (onInstallStarted) { + return onInstallStarted(cbInstall, installDeferred); + } + + return true; + }, + + onDownloadFailed() { + downloadDeferred.reject( + createError("download-failure", { + detail: lazy.AddonManager.errorToString(install.error), + }) + ); + }, + + onDownloadEnded() { + downloadDeferred.resolve(); + return false; // temporarily pause installation for Normandy bookkeeping + }, + + onInstallFailed() { + installDeferred.reject( + createError("install-failure", { + detail: lazy.AddonManager.errorToString(install.error), + }) + ); + }, + + onInstallEnded() { + installDeferred.resolve(); + }, + }; + + install.addListener(listener); + + // Download the add-on + try { + install.install(); + await downloadDeferred.promise; + } catch (err) { + reportError(err); + install.removeListener(listener); + throw err; + } + + // Complete any book-keeping + try { + await applyNormandyChanges(install); + } catch (err) { + reportError(err); + install.removeListener(listener); + install.cancel(); + throw err; + } + + // Finish paused installation + try { + install.install(); + await installDeferred.promise; + } catch (err) { + reportError(err); + install.removeListener(listener); + await undoNormandyChanges(); + throw err; + } + + install.removeListener(listener); + + return [install.addon.id, install.addon.version]; + }, +}; diff --git a/toolkit/components/normandy/lib/NormandyApi.sys.mjs b/toolkit/components/normandy/lib/NormandyApi.sys.mjs new file mode 100644 index 0000000000..cbb1af4bb3 --- /dev/null +++ b/toolkit/components/normandy/lib/NormandyApi.sys.mjs @@ -0,0 +1,157 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "CanonicalJSON", + "resource://gre/modules/CanonicalJSON.jsm" +); + +const prefs = Services.prefs.getBranch("app.normandy."); + +let indexPromise = null; + +function getChainRootIdentifier() { + const normandy_url = Services.prefs.getCharPref("app.normandy.api_url"); + if (normandy_url == "https://normandy.cdn.mozilla.net/api/v1") { + return Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot; + } + if (normandy_url.includes("stage.")) { + return Ci.nsIContentSignatureVerifier.ContentSignatureStageRoot; + } + if (normandy_url.includes("dev.")) { + return Ci.nsIContentSignatureVerifier.ContentSignatureDevRoot; + } + if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + return Ci.nsIX509CertDB.AppXPCShellRoot; + } + return Ci.nsIContentSignatureVerifier.ContentSignatureLocalRoot; +} + +export var NormandyApi = { + InvalidSignatureError: class InvalidSignatureError extends Error {}, + + clearIndexCache() { + indexPromise = null; + }, + + get(endpoint, data) { + const url = new URL(endpoint); + if (data) { + for (const key of Object.keys(data)) { + url.searchParams.set(key, data[key]); + } + } + return fetch(url.href, { + method: "get", + headers: { Accept: "application/json" }, + credentials: "omit", + }); + }, + + absolutify(url) { + if (url.startsWith("http")) { + return url; + } + const apiBase = prefs.getCharPref("api_url"); + const server = new URL(apiBase).origin; + if (url.startsWith("/")) { + return server + url; + } + throw new Error("Can't use relative urls"); + }, + + async getApiUrl(name) { + if (!indexPromise) { + const apiBase = new URL(prefs.getCharPref("api_url")); + if (!apiBase.pathname.endsWith("/")) { + apiBase.pathname += "/"; + } + indexPromise = this.get(apiBase.toString()).then(res => res.json()); + } + const index = await indexPromise; + if (!(name in index)) { + throw new Error(`API endpoint with name "${name}" not found.`); + } + const url = index[name]; + return this.absolutify(url); + }, + + /** + * Verify content signature, by serializing the specified `object` as + * canonical JSON, and using the Normandy signer verifier to check that + * it matches the signature specified in `signaturePayload`. + * + * If the the signature is not valid, an error is thrown. Otherwise this + * function returns undefined. + * + * @param {object|String} data The object (or string) to be checked + * @param {object} signaturePayload The signature information + * @param {String} signaturePayload.x5u The certificate chain URL + * @param {String} signaturePayload.signature base64 signature bytes + * @param {String} type The object type (eg. `"recipe"`, `"action"`) + * @returns {Promise<undefined>} If the signature is valid, this function returns without error + * @throws {NormandyApi.InvalidSignatureError} if signature is invalid. + */ + async verifyObjectSignature(data, signaturePayload, type) { + const { signature, x5u } = signaturePayload; + const certChainResponse = await this.get(this.absolutify(x5u)); + const certChain = await certChainResponse.text(); + const builtSignature = `p384ecdsa=${signature}`; + + const serialized = + typeof data == "string" ? data : lazy.CanonicalJSON.stringify(data); + + const verifier = Cc[ + "@mozilla.org/security/contentsignatureverifier;1" + ].createInstance(Ci.nsIContentSignatureVerifier); + + let valid; + try { + valid = await verifier.asyncVerifyContentSignature( + serialized, + builtSignature, + certChain, + "normandy.content-signature.mozilla.org", + getChainRootIdentifier() + ); + } catch (err) { + throw new NormandyApi.InvalidSignatureError( + `${type} signature validation failed: ${err}` + ); + } + + if (!valid) { + throw new NormandyApi.InvalidSignatureError( + `${type} signature is not valid` + ); + } + }, + + /** + * Fetch metadata about this client determined by the server. + * @return {object} Metadata specified by the server + */ + async classifyClient() { + const classifyClientUrl = await this.getApiUrl("classify-client"); + const response = await this.get(classifyClientUrl); + const clientData = await response.json(); + clientData.request_time = new Date(clientData.request_time); + return clientData; + }, + + /** + * Fetch details for an extension from the server. + * @param extensionId {integer} The ID of the extension to look up + * @resolves {Object} + */ + async fetchExtensionDetails(extensionId) { + const baseUrl = await this.getApiUrl("extension-list"); + const extensionDetailsUrl = `${baseUrl}${extensionId}/`; + const response = await this.get(extensionDetailsUrl); + return response.json(); + }, +}; diff --git a/toolkit/components/normandy/lib/NormandyUtils.sys.mjs b/toolkit/components/normandy/lib/NormandyUtils.sys.mjs new file mode 100644 index 0000000000..7db12c59b7 --- /dev/null +++ b/toolkit/components/normandy/lib/NormandyUtils.sys.mjs @@ -0,0 +1,10 @@ +/* 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/. */ + +export var NormandyUtils = { + generateUuid() { + // Generate a random UUID, convert it to a string, and slice the braces off the ends. + return Services.uuid.generateUUID().toString().slice(1, -1); + }, +}; diff --git a/toolkit/components/normandy/lib/PrefUtils.sys.mjs b/toolkit/components/normandy/lib/PrefUtils.sys.mjs new file mode 100644 index 0000000000..445744d88f --- /dev/null +++ b/toolkit/components/normandy/lib/PrefUtils.sys.mjs @@ -0,0 +1,132 @@ +/* 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 = {}; +ChromeUtils.defineESModuleGetters(lazy, { + LogManager: "resource://normandy/lib/LogManager.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LogManager.getLogger("preference-experiments"); +}); + +const kPrefBranches = { + user: Services.prefs, + default: Services.prefs.getDefaultBranch(""), +}; + +export var PrefUtils = { + /** + * Get a preference of any type from the named branch. + * @param {string} pref + * @param {object} [options] + * @param {"default"|"user"} [options.branchName="user"] One of "default" or "user" + * @param {string|boolean|integer|null} [options.defaultValue] + * The value to return if the preference does not exist. Defaults to null. + */ + getPref(pref, { branch = "user", defaultValue = null } = {}) { + const branchObj = kPrefBranches[branch]; + if (!branchObj) { + throw new this.UnexpectedPreferenceBranch( + `"${branch}" is not a valid preference branch` + ); + } + const type = branchObj.getPrefType(pref); + + try { + switch (type) { + case Services.prefs.PREF_BOOL: { + return branchObj.getBoolPref(pref); + } + case Services.prefs.PREF_STRING: { + return branchObj.getStringPref(pref); + } + case Services.prefs.PREF_INT: { + return branchObj.getIntPref(pref); + } + case Services.prefs.PREF_INVALID: { + return defaultValue; + } + } + } catch (e) { + if (branch === "default" && e.result === Cr.NS_ERROR_UNEXPECTED) { + // There is a value for the pref on the user branch but not on the default branch. This is ok. + return defaultValue; + } + // Unexpected error, re-throw it + throw e; + } + + // If `type` isn't any of the above, throw an error. Don't do this in a + // default branch of switch so that error handling is easier. + throw new TypeError(`Unknown preference type (${type}) for ${pref}.`); + }, + + /** + * Set a preference on the named branch + * @param {string} pref + * @param {string|boolean|integer|null} value The value to set. + * @param {object} options + * @param {"user"|"default"} options.branchName The branch to make the change on. + */ + setPref(pref, value, { branch = "user" } = {}) { + if (value === null) { + this.clearPref(pref, { branch }); + return; + } + const branchObj = kPrefBranches[branch]; + if (!branchObj) { + throw new this.UnexpectedPreferenceBranch( + `"${branch}" is not a valid preference branch` + ); + } + switch (typeof value) { + case "boolean": { + branchObj.setBoolPref(pref, value); + break; + } + case "string": { + branchObj.setStringPref(pref, value); + break; + } + case "number": { + branchObj.setIntPref(pref, value); + break; + } + default: { + throw new TypeError( + `Unexpected value type (${typeof value}) for ${pref}.` + ); + } + } + }, + + /** + * Remove a preference from a branch. Note that default branch preferences + * cannot effectively be cleared. If "default" is passed for a branch, an + * error will be logged and nothing else will happen. + * + * @param {string} pref + * @param {object} options + * @param {"user"|"default"} options.branchName The branch to clear + */ + clearPref(pref, { branch = "user" } = {}) { + if (branch === "user") { + kPrefBranches.user.clearUserPref(pref); + } else if (branch === "default") { + lazy.log.warn( + `Cannot reset pref ${pref} on the default branch. Pref will be cleared at next restart.` + ); + } else { + throw new this.UnexpectedPreferenceBranch( + `"${branch}" is not a valid preference branch` + ); + } + }, + + UnexpectedPreferenceType: class extends Error {}, + UnexpectedPreferenceBranch: class extends Error {}, +}; diff --git a/toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs b/toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs new file mode 100644 index 0000000000..0c96745df9 --- /dev/null +++ b/toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs @@ -0,0 +1,1069 @@ +/* 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/. */ + +/** + * Preference Experiments temporarily change a preference to one of several test + * values for the duration of the experiment. Telemetry packets are annotated to + * show what experiments are active, and we use this data to measure the + * effectiveness of the preference change. + * + * Info on active and past experiments is stored in a JSON file in the profile + * folder. + * + * Active preference experiments are stopped if they aren't active on the recipe + * server. They also expire if Firefox isn't able to contact the recipe server + * after a period of time, as well as if the user modifies the preference during + * an active experiment. + */ + +/** + * Experiments store info about an active or expired preference experiment. + * @typedef {Object} Experiment + * @property {string} slug + * A string uniquely identifying the experiment. Used for telemetry, and other + * machine-oriented use cases. Used as a display name if `userFacingName` is + * null. + * @property {string|null} userFacingName + * A user-friendly name for the experiment. Null on old-style single-preference + * experiments, which do not have a userFacingName. + * @property {string|null} userFacingDescription + * A user-friendly description of the experiment. Null on old-style + * single-preference experiments, which do not have a userFacingDescription. + * @property {string} branch + * Experiment branch that the user was matched to + * @property {boolean} expired + * If false, the experiment is active. + * ISO-formatted date string of when the experiment was last seen from the + * recipe server. + * @property {string|null} temporaryErrorDeadline + * ISO-formatted date string of when temporary errors with this experiment + * should not longer be considered temporary. After this point, further errors + * will result in unenrollment. + * @property {Object} preferences + * An object consisting of all the preferences that are set by this experiment. + * Keys are the name of each preference affected by this experiment. Values are + * Preference Objects, about which see below. + * @property {string} experimentType + * The type to report to Telemetry's experiment marker API. + * @property {string} enrollmentId + * A random ID generated at time of enrollment. It should be included on all + * telemetry related to this experiment. It should not be re-used by other + * studies, or any other purpose. May be null on old experiments. + * @property {string} actionName + * The action who knows about this experiment and is responsible for cleaning + * it up. This should correspond to the `name` of some BaseAction subclass. + */ + +/** + * Each Preference stores information about a preference that an + * experiment sets. + * @property {string|integer|boolean} preferenceValue + * Value to change the preference to during the experiment. + * @property {string} preferenceType + * Type of the preference value being set. + * @property {string|integer|boolean|undefined} previousPreferenceValue + * Value of the preference prior to the experiment, or undefined if it was + * unset. + * @property {PreferenceBranchType} preferenceBranchType + * Controls how we modify the preference to affect the client. + * + * If "default", when the experiment is active, the default value for the + * preference is modified on startup of the add-on. If "user", the user value + * for the preference is modified when the experiment starts, and is reset to + * its original value when the experiment ends. + * @property {boolean} overridden + * Tracks if this preference has been changed away from the experimental value. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { CleanupManager } from "resource://normandy/lib/CleanupManager.sys.mjs"; +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +const EXPERIMENT_FILE = "shield-preference-experiments.json"; +const STARTUP_EXPERIMENT_PREFS_BRANCH = "app.normandy.startupExperimentPrefs."; + +const MAX_EXPERIMENT_TYPE_LENGTH = 20; // enforced by TelemetryEnvironment +const EXPERIMENT_TYPE_PREFIX = "normandy-"; +const MAX_EXPERIMENT_SUBTYPE_LENGTH = + MAX_EXPERIMENT_TYPE_LENGTH - EXPERIMENT_TYPE_PREFIX.length; + +const PREFERENCE_TYPE_MAP = { + boolean: Services.prefs.PREF_BOOL, + string: Services.prefs.PREF_STRING, + integer: Services.prefs.PREF_INT, +}; + +const UserPreferences = Services.prefs; +const DefaultPreferences = Services.prefs.getDefaultBranch(""); + +/** + * Enum storing Preference modules for each type of preference branch. + * @enum {Object} + */ +const PreferenceBranchType = { + user: UserPreferences, + default: DefaultPreferences, +}; + +/** + * Asynchronously load the JSON file that stores experiment status in the profile. + */ +let gStorePromise; +function ensureStorage() { + if (gStorePromise === undefined) { + const path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + EXPERIMENT_FILE + ); + const storage = new lazy.JSONFile({ path }); + // `storage.load()` is defined as being infallible: It won't ever throw an + // error. However, if there are are I/O errors, such as a corrupt, missing, + // or unreadable file the data loaded will be an empty object. This can + // happen ever after our migrations have run. If that happens, edit the + // storage to match our expected schema before returning it to the rest of + // the module. + gStorePromise = storage.load().then(() => { + if (!storage.data.experiments) { + storage.data = { ...storage.data, experiments: {} }; + } + return storage; + }); + } + return gStorePromise; +} + +const log = LogManager.getLogger("preference-experiments"); + +// List of active preference observers. Cleaned up on shutdown. +let experimentObservers = new Map(); +CleanupManager.addCleanupHandler(() => + PreferenceExperiments.stopAllObservers() +); + +export var PreferenceExperiments = { + /** + * Update the the experiment storage with changes that happened during early startup. + * @param {object} studyPrefsChanged Map from pref name to previous pref value + */ + async recordOriginalValues(studyPrefsChanged) { + const store = await ensureStorage(); + + for (const experiment of Object.values(store.data.experiments)) { + for (const [prefName, prefInfo] of Object.entries( + experiment.preferences + )) { + if (studyPrefsChanged.hasOwnProperty(prefName)) { + if (experiment.expired) { + log.warn( + "Expired preference experiment changed value during startup" + ); + } + if (prefInfo.preferenceBranch !== "default") { + log.warn( + "Non-default branch preference experiment changed value during startup" + ); + } + prefInfo.previousPreferenceValue = studyPrefsChanged[prefName]; + } + } + } + + // not calling store.saveSoon() because if the data doesn't get + // written, it will get updated with fresher data next time the + // browser starts. + }, + + /** + * Set the default preference value for active experiments that use the + * default preference branch. + */ + async init() { + CleanupManager.addCleanupHandler(() => this.saveStartupPrefs()); + + for (const experiment of await this.getAllActive()) { + // Check that the current value of the preference is still what we set it to + for (const [preferenceName, spec] of Object.entries( + experiment.preferences + )) { + if ( + !spec.overridden && + lazy.PrefUtils.getPref(preferenceName) !== spec.preferenceValue + ) { + // if not, record the difference + await this.recordPrefChange({ + experiment, + preferenceName, + reason: "sideload", + }); + } + } + + // Notify Telemetry of experiments we're running, since they don't persist between restarts + lazy.TelemetryEnvironment.setExperimentActive( + experiment.slug, + experiment.branch, + { + type: EXPERIMENT_TYPE_PREFIX + experiment.experimentType, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + + // Watch for changes to the experiment's preference + this.startObserver(experiment.slug, experiment.preferences); + } + }, + + /** + * Save in-progress, default-branch preference experiments in a sub-branch of + * the normandy preferences. On startup, we read these to set the + * experimental values. + * + * This is needed because the default branch does not persist between Firefox + * restarts. To compensate for that, Normandy sets the default branch to the + * experiment values again every startup. The values to set the preferences + * to are stored in user-branch preferences because preferences have minimal + * impact on the performance of startup. + */ + async saveStartupPrefs() { + const prefBranch = Services.prefs.getBranch( + STARTUP_EXPERIMENT_PREFS_BRANCH + ); + for (const pref of prefBranch.getChildList("")) { + prefBranch.clearUserPref(pref); + } + + // Only store prefs to set on the default branch. + // Be careful not to store user branch prefs here, because this + // would cause the default branch to match the user branch, + // causing the user branch pref to get cleared. + const allExperiments = await this.getAllActive(); + const defaultBranchPrefs = allExperiments + .flatMap(exp => Object.entries(exp.preferences)) + .filter( + ([preferenceName, preferenceInfo]) => + preferenceInfo.preferenceBranchType === "default" + ); + for (const [preferenceName, { preferenceValue }] of defaultBranchPrefs) { + switch (typeof preferenceValue) { + case "string": + prefBranch.setCharPref(preferenceName, preferenceValue); + break; + + case "number": + prefBranch.setIntPref(preferenceName, preferenceValue); + break; + + case "boolean": + prefBranch.setBoolPref(preferenceName, preferenceValue); + break; + + default: + throw new Error(`Invalid preference type ${typeof preferenceValue}`); + } + } + }, + + /** + * Test wrapper that temporarily replaces the stored experiment data with fake + * data for testing. + */ + withMockExperiments(prefExperiments = []) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const experiments = {}; + + for (const exp of prefExperiments) { + if (exp.name) { + throw new Error( + "Preference experiments 'name' field has been replaced by 'slug' and 'userFacingName', please update." + ); + } + + experiments[exp.slug] = exp; + } + const data = { experiments }; + + const oldPromise = gStorePromise; + gStorePromise = Promise.resolve({ + data, + saveSoon() {}, + }); + const oldObservers = experimentObservers; + experimentObservers = new Map(); + try { + await testFunction({ ...args, prefExperiments }); + } finally { + gStorePromise = oldPromise; + PreferenceExperiments.stopAllObservers(); + experimentObservers = oldObservers; + } + }; + }; + }, + + /** When Telemetry is disabled, clear all identifiers from the stored experiments. */ + async onTelemetryDisabled() { + const store = await ensureStorage(); + for (const experiment of Object.values(store.data.experiments)) { + experiment.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER; + } + store.saveSoon(); + }, + + /** + * Clear all stored data about active and past experiments. + */ + async clearAllExperimentStorage() { + const store = await ensureStorage(); + store.data = { + experiments: {}, + }; + store.saveSoon(); + }, + + /** + * Start a new preference experiment. + * @param {Object} experiment + * @param {string} experiment.slug + * @param {string} experiment.actionName The action who knows about this + * experiment and is responsible for cleaning it up. This should + * correspond to the name of some BaseAction subclass. + * @param {string} experiment.branch + * @param {string} experiment.preferenceName + * @param {string|integer|boolean} experiment.preferenceValue + * @param {PreferenceBranchType} experiment.preferenceBranchType + * @returns {Experiment} The experiment object stored in the data store + * @rejects {Error} + * - If an experiment with the given name already exists + * - if an experiment for the given preference is active + * - If the given preferenceType does not match the existing stored preference + */ + async start({ + name = null, // To check if old code is still using `name` instead of `slug`, and provide a nice error message + slug, + actionName, + branch, + preferences, + experimentType = "exp", + userFacingName = null, + userFacingDescription = null, + }) { + if (name) { + throw new Error( + "Preference experiments 'name' field has been replaced by 'slug' and 'userFacingName', please update." + ); + } + + log.debug(`PreferenceExperiments.start(${slug}, ${branch})`); + + const store = await ensureStorage(); + if (slug in store.data.experiments) { + lazy.TelemetryEvents.sendEvent("enrollFailed", "preference_study", slug, { + reason: "name-conflict", + }); + throw new Error( + `A preference experiment with the slug "${slug}" already exists.` + ); + } + + const activeExperiments = Object.values(store.data.experiments).filter( + e => !e.expired + ); + const preferencesWithConflicts = Object.keys(preferences).filter( + preferenceName => { + return activeExperiments.some(e => + e.preferences.hasOwnProperty(preferenceName) + ); + } + ); + + if (preferencesWithConflicts.length) { + lazy.TelemetryEvents.sendEvent("enrollFailed", "preference_study", slug, { + reason: "pref-conflict", + }); + throw new Error( + `Another preference experiment for the pref "${preferencesWithConflicts[0]}" is currently active.` + ); + } + + if (experimentType.length > MAX_EXPERIMENT_SUBTYPE_LENGTH) { + lazy.TelemetryEvents.sendEvent("enrollFailed", "preference_study", slug, { + reason: "experiment-type-too-long", + }); + throw new Error( + `experimentType must be less than ${MAX_EXPERIMENT_SUBTYPE_LENGTH} characters. ` + + `"${experimentType}" is ${experimentType.length} long.` + ); + } + + // Sanity check each preference + for (const [preferenceName, preferenceInfo] of Object.entries( + preferences + )) { + // Ensure preferenceBranchType is set, using the default from + // the schema. This also modifies the preferenceInfo for use in + // the rest of the function. + preferenceInfo.preferenceBranchType = + preferenceInfo.preferenceBranchType || "default"; + const { preferenceBranchType, preferenceType } = preferenceInfo; + if ( + !(preferenceBranchType === "user" || preferenceBranchType === "default") + ) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_study", + slug, + { + reason: "invalid-branch", + prefBranch: preferenceBranchType.slice(0, 80), + } + ); + throw new Error( + `Invalid value for preferenceBranchType: ${preferenceBranchType}` + ); + } + + const prevPrefType = Services.prefs.getPrefType(preferenceName); + const givenPrefType = PREFERENCE_TYPE_MAP[preferenceType]; + + if (!preferenceType || !givenPrefType) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_study", + slug, + { + reason: "invalid-type", + } + ); + throw new Error( + `Invalid preferenceType provided (given "${preferenceType}")` + ); + } + + if ( + prevPrefType !== Services.prefs.PREF_INVALID && + prevPrefType !== givenPrefType + ) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_study", + slug, + { + reason: "invalid-type", + } + ); + throw new Error( + `Previous preference value is of type "${prevPrefType}", but was given ` + + `"${givenPrefType}" (${preferenceType})` + ); + } + + preferenceInfo.previousPreferenceValue = lazy.PrefUtils.getPref( + preferenceName, + { branch: preferenceBranchType } + ); + } + + const alreadyOverriddenPrefs = new Set(); + for (const [preferenceName, preferenceInfo] of Object.entries( + preferences + )) { + const { preferenceValue, preferenceBranchType } = preferenceInfo; + + if (preferenceBranchType === "default") { + // Only set the pref if there is no user-branch value, because + // changing the default-branch value to the same value as the + // user-branch will effectively delete the user value. + if (Services.prefs.prefHasUserValue(preferenceName)) { + alreadyOverriddenPrefs.add(preferenceName); + } else { + lazy.PrefUtils.setPref(preferenceName, preferenceValue, { + branch: preferenceBranchType, + }); + } + } else if (preferenceBranchType === "user") { + // The original value was already backed up above. + lazy.PrefUtils.setPref(preferenceName, preferenceValue, { + branch: preferenceBranchType, + }); + } else { + log.error(`Unexpected preference branch type ${preferenceBranchType}`); + } + } + PreferenceExperiments.startObserver(slug, preferences); + + const enrollmentId = lazy.NormandyUtils.generateUuid(); + + /** @type {Experiment} */ + const experiment = { + slug, + actionName, + branch, + expired: false, + lastSeen: new Date().toJSON(), + preferences, + experimentType, + userFacingName, + userFacingDescription, + enrollmentId, + }; + + store.data.experiments[slug] = experiment; + store.saveSoon(); + + // Record telemetry that the experiment started + lazy.TelemetryEnvironment.setExperimentActive(slug, branch, { + type: EXPERIMENT_TYPE_PREFIX + experimentType, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + lazy.TelemetryEvents.sendEvent("enroll", "preference_study", slug, { + experimentType, + branch, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + + // Send events for any default branch preferences set that already had user + // values overriding them. + for (const preferenceName of alreadyOverriddenPrefs) { + await this.recordPrefChange({ + experiment, + preferenceName, + reason: "onEnroll", + }); + } + await this.saveStartupPrefs(); + + return experiment; + }, + + /** + * Register a preference observer that stops an experiment when the user + * modifies the preference. + * @param {string} experimentSlug + * @param {string} preferenceName + * @param {string|integer|boolean} preferenceValue + * @throws {Error} + * If an observer for the experiment is already active. + */ + startObserver(experimentSlug, preferences) { + log.debug(`PreferenceExperiments.startObserver(${experimentSlug})`); + + if (experimentObservers.has(experimentSlug)) { + throw new Error( + `An observer for the preference experiment ${experimentSlug} is already active.` + ); + } + + const observerInfo = { + preferences, + observe(aSubject, aTopic, preferenceName) { + const prefInfo = preferences[preferenceName]; + // if `preferenceName` is one of the experiment prefs but with more on + // the end (ie, foo.bar vs foo.bar.baz) then this can be triggered for + // changes we don't care about. Check for that. + if (!prefInfo) { + return; + } + const originalValue = prefInfo.preferenceValue; + const newValue = lazy.PrefUtils.getPref(preferenceName); + if (newValue !== originalValue) { + PreferenceExperiments.recordPrefChange({ + experimentSlug, + preferenceName, + reason: "observer", + }); + Services.prefs.removeObserver(preferenceName, observerInfo); + } + }, + }; + experimentObservers.set(experimentSlug, observerInfo); + for (const [preferenceName, spec] of Object.entries(preferences)) { + if (!spec.overridden) { + Services.prefs.addObserver(preferenceName, observerInfo); + } + } + }, + + /** + * Check if a preference observer is active for an experiment. + * @param {string} experimentSlug + * @return {Boolean} + */ + hasObserver(experimentSlug) { + log.debug(`PreferenceExperiments.hasObserver(${experimentSlug})`); + return experimentObservers.has(experimentSlug); + }, + + /** + * Disable a preference observer for an experiment. + * @param {string} experimentSlug + * @throws {Error} + * If there is no active observer for the experiment. + */ + stopObserver(experimentSlug) { + log.debug(`PreferenceExperiments.stopObserver(${experimentSlug})`); + + if (!experimentObservers.has(experimentSlug)) { + throw new Error( + `No observer for the preference experiment ${experimentSlug} found.` + ); + } + + const observer = experimentObservers.get(experimentSlug); + for (const preferenceName of Object.keys(observer.preferences)) { + Services.prefs.removeObserver(preferenceName, observer); + } + experimentObservers.delete(experimentSlug); + }, + + /** + * Disable all currently-active preference observers for experiments. + */ + stopAllObservers() { + log.debug("PreferenceExperiments.stopAllObservers()"); + for (const observer of experimentObservers.values()) { + for (const preferenceName of Object.keys(observer.preferences)) { + Services.prefs.removeObserver(preferenceName, observer); + } + } + experimentObservers.clear(); + }, + + /** + * Update the timestamp storing when Normandy last sent a recipe for the + * experiment. + * @param {string} experimentSlug + * @rejects {Error} + * If there is no stored experiment with the given slug. + */ + async markLastSeen(experimentSlug) { + log.debug(`PreferenceExperiments.markLastSeen(${experimentSlug})`); + + const store = await ensureStorage(); + if (!(experimentSlug in store.data.experiments)) { + throw new Error( + `Could not find a preference experiment with the slug "${experimentSlug}"` + ); + } + + store.data.experiments[experimentSlug].lastSeen = new Date().toJSON(); + store.saveSoon(); + }, + + /** + * Called when an experimental pref has changed away from its experimental + * value for the first time. + * + * One of `experiment` or `slug` must be passed. + * + * @param {object} options + * @param {Experiment} [options.experiment] + * The experiment that had a pref change. If this is passed, slug is ignored. + * @param {string} [options.slug] + * The slug of the experiment that had a pref change. This will be used to + * fetch an experiment if none was passed. + * @param {string} options.preferenceName The preference changed. + * @param {string} options.reason The reason the preference change was detected. + */ + async recordPrefChange({ + experiment = null, + experimentSlug = null, + preferenceName, + reason, + }) { + if (!experiment) { + experiment = await PreferenceExperiments.get(experimentSlug); + } + let preferenceSpecification = experiment.preferences[preferenceName]; + if (!preferenceSpecification) { + throw new PreferenceExperiments.InvalidPreferenceName( + `Preference "${preferenceName}" is not a part of experiment "${experimentSlug}"` + ); + } + + preferenceSpecification.overridden = true; + await this.update(experiment); + + lazy.TelemetryEvents.sendEvent( + "expPrefChanged", + "preference_study", + experiment.slug, + { + preferenceName, + reason, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + }, + + /** + * Stop an active experiment, deactivate preference watchers, and optionally + * reset the associated preference to its previous value. + * @param {string} experimentSlug + * @param {Object} options + * @param {boolean} [options.resetValue = true] + * If true, reset the preference to its original value prior to + * the experiment. Optional, defaults to true. + * @param {String} [options.reason = "unknown"] + * Reason that the experiment is ending. Optional, defaults to + * "unknown". + * @rejects {Error} + * If there is no stored experiment with the given slug, or if the + * experiment has already expired. + */ + async stop( + experimentSlug, + { resetValue = true, reason = "unknown", changedPref, caller } = {} + ) { + log.debug( + `PreferenceExperiments.stop(${experimentSlug}, {resetValue: ${resetValue}, reason: ${reason}, changedPref: ${changedPref}, caller: ${caller}})` + ); + if (reason === "unknown") { + log.warn(`experiment ${experimentSlug} ending for unknown reason`); + } + + const store = await ensureStorage(); + if (!(experimentSlug in store.data.experiments)) { + lazy.TelemetryEvents.sendEvent( + "unenrollFailed", + "preference_study", + experimentSlug, + { + reason: "does-not-exist", + originalReason: reason, + ...(changedPref ? { changedPref } : {}), + } + ); + throw new Error( + `Could not find a preference experiment with the slug "${experimentSlug}"` + ); + } + + const experiment = store.data.experiments[experimentSlug]; + if (experiment.expired) { + const extra = { + reason: "already-unenrolled", + originalReason: reason, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }; + if (changedPref) { + extra.changedPref = changedPref; + } + if (caller && AppConstants.NIGHTLY_BUILD) { + extra.caller = caller; + } + lazy.TelemetryEvents.sendEvent( + "unenrollFailed", + "preference_study", + experimentSlug, + extra + ); + throw new Error( + `Cannot stop preference experiment "${experimentSlug}" because it is already expired` + ); + } + + if (PreferenceExperiments.hasObserver(experimentSlug)) { + PreferenceExperiments.stopObserver(experimentSlug); + } + + if (resetValue) { + for (const [ + preferenceName, + { previousPreferenceValue, preferenceBranchType, overridden }, + ] of Object.entries(experiment.preferences)) { + // Overridden user prefs should keep their new value, even if that value + // is the same as the experimental value, since it is the value the user + // chose. + if (overridden && preferenceBranchType === "user") { + continue; + } + + const preferences = PreferenceBranchType[preferenceBranchType]; + + if (previousPreferenceValue !== null) { + lazy.PrefUtils.setPref(preferenceName, previousPreferenceValue, { + branch: preferenceBranchType, + }); + } else if (preferenceBranchType === "user") { + // Remove the "user set" value (which Shield set), but leave the default intact. + preferences.clearUserPref(preferenceName); + } else { + log.warn( + `Can't revert pref ${preferenceName} for experiment ${experimentSlug} ` + + `because it had no default value. ` + + `Preference will be reset at the next restart.` + ); + // It would seem that Services.prefs.deleteBranch() could be used for + // this, but in Normandy's case it does not work. See bug 1502410. + } + } + } + + experiment.expired = true; + if (experiment.temporaryErrorDeadline) { + experiment.temporaryErrorDeadline = null; + } + await store.saveSoon(); + + lazy.TelemetryEnvironment.setExperimentInactive(experimentSlug); + lazy.TelemetryEvents.sendEvent( + "unenroll", + "preference_study", + experimentSlug, + { + didResetValue: resetValue ? "true" : "false", + branch: experiment.branch, + reason, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + ...(changedPref ? { changedPref } : {}), + } + ); + await this.saveStartupPrefs(); + Services.obs.notifyObservers( + null, + "normandy:preference-experiment:stopped", + experimentSlug + ); + }, + + /** + * Clone an experiment using knowledge of its structure to avoid + * having to serialize/deserialize it. + * + * We do this in places where return experiments so clients can't + * accidentally mutate our data underneath us. + */ + _cloneExperiment(experiment) { + return { + ...experiment, + preferences: { + ...experiment.preferences, + }, + }; + }, + + /** + * Get the experiment object for the experiment. + * @param {string} experimentSlug + * @resolves {Experiment} + * @rejects {Error} + * If no preference experiment exists with the given slug. + */ + async get(experimentSlug) { + log.debug(`PreferenceExperiments.get(${experimentSlug})`); + const store = await ensureStorage(); + if (!(experimentSlug in store.data.experiments)) { + throw new PreferenceExperiments.NotFoundError( + `Could not find a preference experiment with the slug "${experimentSlug}"` + ); + } + + return this._cloneExperiment(store.data.experiments[experimentSlug]); + }, + + /** + * Get a list of all stored experiment objects. + * @resolves {Experiment[]} + */ + async getAll() { + const store = await ensureStorage(); + return Object.values(store.data.experiments).map(experiment => + this._cloneExperiment(experiment) + ); + }, + + /** + * Get a list of experiment objects for all active experiments. + * @resolves {Experiment[]} + */ + async getAllActive() { + const store = await ensureStorage(); + return Object.values(store.data.experiments) + .filter(e => !e.expired) + .map(e => this._cloneExperiment(e)); + }, + + /** + * Check if an experiment exists with the given slug. + * @param {string} experimentSlug + * @resolves {boolean} True if the experiment exists, false if it doesn't. + */ + async has(experimentSlug) { + log.debug(`PreferenceExperiments.has(${experimentSlug})`); + const store = await ensureStorage(); + return experimentSlug in store.data.experiments; + }, + + /** + * Update an experiment in the data store. If an experiment with the given + * slug is not already in the store, an error will be thrown. + * + * @param experiment {Experiment} The experiment to update + * @param experiment.slug {String} The experiment must have a slug + */ + async update(experiment) { + const store = await ensureStorage(); + + if (!(experiment.slug in store.data.experiments)) { + throw new Error( + `Could not update a preference experiment with the slug "${experiment.slug}"` + ); + } + + store.data.experiments[experiment.slug] = experiment; + store.saveSoon(); + }, + + NotFoundError: class extends Error {}, + InvalidPreferenceName: class extends Error {}, + + /** + * These migrations should only be called from `NormandyMigrations.jsm` and tests. + */ + migrations: { + /** Move experiments into a specific key. */ + async migration01MoveExperiments(storage = null) { + if (storage === null) { + storage = await ensureStorage(); + } + if (Object.hasOwnProperty.call(storage.data, "experiments")) { + return; + } + storage.data = { + experiments: storage.data, + }; + delete storage.data.experiments.__version; + storage.saveSoon(); + }, + + /** Migrate storage.data to multi-preference format */ + async migration02MultiPreference(storage = null) { + if (storage === null) { + storage = await ensureStorage(); + } + + const oldExperiments = storage.data.experiments; + const v2Experiments = {}; + + for (let [expName, oldExperiment] of Object.entries(oldExperiments)) { + if (expName == "__version") { + // A stray "__version" entry snuck in, likely from old migrations. + // Ignore it and continue. It won't be propagated to future + // migrations, since `v2Experiments` won't have it. + continue; + } + if (oldExperiment.preferences) { + // experiment is already migrated + v2Experiments[expName] = oldExperiment; + continue; + } + v2Experiments[expName] = { + name: oldExperiment.name, + branch: oldExperiment.branch, + expired: oldExperiment.expired, + lastSeen: oldExperiment.lastSeen, + preferences: { + [oldExperiment.preferenceName]: { + preferenceBranchType: oldExperiment.preferenceBranchType, + preferenceType: oldExperiment.preferenceType, + preferenceValue: oldExperiment.preferenceValue, + previousPreferenceValue: oldExperiment.previousPreferenceValue, + }, + }, + experimentType: oldExperiment.experimentType, + }; + } + storage.data.experiments = v2Experiments; + storage.saveSoon(); + }, + + /** Add "actionName" field for experiments that don't have it. */ + async migration03AddActionName(storage = null) { + if (storage === null) { + storage = await ensureStorage(); + } + + for (const experiment of Object.values(storage.data.experiments)) { + if (!experiment.actionName) { + // Assume SinglePreferenceExperimentAction because as of this + // writing, no multi-pref experiment recipe has launched. + experiment.actionName = "SinglePreferenceExperimentAction"; + } + } + storage.saveSoon(); + }, + + async migration04RenameNameToSlug(storage = null) { + if (!storage) { + storage = await ensureStorage(); + } + // Rename "name" to "slug" to match the intended purpose of the field. + for (const experiment of Object.values(storage.data.experiments)) { + if (experiment.name && !experiment.slug) { + experiment.slug = experiment.name; + delete experiment.name; + } + } + storage.saveSoon(); + }, + + async migration05RemoveOldAction() { + const experiments = await PreferenceExperiments.getAllActive(); + for (const experiment of experiments) { + if (experiment.actionName == "SinglePreferenceExperimentAction") { + try { + await PreferenceExperiments.stop(experiment.slug, { + resetValue: true, + reason: "migration-removing-single-pref-action", + caller: "migration05RemoveOldAction", + }); + } catch (e) { + log.error( + `Stopping preference experiment ${experiment.slug} during migration failed: ${e}` + ); + } + } + } + }, + + async migration06TrackOverriddenPrefs(storage = null) { + if (!storage) { + storage = await ensureStorage(); + } + for (const experiment of Object.values(storage.data.experiments)) { + for (const [preferenceName, specification] of Object.entries( + experiment.preferences + )) { + if (specification.overridden !== undefined) { + continue; + } + specification.overridden = + lazy.PrefUtils.getPref(preferenceName) !== + specification.preferenceValue; + } + } + storage.saveSoon(); + }, + }, +}; diff --git a/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs b/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs new file mode 100644 index 0000000000..52204d2fa5 --- /dev/null +++ b/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs @@ -0,0 +1,350 @@ +/* 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 { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +const log = LogManager.getLogger("recipe-runner"); + +/** + * PreferenceRollouts store info about an active or expired preference rollout. + * @typedef {object} PreferenceRollout + * @property {string} slug + * Unique slug of the experiment + * @property {string} state + * The current state of the rollout: "active", "rolled-back", "graduated". + * Active means that Normandy is actively managing therollout. Rolled-back + * means that the rollout was previously active, but has been rolled back for + * this user. Graduated means that the built-in default now matches the + * rollout value, and so Normandy is no longer managing the preference. + * @property {Array<PreferenceSpec>} preferences + * An array of preferences specifications involved in the rollout. + * @property {string} enrollmentId + * A random ID generated at time of enrollment. It should be included on all + * telemetry related to this rollout. It should not be re-used by other + * rollouts, or any other purpose. May be null on old rollouts. + */ + +/** + * PreferenceSpec describe how a preference should change during a rollout. + * @typedef {object} PreferenceSpec + * @property {string} preferenceName + * The preference to modify. + * @property {string} preferenceType + * Type of the preference being set. + * @property {string|integer|boolean} value + * The value to change the preference to. + * @property {string|integer|boolean} previousValue + * The value the preference would have on the default branch if this rollout + * were not active. + */ + +const STARTUP_PREFS_BRANCH = "app.normandy.startupRolloutPrefs."; +const DB_NAME = "normandy-preference-rollout"; +const STORE_NAME = "preference-rollouts"; +const DB_VERSION = 1; + +/** + * Create a new connection to the database. + */ +function openDatabase() { + return lazy.IndexedDB.open(DB_NAME, DB_VERSION, db => { + db.createObjectStore(STORE_NAME, { + keyPath: "slug", + }); + }); +} + +/** + * Cache the database connection so that it is shared among multiple operations. + */ +let databasePromise; +function getDatabase() { + if (!databasePromise) { + databasePromise = openDatabase(); + } + return databasePromise; +} + +/** + * Get a transaction for interacting with the rollout store. + * + * @param {IDBDatabase} db + * @param {String} mode Either "readonly" or "readwrite" + * + * NOTE: Methods on the store returned by this function MUST be called + * synchronously, otherwise the transaction with the store will expire. + * This is why the helper takes a database as an argument; if we fetched the + * database in the helper directly, the helper would be async and the + * transaction would expire before methods on the store were called. + */ +function getStore(db, mode) { + if (!mode) { + throw new Error("mode is required"); + } + return db.objectStore(STORE_NAME, mode); +} + +export var PreferenceRollouts = { + STATE_ACTIVE: "active", + STATE_ROLLED_BACK: "rolled-back", + STATE_GRADUATED: "graduated", + + // A set of rollout slugs that are obsolete based on the code in this build of + // Firefox. This may include things like the preference no longer being + // applicable, or the feature changing in such a way that Normandy's automatic + // graduation system cannot detect that the rollout should hand off to the + // built-in code. + GRADUATION_SET: new Set([ + "pref-webrender-intel-rollout-70-release", + "bug-1703186-rollout-http3-support-release-88-89", + "rollout-doh-nightly-rollout-to-all-us-desktop-users-nightly-74-80-bug-1613481", + "rollout-doh-beta-rollout-to-all-us-desktop-users-v2-beta-74-80-bug-1613489", + "rollout-doh-us-staged-rollout-to-all-us-desktop-users-release-73-77-bug-1586331", + "bug-1648229-rollout-comcast-steering-rollout-release-78-80", + "bug-1732206-rollout-fission-release-rollout-release-94-95", + "bug-1745237-rollout-fission-beta-96-97-rollout-beta-96-97", + "bug-1750601-rollout-doh-steering-in-canada-staggered-starting-for-release-97-98", + "bug-1758988-rollout-doh-enablment-to-new-countries-staggered-st-release-98-100", + "bug-1758818-rollout-enabling-doh-in-new-countries-staggered-sta-release-98-100", + ]), + + /** + * Update the rollout database with changes that happened during early startup. + * @param {object} rolloutPrefsChanged Map from pref name to previous pref value + */ + async recordOriginalValues(originalPreferences) { + for (const rollout of await this.getAllActive()) { + let shouldSaveRollout = false; + + // Count the number of preferences in this rollout that are now redundant. + let prefMatchingDefaultCount = 0; + + for (const prefSpec of rollout.preferences) { + const builtInDefault = originalPreferences[prefSpec.preferenceName]; + if (prefSpec.value === builtInDefault) { + prefMatchingDefaultCount++; + } + // Store the current built-in default. That way, if the preference is + // rolled back during the current session (ie, until the browser is + // shut down), the correct value will be used. + if (prefSpec.previousValue !== builtInDefault) { + prefSpec.previousValue = builtInDefault; + shouldSaveRollout = true; + } + } + + if (prefMatchingDefaultCount === rollout.preferences.length) { + // Firefox's builtin defaults have caught up to the rollout, making all + // of the rollout's changes redundant, so graduate the rollout. + await this.graduate(rollout, "all-prefs-match"); + // `this.graduate` writes the rollout to the db, so we don't need to do it anymore. + shouldSaveRollout = false; + } + + if (shouldSaveRollout) { + const db = await getDatabase(); + await getStore(db, "readwrite").put(rollout); + } + } + }, + + async init() { + lazy.CleanupManager.addCleanupHandler(() => this.saveStartupPrefs()); + + for (const rollout of await this.getAllActive()) { + if (this.GRADUATION_SET.has(rollout.slug)) { + await this.graduate(rollout, "in-graduation-set"); + continue; + } + lazy.TelemetryEnvironment.setExperimentActive( + rollout.slug, + rollout.state, + { + type: "normandy-prefrollout", + enrollmentId: + rollout.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + } + }, + + /** When Telemetry is disabled, clear all identifiers from the stored rollouts. */ + async onTelemetryDisabled() { + const rollouts = await this.getAll(); + for (const rollout of rollouts) { + rollout.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER; + } + await this.updateMany(rollouts); + }, + + /** + * Test wrapper that temporarily replaces the stored rollout data with fake + * data for testing. + */ + withTestMock({ + graduationSet = new Set(), + rollouts: prefRollouts = [], + } = {}) { + return testFunction => { + return async args => { + let db = await getDatabase(); + const oldData = await getStore(db, "readonly").getAll(); + await getStore(db, "readwrite").clear(); + await Promise.all(prefRollouts.map(r => this.add(r))); + const oldGraduationSet = this.GRADUATION_SET; + this.GRADUATION_SET = graduationSet; + + try { + await testFunction({ ...args, prefRollouts }); + } finally { + this.GRADUATION_SET = oldGraduationSet; + db = await getDatabase(); + await getStore(db, "readwrite").clear(); + const store = getStore(db, "readwrite"); + await Promise.all(oldData.map(d => store.add(d))); + } + }; + }; + }, + + /** + * Add a new rollout + * @param {PreferenceRollout} rollout + */ + async add(rollout) { + if (!rollout.enrollmentId) { + throw new Error("Rollout must have an enrollment ID"); + } + const db = await getDatabase(); + return getStore(db, "readwrite").add(rollout); + }, + + /** + * Update an existing rollout + * @param {PreferenceRollout} rollout + * @throws If a matching rollout does not exist. + */ + async update(rollout) { + if (!(await this.has(rollout.slug))) { + throw new Error( + `Tried to update ${rollout.slug}, but it doesn't already exist.` + ); + } + const db = await getDatabase(); + return getStore(db, "readwrite").put(rollout); + }, + + /** + * Update many existing rollouts. More efficient than calling `update` many + * times in a row. + * @param {Array<PreferenceRollout>} rollouts + * @throws If any of the passed rollouts have a slug that doesn't exist in the database already. + */ + async updateMany(rollouts) { + // Don't touch the database if there is nothing to do + if (!rollouts.length) { + return; + } + + // Both of the below operations use .map() instead of a normal loop becaues + // once we get the object store, we can't let it expire by spinning the + // event loop. This approach queues up all the interactions with the store + // immediately, preventing it from expiring too soon. + + const db = await getDatabase(); + let store = await getStore(db, "readonly"); + await Promise.all( + rollouts.map(async ({ slug }) => { + let existingRollout = await store.get(slug); + if (!existingRollout) { + throw new Error(`Tried to update ${slug}, but it doesn't exist.`); + } + }) + ); + + // awaiting spun the event loop, so the store is now invalid. Get a new + // store. This is also a chance to get it in readwrite mode. + store = await getStore(db, "readwrite"); + await Promise.all(rollouts.map(rollout => store.put(rollout))); + }, + + /** + * Test whether there is a rollout in storage with the given slug. + * @param {string} slug + * @returns {boolean} + */ + async has(slug) { + const db = await getDatabase(); + const rollout = await getStore(db, "readonly").get(slug); + return !!rollout; + }, + + /** + * Get a rollout by slug + * @param {string} slug + */ + async get(slug) { + const db = await getDatabase(); + return getStore(db, "readonly").get(slug); + }, + + /** Get all rollouts in the database. */ + async getAll() { + const db = await getDatabase(); + return getStore(db, "readonly").getAll(); + }, + + /** Get all rollouts in the "active" state. */ + async getAllActive() { + const rollouts = await this.getAll(); + return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE); + }, + + /** + * Save in-progress preference rollouts in a sub-branch of the normandy prefs. + * On startup, we read these to set the rollout values. + */ + async saveStartupPrefs() { + const prefBranch = Services.prefs.getBranch(STARTUP_PREFS_BRANCH); + for (const pref of prefBranch.getChildList("")) { + prefBranch.clearUserPref(pref); + } + + for (const rollout of await this.getAllActive()) { + for (const prefSpec of rollout.preferences) { + lazy.PrefUtils.setPref( + STARTUP_PREFS_BRANCH + prefSpec.preferenceName, + prefSpec.value + ); + } + } + }, + + async graduate(rollout, reason) { + log.debug(`Graduating rollout: ${rollout.slug}`); + rollout.state = this.STATE_GRADUATED; + const db = await getDatabase(); + await getStore(db, "readwrite").put(rollout); + lazy.TelemetryEvents.sendEvent( + "graduate", + "preference_rollout", + rollout.slug, + { + reason, + enrollmentId: + rollout.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + }, +}; diff --git a/toolkit/components/normandy/lib/RecipeRunner.sys.mjs b/toolkit/components/normandy/lib/RecipeRunner.sys.mjs new file mode 100644 index 0000000000..ac55328a01 --- /dev/null +++ b/toolkit/components/normandy/lib/RecipeRunner.sys.mjs @@ -0,0 +1,645 @@ +/* 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"; +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; +import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "timerManager", + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager" +); + +ChromeUtils.defineESModuleGetters(lazy, { + ActionsManager: "resource://normandy/lib/ActionsManager.sys.mjs", + BaseAction: "resource://normandy/actions/BaseAction.sys.mjs", + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + FilterExpressions: + "resource://gre/modules/components-utils/FilterExpressions.sys.mjs", + LegacyHeartbeat: "resource://normandy/lib/LegacyHeartbeat.sys.mjs", + Normandy: "resource://normandy/Normandy.sys.mjs", + NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + RemoteSettingsClient: + "resource://services-settings/RemoteSettingsClient.sys.mjs", + Storage: "resource://normandy/lib/Storage.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", + Uptake: "resource://normandy/lib/Uptake.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const log = LogManager.getLogger("recipe-runner"); +const TIMER_NAME = "recipe-client-addon-run"; +const REMOTE_SETTINGS_COLLECTION = "normandy-recipes-capabilities"; +const PREF_CHANGED_TOPIC = "nsPref:changed"; + +const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds"; +const FIRST_RUN_PREF = "app.normandy.first_run"; +const SHIELD_ENABLED_PREF = "app.normandy.enabled"; +const DEV_MODE_PREF = "app.normandy.dev_mode"; +const API_URL_PREF = "app.normandy.api_url"; +const LAZY_CLASSIFY_PREF = "app.normandy.experiments.lazy_classify"; +const ONSYNC_SKEW_SEC_PREF = "app.normandy.onsync_skew_sec"; + +// Timer last update preference. +// see https://searchfox.org/mozilla-central/rev/11cfa0462/toolkit/components/timermanager/UpdateTimerManager.jsm#8 +const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`; + +const PREFS_TO_WATCH = [RUN_INTERVAL_PREF, SHIELD_ENABLED_PREF, API_URL_PREF]; + +XPCOMUtils.defineLazyGetter(lazy, "gRemoteSettingsClient", () => { + return lazy.RemoteSettings(REMOTE_SETTINGS_COLLECTION); +}); + +/** + * cacheProxy returns an object Proxy that will memoize properties of the target. + */ +function cacheProxy(target) { + const cache = new Map(); + return new Proxy(target, { + get(target, prop, receiver) { + if (!cache.has(prop)) { + cache.set(prop, target[prop]); + } + return cache.get(prop); + }, + set(target, prop, value, receiver) { + cache.set(prop, value); + return true; + }, + has(target, prop) { + return cache.has(prop) || prop in target; + }, + }); +} + +export var RecipeRunner = { + initializedPromise: PromiseUtils.defer(), + + async init() { + this.running = false; + this.enabled = null; + this.loadFromRemoteSettings = false; + this._syncSkewTimeout = null; + + this.checkPrefs(); // sets this.enabled + this.watchPrefs(); + this.setUpRemoteSettings(); + + // Here "first run" means the first run this profile has ever done. This + // preference is set to true at the end of this function, and never reset to + // false. + const firstRun = Services.prefs.getBoolPref(FIRST_RUN_PREF, true); + + // If we've seen a build ID from a previous run that doesn't match the + // current build ID, run immediately. This is probably an upgrade or + // downgrade, which may cause recipe eligibility to change. + let hasNewBuildID = + Services.appinfo.lastAppBuildID != null && + Services.appinfo.lastAppBuildID != Services.appinfo.appBuildID; + + // Dev mode is a mode used for development and QA that bypasses the normal + // timer function of Normandy, to make testing more convenient. + const devMode = Services.prefs.getBoolPref(DEV_MODE_PREF, false); + + if (this.enabled && (devMode || firstRun || hasNewBuildID)) { + // In dev mode, if remote settings is enabled, force an immediate sync + // before running. This ensures that the latest data is used for testing. + // This is not needed for the first run case, because remote settings + // already handles empty collections well. + if (devMode) { + await lazy.gRemoteSettingsClient.sync(); + } + let trigger; + if (devMode) { + trigger = "devMode"; + } else if (firstRun) { + trigger = "firstRun"; + } else if (hasNewBuildID) { + trigger = "newBuildID"; + } + + await this.run({ trigger }); + } + + // Update the firstRun pref, to indicate that Normandy has run at least once + // on this profile. + if (firstRun) { + Services.prefs.setBoolPref(FIRST_RUN_PREF, false); + } + + this.initializedPromise.resolve(); + }, + + enable() { + if (this.enabled) { + return; + } + this.registerTimer(); + this.enabled = true; + }, + + disable() { + if (this.enabled) { + this.unregisterTimer(); + } + // this.enabled may be null, so always set it to false + this.enabled = false; + }, + + /** Watch for prefs to change, and call this.observer when they do */ + watchPrefs() { + for (const pref of PREFS_TO_WATCH) { + Services.prefs.addObserver(pref, this); + } + + lazy.CleanupManager.addCleanupHandler(this.unwatchPrefs.bind(this)); + }, + + unwatchPrefs() { + for (const pref of PREFS_TO_WATCH) { + Services.prefs.removeObserver(pref, this); + } + }, + + /** When prefs change, this is fired */ + observe(subject, topic, data) { + switch (topic) { + case PREF_CHANGED_TOPIC: { + const prefName = data; + + switch (prefName) { + case RUN_INTERVAL_PREF: + this.updateRunInterval(); + break; + + // explicit fall-through + case SHIELD_ENABLED_PREF: + case API_URL_PREF: + this.checkPrefs(); + break; + + default: + log.debug( + `Observer fired with unexpected pref change: ${prefName}` + ); + } + + break; + } + } + }, + + checkPrefs() { + if (!Services.prefs.getBoolPref(SHIELD_ENABLED_PREF)) { + log.debug( + `Disabling Shield because ${SHIELD_ENABLED_PREF} is set to false` + ); + this.disable(); + return; + } + + const apiUrl = Services.prefs.getCharPref(API_URL_PREF); + if (!apiUrl) { + log.warn(`Disabling Shield because ${API_URL_PREF} is not set.`); + this.disable(); + return; + } + if (!apiUrl.startsWith("https://")) { + log.warn( + `Disabling Shield because ${API_URL_PREF} is not an HTTPS url: ${apiUrl}.` + ); + this.disable(); + return; + } + + log.debug(`Enabling Shield`); + this.enable(); + }, + + registerTimer() { + this.updateRunInterval(); + lazy.CleanupManager.addCleanupHandler(() => + lazy.timerManager.unregisterTimer(TIMER_NAME) + ); + }, + + unregisterTimer() { + lazy.timerManager.unregisterTimer(TIMER_NAME); + }, + + setUpRemoteSettings() { + if (this._alreadySetUpRemoteSettings) { + return; + } + this._alreadySetUpRemoteSettings = true; + + if (!this._onSync) { + this._onSync = this.onSync.bind(this); + } + lazy.gRemoteSettingsClient.on("sync", this._onSync); + + lazy.CleanupManager.addCleanupHandler(() => { + lazy.gRemoteSettingsClient.off("sync", this._onSync); + this._alreadySetUpRemoteSettings = false; + }); + }, + + /** Called when our Remote Settings collection is updated */ + async onSync() { + if (!this.enabled) { + return; + } + + // Delay the Normandy run by a random amount, determined by preference. + // This helps alleviate server load, since we don't have a thundering + // herd of users trying to update all at once. + if (this._syncSkewTimeout) { + lazy.clearTimeout(this._syncSkewTimeout); + } + let minSkewSec = 1; // this is primarily is to avoid race conditions in tests + let maxSkewSec = Services.prefs.getIntPref(ONSYNC_SKEW_SEC_PREF, 0); + if (maxSkewSec >= minSkewSec) { + let skewMillis = + (minSkewSec + Math.random() * (maxSkewSec - minSkewSec)) * 1000; + log.debug( + `Delaying on-sync Normandy run for ${Math.floor( + skewMillis / 1000 + )} seconds` + ); + this._syncSkewTimeout = lazy.setTimeout( + () => this.run({ trigger: "sync" }), + skewMillis + ); + } else { + log.debug(`Not skewing on-sync Normandy run`); + await this.run({ trigger: "sync" }); + } + }, + + updateRunInterval() { + // Run once every `runInterval` wall-clock seconds. This is managed by setting a "last ran" + // timestamp, and running if it is more than `runInterval` seconds ago. Even with very short + // intervals, the timer will only fire at most once every few minutes. + const runInterval = Services.prefs.getIntPref(RUN_INTERVAL_PREF); + lazy.timerManager.registerTimer(TIMER_NAME, () => this.run(), runInterval); + }, + + async run({ trigger = "timer" } = {}) { + if (this.running) { + // Do nothing if already running. + return; + } + this.running = true; + + await lazy.Normandy.defaultPrefsHaveBeenApplied.promise; + + try { + this.running = true; + Services.obs.notifyObservers(null, "recipe-runner:start"); + + if (this._syncSkewTimeout) { + lazy.clearTimeout(this._syncSkewTimeout); + this._syncSkewTimeout = null; + } + + this.clearCaches(); + // Unless lazy classification is enabled, prep the classify cache. + if (!Services.prefs.getBoolPref(LAZY_CLASSIFY_PREF, false)) { + try { + await lazy.ClientEnvironment.getClientClassification(); + } catch (err) { + // Try to go on without this data; the filter expressions will + // gracefully fail without this info if they need it. + } + } + + // Fetch recipes before execution in case we fail and exit early. + let recipesAndSignatures; + try { + recipesAndSignatures = await lazy.gRemoteSettingsClient.get({ + // Do not return an empty list if an error occurs. + emptyListFallback: false, + }); + } catch (e) { + await lazy.Uptake.reportRunner(lazy.Uptake.RUNNER_SERVER_ERROR); + return; + } + + const actionsManager = new lazy.ActionsManager(); + + const legacyHeartbeat = lazy.LegacyHeartbeat.getHeartbeatRecipe(); + const noRecipes = + !recipesAndSignatures.length && legacyHeartbeat === null; + + // Execute recipes, if we have any. + if (noRecipes) { + log.debug("No recipes to execute"); + } else { + for (const { recipe, signature } of recipesAndSignatures) { + let suitability = await this.getRecipeSuitability(recipe, signature); + await actionsManager.processRecipe(recipe, suitability); + } + + if (legacyHeartbeat !== null) { + await actionsManager.processRecipe( + legacyHeartbeat, + lazy.BaseAction.suitability.FILTER_MATCH + ); + } + } + + await actionsManager.finalize({ noRecipes }); + + await lazy.Uptake.reportRunner(lazy.Uptake.RUNNER_SUCCESS); + Services.obs.notifyObservers(null, "recipe-runner:end"); + } finally { + this.running = false; + if (trigger != "timer") { + // `run()` was executed outside the scheduled timer. + // Update the last time it ran to make sure it is rescheduled later. + const lastUpdateTime = Math.round(Date.now() / 1000); + Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime); + } + } + }, + + getFilterContext(recipe) { + const environment = cacheProxy(lazy.ClientEnvironment); + environment.recipe = { + id: recipe.id, + arguments: recipe.arguments, + }; + return { + env: environment, + // Backwards compatibility -- see bug 1477255. + normandy: environment, + }; + }, + + /** + * Return the set of capabilities this runner has. + * + * This is used to pre-filter recipes that aren't compatible with this client. + * + * @returns {Set<String>} The capabilities supported by this client. + */ + getCapabilities() { + let capabilities = new Set([ + "capabilities-v1", // The initial version of the capabilities system. + ]); + + // Get capabilities from ActionsManager. + for (const actionCapability of lazy.ActionsManager.getCapabilities()) { + capabilities.add(actionCapability); + } + + // Add a capability for each transform available to JEXL. + for (const transform of lazy.FilterExpressions.getAvailableTransforms()) { + capabilities.add(`jexl.transform.${transform}`); + } + + // Add two capabilities for each top level key available in the context: one + // for the `normandy.` namespace, and another for the `env.` namespace. + capabilities.add("jexl.context.env"); + capabilities.add("jexl.context.normandy"); + let env = lazy.ClientEnvironment; + while (env && env.name) { + // Walk up the class chain for ClientEnvironment, collecting applicable + // properties as we go. Stop when we get to an unnamed object, which is + // usually just a plain function is the super class of a class that doesn't + // extend anything. Also stop if we get to an undefined object, just in + // case. + for (const [name, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(env) + )) { + // All of the properties we are looking for are are static getters (so + // will have a truthy `get` property) and are defined on the class, so + // will be configurable + if (descriptor.configurable && descriptor.get) { + capabilities.add(`jexl.context.env.${name}`); + capabilities.add(`jexl.context.normandy.${name}`); + } + } + // Check for the next parent + env = Object.getPrototypeOf(env); + } + + return capabilities; + }, + + /** + * Decide if a recipe is suitable to run, and returns a value from + * `BaseAction.suitability`. + * + * This checks several things in order: + * - recipe signature + * - capabilities + * - filter expression + * + * If the provided signature does not match the provided recipe, then + * `SIGNATURE_ERROR` is returned. Recipes with this suitability should not be + * trusted. These recipes are included so that temporary signature errors on + * the server can be handled intelligently by actions. + * + * Capabilities are a simple set of strings in the recipe. If the Normandy + * client has all of the capabilities listed, then execution continues. If + * not, then `CAPABILITY_MISMATCH` is returned. Recipes with this suitability + * should be considered incompatible and treated with caution. + * + * If the capabilities check passes, then the filter expression is evaluated + * against the current environment. The result of the expression is cast to a + * boolean. If it is true, then `FILTER_MATCH` is returned. If not, then + * `FILTER_MISMATCH` is returned. + * + * If there is an error while evaluating the recipe's filter, `FILTER_ERROR` + * is returned instead. + * + * @param {object} recipe + * @param {object} signature + * @param {string} recipe.filter_expression The expression to evaluate against the environment. + * @param {Set<String>} runnerCapabilities The capabilities provided by this runner. + * @return {Promise<BaseAction.suitability>} The recipe's suitability + */ + async getRecipeSuitability(recipe, signature) { + let generator = this.getAllSuitabilities(recipe, signature); + // For our purposes, only the first suitability matters, so pull the first + // value out of the async generator. This additionally guarantees if we fail + // a security or compatibility check, we won't continue to run other checks, + // which is good for the general case of running recipes. + let { value: suitability } = await generator.next(); + switch (suitability) { + case lazy.BaseAction.suitability.SIGNATURE_ERROR: { + await lazy.Uptake.reportRecipe( + recipe, + lazy.Uptake.RECIPE_INVALID_SIGNATURE + ); + break; + } + + case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: { + await lazy.Uptake.reportRecipe( + recipe, + lazy.Uptake.RECIPE_INCOMPATIBLE_CAPABILITIES + ); + break; + } + + case lazy.BaseAction.suitability.FILTER_MATCH: { + // No telemetry needs to be sent for this right now. + break; + } + + case lazy.BaseAction.suitability.FILTER_MISMATCH: { + // This represents a terminal state for the given recipe, so + // report its outcome. Others are reported when executed in + // ActionsManager. + await lazy.Uptake.reportRecipe( + recipe, + lazy.Uptake.RECIPE_DIDNT_MATCH_FILTER + ); + break; + } + + case lazy.BaseAction.suitability.FILTER_ERROR: { + await lazy.Uptake.reportRecipe( + recipe, + lazy.Uptake.RECIPE_FILTER_BROKEN + ); + break; + } + + case lazy.BaseAction.suitability.ARGUMENTS_INVALID: { + // This shouldn't ever occur, since the arguments schema is checked by + // BaseAction itself. + throw new Error(`Shouldn't get ${suitability} in RecipeRunner`); + } + + default: { + throw new Error(`Unexpected recipe suitability ${suitability}`); + } + } + + return suitability; + }, + + /** + * Some uses cases, such as Normandy Devtools, want the status of all + * suitabilities, not only the most important one. This checks the cases of + * suitabilities in order from most blocking to least blocking. The first + * yielded is the "primary" suitability to pass on to actions. + * + * If this function yields only [FILTER_MATCH], then the recipe fully matches + * and should be executed. If any other statuses are yielded, then the recipe + * should not be executed as normal. + * + * This is a generator so that the execution can be halted as needed. For + * example, after receiving a signature error, a caller can stop advancing + * the iterator to avoid exposing the browser to unneeded risk. + */ + async *getAllSuitabilities(recipe, signature) { + try { + await lazy.NormandyApi.verifyObjectSignature(recipe, signature, "recipe"); + } catch (e) { + yield lazy.BaseAction.suitability.SIGNATURE_ERROR; + } + + const runnerCapabilities = this.getCapabilities(); + if (Array.isArray(recipe.capabilities)) { + for (const recipeCapability of recipe.capabilities) { + if (!runnerCapabilities.has(recipeCapability)) { + log.debug( + `Recipe "${recipe.name}" requires unknown capabilities. ` + + `Recipe capabilities: ${JSON.stringify(recipe.capabilities)}. ` + + `Local runner capabilities: ${JSON.stringify( + Array.from(runnerCapabilities) + )}` + ); + yield lazy.BaseAction.suitability.CAPABILITIES_MISMATCH; + } + } + } + + const context = this.getFilterContext(recipe); + const targetingContext = new lazy.TargetingContext(); + try { + if (await targetingContext.eval(recipe.filter_expression, context)) { + yield lazy.BaseAction.suitability.FILTER_MATCH; + } else { + yield lazy.BaseAction.suitability.FILTER_MISMATCH; + } + } catch (err) { + log.error( + `Error checking filter for "${recipe.name}". Filter: [${recipe.filter_expression}]. Error: "${err}"` + ); + yield lazy.BaseAction.suitability.FILTER_ERROR; + } + }, + + /** + * Clear all caches of systems used by RecipeRunner, in preparation + * for a clean run. + */ + clearCaches() { + lazy.ClientEnvironment.clearClassifyCache(); + lazy.NormandyApi.clearIndexCache(); + }, + + /** + * Clear out cached state and fetch/execute recipes from the given + * API url. This is used mainly by the mock-recipe-server JS that is + * executed in the browser console. + */ + async testRun(baseApiUrl) { + const oldApiUrl = Services.prefs.getCharPref(API_URL_PREF); + Services.prefs.setCharPref(API_URL_PREF, baseApiUrl); + + try { + lazy.Storage.clearAllStorage(); + this.clearCaches(); + await this.run(); + } finally { + Services.prefs.setCharPref(API_URL_PREF, oldApiUrl); + this.clearCaches(); + } + }, + + /** + * Offer a mechanism to get access to the lazily-instantiated + * gRemoteSettingsClient, because if someone instantiates it + * themselves, it won't have the options we provided in this module, + * and it will prevent instantiation by this module later. + * + * This is only meant to be used in testing, where it is a + * convenient hook to store data in the underlying remote-settings + * collection. + */ + get _remoteSettingsClientForTesting() { + return lazy.gRemoteSettingsClient; + }, + + migrations: { + /** + * Delete the now-unused collection of recipes, since we are using the + * "normandy-recipes-capabilities" collection now. + */ + async migration01RemoveOldRecipesCollection() { + // Don't bother to open IDB and clear on clean profiles. + const lastCheckPref = + "services.settings.main.normandy-recipes.last_check"; + if (Services.prefs.prefHasUserValue(lastCheckPref)) { + // We instantiate a client, but it won't take part of sync. + const client = new lazy.RemoteSettingsClient("normandy-recipes"); + await client.db.clear(); + Services.prefs.clearUserPref(lastCheckPref); + } + }, + }, +}; diff --git a/toolkit/components/normandy/lib/ShieldPreferences.sys.mjs b/toolkit/components/normandy/lib/ShieldPreferences.sys.mjs new file mode 100644 index 0000000000..730febe975 --- /dev/null +++ b/toolkit/components/normandy/lib/ShieldPreferences.sys.mjs @@ -0,0 +1,78 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BranchedAddonStudyAction: + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs", + AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs", + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + PreferenceExperiments: + "resource://normandy/lib/PreferenceExperiments.sys.mjs", +}); + +const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; // from modules/libpref/nsIPrefBranch.idl +const PREF_OPT_OUT_STUDIES_ENABLED = "app.shield.optoutstudies.enabled"; + +/** + * Handles Shield-specific preferences, including their UI. + */ +export var ShieldPreferences = { + init() { + // Watch for changes to the Opt-out pref + Services.prefs.addObserver(PREF_OPT_OUT_STUDIES_ENABLED, this); + + lazy.CleanupManager.addCleanupHandler(() => { + Services.prefs.removeObserver(PREF_OPT_OUT_STUDIES_ENABLED, this); + }); + }, + + observe(subject, topic, data) { + switch (topic) { + case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID: + this.observePrefChange(data); + break; + } + }, + + async observePrefChange(prefName) { + let prefValue; + switch (prefName) { + // If the opt-out pref changes to be false, disable all current studies. + case PREF_OPT_OUT_STUDIES_ENABLED: { + prefValue = Services.prefs.getBoolPref(PREF_OPT_OUT_STUDIES_ENABLED); + if (!prefValue) { + const action = new lazy.BranchedAddonStudyAction(); + const studyPromises = (await lazy.AddonStudies.getAll()).map( + study => { + if (!study.active) { + return null; + } + return action.unenroll(study.recipeId, "general-opt-out"); + } + ); + + const experimentPromises = ( + await lazy.PreferenceExperiments.getAll() + ).map(experiment => { + if (experiment.expired) { + return null; + } + return lazy.PreferenceExperiments.stop(experiment.slug, { + reason: "general-opt-out", + caller: "observePrefChange::general-opt-out", + }); + }); + + const allPromises = studyPromises + .concat(experimentPromises) + .map(p => p && p.catch(err => console.error(err))); + await Promise.all(allPromises); + } + break; + } + } + }, +}; diff --git a/toolkit/components/normandy/lib/Storage.sys.mjs b/toolkit/components/normandy/lib/Storage.sys.mjs new file mode 100644 index 0000000000..8d563db24c --- /dev/null +++ b/toolkit/components/normandy/lib/Storage.sys.mjs @@ -0,0 +1,90 @@ +/* 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 = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", +}); + +// Lazy-load JSON file that backs Storage instances. +XPCOMUtils.defineLazyGetter(lazy, "lazyStore", async function () { + const path = PathUtils.join( + PathUtils.profileDir, + "shield-recipe-client.json" + ); + const store = new lazy.JSONFile({ path }); + await store.load(); + return store; +}); + +export var Storage = class { + constructor(prefix) { + this.prefix = prefix; + } + + /** + * Clear ALL storage data and save to the disk. + */ + static async clearAllStorage() { + const store = await lazy.lazyStore; + store.data = {}; + store.saveSoon(); + } + + /** + * Sets an item in the prefixed storage. + * @returns {Promise} + * @resolves With the stored value, or null. + * @rejects Javascript exception. + */ + async getItem(name) { + const store = await lazy.lazyStore; + const namespace = store.data[this.prefix] || {}; + return namespace[name] || null; + } + + /** + * Sets an item in the prefixed storage. + * @returns {Promise} + * @resolves When the operation is completed successfully + * @rejects Javascript exception. + */ + async setItem(name, value) { + const store = await lazy.lazyStore; + if (!(this.prefix in store.data)) { + store.data[this.prefix] = {}; + } + store.data[this.prefix][name] = value; + store.saveSoon(); + } + + /** + * Removes a single item from the prefixed storage. + * @returns {Promise} + * @resolves When the operation is completed successfully + * @rejects Javascript exception. + */ + async removeItem(name) { + const store = await lazy.lazyStore; + if (this.prefix in store.data) { + delete store.data[this.prefix][name]; + store.saveSoon(); + } + } + + /** + * Clears all storage for the prefix. + * @returns {Promise} + * @resolves When the operation is completed successfully + * @rejects Javascript exception. + */ + async clear() { + const store = await lazy.lazyStore; + store.data[this.prefix] = {}; + store.saveSoon(); + } +}; diff --git a/toolkit/components/normandy/lib/TelemetryEvents.sys.mjs b/toolkit/components/normandy/lib/TelemetryEvents.sys.mjs new file mode 100644 index 0000000000..003b004e26 --- /dev/null +++ b/toolkit/components/normandy/lib/TelemetryEvents.sys.mjs @@ -0,0 +1,30 @@ +/* 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/. */ + +const TELEMETRY_CATEGORY = "normandy"; + +export const TelemetryEvents = { + NO_ENROLLMENT_ID_MARKER: "__NO_ENROLLMENT_ID__", + + init() { + Services.telemetry.setEventRecordingEnabled(TELEMETRY_CATEGORY, true); + }, + + sendEvent(method, object, value, extra) { + for (const val of Object.values(extra)) { + if (val == null) { + throw new Error( + "Extra parameters in telemetry events must not be null" + ); + } + } + Services.telemetry.recordEvent( + TELEMETRY_CATEGORY, + method, + object, + value, + extra + ); + }, +}; diff --git a/toolkit/components/normandy/lib/Uptake.sys.mjs b/toolkit/components/normandy/lib/Uptake.sys.mjs new file mode 100644 index 0000000000..73f3eb2068 --- /dev/null +++ b/toolkit/components/normandy/lib/Uptake.sys.mjs @@ -0,0 +1,67 @@ +/* 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 { UptakeTelemetry } from "resource://services-common/uptake-telemetry.sys.mjs"; + +const COMPONENT = "normandy"; + +export var Uptake = { + // Action uptake + ACTION_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR, + ACTION_PRE_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_1_ERROR, + ACTION_POST_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_2_ERROR, + ACTION_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR, + ACTION_SUCCESS: UptakeTelemetry.STATUS.SUCCESS, + + // Per-recipe uptake + RECIPE_ACTION_DISABLED: UptakeTelemetry.STATUS.CUSTOM_1_ERROR, + RECIPE_DIDNT_MATCH_FILTER: UptakeTelemetry.STATUS.BACKOFF, + RECIPE_INCOMPATIBLE_CAPABILITIES: UptakeTelemetry.STATUS.BACKOFF, + RECIPE_EXECUTION_ERROR: UptakeTelemetry.STATUS.APPLY_ERROR, + RECIPE_FILTER_BROKEN: UptakeTelemetry.STATUS.CONTENT_ERROR, + RECIPE_ARGUMENTS_INVALID: UptakeTelemetry.STATUS.CONTENT_ERROR, + RECIPE_INVALID_ACTION: UptakeTelemetry.STATUS.DOWNLOAD_ERROR, + RECIPE_SUCCESS: UptakeTelemetry.STATUS.SUCCESS, + RECIPE_INVALID_SIGNATURE: UptakeTelemetry.STATUS.SIGNATURE_ERROR, + + // Uptake for the runner as a whole + RUNNER_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR, + RUNNER_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR, + RUNNER_SUCCESS: UptakeTelemetry.STATUS.SUCCESS, + + async _report(status, source) { + // Telemetry doesn't help us much with error detection, so do some here. + if (!status) { + throw new Error( + `Uptake status is required (got "${JSON.stringify(status)}"` + ); + } + if (!source) { + throw new Error( + `Uptake source is required (got "${JSON.stringify(status)}` + ); + } + await UptakeTelemetry.report(COMPONENT, status, { + source: `${COMPONENT}/${source}`, + }); + }, + + async reportRunner(status) { + await Uptake._report(status, "runner"); + }, + + async reportRecipe(recipe, status) { + await Uptake._report(status, `recipe/${recipe.id}`); + const revisionId = parseInt(recipe.revision_id, 10); + Services.telemetry.keyedScalarSet( + "normandy.recipe_freshness", + recipe.id, + revisionId + ); + }, + + async reportAction(actionName, status) { + await Uptake._report(status, `action/${actionName}`); + }, +}; |