Source code

Revision control

Copy as Markdown

Other Tools

/* 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 {
AsyncSetting,
AsyncSettingHandler,
} from "chrome://global/content/preferences/AsyncSetting.mjs";
import { Preferences } from "chrome://global/content/preferences/Preferences.mjs";
/**
* @import { type Preference } from "chrome://global/content/preferences/Preference.mjs"
* @import { SettingControlConfig } from "chrome://browser/content/preferences/widgets/setting-control.mjs"
* @import { ExtensionSettingsStore } from "resource://gre/modules/ExtensionSettingsStore.sys.mjs"
* @import { AddonManager } from "resource://gre/modules/AddonManager.sys.mjs"
* @import { Management } from "resource://gre/modules/Extension.sys.mjs"
*/
/**
* A map of Setting instances (values) along with their IDs
* (keys) so that the dependencies of a setting can
* be easily looked up by just their ID.
*
* @typedef {Record<string, Setting>} SettingDeps
*/
/**
* @typedef {string | boolean | number | nsIFile | void} SettingValue
*/
/**
* @callback SettingVisibleCallback
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {any} If truthy shows the setting in the UI, or hides it if not
*/
/**
* Gets the value of a {@link Setting}.
*
* @callback SettingGetCallback
* @param {any} val - The value that was retrieved from the preferences backend
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {any} - The value to set onto the setting
*/
/**
* Sets the value of a {@link Setting}.
*
* @callback SettingSetCallback
* @param {SettingValue} val - The value/pressed/checked from the input (control) associated with the setting
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {void}
*/
/**
* @callback SettingOnUserChangeCallback
* @param {SettingValue} val - The value/pressed/checked from the input of the control associated with the setting
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {void}
*/
/**
* @callback SettingDisabledCallback
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {any}
*/
/**
* @callback SettingGetControlConfigCallback
* @param {SettingControlConfig} config
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {SettingControlConfig}
*/
/**
* @callback SettingTeardownCallback
* @returns {void}
*/
/**
* @callback SettingEmitChange
*/
/**
* @callback SettingSetupCallback
* @param {SettingEmitChange} emitChange Notify listeners of a change to this setting.
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {SettingTeardownCallback | void}
*/
/**
* @callback SettingOnUserClickCallback
* @param {MouseEvent} event
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {void}
*/
/**
* @callback SettingOnMessageBarDismissCallback
* @param {CustomEvent} event - The dismiss event
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {void}
*/
/**
* @callback SettingOnUserReorderCallback
* @param {CustomEvent} event - The reorder event with detail containing draggedElement, targetElement, position, draggedIndex, targetIndex
* @param {SettingDeps} deps
* @param {Setting} setting
* @returns {void}
*/
/**
* @typedef {object} SettingControllingExtensionInfo
* @property {string} storeId The ExtensionSettingsStore id that controls this setting.
* @property {string} l10nId A fluent id to show in a controlled by extension message.
* @property {string} [name] The controlling extension's name.
* @property {string} [id] The controlling extension's id.
* @property {string} [supportPage] A support page to show in the message.
* @property {boolean} [allowControl] If the control should be enabled while controlled.
*/
/**
* @typedef {object} SettingConfig
* @property {string} id - The ID for the Setting, this should match the layout id
* @property {string} [pref] - A {@link Services.prefs} id that will be used as the backend if it is provided
* @property {string[]} [deps] - An array of setting IDs that this setting depends on, when these settings change this setting will emit a change event to update the UI
* @property {Pick<SettingControllingExtensionInfo, "storeId" | "l10nId" | "allowControl" | "supportPage">} [controllingExtensionInfo] Data related to the setting being controlled by an extension.
* @property {SettingVisibleCallback} [visible] - Function to determine if a setting is visible in the UI
* @property {SettingGetCallback} [get] - Function to get the value of the setting. Optional if {@link SettingConfig#pref} is set.
* @property {SettingSetCallback} [set] - Function to set the value of the setting. Optional if {@link SettingConfig#pref} is set.
* @property {SettingGetControlConfigCallback} [getControlConfig] - Function that allows the setting to modify its layout, this is intended to be used to provide the options, {@link SettingConfig#l10nId} or {@link SettingConfig#l10nArgs} data if necessary, but technically it can change anything (that doesn't mean it will have any effect though).
* @property {SettingOnUserChangeCallback} [onUserChange] - A function that will be called when the setting
* has been modified by the user, it is passed the value/pressed/checked from its input. NOTE: This should be used for
* additional work that needs to happen, such as recording telemetry.
* If you want to set the value of the Setting then use the {@link SettingConfig.set} function.
* @property {SettingSetupCallback} [setup] - A function to be called to register listeners for
* the setting. It should return a {@link SettingTeardownCallback} function to
* remove the listeners if necessary. This should emit change events when the setting has changed to
* ensure the UI stays in sync if possible.
* @property {SettingDisabledCallback} [disabled] - A function to determine if a setting should be disabled
* @property {SettingOnUserClickCallback} [onUserClick] - A function that will be called when a setting has been
* clicked, the element name must be included in the CLICK_HANDLERS array
* used for controls that aren't regular form controls but instead perform an action when clicked, like a button or link.
* @property {SettingOnMessageBarDismissCallback} [onMessageBarDismiss] - A function that will be called when a message bar has been
* dismissed. This should be used for moz-message-bar to override the default behavior.
* @property {SettingOnUserReorderCallback} [onUserReorder] - A function that will be called when items in a
* reorderable list have been reordered. This should be used to update the underlying data when the user
* reorders items, such as updating preference values.
*/
const { EventEmitter } = ChromeUtils.importESModule(
"resource://gre/modules/EventEmitter.sys.mjs"
);
/** @type {{ ExtensionSettingsStore: typeof ExtensionSettingsStore, AddonManager: typeof AddonManager, Management: typeof Management }} */
// @ts-expect-error bug 1996860
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ExtensionSettingsStore:
"resource://gre/modules/ExtensionSettingsStore.sys.mjs",
AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
Management: "resource://gre/modules/Extension.sys.mjs",
});
class PreferenceNotAddedError extends Error {
/**
* @param {string} settingId
* @param {string} prefId
*/
constructor(settingId, prefId) {
super(
`Setting "${settingId}" was unable to find Preference "${prefId}". Did you register it with Preferences.add/addAll?`
);
this.name = "PreferenceNotAddedError";
this.settingId = settingId;
this.prefId = prefId;
}
}
export class Setting extends EventEmitter {
/**
* @type {Preference}
*/
_pref;
/**
* Keeps a cache of each dep's Setting so that
* it can be easily looked up by its ID.
*
* @type {SettingDeps}
*/
_deps;
/**
* @type {SettingConfig | AsyncSettingHandler}
*/
config;
get pref() {
return this._pref;
}
set pref(newPref) {
if (this._pref) {
this._pref.off("change", this.onChange);
}
this._pref = newPref;
if (this._pref) {
this._pref.on("change", this.onChange);
}
}
/**
* @param {SettingConfig['id']} id
* @param {SettingConfig | typeof AsyncSetting} config
* @throws {Error} Will throw an error (PreferenceNotAddedError) if
* config.pref was not registered
*/
constructor(id, config) {
super();
/** @type {SettingConfig | AsyncSettingHandler} */
let configObj;
if (Object.getPrototypeOf(config) == AsyncSetting) {
configObj = new AsyncSettingHandler(
id,
/** @type {typeof AsyncSetting} */ (config)
);
} else {
configObj = config;
}
this.id = id;
this.config = configObj;
this.pref = configObj.pref && Preferences.get(configObj.pref);
if (configObj.pref && !this.pref) {
throw new PreferenceNotAddedError(id, configObj.pref);
}
this._emitting = false;
/** @type {SettingControllingExtensionInfo} */
this.controllingExtensionInfo = {
...this.config.controllingExtensionInfo,
};
if (this.config.controllingExtensionInfo?.storeId) {
this._checkForControllingExtension();
this.watchExtensionPrefChange();
}
if (typeof this.config.setup === "function") {
this._teardown = this.config.setup(this.onChange, this.deps, this);
}
}
onChange = () => {
if (this._emitting) {
return;
}
this._emitting = true;
this.emit("change");
this._emitting = false;
};
/**
* A map of each dep and it's associated {@link Setting} instance.
*
* @type {SettingDeps}
*/
get deps() {
if (this._deps) {
return this._deps;
}
/**
* @type {SettingDeps}
*/
const deps = {};
if (this.config.deps) {
for (let id of this.config.deps) {
const setting = Preferences.getSetting(id);
if (setting) {
deps[id] = setting;
}
}
}
this._deps = deps;
for (const setting of Object.values(this._deps)) {
setting.on("change", this.onChange);
}
return this._deps;
}
/**
* @type {SettingValue}
*/
get value() {
let prefVal = this.pref?.value;
if (this.config.get) {
return this.config.get(prefVal, this.deps, this);
}
return prefVal;
}
/**
* @param {SettingValue} val
*/
set value(val) {
let newVal = this.config.set ? this.config.set(val, this.deps, this) : val;
if (this.pref && !(newVal instanceof Object && "then" in newVal)) {
this.pref.value = newVal;
}
}
/**
* @type {boolean}
*/
get locked() {
return this.pref?.locked ?? false;
}
get visible() {
return this.config.visible ? this.config.visible(this.deps, this) : true;
}
get disabled() {
return this.config.disabled ? this.config.disabled(this.deps, this) : false;
}
/**
* @param {SettingControlConfig} config
* @returns {SettingControlConfig}
*/
getControlConfig(config) {
if (this.config.getControlConfig) {
return this.config.getControlConfig(config, this.deps, this);
}
return config;
}
/**
* @param {MouseEvent} event
*/
userClick(event) {
if (this.config.onUserClick) {
this.config.onUserClick(event, this.deps, this);
}
}
/**
* @param {CustomEvent} event
*/
messageBarDismiss(event) {
if (this.config.onMessageBarDismiss) {
this.config.onMessageBarDismiss(event, this.deps, this);
}
}
/**
* @param {CustomEvent} event
*/
userReorder(event) {
if (this.config.onUserReorder) {
this.config.onUserReorder(event, this.deps, this);
}
}
/**
* @param {string} val
*/
userChange(val) {
this.value = val;
if (this.config.onUserChange) {
this.config.onUserChange(val, this.deps, this);
}
}
async disableControllingExtension() {
if (
this.controllingExtensionInfo.name &&
this.controllingExtensionInfo.id
) {
await lazy.ExtensionSettingsStore.initialize();
let { id } = lazy.ExtensionSettingsStore.getSetting(
"prefs",
this.controllingExtensionInfo.storeId
);
if (id) {
let addon = await lazy.AddonManager.getAddonByID(id);
await addon.disable();
}
}
}
_checkForControllingExtension = async () => {
// Make sure all settings API modules are loaded
// and the extension controlling settings metadata
// loaded from the ExtensionSettingsStore backend.
await lazy.Management.asyncLoadSettingsModules();
await lazy.ExtensionSettingsStore.initialize();
// Retrieve the extension controlled settings info
// for the given setting storeId.
let info = lazy.ExtensionSettingsStore.getSetting(
"prefs",
this.config.controllingExtensionInfo?.storeId
);
if (info && info.id) {
let addon = await lazy.AddonManager.getAddonByID(info.id);
if (addon) {
this.controllingExtensionInfo.name = addon.name;
this.controllingExtensionInfo.id = info.id;
this.emit("change");
return;
}
}
this._clearControllingExtensionInfo();
};
_clearControllingExtensionInfo() {
delete this.controllingExtensionInfo.id;
delete this.controllingExtensionInfo.name;
delete this.controllingExtensionInfo.supportPage;
// Request an update to the setting control so the UI is in the correct state
this.onChange();
}
watchExtensionPrefChange() {
lazy.Management.on(
`extension-setting-changed:${this.config.controllingExtensionInfo?.storeId}`,
this._checkForControllingExtension
);
}
destroy() {
if (typeof this._teardown === "function") {
this._teardown();
this._teardown = null;
}
if (this.pref) {
this.pref.off("change", this.onChange);
}
if (this.config.controllingExtensionInfo?.storeId) {
lazy.Management.off(
`extension-setting-changed:${this.config.controllingExtensionInfo?.storeId}`,
this._checkForControllingExtension
);
}
}
}