/* 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} 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} [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 * in {@link file://./../../browser/components/preferences/widgets/setting-group/setting-group.mjs}. This should be * 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 ); } } }