mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-04 18:29:29 +02:00
Original Revision: https://phabricator.services.mozilla.com/D217967 Differential Revision: https://phabricator.services.mozilla.com/D217991
407 lines
12 KiB
JavaScript
407 lines
12 KiB
JavaScript
/* 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, {
|
|
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
|
|
PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
|
|
});
|
|
|
|
const FEATURE_ID = "prefFlips";
|
|
export const REASON_PREFFLIPS_FAILED = "prefFlips-failed";
|
|
|
|
export class PrefFlipsFeature {
|
|
#initialized;
|
|
#updating;
|
|
|
|
static get FEATURE_ID() {
|
|
return FEATURE_ID;
|
|
}
|
|
|
|
constructor({ manager }) {
|
|
this.manager = manager;
|
|
this._prefs = new Map();
|
|
|
|
this.#initialized = false;
|
|
this.#updating = false;
|
|
}
|
|
|
|
onFeatureUpdate() {
|
|
if (this.#updating) {
|
|
return;
|
|
}
|
|
|
|
this.#updating = true;
|
|
|
|
const activeEnrollment =
|
|
this.manager.store.getExperimentForFeature(PrefFlipsFeature.FEATURE_ID) ??
|
|
this.manager.store.getRolloutForFeature(PrefFlipsFeature.FEATURE_ID);
|
|
|
|
const prefs = lazy.NimbusFeatures[FEATURE_ID].getVariable("prefs") ?? {};
|
|
|
|
try {
|
|
for (const [pref, details] of this._prefs.entries()) {
|
|
if (Object.hasOwn(prefs, pref)) {
|
|
// The pref may have changed.
|
|
const newDetails = prefs[pref];
|
|
|
|
if (
|
|
newDetails.branch !== details.branch ||
|
|
newDetails.value !== details.value
|
|
) {
|
|
this._updatePref({
|
|
pref,
|
|
branch: newDetails.branch,
|
|
value: newDetails.value,
|
|
slug: activeEnrollment.slug,
|
|
});
|
|
}
|
|
} else {
|
|
// The pref is no longer controlled by us.
|
|
this._unregisterPref(pref);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof PrefFlipsFailedError) {
|
|
this.#updating = false;
|
|
|
|
this._unenrollForFailure(activeEnrollment, e.pref);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
for (const [pref, { branch, value }] of Object.entries(prefs)) {
|
|
const known = this._prefs.get(pref);
|
|
if (known) {
|
|
// We have already processed this pref.
|
|
continue;
|
|
}
|
|
|
|
const setPref = this.manager._prefs.get(pref);
|
|
if (setPref) {
|
|
const toUnenroll = Array.from(setPref.slugs.values()).map(slug =>
|
|
this.manager.store.get(slug)
|
|
);
|
|
|
|
if (toUnenroll.length === 2 && !toUnenroll[0].isRollout) {
|
|
toUnenroll.reverse();
|
|
}
|
|
|
|
for (const enrollment of toUnenroll) {
|
|
this.manager._unenroll(enrollment, {
|
|
reason: "prefFlips-conflict",
|
|
conflictingSlug: activeEnrollment.slug,
|
|
});
|
|
}
|
|
}
|
|
|
|
this._registerPref({
|
|
pref,
|
|
branch,
|
|
value,
|
|
originalValue: lazy.PrefUtils.getPref(pref, { branch }),
|
|
slug: activeEnrollment.slug,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
this.#updating = false;
|
|
|
|
this._unenrollForFailure(activeEnrollment, e.pref);
|
|
return;
|
|
}
|
|
|
|
if (activeEnrollment) {
|
|
// If this is new enrollment, we need to cache the original values of prefs
|
|
// so they can be restored.
|
|
if (!Object.hasOwn(activeEnrollment, "prefFlips")) {
|
|
activeEnrollment.prefFlips = {};
|
|
}
|
|
|
|
activeEnrollment.prefFlips.originalValues = Object.fromEntries(
|
|
Array.from(this._prefs.entries(), ([pref, { originalValue }]) => [
|
|
pref,
|
|
originalValue,
|
|
])
|
|
);
|
|
}
|
|
|
|
this.#updating = false;
|
|
}
|
|
|
|
/**
|
|
* Intialize the prefFlips feature.
|
|
*
|
|
* This will re-hydrate `this._prefs` from the active enrollment (if any) and
|
|
* register any necessary pref observers.
|
|
*
|
|
* onFeatureUpdate will be called for any future feature changes.
|
|
*/
|
|
init() {
|
|
if (this.#initialized) {
|
|
return;
|
|
}
|
|
|
|
const activeEnrollment =
|
|
this.manager.store.getExperimentForFeature(FEATURE_ID) ??
|
|
this.manager.store.getRolloutForFeature(FEATURE_ID);
|
|
|
|
if (activeEnrollment?.prefFlips?.originalValues) {
|
|
const featureValue = activeEnrollment.branch.features.find(
|
|
fc => fc.featureId === FEATURE_ID
|
|
).value;
|
|
try {
|
|
for (const [pref, { branch, value }] of Object.entries(
|
|
featureValue.prefs
|
|
)) {
|
|
this._registerPref({
|
|
pref,
|
|
branch,
|
|
value,
|
|
originalValue: activeEnrollment.prefFlips.originalValues[pref],
|
|
slug: activeEnrollment.slug,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof PrefFlipsFailedError) {
|
|
this._unenrollForFailure(activeEnrollment, e.pref);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
lazy.NimbusFeatures.prefFlips.onUpdate((...args) =>
|
|
this.onFeatureUpdate(...args)
|
|
);
|
|
|
|
this.#initialized = true;
|
|
}
|
|
|
|
_registerPref({ pref, branch, value, originalValue, slug }) {
|
|
const observer = (_aSubject, _aTopic, aData) => {
|
|
// This observer will be called for changes to `name` as well as any
|
|
// other pref that begins with `name.`, so we have to filter to
|
|
// exactly the pref we care about.
|
|
if (aData === pref) {
|
|
this._onPrefChanged(pref);
|
|
}
|
|
};
|
|
|
|
// If we *just* unenrolled a setPref experiment for this pref on the default
|
|
// branch, the pref will only be correctly restored if the pref had a value
|
|
// on the default branch. Otherwise, it will be left as-is until restart.
|
|
// This may result in us computing an incorrect originalValue, but (a) we
|
|
// couldn't correct the problem even if we recorded the correct (i.e., null)
|
|
// value and (b) the issue will resolve itself at next startup. This is
|
|
// consistent with how setPref experiments work.
|
|
const entry = {
|
|
branch,
|
|
originalValue,
|
|
value: value ?? null,
|
|
observer,
|
|
slug,
|
|
};
|
|
|
|
try {
|
|
lazy.PrefUtils.setPref(pref, value ?? null, { branch });
|
|
} catch (e) {
|
|
throw new PrefFlipsFailedError(pref);
|
|
}
|
|
|
|
Services.prefs.addObserver(pref, observer);
|
|
this._prefs.set(pref, entry);
|
|
}
|
|
|
|
_updatePref({ pref, branch, value, slug }) {
|
|
const entry = this._prefs.get(pref);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
Services.prefs.removeObserver(pref, entry.observer);
|
|
|
|
let originalValue = entry.originalValue;
|
|
if (entry.branch !== branch) {
|
|
// Restore the value on the previous branch.
|
|
//
|
|
// Because we were able to set the pref, it must have the same type as the
|
|
// originalValue, so this will also succeed.
|
|
lazy.PrefUtils.setPref(pref, entry.originalValue, {
|
|
branch: entry.branch,
|
|
});
|
|
|
|
originalValue = lazy.PrefUtils.getPref(pref, { branch });
|
|
}
|
|
|
|
Object.assign(entry, {
|
|
branch,
|
|
value,
|
|
originalValue,
|
|
slug,
|
|
});
|
|
|
|
try {
|
|
lazy.PrefUtils.setPref(pref, value, { branch });
|
|
} catch (e) {
|
|
throw new PrefFlipsFailedError(pref);
|
|
}
|
|
Services.prefs.addObserver(pref, entry.observer);
|
|
}
|
|
|
|
_unregisterPref(pref) {
|
|
const entry = this._prefs.get(pref);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
this._prefs.delete(pref);
|
|
Services.prefs.removeObserver(pref, entry.observer);
|
|
|
|
const { originalValue, branch } = entry;
|
|
lazy.PrefUtils.setPref(pref, originalValue, { branch });
|
|
}
|
|
|
|
_onPrefChanged(pref) {
|
|
if (this.#updating) {
|
|
return;
|
|
}
|
|
|
|
if (this.manager._prefs.get(pref)?.enrollmentChanging) {
|
|
return;
|
|
}
|
|
|
|
this.#updating = true;
|
|
|
|
const entry = this._prefs.get(pref);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
this._prefs.delete(pref);
|
|
Services.prefs.removeObserver(pref, entry.observer);
|
|
|
|
const changedPref = {
|
|
name: pref,
|
|
branch: PrefFlipsFeature.determinePrefChangeBranch(
|
|
pref,
|
|
entry.branch,
|
|
entry.value
|
|
),
|
|
};
|
|
|
|
// If there is both an experiment and a rollout that would both control the
|
|
// same pref, we unenroll both because if we only unenrolled the experiment,
|
|
// the rollout would clobber the pref change that just happened.
|
|
const toUnenroll = this.manager.store.getAll().filter(enrollment => {
|
|
if (!enrollment.active || !enrollment.featureIds.includes(FEATURE_ID)) {
|
|
return false;
|
|
}
|
|
|
|
const featureValue = enrollment.branch.features.find(
|
|
featureConfig => featureConfig.featureId === FEATURE_ID
|
|
).value;
|
|
return Object.hasOwn(featureValue.prefs, pref);
|
|
});
|
|
|
|
// We have to restore every *other* pref controlled by these enrollments.
|
|
const toRestore = new Set(
|
|
toUnenroll.flatMap(enrollment =>
|
|
Object.keys(
|
|
enrollment.branch.features.find(
|
|
featureConfig => featureConfig.featureId === FEATURE_ID
|
|
).value.prefs
|
|
)
|
|
)
|
|
);
|
|
toRestore.delete(pref);
|
|
|
|
for (const prefToRestore of toRestore) {
|
|
this._unregisterPref(prefToRestore);
|
|
}
|
|
|
|
// Unenrollment doesn't matter here like it does in ExperimentManager's
|
|
// managed prefs because we've already restored prefs before unenrollment.
|
|
for (const enrollment of toUnenroll) {
|
|
this.manager._unenroll(enrollment, {
|
|
reason: "changed-pref",
|
|
changedPref,
|
|
});
|
|
}
|
|
|
|
this.#updating = false;
|
|
|
|
// If we've caused unenrollments, we need to recompute state.
|
|
this.onFeatureUpdate();
|
|
}
|
|
|
|
static determinePrefChangeBranch(pref, expectedBranch, expectedValue) {
|
|
// We want to know what branch was changed so we can know if we should
|
|
// restore prefs (.e.,g if we have a pref set on the user branch and the
|
|
// user branch changed, we do not want to then overwrite the user's choice).
|
|
|
|
// This is not complicated if a pref simply changed. However, we must also
|
|
// detect `nsIPrefBranch::clearUserPref()`, which wipes out the user branch
|
|
// and leaves the default branch untouched. That is where this gets
|
|
// complicated.
|
|
|
|
if (Services.prefs.prefHasUserValue(pref)) {
|
|
// If there is a user branch value, then the user branch changed, because
|
|
// a change to the default branch wouldn't have triggered the observer.
|
|
return "user";
|
|
} else if (!Services.prefs.prefHasDefaultValue(pref)) {
|
|
// If there is no user branch value *or* default branch avlue, then the
|
|
// user branch must have been cleared because you cannot clear the default
|
|
// branch.
|
|
return "user";
|
|
} else if (expectedBranch === "default") {
|
|
const value = lazy.PrefUtils.getPref(pref, { branch: "default" });
|
|
if (value === expectedValue) {
|
|
// The pref we control was set on the default branch and still matches
|
|
// the expected value. Therefore, the user branch must have been
|
|
// cleared.
|
|
return "user";
|
|
}
|
|
// The default value branch does not match the value we expect, so it
|
|
// must have just changed.
|
|
return "default";
|
|
}
|
|
return "user";
|
|
}
|
|
|
|
_unenrollForFailure(enrollment, pref) {
|
|
const rawType = Services.prefs.getPrefType(pref);
|
|
let prefType = "invalid";
|
|
|
|
switch (rawType) {
|
|
case Ci.nsIPrefBranch.PREF_BOOL:
|
|
prefType = "bool";
|
|
break;
|
|
|
|
case Ci.nsIPrefBranch.PREF_STRING:
|
|
prefType = "string";
|
|
break;
|
|
|
|
case Ci.nsIPrefBranch.PREF_INT:
|
|
prefType = "int";
|
|
break;
|
|
}
|
|
|
|
this.manager._unenroll(enrollment, {
|
|
reason: REASON_PREFFLIPS_FAILED,
|
|
prefName: pref,
|
|
prefType,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thrown when the prefFlips feature fails to set a pref.
|
|
*/
|
|
class PrefFlipsFailedError extends Error {
|
|
constructor(pref, value) {
|
|
super(`The Nimbus prefFlips feature failed to set ${pref}=${value}`);
|
|
this.pref = pref;
|
|
}
|
|
}
|