Bug 1545242 - Add DNS-over-HTTPS resolver picker to the connections prefs UI. r=flod,johannh
authorSam Foster <sfoster@mozilla.com>
Fri, 03 May 2019 16:15:45 +0000
changeset 531334 c7b00a72c46d60c4d04674dd95fa9645bb69c542
parent 531333 67590bfca46ffa5d84426b453cd4e88ac36d6b0b
child 531335 01e77d5c83c48d6a96b4a03e4deb7afc3e090f50
push id11265
push userffxbld-merge
push dateMon, 13 May 2019 10:53:39 +0000
treeherdermozilla-beta@77e0fe8dbdd3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersflod, johannh
bugs1545242
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1545242 - Add DNS-over-HTTPS resolver picker to the connections prefs UI. r=flod,johannh * Create new network.trr.resolvers pref which is a JSON array of objects with a name and url representing each resolver * Add menulist to represent the resolver choices, and a "custom" option to use the network.trr.custom_uri as the trr.uri value Differential Revision: https://phabricator.services.mozilla.com/D29393
browser/components/preferences/connection.js
browser/components/preferences/connection.xul
browser/components/preferences/in-content/tests/browser_connection_dnsoverhttps.js
browser/locales/en-US/browser/preferences/connection.ftl
modules/libpref/init/all.js
--- a/browser/components/preferences/connection.js
+++ b/browser/components/preferences/connection.js
@@ -32,46 +32,65 @@ Preferences.addAll([
   { id: "network.proxy.backup.ftp", type: "string" },
   { id: "network.proxy.backup.ftp_port", type: "int" },
   { id: "network.proxy.backup.ssl", type: "string" },
   { id: "network.proxy.backup.ssl_port", type: "int" },
   { id: "network.proxy.backup.socks", type: "string" },
   { id: "network.proxy.backup.socks_port", type: "int" },
   { id: "network.trr.mode", type: "int" },
   { id: "network.trr.uri", type: "string" },
+  { id: "network.trr.resolvers", type: "string" },
   { id: "network.trr.custom_uri", "type": "string" },
 ]);
 
 window.addEventListener("DOMContentLoaded", () => {
   Preferences.get("network.proxy.type").on("change",
     gConnectionsDialog.proxyTypeChanged.bind(gConnectionsDialog));
   Preferences.get("network.proxy.socks_version").on("change",
     gConnectionsDialog.updateDNSPref.bind(gConnectionsDialog));
 
-  // wait until the network.trr prefs are added before init'ing the UI for them
+  Preferences.get("network.trr.uri").on("change", () => {
+    gConnectionsDialog.updateDnsOverHttpsUI();
+  });
+
+  Preferences.get("network.trr.resolvers").on("change", () => {
+    gConnectionsDialog.initDnsOverHttpsUI();
+  });
+
+  // XXX: We can't init the DNS-over-HTTPs UI until the onsyncfrompreference for network.trr.mode
+  //      has been called. The uiReady promise will be resolved after the first call to
+  //      readDnsOverHttpsMode and the subsequent call to initDnsOverHttpsUI has happened.
   gConnectionsDialog.uiReady = new Promise(resolve => {
-    gConnectionsDialog._initialPrefsAdded = resolve;
+    gConnectionsDialog._areTrrPrefsReady = false;
+    gConnectionsDialog._handleTrrPrefsReady = resolve;
   }).then(() => {
-    delete gConnectionsDialog._initialPrefsAdded;
     gConnectionsDialog.initDnsOverHttpsUI();
   });
 
   document
     .getElementById("disableProxyExtension")
     .addEventListener(
       "command", makeDisableControllingExtension(
         PREF_SETTING_TYPE, PROXY_KEY).bind(gConnectionsDialog));
   gConnectionsDialog.updateProxySettingsUI();
   initializeProxyUI(gConnectionsDialog);
 }, { once: true, capture: true });
 
 var gConnectionsDialog = {
   beforeAccept() {
-    if (document.getElementById("customDnsOverHttpsUrlRadio").selected) {
-      Services.prefs.setStringPref("network.trr.uri", document.getElementById("customDnsOverHttpsInput").value);
+    let dnsOverHttpsResolverChoice = document.getElementById("networkDnsOverHttpsResolverChoices").value;
+    if (dnsOverHttpsResolverChoice == "custom") {
+      let customValue = document.getElementById("networkCustomDnsOverHttpsInput").value.trim();
+      if (customValue) {
+        Services.prefs.setStringPref("network.trr.uri", customValue);
+      } else {
+        Services.prefs.clearUserPref("network.trr.uri");
+      }
+    } else {
+      Services.prefs.setStringPref("network.trr.uri", dnsOverHttpsResolverChoice);
     }
 
     var proxyTypePref = Preferences.get("network.proxy.type");
     if (proxyTypePref.value == 2) {
       this.doAutoconfigURLFixup();
       return true;
     }
 
@@ -291,78 +310,156 @@ var gConnectionsDialog = {
       hideControllingExtension(PROXY_KEY);
       setInputsDisabledState(false);
     } else {
       handleControllingExtension(PREF_SETTING_TYPE, PROXY_KEY)
         .then(setInputsDisabledState);
     }
   },
 
+  get dnsOverHttpsResolvers() {
+    let rawValue = Preferences.get("network.trr.resolvers", "").value;
+    // if there's no default, we'll hold its position with an empty string
+    let defaultURI = Preferences.get("network.trr.uri", "").defaultValue;
+    let providers = [];
+    if (rawValue) {
+      try {
+        providers = JSON.parse(rawValue);
+      } catch (ex) {
+        Cu.reportError(`Bad JSON data in pref network.trr.resolvers: ${rawValue}`);
+      }
+    }
+    if (!Array.isArray(providers)) {
+        Cu.reportError(`Expected a JSON array in network.trr.resolvers: ${rawValue}`);
+        providers = [];
+    }
+    let defaultIndex = providers.findIndex(p => p.url == defaultURI);
+    if (defaultIndex == -1 && defaultURI) {
+      // the default value for the pref isn't included in the resolvers list
+      // so we'll make a stub for it. Without an id, we'll have to use the url as the label
+      providers.unshift({ url: defaultURI });
+    }
+    return providers;
+  },
+
   isDnsOverHttpsLocked() {
     return Services.prefs.prefIsLocked("network.trr.mode");
   },
 
   isDnsOverHttpsEnabled() {
     // values outside 1:4 are considered falsey/disabled in this context
     let trrPref = Preferences.get("network.trr.mode");
     let enabled = trrPref.value > 0 && trrPref.value < 5;
     return enabled;
   },
 
   readDnsOverHttpsMode() {
     // called to update checked element property to reflect current pref value
-    // this is the first signal we get when the prefs are added, so lazy-init
     let enabled = this.isDnsOverHttpsEnabled();
     let uriPref = Preferences.get("network.trr.uri");
     uriPref.disabled = !enabled || this.isDnsOverHttpsLocked();
-    if (this._initialPrefsAdded) {
-      this._initialPrefsAdded();
+    // this is the first signal we get when the prefs are available, so
+    // lazy-init if appropriate
+    if (!this._areTrrPrefsReady) {
+      this._areTrrPrefsReady = true;
+      this._handleTrrPrefsReady();
+    } else {
+      this.updateDnsOverHttpsUI();
     }
     return enabled;
   },
 
   writeDnsOverHttpsMode() {
     // called to update pref with user change
     let trrModeCheckbox = document.getElementById("networkDnsOverHttps");
     // we treat checked/enabled as mode 2
     return trrModeCheckbox.checked ? 2 : 0;
   },
 
   updateDnsOverHttpsUI() {
-    // Disable the custom url input box if the parent checkbox and custom radio button attached to it is not selected.
-    // Disable the custom radio button if the parent checkbox is not selected.
-    let parentCheckbox = document.getElementById("networkDnsOverHttps");
-    let customDnsOverHttpsUrlRadio = document.getElementById("customDnsOverHttpsUrlRadio");
-    let customDnsOverHttpsInput = document.getElementById("customDnsOverHttpsInput");
-    customDnsOverHttpsInput.disabled = !parentCheckbox.checked || !customDnsOverHttpsUrlRadio.selected;
-    customDnsOverHttpsUrlRadio.disabled = !parentCheckbox.checked;
+    // init and update of the UI must wait until the pref values are ready
+    if (!this._areTrrPrefsReady) {
+      return;
+    }
+    let [menu, customInput] = this.getDnsOverHttpsControls();
+    let customContainer = document.getElementById("customDnsOverHttpsContainer");
+    let customURI = Preferences.get("network.trr.custom_uri").value;
+    let currentURI = Preferences.get("network.trr.uri").value;
+    let resolvers = this.dnsOverHttpsResolvers;
+    let isCustom = menu.value == "custom";
+
+    if (this.isDnsOverHttpsEnabled()) {
+      this.toggleDnsOverHttpsUI(false);
+      if (isCustom) {
+        // if the current and custom_uri values mismatch, update the uri pref
+        if (currentURI && !customURI && !resolvers.find(r => r.url == currentURI)) {
+          Services.prefs.setStringPref("network.trr.custom_uri", currentURI);
+        }
+      }
+    } else {
+      this.toggleDnsOverHttpsUI(true);
+    }
+
+    if (!menu.disabled && isCustom) {
+      customContainer.hidden = false;
+      customInput.disabled = false;
+      customContainer.scrollIntoView();
+    } else {
+      customContainer.hidden = true;
+      customInput.disabled = true;
+    }
   },
 
   getDnsOverHttpsControls() {
     return [
-      document.getElementById("networkDnsOverHttps"),
-      document.getElementById("customDnsOverHttpsUrlRadio"),
-      document.getElementById("defaultDnsOverHttpsUrlRadio"),
-      document.getElementById("customDnsOverHttpsInput"),
+      document.getElementById("networkDnsOverHttpsResolverChoices"),
+      document.getElementById("networkCustomDnsOverHttpsInput"),
+      document.getElementById("networkDnsOverHttpsResolverChoicesLabel"),
+      document.getElementById("networkCustomDnsOverHttpsInputLabel"),
     ];
   },
 
-  disableDnsOverHttpsUI(disabled) {
+  toggleDnsOverHttpsUI(disabled) {
     for (let element of this.getDnsOverHttpsControls()) {
       element.disabled = disabled;
     }
   },
 
   initDnsOverHttpsUI() {
-    // If we have a locked pref disable the UI.
-    this.disableDnsOverHttpsUI(this.isDnsOverHttpsLocked());
+    let resolvers = this.dnsOverHttpsResolvers;
+    let defaultURI = Preferences.get("network.trr.uri").defaultValue;
+    let currentURI = Preferences.get("network.trr.uri").value;
+    let menu = document.getElementById("networkDnsOverHttpsResolverChoices");
 
-    let defaultDnsOverHttpsUrlRadio = document.getElementById("defaultDnsOverHttpsUrlRadio");
-    let defaultPrefUrl = Preferences.get("network.trr.uri").defaultValue;
-    document.l10n.setAttributes(defaultDnsOverHttpsUrlRadio, "connection-dns-over-https-url-default", {
-      url: defaultPrefUrl,
-    });
-    defaultDnsOverHttpsUrlRadio.value = defaultPrefUrl;
-    let radioGroup = document.getElementById("DnsOverHttpsUrlRadioGroup");
-    radioGroup.selectedIndex = Preferences.get("network.trr.uri").hasUserValue ? 1 : 0;
-    this.updateDnsOverHttpsUI();
+    // populate the DNS-Over-HTTPs resolver list
+    menu.removeAllItems();
+    for (let resolver of resolvers) {
+      let item = menu.appendItem(undefined, resolver.url);
+      if (resolver.url == defaultURI) {
+        document.l10n.setAttributes(item, "connection-dns-over-https-url-item-default", {
+          name: resolver.name || resolver.url,
+        });
+      } else {
+        item.label = resolver.name || resolver.url;
+      }
+    }
+    let lastItem = menu.appendItem(undefined, "custom");
+    document.l10n.setAttributes(lastItem, "connection-dns-over-https-url-custom");
+
+    // set initial selection in the resolver provider picker
+    let selectedIndex = currentURI ? resolvers.findIndex(r => r.url == currentURI) : 0;
+    if (selectedIndex == -1) {
+      // select the last "Custom" item
+      selectedIndex = menu.itemCount - 1;
+    }
+    menu.selectedIndex = selectedIndex;
+
+    if (this.isDnsOverHttpsLocked()) {
+      // disable all the options and the checkbox itself to disallow enabling them
+      this.toggleDnsOverHttpsUI(true);
+      document.getElementById("networkDnsOverHttps").disabled = true;
+    } else {
+      this.toggleDnsOverHttpsUI(false);
+      this.updateDnsOverHttpsUI();
+      document.getElementById("networkDnsOverHttps").disabled = false;
+    }
   },
 };
--- a/browser/components/preferences/connection.xul
+++ b/browser/components/preferences/connection.xul
@@ -143,30 +143,44 @@
   <html:textarea id="networkProxyNone" preference="network.proxy.no_proxies_on" rows="2"/>
   <label control="networkProxyNone" data-l10n-id="connection-proxy-noproxy-desc" />
   <checkbox id="autologinProxy"
             data-l10n-id="connection-proxy-autologin"
             preference="signon.autologin.proxy" />
   <checkbox id="networkProxySOCKSRemoteDNS"
             preference="network.proxy.socks_remote_dns"
             data-l10n-id="connection-proxy-socks-remote-dns" />
-  <checkbox id="networkDnsOverHttps"
+
+  <groupbox>
+    <checkbox id="networkDnsOverHttps"
             data-l10n-id="connection-dns-over-https"
             preference="network.trr.mode"
             onsyncfrompreference="return gConnectionsDialog.readDnsOverHttpsMode();"
-            onsynctopreference="return gConnectionsDialog.writeDnsOverHttpsMode();"
-            oncommand="gConnectionsDialog.updateDnsOverHttpsUI();"/>
-  <vbox class="indent" flex="1">
-    <radiogroup id="DnsOverHttpsUrlRadioGroup" orient="vertical">
-      <radio id="defaultDnsOverHttpsUrlRadio"
-             data-l10n-id="connection-dns-over-https-url-default"
-             data-l10n-args='{"url":""}'
-             preference="network.trr.uri"
-             oncommand="gConnectionsDialog.updateDnsOverHttpsUI();"/>
-      <hbox>
-        <radio id="customDnsOverHttpsUrlRadio"
-               data-l10n-id="connection-dns-over-https-url-custom"
-               oncommand="gConnectionsDialog.updateDnsOverHttpsUI();"/>
-        <textbox id="customDnsOverHttpsInput" flex="1" preference="network.trr.custom_uri"/>
-      </hbox>
-    </radiogroup>
-  </vbox>
+            onsynctopreference="return gConnectionsDialog.writeDnsOverHttpsMode();"/>
+
+    <grid class="indent" flex="1">
+      <columns>
+        <column></column>
+        <column flex="1"></column>
+      </columns>
+      <rows>
+        <row align="center">
+          <hbox pack="end">
+            <label id="networkDnsOverHttpsResolverChoicesLabel"
+                   data-l10n-id="connection-dns-over-https-url-resolver"
+                   control="networkDnsOverHttpsResolverChoices"/>
+          </hbox>
+          <menulist id="networkDnsOverHttpsResolverChoices"
+                    oncommand="gConnectionsDialog.updateDnsOverHttpsUI()"></menulist>
+        </row>
+        <row align="center" id="customDnsOverHttpsContainer" hidden="true">
+          <hbox pack="end">
+            <label id="networkCustomDnsOverHttpsInputLabel"
+                   data-l10n-id="connection-dns-over-https-custom-label"
+                   control="networkCustomDnsOverHttpsInput"/>
+          </hbox>
+          <textbox id="networkCustomDnsOverHttpsInput" flex="1"
+                   preference="network.trr.custom_uri"/>
+        </row>
+      </rows>
+    </grid>
+  </groupbox>
 </dialog>
--- a/browser/components/preferences/in-content/tests/browser_connection_dnsoverhttps.js
+++ b/browser/components/preferences/in-content/tests/browser_connection_dnsoverhttps.js
@@ -1,26 +1,34 @@
 var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 const SUBDIALOG_URL = "chrome://browser/content/preferences/connection.xul";
 const TRR_MODE_PREF = "network.trr.mode";
 const TRR_URI_PREF = "network.trr.uri";
+const TRR_RESOLVERS_PREF = "network.trr.resolvers";
 const TRR_CUSTOM_URI_PREF = "network.trr.custom_uri";
+const DEFAULT_RESOLVER_VALUE = "https://mozilla.cloudflare-dns.com/dns-query";
 
 const modeCheckboxSelector = "#networkDnsOverHttps";
-const uriTextboxSelector = "#customDnsOverHttpsInput";
+const uriTextboxSelector = "#networkCustomDnsOverHttpsInput";
+const resolverMenulistSelector = "#networkDnsOverHttpsResolverChoices";
 const defaultPrefValues = Object.freeze({
   [TRR_MODE_PREF]: 0,
   [TRR_URI_PREF]: "https://mozilla.cloudflare-dns.com/dns-query",
+  [TRR_RESOLVERS_PREF]: JSON.stringify([
+    { "name": "Cloudflare",  "url": DEFAULT_RESOLVER_VALUE },
+    { "name": "example.org", "url": "https://example.org/dns-query" },
+  ]),
   [TRR_CUSTOM_URI_PREF]: "",
 });
 
 function resetPrefs() {
   Services.prefs.clearUserPref(TRR_MODE_PREF);
   Services.prefs.clearUserPref(TRR_URI_PREF);
+  Services.prefs.clearUserPref(TRR_RESOLVERS_PREF);
   Services.prefs.clearUserPref(TRR_CUSTOM_URI_PREF);
 }
 
 let preferencesOpen = new Promise(res => open_preferences(res));
 
 registerCleanupFunction(() => {
   resetPrefs();
   gBrowser.removeCurrentTab();
@@ -57,43 +65,60 @@ async function testWithProperties(props,
     Services.prefs.setIntPref(TRR_MODE_PREF, props[TRR_MODE_PREF]);
   }
   if (props.hasOwnProperty(TRR_CUSTOM_URI_PREF)) {
     Services.prefs.setStringPref(TRR_CUSTOM_URI_PREF, props[TRR_CUSTOM_URI_PREF]);
   }
   if (props.hasOwnProperty(TRR_URI_PREF)) {
     Services.prefs.setStringPref(TRR_URI_PREF, props[TRR_URI_PREF]);
   }
+  if (props.hasOwnProperty(TRR_RESOLVERS_PREF)) {
+    info(`Setting ${TRR_RESOLVERS_PREF} to ${props[TRR_RESOLVERS_PREF]}`);
+    Services.prefs.setStringPref(TRR_RESOLVERS_PREF, props[TRR_RESOLVERS_PREF]);
+  }
 
   let dialog = await openConnectionsSubDialog();
   await dialog.uiReady;
   info((Date.now() - startTime) + ": testWithProperties: connections dialog now open");
   let doc = dialog.document;
   let win = doc.ownerGlobal;
   let dialogClosingPromise = BrowserTestUtils.waitForEvent(doc.documentElement,
                                                            "dialogclosing");
   let modeCheckbox = doc.querySelector(modeCheckboxSelector);
   let uriTextbox = doc.querySelector(uriTextboxSelector);
+  let resolverMenulist = doc.querySelector(resolverMenulistSelector);
   let uriPrefChangedPromise;
   let modePrefChangedPromise;
 
   if (props.hasOwnProperty("expectedModeChecked")) {
     is(modeCheckbox.checked, props.expectedModeChecked, "mode checkbox has expected checked state");
   }
   if (props.hasOwnProperty("expectedUriValue")) {
     is(uriTextbox.value, props.expectedUriValue, "URI textbox has expected value");
   }
+  if (props.hasOwnProperty("expectedResolverListValue")) {
+    is(resolverMenulist.value, props.expectedResolverListValue, "resolver menulist has expected value");
+  }
   if (props.clickMode) {
     info((Date.now() - startTime) + ": testWithProperties: clickMode, waiting for the pref observer");
     modePrefChangedPromise = waitForPrefObserver(TRR_MODE_PREF);
     info((Date.now() - startTime) + ": testWithProperties: clickMode, pref changed");
     modeCheckbox.scrollIntoView();
     EventUtils.synthesizeMouseAtCenter(modeCheckbox, {}, win);
     info((Date.now() - startTime) + ": testWithProperties: clickMode, mouse click synthesized");
   }
+  if (props.hasOwnProperty("selectResolver")) {
+    info((Date.now() - startTime) + ": testWithProperties: selectResolver, creating change event");
+    resolverMenulist.focus();
+    resolverMenulist.value = props.selectResolver;
+    resolverMenulist.dispatchEvent(new Event("input", {bubbles: true}));
+    resolverMenulist.dispatchEvent(new Event("change", {bubbles: true}));
+    info((Date.now() - startTime) + ": testWithProperties: selectResolver, item value set and events dispatched");
+  }
+
   if (props.hasOwnProperty("inputUriKeys")) {
     info((Date.now() - startTime) + ": testWithProperties: inputUriKeys, waiting for the pref observer");
     uriPrefChangedPromise = waitForPrefObserver(TRR_CUSTOM_URI_PREF);
     info((Date.now() - startTime) + ": testWithProperties: inputUriKeys, pref changed, now enter the new value");
     uriTextbox.focus();
     uriTextbox.value = props.inputUriKeys;
     uriTextbox.dispatchEvent(new win.Event("input", {bubbles: true}));
     uriTextbox.dispatchEvent(new win.Event("change", {bubbles: true}));
@@ -115,16 +140,22 @@ async function testWithProperties(props,
     let uriPref = Services.prefs.getStringPref(TRR_URI_PREF);
     is(uriPref, props.expectedFinalUriPref, "uri pref ended up with the expected value");
   }
 
   if (props.hasOwnProperty("expectedModePref")) {
     let modePref = Services.prefs.getIntPref(TRR_MODE_PREF);
     is(modePref, props.expectedModePref, "mode pref ended up with the expected value");
   }
+
+  if (props.hasOwnProperty("expectedFinalCusomUriPref")) {
+    let customUriPref = Services.prefs.getStringPref(TRR_CUSTOM_URI_PREF);
+    is(customUriPref, props.expectedFinalCustomUriPref, "custom_uri pref ended up with the expected value");
+  }
+
   info((Date.now() - startTime) + ": testWithProperties: fin");
 }
 
 add_task(async function default_values() {
   let customUriPref = Services.prefs.getStringPref(TRR_CUSTOM_URI_PREF);
   let uriPref = Services.prefs.getStringPref(TRR_URI_PREF);
   let modePref = Services.prefs.getIntPref(TRR_MODE_PREF);
   is(modePref, defaultPrefValues[TRR_MODE_PREF],
@@ -132,52 +163,91 @@ add_task(async function default_values()
   is(uriPref, defaultPrefValues[TRR_URI_PREF],
      `Actual value of ${TRR_URI_PREF} matches expected default value`);
   is(customUriPref, defaultPrefValues[TRR_CUSTOM_URI_PREF],
      `Actual value of ${TRR_CUSTOM_URI_PREF} matches expected default value`);
 });
 
 let testVariations = [
   // verify state with defaults
-  { expectedModePref: 0, expectedUriValue: "" },
+  { name: "default", expectedModePref: 0, expectedUriValue: "" },
 
   // verify each of the modes maps to the correct checked state
-  { [TRR_MODE_PREF]: 0, expectedModeChecked: false },
-  { [TRR_MODE_PREF]: 1, expectedModeChecked: true },
-  { [TRR_MODE_PREF]: 2, expectedModeChecked: true },
-  { [TRR_MODE_PREF]: 3, expectedModeChecked: true },
-  { [TRR_MODE_PREF]: 4, expectedModeChecked: true },
-  { [TRR_MODE_PREF]: 5, expectedModeChecked: false },
+  { name: "mode 0",
+    [TRR_MODE_PREF]: 0, expectedModeChecked: false },
+  { name: "mode 1",
+    [TRR_MODE_PREF]: 1, expectedModeChecked: true, expectedFinalUriPref: DEFAULT_RESOLVER_VALUE},
+  { name: "mode 2",
+    [TRR_MODE_PREF]: 2, expectedModeChecked: true, expectedFinalUriPref: DEFAULT_RESOLVER_VALUE},
+  { name: "mode 3",
+    [TRR_MODE_PREF]: 3, expectedModeChecked: true, expectedFinalUriPref: DEFAULT_RESOLVER_VALUE},
+  { name: "mode 4",
+    [TRR_MODE_PREF]: 4, expectedModeChecked: true, expectedFinalUriPref: DEFAULT_RESOLVER_VALUE},
+  { name: "mode 5",
+    [TRR_MODE_PREF]: 5, expectedModeChecked: false },
   // verify an out of bounds mode value maps to the correct checked state
-  { [TRR_MODE_PREF]: 77, expectedModeChecked: false },
+  { name: "mode out-of-bounds",
+    [TRR_MODE_PREF]: 77, expectedModeChecked: false },
 
   // verify toggling the checkbox gives the right outcomes
-  { clickMode: true, expectedModeValue: 2, expectedUriValue: "" },
+  { name: "toggle mode on",
+    clickMode: true, expectedModeValue: 2, expectedUriValue: "", expectedFinalUriPref: DEFAULT_RESOLVER_VALUE },
   {
+    name: "toggle mode off",
     [TRR_MODE_PREF]: 4,
     expectedModeChecked: true, clickMode: true, expectedModePref: 0,
   },
-  // test that setting TRR_CUSTOM_URI_PREF subsequently changes TRR_URI_PREF
+  // test that selecting Custom, when we have a TRR_CUSTOM_URI_PREF subsequently changes TRR_URI_PREF
   {
+    name: "select custom with existing custom_uri pref value",
     [TRR_MODE_PREF]: 2, [TRR_CUSTOM_URI_PREF]: "https://example.com",
-    expectedModeValue: true, expectedUriValue: "https://example.com",
+    expectedModeValue: true, selectResolver: "custom", expectedUriValue: "https://example.com",
+    expectedFinalUriPref: "https://example.com",
+    expectedFinalCustomUriPref: "https://example.com",
   },
   {
-    [TRR_URI_PREF]: "",
-    clickMode: true, inputUriKeys: "https://example.com",
+    name: "select custom and enter new custom_uri pref value",
+    [TRR_URI_PREF]: "", [TRR_CUSTOM_URI_PREF]: "",
+    clickMode: true, selectResolver: "custom", inputUriKeys: "https://example.com",
     expectedModePref: 2, expectedFinalUriPref: "https://example.com",
+    expectedFinalCustomUriPref: "https://example.com",
   },
 
-  // verify the uri can be cleared
   {
+    name: "return to default from custom",
+    [TRR_MODE_PREF]: 2, [TRR_URI_PREF]: "https://example.com", [TRR_CUSTOM_URI_PREF]: "https://example.com",
+    expectedUriValue: "https://example.com",
+    expectedResolverListValue: "custom",
+    selectResolver: DEFAULT_RESOLVER_VALUE,
+    expectedFinalUriPref: DEFAULT_RESOLVER_VALUE,
+    expectedFinalCustomUriPref: "https://example.com",
+  },
+  {
+    name: "clear the custom uri",
     [TRR_MODE_PREF]: 2, [TRR_URI_PREF]: "https://example.com", [TRR_CUSTOM_URI_PREF]: "https://example.com",
-    expectedUriValue: "https://example.com", inputUriKeys: "", expectedFinalUriPref: "",
+    expectedUriValue: "https://example.com",
+    expectedResolverListValue: "custom",
+    inputUriKeys: "",
+    expectedFinalUriPref: DEFAULT_RESOLVER_VALUE,
+    expectedFinalCustomUriPref: "",
   },
+  {
+    name: "empty default resolver list",
+    [TRR_RESOLVERS_PREF]: "",
+    [TRR_MODE_PREF]: 2, [TRR_URI_PREF]: "https://example.com", [TRR_CUSTOM_URI_PREF]: "",
+    [TRR_RESOLVERS_PREF]: "",
+    expectedUriValue: "https://example.com",
+    expectedResolverListValue: "custom",
+    expectedFinalUriPref: "https://example.com",
+    expectedFinalCustomUriPref: "https://example.com",
+  },
+
 ];
 
 for (let props of testVariations) {
   add_task(async function testVariation() {
     await preferencesOpen;
     let startTime = Date.now();
     resetPrefs();
+    info("starting test: " + props.name);
     await testWithProperties(props, startTime);
   });
 }
--- a/browser/locales/en-US/browser/preferences/connection.ftl
+++ b/browser/locales/en-US/browser/preferences/connection.ftl
@@ -81,19 +81,23 @@ connection-proxy-autologin =
 connection-proxy-socks-remote-dns =
     .label = Proxy DNS when using SOCKS v5
     .accesskey = D
 
 connection-dns-over-https =
     .label = Enable DNS over HTTPS
     .accesskey = b
 
+connection-dns-over-https-url-resolver = Use Provider
+    .accesskey = P
+
 # Variables:
-#   $url (String) - URL for the DNS over HTTPS provider
-connection-dns-over-https-url-default =
-    .label = Use default ({ $url })
-    .accesskey = U
+#   $name (String) - Display name or URL for the DNS over HTTPS provider
+connection-dns-over-https-url-item-default =
+    .label = { $name } (Default)
     .tooltiptext = Use the default URL for resolving DNS over HTTPS
 
 connection-dns-over-https-url-custom =
     .label = Custom
     .accesskey = C
     .tooltiptext = Enter your preferred URL for resolving DNS over HTTPS
+
+connection-dns-over-https-custom-label = Custom
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -5510,16 +5510,18 @@ pref("network.connectivity-service.DNSv6
 pref("network.connectivity-service.IPv4.url", "http://detectportal.firefox.com/success.txt?ipv4");
 pref("network.connectivity-service.IPv6.url", "http://detectportal.firefox.com/success.txt?ipv6");
 
 // DNS Trusted Recursive Resolver
 // 0 - default off, 1 - race, 2 TRR first, 3 TRR only, 4 shadow, 5 off by choice
 pref("network.trr.mode", 0);
 // DNS-over-HTTP service to use, must be HTTPS://
 pref("network.trr.uri", "https://mozilla.cloudflare-dns.com/dns-query");
+// DNS-over-HTTP service options, must be HTTPS://
+pref("network.trr.resolvers", "[{ \"name\": \"Cloudflare\", \"url\": \"https://mozilla.cloudflare-dns.com/dns-query\" }]");
 // credentials to pass to DOH end-point
 pref("network.trr.credentials", "");
 pref("network.trr.custom_uri", "");
 // Wait for captive portal confirmation before enabling TRR
 #if defined(ANDROID)
 // On Android, the captive portal is handled by the OS itself
 pref("network.trr.wait-for-portal", false);
 #else