richard pushed to branch tor-browser-115.7.0esr-13.5-1 at The Tor Project / Applications / Tor Browser
Commits: 39bde3a7 by Henry Wilkes at 2024-01-23T17:30:24+00:00 fixup! Bug 31286: Implementation of bridge, proxy, and firewall settings in about:preferences#connection
Bug 42036: Remove old bridge cards.
- - - - - 8fee6e00 by Henry Wilkes at 2024-01-23T17:30:25+00:00 fixup! Add TorStrings module for localization
Bug 42036: Remove old bridge cards.
- - - - - dc354cb3 by Henry Wilkes at 2024-01-23T17:30:25+00:00 fixup! Bug 40597: Implement TorSettings module
Bug 42036: Add syncSettings to TorSettings and improve error reporting.
- - - - - 0b412130 by Henry Wilkes at 2024-01-23T17:30:26+00:00 fixup! Bug 31286: Implementation of bridge, proxy, and firewall settings in about:preferences#connection
Bug 42036: Implement new bridge settings UI, ready for Lox.
- - - - - 02cf2743 by Henry Wilkes at 2024-01-23T17:30:26+00:00 fixup! Tor Browser strings
Bug 42036: New bridge UI strings.
- - - - -
12 changed files:
- + browser/components/torpreferences/content/bridge-qr.svg - + browser/components/torpreferences/content/bridge.svg - browser/components/torpreferences/content/builtinBridgeDialog.mjs - browser/components/torpreferences/content/builtinBridgeDialog.xhtml - browser/components/torpreferences/content/connectionPane.js - browser/components/torpreferences/content/connectionPane.xhtml - browser/components/torpreferences/content/torPreferences.css - browser/components/torpreferences/jar.mn - browser/locales/en-US/browser/tor-browser.ftl - toolkit/modules/TorSettings.sys.mjs - toolkit/modules/TorStrings.sys.mjs - toolkit/torbutton/chrome/locale/en-US/settings.properties
Changes:
===================================== browser/components/torpreferences/content/bridge-qr.svg ===================================== @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="context-fill" xmlns="http://www.w3.org/2000/svg"> + <path d="M5.5 3C4.83696 3 4.20107 3.26339 3.73223 3.73223C3.26339 4.20107 3 4.83696 3 5.5V9H4.75V5.5C4.75 5.30109 4.82902 5.11032 4.96967 4.96967C5.11032 4.82902 5.30109 4.75 5.5 4.75H9V3H5.5ZM11 10.25C11 10.4489 10.921 10.6397 10.7803 10.7803C10.6397 10.921 10.4489 11 10.25 11H7.75C7.55109 11 7.36032 10.921 7.21967 10.7803C7.07902 10.6397 7 10.4489 7 10.25V7.75C7 7.55109 7.07902 7.36032 7.21967 7.21967C7.36032 7.07902 7.55109 7 7.75 7H10.25C10.4489 7 10.6397 7.07902 10.7803 7.21967C10.921 7.36032 11 7.55109 11 7.75V10.25ZM17 15H15V13H17V11H15V9H17V7H15V9H13V11H15V13H13V15H11V13H9V15H7V17H9V15H11V17H13V15H15V17H17V15ZM3 18.5V15H4.75V18.5C4.75 18.914 5.086 19.25 5.5 19.25H9V21H5.5C4.83696 21 4.20107 20.7366 3.73223 20.2678C3.26339 19.7989 3 19.163 3 18.5ZM15 3H18.5C19.163 3 19.7989 3.26339 20.2678 3.73223C20.7366 4.20107 21 4.83696 21 5.5V9H19.25V5.5C19.25 5.30109 19.171 5.11032 19.0303 4.96967C18.8897 4.82902 18.6989 4.75 18.5 4.75H15V3ZM21 18.5V15H19.25V18.5C19.25 18.6989 19.171 18.8897 19.0303 19.0303C18.8897 19.171 18.6989 19.25 18.5 19.25H15V21H18.5C19.163 21 19.7989 20.7366 20.2678 20.2678C20.7366 19.7989 21 19.163 21 18.5Z"/> +</svg>
===================================== browser/components/torpreferences/content/bridge.svg ===================================== @@ -0,0 +1,5 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="context-fill" xmlns="http://www.w3.org/2000/svg"> + <path d="M15.5 11.5C15.5 7.35786 12.1421 4 8 4C3.85786 4 0.5 7.35786 0.5 11.5V12.7461H1.67188V11.5C1.67188 8.00507 4.50507 5.17188 8 5.17188C11.4949 5.17188 14.3281 8.00507 14.3281 11.5V12.7461H15.5V11.5Z"/> + <path d="M13.25 11.5C13.25 8.6005 10.8995 6.24999 7.99999 6.24999C5.1005 6.24999 2.74999 8.6005 2.74999 11.5L2.74989 12.7461H3.92177L3.92187 11.5C3.92187 9.24771 5.74771 7.42187 7.99999 7.42187C10.2523 7.42187 12.0781 9.24771 12.0781 11.5L12.0782 12.7461H13.2501L13.25 11.5Z"/> + <path d="M8 8.5C9.65686 8.5 11 9.84315 11 11.5L11.0002 12.7461H9.82836L9.82813 11.5C9.82813 10.4904 9.00965 9.67188 8 9.67188C6.99036 9.67188 6.17188 10.4904 6.17188 11.5L6.17164 12.7461H4.99977V11.5C4.99977 9.84315 6.34315 8.5 8 8.5Z"/> +</svg>
===================================== browser/components/torpreferences/content/builtinBridgeDialog.mjs ===================================== @@ -69,10 +69,12 @@ export class BuiltinBridgeDialog { optionEl.querySelector( ".torPreferences-current-bridge-label" ).textContent = TorStrings.settings.currentBridge; - optionEl.classList.toggle( - "current-builtin-bridge-type", - type === currentBuiltinType - ); + optionEl + .querySelector(".bridge-status-badge") + .classList.toggle( + "bridge-status-current-built-in", + type === currentBuiltinType + ); }
if (currentBuiltinType) {
===================================== browser/components/torpreferences/content/builtinBridgeDialog.xhtml ===================================== @@ -20,8 +20,8 @@ aria-describedby="obfs-bridges-current obfs-bridges-description" value="obfs4" /> - <html:span class="torPreferences-current-bridge-badge"> - <image class="torPreferences-current-bridge-icon" /> + <html:span class="bridge-status-badge"> + <html:div class="bridge-status-icon"></html:div> <html:span id="obfs-bridges-current" class="torPreferences-current-bridge-label" @@ -41,8 +41,8 @@ aria-describedby="snowflake-bridges-current snowflake-bridges-description" value="snowflake" /> - <html:span class="torPreferences-current-bridge-badge"> - <image class="torPreferences-current-bridge-icon" /> + <html:span class="bridge-status-badge"> + <html:div class="bridge-status-icon"></html:div> <html:span id="snowflake-bridges-current" class="torPreferences-current-bridge-label" @@ -62,8 +62,8 @@ aria-describedby="meek-bridges-current meek-bridges-description" value="meek-azure" /> - <html:span class="torPreferences-current-bridge-badge"> - <image class="torPreferences-current-bridge-icon" /> + <html:span class="bridge-status-badge"> + <html:div class="bridge-status-icon"></html:div> <html:span id="meek-bridges-current" class="torPreferences-current-bridge-label"
===================================== browser/components/torpreferences/content/connectionPane.js ===================================== @@ -66,6 +66,1642 @@ const InternetStatus = Object.freeze({ Offline: -1, });
+/** + * Make changes to TorSettings and save them. + * + * Bulk changes will be frozen together. + * + * @param {Function} changes - Method to apply changes to TorSettings. + */ +async function setTorSettings(changes) { + if (!TorSettings.initialized) { + console.warning("Ignoring changes to uninitialized TorSettings"); + return; + } + TorSettings.freezeNotifications(); + try { + changes(); + // This will trigger TorSettings.#cleanupSettings() + TorSettings.saveToPrefs(); + try { + // May throw. + await TorSettings.applySettings(); + } catch (e) { + console.error("Failed to save Tor settings", e); + } + } finally { + TorSettings.thawNotifications(); + } +} + +/** + * Get the ID/fingerprint of the bridge used in the most recent Tor circuit. + * + * @returns {string?} - The bridge ID or null if a bridge with an id was not + * used in the last circuit. + */ +async function getConnectedBridgeId() { + // TODO: PieroV: We could make sure TorSettings is in sync by monitoring also + // changes of settings. At that point, we could query it, instead of doing a + // query over the control port. + let bridge = null; + try { + const provider = await TorProviderBuilder.build(); + bridge = provider.currentBridge; + } catch (e) { + console.warn("Could not get current bridge", e); + } + return bridge?.fingerprint ?? null; +} + +// TODO: Instead of aria-live in the DOM, use the proposed ariaNotify +// API if it gets accepted into firefox and works with screen readers. +// See https://github.com/WICG/proposals/issues/112 +/** + * Notification for screen reader users. + */ +const gBridgesNotification = { + /** + * The screen reader area that shows updates. + * + * @type {Element?} + */ + _updateArea: null, + /** + * The text for the screen reader update. + * + * @type {Element?} + */ + _textEl: null, + /** + * A timeout for hiding the update. + * + * @type {integer?} + */ + _hideUpdateTimeout: null, + + /** + * Initialize the area for notifications. + */ + init() { + this._updateArea = document.getElementById("tor-bridges-update-area"); + this._textEl = document.getElementById("tor-bridges-update-area-text"); + }, + + /** + * Post a new notification, replacing any existing one. + * + * @param {string} type - The notification type. + */ + post(type) { + this._updateArea.hidden = false; + // First we clear the update area to reset the text to be empty. + this._textEl.removeAttribute("data-l10n-id"); + this._textEl.textContent = ""; + if (this._hideUpdateTimeout !== null) { + clearTimeout(this._hideUpdateTimeout); + this._hideUpdateTimeout = null; + } + + 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; + } + + // Hide the area after 5 minutes, when the update is not "recent" any + // more. + this._hideUpdateTimeout = setTimeout(() => { + this._updateArea.hidden = true; + }, 300000); + + // Wait a small amount of time to actually set the textContent. Otherwise + // the screen reader (tested with Orca) may not pick up on the change in + // text. + setTimeout(() => { + document.l10n.setAttributes(this._textEl, updateId); + }, 500); + }, +}; + +/** + * 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} EmojiCell + * + * @property {Element} cell - The grid cell element. + * @property {Element} img - The grid cell icon. + * @property {Element} index - The emoji index. + */ + /** + * @typedef {object} BridgeGridRow + * + * @property {Element} element - The row element. + * @property {Element} optionsButton - The options button. + * @property {EmojiCell[]} emojis - The emoji cells. + * @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); + + Services.obs.addObserver(this, TorSettingsTopics.SettingsChanged); + + // NOTE: Before initializedPromise completes, this area is hidden. + TorSettings.initializedPromise.then(() => { + this._updateRows(true); + }); + }, + + /** + * Uninitialize the bridge grid. + */ + uninit() { + Services.obs.removeObserver(this, TorSettingsTopics.SettingsChanged); + this.deactivate(); + }, + + /** + * 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; + + Services.obs.addObserver(this, "intl:app-locales-changed"); + Services.obs.addObserver(this, TorProviderTopics.BridgeChanged); + + this._grid.classList.add("grid-active"); + + this._updateEmojiLangCode(); + this._updateConnectedBridge(); + }, + + /** + * Deactivate and hide the bridge grid. + */ + deactivate() { + if (!this._active) { + return; + } + + this._active = false; + + this._forceCloseRowMenus(); + + this._grid.classList.remove("grid-active"); + + Services.obs.removeObserver(this, "intl:app-locales-changed"); + Services.obs.removeObserver(this, TorProviderTopics.BridgeChanged); + }, + + observe(subject, topic, data) { + switch (topic) { + case TorSettingsTopics.SettingsChanged: + const { changes } = subject.wrappedJSObject; + if ( + changes.includes("bridges.source") || + changes.includes("bridges.bridge_strings") + ) { + this._updateRows(); + } + break; + case "intl:app-locales-changed": + this._updateEmojiLangCode(); + break; + case TorProviderTopics.BridgeChanged: + this._updateConnectedBridge(); + break; + } + }, + + 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, + /** + * Update _connectedBridgeId. + */ + async _updateConnectedBridge() { + const bridgeId = await getConnectedBridgeId(); + 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"); + } + }, + + /** + * The language code for emoji annotations. + * + * null if unset. + * + * @type {string?} + */ + _emojiLangCode: null, + /** + * A promise that resolves to two JSON structures for bridge-emojis.json and + * annotations.json, respectively. + * + * @type {Promise} + */ + _emojiPromise: Promise.all([ + fetch( + "chrome://browser/content/torpreferences/bridgemoji/bridge-emojis.json" + ).then(response => response.json()), + fetch( + "chrome://browser/content/torpreferences/bridgemoji/annotations.json" + ).then(response => response.json()), + ]), + + /** + * Update _emojiLangCode. + */ + async _updateEmojiLangCode() { + let langCode; + const emojiAnnotations = (await this._emojiPromise)[1]; + // Find the first desired locale we have annotations for. + // Add "en" as a fallback. + for (const bcp47 of [...Services.locale.appLocalesAsBCP47, "en"]) { + langCode = bcp47; + if (langCode in emojiAnnotations) { + break; + } + // Remove everything after the dash, if there is one. + langCode = bcp47.replace(/-.*/, ""); + if (langCode in emojiAnnotations) { + break; + } + } + if (langCode !== this._emojiLangCode) { + this._emojiLangCode = langCode; + for (const row of this._rows) { + this._updateRowEmojis(row); + } + } + }, + + /** + * Update the bridge emojis to show their corresponding emoji with an + * annotation that matches the current locale. + * + * @param {BridgeGridRow} row - The row to update the emojis of. + */ + async _updateRowEmojis(row) { + if (!this._emojiLangCode) { + // No lang code yet, wait until it is updated. + return; + } + + const [emojiList, emojiAnnotations] = await this._emojiPromise; + const unknownString = await document.l10n.formatValue( + "tor-bridges-emoji-unknown" + ); + + for (const { cell, img, index } of row.emojis) { + const emoji = emojiList[index]; + let emojiName; + if (!emoji) { + // Unexpected. + img.removeAttribute("src"); + } else { + const cp = emoji.codePointAt(0).toString(16); + img.setAttribute( + "src", + `chrome://browser/content/torpreferences/bridgemoji/svgs/${cp}.svg` + ); + emojiName = emojiAnnotations[this._emojiLangCode][cp]; + } + if (!emojiName) { + console.error(`No emoji for index ${index}`); + emojiName = unknownString; + } + document.l10n.setAttributes(cell, "tor-bridges-emoji-cell", { + emojiName, + }); + } + }, + + /** + * 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 = 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"); + row.emojis = makeBridgeId(bridgeLine).map(index => { + const cell = document.createElement("span"); + // Each emoji is its own cell, we rely on the fact that makeBridgeId + // always returns four indices. + cell.setAttribute("role", "gridcell"); + cell.classList.add("tor-bridges-grid-cell", "tor-bridges-emoji-cell"); + + const img = document.createElement("img"); + img.classList.add("tor-bridges-emoji-icon"); + // Accessible name will be set on the cell itself. + img.setAttribute("alt", ""); + + cell.appendChild(img); + emojiBlock.appendChild(cell); + // Image and text is set in _updateRowEmojis. + return { cell, img, index }; + }); + + 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 }); + } + + // TODO: properly handle "vanilla" bridges? + document.l10n.setAttributes( + row.element.querySelector(".tor-bridges-type-cell"), + "tor-bridges-type-prefix", + { type: details?.transport ?? "vanilla" } + ); + + row.element.querySelector(".tor-bridges-address-cell").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); + this._updateRowEmojis(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(); + }); + + row.menu + .querySelector(".tor-bridges-options-qr-one-menu-item") + .addEventListener("click", () => { + const bridgeLine = row.bridgeLine; + if (!bridgeLine) { + return; + } + const dialog = new BridgeQrDialog(); + dialog.openDialog(gSubDialog, 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); + }); + row.menu + .querySelector(".tor-bridges-options-remove-one-menu-item") + .addEventListener("click", () => { + const bridgeLine = row.bridgeLine; + const strings = TorSettings.bridges.bridge_strings; + const index = strings.indexOf(bridgeLine); + if (index === -1) { + return; + } + strings.splice(index, 1); + + setTorSettings(() => { + TorSettings.bridges.bridge_strings = strings; + }); + }); + }, + + /** + * 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: [TorBridgeSource.BridgeDB, TorBridgeSource.UserProvided], + + /** + * Update the grid to show the latest bridge strings. + * + * @param {boolean} [initializing=false] - Whether this is being called as + * part of initialization. + */ + _updateRows(initializing = false) { + // 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 = TorSettings.bridges.source; + if (bridgeSource !== this._bridgeSource) { + newSource = true; + + this._bridgeSource = bridgeSource; + + if (this._supportedSources.includes(bridgeSource)) { + this.activate(); + } else { + if (this._active && bridgeSource === TorBridgeSource.Invalid) { + lostAllBridges = true; + } + this.deactivate(); + } + } + + const ordered = this._active + ? TorSettings.bridges.bridge_strings.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); + } + if (!this._active && focusWithin) { + // Move focus out of this element, which has been hidden. + gBridgeSettings.takeFocus(); + } + + // 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) { + gBridgesNotification.post(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" + ); + + Services.obs.addObserver(this, TorSettingsTopics.SettingsChanged); + + // NOTE: Before initializedPromise completes, this area is hidden. + TorSettings.initializedPromise.then(() => { + this._updateBridgeType(true); + }); + }, + + /** + * Uninitialize the built-in bridges area. + */ + uninit() { + Services.obs.removeObserver(this, TorSettingsTopics.SettingsChanged); + this.deactivate(); + }, + + /** + * 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; + + Services.obs.addObserver(this, TorProviderTopics.BridgeChanged); + + this._area.classList.add("built-in-active"); + + this._updateBridgeIds(); + this._updateConnectedBridge(); + }, + + /** + * Deactivate and hide built-in bridge area. + */ + deactivate() { + if (!this._active) { + return; + } + this._active = false; + + this._area.classList.remove("built-in-active"); + + Services.obs.removeObserver(this, TorProviderTopics.BridgeChanged); + }, + + observe(subject, topic, data) { + switch (topic) { + case TorSettingsTopics.SettingsChanged: + const { changes } = subject.wrappedJSObject; + if ( + changes.includes("bridges.source") || + changes.includes("bridges.builtin_type") + ) { + this._updateBridgeType(); + } + if (changes.includes("bridges.bridge_strings")) { + this._updateBridgeIds(); + } + break; + case TorProviderTopics.BridgeChanged: + this._updateConnectedBridge(); + break; + } + }, + + /** + * Updates the shown connected state. + */ + _updateConnectedState() { + this._connectionStatusEl.classList.toggle( + "bridge-status-connected", + this._bridgeType && + this._connectedBridgeId && + this._bridgeIds.includes(this._connectedBridgeId) + ); + }, + + /** + * The currently shown bridge type. Empty if deactivated, and null if + * uninitialized. + * + * @type {string?} + */ + _bridgeType: null, + /** + * The strings for each known bridge type. + * + * @type {Object<string,object>} + */ + _bridgeTypeStrings: { + // TODO: Change to Fluent ids. + obfs4: { + name: TorStrings.settings.builtinBridgeObfs4Title, + description: TorStrings.settings.builtinBridgeObfs4Description2, + }, + snowflake: { + name: TorStrings.settings.builtinBridgeSnowflake, + description: TorStrings.settings.builtinBridgeSnowflakeDescription2, + }, + "meek-azure": { + name: TorStrings.settings.builtinBridgeMeekAzure, + description: TorStrings.settings.builtinBridgeMeekAzureDescription2, + }, + }, + + /** + * The known bridge source. + * + * Initially null to indicate that it is unset. + * + * @type {integer?} + */ + _bridgeSource: null, + + /** + * Update the shown bridge type. + * + * @param {boolean} [initializing=false] - Whether this is being called as + * part of initialization. + */ + async _updateBridgeType(initializing = false) { + let lostAllBridges = false; + let newSource = false; + const bridgeSource = TorSettings.bridges.source; + if (bridgeSource !== this._bridgeSource) { + newSource = true; + + this._bridgeSource = bridgeSource; + + if (bridgeSource === TorBridgeSource.BuiltIn) { + this.activate(); + } else { + if (this._active && bridgeSource === TorBridgeSource.Invalid) { + lostAllBridges = true; + } + const hadFocus = this._area.contains(document.activeElement); + this.deactivate(); + if (hadFocus) { + gBridgeSettings.takeFocus(); + } + } + } + + const bridgeType = this._active ? TorSettings.bridges.builtin_type : ""; + + 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 + ); + */ + this._nameEl.textContent = bridgeStrings.name; + this._descriptionEl.textContent = 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) { + gBridgesNotification.post(notificationType); + } + } + }, + + /** + * The bridge IDs/fingerprints for the built-in bridges. + * + * @type {Array<string>} + */ + _bridgeIds: [], + /** + * Update _bridgeIds + */ + _updateBridgeIds() { + this._bridgeIds = []; + for (const bridgeLine of TorSettings.bridges.bridge_strings) { + try { + this._bridgeIds.push(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, + /** + * Update _connectedBridgeId. + */ + async _updateConnectedBridge() { + this._connectedBridgeId = await getConnectedBridgeId(); + this._updateConnectedState(); + }, +}; + +/** + * Controls the bridge settings. + */ +const gBridgeSettings = { + /** + * The preferences <groupbox> for bridges + * + * @type {Element?} + */ + _groupEl: null, + /** + * The button for controlling whether bridges are enabled. + * + * @type {Element?} + */ + _toggleButton: null, + /** + * The area for showing current bridges. + * + * @type {Element?} + */ + _bridgesEl: null, + /** + * The heading for the bridge settings. + * + * @type {Element?} + */ + _bridgesSettingsHeading: null, + /** + * The current bridges heading, at the start of the area. + * + * @type {Element?} + */ + _currentBridgesHeading: null, + /** + * The area for showing no bridges. + * + * @type {Element?} + */ + _noBridgesEl: null, + + /** + * Initialize the bridge settings. + */ + init() { + gBridgesNotification.init(); + + this._bridgesSettingsHeading = document.getElementById( + "torPreferences-bridges-header" + ); + this._currentBridgesHeading = document.getElementById( + "tor-bridges-current-heading" + ); + this._bridgesEl = document.getElementById("tor-bridges-current"); + this._noBridgesEl = document.getElementById("tor-bridges-none"); + this._groupEl = document.getElementById("torPreferences-bridges-group"); + this._toggleButton = document.getElementById("tor-bridges-enabled-toggle"); + // Initially disabled whilst TorSettings may not be initialized. + this._toggleButton.disabled = true; + + this._toggleButton.addEventListener("toggle", () => { + if (!this._haveBridges) { + return; + } + setTorSettings(() => { + TorSettings.bridges.enabled = this._toggleButton.pressed; + }); + }); + + Services.obs.addObserver(this, TorSettingsTopics.SettingsChanged); + + gBridgeGrid.init(); + gBuiltinBridgesArea.init(); + + this._initBridgesMenu(); + this._initShareArea(); + + // NOTE: Before initializedPromise completes, the current bridges sections + // should be hidden. + // And gBridgeGrid and gBuiltinBridgesArea are not active. + TorSettings.initializedPromise.then(() => { + this._updateEnabled(); + this._updateBridgeStrings(); + this._updateSource(); + }); + }, + + /** + * Un-initialize the bridge settings. + */ + uninit() { + gBridgeGrid.uninit(); + gBuiltinBridgesArea.uninit(); + + Services.obs.removeObserver(this, TorSettingsTopics.SettingsChanged); + }, + + observe(subject, topic, data) { + switch (topic) { + case TorSettingsTopics.SettingsChanged: + const { changes } = subject.wrappedJSObject; + if (changes.includes("bridges.enabled")) { + this._updateEnabled(); + } + if (changes.includes("bridges.source")) { + this._updateSource(); + } + if (changes.includes("bridges.bridge_strings")) { + this._updateBridgeStrings(); + } + break; + } + }, + + /** + * Update whether the bridges should be shown as enabled. + */ + _updateEnabled() { + // Changing the pressed property on moz-toggle should not trigger its + // "toggle" event. + this._toggleButton.pressed = TorSettings.bridges.enabled; + }, + + /** + * The shown bridge source. + * + * Initially null to indicate that it is unset for the first call to + * _updateSource. + * + * @type {integer?} + */ + _bridgeSource: null, + + /** + * Update _bridgeSource. + */ + _updateSource() { + // NOTE: This should only ever be called after TorSettings is already + // initialized. + const bridgeSource = TorSettings.bridges.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); + + this._bridgesEl.classList.toggle( + "source-built-in", + bridgeSource === TorBridgeSource.BuiltIn + ); + this._bridgesEl.classList.toggle( + "source-user", + bridgeSource === TorBridgeSource.UserProvided + ); + this._bridgesEl.classList.toggle( + "source-requested", + bridgeSource === TorBridgeSource.BridgeDB + ); + + // 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. + // NOTE: gBuiltinBridges._updateBridgeType and gBridgeGrid._updateRows + // may have already called takeFocus in response to them being + // de-activated. The re-call should be safe. + this.takeFocus(); + } + }, + + /** + * Whether we have bridges or not, or null if it is unknown. + * + * @type {boolean?} + */ + _haveBridges: null, + + /** + * Update the _haveBridges value. + */ + _updateHaveBridges() { + // NOTE: We use the TorSettings.bridges.source value, rather than + // this._bridgeSource because _updateHaveBridges can be called just before + // _updateSource (via takeFocus). + const haveBridges = TorSettings.bridges.source !== TorBridgeSource.Invalid; + + if (haveBridges === this._haveBridges) { + return; + } + + this._haveBridges = haveBridges; + + this._toggleButton.disabled = !haveBridges; + // Add classes to show or hide the "no bridges" and "Your bridges" sections. + // NOTE: Before haveBridges is set, neither class is added, so both sections + // and hidden. + this._groupEl.classList.toggle("no-bridges", !haveBridges); + this._groupEl.classList.toggle("have-bridges", haveBridges); + }, + + /** + * Force the focus to move to the bridge area. + */ + takeFocus() { + if (this._haveBridges === null) { + // The bridges area has not been initialized yet, which means that + // TorSettings may not be initialized. + // Unexpected to receive a call before then, so just return early. + return; + } + + // Make sure we have the latest value for _haveBridges. + // We also ensure that the _currentBridgesHeading element is visible before + // we focus it. + this._updateHaveBridges(); + if (this._haveBridges) { + // Move focus to the start of the area, which is the heading. + // It has tabindex="-1" so should be focusable, even though it is not part + // of the usual tab navigation. + this._currentBridgesHeading.focus(); + } else { + // Move focus to the top of the bridge settings. + this._bridgesSettingsHeading.focus(); + } + }, + + /** + * 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 = TorSettings.bridges.bridge_strings; + + 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; + } + const dialog = new BridgeQrDialog(); + dialog.openDialog(gSubDialog, 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", () => { + // TODO: move to gBridgeSettings. + // TODO: Change dialog title. Do not allow Lox invite. + gConnectionPane.onAddBridgeManually(); + }); + + // 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", () => { + // 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_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL; + + // TODO: Update the text, and remove old strings. + const buttonIndex = Services.prompt.confirmEx( + parentWindow, + TorStrings.settings.bridgeRemoveAllDialogTitle, + TorStrings.settings.bridgeRemoveAllDialogDescription, + flags, + TorStrings.settings.remove, + null, + null, + null, + {} + ); + + if (buttonIndex !== 0) { + return; + } + + setTorSettings(() => { + // This should always have the side effect of disabling bridges as + // well. + TorSettings.bridges.bridge_strings = []; + }); + }); + + this._bridgesMenu.addEventListener("showing", () => { + const canCopy = this._bridgeSource !== TorBridgeSource.BuiltIn; + qrItem.hidden = !this._canQRBridges || !canCopy; + copyItem.hidden = !canCopy; + editItem.hidden = this._bridgeSource !== 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 }); + }, +}; + /* Connection Pane
@@ -96,29 +1732,6 @@ const gConnectionPane = (function () { location: "#torPreferences-bridges-location", locationEntries: "#torPreferences-bridges-locationEntries", chooseForMe: "#torPreferences-bridges-buttonChooseBridgeForMe", - currentHeader: "#torPreferences-currentBridges-header", - currentDescription: "#torPreferences-currentBridges-description", - currentDescriptionText: "#torPreferences-currentBridges-descriptionText", - controls: "#torPreferences-currentBridges-controls", - switch: "#torPreferences-currentBridges-switch", - cards: "#torPreferences-currentBridges-cards", - cardTemplate: "#torPreferences-bridgeCard-template", - card: ".torPreferences-bridgeCard", - cardId: ".torPreferences-bridgeCard-id", - cardHeadingManualLink: ".torPreferences-bridgeCard-manualLink", - cardHeadingAddr: ".torPreferences-bridgeCard-headingAddr", - cardConnectedLabel: ".torPreferences-current-bridge-label", - cardOptions: ".torPreferences-bridgeCard-options", - cardMenu: "#torPreferences-bridgeCard-menu", - cardQrGrid: ".torPreferences-bridgeCard-grid", - cardQrContainer: ".torPreferences-bridgeCard-qr", - cardQr: ".torPreferences-bridgeCard-qrCode", - cardShare: ".torPreferences-bridgeCard-share", - cardAddr: ".torPreferences-bridgeCard-addr", - cardLearnMore: ".torPreferences-bridgeCard-learnMore", - cardCopy: ".torPreferences-bridgeCard-copyButton", - showAll: "#torPreferences-currentBridges-showAll", - removeAll: "#torPreferences-currentBridges-removeAll", addHeader: "#torPreferences-addBridge-header", addBuiltinLabel: "#torPreferences-addBridge-labelBuiltinBridge", addBuiltinButton: "#torPreferences-addBridge-buttonBuiltinBridge", @@ -142,8 +1755,6 @@ const gConnectionPane = (function () {
_internetStatus: InternetStatus.Unknown,
- _currentBridgeId: null, - // populate xul with strings and cache the relevant elements _populateXUL() { // saves tor settings to disk when navigate away from about:preferences @@ -387,390 +1998,6 @@ const gConnectionPane = (function () { this._showAutoconfiguration(); }
- // Bridge cards - const bridgeHeader = prefpane.querySelector( - selectors.bridges.currentHeader - ); - bridgeHeader.textContent = TorStrings.settings.bridgeCurrent; - const bridgeControls = prefpane.querySelector(selectors.bridges.controls); - const bridgeSwitch = prefpane.querySelector(selectors.bridges.switch); - bridgeSwitch.setAttribute("label", TorStrings.settings.allBridgesEnabled); - bridgeSwitch.addEventListener("toggle", () => { - TorSettings.bridges.enabled = bridgeSwitch.pressed; - TorSettings.saveToPrefs(); - TorSettings.applySettings().finally(() => { - this._populateBridgeCards(); - }); - }); - const bridgeDescription = prefpane.querySelector( - selectors.bridges.currentDescription - ); - bridgeDescription.querySelector( - selectors.bridges.currentDescriptionText - ).textContent = TorStrings.settings.bridgeCurrentDescription; - const bridgeTemplate = prefpane.querySelector( - selectors.bridges.cardTemplate - ); - { - const learnMore = bridgeTemplate.querySelector( - selectors.bridges.cardLearnMore - ); - learnMore.setAttribute("value", TorStrings.settings.learnMore); - learnMore.setAttribute( - "href", - TorStrings.settings.learnMoreBridgesCardURL - ); - if (TorStrings.settings.learnMoreBridgesCardURL.startsWith("about:")) { - learnMore.setAttribute("useoriginprincipal", "true"); - } - } - { - const manualLink = bridgeTemplate.querySelector( - selectors.bridges.cardHeadingManualLink - ); - manualLink.setAttribute("value", TorStrings.settings.whatAreThese); - manualLink.setAttribute( - "href", - TorStrings.settings.learnMoreBridgesCardURL - ); - if (TorStrings.settings.learnMoreBridgesCardURL.startsWith("about:")) { - manualLink.setAttribute("useoriginprincipal", "true"); - } - } - bridgeTemplate.querySelector( - selectors.bridges.cardConnectedLabel - ).textContent = TorStrings.settings.connectedBridge; - bridgeTemplate - .querySelector(selectors.bridges.cardCopy) - .setAttribute("label", TorStrings.settings.bridgeCopy); - bridgeTemplate.querySelector(selectors.bridges.cardShare).textContent = - TorStrings.settings.bridgeShare; - const bridgeCards = prefpane.querySelector(selectors.bridges.cards); - const bridgeMenu = prefpane.querySelector(selectors.bridges.cardMenu); - - this._addBridgeCard = bridgeString => { - const card = bridgeTemplate.cloneNode(true); - card.removeAttribute("id"); - const grid = card.querySelector(selectors.bridges.cardQrGrid); - card.addEventListener("click", e => { - if ( - card.classList.contains("currently-connected") || - bridgeCards.classList.contains("single-card") - ) { - return; - } - let target = e.target; - let apply = true; - while (target !== null && target !== card && apply) { - // Deal with mixture of "command" and "click" events - apply = !target.classList?.contains("stop-click"); - target = target.parentElement; - } - if (apply) { - if (card.classList.toggle("expanded")) { - grid.classList.add("to-animate"); - grid.style.height = `${grid.scrollHeight}px`; - } else { - // Be sure we still have the to-animate class - grid.classList.add("to-animate"); - grid.style.height = ""; - } - } - }); - const emojis = makeBridgeId(bridgeString).map(emojiIndex => { - const img = document.createElement("img"); - img.classList.add("emoji"); - // Image is set in _updateBridgeEmojis. - img.dataset.emojiIndex = emojiIndex; - return img; - }); - const idString = TorStrings.settings.bridgeId; - const id = card.querySelector(selectors.bridges.cardId); - let details; - try { - details = TorParsers.parseBridgeLine(bridgeString); - } catch (e) { - console.error(`Detected invalid bridge line: ${bridgeString}`, e); - } - if (details && details.id !== undefined) { - card.setAttribute("data-bridge-id", details.id); - } - // TODO: properly handle "vanilla" bridges? - const type = - details && details.transport !== undefined - ? details.transport - : "vanilla"; - for (const piece of idString.split(/(%[12]$S)/)) { - if (piece == "%1$S") { - id.append(type); - } else if (piece == "%2$S") { - id.append(...emojis); - } else { - id.append(piece); - } - } - card.querySelector(selectors.bridges.cardHeadingAddr).textContent = - bridgeString; - const optionsButton = card.querySelector(selectors.bridges.cardOptions); - if (TorSettings.bridges.source === TorBridgeSource.BuiltIn) { - optionsButton.setAttribute("hidden", "true"); - } else { - // Cloning the menupopup element does not work as expected. - // Therefore, we use only one, and just before opening it, we remove - // its previous items, and add the ones relative to the bridge whose - // button has been pressed. - optionsButton.addEventListener("click", () => { - const menuItem = document.createXULElement("menuitem"); - menuItem.setAttribute("label", TorStrings.settings.remove); - menuItem.classList.add("menuitem-iconic"); - menuItem.image = "chrome://global/skin/icons/delete.svg"; - menuItem.addEventListener("command", e => { - const strings = TorSettings.bridges.bridge_strings; - const index = strings.indexOf(bridgeString); - if (index !== -1) { - strings.splice(index, 1); - } - TorSettings.bridges.enabled = - bridgeSwitch.pressed && !!strings.length; - TorSettings.bridges.bridge_strings = strings.join("\n"); - TorSettings.saveToPrefs(); - TorSettings.applySettings().finally(() => { - this._populateBridgeCards(); - }); - }); - if (bridgeMenu.firstChild) { - bridgeMenu.firstChild.remove(); - } - bridgeMenu.append(menuItem); - bridgeMenu.openPopup(optionsButton, { - position: "bottomleft topleft", - }); - }); - } - const bridgeAddr = card.querySelector(selectors.bridges.cardAddr); - bridgeAddr.setAttribute("value", bridgeString); - const bridgeCopy = card.querySelector(selectors.bridges.cardCopy); - let restoreTimeout = null; - bridgeCopy.addEventListener("command", e => { - this.onCopyBridgeAddress(bridgeAddr); - const label = bridgeCopy.querySelector("label"); - label.setAttribute("value", TorStrings.settings.copied); - bridgeCopy.classList.add("primary"); - - const RESTORE_TIME = 1200; - if (restoreTimeout !== null) { - clearTimeout(restoreTimeout); - } - restoreTimeout = setTimeout(() => { - label.setAttribute("value", TorStrings.settings.bridgeCopy); - bridgeCopy.classList.remove("primary"); - restoreTimeout = null; - }, RESTORE_TIME); - }); - if (details?.id && details.id === this._currentBridgeId) { - card.classList.add("currently-connected"); - bridgeCards.prepend(card); - } else { - bridgeCards.append(card); - } - // Add the QR only after appending the card, to have the computed style - try { - const container = card.querySelector(selectors.bridges.cardQr); - const style = getComputedStyle(container); - const width = style.width.substring(0, style.width.length - 2); - const height = style.height.substring(0, style.height.length - 2); - new QRCode(container, { - text: bridgeString, - width, - height, - colorDark: style.color, - colorLight: style.backgroundColor, - document, - }); - container.parentElement.addEventListener("click", () => { - this.onShowQr(bridgeString); - }); - } catch (err) { - // TODO: Add a generic image in case of errors such as code overflow. - // It should never happen with correct codes, but after all this - // content can be generated by users... - console.error("Could not generate the QR code for the bridge:", err); - } - }; - this._checkBridgeCardsHeight = () => { - for (const card of bridgeCards.children) { - // Expanded cards have the height set manually to their details for - // the CSS animation. However, when resizing the window, we may need - // to adjust their height. - if ( - card.classList.contains("expanded") || - card.classList.contains("currently-connected") - ) { - const grid = card.querySelector(selectors.bridges.cardQrGrid); - // Reset it first, to avoid having a height that is higher than - // strictly needed. Also, remove the to-animate class, because the - // animation interferes with this process! - grid.classList.remove("to-animate"); - grid.style.height = ""; - grid.style.height = `${grid.scrollHeight}px`; - } - } - }; - this._currentBridgesExpanded = false; - const showAll = prefpane.querySelector(selectors.bridges.showAll); - showAll.setAttribute("label", TorStrings.settings.bridgeShowAll); - showAll.addEventListener("command", () => { - this._currentBridgesExpanded = !this._currentBridgesExpanded; - this._populateBridgeCards(); - if (!this._currentBridgesExpanded) { - bridgeSwitch.scrollIntoView({ behavior: "smooth" }); - } - }); - const removeAll = prefpane.querySelector(selectors.bridges.removeAll); - removeAll.setAttribute("label", TorStrings.settings.bridgeRemoveAll); - removeAll.addEventListener("command", () => { - this._confirmBridgeRemoval(); - }); - this._populateBridgeCards = () => { - const collapseThreshold = 4; - - const newStrings = new Set(TorSettings.bridges.bridge_strings); - const numBridges = newStrings.size; - const noBridges = !numBridges; - bridgeHeader.hidden = noBridges; - bridgeDescription.hidden = noBridges; - bridgeControls.hidden = noBridges; - bridgeCards.hidden = noBridges; - if (noBridges) { - showAll.hidden = true; - removeAll.hidden = true; - bridgeCards.textContent = ""; - return; - } - // Changing the pressed property on moz-toggle should not trigger its - // "toggle" event. - bridgeSwitch.pressed = TorSettings.bridges.enabled; - bridgeCards.classList.toggle("disabled", !TorSettings.bridges.enabled); - bridgeCards.classList.toggle("single-card", numBridges === 1); - - let shownCards = 0; - const toShow = this._currentBridgesExpanded - ? numBridges - : collapseThreshold; - - // Do not remove all the old cards, because it makes scrollbar "jump" - const currentCards = bridgeCards.querySelectorAll( - selectors.bridges.card - ); - for (const card of currentCards) { - const string = card.querySelector(selectors.bridges.cardAddr).value; - const hadString = newStrings.delete(string); - if (!hadString || shownCards == toShow) { - card.remove(); - } else { - shownCards++; - } - } - - // Add only the new strings that remained in the set - for (const bridge of newStrings) { - if (shownCards >= toShow) { - if (!this._currentBridgeId) { - break; - } else if (!bridge.includes(this._currentBridgeId)) { - continue; - } - } - this._addBridgeCard(bridge); - shownCards++; - } - - // If we know the connected bridge, we may have added more than the ones - // we should actually show (but the connected ones have been prepended, - // if needed). So, remove any exceeding ones. - while (shownCards > toShow) { - bridgeCards.lastElementChild.remove(); - shownCards--; - } - - // Newly added emojis. - this._updateBridgeEmojis(); - - // And finally update the buttons - removeAll.hidden = false; - showAll.classList.toggle("primary", TorSettings.bridges.enabled); - if (numBridges > collapseThreshold) { - showAll.hidden = false; - showAll.setAttribute( - "aria-expanded", - // Boolean value gets converted to string "true" or "false". - this._currentBridgesExpanded - ); - showAll.setAttribute( - "label", - this._currentBridgesExpanded - ? TorStrings.settings.bridgeShowFewer - : TorStrings.settings.bridgeShowAll - ); - // We do not want both collapsed and disabled at the same time, - // because we use collapsed only to display a gradient on the list. - bridgeCards.classList.toggle( - "list-collapsed", - !this._currentBridgesExpanded && TorSettings.bridges.enabled - ); - } else { - // NOTE: We do not expect the showAll button to have focus when we - // hide it since we do not expect `numBridges` to decrease whilst - // this button is focused. - showAll.hidden = true; - bridgeCards.classList.remove("list-collapsed"); - } - }; - this._populateBridgeCards(); - this._updateConnectedBridges = () => { - for (const card of bridgeCards.querySelectorAll( - ".currently-connected" - )) { - card.classList.remove("currently-connected"); - card.querySelector(selectors.bridges.cardQrGrid).style.height = ""; - } - if (!this._currentBridgeId) { - return; - } - // Make sure we have the connected bridge in the list - this._populateBridgeCards(); - // At the moment, IDs do not have to be unique (and it is a concrete - // case also with built-in bridges!). E.g., one line for the IPv4 - // address and one for the IPv6 address, so use querySelectorAll - const cards = bridgeCards.querySelectorAll( - `[data-bridge-id="${this._currentBridgeId}"]` - ); - for (const card of cards) { - card.classList.add("currently-connected"); - } - const placeholder = document.createElement("span"); - bridgeCards.prepend(placeholder); - placeholder.replaceWith(...cards); - this._checkBridgeCardsHeight(); - }; - this._checkConnectedBridge = async () => { - // TODO: We could make sure TorSettings is in sync by monitoring also - // changes of settings. At that point, we could query it, instead of - // doing a query over the control port. - let bridge = null; - try { - const provider = await TorProviderBuilder.build(); - bridge = provider.currentBridge; - } catch (e) { - console.warn("Could not get current bridge", e); - } - if (bridge?.fingerprint !== this._currentBridgeId) { - this._currentBridgeId = bridge?.fingerprint ?? null; - this._updateConnectedBridges(); - } - }; - this._checkConnectedBridge(); - // Add a new bridge prefpane.querySelector(selectors.bridges.addHeader).textContent = TorStrings.settings.bridgeAdd; @@ -804,34 +2031,6 @@ const gConnectionPane = (function () { }); }
- this._confirmBridgeRemoval = () => { - const aParentWindow = - Services.wm.getMostRecentWindow("navigator:browser"); - - const ps = Services.prompt; - const btnFlags = - ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + - ps.BUTTON_POS_0_DEFAULT + - ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL; - - const notUsed = { value: false }; - const btnIndex = ps.confirmEx( - aParentWindow, - TorStrings.settings.bridgeRemoveAllDialogTitle, - TorStrings.settings.bridgeRemoveAllDialogDescription, - btnFlags, - TorStrings.settings.remove, - null, - null, - null, - notUsed - ); - - if (btnIndex === 0) { - this.onRemoveAllBridges(); - } - }; - // Advanced setup prefpane.querySelector(selectors.advanced.header).innerText = TorStrings.settings.advancedHeading; @@ -862,11 +2061,11 @@ const gConnectionPane = (function () { });
Services.obs.addObserver(this, TorConnectTopics.StateChange); - Services.obs.addObserver(this, TorProviderTopics.BridgeChanged); - Services.obs.addObserver(this, "intl:app-locales-changed"); },
init() { + gBridgeSettings.init(); + TorSettings.initializedPromise.then(() => this._populateXUL());
const onUnload = () => { @@ -874,21 +2073,14 @@ const gConnectionPane = (function () { gConnectionPane.uninit(); }; window.addEventListener("unload", onUnload); - - window.addEventListener("resize", () => { - this._checkBridgeCardsHeight(); - }); - window.addEventListener("hashchange", () => { - this._checkBridgeCardsHeight(); - }); },
uninit() { + gBridgeSettings.uninit(); + // unregister our observer topics Services.obs.removeObserver(this, TorSettingsTopics.SettingsChanged); Services.obs.removeObserver(this, TorConnectTopics.StateChange); - Services.obs.removeObserver(this, TorProviderTopics.BridgeChanged); - Services.obs.removeObserver(this, "intl:app-locales-changed"); },
// whether the page should be present in about:preferences @@ -916,64 +2108,6 @@ const gConnectionPane = (function () { this.onStateChange(); break; } - case TorProviderTopics.BridgeChanged: { - this._checkConnectedBridge(); - break; - } - case "intl:app-locales-changed": { - this._updateBridgeEmojis(); - break; - } - } - }, - - /** - * Update the bridge emojis to show their corresponding emoji with an - * annotation that matches the current locale. - */ - async _updateBridgeEmojis() { - if (!this._emojiPromise) { - this._emojiPromise = Promise.all([ - fetch( - "chrome://browser/content/torpreferences/bridgemoji/bridge-emojis.json" - ).then(response => response.json()), - fetch( - "chrome://browser/content/torpreferences/bridgemoji/annotations.json" - ).then(response => response.json()), - ]); - } - const [emojiList, emojiAnnotations] = await this._emojiPromise; - let langCode; - // Find the first desired locale we have annotations for. - // Add "en" as a fallback. - for (const bcp47 of [...Services.locale.appLocalesAsBCP47, "en"]) { - langCode = bcp47; - if (langCode in emojiAnnotations) { - break; - } - // Remove everything after the dash, if there is one. - langCode = bcp47.replace(/-.*/, ""); - if (langCode in emojiAnnotations) { - break; - } - } - for (const img of document.querySelectorAll(".emoji[data-emoji-index]")) { - const emoji = emojiList[img.dataset.emojiIndex]; - if (!emoji) { - // Unexpected. - console.error(`No emoji for index ${img.dataset.emojiIndex}`); - img.removeAttribute("src"); - img.removeAttribute("alt"); - img.removeAttribute("title"); - continue; - } - const cp = emoji.codePointAt(0).toString(16); - img.setAttribute( - "src", - `chrome://browser/content/torpreferences/bridgemoji/svgs/${cp}.svg` - ); - img.setAttribute("alt", emoji); - img.setAttribute("title", emojiAnnotations[langCode][cp]); } },
@@ -999,51 +2133,21 @@ const gConnectionPane = (function () { onStateChange() { this._populateStatus(); this._showAutoconfiguration(); - this._populateBridgeCards(); - }, - - onShowQr(bridgeString) { - const dialog = new BridgeQrDialog(); - dialog.openDialog(gSubDialog, bridgeString); - }, - - onCopyBridgeAddress(addressElem) { - const clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( - Ci.nsIClipboardHelper - ); - clipboard.copyString(addressElem.value); - }, - - onRemoveAllBridges() { - TorSettings.bridges.enabled = false; - TorSettings.bridges.bridge_strings = ""; - if (TorSettings.bridges.source === TorBridgeSource.BuiltIn) { - TorSettings.bridges.builtin_type = ""; - } - TorSettings.saveToPrefs(); - TorSettings.applySettings().finally(() => { - this._populateBridgeCards(); - }); },
/** * Save and apply settings, then optionally open about:torconnect and start * bootstrapping. * + * @param {fucntion} changes - The changes to make. * @param {boolean} connect - Whether to open about:torconnect and start * bootstrapping if possible. */ - async saveBridgeSettings(connect) { - TorSettings.saveToPrefs(); - // FIXME: This can throw if the user adds a bridge manually with invalid - // content. Should be addressed by tor-browser#41913. - try { - await TorSettings.applySettings(); - } catch (e) { - console.error("Applying settings failed", e); - } - - this._populateBridgeCards(); + async saveBridgeSettings(changes, connect) { + // TODO: Move focus into the bridge area. + // dialog.ownerGlobal.addEventListener("unload", () => gCurrentBridgesArea.takeFocus(), { once: true }); + // or use closedCallback in gSubDialog.open() + setTorSettings(changes);
if (!connect) { return; @@ -1071,11 +2175,11 @@ const gConnectionPane = (function () { onAddBuiltinBridge() { const builtinBridgeDialog = new BuiltinBridgeDialog( (bridgeType, connect) => { - TorSettings.bridges.enabled = true; - TorSettings.bridges.source = TorBridgeSource.BuiltIn; - TorSettings.bridges.builtin_type = bridgeType; - - this.saveBridgeSettings(connect); + this.saveBridgeSettings(() => { + TorSettings.bridges.enabled = true; + TorSettings.bridges.source = TorBridgeSource.BuiltIn; + TorSettings.bridges.builtin_type = bridgeType; + }, connect); } ); builtinBridgeDialog.openDialog(gSubDialog); @@ -1088,12 +2192,14 @@ const gConnectionPane = (function () { if (!aBridges.length) { return; } + const bridgeStrings = aBridges.join("\n"); - TorSettings.bridges.enabled = true; - TorSettings.bridges.source = TorBridgeSource.BridgeDB; - TorSettings.bridges.bridge_strings = bridgeStrings;
- this.saveBridgeSettings(connect); + this.saveBridgeSettings(() => { + TorSettings.bridges.enabled = true; + TorSettings.bridges.source = TorBridgeSource.BridgeDB; + TorSettings.bridges.bridge_strings = bridgeStrings; + }, connect); } ); requestBridgeDialog.openDialog(gSubDialog); @@ -1102,11 +2208,11 @@ const gConnectionPane = (function () { onAddBridgeManually() { const provideBridgeDialog = new ProvideBridgeDialog( (aBridgeString, connect) => { - TorSettings.bridges.enabled = true; - TorSettings.bridges.source = TorBridgeSource.UserProvided; - TorSettings.bridges.bridge_strings = aBridgeString; - - this.saveBridgeSettings(connect); + this.saveBridgeSettings(() => { + TorSettings.bridges.enabled = true; + TorSettings.bridges.source = TorBridgeSource.UserProvided; + TorSettings.bridges.bridge_strings = aBridgeString; + }, connect); } ); provideBridgeDialog.openDialog(gSubDialog);
===================================== browser/components/torpreferences/content/connectionPane.xhtml ===================================== @@ -67,7 +67,7 @@
<!-- Bridges --> <hbox class="subcategory" data-category="paneConnection" hidden="true"> - <html:h1 id="torPreferences-bridges-header" /> + <html:h1 id="torPreferences-bridges-header" tabindex="-1" /> </hbox> <groupbox id="torPreferences-bridges-group" @@ -103,73 +103,196 @@ class="primary" /> </hbox> - <html:h2 id="torPreferences-currentBridges-header"> </html:h2> - <description flex="1" id="torPreferences-currentBridges-description"> - <html:span id="torPreferences-currentBridges-descriptionText" /> - </description> - <hbox align="center" id="torPreferences-currentBridges-controls"> - <html:moz-toggle - id="torPreferences-currentBridges-switch" - label-align-after="" - /> - <spacer flex="1" /> - <button id="torPreferences-currentBridges-removeAll" /> - </hbox> - <menupopup id="torPreferences-bridgeCard-menu" /> - <vbox - id="torPreferences-bridgeCard-template" - class="torPreferences-bridgeCard" - > - <hbox class="torPreferences-bridgeCard-heading"> - <html:div class="torPreferences-bridgeCard-id" /> - <label - class="torPreferences-bridgeCard-manualLink learnMore text-link stop-click" - is="text-link" - /> - <html:div class="torPreferences-bridgeCard-headingAddr" /> - <html:div class="torPreferences-bridgeCard-buttons"> - <html:span class="torPreferences-current-bridge-badge"> - <image class="torPreferences-current-bridge-icon" /> - <html:span class="torPreferences-current-bridge-label"></html:span> + <html:moz-toggle + id="tor-bridges-enabled-toggle" + label-align-after="" + data-l10n-id="tor-bridges-use-bridges" + data-l10n-attrs="label" + /> + <!-- Add an aria-live area where we can post notifications to screen + - reader users about changes to their list of bridges. This is to give + - these users some feedback for when the remove a bridge or change + - their bridges in other ways. I.e. whenever tor-bridges-grid-display + - changes its rows. + - + - If we change the text in #tor-bridges-update-area-text, a screen + - reader should speak out the text to the user, even when this area + - does not have focus. + - + - In fact, we don't really want the user to navigate to this element + - directly. But currently using an aria-live region in the DOM is the + - only way to effectively pass a notification to a screen reader user. + - Since it must be somewhere in the DOM, we logically place it just + - before the grid, where it is hopefully least confusing to stumble + - across. + - + - TODO: Instead of aria-live in the DOM, use the proposed ariaNotify + - API if it gets accepted into firefox and works with screen readers. + - See https://github.com/WICG/proposals/issues/112 + --> + <!-- NOTE: This area is hidden by default, and is only shown temporarily + - when a notification is added. --> + <html:div id="tor-bridges-update-area" hidden="hidden"> + <!-- NOTE: This first span's text content will *not* be read out as part + - of the notification because it does not have an aria-live + - attribute. Instead it is just here to give context to the following + - text in #tor-bridges-update-area-text if the user navigates to + - #tor-bridges-update-area manually whilst it is not hidden. + - I.e. it is just here to make it less confusing if a screen reader + - user stumbles across this. + --> + <html:span data-l10n-id="tor-bridges-update-area-intro"></html:span> + <!-- Whitespace between spans. --> + <!-- This second span is the area to place notification text in. --> + <html:span + id="tor-bridges-update-area-text" + aria-live="polite" + ></html:span> + </html:div> + <html:div id="tor-bridges-none"> + <html:img id="tor-bridges-none-icon" alt="" /> + <html:div data-l10n-id="tor-bridges-none-added"></html:div> + </html:div> + <html:div id="tor-bridges-current"> + <html:div id="tor-bridges-current-header-bar"> + <html:h2 + id="tor-bridges-current-heading" + tabindex="-1" + data-l10n-id="tor-bridges-your-bridges" + ></html:h2> + <html:span + id="tor-bridges-user-label" + data-l10n-id="tor-bridges-source-user" + ></html:span> + <html:span + id="tor-bridges-built-in-label" + data-l10n-id="tor-bridges-source-built-in" + ></html:span> + <html:span + id="tor-bridges-requested-label" + data-l10n-id="tor-bridges-source-requested" + ></html:span> + <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"> + <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> + <html:div id="tor-bridges-built-in-display"> + <html:div id="tor-bridges-built-in-type-name"></html:div> + <html:div + 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:div> + <html:div id="tor-bridges-built-in-description"></html:div> + </html:div> + <html:div + id="tor-bridges-grid-display" + role="grid" + aria-labelledby="tor-bridges-current-heading" + ></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> + <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"> + <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:button class="torPreferences-bridgeCard-options stop-click" /> </html:div> - </hbox> - <box class="torPreferences-bridgeCard-grid"> - <box class="torPreferences-bridgeCard-qrWrapper"> - <html:div class="torPreferences-bridgeCard-qr stop-click"> - <html:div class="torPreferences-bridgeCard-qrCode" /> - <html:div class="torPreferences-bridgeCard-qrOnionBox" /> - <html:div class="torPreferences-bridgeCard-qrOnion" /> - </html:div> - </box> - <description class="torPreferences-bridgeCard-share"></description> - <hbox class="torPreferences-bridgeCard-addrBox"> - <html:input - class="torPreferences-bridgeCard-addr stop-click" - type="text" - readonly="readonly" - /> - </hbox> - <!-- tor-browser#41977 disable this learn more link until we have an alternate manual entry --> - <hbox class="torPreferences-bridgeCard-learnMoreBox" align="center" hidden="true"> - <label - class="torPreferences-bridgeCard-learnMore learnMore text-link stop-click" - is="text-link" - /> - </hbox> - <hbox class="torPreferences-bridgeCard-copy" align="center"> - <button class="torPreferences-bridgeCard-copyButton stop-click" /> - </hbox> - </box> - </vbox> - <vbox id="torPreferences-currentBridges-cards"></vbox> - <vbox align="center"> - <button - id="torPreferences-currentBridges-showAll" - aria-controls="torPreferences-currentBridges-cards" - /> - </vbox> + </html:template> + <html:div id="tor-bridges-share"> + <html:h3 + id="tor-bridges-share-heading" + data-l10n-id="tor-bridges-share-heading" + ></html:h3> + <html:span + id="tor-bridges-share-description" + data-l10n-id="tor-bridges-share-description" + ></html:span> + <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> <html:h2 id="torPreferences-addBridge-header"></html:h2> <hbox align="center"> <label id="torPreferences-addBridge-labelBuiltinBridge" flex="1" />
===================================== browser/components/torpreferences/content/torPreferences.css ===================================== @@ -69,282 +69,391 @@ }
/* Bridge settings */ -#torPreferences-bridges-location { - width: 280px; -}
-#torPreferences-bridges-location menuitem[disabled="true"] { - color: var(--in-content-button-text-color, inherit); - font-weight: 700; +.bridge-status-badge { + display: flex; + min-width: max-content; + align-items: center; + gap: 0.5em; + font-size: 0.85em; }
-/* Bridge cards */ -:root { - --bridgeCard-animation-time: 0.25s; +.bridge-status-badge:not( + .bridge-status-connected, + .bridge-status-none, + .bridge-status-current-built-in +) { + display: none; }
-#torPreferences-currentBridges-cards { - /* The padding is needed because the mask-image creates an unexpected result - otherwise... */ - padding: 24px 4px; +.bridge-status-badge.bridge-status-connected { + color: var(--purple-60); }
-#torPreferences-currentBridges-cards.list-collapsed { - mask-image: linear-gradient(rgb(0, 0, 0) 0% 75%, rgba(0, 0, 0, 0.1)); +@media (prefers-color-scheme: dark) { + .bridge-status-badge.bridge-status-connected { + color: var(--purple-30); + } }
-#torPreferences-currentBridges-cards.disabled { - opacity: 0.4; +.bridge-status-badge.bridge-status-current-built-in { + color: var(--in-content-accent-color); }
-.torPreferences-bridgeCard { - padding: 16px 12px; - /* define border-radius here because of the transition */ - border-radius: 4px; - transition: margin var(--bridgeCard-animation-time), box-shadow 150ms; - cursor: pointer; +.bridge-status-badge > * { + flex: 0 0 auto; }
-.torPreferences-bridgeCard.expanded, -.torPreferences-bridgeCard.currently-connected, -.single-card .torPreferences-bridgeCard { - margin: 12px 0; - background: var(--in-content-box-background); - box-shadow: var(--card-shadow); +.bridge-status-icon { + width: 16px; + height: 16px; + background-repeat: no-repeat; + background-position: center center; + -moz-context-properties: fill; + fill: currentColor; }
-.torPreferences-bridgeCard:hover { - background: var(--in-content-box-background); - box-shadow: var(--card-shadow-hover); +.bridge-status-badge:is( + .bridge-status-connected, + .bridge-status-current-built-in +) .bridge-status-icon { + background-image: url("chrome://global/skin/icons/check.svg"); }
-.single-card .torPreferences-bridgeCard, -.torPreferences-bridgeCard.currently-connected { - cursor: default; +.bridge-status-badge.bridge-status-none .bridge-status-icon { + /* Hide the icon. */ + display: none; }
-.torPreferences-bridgeCard-heading { - display: flex; - align-items: center; +#tor-bridges-enabled-toggle { + margin-block: 16px; + width: max-content; }
-.torPreferences-bridgeCard-id { - display: flex; - align-items: center; - font-weight: 700; +#tor-bridges-update-area { + /* Still accessible to screen reader, but not visual. */ + position: absolute; + clip-path: inset(50%); }
-.torPreferences-bridgeCard-id .emoji { - width: 20px; - height: 20px; - margin-inline-start: 4px; - padding: 4px; - font-size: 20px; - border-radius: 4px; - background: var(--in-content-box-background-odd); +#torPreferences-bridges-group:not(.have-bridges, .no-bridges) { + /* Hide bridge settings whilst not initialized. */ + display: none; }
-#torPreferences-currentBridges-cards:not( - .single-card -) .torPreferences-bridgeCard:not( - .expanded, - .currently-connected -) .torPreferences-bridgeCard-manualLink { +#torPreferences-bridges-group:not(.have-bridges) #tor-bridges-current { display: none; }
-.torPreferences-bridgeCard-manualLink { - margin: 0 8px; +#torPreferences-bridges-group:not(.no-bridges) #tor-bridges-none { + display: none; }
-.torPreferences-bridgeCard-headingAddr { - /* flex extends the element when needed, but without setting a width (any) the - overflow + ellipses does not work. */ - width: 20px; - flex: 1; - margin: 0 8px; - overflow: hidden; - color: var(--text-color-deemphasized); - white-space: nowrap; - text-overflow: ellipsis; +#tor-bridges-current:not(.source-built-in) #tor-bridges-built-in-label { + display: none; }
-.expanded .torPreferences-bridgeCard-headingAddr, -.currently-connected .torPreferences-bridgeCard-headingAddr, -.single-card .torPreferences-bridgeCard-headingAddr { +#tor-bridges-current:not(.source-user) #tor-bridges-user-label { display: none; }
-.torPreferences-bridgeCard-buttons { - display: flex; - align-items: center; - margin-inline-start: auto; - align-self: center; +#tor-bridges-current:not(.source-requested) #tor-bridges-requested-label { + display: none; }
-.torPreferences-current-bridge-badge { - /* Hidden by default, otherwise display is "flex". */ +#tor-bridges-current:not( + .source-user, + .source-requested +) #tor-bridges-share { display: none; - align-items: center; - font-size: 0.85em; }
-:is( - .builtin-bridges-option.current-builtin-bridge-type, - .torPreferences-bridgeCard.currently-connected -) .torPreferences-current-bridge-badge { - display: flex; +#tor-bridges-none, +#tor-bridges-current { + margin-inline: 0; + margin-block: 32px; + line-height: 1.8; }
-.torPreferences-current-bridge-icon { - margin-inline-start: 1px; - margin-inline-end: 7px; - list-style-image: url("chrome://global/skin/icons/check.svg"); +#tor-bridges-none { + display: grid; + justify-items: center; + text-align: center; + padding-block: 64px; + padding-inline: 32px; + gap: 16px; + border-radius: 4px; + color: var(--text-color-deemphasized); + border: 2px dashed color-mix(in srgb, currentColor 20%, transparent); +} + +#tor-bridges-none-icon { + width: 20px; + height: 20px; + content: url("chrome://browser/content/torpreferences/bridge.svg"); -moz-context-properties: fill; fill: currentColor; - flex: 0 0 auto; }
-.torPreferences-bridgeCard .torPreferences-current-bridge-badge { - color: var(--purple-60); - margin-inline-end: 12px; +#tor-bridges-current { + padding: 16px; + border-radius: 4px; + background: var(--in-content-box-info-background); }
-@media (prefers-color-scheme: dark) { - .torPreferences-bridgeCard .torPreferences-current-bridge-badge { - color: var(--purple-30); - } +#tor-bridges-current-header-bar { + display: flex; + min-width: max-content; + align-items: center; + border-block-end: 1px solid var(--in-content-border-color); + padding-block-end: 16px; + margin-block-end: 16px; }
-.torPreferences-bridgeCard-options { - width: 24px; - min-width: 0; - height: 24px; - min-height: 0; - margin-inline-start: 8px; - padding: 1px; +#tor-bridges-current-header-bar > * { + flex: 0 0 auto; +} + +#tor-bridges-current-heading { + margin: 0; + margin-inline-end: 2em; + font-size: inherit; + flex: 1 0 auto; +} + +.tor-bridges-options-button { + padding: 3px; + margin: 0; + min-height: auto; + min-width: auto; + box-sizing: content-box; + width: 16px; + height: 16px; background-image: url("chrome://global/skin/icons/more.svg"); background-repeat: no-repeat; background-position: center center; - fill: currentColor; + background-origin: content-box; + background-size: contain; -moz-context-properties: fill; + fill: currentColor; }
-#torPreferences-bridgeCard-menu menuitem { - fill: currentColor; - -moz-context-properties: fill; +#tor-bridges-all-options-button { + margin-inline-start: 8px; }
-.torPreferences-bridgeCard-qrWrapper { - grid-area: bridge-qr; - display: block; /* So it doesn't stretch the child vertically. */ - margin-inline-end: 14px; +#tor-bridges-built-in-display { + display: grid; + grid-template: + "type status" min-content + "description description" auto + / max-content 1fr; + gap: 4px 1.5em; + margin-block-end: 16px; }
-.torPreferences-bridgeCard-qr { - --qr-one: black; - --qr-zero: white; - background: var(--qr-zero); - position: relative; - padding: 4px; - border-radius: 2px; +#tor-bridges-built-in-display:not(.built-in-active) { + display: none; }
-.torPreferences-bridgeCard-qrCode { - width: 112px; - height: 112px; - /* Define these colors, as they will be passed to the QR code library */ - background: var(--qr-zero); - color: var(--qr-one); +#tor-bridges-built-in-type-name { + font-weight: 700; + grid-area: type; }
-.torPreferences-bridgeCard-qrOnionBox { - width: 28px; - height: 28px; - position: absolute; - top: calc(50% - 14px); - inset-inline-start: calc(50% - 14px); - background: var(--qr-zero); +#tor-bridges-built-in-connected { + grid-area: status; + justify-self: end; }
-.torPreferences-bridgeCard-qrOnion { - width: 16px; - height: 16px; - position: absolute; - top: calc(50% - 8px); - inset-inline-start: calc(50% - 8px); +#tor-bridges-built-in-description { + grid-area: description; +}
- mask: url("chrome://browser/content/torpreferences/bridge-qr-onion-mask.svg"); - mask-repeat: no-repeat; - mask-size: 16px; - background: var(--qr-one); +#tor-bridges-grid-display { + display: grid; + grid-template-columns: max-content repeat(4, max-content) 1fr; + --tor-bridges-grid-column-gap: 8px; + --tor-bridges-grid-column-short-gap: 4px; }
-.torPreferences-bridgeCard-qr:hover .torPreferences-bridgeCard-qrOnionBox { - background: var(--qr-one); +#tor-bridges-grid-display:not(.grid-active) { + display: none; }
-.torPreferences-bridgeCard-qr:hover .torPreferences-bridgeCard-qrOnion { - mask: url("chrome://global/skin/icons/search-glass.svg"); - background: var(--qr-zero); +.tor-bridges-grid-row { + /* We want each row to act as a row of three items in the + * #tor-bridges-grid-display grid layout. + * We also want a 16px spacing between rows, and 8px spacing between columns, + * which are outside the .tor-bridges-grid-cell's border area. So that + * clicking these gaps will not focus any item, and their focus outlines do + * not overlap. + * Moreover, we also want each row to show its .tor-bridges-options-cell when + * the .tor-bridges-grid-row has :hover. + * + * We could use "display: contents" on the row and set a "gap: 16px 8px" on + * the parent so that its items fall into the parent layout. However, the gap + * between the items would mean there are places where no row has :hover. So + * if the user glided across the grid, the options button would visibly + * disappear any time the pointer entered a gap, causing the display to feel + * "jumpy". + * + * Instead, we use a "subgrid" layout for each .tor-bridges-grid-row, and + * using padding, rather than a gap, for the vertical spacing. Therefore, + * every part of the grid is covered by a row, so moving the pointer over the + * grid will always have one row with :hover, so one of the options cell will + * always be visible. + */ + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + /* Add 16px gap between rows, plus 8px at the start and end of the grid. */ + padding-block: 8px; }
-.torPreferences-bridgeCard-grid { - height: 0; /* We will set it in JS when expanding it! */ +.tor-bridges-grid-cell:focus-visible { + outline: var(--in-content-focus-outline); + outline-offset: var(--in-content-focus-outline-offset); +} + +.tor-bridges-grid-cell { + /* The cell is stretched to the height of the row, so that each focus outline + * shares the same height, but we want to center-align the content within, + * which is either a single Element or a TextNode. */ display: grid; - grid-template-rows: auto 1fr; - grid-template-columns: auto 1fr auto; - grid-template-areas: - 'bridge-qr bridge-share bridge-share' - 'bridge-qr bridge-address bridge-address' - 'bridge-qr bridge-learn-more bridge-copy'; - visibility: hidden; + align-content: center; }
-.expanded .torPreferences-bridgeCard-grid, -.currently-connected .torPreferences-bridgeCard-grid, -.single-card .torPreferences-bridgeCard-grid { - padding-top: 12px; - visibility: visible; +.tor-bridges-type-cell { + margin-inline-end: var(--tor-bridges-grid-column-gap); }
-.currently-connected .torPreferences-bridgeCard-grid, -.single-card .torPreferences-bridgeCard-grid { - height: auto; +.tor-bridges-emojis-block { + /* Emoji block occupies four columns, but with a smaller gap. */ + display: contents; }
-.torPreferences-bridgeCard-grid.to-animate { - transition: height var(--bridgeCard-animation-time) ease-out, visibility var(--bridgeCard-animation-time); - overflow: hidden; +.tor-bridges-emoji-cell:not(:last-child) { + margin-inline-end: var(--tor-bridges-grid-column-short-gap); }
-.torPreferences-bridgeCard-share { - grid-area: bridge-share; +.tor-bridges-emoji-icon { + display: block; + box-sizing: content-box; + width: 16px; + height: 16px; + background: var(--in-content-button-background); + border-radius: 4px; + padding: 8px; }
-.torPreferences-bridgeCard-addrBox { - grid-area: bridge-address; +.tor-bridges-grid-end-block { + /* The last three cells all share a single grid item slot in the + * #tor-bridges-grid-display layout. + * This is because we do not want to align its cells between rows. */ + min-width: max-content; display: flex; - align-items: center; - justify-content: center; - margin: 8px 0; + /* Choose "stretch" instead of "center" so that focus outline is a consistent + * height between cells. */ + align-items: stretch; + margin-inline-start: var(--tor-bridges-grid-column-gap); + gap: var(--tor-bridges-grid-column-gap); }
-input.torPreferences-bridgeCard-addr { - width: 100%; +.tor-bridges-address-cell { + /* base size */ + width: 10em; + flex: 1 0 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; color: var(--text-color-deemphasized); }
-.torPreferences-bridgeCard-leranMoreBox { - grid-area: bridge-learn-more; +.tor-bridges-status-cell, +.tor-bridges-options-cell { + flex: 0 0 auto; }
-.torPreferences-bridgeCard-copy { - grid-area: bridge-copy; +/* Hide the options button if the row does not have hover or focus. */ +.tor-bridges-grid-row:not( + :hover, + :focus-within +) .tor-bridges-options-cell, +/* Hide the status cell when it shows "No status" if the cell does not have + * focus. */ +.tor-bridges-grid-row.hide-status .tor-bridges-status-cell:not(:focus) { + /* Still accessible to screen reader, but not visual and does not contribute + * to the parent flex layout. */ + /* NOTE: We assume that the height of these cell's content is equal to or less + * than the other cells, so there won't be a jump in row height when they + * become visual again and contribute to the layout. */ + position: absolute; + clip-path: inset(50%); }
-#torPreferences-bridgeCard-template { - display: none; +#tor-bridges-share { + margin-block-start: 24px; + border-radius: 4px; + border: 1px solid var(--in-content-border-color); + padding: 16px; + display: grid; + grid-template: + "heading heading heading" min-content + /* If the description spans one line, it will be center-aligned with the + * buttons, otherwise it will start to expand upwards. */ + "description . ." 1fr + "description copy qr" min-content + / 1fr max-content max-content; + gap: 0 8px; + align-items: center; +} + +#tor-bridges-share-heading { + grid-area: heading; + font-size: inherit; + margin: 0; + font-weight: 700; +} + +#tor-bridges-share-description { + grid-area: description; +} + +#tor-bridges-copy-addresses-button { + grid-area: copy; + margin: 0; + /* Match the QR height if it is higher than ours. */ + min-height: auto; + line-height: 1; + align-self: stretch; +} + +#tor-bridges-qr-addresses-button { + grid-area: qr; + padding: 5px; + margin: 0; + min-height: auto; + min-width: auto; + box-sizing: content-box; + width: 24px; + height: 24px; + background-image: url("chrome://browser/content/torpreferences/bridge-qr.svg"); + background-repeat: no-repeat; + background-position: center center; + background-origin: content-box; + background-size: contain; + -moz-context-properties: fill; + fill: currentColor; +} + +#torPreferences-bridges-location { + width: 280px; +} + +#torPreferences-bridges-location menuitem[disabled="true"] { + color: var(--in-content-button-text-color, inherit); + font-weight: 700; }
/* Advanced Settings */ @@ -446,10 +555,6 @@ dialog#torPreferences-requestBridge-dialog > hbox { font-weight: 700; }
-.builtin-bridges-option .torPreferences-current-bridge-badge { - color: var(--in-content-accent-color); -} - /* Request bridge dialog */ /* This hbox is hidden by css here by default so that the
===================================== browser/components/torpreferences/jar.mn ===================================== @@ -1,4 +1,6 @@ browser.jar: + content/browser/torpreferences/bridge.svg (content/bridge.svg) + content/browser/torpreferences/bridge-qr.svg (content/bridge-qr.svg) content/browser/torpreferences/bridgeQrDialog.xhtml (content/bridgeQrDialog.xhtml) content/browser/torpreferences/bridgeQrDialog.mjs (content/bridgeQrDialog.mjs) content/browser/torpreferences/builtinBridgeDialog.xhtml (content/builtinBridgeDialog.xhtml)
===================================== browser/locales/en-US/browser/tor-browser.ftl ===================================== @@ -44,3 +44,80 @@ tor-browser-home-message-testing = This is an unstable version of Tor Browser fo # Shown in Home settings, corresponds to the default about:tor home page. home-mode-choice-tor = .label = Tor Browser Home + +## Tor Bridges Settings + +# Toggle button for enabling and disabling the use of bridges. +tor-bridges-use-bridges = + .label = Use bridges + +tor-bridges-none-added = No bridges added +tor-bridges-your-bridges = Your bridges +tor-bridges-source-user = Added by you +tor-bridges-source-built-in = Built-in +tor-bridges-source-requested = Requested from Tor +# The "..." menu button for all current bridges. +tor-bridges-options-button = + .title = All bridges +# Shown in the "..." menu for all bridges when the user can generate a QR code for all of their bridges. +tor-bridges-menu-item-qr-all-bridge-addresses = Show QR code + .accesskey = Q +# Shown in the "..." menu for all bridges when the user can copy all of their bridges. +tor-bridges-menu-item-copy-all-bridge-addresses = Copy bridge addresses + .accesskey = C +# Only shown in the "..." menu for bridges added by the user. +tor-bridges-menu-item-edit-all-bridges = Edit bridges + .accesskey = E +# Shown in the "..." menu for all current bridges. +tor-bridges-menu-item-remove-all-bridges = Remove all bridges + .accesskey = R + +# Shown when one of the built-in bridges is in use. +tor-bridges-built-in-status-connected = Connected + +# Shown at the start of a Tor bridge line. +# $type (String) - The Tor bridge type ("snowflake", "obfs4", "meek-azure"). +tor-bridges-type-prefix = { $type } bridge: +# The name and accessible description for a bridge emoji cell. Each bridge address can be hashed into four emojis shown to the user (bridgemoji feature). This cell corresponds to a *single* such emoji. The "title" should just be emojiName. The "aria-description" should give screen reader users enough of a hint that the cell contains a single emoji. +# $emojiName (String) - The name of the emoji, already localized. +# E.g. with Orca screen reader in en-US this would read "unicorn. Row 2 Column 2. Emoji". +tor-bridges-emoji-cell = + .title = { $emojiName } + .aria-description = Emoji +# The emoji name to show on hover when a bridge emoji's name is unknown. +tor-bridges-emoji-unknown = Unknown +# Shown when the bridge has been used for the most recent Tor circuit, i.e. the most recent bridge we have connected to. +tor-bridges-status-connected = Connected +# Used when the bridge has no status, i.e. the *absence* of a status to report to the user. This is only visibly shown when the status cell has keyboard focus. +tor-bridges-status-none = No status +# The "..." menu button for an individual bridge row. +tor-bridges-individual-bridge-options-button = + .title = Bridge options +# Shown in the "..." menu for an individual bridge. Shows the QR code for this one bridge. +tor-bridges-menu-item-qr-address = Show QR code + .accesskey = Q +# Shown in the "..." menu for an individual bridge. Copies the single bridge address to clipboard. +tor-bridges-menu-item-copy-address = Copy bridge address + .accesskey = C +# Shown in the "..." menu for an individual bridge. Removes this one bridge. +tor-bridges-menu-item-remove-bridge = Remove bridge + .accesskey = R + +# Text shown just before a description of the most recent change to the list of user's bridges. Some white space will separate this text from the change description. +# This text is not visible, but is instead used for screen reader users. +# E.g. in English this could be "Recent update: One of your Tor bridges has been removed." +tor-bridges-update-area-intro = Recent update: +# Update text for screen reader users when only one of their bridges has been removed. +tor-bridges-update-removed-one-bridge = One of your Tor bridges has been removed. +# Update text for screen reader users when all of their bridges have been removed. +tor-bridges-update-removed-all-bridges = All of your Tor bridges have been removed. +# Update text for screen reader users when their bridges have changed in some arbitrary way. +tor-bridges-update-changed-bridges = Your Tor bridges have changed. + +# Shown for requested bridges and bridges added by the user. +tor-bridges-share-heading = Help others connect +# +tor-bridges-share-description = Share your bridges with trusted contacts. +tor-bridges-copy-addresses-button = Copy addresses +tor-bridges-qr-addresses-button = + .title = Show QR code
===================================== toolkit/modules/TorSettings.sys.mjs ===================================== @@ -175,6 +175,14 @@ class TorSettingsImpl { allowed_ports: [], }, }; + /** + * Accumulated errors from trying to set settings. + * + * Only added to if not null. + * + * @type {Array<Error>?} + */ + #settingErrors = null;
/** * The recommended pluggable transport. @@ -224,16 +232,33 @@ class TorSettingsImpl { enabled: {}, }); this.#addProperties("bridges", { + /** + * Whether the bridges are enabled or not. + * + * @type {boolean} + */ enabled: {}, + /** + * The current bridge source. + * + * @type {integer} + */ source: { - transform: val => { + transform: (val, addError) => { if (Object.values(TorBridgeSource).includes(val)) { return val; } - lazy.logger.error(`Not a valid bridge source: "${val}"`); + addError(`Not a valid bridge source: "${val}"`); return TorBridgeSource.Invalid; }, }, + /** + * The current bridge strings. + * + * Can only be non-empty if the "source" is not Invalid. + * + * @type {Array<string>} + */ bridge_strings: { transform: val => { if (Array.isArray(val)) { @@ -244,13 +269,15 @@ class TorSettingsImpl { copy: val => [...val], equal: (val1, val2) => this.#arrayEqual(val1, val2), }, + /** + * The built-in type to use when using the BuiltIn "source", or empty when + * using any other source. + * + * @type {string} + */ builtin_type: { - callback: val => { + callback: (val, addError) => { if (!val) { - // Make sure that the source is not BuiltIn - if (this.bridges.source === TorBridgeSource.BuiltIn) { - this.bridges.source = TorBridgeSource.Invalid; - } return; } const bridgeStrings = this.#getBuiltinBridges(val); @@ -258,39 +285,28 @@ class TorSettingsImpl { this.bridges.bridge_strings = bridgeStrings; return; } - lazy.logger.error(`No built-in ${val} bridges found`); - // Change to be empty, this will trigger this callback again, - // but with val as "". - this.bridges.builtin_type == ""; + + addError(`No built-in ${val} bridges found`); + // Set as invalid, which will make the builtin_type "" and set the + // bridge_strings to be empty at the next #cleanupSettings. + this.bridges.source = TorBridgeSource.Invalid; }, }, }); this.#addProperties("proxy", { - enabled: { - callback: val => { - if (val) { - return; - } - // Reset proxy settings. - this.proxy.type = TorProxyType.Invalid; - this.proxy.address = ""; - this.proxy.port = 0; - this.proxy.username = ""; - this.proxy.password = ""; - }, - }, + enabled: {}, type: { - transform: val => { + transform: (val, addError) => { if (Object.values(TorProxyType).includes(val)) { return val; } - lazy.logger.error(`Not a valid proxy type: "${val}"`); + addError(`Not a valid proxy type: "${val}"`); return TorProxyType.Invalid; }, }, address: {}, port: { - transform: val => { + transform: (val, addError) => { if (val === 0) { // This is a valid value that "unsets" the port. // Keep this value without giving a warning. @@ -298,15 +314,11 @@ class TorSettingsImpl { return 0; } // Unset to 0 if invalid null is returned. - return this.#parsePort(val, false) ?? 0; + return this.#parsePort(val, false, addError) ?? 0; }, }, - username: { - transform: val => val ?? "", - }, - password: { - transform: val => val ?? "", - }, + username: {}, + password: {}, uri: { getter: () => { const { type, address, port, username, password } = this.proxy; @@ -329,20 +341,16 @@ class TorSettingsImpl { }, }); this.#addProperties("firewall", { - enabled: { - callback: val => { - if (!val) { - this.firewall.allowed_ports = ""; - } - }, - }, + enabled: {}, allowed_ports: { - transform: val => { + transform: (val, addError) => { if (!Array.isArray(val)) { val = val === "" ? [] : val.split(","); } // parse and remove duplicates - const portSet = new Set(val.map(p => this.#parsePort(p, true))); + const portSet = new Set( + val.map(p => this.#parsePort(p, true, addError)) + ); // parsePort returns null for failed parses, so remove it. portSet.delete(null); return [...portSet]; @@ -353,6 +361,39 @@ class TorSettingsImpl { }); }
+ /** + * Clean the setting values after making some changes, so that the values do + * not contradict each other. + */ + #cleanupSettings() { + this.freezeNotifications(); + try { + if (this.bridges.source === TorBridgeSource.Invalid) { + this.bridges.enabled = false; + this.bridges.bridge_strings = []; + } + if (!this.bridges.bridge_strings.length) { + this.bridges.enabled = false; + this.bridges.source = TorBridgeSource.Invalid; + } + if (this.bridges.source !== TorBridgeSource.BuiltIn) { + this.bridges.builtin_type = ""; + } + if (!this.proxy.enabled) { + this.proxy.type = TorProxyType.Invalid; + this.proxy.address = ""; + this.proxy.port = 0; + this.proxy.username = ""; + this.proxy.password = ""; + } + if (!this.firewall.enabled) { + this.firewall.allowed_ports = []; + } + } finally { + this.thawNotifications(); + } + } + /** * The current number of freezes applied to the notifications. * @@ -435,6 +476,13 @@ class TorSettingsImpl { const group = {}; for (const name in propParams) { const { getter, transform, callback, copy, equal } = propParams[name]; + // Method for adding setting errors. + const addError = message => { + message = `TorSettings.${groupname}.${name}: ${message}`; + lazy.logger.error(message); + // Only add to #settingErrors if it is not null. + this.#settingErrors?.push(message); + }; Object.defineProperty(group, name, { get: getter ? () => { @@ -467,16 +515,20 @@ class TorSettingsImpl { this.freezeNotifications(); try { if (transform) { - val = transform(val); + val = transform(val, addError); } const isEqual = equal ? equal(val, prevVal) : val === prevVal; if (!isEqual) { - if (callback) { - callback(val); - } + // Set before the callback. this.#settings[groupname][name] = val; this.#notificationQueue.add(`${groupname}.${name}`); + + if (callback) { + callback(val, addError); + } } + } catch (e) { + addError(e.message); } finally { this.thawNotifications(); } @@ -503,11 +555,12 @@ class TorSettingsImpl { * @param {string|integer} val - The value to parse. * @param {boolean} trim - Whether a string value can be stripped of * whitespace before parsing. + * @param {function} addError - Callback to add error messages to. * * @return {integer?} - The port number, or null if the given value was not * valid. */ - #parsePort(val, trim) { + #parsePort(val, trim, addError) { if (typeof val === "string") { if (trim) { val = val.trim(); @@ -516,12 +569,12 @@ class TorSettingsImpl { if (this.#portRegex.test(val)) { val = Number.parseInt(val, 10); } else { - lazy.logger.error(`Invalid port string "${val}"`); + addError(`Invalid port string "${val}"`); return null; } } if (!Number.isInteger(val) || val < 1 || val > 65535) { - lazy.logger.error(`Port out of range: ${val}`); + addError(`Port out of range: ${val}`); return null; } return val; @@ -739,6 +792,8 @@ class TorSettingsImpl { "" ); } + + this.#cleanupSettings(); }
/** @@ -748,6 +803,7 @@ class TorSettingsImpl { lazy.logger.debug("saveToPrefs()");
this.#checkIfInitialized(); + this.#cleanupSettings();
/* Quickstart */ Services.prefs.setBoolPref( @@ -847,6 +903,8 @@ class TorSettingsImpl { async #applySettings(allowUninitialized) { lazy.logger.debug("#applySettings()");
+ this.#cleanupSettings(); + const settingsMap = new Map();
// #applySettings can be called only when #allowUninitialized is false @@ -928,6 +986,8 @@ class TorSettingsImpl {
const backup = this.getSettings(); const backupNotifications = [...this.#notificationQueue]; + // Start collecting errors. + this.#settingErrors = [];
// Hold off on lots of notifications until all settings are changed. this.freezeNotifications(); @@ -946,25 +1006,11 @@ class TorSettingsImpl { case TorBridgeSource.UserProvided: this.bridges.bridge_strings = settings.bridges.bridge_strings; break; - case TorBridgeSource.BuiltIn: { + case TorBridgeSource.BuiltIn: this.bridges.builtin_type = settings.bridges.builtin_type; - if (!this.bridges.bridge_strings.length) { - // No bridges were found when setting the builtin_type. - throw new Error( - `No available builtin bridges of type ${settings.bridges.builtin_type}` - ); - } break; - } case TorBridgeSource.Invalid: break; - default: - if (settings.bridges.enabled) { - throw new Error( - `Bridge source '${settings.source}' is not a valid source` - ); - } - break; } }
@@ -985,6 +1031,12 @@ class TorSettingsImpl { this.firewall.allowed_ports = settings.firewall.allowed_ports; } } + + this.#cleanupSettings(); + + if (this.#settingErrors.length) { + throw Error(this.#settingErrors.join("; ")); + } } catch (ex) { // Restore the old settings without any new notifications generated from // the above code. @@ -1001,6 +1053,8 @@ class TorSettingsImpl { lazy.logger.error("setSettings failed", ex); } finally { this.thawNotifications(); + // Stop collecting errors. + this.#settingErrors = null; }
lazy.logger.debug("setSettings result", this.#settings);
===================================== toolkit/modules/TorStrings.sys.mjs ===================================== @@ -3,8 +3,6 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/.
-"use strict"; - const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { AppConstants } = ChromeUtils.import( "resource://gre/modules/AppConstants.jsm" @@ -86,7 +84,6 @@ const Loader = { statusTorNotConnected: "Not Connected", statusTorBlocked: "Potentially Blocked", learnMore: "Learn more", - whatAreThese: "What are these?", // Quickstart quickstartHeading: "Quickstart", quickstartDescription: @@ -101,22 +98,10 @@ const Loader = { bridgeLocationFrequent: "Frequently selected locations", bridgeLocationOther: "Other locations", bridgeChooseForMe: "Choose a Bridge For Me…", - bridgeCurrent: "Your Current Bridges", - bridgeCurrentDescription: - "You can keep one or more bridges saved, and Tor will choose which one to use when you connect. Tor will automatically switch to use another bridge when needed.", - bridgeId: "%1$S bridge: %2$S", currentBridge: "Current bridge", - connectedBridge: "Connected", remove: "Remove", bridgeDisableBuiltIn: "Disable built-in bridges", - bridgeShare: - "Share this bridge using the QR code or by copying its address:", - bridgeCopy: "Copy Bridge Address", copied: "Copied!", - bridgeShowAll: "Show All Bridges", - bridgeShowFewer: "Show Fewer Bridges", - allBridgesEnabled: "Use current bridges", - bridgeRemoveAll: "Remove All Bridges", bridgeRemoveAllDialogTitle: "Remove all bridges?", bridgeRemoveAllDialogDescription: "If these bridges were received from torproject.org or added manually, this action cannot be undone", @@ -199,8 +184,6 @@ const Loader = { ...tsb.getStrings(strings), learnMoreTorBrowserURL: "about:manual#about", learnMoreBridgesURL: "about:manual#bridges", - learnMoreBridgesCardURL: "about:manual#bridges_bridge-moji", - learnMoreCircumventionURL: "about:manual#circumvention", }; } /* Tor Network Settings Strings */,
===================================== toolkit/torbutton/chrome/locale/en-US/settings.properties ===================================== @@ -32,23 +32,11 @@ settings.bridgeLocationAutomatic=Automatic settings.bridgeLocationFrequent=Frequently selected locations settings.bridgeLocationOther=Other locations settings.bridgeChooseForMe=Choose a Bridge For Me… -settings.bridgeCurrent=Your Current Bridges -settings.bridgeCurrentDescription=You can keep one or more bridges saved, and Tor will choose which one to use when you connect. Tor will automatically switch to use another bridge when needed.
-# Translation note: %1$S = bridge type; %2$S = bridge emoji id -settings.bridgeId=%1$S bridge: %2$S -settings.connectedBridge=Connected settings.currentBridge=Current bridge settings.remove=Remove settings.bridgeDisableBuiltIn=Disable built-in bridges -settings.bridgeShare=Share this bridge using the QR code or by copying its address: -settings.whatAreThese=What are these? -settings.bridgeCopy=Copy Bridge Address settings.copied=Copied! -settings.bridgeShowAll=Show All Bridges -settings.bridgeShowFewer=Show Fewer Bridges -settings.allBridgesEnabled=Use current bridges -settings.bridgeRemoveAll=Remove All Bridges settings.bridgeRemoveAllDialogTitle=Remove all bridges? settings.bridgeRemoveAllDialogDescription=If these bridges were received from torproject.org or added manually, this action cannot be undone settings.bridgeAdd=Add a New Bridge @@ -121,3 +109,19 @@ settings.allowedPortsPlaceholder=Comma-separated values # Log dialog settings.torLogDialogTitle=Tor Logs settings.copyLog=Copy Tor Log to Clipboard + + +# TODO: Remove + +settings.bridgeCurrent=Your Current Bridges +settings.bridgeCurrentDescription=You can keep one or more bridges saved, and Tor will choose which one to use when you connect. Tor will automatically switch to use another bridge when needed. +# Translation note: %1$S = bridge type; %2$S = bridge emoji id +settings.bridgeId=%1$S bridge: %2$S +settings.connectedBridge=Connected +settings.bridgeShare=Share this bridge using the QR code or by copying its address: +settings.whatAreThese=What are these? +settings.bridgeCopy=Copy Bridge Address +settings.bridgeShowAll=Show All Bridges +settings.bridgeShowFewer=Show Fewer Bridges +settings.allBridgesEnabled=Use current bridges +settings.bridgeRemoveAll=Remove All Bridges
View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/cbeecf9...