morgan pushed to branch tor-browser-153.0esr-16.0-1 at The Tor Project / Applications / Tor Browser Commits: 0b37b3e9 by Henry Wilkes at 2026-08-04T13:23:04+00:00 fixup! TB 31286: Implementation of bridge, proxy, and firewall settings in about:preferences#connection TB 45058: Copy code from content/connectionPane.js to widgets/tor-bridges-display.mjs. Copy markup within connectionPane.inc.xhtml. - - - - - 550bff23 by Henry Wilkes at 2026-08-04T13:23:04+00:00 fixup! TB 31286: Implementation of bridge, proxy, and firewall settings in about:preferences#connection TB 45058: Convert bridge settings to use config. - - - - - 5088e918 by Henry Wilkes at 2026-08-04T13:23:04+00:00 fixup! TB 40597: Implement TorSettings module TB 45058: Clean up TorSettings double init logic. - - - - - 1ab514d3 by Henry Wilkes at 2026-08-04T13:23:04+00:00 fixup! Tor Browser strings TB 45058: Combine bridge setting strings together. - - - - - d064e421 by Henry Wilkes at 2026-08-04T13:23:04+00:00 fixup! Tor Browser localization migration scripts. TB 45058: Combine bridge setting strings together. - - - - - 2a72140f by Henry Wilkes at 2026-08-04T13:23:04+00:00 TB 45143: Modify moz-fieldset. - - - - - 13 changed files: - browser/components/preferences/preferences.js - browser/components/preferences/preferences.xhtml - browser/components/torpreferences/config/connection.mjs - + browser/components/torpreferences/config/helpers.mjs - browser/components/torpreferences/content/connectionPane.inc.xhtml - browser/components/torpreferences/content/torPreferences.css - browser/components/torpreferences/jar.mn - + browser/components/torpreferences/widgets/tor-bridges-display.mjs - toolkit/content/widgets/moz-fieldset/moz-fieldset.css - toolkit/content/widgets/moz-fieldset/moz-fieldset.mjs - toolkit/locales/en-US/toolkit/global/tor-browser.ftl - toolkit/modules/TorSettings.sys.mjs - + tools/torbrowser/l10n/migrations/bug-45058-bridge-settings.py Changes: ===================================== browser/components/preferences/preferences.js ===================================== @@ -271,7 +271,7 @@ const CONFIG_PANES = Object.freeze({ connection: { l10nId: "tor-connection-settings-pane", iconSrc: "chrome://browser/content/torconnect/tor-connect.svg", - groupIds: ["connectionStatus"], + groupIds: ["connectionStatus", "torBridges"], module: "chrome://browser/content/torpreferences/config/connection.mjs", visible: () => { return TorConnect.enabled; ===================================== browser/components/preferences/preferences.xhtml ===================================== @@ -108,6 +108,7 @@ <script type="module" src="chrome://browser/content/preferences/widgets/update-information.mjs"></script> <script type="module" src="chrome://browser/content/preferences/widgets/update-state.mjs"></script> <script type="module" src="chrome://browser/content/ipprotection/bandwidth-usage.mjs"></script> + <script type="module" src="chrome://browser/content/torpreferences/widgets/tor-bridges-display.mjs"></script> <script type="module" src="chrome://browser/content/torpreferences/widgets/tor-connection-status.mjs"></script> <script src="chrome://browser/content/torpreferences/bridgemoji/BridgeEmoji.js"/> </head> ===================================== browser/components/torpreferences/config/connection.mjs ===================================== @@ -4,11 +4,33 @@ import { Preferences } from "chrome://global/content/preferences/Preferences.mjs const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { InternetStatus: "moz-src:///toolkit/modules/TorConnect.sys.mjs", + openBridgeDialog: + "chrome://browser/content/torpreferences/config/helpers.mjs", + openUserProvideBridgeDialog: + "chrome://browser/content/torpreferences/config/helpers.mjs", + TorBridgeSource: "moz-src:///toolkit/modules/TorSettings.sys.mjs", TorConnect: "moz-src:///toolkit/modules/TorConnect.sys.mjs", TorConnectStage: "moz-src:///toolkit/modules/TorConnect.sys.mjs", TorConnectTopics: "moz-src:///toolkit/modules/TorConnect.sys.mjs", + TorProviderBuilder: + "moz-src:///toolkit/components/tor-launcher/TorProviderBuilder.sys.mjs", + TorProviderState: + "moz-src:///toolkit/components/tor-launcher/TorProviderBuilder.sys.mjs", + TorProviderTopics: + "moz-src:///toolkit/components/tor-launcher/TorProviderBuilder.sys.mjs", + TorSettings: "moz-src:///toolkit/modules/TorSettings.sys.mjs", + TorSettingsTopics: "moz-src:///toolkit/modules/TorSettings.sys.mjs", }); +// TODO: Change to GetLoxBridges if Lox enabled, and the account is set up. +const TELEGRAM_USER_NAME = "GetBridgesBot"; +const TELEGRAM_HREF = `https://t.me/${TELEGRAM_USER_NAME}`; + +const TOR_BRIDGES_URL_NAME = "bridges.torproject.org"; +const TOR_BRIDGES_HREF = "https://bridges.torproject.org"; + +const TOR_BRIDGES_EMAIL = "bridges@torproject.org"; + SettingGroupManager.registerGroups({ connectionStatus: { inProgress: true, @@ -39,6 +61,123 @@ SettingGroupManager.registerGroups({ }, ], }, + torBridges: { + inProgress: true, + l10nId: "tor-bridges-group", + supportPage: "tor-manual:bridges", + headingLevel: 2, + controlAttrs: { "focusable-heading": true }, + items: [ + { + id: "bridgesEnabled", + l10nId: "tor-bridges-use-bridges", + control: "moz-toggle", + }, + { + id: "torBridgesDisplay", + control: "tor-bridges-display", + }, + { + id: "newBridgesGroup", + control: "moz-fieldset", + controlAttrs: { + headinglevel: 3, + }, + options: [ + { + control: "moz-box-group", + items: [ + { + id: "builtinBridges", + l10nId: "tor-bridges-choose-built-in-button", + control: "moz-box-button", + }, + { + id: "userProvidedBridges", + l10nId: "tor-bridges-enter-bridges-button", + control: "moz-box-button", + }, + ], + }, + ], + }, + { + id: "findMoreBridgesGroup", + l10nId: "tor-bridges-find-more-group", + control: "moz-fieldset", + controlAttrs: { + headinglevel: 3, + }, + options: [ + { + control: "moz-box-item", + options: [ + { + id: "torBridgesRequestBanner", + control: "article", + options: [ + { + control: "img", + controlAttrs: { + alt: "", + src: "chrome://browser/content/torpreferences/bridge-bot.svg", + }, + }, + { + control: "p", + l10nId: "tor-bridges-request-from-browser2", + }, + { + // NOTE: We use the wrapping `div` to simply switch from the + // `options` context to the `items` context, with the latter + // wrapping the elements in a setting-control. + control: "div", + items: [ + { + id: "requestBridges", + l10nId: "tor-bridges-request-button2", + control: "moz-button", + }, + ], + }, + ], + }, + ], + }, + { + control: "moz-box-group", + options: [ + { + l10nId: "tor-bridges-source-telegram-link", + l10nArgs: { telegramUserName: TELEGRAM_USER_NAME }, + control: "moz-box-link", + iconSrc: + "chrome://browser/content/torpreferences/telegram-logo.svg", + controlAttrs: { + href: TELEGRAM_HREF, + }, + }, + { + l10nId: "tor-bridges-source-web-link", + l10nArgs: { url: TOR_BRIDGES_URL_NAME }, + control: "moz-box-link", + iconSrc: "chrome://browser/content/torconnect/network.svg", + controlAttrs: { + href: TOR_BRIDGES_HREF, + }, + }, + { + l10nId: "tor-bridges-source-email-link", + l10nArgs: { address: TOR_BRIDGES_EMAIL }, + control: "moz-box-item", + iconSrc: "chrome://browser/content/torpreferences/mail.svg", + }, + ], + }, + ], + }, + ], + }, }); Preferences.addSetting({ @@ -113,3 +252,277 @@ Preferences.addSetting({ lazy.TorConnect.quickstart = val; }, }); + +Preferences.addSetting({ + id: "torSettingsReady", + _ready: false, + setup(emitChange) { + if (!lazy.TorSettings.enabled) { + // Remain in `false`. + return; + } + // Most likely, TorSettings will already be initialised. + if (lazy.TorSettings.initialized) { + this._ready = true; + return; + } + // Else, wait for it to be initialised. + lazy.TorSettings.initializedPromise.then( + () => { + this._ready = true; + emitChange(); + }, + error => { + // No change in state. + console.error("TorSettings failed to initialize.", error); + } + ); + }, + get() { + return this._ready; + }, +}); + +Preferences.addSetting({ + id: "torBridgesRaw", + deps: ["torSettingsReady"], + _value: null, + setup(emitChange) { + const observer = subject => { + const { changes } = subject.wrappedJSObject; + // NOTE: We do not include "bridges.lox_id" in the changes. Instead, any + // widgets should wait for LoxTopics.UpdateActiveLoxId to ensure that the + // Lox module has responded to the change in ID strictly *before* we do. + // In particular, we want to make sure the invites and event data has been + // cleared. + if ( + changes.includes("bridges.source") || + changes.includes("bridges.bridge_strings") || + changes.includes("bridges.builtin_type") + ) { + // Reset. + this._value = null; + emitChange(); + } + }; + Services.obs.addObserver(observer, lazy.TorSettingsTopics.SettingsChanged); + return () => { + Services.obs.removeObserver( + observer, + lazy.TorSettingsTopics.SettingsChanged + ); + }; + }, + get(_pref, { torSettingsReady }) { + if (this._value === null) { + if (!torSettingsReady.value) { + // TorSettings getter will throw. + return null; + } + const source = lazy.TorSettings.bridges.source; + // Cache a value. + this._value = { + haveBridges: source !== lazy.TorBridgeSource.Invalid, + source, + builtinType: lazy.TorSettings.bridges.builtin_type, + bridgeStrings: lazy.TorSettings.bridges.bridge_strings, + }; + } + return this._value; + }, +}); + +Preferences.addSetting({ + id: "connectedBridgeId", + _value: null, + setup(emitChange) { + const observer = async () => { + // NOTE: It should be safe for this method to be called concurrently. + let bridge = null; + try { + if ( + lazy.TorProviderBuilder.currentState() === + lazy.TorProviderState.Running + ) { + bridge = (await lazy.TorProviderBuilder.build()).currentBridge; + } + // Else, bridge is `null` whilst the provider is not running. + } catch (e) { + console.warn("Could not get current bridge", e); + } + const prevVal = this._value; + this._value = bridge?.fingerprint ?? null; + if (prevVal !== this._value) { + emitChange(); + } + }; + Services.obs.addObserver(observer, lazy.TorProviderTopics.BridgeChanged); + // NOTE: BridgeChanged is only fired directly by the provider instances, + // rather than by TorProviderBuilder. In particular, it will not fire when + // the previous provider had a bridge and the new one does not, because + // neither provider saw a change in their own bridge. But from the user's + // point of view, the overall change would mean that the current bridge has + // changed. + // Moreover, we want to show no connected bridge whilst we are missing a + // provider. Therefore, we also need to listen for a change in provider and + // its state. + // TODO: Maybe this logic should be moved to TorProviderBuilder itself if it + // is ever needed by other parts of the UI. + Services.obs.addObserver( + observer, + lazy.TorProviderTopics.ProviderStateChanged + ); + // Get the initial value. + observer(); + + return () => { + Services.obs.removeObserver( + observer, + lazy.TorProviderTopics.ProviderStateChanged + ); + Services.obs.removeObserver( + observer, + lazy.TorProviderTopics.BridgeChanged + ); + }; + }, + get() { + return this._value; + }, +}); + +Preferences.addSetting({ + id: "bridgesEnabled", + deps: ["torSettingsReady", "torBridgesRaw"], + setup(emitChange) { + const observer = subject => { + const { changes } = subject.wrappedJSObject; + if (changes.includes("bridges.enabled")) { + emitChange(); + } + }; + Services.obs.addObserver(observer, lazy.TorSettingsTopics.SettingsChanged); + return () => { + Services.obs.removeObserver( + observer, + lazy.TorSettingsTopics.SettingsChanged + ); + }; + }, + get(_prefVal, { torSettingsReady }) { + if (!torSettingsReady.value) { + // TorSettings.bridges will throw before TorSettings has finished + // initialisation. + return false; + } + return lazy.TorSettings.bridges.enabled; + }, + set(val) { + lazy.TorSettings.changeSettings({ + bridges: { enabled: val }, + }); + }, + visible({ torSettingsReady }) { + return torSettingsReady.value; + }, + disabled({ torBridgesRaw }) { + return !torBridgesRaw.value?.haveBridges; + }, +}); + +Preferences.addSetting({ + id: "torBridgesDisplay", + deps: ["torSettingsReady", "torBridgesRaw", "connectedBridgeId"], + getControlConfig(config, { torBridgesRaw, connectedBridgeId }) { + config.controlAttrs = { + ...config.controlAttrs, + // Set the `bridges` and `connnectedBridgeId` object *properties* (rather + // than attributes) by using the `.` prefix. + ".bridges": torBridgesRaw.value, + ".connectedBridgeId": connectedBridgeId.value, + }; + return config; + }, + visible({ torSettingsReady }) { + return torSettingsReady.value; + }, +}); + +Preferences.addSetting({ + id: "newBridgesGroup", + deps: ["torSettingsReady", "torBridgesRaw"], + getControlConfig(config, { torBridgesRaw }) { + config.l10nId = torBridgesRaw.value?.haveBridges + ? "tor-bridges-replace-bridges-group" + : "tor-bridges-add-bridges-group"; + return config; + }, + visible({ torSettingsReady }) { + return torSettingsReady.value; + }, +}); + +Preferences.addSetting({ + id: "builtinBridges", + onUserClick() { + lazy.openBridgeDialog( + window, + "chrome://browser/content/torpreferences/builtinBridgeDialog.xhtml", + null, + result => { + if (!result.type) { + return null; + } + return lazy.TorSettings.changeSettings({ + bridges: { + enabled: true, + source: lazy.TorBridgeSource.BuiltIn, + builtin_type: result.type, + }, + }); + } + ); + }, +}); + +Preferences.addSetting({ + id: "userProvidedBridges", + deps: ["torBridgesRaw"], + onUserClick(_event, { torBridgesRaw }) { + lazy.openUserProvideBridgeDialog( + window, + torBridgesRaw.value?.haveBridges ? "replace" : "add" + ); + }, +}); + +Preferences.addSetting({ + id: "findMoreBridgesGroup", + deps: ["torSettingsReady"], + visible({ torSettingsReady }) { + return torSettingsReady.value; + }, +}); + +Preferences.addSetting({ + id: "requestBridges", + onUserClick() { + lazy.openBridgeDialog( + window, + "chrome://browser/content/torpreferences/requestBridgeDialog.xhtml", + null, + result => { + if (!result.bridges?.length) { + return null; + } + return lazy.TorSettings.changeSettings({ + bridges: { + enabled: true, + source: lazy.TorBridgeSource.BridgeDB, + bridge_strings: result.bridges, + }, + }); + } + ); + }, +}); ===================================== browser/components/torpreferences/config/helpers.mjs ===================================== @@ -0,0 +1,125 @@ +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + TorBridgeSource: "moz-src:///toolkit/modules/TorSettings.sys.mjs", + TorConnectParent: + "moz-src:///browser/components/torconnect/TorConnectParent.sys.mjs", + TorSettings: "moz-src:///toolkit/modules/TorSettings.sys.mjs", +}); + +/** + * Force the focus to move to the bridge heading. + * + * @param {Window} win - The preferences window. + * @param {boolean} [forceTopHeading=false] - Force the focus to move to the + * top "Bridges" setting heading. + */ +export function moveFocusToBridgeHeading(win, forceTopHeading = false) { + // Move focus to the start of the relevant section, which is a heading. + // They have tabindex="-1" so should be focusable, even though they are not + // part of the usual tab navigation. + // TODO: It might be better if we could use the # named anchor to + // re-orient the screen reader position instead of using tabIndex=-1, but + // about:preferences currently uses the anchor for showing categories + // only. See bugzilla bug 1799153. + if ( + forceTopHeading || + !win.document.getElementById("torBridgesDisplay").focusHeading() + ) { + win.document + .querySelector('setting-group[groupid="torBridges"] moz-fieldset') + .focusHeading(); + } +} + +/** + * Open a bridge dialog that will change the users bridges. + * + * @param {Window} win - The preferences window. + * @param {string} url - The url of the dialog to open. + * @param {object?} inputData - The input data to send to the dialog window. + * @param {Function} onAccept - The method to call if the bridge dialog was + * accepted by the user. This will be passed a "result" object containing + * data set by the dialog. This should return a promise that resolves once + * the bridge settings have been set, or null if the settings have not + * been applied. + */ +export function openBridgeDialog(win, url, inputData, onAccept) { + const result = { accepted: false, connect: false }; + let savedSettings = null; + win.gSubDialog.open( + url, + { + features: "resizable=yes", + closingCallback: () => { + if (!result.accepted) { + return; + } + savedSettings = onAccept(result); + if (!savedSettings) { + // No change in settings. + return; + } + if (!result.connect) { + // Do not open about:torconnect. + return; + } + + // Wait until the settings are applied before bootstrapping. + // NOTE: Saving the settings should also cancel any existing bootstrap + // attempt first. See tor-browser#41921. + savedSettings.then(() => { + // The bridge dialog button is "connect" when Tor is not + // bootstrapped, so do the connect. + + // Start Bootstrapping, which should use the configured bridges. + // NOTE: We do this regardless of any previous TorConnect Error. + lazy.TorConnectParent.open({ beginBootstrapping: "hard" }); + }); + }, + // closedCallback should be called after gSubDialog has already + // re-assigned focus back to the document. + closedCallback: () => { + if (!savedSettings) { + return; + } + // Wait until the settings have changed, so that the UI could + // respond, then move focus. + savedSettings.then(() => { + moveFocusToBridgeHeading(win); + }); + }, + }, + result, + inputData + ); +} + +/** + * Open the user provide dialog. + * + * @param {Window} win - The preferences window. + * @param {string} mode - The mode to open the dialog in: "add", "replace" or + * "edit". + */ +export function openUserProvideBridgeDialog(win, mode) { + openBridgeDialog( + win, + "chrome://browser/content/torpreferences/provideBridgeDialog.xhtml", + { mode }, + result => { + const loxId = result.loxId; + if (!loxId && !result.addresses?.length) { + return null; + } + const bridges = { enabled: true }; + if (loxId) { + bridges.source = lazy.TorBridgeSource.Lox; + bridges.lox_id = loxId; + } else { + bridges.source = lazy.TorBridgeSource.UserProvided; + bridges.bridge_strings = result.addresses; + } + return lazy.TorSettings.changeSettings({ bridges }); + } + ); +} ===================================== browser/components/torpreferences/content/connectionPane.inc.xhtml ===================================== @@ -1,3 +1,251 @@ +<html:template id="tor-bridges-display-template"> + <html:div id="tor-bridges-none" hidden="hidden"> + <html:img id="tor-bridges-none-icon" alt="" /> + <html:p data-l10n-id="tor-bridges-none-added"></html:p> + </html:div> + <html:fieldset id="tor-bridges-current" hidden="hidden"> + <html:legend> + <html:h4 + id="tor-bridges-current-heading-non-search" + class="tor-bridges-current-heading tor-focusable-heading tor-small-heading" + tabindex="-1" + data-l10n-id="tor-bridges-your-bridges" + ></html:h4> + </html:legend> + <html:p + id="tor-bridges-user-label" + class="tor-bridges-source-label" + data-l10n-id="tor-bridges-source-user" + ></html:p> + <html:p + id="tor-bridges-built-in-label" + class="tor-bridges-source-label" + data-l10n-id="tor-bridges-source-built-in" + ></html:p> + <html:p + id="tor-bridges-requested-label" + class="tor-bridges-source-label" + data-l10n-id="tor-bridges-source-requested" + ></html:p> + <html:p id="tor-bridges-lox-label" class="tor-bridges-source-label"> + <html:img id="tor-bridges-lox-label-icon" alt="" /> + <html:span data-l10n-id="tor-bridges-source-lox"></html:span> + </html:p> + <html:button + id="tor-bridges-all-options-button" + class="tor-bridges-options-button" + aria-haspopup="menu" + aria-expanded="false" + aria-controls="tor-bridges-all-options-menu" + data-l10n-id="tor-bridges-options-button" + ></html:button> + <html:panel-list + id="tor-bridges-all-options-menu" + data-hidden-from-search="true" + > + <html:panel-item + id="tor-bridges-options-qr-all-menu-item" + data-l10n-attrs="accesskey" + data-l10n-id="tor-bridges-menu-item-qr-all-bridge-addresses" + ></html:panel-item> + <html:panel-item + id="tor-bridges-options-copy-all-menu-item" + data-l10n-attrs="accesskey" + data-l10n-id="tor-bridges-menu-item-copy-all-bridge-addresses" + ></html:panel-item> + <html:panel-item + id="tor-bridges-options-edit-all-menu-item" + data-l10n-attrs="accesskey" + data-l10n-id="tor-bridges-menu-item-edit-all-bridges" + ></html:panel-item> + <html:panel-item + id="tor-bridges-options-remove-all-menu-item" + data-l10n-attrs="accesskey" + data-l10n-id="tor-bridges-menu-item-remove-all-bridges" + ></html:panel-item> + </html:panel-list> + <html:div id="tor-bridges-built-in-display" hidden="hidden"> + <html:p id="tor-bridges-built-in-type-name"></html:p> + <html:p id="tor-bridges-built-in-connected" class="bridge-status-badge"> + <html:div class="bridge-status-icon"></html:div> + <html:span + data-l10n-id="tor-bridges-built-in-status-connected" + ></html:span> + </html:p> + <html:p id="tor-bridges-built-in-description"></html:p> + </html:div> + <html:div + id="tor-bridges-grid-display" + class="tor-bridges-grid" + role="grid" + aria-labelledby="tor-bridges-current-heading-non-search" + hidden="hidden" + ></html:div> + <html:template id="tor-bridges-grid-row-template"> + <html:div class="tor-bridges-grid-row" role="row"> + <!-- TODO: lox status cell for new bridges? --> + <html:span + class="tor-bridges-type-cell tor-bridges-grid-cell" + role="gridcell" + ></html:span> + <html:span class="tor-bridges-emojis-block" role="none"></html:span> + <html:span class="tor-bridges-grid-end-block" role="none"> + <html:span + class="tor-bridges-address-cell tor-bridges-grid-cell" + role="gridcell" + > + <html:span class="tor-bridges-address-cell-text"></html:span> + </html:span> + <html:span + class="tor-bridges-status-cell tor-bridges-grid-cell" + role="gridcell" + > + <html:div class="bridge-status-badge"> + <html:div class="bridge-status-icon"></html:div> + <html:span class="tor-bridges-status-cell-text"></html:span> + </html:div> + </html:span> + <html:span + class="tor-bridges-options-cell tor-bridges-grid-cell" + role="gridcell" + > + <html:button + class="tor-bridges-options-cell-button tor-bridges-options-button tor-bridges-grid-focus" + aria-haspopup="menu" + aria-expanded="false" + data-l10n-id="tor-bridges-individual-bridge-options-button" + ></html:button> + <html:panel-list + class="tor-bridges-individual-options-menu" + data-hidden-from-search="true" + > + <html:panel-item + class="tor-bridges-options-qr-one-menu-item" + data-l10n-attrs="accesskey" + data-l10n-id="tor-bridges-menu-item-qr-address" + ></html:panel-item> + <html:panel-item + class="tor-bridges-options-copy-one-menu-item" + data-l10n-attrs="accesskey" + data-l10n-id="tor-bridges-menu-item-copy-address" + ></html:panel-item> + <html:panel-item + class="tor-bridges-options-remove-one-menu-item" + data-l10n-attrs="accesskey" + data-l10n-id="tor-bridges-menu-item-remove-bridge" + ></html:panel-item> + </html:panel-list> + </html:span> + </html:span> + </html:div> + </html:template> + <html:div + id="tor-bridges-share" + class="tor-bridges-details-box" + hidden="hidden" + > + <html:h5 + class="tor-bridges-share-heading tor-small-heading" + data-l10n-id="tor-bridges-share-heading" + ></html:h5> + <html:p + id="tor-bridges-share-description" + data-l10n-id="tor-bridges-share-description" + ></html:p> + <html:button + id="tor-bridges-copy-addresses-button" + data-l10n-id="tor-bridges-copy-addresses-button" + ></html:button> + <html:button + id="tor-bridges-qr-addresses-button" + data-l10n-id="tor-bridges-qr-addresses-button" + ></html:button> + </html:div> + <html:div id="tor-bridges-lox-status" hidden="hidden"> + <html:div data-l10n-id="tor-bridges-lox-description"></html:div> + <html:div + id="tor-bridges-lox-details" + class="tor-bridges-details-box tor-bridges-lox-box" + hidden="hidden" + > + <html:img alt="" class="tor-bridges-lox-image-inner" /> + <html:img alt="" class="tor-bridges-lox-image-outer" /> + <html:h5 + class="tor-bridges-lox-next-unlock-counter tor-small-heading tor-bridges-lox-intro tor-focusable-heading" + tabindex="-1" + ></html:h5> + <html:ul class="tor-bridges-lox-list"> + <html:li + id="tor-bridges-lox-next-unlock-gain-bridges" + class="tor-bridges-lox-list-item tor-bridges-lox-list-item-bridge" + data-l10n-id="tor-bridges-lox-unlock-two-bridges" + hidden="hidden" + ></html:li> + <html:li + id="tor-bridges-lox-next-unlock-first-invites" + class="tor-bridges-lox-list-item tor-bridges-lox-list-item-invite" + data-l10n-id="tor-bridges-lox-unlock-first-invites" + hidden="hidden" + ></html:li> + <html:li + id="tor-bridges-lox-next-unlock-more-invites" + class="tor-bridges-lox-list-item tor-bridges-lox-list-item-invite" + data-l10n-id="tor-bridges-lox-unlock-more-invites" + hidden="hidden" + ></html:li> + </html:ul> + <html:div + id="tor-bridges-lox-remaining-invites" + hidden="hidden" + ></html:div> + <html:button + id="tor-bridges-lox-show-invites-button" + class="tor-bridges-lox-button" + data-l10n-id="tor-bridges-lox-show-invites-button" + hidden="hidden" + ></html:button> + </html:div> + <html:div + id="tor-bridges-lox-unlock-alert" + role="alert" + class="tor-bridges-details-box tor-bridges-lox-box" + hidden="hidden" + > + <html:img alt="" class="tor-bridges-lox-image-inner" /> + <html:img alt="" class="tor-bridges-lox-image-outer" /> + <html:div + id="tor-bridge-unlock-alert-title" + class="tor-small-heading tor-bridges-lox-intro" + ></html:div> + <html:ul class="tor-bridges-lox-list"> + <html:li + id="tor-bridges-lox-unlock-alert-gain-bridges" + class="tor-bridges-lox-list-item tor-bridges-lox-list-item-bridge" + data-l10n-id="tor-bridges-lox-gained-two-bridges" + hidden="hidden" + ></html:li> + <html:li + id="tor-bridges-lox-unlock-alert-new-bridges" + class="tor-bridges-lox-list-item tor-bridges-lox-list-item-bridge" + data-l10n-id="tor-bridges-lox-new-bridges" + hidden="hidden" + ></html:li> + <html:li + id="tor-bridges-lox-unlock-alert-invites" + class="tor-bridges-lox-list-item tor-bridges-lox-list-item-invite" + hidden="hidden" + ></html:li> + </html:ul> + <html:button + id="tor-bridges-lox-unlock-alert-button" + class="tor-bridges-lox-button" + data-l10n-id="tor-bridges-lox-got-it-button" + ></html:button> + </html:div> + </html:div> + </html:fieldset> +</html:template> + <!-- Tor panel --> <script ===================================== browser/components/torpreferences/content/torPreferences.css ===================================== @@ -201,20 +201,27 @@ button.spoof-button-disabled { #tor-bridges-none, #tor-bridges-current { - margin-inline: 0; - margin-block: var(--space-xxlarge); + @media not -moz-pref("browser.settings-redesign.enabled") { + margin-inline: 0; + margin-block: var(--space-xxlarge); + } } #tor-bridges-none:not([hidden]) { display: grid; justify-items: center; text-align: center; - padding-block: 64px; + padding-block: 54px; padding-inline: var(--space-xxlarge); - gap: var(--space-large); + gap: var(--space-small); border-radius: var(--border-radius-small); color: var(--text-color-deemphasized); border: 2px dashed var(--border-color-deemphasized); + + @media not -moz-pref("browser.settings-redesign.enabled") { + padding-block: 64px; + gap: var(--space-large); + } } #tor-bridges-none-icon { @@ -228,9 +235,13 @@ button.spoof-button-disabled { .tor-bridges-box, .tor-bridges-details-box { padding: var(--space-large); - border-radius: var(--border-radius-small); - background: var(--background-color-box-info); + border-radius: var(--border-radius-medium); border: var(--border-width) solid var(--border-color); + + @media not -moz-pref("browser.settings-redesign.enabled") { + border-radius: var(--border-radius-small); + background: var(--background-color-box-info); + } } @media not forced-colors { @@ -250,6 +261,56 @@ button.spoof-button-disabled { white-space: nowrap; } +tor-bridges-display { + display: block; + margin-block: var(--space-small); + + &.has-tor-bridges { + display: grid; + grid-template: + "heading source button" min-content + "bridges bridges bridges" auto + "extra extra extra" auto + / max-content 1fr max-content; + align-items: center; + border: var(--card-border); + border-radius: var(--border-radius-medium); + background-color: var(--card-background-color); + padding: var(--space-large); + } + + & p { + margin: 0; + } + + & #tor-bridges-current:not([hidden]) { + /* Avoid the special display logic for <fieldset> and <legend>. */ + display: contents; + } + + & legend { + grid-area: heading; + white-space: nowrap; + } + + & .tor-bridges-source-label { + white-space: nowrap; + padding-inline-end: var(--space-small); + border-inline-end: var(--border-width) solid var(--border-color); + } + + & :is(#tor-bridges-built-in-display, #tor-bridges-grid-display) { + grid-area: bridges; + margin-block-start: var(--space-large); + border-block-start: var(--border-width) solid var(--border-color); + padding-block-start: var(--space-large); + } + + & :is(#tor-bridges-share, #tor-bridges-lox-status) { + grid-area: extra; + } +} + .tor-bridges-current-heading { grid-area: heading; } @@ -681,6 +742,24 @@ button.spoof-button-disabled { grid-column: 2 / 3; } +#torBridgesRequestBanner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + text-wrap-style: balance; + gap: var(--space-large); + + & > * { + margin: 0; + flex: 0 0 auto; + } + + & > p { + max-width: var(--size-layout-medium); + } +} + #tor-bridges-request-box { /* Take up the full height in the container. */ align-self: stretch; ===================================== browser/components/torpreferences/jar.mn ===================================== @@ -25,6 +25,8 @@ browser.jar: content/browser/torpreferences/torLogDialog.js (content/torLogDialog.js) content/browser/torpreferences/torLogDialog.xhtml (content/torLogDialog.xhtml) content/browser/torpreferences/config/connection.mjs (config/connection.mjs) + content/browser/torpreferences/config/helpers.mjs (config/helpers.mjs) + content/browser/torpreferences/widgets/tor-bridges-display.mjs (widgets/tor-bridges-display.mjs) content/browser/torpreferences/widgets/tor-connection-status.mjs (widgets/tor-connection-status.mjs) content/browser/torpreferences/widgets/tor-connection-status.css (widgets/tor-connection-status.css) content/browser/torpreferences/connectionPane.js (content/connectionPane.js) ===================================== browser/components/torpreferences/widgets/tor-bridges-display.mjs ===================================== @@ -0,0 +1,1955 @@ +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + Lox: "moz-src:///toolkit/components/lox/Lox.sys.mjs", + LoxTopics: "moz-src:///toolkit/components/lox/Lox.sys.mjs", + moveFocusToBridgeHeading: + "chrome://browser/content/torpreferences/config/helpers.mjs", + openUserProvideBridgeDialog: + "chrome://browser/content/torpreferences/config/helpers.mjs", + TorBridgeSource: "moz-src:///toolkit/modules/TorSettings.sys.mjs", + TorParsers: "moz-src:///toolkit/components/tor-launcher/TorParsers.sys.mjs", + TorSettings: "moz-src:///toolkit/modules/TorSettings.sys.mjs", +}); + +/** + * Show the bridge QR to the user. + * + * @param {string} bridgeString - The string to use in the QR. + */ +function showBridgeQr(bridgeString) { + window.gSubDialog.open( + "chrome://browser/content/torpreferences/bridgeQrDialog.xhtml", + { features: "resizable=yes" }, + bridgeString + ); +} + +/** + * Post a new notification, replacing any existing one. + * + * @param {string} type - The notification type. + */ +async function postBridgeNotification(type) { + let updateId; + switch (type) { + case "removed-one": + updateId = "tor-bridges-update-removed-one-bridge"; + break; + case "removed-all": + updateId = "tor-bridges-update-removed-all-bridges"; + break; + case "changed": + default: + // Generic message for when bridges change. + updateId = "tor-bridges-update-changed-bridges"; + break; + } + const bridgeDisplay = document.querySelector("tor-bridges-display"); + const settingGroup = bridgeDisplay?.closest("setting-group"); + if (!settingGroup) { + console.error("Missing a setting-group for a notification."); + return; + } + if (!settingGroup.checkVisibility()) { + // Only ping the user if the bridge settings are visible. + // NOTE: Most operations to change the bridges will occur within the + // connection settings. However, in principle the user could have multiple + // setting tabs open, or they may have the settings open whilst Connection + // Assist is setting their bridges. + return; + } + const [message] = await Promise.all([ + document.l10n.formatValue(updateId), + // Wait at least a small amount of time to actually trigger ariaNotify. + // Otherwise Orca will ignore the notification when it almost coincides with + // a change in focus, which is normally the case. + new Promise(resolve => setTimeout(resolve, 500)), + ]); + bridgeDisplay.ariaNotify(message); +} + +/** + * Controls the bridge grid. + */ +const gBridgeGrid = { + /** + * The grid element. + * + * @type {Element?} + */ + _grid: null, + /** + * The template for creating new rows. + * + * @type {HTMLTemplateElement?} + */ + _rowTemplate: null, + + /** + * @typedef {object} BridgeGridRow + * + * @property {Element} element - The row element. + * @property {Element} optionsButton - The options button. + * @property {Element} menu - The options menupopup. + * @property {Element} statusEl - The bridge status element. + * @property {Element} statusText - The status text. + * @property {string} bridgeLine - The identifying bridge string for this row. + * @property {string?} bridgeId - The ID/fingerprint for the bridge, or null + * if it doesn't have one. + * @property {integer} index - The index of the row in the grid. + * @property {boolean} connected - Whether we are connected to the bridge + * (recently in use for a Tor circuit). + * @property {BridgeGridCell[]} cells - The cells that belong to the row, + * ordered by their column. + */ + /** + * @typedef {object} BridgeGridCell + * + * @property {Element} element - The cell element. + * @property {Element} focusEl - The element belonging to the cell that should + * receive focus. Should be the cell element itself, or an interactive + * focusable child. + * @property {integer} columnIndex - The index of the column this cell belongs + * to. + * @property {BridgeGridRow} row - The row this cell belongs to. + */ + /** + * The current rows in the grid. + * + * @type {BridgeGridRow[]} + */ + _rows: [], + /** + * The cell that should be the focus target when the user moves focus into the + * grid, or null if the grid itself should be the target. + * + * @type {BridgeGridCell?} + */ + _focusCell: null, + + /** + * Initialize the bridge grid. + */ + init() { + this._grid = document.getElementById("tor-bridges-grid-display"); + // Initially, make only the grid itself part of the keyboard tab cycle. + // matches _focusCell = null. + this._grid.tabIndex = 0; + + this._rowTemplate = document.getElementById( + "tor-bridges-grid-row-template" + ); + + this._grid.addEventListener("keydown", this); + this._grid.addEventListener("mousedown", this); + this._grid.addEventListener("focusin", this); + + this._supportedSources = [ + lazy.TorBridgeSource.BridgeDB, + lazy.TorBridgeSource.UserProvided, + lazy.TorBridgeSource.Lox, + ]; + }, + + /** + * Whether the grid is visible and responsive. + * + * @type {boolean} + */ + _active: false, + + /** + * Activate and show the bridge grid. + */ + activate() { + if (this._active) { + return; + } + + this._active = true; + + this._grid.hidden = false; + }, + + /** + * Deactivate and hide the bridge grid. + */ + deactivate() { + if (!this._active) { + return; + } + + this._active = false; + + this._forceCloseRowMenus(); + + this._grid.hidden = true; + }, + + handleEvent(event) { + if (event.type === "keydown") { + if (event.altKey || event.shiftKey || event.metaKey || event.ctrlKey) { + // Don't interfere with these events. + return; + } + + if (this._rows.some(row => row.menu.open)) { + // Have an open menu, let the menu handle the event instead. + return; + } + + let numRows = this._rows.length; + if (!numRows) { + // Nowhere for focus to go. + return; + } + + let moveRow = 0; + let moveColumn = 0; + const isLTR = this._grid.matches(":dir(ltr)"); + switch (event.key) { + case "ArrowDown": + moveRow = 1; + break; + case "ArrowUp": + moveRow = -1; + break; + case "ArrowRight": + moveColumn = isLTR ? 1 : -1; + break; + case "ArrowLeft": + moveColumn = isLTR ? -1 : 1; + break; + default: + return; + } + + // Prevent scrolling the nearest scroll container. + event.preventDefault(); + + const curCell = this._focusCell; + let row = curCell ? curCell.row.index + moveRow : 0; + let column = curCell ? curCell.columnIndex + moveColumn : 0; + + // Clamp in bounds. + if (row < 0) { + row = 0; + } else if (row >= numRows) { + row = numRows - 1; + } + + const numCells = this._rows[row].cells.length; + if (column < 0) { + column = 0; + } else if (column >= numCells) { + column = numCells - 1; + } + + const newCell = this._rows[row].cells[column]; + + if (newCell !== curCell) { + this._setFocus(newCell); + } + } else if (event.type === "mousedown") { + if (event.button !== 0) { + return; + } + // Move focus index to the clicked target. + // NOTE: Since the cells and the grid have "tabindex=-1", they are still + // click-focusable. Therefore, the default mousedown handler will try to + // move focus to it. + // Rather than block this default handler, we instead re-direct the focus + // to the correct cell in the "focusin" listener. + const newCell = this._getCellFromTarget(event.target); + // NOTE: If newCell is null, then we do nothing here, but instead wait for + // the focusin handler to trigger. + if (newCell && newCell !== this._focusCell) { + this._setFocus(newCell); + } + } else if (event.type === "focusin") { + const focusCell = this._getCellFromTarget(event.target); + if (focusCell !== this._focusCell) { + // Focus is not where it is expected. + // E.g. the user has clicked the edge of the grid. + // Restore focus immediately back to the cell we expect. + this._setFocus(this._focusCell); + } + } + }, + + /** + * Return the cell that was the target of an event. + * + * @param {Element} element - The target of an event. + * + * @returns {BridgeGridCell?} - The cell that the element belongs to, or null + * if it doesn't belong to any cell. + */ + _getCellFromTarget(element) { + for (const row of this._rows) { + for (const cell of row.cells) { + if (cell.element.contains(element)) { + return cell; + } + } + } + return null; + }, + + /** + * Determine whether the document's active element (focus) is within the grid + * or not. + * + * @returns {boolean} - Whether focus is within this grid or not. + */ + _focusWithin() { + return this._grid.contains(document.activeElement); + }, + + /** + * Set the cell that should be the focus target of the grid, possibly moving + * the document's focus as well. + * + * @param {BridgeGridCell?} cell - The cell to make the focus target, or null + * if the grid itself should be the target. + * @param {boolean} [focusWithin] - Whether focus should be moved within the + * grid. If undefined, this will move focus if the grid currently contains + * the document's focus. + */ + _setFocus(cell, focusWithin) { + if (focusWithin === undefined) { + focusWithin = this._focusWithin(); + } + const prevFocusElement = this._focusCell + ? this._focusCell.focusEl + : this._grid; + const newFocusElement = cell ? cell.focusEl : this._grid; + + if (prevFocusElement !== newFocusElement) { + prevFocusElement.tabIndex = -1; + newFocusElement.tabIndex = 0; + } + // Set _focusCell now, before we potentially call "focus", which can trigger + // the "focusin" handler. + this._focusCell = cell; + + if (focusWithin) { + // Focus was within the grid, so we need to actively move it to the new + // element. + newFocusElement.focus({ preventScroll: true }); + // Scroll to the whole cell into view, rather than just the focus element. + (cell?.element ?? newFocusElement).scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + } + }, + + /** + * Reset the grids focus to be the first row's first cell, if any. + * + * @param {boolean} [focusWithin] - Whether focus should be moved within the + * grid. If undefined, this will move focus if the grid currently contains + * the document's focus. + */ + _resetFocus(focusWithin) { + this._setFocus( + this._rows.length ? this._rows[0].cells[0] : null, + focusWithin + ); + }, + + /** + * The bridge ID/fingerprint of the most recently used bridge (appearing in + * the latest Tor circuit). Roughly corresponds to the bridge we are currently + * connected to. + * + * null if there are no such bridges. + * + * @type {string?} + */ + _connectedBridgeId: null, + + set connectedBridgeId(bridgeId) { + if (bridgeId === this._connectedBridgeId) { + return; + } + this._connectedBridgeId = bridgeId; + for (const row of this._rows) { + this._updateRowStatus(row); + } + }, + + /** + * Update the status of a row. + * + * @param {BridgeGridRow} row - The row to update. + */ + _updateRowStatus(row) { + const connected = row.bridgeId && this._connectedBridgeId === row.bridgeId; + // NOTE: row.connected is initially undefined, so won't match `connected`. + if (connected === row.connected) { + return; + } + + row.connected = connected; + + const noStatus = !connected; + + row.element.classList.toggle("hide-status", noStatus); + row.statusEl.classList.toggle("bridge-status-none", noStatus); + row.statusEl.classList.toggle("bridge-status-connected", connected); + + if (connected) { + document.l10n.setAttributes( + row.statusText, + "tor-bridges-status-connected" + ); + } else { + document.l10n.setAttributes(row.statusText, "tor-bridges-status-none"); + } + }, + + /** + * Create a new row for the grid. + * + * @param {string} bridgeLine - The bridge line for this row, which also acts + * as its ID. + * + * @returns {BridgeGridRow} - A new row, with then "index" unset and the + * "element" without a parent. + */ + _createRow(bridgeLine) { + let details; + try { + details = lazy.TorParsers.parseBridgeLine(bridgeLine); + } catch (e) { + console.error(`Detected invalid bridge line: ${bridgeLine}`, e); + } + const row = { + element: this._rowTemplate.content.children[0].cloneNode(true), + bridgeLine, + bridgeId: details?.id ?? null, + cells: [], + }; + + const emojiBlock = row.element.querySelector(".tor-bridges-emojis-block"); + const BridgeEmoji = customElements.get("tor-bridge-emoji"); + for (const cell of BridgeEmoji.createForAddress(bridgeLine)) { + // Each emoji is its own cell, we rely on the fact that createForAddress + // always returns four elements. + cell.setAttribute("role", "gridcell"); + cell.classList.add("tor-bridges-grid-cell", "tor-bridges-emoji-cell"); + emojiBlock.append(cell); + } + + for (const [columnIndex, element] of row.element + .querySelectorAll(".tor-bridges-grid-cell") + .entries()) { + const focusEl = + element.querySelector(".tor-bridges-grid-focus") ?? element; + // Set a negative tabIndex, this makes the element click-focusable but not + // part of the tab navigation sequence. + focusEl.tabIndex = -1; + row.cells.push({ element, focusEl, columnIndex, row }); + } + + const transport = details?.transport ?? "vanilla"; + const typeCell = row.element.querySelector(".tor-bridges-type-cell"); + if (transport === "vanilla") { + document.l10n.setAttributes(typeCell, "tor-bridges-type-prefix-generic"); + } else { + document.l10n.setAttributes(typeCell, "tor-bridges-type-prefix", { + type: transport, + }); + } + + row.element.querySelector(".tor-bridges-address-cell-text").textContent = + bridgeLine; + + row.statusEl = row.element.querySelector( + ".tor-bridges-status-cell .bridge-status-badge" + ); + row.statusText = row.element.querySelector(".tor-bridges-status-cell-text"); + + this._initRowMenu(row); + + this._updateRowStatus(row); + return row; + }, + + /** + * The row menu index used for generating new ids. + * + * @type {integer} + */ + _rowMenuIndex: 0, + /** + * Generate a new id for the options menu. + * + * @returns {string} - The new id. + */ + _generateRowMenuId() { + const id = `tor-bridges-individual-options-menu-${this._rowMenuIndex}`; + // Assume we won't run out of ids. + this._rowMenuIndex++; + return id; + }, + + /** + * Initialize the shared menu for a row. + * + * @param {BridgeGridRow} row - The row to initialize the menu of. + */ + _initRowMenu(row) { + row.menu = row.element.querySelector( + ".tor-bridges-individual-options-menu" + ); + row.optionsButton = row.element.querySelector( + ".tor-bridges-options-cell-button" + ); + + row.menu.id = this._generateRowMenuId(); + row.optionsButton.setAttribute("aria-controls", row.menu.id); + + row.optionsButton.addEventListener("click", event => { + row.menu.toggle(event); + }); + + row.menu.addEventListener("hidden", () => { + // Make sure the button receives focus again when the menu is hidden. + // Currently, panel-list.js only does this when the menu is opened with a + // keyboard, but this causes focus to be lost from the page if the user + // uses a mixture of keyboard and mouse. + row.optionsButton.focus(); + }); + + const qrItem = row.menu.querySelector( + ".tor-bridges-options-qr-one-menu-item" + ); + const removeItem = row.menu.querySelector( + ".tor-bridges-options-remove-one-menu-item" + ); + row.menu.addEventListener("showing", () => { + const show = + this._bridgeSource === lazy.TorBridgeSource.UserProvided || + this._bridgeSource === lazy.TorBridgeSource.BridgeDB; + qrItem.hidden = !show; + removeItem.hidden = !show; + }); + + qrItem.addEventListener("click", () => { + const bridgeLine = row.bridgeLine; + if (!bridgeLine) { + return; + } + showBridgeQr(bridgeLine); + }); + row.menu + .querySelector(".tor-bridges-options-copy-one-menu-item") + .addEventListener("click", () => { + const clipboard = Cc[ + "@mozilla.org/widget/clipboardhelper;1" + ].getService(Ci.nsIClipboardHelper); + clipboard.copyString(row.bridgeLine); + }); + removeItem.addEventListener("click", () => { + const bridgeLine = row.bridgeLine; + const source = lazy.TorSettings.bridges.source; + if (source !== this._bridgesVal?.source) { + // Our value is stale, abort. + return; + } + const strings = lazy.TorSettings.bridges.bridge_strings; + const index = strings.indexOf(bridgeLine); + if (index === -1) { + return; + } + strings.splice(index, 1); + + if (strings.length) { + lazy.TorSettings.changeSettings({ + bridges: { source, bridge_strings: strings }, + }); + } else { + // Remove all bridges and disable. + lazy.TorSettings.changeSettings({ + bridges: { source: lazy.TorBridgeSource.Invalid }, + }); + } + }); + }, + + /** + * Force the row menu to close. + */ + _forceCloseRowMenus() { + for (const row of this._rows) { + row.menu.hide(null, { force: true }); + } + }, + + /** + * The known bridge source. + * + * Initially null to indicate that it is unset. + * + * @type {integer?} + */ + _bridgeSource: null, + /** + * The bridge sources this is shown for. + * + * @type {string[]} + */ + _supportedSources: [], + + /** + * The bridges value, set by the setting-control element. + * + * @type {object} + */ + _bridgesVal: null, + + set bridges(val) { + if (val === null) { + // Ignore and wait for the initial. + return; + } + const initial = this._bridgesVal === null; + this._bridgesVal = val; + this._updateRows(initial); + }, + + /** + * Update the grid to show the latest bridge strings. + * + * @param {boolean} initializing - Whether this is being called as part of + * initialization. + */ + _updateRows(initializing) { + // Store whether we have focus within the grid, before removing or hiding + // DOM elements. + const focusWithin = this._focusWithin(); + + let lostAllBridges = false; + let newSource = false; + const bridgeSource = this._bridgesVal.source; + if (bridgeSource !== this._bridgeSource) { + newSource = true; + + this._bridgeSource = bridgeSource; + + if (this._supportedSources.includes(bridgeSource)) { + this.activate(); + } else { + if (this._active && bridgeSource === lazy.TorBridgeSource.Invalid) { + lostAllBridges = true; + } + this.deactivate(); + } + } + + const ordered = this._active + ? this._bridgesVal.bridgeStrings.map(bridgeLine => { + const row = this._rows.find(r => r.bridgeLine === bridgeLine); + if (row) { + return row; + } + return this._createRow(bridgeLine); + }) + : []; + + // Whether we should reset the grid's focus. + // We always reset when we have a new bridge source. + // We reset the focus if no current Cell has focus. I.e. when adding a row + // to an empty grid, we want the focus to move to the first item. + // We also reset the focus if the current Cell is in a row that will be + // removed (including if all rows are removed). + // NOTE: In principle, if a row is removed, we could move the focus to the + // next or previous row (in the same cell column). However, most likely if + // the grid has the user focus, they are removing a single row using its + // options button. In this case, returning the user to some other row's + // options button might be more disorienting since it would not be simple + // for them to know *which* bridge they have landed on. + // NOTE: We do not reset the focus in other cases because we do not want the + // user to loose their place in the grid unnecessarily. + let resetFocus = + newSource || !this._focusCell || !ordered.includes(this._focusCell.row); + + // Remove rows no longer needed from the DOM. + let numRowsRemoved = 0; + let rowAddedOrMoved = false; + + for (const row of this._rows) { + if (!ordered.includes(row)) { + numRowsRemoved++; + // If the row menu was open, it will also be deleted. + // NOTE: Since the row menu is part of the row, focusWithin will be true + // if the menu had focus, so focus should be re-assigned. + row.element.remove(); + } + } + + // Go through all the rows to set their ".index" property and to ensure they + // are in the correct position in the DOM. + // NOTE: We could use replaceChildren to get the correct DOM structure, but + // we want to avoid rebuilding the entire tree when a single row is added or + // removed. + for (const [index, row] of ordered.entries()) { + row.index = index; + const element = row.element; + // Get the expected previous element, that should already be in the DOM + // from the previous loop. + const prevEl = index ? ordered[index - 1].element : null; + + if ( + element.parentElement === this._grid && + prevEl === element.previousElementSibling + ) { + // Already in the correct position in the DOM. + continue; + } + + rowAddedOrMoved = true; + // NOTE: Any elements already in the DOM, but not in the correct position + // will be removed and re-added by the below command. + // NOTE: if the row has document focus, then it should remain there. + if (prevEl) { + prevEl.after(element); + } else { + this._grid.prepend(element); + } + } + this._rows = ordered; + + // Restore any lost focus. + if (resetFocus) { + // If we are not active (and therefore hidden), we will not try and move + // focus (activeElement), but may still change the *focusable* element for + // when we are shown again. + this._resetFocus(this._active && focusWithin); + } + // NOTE: In the case we were previously active and now inactive, + // tor-bridges-display will have already moved the focus out of this area. + + // Notify the user if there was some change to the DOM. + // If we are initializing, we generate no notification since there has been + // no change in the setting. + if (!initializing) { + let notificationType; + if (lostAllBridges) { + // Just lost all bridges, and became de-active. + notificationType = "removed-all"; + } else if (this._rows.length) { + // Otherwise, only generate a notification if we are still active, with + // at least one bridge. + // I.e. do not generate a message if the new source is "builtin". + if (newSource) { + // A change in source. + notificationType = "changed"; + } else if (numRowsRemoved === 1 && !rowAddedOrMoved) { + // Only one bridge was removed. This is most likely in response to them + // manually removing a single bridge or using the bridge row's options + // menu. + notificationType = "removed-one"; + } else if (numRowsRemoved || rowAddedOrMoved) { + // Some other change. This is most likely in response to a manual edit + // of the existing bridges. + notificationType = "changed"; + } + // Else, there was no change. + } + + if (notificationType) { + postBridgeNotification(notificationType); + } + } + }, +}; + +/** + * Controls the built-in bridges area. + */ +const gBuiltinBridgesArea = { + /** + * The display area. + * + * @type {Element?} + */ + _area: null, + /** + * The type name element. + * + * @type {Element?} + */ + _nameEl: null, + /** + * The bridge type description element. + * + * @type {Element?} + */ + _descriptionEl: null, + /** + * The connection status. + * + * @type {Element?} + */ + _connectionStatusEl: null, + + /** + * Initialize the built-in bridges area. + */ + init() { + this._area = document.getElementById("tor-bridges-built-in-display"); + this._nameEl = document.getElementById("tor-bridges-built-in-type-name"); + this._descriptionEl = document.getElementById( + "tor-bridges-built-in-description" + ); + this._connectionStatusEl = document.getElementById( + "tor-bridges-built-in-connected" + ); + }, + + /** + * Whether the built-in area is visible and responsive. + * + * @type {boolean} + */ + _active: false, + + /** + * Activate and show the built-in bridge area. + */ + activate() { + if (this._active) { + return; + } + this._active = true; + + this._area.hidden = false; + }, + + /** + * Deactivate and hide built-in bridge area. + */ + deactivate() { + if (!this._active) { + return; + } + this._active = false; + + this._area.hidden = true; + }, + + /** + * Updates the shown connected state. + */ + _updateConnectedState() { + this._connectionStatusEl.classList.toggle( + "bridge-status-connected", + this._bridgeType && + this._connectedBridgeId && + this._bridgeIds.includes(this._connectedBridgeId) + ); + }, + + /** + * The bridges value, set by the setting-control element. + * + * @type {object} + */ + _bridgesVal: null, + set bridges(val) { + if (val === null) { + // Ignore and wait for the initial. + return; + } + const initial = this._bridgesVal === null; + this._bridgesVal = val; + this._updateBridgeType(initial); + this._updateBridgeIds(); + }, + + /** + * The currently shown bridge type. Empty if deactivated, and null if + * uninitialized. + * + * @type {string?} + */ + _bridgeType: null, + /** + * The strings for each known bridge type. + * + * @type {{[key: string]: {[key: string]: string}}} + */ + _bridgeTypeStrings: { + obfs4: { + name: "tor-bridges-built-in-obfs4-name", + description: "tor-bridges-built-in-obfs4-description", + }, + snowflake: { + name: "tor-bridges-built-in-snowflake-name", + description: "tor-bridges-built-in-snowflake-description", + }, + meek: { + name: "tor-bridges-built-in-meek-name", + description: "tor-bridges-built-in-meek-description", + }, + }, + + /** + * The known bridge source. + * + * Initially null to indicate that it is unset. + * + * @type {integer?} + */ + _bridgeSource: null, + + /** + * Update the shown bridge type. + * + * @param {boolean} initializing - Whether this is being called as part of + * initialization. + */ + async _updateBridgeType(initializing) { + let lostAllBridges = false; + let newSource = false; + const bridgeSource = this._bridgesVal.source; + if (bridgeSource !== this._bridgeSource) { + newSource = true; + + this._bridgeSource = bridgeSource; + + if (bridgeSource === lazy.TorBridgeSource.BuiltIn) { + this.activate(); + } else { + if (this._active && bridgeSource === lazy.TorBridgeSource.Invalid) { + lostAllBridges = true; + } + this.deactivate(); + // NOTE: In the case we were previously active, tor-bridges-display will + // have already moved the focus out of this area. + } + } + + const bridgeType = this._active ? this._bridgesVal.builtinType : ""; + + let newType = false; + if (bridgeType !== this._bridgeType) { + newType = true; + + this._bridgeType = bridgeType; + + const bridgeStrings = this._bridgeTypeStrings[bridgeType]; + if (bridgeStrings) { + document.l10n.setAttributes(this._nameEl, bridgeStrings.name); + document.l10n.setAttributes( + this._descriptionEl, + bridgeStrings.description + ); + } else { + // Unknown type, or no type. + this._nameEl.removeAttribute("data-l10n-id"); + this._nameEl.textContent = bridgeType; + this._descriptionEl.removeAttribute("data-l10n-id"); + this._descriptionEl.textContent = ""; + } + + this._updateConnectedState(); + } + + // Notify the user if there was some change to the type. + // If we are initializing, we generate no notification since there has been + // no change in the setting. + if (!initializing) { + let notificationType; + if (lostAllBridges) { + // Just lost all bridges, and became de-active. + notificationType = "removed-all"; + } else if (this._active && (newSource || newType)) { + // Otherwise, only generate a notification if we are still active, with + // a bridge type. + // I.e. do not generate a message if the new source is not "builtin". + notificationType = "changed"; + } + + if (notificationType) { + postBridgeNotification(notificationType); + } + } + }, + + /** + * The bridge IDs/fingerprints for the built-in bridges. + * + * @type {Array<string>} + */ + _bridgeIds: [], + /** + * Update _bridgeIds + */ + _updateBridgeIds() { + this._bridgeIds = []; + for (const bridgeLine of this._bridgesVal.bridgeStrings) { + try { + this._bridgeIds.push(lazy.TorParsers.parseBridgeLine(bridgeLine).id); + } catch (e) { + console.error(`Detected invalid bridge line: ${bridgeLine}`, e); + } + } + + this._updateConnectedState(); + }, + + /** + * The bridge ID/fingerprint of the most recently used bridge (appearing in + * the latest Tor circuit). Roughly corresponds to the bridge we are currently + * connected to. + * + * @type {string?} + */ + _connectedBridgeId: null, + + set connectedBridgeId(val) { + this._connectedBridgeId = val; + this._updateConnectedState(); + }, +}; + +/** + * Controls the bridge pass area. + */ +const gLoxStatus = { + /** + * The status area. + * + * @type {Element?} + */ + _area: null, + /** + * The area for showing the next unlock and invites. + * + * @type {Element?} + */ + _detailsArea: null, + /** + * The list items showing the next unlocks. + * + * @type {?{[key: string]: Element}} + */ + _nextUnlockItems: null, + /** + * The day counter headings for the next unlock. + * + * One heading is shown during a search, the other is shown otherwise. + * + * @type {?Element[]} + */ + _nextUnlockCounterEls: null, + /** + * Shows the number of remaining invites. + * + * @type {Element?} + */ + _remainingInvitesEl: null, + /** + * The button to show the invites. + * + * @type {Element?} + */ + _invitesButton: null, + /** + * The alert for new unlocks. + * + * @type {Element?} + */ + _unlockAlert: null, + /** + * The list items showing the unlocks. + * + * @type {?{[key: string]: Element}} + */ + _unlockItems: null, + /** + * The alert title. + * + * @type {Element?} + */ + _unlockAlertTitle: null, + /** + * The alert invites item. + * + * @type {Element?} + */ + _unlockAlertInvitesItem: null, + /** + * Button for the user to dismiss the alert. + * + * @type {Element?} + */ + _unlockAlertButton: null, + + _enabled: false, + + /** + * Initialize the bridge pass area. + */ + init() { + if (!lazy.Lox.enabled) { + // Area should remain inactive and hidden. + return; + } + + this._enabled = true; + this._area = document.getElementById("tor-bridges-lox-status"); + this._detailsArea = document.getElementById("tor-bridges-lox-details"); + this._nextUnlockItems = { + gainBridges: document.getElementById( + "tor-bridges-lox-next-unlock-gain-bridges" + ), + firstInvites: document.getElementById( + "tor-bridges-lox-next-unlock-first-invites" + ), + moreInvites: document.getElementById( + "tor-bridges-lox-next-unlock-more-invites" + ), + }; + this._nextUnlockCounterEls = Array.from( + document.querySelectorAll(".tor-bridges-lox-next-unlock-counter") + ); + this._remainingInvitesEl = document.getElementById( + "tor-bridges-lox-remaining-invites" + ); + this._invitesButton = document.getElementById( + "tor-bridges-lox-show-invites-button" + ); + this._unlockAlert = document.getElementById("tor-bridges-lox-unlock-alert"); + this._unlockItems = { + gainBridges: document.getElementById( + "tor-bridges-lox-unlock-alert-gain-bridges" + ), + newBridges: document.getElementById( + "tor-bridges-lox-unlock-alert-new-bridges" + ), + invites: document.getElementById("tor-bridges-lox-unlock-alert-invites"), + }; + this._unlockAlertTitle = document.getElementById( + "tor-bridge-unlock-alert-title" + ); + this._unlockAlertInviteItem = document.getElementById( + "tor-bridges-lox-unlock-alert-invites" + ); + this._unlockAlertButton = document.getElementById( + "tor-bridges-lox-unlock-alert-button" + ); + + this._invitesButton.addEventListener("click", () => { + window.gSubDialog.open( + "chrome://browser/content/torpreferences/loxInviteDialog.xhtml", + { features: "resizable=yes" } + ); + }); + this._unlockAlertButton.addEventListener("click", () => { + lazy.Lox.clearEventData(this._loxId); + }); + + Services.obs.addObserver(this, lazy.LoxTopics.UpdateActiveLoxId); + Services.obs.addObserver(this, lazy.LoxTopics.UpdateEvents); + Services.obs.addObserver(this, lazy.LoxTopics.UpdateNextUnlock); + Services.obs.addObserver(this, lazy.LoxTopics.UpdateRemainingInvites); + Services.obs.addObserver(this, lazy.LoxTopics.NewInvite); + + window.addEventListener( + "unload", + () => { + Services.obs.removeObserver(this, lazy.LoxTopics.UpdateActiveLoxId); + Services.obs.removeObserver(this, lazy.LoxTopics.UpdateEvents); + Services.obs.removeObserver(this, lazy.LoxTopics.UpdateNextUnlock); + Services.obs.removeObserver( + this, + lazy.LoxTopics.UpdateRemainingInvites + ); + Services.obs.removeObserver(this, lazy.LoxTopics.NewInvite); + }, + { once: true } + ); + }, + + observe(subject, topic) { + switch (topic) { + case lazy.LoxTopics.UpdateActiveLoxId: + this._updateLoxId(); + break; + case lazy.LoxTopics.UpdateNextUnlock: + this._updateNextUnlock(); + break; + case lazy.LoxTopics.UpdateEvents: + this._updatePendingEvents(); + break; + case lazy.LoxTopics.UpdateRemainingInvites: + this._updateRemainingInvites(); + break; + case lazy.LoxTopics.NewInvite: + this._updateHaveExistingInvites(); + break; + } + }, + + /** + * The bridges value, set by the setting-control element. + * + * @type {object} + */ + _bridgesVal: null, + set bridges(val) { + if (val === null) { + // Ignore and wait for initial. + return; + } + if (!this._enabled) { + // Area should remain inactive and hidden. + return; + } + this._bridgesVal = val; + this._updateLoxId(); + }, + + /** + * The Lox id currently shown. Empty if deactivated, and null if + * uninitialized. + * + * @type {string?} + */ + _loxId: null, + + /** + * Update the shown bridge pass. + */ + async _updateLoxId() { + let loxId = + this._bridgesVal?.source === lazy.TorBridgeSource.Lox + ? lazy.Lox.activeLoxId + : ""; + if (loxId === this._loxId) { + return; + } + this._loxId = loxId; + this._area.hidden = !loxId; + // We unset _nextUnlock to ensure the areas no longer use the old value for + // the new loxId. + this._updateNextUnlock(true); + this._updateRemainingInvites(); + this._updateHaveExistingInvites(); + this._updatePendingEvents(); + }, + + /** + * The remaining invites shown, or null if uninitialized or no loxId. + * + * @type {integer?} + */ + _remainingInvites: null, + /** + * Update the shown value. + */ + _updateRemainingInvites() { + const numInvites = this._loxId + ? lazy.Lox.getRemainingInviteCount(this._loxId) + : null; + if (numInvites === this._remainingInvites) { + return; + } + this._remainingInvites = numInvites; + this._updateUnlockArea(); + this._updateInvitesArea(); + }, + /** + * Whether we have existing invites, or null if uninitialized or no loxId. + * + * @type {boolean?} + */ + _haveExistingInvites: null, + /** + * Update the shown value. + */ + _updateHaveExistingInvites() { + const haveInvites = this._loxId ? !!lazy.Lox.getInvites().length : null; + if (haveInvites === this._haveExistingInvites) { + return; + } + this._haveExistingInvites = haveInvites; + this._updateInvitesArea(); + }, + /** + * Details about the next unlock, or null if uninitialized or no loxId. + * + * @type {UnlockData?} + */ + _nextUnlock: null, + /** + * Tracker id to ensure that the results from later calls to _updateNextUnlock + * take priority over earlier calls. + * + * @type {integer} + */ + _nextUnlockCallId: 0, + /** + * Update the shown value asynchronously. + * + * @param {boolean} [unset=false] - Whether to set the _nextUnlock value to + * null before waiting for the new value. I.e. ensure that the current value + * will not be used. + */ + async _updateNextUnlock(unset = false) { + // NOTE: We do not expect the integer to exceed the maximum integer. + this._nextUnlockCallId++; + const callId = this._nextUnlockCallId; + if (unset) { + this._nextUnlock = null; + } + const nextUnlock = this._loxId + ? await lazy.Lox.getNextUnlock(this._loxId) + : null; + if (callId !== this._nextUnlockCallId) { + // Replaced by another update. + // E.g. if the _loxId changed. Or if getNextUnlock triggered + // LoxTopics.UpdateNextUnlock. + return; + } + // Should be safe to trigger the update, even when the value hasn't changed. + this._nextUnlock = nextUnlock; + this._updateUnlockArea(); + }, + /** + * The list of events the user has not yet cleared, or null if uninitialized + * or no loxId. + * + * @type {EventData[]?} + */ + _pendingEvents: null, + /** + * Update the shown value. + */ + _updatePendingEvents() { + // Should be safe to trigger the update, even when the value hasn't changed. + this._pendingEvents = this._loxId + ? lazy.Lox.getEventData(this._loxId) + : null; + this._updateUnlockArea(); + }, + + /** + * Update the display of the current or next unlock. + */ + _updateUnlockArea() { + if ( + !this._loxId || + this._pendingEvents === null || + this._remainingInvites === null || + this._nextUnlock === null + ) { + // Uninitialized or no Lox source. + // NOTE: This area may already be hidden by the change in Lox source, + // but we clean up for the next non-empty id. + this._unlockAlert.hidden = true; + this._detailsArea.hidden = true; + return; + } + + // Grab focus state before changing visibility. + const alertHadFocus = this._unlockAlert.contains(document.activeElement); + const detailsHadFocus = this._detailsArea.contains(document.activeElement); + + const pendingEvents = this._pendingEvents; + const showAlert = !!pendingEvents.length; + this._unlockAlert.hidden = !showAlert; + this._detailsArea.hidden = showAlert; + + if (showAlert) { + // At level 0 and level 1, we do not have any invites. + // If the user starts and ends on level 0 or 1, then overall they would + // have had no change in their invites. So we do not want to show their + // latest updates. + // NOTE: If the user starts at level > 1 and ends with level 1 (levelling + // down to level 0 should not be possible), then we *do* want to show the + // user that they now have "0" invites. + // NOTE: pendingEvents are time-ordered, with the most recent event + // *last*. + const firstEvent = pendingEvents[0]; + // NOTE: We cannot get a blockage event when the user starts at level 1 or + // 0. + const startingAtLowLevel = + firstEvent.type === "levelup" && firstEvent.newLevel <= 2; + const lastEvent = pendingEvents[pendingEvents.length - 1]; + const endingAtLowLevel = lastEvent.newLevel <= 1; + + const showInvites = !(startingAtLowLevel && endingAtLowLevel); + + let blockage = false; + let levelUp = false; + let bridgeGain = false; + // Go through events, in the order that they occurred. + for (const loxEvent of pendingEvents) { + if (loxEvent.type === "levelup") { + levelUp = true; + if (loxEvent.newLevel === 1) { + // Gain 2 bridges from level 0 to 1. + bridgeGain = true; + } + } else { + blockage = true; + } + } + + let alertTitleId; + if (levelUp && !blockage) { + alertTitleId = "tor-bridges-lox-upgrade"; + } else { + // Show as blocked bridges replaced. + // Even if we have a mixture of level ups as well. + alertTitleId = "tor-bridges-lox-blocked"; + } + document.l10n.setAttributes(this._unlockAlertTitle, alertTitleId); + document.l10n.setAttributes( + this._unlockAlertInviteItem, + "tor-bridges-lox-new-invites", + { numInvites: this._remainingInvites } + ); + this._unlockAlert.classList.toggle( + "lox-unlock-upgrade", + levelUp && !blockage + ); + this._unlockItems.gainBridges.hidden = !bridgeGain; + this._unlockItems.newBridges.hidden = !blockage; + this._unlockItems.invites.hidden = !showInvites; + } else { + // Show next unlock. + // Number of days until the next unlock, rounded up. + const numDays = Math.max( + 1, + Math.ceil( + (new Date(this._nextUnlock.date).getTime() - Date.now()) / + (24 * 60 * 60 * 1000) + ) + ); + for (const counterEl of this._nextUnlockCounterEls) { + document.l10n.setAttributes( + counterEl, + "tor-bridges-lox-days-until-unlock", + { numDays } + ); + } + + // Gain 2 bridges from level 0 to 1. After that gain invites. + this._nextUnlockItems.gainBridges.hidden = + this._nextUnlock.nextLevel !== 1; + this._nextUnlockItems.firstInvites.hidden = + this._nextUnlock.nextLevel !== 2; + this._nextUnlockItems.moreInvites.hidden = + this._nextUnlock.nextLevel <= 2; + } + + if (alertHadFocus && !showAlert) { + // Alert has become hidden, move focus back up to the now revealed details + // area. + // NOTE: We have two headings: one shown during a search and one shown + // otherwise. We focus the heading that is currently visible. + // See tor-browser#43320. + // TODO: It might be better if we could use the # named anchor to + // re-orient the screen reader position instead of using tabIndex=-1, but + // about:preferences currently uses the anchor for showing categories + // only. See bugzilla bug 1799153. + if ( + this._nextUnlockCounterEls[0].checkVisibility({ + visibilityProperty: true, + }) + ) { + this._nextUnlockCounterEls[0].focus(); + } else { + this._nextUnlockCounterEls[1].focus(); + } + } else if (detailsHadFocus && showAlert) { + this._unlockAlertButton.focus(); + } + }, + + /** + * Update the invites area. + */ + _updateInvitesArea() { + let hasInvites; + if ( + !this._loxId || + this._remainingInvites === null || + this._haveExistingInvites === null + ) { + // Not initialized yet. + hasInvites = false; + } else { + hasInvites = this._haveExistingInvites || !!this._remainingInvites; + } + + if ( + !hasInvites && + (this._remainingInvitesEl.contains(document.activeElement) || + this._invitesButton.contains(document.activeElement)) + ) { + // About to loose focus. + // Unexpected for the lox level to loose all invites. + // Move to the top of the details area, which should be visible if we + // just had focus. + this._nextUnlockCounterEl.focus(); + } + // Hide the invite elements if we have no historic invites or a way of + // creating new ones. + this._remainingInvitesEl.hidden = !hasInvites; + this._invitesButton.hidden = !hasInvites; + + if (hasInvites) { + document.l10n.setAttributes( + this._remainingInvitesEl, + "tor-bridges-lox-remaining-invites", + { numInvites: this._remainingInvites } + ); + } + }, +}; + +/** + * Controls the bridge settings. + */ +const gBridgeSettings = { + /** + * The display area. + * + * @type {Element?} + */ + _displayEl: null, + /** + * The area for showing current bridges. + * + * @type {Element?} + */ + _bridgesEl: null, + /** + * The area for sharing bridge addresses. + * + * @type {Element?} + */ + _shareEl: null, + /** + * The area for showing no bridges. + * + * @type {Element?} + */ + _noBridgesEl: null, + /** + * A map from the bridge source to its corresponding label. + * + * @type {?Map<number, Element>} + */ + _sourceLabels: null, + + /** + * Whether we have been initialized. + * + * @type {boolean} + */ + _initialized: false, + + /** + * Initialize the bridge settings. + * + * @param {Element} displayEl - The widget element we are controlling. + */ + init(displayEl) { + if (this._initialized) { + return; + } + + this._displayEl = displayEl; + this._bridgesEl = document.getElementById("tor-bridges-current"); + this._noBridgesEl = document.getElementById("tor-bridges-none"); + + this._sourceLabels = new Map([ + [ + lazy.TorBridgeSource.BuiltIn, + document.getElementById("tor-bridges-built-in-label"), + ], + [ + lazy.TorBridgeSource.UserProvided, + document.getElementById("tor-bridges-user-label"), + ], + [ + lazy.TorBridgeSource.BridgeDB, + document.getElementById("tor-bridges-requested-label"), + ], + [ + lazy.TorBridgeSource.Lox, + document.getElementById("tor-bridges-lox-label"), + ], + ]); + this._shareEl = document.getElementById("tor-bridges-share"); + + this._initBridgesMenu(); + this._initShareArea(); + + gBridgeGrid.init(); + gBuiltinBridgesArea.init(); + gLoxStatus.init(); + this._initialized = true; + // Re-trigger our current bridges value to pass on to any descendants. + this.bridges = this._bridgesVal; + this.connectedBridgeId = this._connectedBridgeId; + }, + + /** + * The bridges value, set by the setting-control element. + * + * @type {object} + */ + _bridgesVal: null, + + set bridges(val) { + if (val === null) { + // Corresponds to pending TorSettings initialization, wait for a non-null + // value. + return; + } + this._bridgesVal = val; + if (!this._initialized) { + return; + } + this._updateSource(); + this._updateBridgeStrings(); + // Pass on to descendants. + gBridgeGrid.bridges = val; + gBuiltinBridgesArea.bridges = val; + gLoxStatus.bridges = val; + }, + + /** + * The ID of the currently connected bridge, or `null` if there is none. + * + * @type {string?} + */ + _connectedBridgeId: null, + + set connectedBridgeId(val) { + this._connectedBridgeId = val; + if (!this._initialized) { + return; + } + // NOTE: This should be safe to call, even when _bridgesVal is still null. + gBridgeGrid.connectedBridgeId = val; + gBuiltinBridgesArea.connectedBridgeId = val; + }, + + /** + * The shown bridge source. + * + * Initially null to indicate that it is unset for the first call to + * _updateSource. + * + * @type {integer?} + */ + _bridgeSource: null, + /** + * Whether the user is encouraged to share their bridge addresses. + * + * @type {boolean} + */ + _canShare: false, + + /** + * Update _bridgeSource. + */ + _updateSource() { + // NOTE: This should only ever be called after TorSettings is already + // initialized. + const bridgeSource = this._bridgesVal.source; + if (bridgeSource === this._bridgeSource) { + // Avoid re-activating an area if the source has not changed. + return; + } + + this._bridgeSource = bridgeSource; + + // Before hiding elements, we determine whether our region contained the + // user focus. + const hadFocus = + this._bridgesEl.contains(document.activeElement) || + this._noBridgesEl.contains(document.activeElement); + + for (const [source, labelEl] of this._sourceLabels.entries()) { + labelEl.hidden = source !== bridgeSource; + } + + this._canShare = + bridgeSource === lazy.TorBridgeSource.UserProvided || + bridgeSource === lazy.TorBridgeSource.BridgeDB; + + this._shareEl.hidden = !this._canShare; + + // Force the menu to close whenever the source changes. + // NOTE: If the menu had focus then hadFocus will be true, and focus will be + // re-assigned. + this._forceCloseBridgesMenu(); + + // Update whether we have bridges. + this._updateHaveBridges(); + + if (hadFocus) { + // Always reset the focus to the start of the area whenever the source + // changes. + lazy.moveFocusToBridgeHeading(window); + } + }, + + /** + * Whether we have bridges or not, or null if it is unknown. + * + * @type {boolean?} + */ + _haveBridges: null, + + /** + * Update the _haveBridges value. + */ + _updateHaveBridges() { + const haveBridges = this._bridgesVal.haveBridges; + + if (haveBridges === this._haveBridges) { + return; + } + + this._haveBridges = haveBridges; + + // Add classes to show or hide the "no bridges" and "Your bridges" sections. + this._bridgesEl.hidden = !haveBridges; + this._noBridgesEl.hidden = haveBridges; + + this._displayEl.classList.toggle("has-tor-bridges", haveBridges); + }, + + /** + * The bridge strings in a copy-able form. + * + * @type {string} + */ + _bridgeStrings: "", + /** + * Whether the bridge strings should be shown as a QR code. + * + * @type {boolean} + */ + _canQRBridges: false, + + /** + * Update the stored bridge strings. + */ + _updateBridgeStrings() { + const bridges = this._bridgesVal.bridgeStrings; + + this._bridgeStrings = bridges.join("\n"); + // TODO: Determine what logic we want. + this._canQRBridges = bridges.length <= 3; + + this._qrButton.disabled = !this._canQRBridges; + }, + + /** + * Copy all the bridge addresses to the clipboard. + */ + _copyBridges() { + const clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + clipboard.copyString(this._bridgeStrings); + }, + + /** + * Open the QR code dialog encoding all the bridge addresses. + */ + _openQR() { + if (!this._canQRBridges) { + return; + } + showBridgeQr(this._bridgeStrings); + }, + + /** + * The QR button for copying all QR codes. + * + * @type {Element?} + */ + _qrButton: null, + + _initShareArea() { + document + .getElementById("tor-bridges-copy-addresses-button") + .addEventListener("click", () => { + this._copyBridges(); + }); + + this._qrButton = document.getElementById("tor-bridges-qr-addresses-button"); + this._qrButton.addEventListener("click", () => { + this._openQR(); + }); + }, + + /** + * The menu for all bridges. + * + * @type {Element?} + */ + _bridgesMenu: null, + + /** + * Initialize the menu for all bridges. + */ + _initBridgesMenu() { + this._bridgesMenu = document.getElementById("tor-bridges-all-options-menu"); + + // NOTE: We generally assume that once the bridge menu is opened the + // this._bridgeStrings value will not change. + const qrItem = document.getElementById( + "tor-bridges-options-qr-all-menu-item" + ); + qrItem.addEventListener("click", () => { + this._openQR(); + }); + + const copyItem = document.getElementById( + "tor-bridges-options-copy-all-menu-item" + ); + copyItem.addEventListener("click", () => { + this._copyBridges(); + }); + + const editItem = document.getElementById( + "tor-bridges-options-edit-all-menu-item" + ); + editItem.addEventListener("click", () => { + lazy.openUserProvideBridgeDialog(window, "edit"); + }); + + // TODO: Do we want a different item for built-in bridges, rather than + // "Remove all bridges"? + document + .getElementById("tor-bridges-options-remove-all-menu-item") + .addEventListener("click", async () => { + // TODO: Should we only have a warning when not built-in? + const parentWindow = + Services.wm.getMostRecentWindow("navigator:browser"); + const flags = + Services.prompt.BUTTON_POS_0 * + Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_0_DEFAULT + + Services.prompt.BUTTON_DEFAULT_IS_DESTRUCTIVE + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL; + + const [titleString, bodyString, removeString] = + await document.l10n.formatValues([ + { id: "remove-all-bridges-warning-title" }, + { id: "remove-all-bridges-warning-description" }, + { id: "remove-all-bridges-warning-remove-button" }, + ]); + + // TODO: Update the text, and remove old strings. + const buttonIndex = Services.prompt.confirmEx( + parentWindow, + titleString, + bodyString, + flags, + removeString, + null, + null, + null, + {} + ); + + if (buttonIndex !== 0) { + return; + } + + lazy.TorSettings.changeSettings({ + // This should always have the side effect of disabling bridges as + // well. + bridges: { source: lazy.TorBridgeSource.Invalid }, + }); + }); + + this._bridgesMenu.addEventListener("showing", () => { + qrItem.hidden = !this._canShare || !this._canQRBridges; + editItem.hidden = + this._bridgeSource !== lazy.TorBridgeSource.UserProvided; + }); + + const bridgesMenuButton = document.getElementById( + "tor-bridges-all-options-button" + ); + bridgesMenuButton.addEventListener("click", event => { + this._bridgesMenu.toggle(event, bridgesMenuButton); + }); + + this._bridgesMenu.addEventListener("hidden", () => { + // Make sure the button receives focus again when the menu is hidden. + // Currently, panel-list.js only does this when the menu is opened with a + // keyboard, but this causes focus to be lost from the page if the user + // uses a mixture of keyboard and mouse. + bridgesMenuButton.focus(); + }); + }, + + /** + * Force the bridges menu to close. + */ + _forceCloseBridgesMenu() { + this._bridgesMenu.hide(null, { force: true }); + }, +}; + +// TODO: Replace gBridgeSettings and #tor-bridges-display-template with proper +// widgets (using MozLitElement). +/** + * Show the current bridges. + */ +class TorBridgesDisplay extends HTMLElement { + connectedCallback() { + if (this.children.length) { + return; + } + // Take the template children since we only expect one instance of this. + this.replaceChildren( + ...document.getElementById("tor-bridges-display-template").content + .childNodes + ); + gBridgeSettings.init(this); + } + + set connectedBridgeId(val) { + gBridgeSettings.connectedBridgeId = val; + } + + set bridges(val) { + gBridgeSettings.bridges = val; + } + + /** + * Focus the "Your bridges" heading, if it is visible. + * + * @returns {boolean} - `true` if the heading was visible and focused. + */ + focusHeading() { + if (!gBridgeSettings._haveBridges) { + // Heading is hidden. + return false; + } + document.getElementById("tor-bridges-current-heading-non-search").focus(); + return true; + } +} +customElements.define("tor-bridges-display", TorBridgesDisplay); ===================================== toolkit/content/widgets/moz-fieldset/moz-fieldset.css ===================================== @@ -46,6 +46,10 @@ h4, h5, h6 { margin: 0; + + &[tabindex]:focus-visible { + outline-offset: var(--focus-outline-offset); + } } .description { ===================================== toolkit/content/widgets/moz-fieldset/moz-fieldset.mjs ===================================== @@ -11,12 +11,24 @@ import { MozLitElement } from "../lit-utils.mjs"; * @type {Record<number, (label: string) => ReturnType<typeof html>>} */ const HEADING_LEVEL_TEMPLATES = { - 1: label => html`<h1 class="text-box-trim-start">${label}</h1>`, - 2: label => html`<h2 class="text-box-trim-start">${label}</h2>`, - 3: label => html`<h3 class="text-box-trim-start">${label}</h3>`, - 4: label => html`<h4>${label}</h4>`, - 5: label => html`<h5>${label}</h5>`, - 6: label => html`<h6>${label}</h6>`, + 1: (label, tabindex) => + html`<h1 class="text-box-trim-start" tabindex=${ifDefined(tabindex)}> + ${label} + </h1>`, + 2: (label, tabindex) => + html`<h2 class="text-box-trim-start" tabindex=${ifDefined(tabindex)}> + ${label} + </h2>`, + 3: (label, tabindex) => + html`<h3 class="text-box-trim-start" tabindex=${ifDefined(tabindex)}> + ${label} + </h3>`, + 4: (label, tabindex) => + html`<h4 tabindex=${ifDefined(tabindex)}>${label}</h4>`, + 5: (label, tabindex) => + html`<h5 tabindex=${ifDefined(tabindex)}>${label}</h5>`, + 6: (label, tabindex) => + html`<h6 tabindex=${ifDefined(tabindex)}>${label}</h6>`, }; /** @@ -42,6 +54,9 @@ export default class MozFieldset extends MozLitElement { disabled: { type: Boolean, reflect: true }, iconSrc: { type: String }, badge: { type: String }, + // Allows the heading to be focusable, but not part of the Tab focus cycle. + // See tor-browser#45143. + focusableHeading: { type: Boolean, attribute: "focusable-heading" }, }; constructor() { @@ -132,12 +147,25 @@ export default class MozFieldset extends MozLitElement { legendTemplate() { let label = - HEADING_LEVEL_TEMPLATES[this.headingLevel]?.(this.label) || this.label; + HEADING_LEVEL_TEMPLATES[this.headingLevel]?.( + this.label, + this.focusableHeading ? "-1" : undefined + ) || this.label; return html`<legend part="label"> ${this.iconTemplate()}${label}${this.badgeTemplate()} </legend>`; } + /** + * Move the user's focus to the heading, if it is focusable. + */ + focusHeading() { + if (!this.focusableHeading) { + return; + } + this.shadowRoot.querySelector("h1,h2,h3,h4,h5,h6")?.focus(); + } + iconTemplate() { if (!this.iconSrc) { return ""; ===================================== toolkit/locales/en-US/toolkit/global/tor-browser.ftl ===================================== @@ -152,6 +152,9 @@ tor-connection-status-connect-button = tor-bridges-heading = Bridges tor-bridges-overview = Bridges help you securely access the Tor network in places where Tor is blocked. Depending on where you are, one bridge may work better than another. tor-bridges-learn-more-link = Learn more +tor-bridges-group = + .label = Bridges + .description = Bridges help you securely access the Tor network in places where Tor is blocked. Depending on where you are, one bridge may work better than another. # Toggle button for enabling and disabling the use of bridges. tor-bridges-use-bridges = @@ -310,12 +313,23 @@ tor-bridges-lox-got-it-button = Got it tor-bridges-add-bridges-heading = Add bridges # Shown as a heading when the user has existing bridges that can be replaced. tor-bridges-replace-bridges-heading = Replace your bridges +# Shown as a heading when the user has no current bridges. +tor-bridges-add-bridges-group = + .label = Add bridges +# Shown as a heading when the user has existing bridges that can be replaced. +tor-bridges-replace-bridges-group = + .label = Replace your bridges # -brand-short-name refers to 'Tor Browser', localized. tor-bridges-select-built-in-description = Choose from one of { -brand-short-name }’s built-in bridges +# -brand-short-name refers to 'Tor Browser', localized. +tor-bridges-choose-built-in-button = + .label = Choose from one of { -brand-short-name }’s built-in bridges tor-bridges-select-built-in-button = Select a built-in bridge… tor-bridges-add-addresses-description = Enter bridge addresses you already know +tor-bridges-enter-bridges-button = + .label = Enter bridge addresses you already know # Shown when the user has no current bridges. # Opens a dialog where the user can provide a new bridge address or share code. tor-bridges-add-new-button = Add new bridges… @@ -326,6 +340,10 @@ tor-bridges-replace-button = Replace bridges… tor-bridges-find-more-heading = Find more bridges # "Tor Project" is the organisation name. tor-bridges-find-more-description = Since many bridge addresses aren’t public, you may need to request some from the Tor Project. +# "Tor Project" is the organisation name. +tor-bridges-find-more-group = + .label = Find more bridges + .description = Since many bridge addresses aren’t public, you may need to request some from the Tor Project. # "Telegram" is the common brand name of the Telegram Messenger application tor-bridges-provider-telegram-name = Telegram @@ -333,6 +351,12 @@ tor-bridges-provider-telegram-name = Telegram # $telegramUserName (String) - The Telegram Messenger user name that should receive messages. Should be wrapped in '<a data-l10n-name="user">' and '</a>'. # E.g. in English, "Message GetBridgesBot". tor-bridges-provider-telegram-instruction = Message <a data-l10n-name="user">{ $telegramUserName }</a> +# "Telegram" is the common brand name of the Telegram Messenger application. +# Here "Message" is a verb, short for "Send a message to". This is an instruction to send a message to the given Telegram Messenger user to receive a new bridge. +# $telegramUserName (String) - The Telegram Messenger user name that should receive messages. +tor-bridges-source-telegram-link = + .label = Telegram + .description = Message { $telegramUserName } # "Web" is the proper noun for the "World Wide Web". tor-bridges-provider-web-name = Web @@ -340,15 +364,31 @@ tor-bridges-provider-web-name = Web # $url (String) - The URL for Tor Project bridges. Should be wrapped in '<a data-l10n-name"url">' and '</a>'. tor-bridges-provider-web-instruction = Visit <a data-l10n-name="url">{ $url }</a> +# "Web" is the proper noun for the "World Wide Web". +# $url (String) - The URL for Tor Project bridges. +tor-bridges-source-web-link = + .label = Web + .description = Visit { $url } + # "Gmail" is the Google brand name. "Riseup" refers to the Riseup organisation at riseup.net. tor-bridges-provider-email-name = Gmail or Riseup # Here "Email" is a verb, short for "Send an email to". This is an instruction to send an email to the given address to receive a new bridge. # $address (String) - The email address that should receive the email. # E.g. in English, "Email bridges@torproject.org". tor-bridges-provider-email-instruction = Email { $address } +# "Gmail" is the Google brand name. "Riseup" refers to the Riseup organisation at riseup.net. +# Here "Email" is a verb, short for "Send an email to". This is an instruction to send an email to the given address to receive a new bridge. +# $address (String) - The email address that should receive the email. +# E.g. in English, "Email bridges@torproject.org". +tor-bridges-source-email-link = + .label = Gmail or Riseup + .description = Email { $address } tor-bridges-request-from-browser = You can also get bridges from the bridge bot without leaving { -brand-short-name }. tor-bridges-request-button = Request bridges… +tor-bridges-request-from-browser2 = Get bridges from the bridge bot without leaving { -brand-short-name }. +tor-bridges-request-button2 = + .label = Request bridges… ## Warning dialog when removing all bridges. ===================================== toolkit/modules/TorSettings.sys.mjs ===================================== @@ -358,6 +358,12 @@ class TorSettingsImpl { */ #initialized = false; + /** + * Whether init has been called. + * + * @type {boolean} + */ + #initCalled = false; /** * Whether uninit cleanup has been called. * @@ -510,11 +516,12 @@ class TorSettingsImpl { * Load or init our settings. */ async init() { - if (this.#initialized) { + if (this.#initCalled) { lazy.logger.warn("Called init twice."); await this.#initializedPromise; return; } + this.#initCalled = true; try { await this.#initInternal(); this.#initialized = true; ===================================== tools/torbrowser/l10n/migrations/bug-45058-bridge-settings.py ===================================== @@ -0,0 +1,102 @@ +import re + +import fluent.syntax.ast as FTL +from fluent.migrate.helpers import transforms_from +from fluent.migrate.transforms import COPY_PATTERN, FluentSource +from fluent.syntax.visitor import Visitor + + +class RemoveAnchorVisitor(Visitor): + """Class to remove <a> and </a> wrappers from a Fluent TextElement.""" + + def __init__(self): + # Good enough regex for our needs that will match starting and ending + # tags. + self._anchor_regex = re.compile(r"<\/?[aA](| [^>]*)>") + super().__init__() + + def visit_TextElement(self, node): + node.value = self._anchor_regex.sub("", node.value) + + +class RemoveAnchorTransform(FluentSource): + """Class to remove <a> and </a> wrappers from a Fluent source.""" + + def __call__(self, ctx): + pattern = ctx.get_fluent_source_pattern(self.path, self.key).clone() + # Visit every node in the pattern, replacing each TextElement's content. + RemoveAnchorVisitor().visit(pattern) + return pattern + + +def migrate(ctx): + ctx.add_transforms( + "tor-browser.ftl", + "tor-browser.ftl", + transforms_from( + """ +tor-bridges-group = + .label = { COPY_PATTERN(path, "tor-bridges-heading") } + .description = { COPY_PATTERN(path, "tor-bridges-overview") } +tor-bridges-add-bridges-group = + .label = { COPY_PATTERN(path, "tor-bridges-add-bridges-heading") } +tor-bridges-replace-bridges-group = + .label = { COPY_PATTERN(path, "tor-bridges-replace-bridges-heading") } +tor-bridges-choose-built-in-button = + .label = { COPY_PATTERN(path, "tor-bridges-select-built-in-description") } +tor-bridges-enter-bridges-button = + .label = { COPY_PATTERN(path, "tor-bridges-add-addresses-description") } +tor-bridges-find-more-group = + .label = { COPY_PATTERN(path, "tor-bridges-find-more-heading") } + .description = { COPY_PATTERN(path, "tor-bridges-find-more-description") } +tor-bridges-request-button2 = + .label = { COPY_PATTERN(path, "tor-bridges-request-button") } +tor-bridges-source-email-link = + .label = { COPY_PATTERN(path, "tor-bridges-provider-email-name") } + .description = { COPY_PATTERN(path, "tor-bridges-provider-email-instruction") } +""", + path="tor-browser.ftl", + ) + + [ + FTL.Message( + id=FTL.Identifier("tor-bridges-source-telegram-link"), + value=None, + attributes=[ + FTL.Attribute( + id=FTL.Identifier("label"), + value=COPY_PATTERN( + "tor-browser.ftl", + "tor-bridges-provider-telegram-name", + ), + ), + FTL.Attribute( + id=FTL.Identifier("description"), + value=RemoveAnchorTransform( + "tor-browser.ftl", + "tor-bridges-provider-telegram-instruction", + ), + ), + ], + ), + FTL.Message( + id=FTL.Identifier("tor-bridges-source-web-link"), + value=None, + attributes=[ + FTL.Attribute( + id=FTL.Identifier("label"), + value=COPY_PATTERN( + "tor-browser.ftl", + "tor-bridges-provider-web-name", + ), + ), + FTL.Attribute( + id=FTL.Identifier("description"), + value=RemoveAnchorTransform( + "tor-browser.ftl", + "tor-bridges-provider-web-instruction", + ), + ), + ], + ), + ], + ) View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/7a41b6f... -- View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/7a41b6f... You're receiving this email because of your account on gitlab.torproject.org. Manage all notifications: https://gitlab.torproject.org/-/profile/notifications | Help: https://gitlab.torproject.org/help