tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit fea6411fca41bd95c72d96fcb25e6ebf72bcc9e0
parent d278239b5938108611c7875cafd65d433b3c4eee
Author: Mark Striemer <mstriemer@mozilla.com>
Date:   Mon, 17 Nov 2025 17:09:13 +0000

Bug 1996849 - Improve typing in Settings code r=mconley,tgiles

Differential Revision: https://phabricator.services.mozilla.com/D270779

Diffstat:
Mbrowser/components/preferences/findInPage.js | 21++++++++++++++++-----
Mbrowser/components/preferences/main.js | 368+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mbrowser/components/preferences/preferences.js | 44+++++++++++++++++++++++++++++++++++++-------
Mbrowser/components/preferences/privacy.js | 440+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mbrowser/components/preferences/widgets/setting-control/setting-control.mjs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mbrowser/components/preferences/widgets/setting-element/setting-element.mjs | 38++++++++++++++++++++++----------------
Mbrowser/components/preferences/widgets/setting-group/setting-group.mjs | 42+++++++++++++++++++++++++++++++-----------
Mbrowser/components/preferences/widgets/setting-pane/setting-pane.mjs | 18++++++++++++++++++
Mtoolkit/components/extensions/ExtensionSettingsStore.sys.mjs | 2+-
Mtoolkit/content/preferences/AsyncSetting.mjs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mtoolkit/content/preferences/Preference.mjs | 48+++++++++++++++++++++++++++++++-----------------
Mtoolkit/content/preferences/Preferences.mjs | 207+++++++++++++++++++++++++++-----------------------------------------------------
Mtoolkit/content/preferences/Setting.mjs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mtoolkit/content/widgets/lit-utils.mjs | 7++++++-
Mtools/@types/generated/tspaths.json | 12++++++++++++
15 files changed, 989 insertions(+), 661 deletions(-)

diff --git a/browser/components/preferences/findInPage.js b/browser/components/preferences/findInPage.js @@ -9,9 +9,12 @@ // inside the button, which allows the text to be highlighted when the user // is searching. -const MozButton = customElements.get("button"); -class HighlightableButton extends MozButton { +/** @import MozInputSearch from "chrome://global/content/elements/moz-input-search.mjs" */ + +const MozButtonClass = customElements.get("button"); +class HighlightableButton extends MozButtonClass { static get inheritedAttributes() { + // @ts-expect-error super is MozButton from toolkit/content/widgets/button.js return Object.assign({}, super.inheritedAttributes, { ".button-text": "text=label,accesskey,crop", }); @@ -22,9 +25,14 @@ customElements.define("highlightable-button", HighlightableButton, { }); var gSearchResultsPane = { + /** @type {string} */ + query: undefined, listSearchTooltips: new Set(), listSearchMenuitemIndicators: new Set(), + /** @type {MozInputSearch} */ searchInput: null, + /** @type {HTMLDivElement} */ + searchTooltipContainer: null, // A map of DOM Elements to a string of keywords used in search // XXX: We should invalidate this cache on `intl:app-locales-changed` searchKeywords: new WeakMap(), @@ -49,9 +57,11 @@ var gSearchResultsPane = { return; } this.inited = true; - this.searchInput = document.getElementById("searchInput"); - this.searchTooltipContainer = document.getElementById( - "search-tooltip-container" + this.searchInput = /** @type {MozInputSearch} */ ( + document.getElementById("searchInput") + ); + this.searchTooltipContainer = /** @type {HTMLDivElement} */ ( + document.getElementById("search-tooltip-container") ); window.addEventListener("resize", () => { @@ -71,6 +81,7 @@ var gSearchResultsPane = { ensureScrollPadding(); }, + /** @param {InputEvent} event */ async handleEvent(event) { // Ensure categories are initialized if idle callback didn't run sooo enough. await this.initializeCategories(); diff --git a/browser/components/preferences/main.js b/browser/components/preferences/main.js @@ -2,10 +2,6 @@ * 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 MozButton from "chrome://global/content/elements/moz-button.mjs"; */ -/** @import { SettingGroup } from "./widgets/setting-group/setting-group.mjs" */ -/** @import { PreferencesSettingsConfig } from "chrome://global/content/preferences/Preferences.mjs" */ - /* import-globals-from extensionControlled.js */ /* import-globals-from preferences.js */ /* import-globals-from /toolkit/mozapps/preferences/fontbuilder.js */ @@ -228,104 +224,122 @@ Preferences.addSetting({ pref: "browser.privatebrowsing.autostart", }); -Preferences.addSetting({ - id: "launchOnLoginApproved", - _getLaunchOnLoginApprovedCachedValue: true, - get() { - return this._getLaunchOnLoginApprovedCachedValue; - }, - // Check for a launch on login registry key - // This accounts for if a user manually changes it in the registry - // Disabling in Task Manager works outside of just deleting the registry key - // in HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run - // but it is not possible to change it back to enabled as the disabled value is just a random - // hexadecimal number - async setup() { - if (AppConstants.platform !== "win") { - /** - * WindowsLaunchOnLogin isnt available if not on windows - * but this setup function still fires, so must prevent - * WindowsLaunchOnLogin.getLaunchOnLoginApproved - * below from executing unnecessarily. - */ - return; - } - this._getLaunchOnLoginApprovedCachedValue = - await WindowsLaunchOnLogin.getLaunchOnLoginApproved(); - }, -}); +Preferences.addSetting( + /** @type {{ _getLaunchOnLoginApprovedCachedValue: boolean } & SettingConfig} */ ({ + id: "launchOnLoginApproved", + _getLaunchOnLoginApprovedCachedValue: true, + get() { + return this._getLaunchOnLoginApprovedCachedValue; + }, + // Check for a launch on login registry key + // This accounts for if a user manually changes it in the registry + // Disabling in Task Manager works outside of just deleting the registry key + // in HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run + // but it is not possible to change it back to enabled as the disabled value is just a random + // hexadecimal number + setup() { + if (AppConstants.platform !== "win") { + /** + * WindowsLaunchOnLogin isnt available if not on windows + * but this setup function still fires, so must prevent + * WindowsLaunchOnLogin.getLaunchOnLoginApproved + * below from executing unnecessarily. + */ + return; + } + // @ts-ignore bug 1996860 + WindowsLaunchOnLogin.getLaunchOnLoginApproved().then(val => { + this._getLaunchOnLoginApprovedCachedValue = val; + }); + }, + }) +); Preferences.addSetting({ id: "windowsLaunchOnLoginEnabled", pref: "browser.startup.windowsLaunchOnLogin.enabled", }); -Preferences.addSetting({ - id: "windowsLaunchOnLogin", - deps: ["launchOnLoginApproved", "windowsLaunchOnLoginEnabled"], - _getLaunchOnLoginEnabledValue: false, - get startWithLastProfile() { - return Cc["@mozilla.org/toolkit/profile-service;1"].getService( - Ci.nsIToolkitProfileService - ).startWithLastProfile; - }, - get() { - return this._getLaunchOnLoginEnabledValue; - }, - async setup(emitChange) { - if (AppConstants.platform !== "win") { - /** - * WindowsLaunchOnLogin isnt available if not on windows - * but this setup function still fires, so must prevent - * WindowsLaunchOnLogin.getLaunchOnLoginEnabled - * below from executing unnecessarily. - */ - return; - } +Preferences.addSetting( + /** @type {{_getLaunchOnLoginEnabledValue: boolean, startWithLastProfile: boolean} & SettingConfig} */ ({ + id: "windowsLaunchOnLogin", + deps: ["launchOnLoginApproved", "windowsLaunchOnLoginEnabled"], + _getLaunchOnLoginEnabledValue: false, + get startWithLastProfile() { + return Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ).startWithLastProfile; + }, + get() { + return this._getLaunchOnLoginEnabledValue; + }, + setup(emitChange) { + if (AppConstants.platform !== "win") { + /** + * WindowsLaunchOnLogin isnt available if not on windows + * but this setup function still fires, so must prevent + * WindowsLaunchOnLogin.getLaunchOnLoginEnabled + * below from executing unnecessarily. + */ + return; + } - let getLaunchOnLoginEnabledValue; - if (!this.startWithLastProfile) { - getLaunchOnLoginEnabledValue = false; - } else { - getLaunchOnLoginEnabledValue = - await WindowsLaunchOnLogin.getLaunchOnLoginEnabled(); - } - if (getLaunchOnLoginEnabledValue !== this._getLaunchOnLoginEnabledValue) { - this._getLaunchOnLoginEnabledValue = getLaunchOnLoginEnabledValue; - emitChange(); - } - }, - visible: ({ windowsLaunchOnLoginEnabled }) => { - let isVisible = - AppConstants.platform === "win" && windowsLaunchOnLoginEnabled.value; - if (isVisible) { - NimbusFeatures.windowsLaunchOnLogin.recordExposureEvent({ - once: true, - }); - } - return isVisible; - }, - disabled({ launchOnLoginApproved }) { - return !this.startWithLastProfile || !launchOnLoginApproved.value; - }, - onUserChange(checked) { - if (checked) { - // windowsLaunchOnLogin has been checked: create registry key or shortcut - // The shortcut is created with the same AUMID as Firefox itself. However, - // this is not set during browser tests and the fallback of checking the - // registry fails. As such we pass an arbitrary AUMID for the purpose - // of testing. - WindowsLaunchOnLogin.createLaunchOnLogin(); - Services.prefs.setBoolPref( - "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt", - true - ); - } else { - // windowsLaunchOnLogin has been unchecked: delete registry key and shortcut - WindowsLaunchOnLogin.removeLaunchOnLogin(); - } - }, -}); + /** @type {boolean} */ + let getLaunchOnLoginEnabledValue; + let maybeEmitChange = () => { + if ( + getLaunchOnLoginEnabledValue !== this._getLaunchOnLoginEnabledValue + ) { + this._getLaunchOnLoginEnabledValue = getLaunchOnLoginEnabledValue; + emitChange(); + } + }; + if (!this.startWithLastProfile) { + getLaunchOnLoginEnabledValue = false; + maybeEmitChange(); + } else { + // @ts-ignore bug 1996860 + WindowsLaunchOnLogin.getLaunchOnLoginEnabled().then(val => { + getLaunchOnLoginEnabledValue = val; + maybeEmitChange(); + }); + } + }, + visible: ({ windowsLaunchOnLoginEnabled }) => { + let isVisible = + AppConstants.platform === "win" && windowsLaunchOnLoginEnabled.value; + if (isVisible) { + // @ts-ignore bug 1996860 + NimbusFeatures.windowsLaunchOnLogin.recordExposureEvent({ + once: true, + }); + } + return isVisible; + }, + disabled({ launchOnLoginApproved }) { + return !this.startWithLastProfile || !launchOnLoginApproved.value; + }, + onUserChange(checked) { + if (checked) { + // windowsLaunchOnLogin has been checked: create registry key or shortcut + // The shortcut is created with the same AUMID as Firefox itself. However, + // this is not set during browser tests and the fallback of checking the + // registry fails. As such we pass an arbitrary AUMID for the purpose + // of testing. + // @ts-ignore bug 1996860 + WindowsLaunchOnLogin.createLaunchOnLogin(); + Services.prefs.setBoolPref( + "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt", + true + ); + } else { + // windowsLaunchOnLogin has been unchecked: delete registry key and shortcut + // @ts-ignore bug 1996860 + WindowsLaunchOnLogin.removeLaunchOnLogin(); + } + }, + }) +); Preferences.addSetting({ id: "windowsLaunchOnLoginDisabledProfileBox", @@ -393,6 +407,7 @@ Preferences.addSetting({ if (checked) { // We need to restore the blank homepage setting in our other pref if (startupPref.value === gMainPane.STARTUP_PREF_BLANK) { + // @ts-ignore bug 1996860 HomePage.safeSet("about:blank"); } newValue = gMainPane.STARTUP_PREF_RESTORE_SESSION; @@ -431,51 +446,56 @@ Preferences.addSetting({ id: "useCursorNavigation", pref: "accessibility.browsewithcaret", }); -Preferences.addSetting({ - id: "useFullKeyboardNavigation", - pref: "accessibility.tabfocus", - visible: () => AppConstants.platform == "macosx", - /** - * Returns true if any full keyboard nav is enabled and false otherwise, caching - * the current value to enable proper pref restoration if the checkbox is - * never changed. - * - * accessibility.tabfocus - * - an integer controlling the focusability of: - * 1 text controls - * 2 form elements - * 4 links - * 7 all of the above - */ - get(prefVal) { - this._storedFullKeyboardNavigation = prefVal; - return prefVal == 7; - }, - /** - * Returns the value of the full keyboard nav preference represented by UI, - * preserving the preference's "hidden" value if the preference is - * unchanged and represents a value not strictly allowed in UI. - */ - set(checked) { - if (checked) { - return 7; - } - if (this._storedFullKeyboardNavigation != 7) { - // 1/2/4 values set via about:config should persist - return this._storedFullKeyboardNavigation; - } - // When the checkbox is unchecked, default to just text controls. - return 1; - }, -}); +Preferences.addSetting( + /** @type {{ _storedFullKeyboardNavigation: number } & SettingConfig} */ ({ + _storedFullKeyboardNavigation: -1, + id: "useFullKeyboardNavigation", + pref: "accessibility.tabfocus", + visible: () => AppConstants.platform == "macosx", + /** + * Returns true if any full keyboard nav is enabled and false otherwise, caching + * the current value to enable proper pref restoration if the checkbox is + * never changed. + * + * accessibility.tabfocus + * - an integer controlling the focusability of: + * 1 text controls + * 2 form elements + * 4 links + * 7 all of the above + */ + get(prefVal) { + this._storedFullKeyboardNavigation = prefVal; + return prefVal == 7; + }, + /** + * Returns the value of the full keyboard nav preference represented by UI, + * preserving the preference's "hidden" value if the preference is + * unchanged and represents a value not strictly allowed in UI. + */ + set(checked) { + if (checked) { + return 7; + } + if (this._storedFullKeyboardNavigation != 7) { + // 1/2/4 values set via about:config should persist + return this._storedFullKeyboardNavigation; + } + // When the checkbox is unchecked, default to just text controls. + return 1; + }, + }) +); Preferences.addSetting({ id: "linkPreviewEnabled", pref: "browser.ml.linkPreview.enabled", + // @ts-ignore bug 1996860 visible: () => LinkPreview.canShowPreferences, }); Preferences.addSetting({ id: "linkPreviewKeyPoints", pref: "browser.ml.linkPreview.optin", + // @ts-ignore bug 1996860 visible: () => LinkPreview.canShowKeyPoints, }); Preferences.addSetting({ @@ -532,38 +552,46 @@ Preferences.addSetting({ }, }); -Preferences.addSetting({ - id: "web-appearance-chooser", - themeNames: ["dark", "light", "auto"], - pref: "layout.css.prefers-color-scheme.content-override", - setup(emitChange) { - Services.obs.addObserver(emitChange, "look-and-feel-changed"); - return () => - Services.obs.removeObserver(emitChange, "look-and-feel-changed"); - }, - get(val, _, setting) { - return this.themeNames[val] || this.themeNames[setting.pref.defaultValue]; - }, - set(val) { - return this.themeNames.indexOf(val); - }, - getControlConfig(config) { - // Set the auto theme image to the light/dark that matches. - let systemThemeIndex = Services.appinfo.contentThemeDerivedColorSchemeIsDark - ? 2 - : 1; - config.options[0].controlAttrs = { - ...config.options[0].controlAttrs, - imagesrc: config.options[systemThemeIndex].controlAttrs.imagesrc, - }; - return config; - }, -}); +Preferences.addSetting( + /** @type {{ themeNames: string[] } & SettingConfig}} */ ({ + id: "web-appearance-chooser", + themeNames: ["dark", "light", "auto"], + pref: "layout.css.prefers-color-scheme.content-override", + setup(emitChange) { + Services.obs.addObserver(emitChange, "look-and-feel-changed"); + return () => + Services.obs.removeObserver(emitChange, "look-and-feel-changed"); + }, + get(val, _, setting) { + return ( + this.themeNames[val] || + this.themeNames[/** @type {number} */ (setting.pref.defaultValue)] + ); + }, + /** @param {string} val */ + set(val) { + return this.themeNames.indexOf(val); + }, + getControlConfig(config) { + // Set the auto theme image to the light/dark that matches. + let systemThemeIndex = Services.appinfo + .contentThemeDerivedColorSchemeIsDark + ? 2 + : 1; + config.options[0].controlAttrs = { + ...config.options[0].controlAttrs, + imagesrc: config.options[systemThemeIndex].controlAttrs.imagesrc, + }; + return config; + }, + }) +); Preferences.addSetting({ id: "web-appearance-manage-themes-link", onUserClick: e => { e.preventDefault(); + // @ts-ignore topChromeWindow global window.browsingContext.topChromeWindow.BrowserAddonUI.openAddonsMgr( "addons://list/theme" ); @@ -893,7 +921,11 @@ const DefaultBrowserHelper = { * @type {typeof import('../shell/ShellService.sys.mjs').ShellService | undefined} */ get shellSvc() { - return AppConstants.HAVE_SHELL_SERVICE && getShellService(); + return ( + AppConstants.HAVE_SHELL_SERVICE && + // @ts-ignore from utilityOverlay.js + getShellService() + ); }, /** @@ -1017,7 +1049,7 @@ Preferences.addSetting({ * browser is already the default browser. */ visible: () => DefaultBrowserHelper.canCheck, - disabled: (deps, setting) => + disabled: (_, setting) => !DefaultBrowserHelper.canCheck || setting.locked || DefaultBrowserHelper.isBrowserDefault, @@ -1095,9 +1127,7 @@ Preferences.addSetting({ }, }); -/** - * @type {Record<string, PreferencesSettingsConfig>} SettingConfig - */ +/** @type {Record<string, SettingGroupConfig>} */ let SETTINGS_CONFIG = { containers: { // This section is marked as in progress for testing purposes @@ -2799,7 +2829,7 @@ var gMainPane = { * The fully initialized state. * * @param {Object} supportedLanguages - * @param {Array<{ langTag: string, displayName: string}} languageList + * @param {Array<{ langTag: string, displayName: string}>} languageList * @param {Map<string, DownloadPhase>} downloadPhases */ constructor(supportedLanguages, languageList, downloadPhases) { @@ -2835,8 +2865,8 @@ var gMainPane = { /** * Determine the download phase of each language file. * - * @param {Array<{ langTag: string, displayName: string}} languageList. - * @returns {Map<string, DownloadPhase>} Map the language tag to whether it is downloaded. + * @param {Array<{ langTag: string, displayName: string}>} languageList + * @returns {Promise<Map<string, DownloadPhase>>} Map the language tag to whether it is downloaded. */ static async createDownloadPhases(languageList) { const downloadPhases = new Map(); @@ -3109,7 +3139,7 @@ var gMainPane = { case "loading": downloadButton.hidden = false; deleteButton.hidden = true; - downloadButton.setAttribute("disabled", true); + downloadButton.setAttribute("disabled", "true"); break; } } @@ -3533,7 +3563,7 @@ var gMainPane = { * The search mode is only available from the menu to change the primary browser * language. * - * @param {{ search: boolean }} + * @param {{ search: boolean }} search */ showBrowserLanguagesSubDialog({ search }) { // Record the telemetry event with an id to associate related actions. diff --git a/browser/components/preferences/preferences.js b/browser/components/preferences/preferences.js @@ -16,6 +16,12 @@ /* import-globals-from /browser/base/content/utilityOverlay.js */ /* import-globals-from /toolkit/content/preferencesBindings.js */ +/** @import MozButton from "chrome://global/content/elements/moz-button.mjs" */ +/** @import {SettingConfig, SettingEmitChange} from "chrome://global/content/preferences/Setting.mjs" */ +/** @import {SettingControlConfig} from "chrome://browser/content/preferences/widgets/setting-control.mjs" */ +/** @import {SettingGroup, SettingGroupConfig} from "chrome://browser/content/preferences/widgets/setting-group.mjs" */ +/** @import {SettingPane, SettingPaneConfig} from "chrome://browser/content/preferences/widgets/setting-pane.mjs" */ + "use strict"; var { AppConstants } = ChromeUtils.importESModule( @@ -146,6 +152,7 @@ ChromeUtils.defineLazyGetter(this, "gSubDialog", function () { }); }); +/** @type {Record<string, boolean>} */ const srdSectionPrefs = {}; XPCOMUtils.defineLazyPreferenceGetter( srdSectionPrefs, @@ -154,6 +161,9 @@ XPCOMUtils.defineLazyPreferenceGetter( false ); +/** + * @param {string} section + */ function srdSectionEnabled(section) { if (!(section in srdSectionPrefs)) { XPCOMUtils.defineLazyPreferenceGetter( @@ -166,6 +176,7 @@ function srdSectionEnabled(section) { return srdSectionPrefs.all || srdSectionPrefs[section]; } +/** @type {Record<string, SettingPaneConfig>} */ const CONFIG_PANES = { containers2: { parent: "general", @@ -233,10 +244,12 @@ function init_all() { for (let [subPane, config] of Object.entries(CONFIG_PANES)) { subPane = friendlyPrefCategoryNameToInternalName(subPane); - let settingPane = document.createElement("setting-pane"); + let settingPane = /** @type {SettingPane} */ ( + document.createElement("setting-pane") + ); settingPane.name = subPane; settingPane.config = config; - settingPane.isSubPane = config.parent; + settingPane.isSubPane = !!config.parent; document.getElementById("mainPrefPane").append(settingPane); register_module(subPane, { init() { @@ -323,6 +336,12 @@ function onHashChange() { gotoPref(null, "Hash"); } +/** + * @param {string} [aCategory] The pane to show, defaults to the hash of URL or general + * @param {"Click"|"Initial"|"Hash"} [aShowReason] + * What triggered the navigation. Defaults to "Click" if aCategory is provided, + * otherwise "Initial". + */ async function gotoPref( aCategory, aShowReason = aCategory ? "Click" : "Initial" @@ -331,7 +350,7 @@ async function gotoPref( const kDefaultCategoryInternalName = "paneGeneral"; const kDefaultCategory = "general"; let hash = document.location.hash; - let category = aCategory || hash.substr(1) || kDefaultCategoryInternalName; + let category = aCategory || hash.substring(1) || kDefaultCategoryInternalName; let breakIndex = category.indexOf("-"); // Subcategories allow for selecting smaller sections of the preferences @@ -367,8 +386,8 @@ async function gotoPref( element.hidden = true; } - item = categories.querySelector( - ".category[value=" + CSS.escape(category) + "]" + item = /** @type {HTMLElement} */ ( + categories.querySelector(".category[value=" + CSS.escape(category) + "]") ); if (!item || item.hidden) { unknownCategory = true; @@ -402,8 +421,10 @@ async function gotoPref( gLastCategory.category = category; gLastCategory.subcategory = subcategory; if (item) { + // @ts-ignore MozElements.RichListBox categories.selectedItem = item; } else { + // @ts-ignore MozElements.RichListBox categories.clearSelection(); } window.history.replaceState(category, document.title); @@ -456,7 +477,10 @@ async function gotoPref( categoryModule.handlePrefControlledSection?.(); // Record which category is shown - Glean.aboutpreferences["show" + aShowReason].record({ value: category }); + let gleanId = /** @type {"showClick" | "showHash" | "showInitial"} */ ( + "show" + aShowReason + ); + Glean.aboutpreferences[gleanId].record({ value: category }); document.dispatchEvent( new CustomEvent("paneshown", { @@ -469,9 +493,15 @@ async function gotoPref( ); } +/** + * @param {string} aQuery + * @param {string} aAttribute + */ function search(aQuery, aAttribute) { let mainPrefPane = document.getElementById("mainPrefPane"); - let elements = mainPrefPane.children; + let elements = /** @type {HTMLElement[]} */ ( + Array.from(mainPrefPane.children) + ); for (let element of elements) { // If the "data-hidden-from-search" is "true", the // element will not get considered during search. diff --git a/browser/components/preferences/privacy.js b/browser/components/preferences/privacy.js @@ -568,58 +568,73 @@ if (Services.prefs.getBoolPref("privacy.ui.status_card", false)) { pref: "browser.contentblocking.category", get: prefValue => prefValue == "strict", }); - Preferences.addSetting({ - id: "trackerCount", - cachedValue: null, - async setup(emitChange) { - const now = Date.now(); - const aMonthAgo = new Date(now - 30 * 24 * 60 * 60 * 1000); - const events = await lazy.TrackingDBService.getEventsByDateRange( - now, - aMonthAgo - ); - const total = events.reduce((acc, day) => { - return acc + day.getResultByName("count"); - }, 0); - this.cachedValue = total; - emitChange(); - }, - get() { - return this.cachedValue; - }, - }); - Preferences.addSetting({ - id: "appUpdateStatus", - cachedValue: AppUpdater.STATUS.NO_UPDATER, - async setup(emitChange) { - if (AppConstants.MOZ_UPDATER && !gIsPackagedApp) { - let appUpdater = new AppUpdater(); - let listener = (status, ..._args) => { - this.cachedValue = status; - emitChange(); - }; - appUpdater.addListener(listener); - await appUpdater.check(); - return () => { - appUpdater.removeListener(listener); - appUpdater.stop(); - }; - } - return () => {}; - }, - get() { - return this.cachedValue; - }, - set(value) { - this.cachedValue = value; - }, - }); + Preferences.addSetting( + /** @type {{ cachedValue: number, loadTrackerCount: (emitChange: SettingEmitChange) => Promise<void> } & SettingConfig} */ ({ + id: "trackerCount", + cachedValue: null, + async loadTrackerCount(emitChange) { + const now = Date.now(); + const aMonthAgo = new Date(now - 30 * 24 * 60 * 60 * 1000); + /** @type {{ getResultByName: (_: string) => number }[]} */ + const events = await lazy.TrackingDBService.getEventsByDateRange( + now, + aMonthAgo + ); + + const total = events.reduce((acc, day) => { + return acc + day.getResultByName("count"); + }, 0); + this.cachedValue = total; + emitChange(); + }, + setup(emitChange) { + this.loadTrackerCount(emitChange); + }, + get() { + return this.cachedValue; + }, + }) + ); + Preferences.addSetting( + /** @type {{ cachedValue: any } & SettingConfig} */ ({ + id: "appUpdateStatus", + cachedValue: AppUpdater.STATUS.NO_UPDATER, + setup(emitChange) { + if (AppConstants.MOZ_UPDATER && !gIsPackagedApp) { + let appUpdater = new AppUpdater(); + /** + * @param {number} status + * @param {any[]} _args + */ + let listener = (status, ..._args) => { + this.cachedValue = status; + emitChange(); + }; + appUpdater.addListener(listener); + appUpdater.check(); + return () => { + appUpdater.removeListener(listener); + appUpdater.stop(); + }; + } + return () => {}; + }, + get() { + return this.cachedValue; + }, + set(value) { + this.cachedValue = value; + }, + }) + ); } /** * This class is used to create Settings that are used to warn the user about * potential misconfigurations. It should be passed into Preferences.addSetting * to create the Preference for a <moz-box-item> because it creates * separate members on pref.config + * + * @implements {SettingConfig} */ class WarningSettingConfig { /** @@ -689,9 +704,9 @@ class WarningSettingConfig { * This initializes the Setting created with this config, starting listeners for all dependent * Preferences and providing a cleanup callback to remove them * - * @param {Function} emitChange - a callback to be invoked any time that the Setting created + * @param {() => any} emitChange - a callback to be invoked any time that the Setting created * with this config is changed - * @returns {Function} a function that cleans up the state from this Setting, namely pref change listeners. + * @returns {() => any} a function that cleans up the state from this Setting, namely pref change listeners. */ setup(emitChange) { for (let [getter, prefId] of Object.entries(this.prefMapping)) { @@ -710,7 +725,7 @@ class WarningSettingConfig { * "dismiss" action depending on the target, and those callbacks are defined * in this class. * - * @param {Event} event - The event for the user click + * @param {PointerEvent} event - The event for the user click */ onUserClick(event) { switch (event.target.id) { @@ -1112,6 +1127,7 @@ if (Services.prefs.getBoolPref("privacy.ui.status_card", false)) { ); } +/** @type {SettingControlConfig[]} */ const SECURITY_WARNINGS = [ { l10nId: "security-privacy-issue-warning-test", @@ -1207,39 +1223,41 @@ const SECURITY_WARNINGS = [ }, ]; -Preferences.addSetting({ - id: "securityWarningsGroup", - makeSecurityWarningItems() { - return SECURITY_WARNINGS.map(({ id, l10nId }) => ({ - id, - l10nId, - control: "moz-box-item", - options: [ - { - control: "moz-button", - l10nId: "issue-card-reset-button", - controlAttrs: { slot: "actions", size: "small", id: "reset" }, - }, - { - control: "moz-button", - l10nId: "issue-card-dismiss-button", - controlAttrs: { - slot: "actions", - size: "small", - iconsrc: "chrome://global/skin/icons/close.svg", - id: "dismiss", +Preferences.addSetting( + /** @type {{ makeSecurityWarningItems: () => SettingControlConfig[] } & SettingConfig} */ ({ + id: "securityWarningsGroup", + makeSecurityWarningItems() { + return SECURITY_WARNINGS.map(({ id, l10nId }) => ({ + id, + l10nId, + control: "moz-box-item", + options: [ + { + control: "moz-button", + l10nId: "issue-card-reset-button", + controlAttrs: { slot: "actions", size: "small", id: "reset" }, }, - }, - ], - })); - }, - getControlConfig(config) { - if (!config.items) { - return { ...config, items: this.makeSecurityWarningItems() }; - } - return config; - }, -}); + { + control: "moz-button", + l10nId: "issue-card-dismiss-button", + controlAttrs: { + slot: "actions", + size: "small", + iconsrc: "chrome://global/skin/icons/close.svg", + id: "dismiss", + }, + }, + ], + })); + }, + getControlConfig(config) { + if (!config.items) { + return { ...config, items: this.makeSecurityWarningItems() }; + } + return config; + }, + }) +); Preferences.addSetting({ id: "privacyCard", @@ -1508,7 +1526,7 @@ Preferences.addSetting({ deps.blockUnwantedDownloads.value = value; let malwareTable = Preferences.get("urlclassifier.malwareTable"); - let malware = malwareTable.value + let malware = /** @type {string} */ (malwareTable.value) .split(",") .filter( x => @@ -1545,61 +1563,68 @@ Preferences.addSetting({ Preferences.addSetting({ id: "manageDataSettingsGroup", }); -Preferences.addSetting({ - id: "siteDataSize", - setup(emitChange) { - let onUsageChanged = async () => { - let [siteDataUsage, cacheUsage] = await Promise.all([ - SiteDataManager.getTotalUsage(), - SiteDataManager.getCacheSize(), - ]); - let totalUsage = siteDataUsage + cacheUsage; - let [value, unit] = DownloadUtils.convertByteUnits(totalUsage); - this.usage = { value, unit }; - - this.isUpdatingSites = false; - emitChange(); - }; - - let onUpdatingSites = () => { - this.isUpdatingSites = true; - emitChange(); - }; +Preferences.addSetting( + /** @type {{ isUpdatingSites: boolean, usage: { value: number, unit: string } | void } & SettingConfig} */ ({ + id: "siteDataSize", + usage: null, + isUpdatingSites: false, + setup(emitChange) { + let onUsageChanged = async () => { + let [siteDataUsage, cacheUsage] = await Promise.all([ + SiteDataManager.getTotalUsage(), + SiteDataManager.getCacheSize(), + ]); + let totalUsage = siteDataUsage + cacheUsage; + let [value, unit] = DownloadUtils.convertByteUnits(totalUsage); + this.usage = { value, unit }; + + this.isUpdatingSites = false; + emitChange(); + }; - Services.obs.addObserver(onUsageChanged, "sitedatamanager:sites-updated"); - Services.obs.addObserver(onUpdatingSites, "sitedatamanager:updating-sites"); + let onUpdatingSites = () => { + this.isUpdatingSites = true; + emitChange(); + }; - return () => { - Services.obs.removeObserver( - onUsageChanged, - "sitedatamanager:sites-updated" - ); - Services.obs.removeObserver( + Services.obs.addObserver(onUsageChanged, "sitedatamanager:sites-updated"); + Services.obs.addObserver( onUpdatingSites, "sitedatamanager:updating-sites" ); - }; - }, - getControlConfig(config) { - if (this.isUpdatingSites || !this.usage) { - // Data not retrieved yet, show a loading state. + + return () => { + Services.obs.removeObserver( + onUsageChanged, + "sitedatamanager:sites-updated" + ); + Services.obs.removeObserver( + onUpdatingSites, + "sitedatamanager:updating-sites" + ); + }; + }, + getControlConfig(config) { + if (this.isUpdatingSites || !this.usage) { + // Data not retrieved yet, show a loading state. + return { + ...config, + l10nId: "sitedata-total-size-calculating", + }; + } + + let { value, unit } = this.usage; return { ...config, - l10nId: "sitedata-total-size-calculating", + l10nId: "sitedata-total-size2", + l10nArgs: { + value, + unit, + }, }; - } - - let { value, unit } = this.usage; - return { - ...config, - l10nId: "sitedata-total-size2", - l10nArgs: { - value, - unit, - }, - }; - }, -}); + }, + }) +); Preferences.addSetting({ id: "deleteOnCloseInfo", @@ -1609,91 +1634,104 @@ Preferences.addSetting({ }, }); -Preferences.addSetting({ - id: "clearSiteDataButton", - setup(emitChange) { - let onSitesUpdated = async () => { - this.isUpdatingSites = false; - emitChange(); - }; - - let onUpdatingSites = () => { - this.isUpdatingSites = true; - emitChange(); - }; +Preferences.addSetting( + /** @type {{ isUpdatingSites: boolean } & SettingConfig} */ ({ + id: "clearSiteDataButton", + isUpdatingSites: false, + setup(emitChange) { + let onSitesUpdated = async () => { + this.isUpdatingSites = false; + emitChange(); + }; - Services.obs.addObserver(onSitesUpdated, "sitedatamanager:sites-updated"); - Services.obs.addObserver(onUpdatingSites, "sitedatamanager:updating-sites"); + let onUpdatingSites = () => { + this.isUpdatingSites = true; + emitChange(); + }; - return () => { - Services.obs.removeObserver( - onSitesUpdated, - "sitedatamanager:sites-updated" - ); - Services.obs.removeObserver( + Services.obs.addObserver(onSitesUpdated, "sitedatamanager:sites-updated"); + Services.obs.addObserver( onUpdatingSites, "sitedatamanager:updating-sites" ); - }; - }, - onUserClick() { - let uri; - if (useOldClearHistoryDialog) { - uri = "chrome://browser/content/preferences/dialogs/clearSiteData.xhtml"; - } else { - uri = "chrome://browser/content/sanitize_v2.xhtml"; - } - gSubDialog.open( - uri, - { - features: "resizable=no", - }, - { - mode: "clearSiteData", + return () => { + Services.obs.removeObserver( + onSitesUpdated, + "sitedatamanager:sites-updated" + ); + Services.obs.removeObserver( + onUpdatingSites, + "sitedatamanager:updating-sites" + ); + }; + }, + onUserClick() { + let uri; + if (useOldClearHistoryDialog) { + uri = + "chrome://browser/content/preferences/dialogs/clearSiteData.xhtml"; + } else { + uri = "chrome://browser/content/sanitize_v2.xhtml"; } - ); - }, - disabled() { - return this.isUpdatingSites; - }, -}); -Preferences.addSetting({ - id: "siteDataSettings", - setup(emitChange) { - let onSitesUpdated = async () => { - this.isUpdatingSites = false; - emitChange(); - }; - let onUpdatingSites = () => { - this.isUpdatingSites = true; - emitChange(); - }; + gSubDialog.open( + uri, + { + features: "resizable=no", + }, + { + mode: "clearSiteData", + } + ); + }, + disabled() { + return this.isUpdatingSites; + }, + }) +); +Preferences.addSetting( + /** @type {{ isUpdatingSites: boolean } & SettingConfig} */ ({ + id: "siteDataSettings", + isUpdatingSites: false, + setup(emitChange) { + let onSitesUpdated = async () => { + this.isUpdatingSites = false; + emitChange(); + }; - Services.obs.addObserver(onSitesUpdated, "sitedatamanager:sites-updated"); - Services.obs.addObserver(onUpdatingSites, "sitedatamanager:updating-sites"); + let onUpdatingSites = () => { + this.isUpdatingSites = true; + emitChange(); + }; - return () => { - Services.obs.removeObserver( - onSitesUpdated, - "sitedatamanager:sites-updated" - ); - Services.obs.removeObserver( + Services.obs.addObserver(onSitesUpdated, "sitedatamanager:sites-updated"); + Services.obs.addObserver( onUpdatingSites, "sitedatamanager:updating-sites" ); - }; - }, - onUserClick() { - gSubDialog.open( - "chrome://browser/content/preferences/dialogs/siteDataSettings.xhtml" - ); - }, - disabled() { - return this.isUpdatingSites; - }, -}); + + return () => { + Services.obs.removeObserver( + onSitesUpdated, + "sitedatamanager:sites-updated" + ); + Services.obs.removeObserver( + onUpdatingSites, + "sitedatamanager:updating-sites" + ); + }; + }, + onUserClick() { + gSubDialog.open( + "chrome://browser/content/preferences/dialogs/siteDataSettings.xhtml" + ); + }, + disabled() { + return this.isUpdatingSites; + }, + }) +); Preferences.addSetting({ id: "cookieExceptions", onUserClick() { diff --git a/browser/components/preferences/widgets/setting-control/setting-control.mjs b/browser/components/preferences/widgets/setting-control/setting-control.mjs @@ -15,25 +15,53 @@ import { SettingElement, spread, } from "chrome://browser/content/preferences/widgets/setting-element.mjs"; +import MozInputFolder from "chrome://global/content/elements/moz-input-folder.mjs"; -/** @import MozCheckbox from "../../../../../toolkit/content/widgets/moz-checkbox/moz-checkbox.mjs"*/ -/** @import { Setting } from "chrome://global/content/preferences/Setting.mjs"; */ -/** @import { PreferencesSettingConfigNestedControlOption } from "chrome://global/content/preferences/Preferences.mjs"; */ +/** @import { LitElement, Ref, TemplateResult } from "chrome://global/content/vendor/lit.all.mjs" */ +/** @import { SettingElementConfig } from "chrome://browser/content/preferences/widgets/setting-element.mjs" */ +/** @import { Setting } from "chrome://global/content/preferences/Setting.mjs" */ /** - * Properties that represent a nested HTML element that will be a direct descendant of this setting control element - * - * @typedef {object} SettingNestedElementOption - * @property {Array<SettingNestedElementOption>} [options] - * @property {string} control - The {@link HTMLElement#localName} of any HTML element - * @property {Record<string, string>} [controlAttrs] - Attributes for the element + * @typedef {Object} SettingNestedConfig + * @property {SettingControlConfig[]} [items] Additional nested SettingControls to render. + * @property {SettingOptionConfig[]} [options] + * Additional nested plain elements to render (may have SettingControls nested within them, though). + */ + +/** + * @typedef {Object} SettingOptionConfigExtensions + * @property {string} [control] + * The element tag to render, default assumed based on parent control. + * @property {any} [value] A value to set on the option. + */ + +/** + * @typedef {Object} SettingControlConfigExtensions + * @property {string} id + * The ID for the Setting, also set in the DOM unless overridden with controlAttrs.id + * @property {string} [control] The element to render, default to "moz-checkbox". + * @property {string} [controllingExtensionInfo] + * ExtensionSettingStore id for checking if a setting is controlled by an extension. + */ + +/** + * @typedef {SettingOptionConfigExtensions & SettingElementConfig & SettingNestedConfig} SettingOptionConfig + * @typedef {SettingControlConfigExtensions & SettingElementConfig & SettingNestedConfig} SettingControlConfig + * @typedef {{ control: SettingControl } & HTMLElement} SettingControlChild + */ + +/** + * @template T=Event + * @typedef {T & { target: SettingControlChild }} SettingControlEvent + * SettingControlEvent simplifies the types in this file, but causes issues when + * doing more involved work when used in Setting.mjs. When casting the + * `event.target` to a more specific type like MozButton (or even + * HTMLButtonElement) it gets flagged as being too different from SettingControlChild. */ /** * Mapping of parent control tag names to the literal tag name for their * expected children. eg. "moz-radio-group"->literal`moz-radio`. - * - * @type Map<string, literal> */ const KNOWN_OPTIONS = new Map([ ["moz-radio-group", literal`moz-radio`], @@ -89,6 +117,7 @@ export class SettingControl extends SettingElement { constructor() { super(); + /** @type {Ref<LitElement>} */ this.controlRef = createRef(); /** @@ -102,7 +131,7 @@ export class SettingControl extends SettingElement { this.setting = undefined; /** - * @type {PreferencesSettingsConfig | undefined} + * @type {SettingControlConfig | undefined} */ this.config = undefined; @@ -122,7 +151,7 @@ export class SettingControl extends SettingElement { } focus() { - this.controlRef.value.focus(); + this.controlEl.focus(); } get controlEl() { @@ -162,9 +191,6 @@ export class SettingControl extends SettingElement { } } - /** - * @type {MozLitElement['updated']} - */ updated() { const control = this.controlRef?.value; if (!control) { @@ -189,7 +215,7 @@ export class SettingControl extends SettingElement { * or controlled by an extension but not both. * * @override - * @param {PreferencesSettingsConfig} config + * @param {SettingElementConfig} config * @returns {ReturnType<SettingElement['getCommonPropertyMapping']>} */ getCommonPropertyMapping(config) { @@ -203,7 +229,7 @@ export class SettingControl extends SettingElement { /** * The default properties for an option. * - * @param {PreferencesSettingConfigNestedControlOption | SettingNestedElementOption} config + * @param {SettingOptionConfig} config */ getOptionPropertyMapping(config) { const props = this.getCommonPropertyMapping(config); @@ -215,6 +241,8 @@ export class SettingControl extends SettingElement { /** * The default properties for this control. + * + * @param {SettingControlConfig} config */ getControlPropertyMapping(config) { const props = this.getCommonPropertyMapping(config); @@ -236,23 +264,38 @@ export class SettingControl extends SettingElement { }; /** - * @param {MozCheckbox | HTMLInputElement} el - * @returns {boolean | string | undefined} + * @param {HTMLElement} el + * @returns {any} */ controlValue(el) { - if (el.constructor.activatedProperty && el.localName != "moz-radio") { - return el[el.constructor.activatedProperty]; - } else if (el.localName == "moz-input-folder") { + let Cls = el.constructor; + if ( + "activatedProperty" in Cls && + Cls.activatedProperty && + el.localName != "moz-radio" + ) { + return el[/** @type {keyof typeof el} */ (Cls.activatedProperty)]; + } + if (el instanceof MozInputFolder) { return el.folder; } - return el.value; + return "value" in el ? el.value : null; } - // Called by our parent when our input changed. + /** + * Called by our parent when our input changed. + * + * @param {SettingControlChild} el + */ onChange(el) { this.setting.userChange(this.controlValue(el)); } + /** + * Called by our parent when our input is clicked. + * + * @param {MouseEvent} event + */ onClick(event) { this.setting.userClick(event); } @@ -273,9 +316,14 @@ export class SettingControl extends SettingElement { this.showEnableExtensionMessage = false; } + /** + * @param {MouseEvent} event + */ navigateToAddons(event) { - if (event.target.matches("a[data-l10n-name='addons-link']")) { + let link = /** @type {HTMLAnchorElement} */ (event.target); + if (link.matches("a[data-l10n-name='addons-link']")) { event.preventDefault(); + // @ts-ignore let mainWindow = window.browsingContext.topChromeWindow; mainWindow.BrowserAddonUI.openAddonsMgr("addons://list/theme"); } @@ -292,8 +340,8 @@ export class SettingControl extends SettingElement { /** * Prepare nested item config and settings. * - * @param {PreferencesSettingConfigNestedControlOption} config - * @returns {Array<string>} + * @param {SettingControlConfig | SettingOptionConfig} config + * @returns {TemplateResult[]} */ itemsTemplate(config) { if (!config.items) { @@ -319,8 +367,8 @@ export class SettingControl extends SettingElement { /** * Prepares any children (and any of its children's children) that this element may need. * - * @param {PreferencesSettingConfigNestedControlOption | SettingNestedElementOption} config - * @returns {Array<string>} + * @param {SettingOptionConfig} config + * @returns {TemplateResult[]} */ optionsTemplate(config) { if (!config.options) { @@ -331,9 +379,11 @@ export class SettingControl extends SettingElement { let optionTag = opt.control ? unsafeStatic(opt.control) : KNOWN_OPTIONS.get(control); + let children = + "items" in opt ? this.itemsTemplate(opt) : this.optionsTemplate(opt); return staticHtml`<${optionTag} ${spread(this.getOptionPropertyMapping(opt))} - >${"items" in opt ? this.itemsTemplate(opt) : this.optionsTemplate(opt)}</${optionTag}>`; + >${children}</${optionTag}>`; }); } diff --git a/browser/components/preferences/widgets/setting-element/setting-element.mjs b/browser/components/preferences/widgets/setting-element/setting-element.mjs @@ -11,6 +11,19 @@ import { import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +/** @import { AttributePart } from "chrome://global/content/vendor/lit.all.mjs" */ + +/** + * @typedef {Object} SettingElementConfig + * @property {string} [id] - The ID for the Setting, this should match the layout id + * @property {string} [l10nId] - The Fluent l10n ID for the setting + * @property {Record<string, string>} [l10nArgs] - An object containing l10n IDs and their values that will be translated with Fluent + * @property {Record<string, any>} [controlAttrs] - An object of additional attributes to be set on the control. These can be used to further customize the control for example a message bar of the warning type, or what dialog a button should open + * @property {string} [iconSrc] - A path to the icon for the control (if the control supports one) + * @property {string} [supportPage] - The SUMO support page slug for the setting + * @property {string} [subcategory] - The sub-category slug used for direct linking to a setting from SUMO + */ + /** * A Lit directive that applies all properties of an object to a DOM element. * @@ -38,9 +51,10 @@ class SpreadDirective extends Directive { * Render nothing by default as all changes are made in update using DOM APIs * on the element directly. * - * @returns {typeof nothing} + * @param {Record<string, unknown>} props The props to apply to this element. */ - render() { + // eslint-disable-next-line no-unused-vars + render(props) { return nothing; } @@ -59,7 +73,6 @@ class SpreadDirective extends Directive { // implementing the auto-clearing hopefully the consumer will do something // that fits their use case. - /** @type {HTMLElement} */ let el = part.element; for (let [key, value] of Object.entries(props)) { @@ -75,6 +88,7 @@ class SpreadDirective extends Directive { if (key.startsWith("?")) { el.toggleAttribute(key.slice(1), Boolean(value)); } else if (key.startsWith(".")) { + // @ts-ignore el[key.slice(1)] = value; } else if (key.startsWith("@")) { throw new Error( @@ -98,28 +112,20 @@ export class SettingElement extends MozLitElement { /** * The default properties that the setting element accepts. * - * @param {PreferencesSettingsConfig} config - * @returns {Record<string, any>} + * @param {SettingElementConfig} config */ getCommonPropertyMapping(config) { - /** - * @type {Record<string, any>} - */ - const result = { + return { id: config.id, + "data-l10n-id": config.l10nId ? config.l10nId : undefined, "data-l10n-args": config.l10nArgs ? JSON.stringify(config.l10nArgs) : undefined, ".iconSrc": config.iconSrc, "data-subcategory": config.subcategory, + ".supportPage": + config.supportPage != undefined ? config.supportPage : undefined, ...config.controlAttrs, }; - if (config.supportPage != undefined) { - result[".supportPage"] = config.supportPage; - } - if (config.l10nId) { - result["data-l10n-id"] = config.l10nId; - } - return result; } } diff --git a/browser/components/preferences/widgets/setting-group/setting-group.mjs b/browser/components/preferences/widgets/setting-group/setting-group.mjs @@ -8,8 +8,19 @@ import { spread, } from "chrome://browser/content/preferences/widgets/setting-element.mjs"; -/** @import { SettingControl } from "../setting-control/setting-control.mjs"; */ -/** @import {PreferencesSettingsConfig, Preferences} from "chrome://global/content/preferences/Preferences.mjs" */ +/** @import { SettingElementConfig } from "chrome://browser/content/preferences/widgets/setting-element.mjs" */ +/** @import { SettingControlConfig, SettingControlEvent } from "../setting-control/setting-control.mjs" */ +/** @import { Preferences } from "chrome://global/content/preferences/Preferences.mjs" */ + +/** + * @typedef {object} SettingGroupConfigExtensions + * @property {SettingControlConfig[]} items Array of SettingControlConfigs to render. + * @property {number} [headingLevel] A heading level to create the legend as (1-6). + * @property {boolean} [inProgress] + * Hide this section unless the browser.settings-redesign.enabled or + * browser.settings-redesign.<groupid>.enabled prefs are true. + */ +/** @typedef {SettingElementConfig & SettingGroupConfigExtensions} SettingGroupConfig */ const CLICK_HANDLERS = new Set([ "dialog-button", @@ -30,7 +41,7 @@ export class SettingGroup extends SettingElement { this.getSetting = undefined; /** - * @type {PreferencesSettingsConfig | undefined} + * @type {SettingGroupConfig | undefined} */ this.config = undefined; } @@ -51,9 +62,10 @@ export class SettingGroup extends SettingElement { async handleVisibilityChange() { await this.updateComplete; + // @ts-expect-error bug 1997478 let hasVisibleControls = [...this.controlEls].some(el => !el.hidden); this.hidden = !hasVisibleControls; - let groupbox = this.closest("groupbox"); + let groupbox = /** @type {XULElement} */ (this.closest("groupbox")); if (hasVisibleControls) { this.removeAttribute("data-hidden-from-search"); if (groupbox && groupbox.hasAttribute("data-hidden-by-setting-group")) { @@ -73,6 +85,7 @@ export class SettingGroup extends SettingElement { async getUpdateComplete() { let result = await super.getUpdateComplete(); + // @ts-expect-error bug 1997478 await Promise.all([...this.controlEls].map(el => el.updateComplete)); return result; } @@ -81,24 +94,31 @@ export class SettingGroup extends SettingElement { * Notify child controls when their input has fired an event. When controls * are nested the parent receives events for the nested controls, so this is * actually easier to manage here; it also registers fewer listeners. + * + * @param {SettingControlEvent<InputEvent>} e */ onChange(e) { let inputEl = e.target; - let control = inputEl.control; - control?.onChange(inputEl); + inputEl.control?.onChange(inputEl); } + /** + * Notify child controls when their input has been clicked. When controls + * are nested the parent receives events for the nested controls, so this is + * actually easier to manage here; it also registers fewer listeners. + * + * @param {SettingControlEvent<MouseEvent>} e + */ onClick(e) { - if (!CLICK_HANDLERS.has(e.target.localName)) { + let inputEl = e.target; + if (!CLICK_HANDLERS.has(inputEl.localName)) { return; } - let inputEl = e.target; - let control = inputEl.control; - control?.onClick(e); + inputEl.control?.onClick(e); } /** - * @param {PreferencesSettingsConfig} item + * @param {SettingControlConfig} item */ itemTemplate(item) { let setting = this.getSetting(item.id); diff --git a/browser/components/preferences/widgets/setting-pane/setting-pane.mjs b/browser/components/preferences/widgets/setting-pane/setting-pane.mjs @@ -5,6 +5,13 @@ import { html } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +/** + * @typedef {object} SettingPaneConfig + * @property {string} [parent] The pane that links to this one. + * @property {string} l10nId Fluent id for the heading/description. + * @property {string[]} groupIds What setting groups should be rendered. + */ + export class SettingPane extends MozLitElement { static properties = { name: { type: String }, @@ -12,6 +19,16 @@ export class SettingPane extends MozLitElement { config: { type: Object }, }; + constructor() { + super(); + /** @type {string} */ + this.name = undefined; + /** @type {boolean} */ + this.isSubPane = false; + /** @type {SettingPaneConfig} */ + this.config = undefined; + } + createRenderRoot() { return this; } @@ -50,6 +67,7 @@ export class SettingPane extends MozLitElement { document.getElementById("categories").append(categoryButton); } + /** @param {string} groupId */ groupTemplate(groupId) { return html`<setting-group groupid=${groupId}></setting-group>`; } diff --git a/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs b/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs @@ -548,7 +548,7 @@ export var ExtensionSettingsStore = { * The id of the extension for which the setting is being retrieved. * Defaults to undefined, in which case the top setting is returned. * - * @returns {object} An object with properties for key, value and id. + * @returns {{ id: string, key: string, value: string}} An object with properties for key, value and id. */ getSetting(type, key, id) { return getItem(type, key, id); diff --git a/toolkit/content/preferences/AsyncSetting.mjs b/toolkit/content/preferences/AsyncSetting.mjs @@ -6,21 +6,25 @@ const { EventEmitter } = ChromeUtils.importESModule( "resource://gre/modules/EventEmitter.sys.mjs" ); +/** @import { SettingControlConfig } from "chrome://browser/content/preferences/widgets/setting-control.mjs" */ +/** @import { SettingConfig, SettingValue } from "./Setting.mjs" */ + /** * This is the interface for the async setting classes to implement. * * For the actual implementation see AsyncSettingMixin. */ export class AsyncSetting extends EventEmitter { - /** @type {string} */ static id = ""; - /** @type {Object} */ + /** @type {SettingConfig['controllingExtensionInfo']} */ static controllingExtensionInfo; + /** @type {SettingValue} */ defaultValue = ""; defaultDisabled = false; defaultVisible = true; + /** @type {Partial<SettingControlConfig>} */ defaultGetControlConfig = {}; /** @@ -35,7 +39,7 @@ export class AsyncSetting extends EventEmitter { * Setup any external listeners that are required for managing this * setting's state. When the state needs to update the Setting.emitChange method should be called. * - * @returns {Function | void} Teardown function to clean up external listeners. + * @returns {ReturnType<SettingConfig['setup']>} Teardown function to clean up external listeners. */ setup() {} @@ -43,7 +47,7 @@ export class AsyncSetting extends EventEmitter { * Get the value of this setting. * * @abstract - * @returns {Promise<boolean | number | string | void>} + * @returns {Promise<SettingValue>} */ async get() {} @@ -51,10 +55,11 @@ export class AsyncSetting extends EventEmitter { * Set the value of this setting. * * @abstract - * @param value {any} The value from the input that triggered the update. + * @param {SettingValue} value The value from the input that triggered the update. * @returns {Promise<void>} */ - async set() {} + // eslint-disable-next-line no-unused-vars + async set(value) {} /** * Whether the control should be disabled. @@ -78,7 +83,7 @@ export class AsyncSetting extends EventEmitter { * Override the initial control config. This will be spread into the * initial config, with this object taking precedence. * - * @returns {Promise<Object>} + * @returns {Promise<Partial<SettingControlConfig>>} */ async getControlConfig() { return {}; @@ -88,15 +93,25 @@ export class AsyncSetting extends EventEmitter { * Callback fired after a user has changed the setting's value. Useful for * recording telemetry. * - * @param value {any} - * @returns {Promise<void>} + * @param {SettingValue} value */ - async onUserChange() {} + // eslint-disable-next-line no-unused-vars + onUserChange(value) {} + + /** + * Callback fired after a user has clicked a setting's control. + * + * @param {MouseEvent} event + */ + // eslint-disable-next-line no-unused-vars + onUserClick(event) {} } /** * Wraps an AsyncSetting and adds caching of values to provide a synchronous * API to the Setting class. + * + * @implements {SettingConfig} */ export class AsyncSettingHandler { /** @type {AsyncSetting} */ @@ -105,41 +120,48 @@ export class AsyncSettingHandler { /** @type {Function} */ #emitChange; - /** @type {import("./Setting.mjs").PreferenceSettingDepsMap} */ - deps; + /** @type {string} */ + pref; + + /** + * Dependencies are not supported on AsyncSettings, but we include an empty + * array for consistency with {@link SettingConfig}. + * + * @type {string[]} + */ + deps = []; - /** @type {Setting} */ - setting; + /** @type {SettingConfig['controllingExtensionInfo']} */ + controllingExtensionInfo; /** - * @param {AsyncSetting} asyncSetting + * @param {string} id + * @param {typeof AsyncSetting} AsyncSettingClass */ - constructor(asyncSetting) { - this.asyncSetting = asyncSetting; + constructor(id, AsyncSettingClass) { + this.asyncSetting = new AsyncSettingClass(); + this.id = id; + this.controllingExtensionInfo = AsyncSettingClass.controllingExtensionInfo; this.#emitChange = () => {}; // Initialize cached values with defaults - this.cachedValue = asyncSetting.defaultValue; - this.cachedDisabled = asyncSetting.defaultDisabled; - this.cachedVisible = asyncSetting.defaultVisible; - this.cachedGetControlConfig = asyncSetting.defaultGetControlConfig; + this.cachedValue = this.asyncSetting.defaultValue; + this.cachedDisabled = this.asyncSetting.defaultDisabled; + this.cachedVisible = this.asyncSetting.defaultVisible; + this.cachedGetControlConfig = this.asyncSetting.defaultGetControlConfig; // Listen for change events from the async setting this.asyncSetting.on("change", () => this.refresh()); } /** - * @param emitChange {Function} - * @param deps {Record<string, Setting>} - * @param setting {Setting} - * @returns {Function | void} + * @param {() => any} emitChange + * @returns {ReturnType<SettingConfig['setup']>} */ - setup(emitChange, deps, setting) { + setup(emitChange) { let teardown = this.asyncSetting.setup(); this.#emitChange = emitChange; - this.deps = deps; - this.setting = setting; this.refresh(); return teardown; @@ -164,14 +186,14 @@ export class AsyncSettingHandler { } /** - * @returns {boolean | number | string | void} + * @returns {SettingValue} */ get() { return this.cachedValue; } /** - * @param value {any} + * @param {any} value * @returns {Promise<void>} */ set(value) { @@ -193,8 +215,8 @@ export class AsyncSettingHandler { } /** - * @param config {Object} - * @returns {Object} + * @param {SettingControlConfig} config + * @returns {SettingControlConfig} */ getControlConfig(config) { return { @@ -204,10 +226,16 @@ export class AsyncSettingHandler { } /** - * @param value {any} - * @returns {Promise<void>} + * @param {SettingValue} value */ onUserChange(value) { return this.asyncSetting.onUserChange(value); } + + /** + * @param {MouseEvent} event + */ + onUserClick(event) { + this.asyncSetting.onUserClick(event); + } } diff --git a/toolkit/content/preferences/Preference.mjs b/toolkit/content/preferences/Preference.mjs @@ -9,7 +9,11 @@ const { EventEmitter } = ChromeUtils.importESModule( ); /** - * @typedef {object} PreferenceConfigInfo + * @typedef {string | boolean | number | nsIFile | void} PreferenceValue + */ + +/** + * @typedef {object} PreferenceConfig * @property {string} id * @property {string} type * @property {boolean} [inverted] @@ -29,12 +33,12 @@ function getElementsByAttribute(name, value) { export class Preference extends EventEmitter { /** - * @type {string | undefined | null} + * @type {PreferenceValue} */ _value; /** - * @param {PreferenceConfigInfo} configInfo + * @param {PreferenceConfig} configInfo */ constructor({ id, type, inverted }) { super(); @@ -89,7 +93,7 @@ export class Preference extends EventEmitter { } if (aElement.labels?.length) { for (let label of aElement.labels) { - label.toggleAttribute("disabled", this.locked); + /** @type {Element} */ (label).toggleAttribute("disabled", this.locked); } } @@ -117,9 +121,14 @@ export class Preference extends EventEmitter { * the property setter does not yet exist by setting the same attribute * on the XUL element using DOM apis and assuming the element's * constructor or property getters appropriately handle this state. + * + * @param {Element} element + * @param {string} attribute + * @param {string} value */ function setValue(element, attribute, value) { if (attribute in element) { + // @ts-expect-error The property might not be writable... element[attribute] = value; } else if (attribute === "checked" || attribute === "pressed") { // The "checked" attribute can't simply be set to the specified value; @@ -151,7 +160,7 @@ export class Preference extends EventEmitter { /** * @param {HTMLElement} aElement - * @returns {string} + * @returns {PreferenceValue} */ getElementValue(aElement) { if (Preferences._syncToPrefListeners.has(aElement)) { @@ -170,10 +179,14 @@ export class Preference extends EventEmitter { * attribute is a property on the element's node API. If the property * is not present in the API, then assume its value is contained in * an attribute, as is the case before a binding has been attached. + * + * @param {Element} element + * @param {string} attribute + * @returns {any} */ function getValue(element, attribute) { if (attribute in element) { - return element[attribute]; + return element[/** @type {keyof typeof element} */ (attribute)]; } return element.getAttribute(attribute); } @@ -181,7 +194,8 @@ export class Preference extends EventEmitter { if ( aElement.localName == "checkbox" || aElement.localName == "moz-checkbox" || - (aElement.localName == "input" && aElement.type == "checkbox") + (aElement.localName == "input" && + /** @type {HTMLInputElement} */ (aElement).type == "checkbox") ) { value = getValue(aElement, "checked"); } else if (aElement.localName == "moz-toggle") { @@ -201,7 +215,6 @@ export class Preference extends EventEmitter { /** * @param {HTMLElement} aElement - * @returns {boolean} */ isElementEditable(aElement) { switch (aElement.localName) { @@ -241,14 +254,14 @@ export class Preference extends EventEmitter { } /** - * @type {Preference['_value']} + * @type {PreferenceValue} */ get value() { return this._value; } /** - * @param {string} val + * @param {PreferenceValue} val */ set value(val) { if (this.value !== val) { @@ -293,6 +306,7 @@ export class Preference extends EventEmitter { return Services.prefs.prefHasUserValue(this.id) && this.value !== undefined; } + /** @returns {PreferenceValue} */ get defaultValue() { this._useDefault = true; const val = this.valueFromPreferences; @@ -305,7 +319,7 @@ export class Preference extends EventEmitter { } /** - * @type {string} + * @type {PreferenceValue} */ get valueFromPreferences() { try { @@ -344,7 +358,7 @@ export class Preference extends EventEmitter { } /** - * @param {string} val + * @param {PreferenceValue} val */ set valueFromPreferences(val) { // Exit early if nothing to do. @@ -361,23 +375,23 @@ export class Preference extends EventEmitter { // Force a resync of preferences with value. switch (this.type) { case "int": - Services.prefs.setIntPref(this.id, val); + Services.prefs.setIntPref(this.id, /** @type {number} */ (val)); break; case "bool": - Services.prefs.setBoolPref(this.id, this.inverted ? !val : val); + Services.prefs.setBoolPref(this.id, this.inverted ? !val : !!val); break; case "wstring": { const pls = Cc["@mozilla.org/pref-localizedstring;1"].createInstance( Ci.nsIPrefLocalizedString ); - pls.data = val; + pls.data = /** @type {string} */ (val); Services.prefs.setComplexValue(this.id, Ci.nsIPrefLocalizedString, pls); break; } case "string": case "unichar": case "fontname": - Services.prefs.setStringPref(this.id, val); + Services.prefs.setStringPref(this.id, /** @type {string} */ (val)); break; case "file": { let lf; @@ -388,7 +402,7 @@ export class Preference extends EventEmitter { lf.initWithPath(val); } } else { - lf = val.QueryInterface(Ci.nsIFile); + lf = /** @type {nsIFile} */ (val).QueryInterface(Ci.nsIFile); } Services.prefs.setComplexValue(this.id, Ci.nsIFile, lf); break; diff --git a/toolkit/content/preferences/Preferences.mjs b/toolkit/content/preferences/Preferences.mjs @@ -6,134 +6,17 @@ import { AsyncSetting } from "chrome://global/content/preferences/AsyncSetting.m import { Preference } from "chrome://global/content/preferences/Preference.mjs"; import { Setting } from "chrome://global/content/preferences/Setting.mjs"; -/** @import {PreferenceConfigInfo} from "chrome://global/content/preferences/Preference.mjs" */ -/** @import {PreferenceSettingDepsMap} from "chrome://global/content/preferences/Setting.mjs" */ +/** @import {PreferenceConfig} from "chrome://global/content/preferences/Preference.mjs" */ +/** @import {SettingConfig} from "chrome://global/content/preferences/Setting.mjs" */ +/** @import {DeferredTask} from "resource://gre/modules/DeferredTask.sys.mjs" */ /** - * @callback PreferenceSettingVisibleFunction - * @param {PreferenceSettingDepsMap} deps - * @param {Setting} setting - * @returns {boolean | string | undefined} If truthy shows the setting in the UI, or hides it if not - */ - -/** - * Gets the value of a {@link PreferencesSettingsConfig}. - * - * @callback PreferenceSettingGetter - * @param {string | number} val - The value that was retrieved from the preferences backend - * @param {PreferenceSettingDepsMap} deps - * @param {Setting} setting - * @returns {any} - The value to set onto the setting - */ - -/** - * Sets the value of a {@link PreferencesSettingsConfig}. - * - * @callback PreferenceSettingSetter - * @param {string | undefined} val - The value/pressed/checked from the input (control) associated with the setting - * @param {PreferenceSettingDepsMap} deps - * @param {Setting} setting - * @returns {void} - */ - -/** - * @callback PreferencesSettingOnUserChangeFunction - * @param {string} val - The value/pressed/checked from the input of the control associated with the setting - * @param {PreferenceSettingDepsMap} deps - * @param {Setting} setting - * @returns {void} - */ - -/** - * @callback PreferencesSettingConfigDisabledFunction - * @param {PreferenceSettingDepsMap} deps - * @param {Setting} setting - * @returns {boolean} - */ - -/** - * @callback PreferencesSettingGetControlConfigFunction - * @param {PreferencesSettingsConfig} config - * @param {PreferenceSettingDepsMap} deps - * @param {Setting} setting - * @returns {PreferencesSettingsConfig | undefined} - */ - -/** - * @callback PreferencesSettingConfigTeardownFunction - * @returns {void} - */ - -/** - * @callback PreferencesSettingConfigSetupFunction - * @param {Function} emitChange - * @param {PreferenceSettingDepsMap} deps - * @param {Setting} setting - * @returns {PreferencesSettingConfigTeardownFunction | void} - */ - -/** - * @callback PreferencesSettingConfigOnUserClickFunction - * @param {Event} event - * @param {PreferenceSettingDepsMap} deps - * @param {Setting} setting - * @returns {void} - */ - -/** - * @typedef {Record<string, any>} PreferencesSettingConfigControlAttributes - */ - -/** - * @typedef {Omit<PreferencesSettingConfigNestedControlOption, 'id | value'>} PreferencesSettingConfigNestedElementOption - */ - -/** - * A set of properties that represent a nested control or element. - * - * @typedef {object} PreferencesSettingConfigNestedControlOption - * @property {string} [control] - The {@link HTMLElement#localName} of any HTML element that will be nested as a direct descendant of the control element. A moz-checkbox will be rendered by default. - * @property {PreferencesSettingConfigControlAttributes} [controlAttrs] - A map of any attributes to add to the control - * @property {string} [l10nId] - The fluent ID of the control - * @property {Array<PreferencesSettingConfigNestedElementOption>} [options] - Options for additional nested HTML elements. This will be overridden if items property is used. - * @property {string} [id] - * @property {string} [value] - An optional initial value used for the control element if it's an input element that supports a value property - * @property {Array<PreferencesSettingsConfig>} [items] - A list of setting control items that will get rendered as direct descendants of the setting control. This overrides the options property. - */ - -/** - * @typedef {object} PreferencesSettingsConfig - * @property {string} id - The ID for the Setting, this should match the layout id - * @property {string} [l10nId] - The Fluent l10n ID for the setting - * @property {Record<string, string>} [l10nArgs] - An object containing l10n IDs and their values that will be translated with Fluent - * @property {string} [pref] - A {@link Services.prefs} id that will be used as the backend if it is provided - * @property {PreferenceSettingVisibleFunction} [visible] - Function to determine if a setting is visible in the UI - * @property {PreferenceSettingGetter} [get] - Function to get the value of the setting. Optional if {@link PreferencesSettingsConfig#pref} is set. - * @property {PreferenceSettingSetter} [set] - Function to set the value of the setting. Optional if {@link PreferencesSettingsConfig#pref} is set. - * @property {PreferencesSettingGetControlConfigFunction} [getControlConfig] - Function that allows the setting to modify its layout, this is intended to be used to provide the options, {@link PreferencesSettingsConfig#l10nId} or {@link PreferencesSettingsConfig#l10nArgs} data if necessary, but technically it can change anything (that doesn't mean it will have any effect though). - * @property {PreferencesSettingOnUserChangeFunction} [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 PreferencesSettingsConfig.set} function. - * @property {Array<PreferencesSettingsConfig> | undefined} [items] - * @property {PreferencesSettingConfigNestedControlOption['control']} [control] - The {@link HTMLElement#localName} of any HTML element that will be rendered as a control in the UI for the setting. - * @property {PreferencesSettingConfigSetupFunction} [setup] - A function to be called to register listeners for - * the setting. It should return a {@link PreferencesSettingConfigTeardownFunction} 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 {PreferencesSettingConfigDisabledFunction} [disabled] - A function to determine if a setting should be disabled - * @property {PreferencesSettingConfigOnUserClickFunction} [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 {Array<string> | void} [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 {PreferencesSettingConfigControlAttributes} [controlAttrs] - An object of additional attributes to be set on the control. These can be used to further customize the control for example a message bar of the warning type, or what dialog a button should open - * @property {Array<PreferencesSettingConfigNestedControlOption>} [options] - An optional list of nested controls for this setting (select options, radio group radios, etc) - * @property {string} [iconSrc] - A path to the icon for the control (if the control supports one) - * @property {string} [supportPage] - The SUMO support page slug for the setting - * @property {string} [subcategory] - The sub-category slug used for direct linking to a setting from SUMO + * @typedef {{ _deferredValueUpdateTask: DeferredTask }} DeferredValueObject + * @typedef {DeferredValueObject & HTMLElement} DeferredValueHTMLElement */ +/** @type {{ DeferredTask: typeof DeferredTask }} */ +// @ts-expect-error bug 1996860 const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", @@ -158,7 +41,7 @@ export const Preferences = { _settings: new Map(), /** - * @param {PreferenceConfigInfo} prefInfo + * @param {PreferenceConfig} prefInfo */ _add(prefInfo) { if (this._all[prefInfo.id]) { @@ -175,7 +58,7 @@ export const Preferences = { }, /** - * @param {PreferenceConfigInfo} prefInfo + * @param {PreferenceConfig} prefInfo * @returns {Preference} */ add(prefInfo) { @@ -184,7 +67,7 @@ export const Preferences = { }, /** - * @param {Array<PreferenceConfigInfo>} prefInfos + * @param {Array<PreferenceConfig>} prefInfos */ addAll(prefInfos) { prefInfos.map(prefInfo => this._add(prefInfo)); @@ -199,7 +82,7 @@ export const Preferences = { }, /** - * @returns {Array<PreferenceConfigInfo>} + * @returns {Array<Preference>} */ getAll() { return Object.values(this._all); @@ -210,7 +93,7 @@ export const Preferences = { * that includes all of the configuration for the control * such as its Fluent strings, support page, subcategory etc. * - * @param {PreferencesSettingsConfig} settingConfig + * @param {SettingConfig} settingConfig */ addSetting(settingConfig) { this._settings.set( @@ -252,7 +135,12 @@ export const Preferences = { this._instantApplyForceEnabled = true; }, - observe(subject, topic, data) { + /** + * @param {nsISupports} _ + * @param {string} __ + * @param {string} data + */ + observe(_, __, data) { const pref = this._all[data]; if (pref) { pref.value = pref.valueFromPreferences; @@ -293,15 +181,21 @@ export const Preferences = { }); }, - onUnload() { + /** + * @param {Event} _ + */ + onUnload(_) { this._settings.forEach(setting => setting?.destroy?.()); - Services.prefs.removeObserver("", this); + Services.prefs.removeObserver("", /** @type {nsIObserver} */ (this)); }, QueryInterface: ChromeUtils.generateQI(["nsITimerCallback", "nsIObserver"]), _deferredValueUpdateElements: new Set(), + /** + * @param {boolean} aFlushToDisk + */ writePreferences(aFlushToDisk) { // Write all values to preferences. if (this._deferredValueUpdateElements.size) { @@ -319,6 +213,9 @@ export const Preferences = { } }, + /** + * @param {HTMLElement} aStartElement + */ getPreferenceElement(aStartElement) { let temp = aStartElement; while ( @@ -326,11 +223,15 @@ export const Preferences = { temp.nodeType == Node.ELEMENT_NODE && !temp.hasAttribute("preference") ) { + // @ts-expect-error temp = temp.parentNode; } return temp && temp.nodeType == Node.ELEMENT_NODE ? temp : aStartElement; }, + /** + * @param {DeferredValueHTMLElement} aElement + */ _deferredValueUpdate(aElement) { delete aElement._deferredValueUpdateTask; const prefID = aElement.getAttribute("preference"); @@ -348,8 +249,13 @@ export const Preferences = { } }, + /** + * @param {HTMLElement} aElement + */ userChangedValue(aElement) { - const element = this.getPreferenceElement(aElement); + const element = /** @type {DeferredValueHTMLElement} */ ( + this.getPreferenceElement(aElement) + ); if (element.hasAttribute("preference")) { if (element.getAttribute("delayprefsave") != "true") { const preference = Preferences.get(element.getAttribute("preference")); @@ -371,27 +277,37 @@ export const Preferences = { } }, + /** + * @typedef {{ sourceEvent: CommandEventWithSource } & CommandEvent} CommandEventWithSource + * @param {CommandEventWithSource} event + */ onCommand(event) { // This "command" event handler tracks changes made to preferences by // the user in this window. if (event.sourceEvent) { event = event.sourceEvent; } - this.userChangedValue(event.target); + this.userChangedValue(/** @type {HTMLElement} */ (event.target)); }, + /** @param {Event} event */ onChange(event) { // This "change" event handler tracks changes made to preferences by // the user in this window. - this.userChangedValue(event.target); + this.userChangedValue(/** @type {HTMLElement} */ (event.target)); }, + /** @param {Event} event */ onInput(event) { // This "input" event handler tracks changes made to preferences by // the user in this window. - this.userChangedValue(event.target); + this.userChangedValue(/** @type {HTMLElement} */ (event.target)); }, + /** + * @param {string} aEventName + * @param {HTMLElement} aTarget + */ _fireEvent(aEventName, aTarget) { try { const event = new CustomEvent(aEventName, { @@ -405,6 +321,9 @@ export const Preferences = { return false; }, + /** + * @param {Event} event + */ onDialogAccept(event) { let dialog = document.querySelector("dialog"); if (!this._fireEvent("beforeaccept", dialog)) { @@ -415,6 +334,9 @@ export const Preferences = { return true; }, + /** + * @param {Event} event + */ close(event) { if (Preferences.instantApply) { window.close(); @@ -423,13 +345,16 @@ export const Preferences = { event.preventDefault(); }, + /** + * @param {Event} event + */ handleEvent(event) { switch (event.type) { case "toggle": case "change": return this.onChange(event); case "command": - return this.onCommand(event); + return this.onCommand(/** @type {CommandEventWithSource} */ (event)); case "dialogaccept": return this.onDialogAccept(event); case "input": @@ -484,10 +409,16 @@ export const Preferences = { } }, + /** + * @param {Element} aElement + */ removeSyncFromPrefListener(aElement) { this._syncFromPrefListeners.delete(aElement); }, + /** + * @param {Element} aElement + */ removeSyncToPrefListener(aElement) { this._syncToPrefListeners.delete(aElement); }, @@ -497,7 +428,7 @@ export const Preferences = { Setting, }; -Services.prefs.addObserver("", Preferences); +Services.prefs.addObserver("", /** @type {nsIObserver} */ (Preferences)); window.addEventListener("toggle", Preferences); window.addEventListener("change", Preferences); window.addEventListener("command", Preferences); diff --git a/toolkit/content/preferences/Setting.mjs b/toolkit/content/preferences/Setting.mjs @@ -8,13 +8,140 @@ import { } 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 { PreferencesSettingsConfig } 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} + */ + +/** + * @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. + */ + +/** + * @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">} [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. + */ 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: @@ -23,19 +150,11 @@ ChromeUtils.defineESModuleGetters(lazy, { Management: "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 | undefined>} PreferenceSettingDepsMap - */ - -/** - * @typedef {string | boolean | number} SettingValue - */ - 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?` @@ -48,7 +167,7 @@ class PreferenceNotAddedError extends Error { export class Setting extends EventEmitter { /** - * @type {Preference | undefined | null} + * @type {Preference} */ pref; @@ -56,36 +175,45 @@ export class Setting extends EventEmitter { * Keeps a cache of each dep's Setting so that * it can be easily looked up by its ID. * - * @type {PreferenceSettingDepsMap | undefined} + * @type {SettingDeps} */ _deps; /** - * @type {PreferencesSettingsConfig} + * @type {SettingConfig | AsyncSettingHandler} */ config; /** - * @param {PreferencesSettingsConfig['id']} id - * @param {PreferencesSettingsConfig} config + * @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) { - config = new AsyncSettingHandler(new config()); + configObj = new AsyncSettingHandler( + id, + /** @type {typeof AsyncSetting} */ (config) + ); + } else { + configObj = config; } this.id = id; - this.config = config; - this.pref = config.pref && Preferences.get(config.pref); - if (config.pref && !this.pref) { - throw new PreferenceNotAddedError(id, config.pref); + 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, }; @@ -113,14 +241,14 @@ export class Setting extends EventEmitter { /** * A map of each dep and it's associated {@link Setting} instance. * - * @type {PreferenceSettingDepsMap} + * @type {SettingDeps} */ get deps() { if (this._deps) { return this._deps; } /** - * @type {PreferenceSettingDepsMap} + * @type {SettingDeps} */ const deps = {}; @@ -157,7 +285,7 @@ export class Setting extends EventEmitter { */ set value(val) { let newVal = this.config.set ? this.config.set(val, this.deps, this) : val; - if (this.pref) { + if (this.pref && !(newVal instanceof Object && "then" in newVal)) { this.pref.value = newVal; } } @@ -178,8 +306,8 @@ export class Setting extends EventEmitter { } /** - * @param {PreferencesSettingsConfig} config - * @returns {PreferencesSettingsConfig | undefined} + * @param {SettingControlConfig} config + * @returns {SettingControlConfig} */ getControlConfig(config) { if (this.config.getControlConfig) { @@ -188,6 +316,9 @@ export class Setting extends EventEmitter { return config; } + /** + * @param {MouseEvent} event + */ userClick(event) { if (this.config.onUserClick) { this.config.onUserClick(event, this.deps, this); @@ -210,7 +341,7 @@ export class Setting extends EventEmitter { this.controllingExtensionInfo.id ) { await lazy.ExtensionSettingsStore.initialize(); - let { id } = await lazy.ExtensionSettingsStore.getSetting( + let { id } = lazy.ExtensionSettingsStore.getSetting( "prefs", this.controllingExtensionInfo.storeId ); @@ -221,6 +352,10 @@ export class Setting extends EventEmitter { } } + /** + * @param {any} _ + * @param {{ key: string, type: string }} setting ExtensionSettingsStore setting + */ _observeExtensionSettingChanged = (_, setting) => { if ( setting.key == this.config.controllingExtensionInfo.storeId && diff --git a/toolkit/content/widgets/lit-utils.mjs b/toolkit/content/widgets/lit-utils.mjs @@ -263,7 +263,10 @@ export class MozBaseInputElement extends MozLitElement { ariaLabel: { type: String, mapped: true }, ariaDescription: { type: String, mapped: true }, }; + /** @type {"inline" | "block"} */ static inputLayout = "inline"; + /** @type {keyof MozBaseInputElement} */ + static activatedProperty = null; constructor() { super(); @@ -305,7 +308,9 @@ export class MozBaseInputElement extends MozLitElement { if (changedProperties.has("value")) { this.setFormValue(this.value); } - let activatedProperty = this.constructor.activatedProperty; + let activatedProperty = /** @type {typeof MozBaseInputElement} */ ( + this.constructor + ).activatedProperty; if ( (activatedProperty && changedProperties.has(activatedProperty)) || changedProperties.has("disabled") || diff --git a/tools/@types/generated/tspaths.json b/tools/@types/generated/tspaths.json @@ -125,6 +125,9 @@ "chrome://browser/content/preferences/widgets/setting-group.mjs": [ "browser/components/preferences/widgets/setting-group/setting-group.mjs" ], + "chrome://browser/content/preferences/widgets/setting-pane.mjs": [ + "browser/components/preferences/widgets/setting-pane/setting-pane.mjs" + ], "chrome://browser/content/preferences/widgets/sync-device-name.mjs": [ "browser/components/preferences/widgets/sync-device-name/sync-device-name.mjs" ], @@ -248,6 +251,12 @@ "chrome://global/content/elements/moz-fieldset.mjs": [ "toolkit/content/widgets/moz-fieldset/moz-fieldset.mjs" ], + "chrome://global/content/elements/moz-input-folder.mjs": [ + "toolkit/content/widgets/moz-input-folder/moz-input-folder.mjs" + ], + "chrome://global/content/elements/moz-input-search.mjs": [ + "toolkit/content/widgets/moz-input-search/moz-input-search.mjs" + ], "chrome://global/content/elements/moz-input-text.mjs": [ "toolkit/content/widgets/moz-input-text/moz-input-text.mjs" ], @@ -257,6 +266,9 @@ "chrome://global/content/elements/moz-message-bar.mjs": [ "toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs" ], + "chrome://global/content/elements/moz-radio-group.mjs": [ + "toolkit/content/widgets/moz-radio-group/moz-radio-group.mjs" + ], "chrome://global/content/elements/moz-support-link.mjs": [ "toolkit/content/widgets/moz-support-link/moz-support-link.mjs" ],