tor-browser

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

commit 119527231c32d279fa3565faa8a94963cf95e05b
parent 6b53a2ec7336bf3350d55d4ce634810abd26ac38
Author: Jared Hirsch <ohai@6a68.net>
Date:   Mon, 24 Nov 2025 23:17:34 +0000

Bug 1992201 - Add settings sub-pane for profiles, including copy feature r=profiles-reviewers,fluent-reviewers,desktop-theme-reviewers,bolsson,hjones,niklas

Note we hide the copy profile section until you have either created a
profile (app menu > profiles > new profile) or inited the database (by
clicking the 'manage profiles' button just above. Then you have to
refresh the page.

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

Diffstat:
Mbrowser/components/preferences/main.inc.xhtml | 18+-----------------
Mbrowser/components/preferences/main.js | 184++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mbrowser/components/preferences/preferences.js | 5+++++
Mbrowser/components/preferences/preferences.xhtml | 4++++
Mbrowser/components/preferences/widgets/setting-control/setting-control.mjs | 4+++-
Mbrowser/components/profiles/tests/browser/browser_preferences.js | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mbrowser/locales/en-US/browser/preferences/preferences.ftl | 26+++++++++++++++++++++++---
Apython/l10n/fluent_migrations/bug_1992201_preferences_profiles.py | 28++++++++++++++++++++++++++++
8 files changed, 399 insertions(+), 35 deletions(-)

diff --git a/browser/components/preferences/main.inc.xhtml b/browser/components/preferences/main.inc.xhtml @@ -47,23 +47,7 @@ <!-- Profiles --> <groupbox id="profilesGroup" data-category="paneGeneral" hidden="true"> - <label><html:h2 data-l10n-id="preferences-profiles-header"/></label> - - <hbox id="profiles" flex="1"> - <description flex="1" class="description-deemphasized" control="manage-profiles"> - <html:span data-l10n-id="preferences-manage-profiles-description"></html:span> - <html:a - is="moz-support-link" - id="profile-management-learn-more" - support-page="profile-management" - data-l10n-id="preferences-manage-profiles-learn-more" - /> - </description> - <button id="manage-profiles" - is="highlightable-button" - class="accessory-button" - data-l10n-id="preferences-manage-profiles-button"/> - </hbox> + <html:setting-group groupid="profiles" data-category="paneGeneral"></html:setting-group> </groupbox> <!-- Tab preferences --> diff --git a/browser/components/preferences/main.js b/browser/components/preferences/main.js @@ -634,6 +634,122 @@ Preferences.addSetting({ onUserClick: () => gMainPane.showConnections(), }); +Preferences.addSetting({ + id: "profilesPane", + onUserClick(e) { + e.preventDefault(); + gotoPref("paneProfiles"); + }, +}); +Preferences.addSetting({ + id: "profilesSettings", + visible() { + return SelectableProfileService.isEnabled; + }, + onUserClick: e => { + e.preventDefault(); + gotoPref("profiles"); + }, +}); +Preferences.addSetting({ + id: "manageProfiles", + onUserClick: e => { + e.preventDefault(); + // Using the existing function for now, since privacy.js also calls it + gMainPane.manageProfiles(); + }, +}); +Preferences.addSetting({ + id: "copyProfile", + deps: ["copyProfileSelect"], + disabled: ({ copyProfileSelect }) => !copyProfileSelect.value, + onUserClick: (e, { copyProfileSelect }) => { + e.preventDefault(); + SelectableProfileService.getProfile(copyProfileSelect.value).then( + profile => { + profile?.copyProfile(); + copyProfileSelect.config.set(""); + } + ); + }, +}); +Preferences.addSetting({ + id: "copyProfileBox", + visible: () => SelectableProfileService.initialized, +}); +Preferences.addSetting({ + id: "copyProfileError", + _hasError: false, + setup(emitChange) { + this.emitChange = emitChange; + }, + visible() { + return this._hasError; + }, + setError(value) { + this._hasError = !!value; + this.emitChange(); + }, +}); +Preferences.addSetting( + class ProfileList extends Preferences.AsyncSetting { + static id = "profileList"; + static PROFILE_UPDATED_OBS = "sps-profiles-updated"; + setup() { + Services.obs.addObserver( + this.emitChange, + ProfileList.PROFILE_UPDATED_OBS + ); + return () => { + Services.obs.removeObserver( + this.emitChange, + ProfileList.PROFILE_UPDATED_OBS + ); + }; + } + + async get() { + let profiles = await SelectableProfileService.getAllProfiles(); + return profiles; + } + } +); +Preferences.addSetting({ + id: "copyProfileSelect", + deps: ["profileList"], + _selectedProfile: null, + setup(emitChange) { + this.emitChange = emitChange; + document.l10n + .formatValue("preferences-copy-profile-select") + .then(result => (this.placeholderString = result)); + }, + get() { + return this._selectedProfile; + }, + set(inputVal) { + this._selectedProfile = inputVal; + this.emitChange(); + }, + getControlConfig(config, { profileList }) { + config.options = profileList.value.map(profile => { + return { controlAttrs: { label: profile.name }, value: profile.id }; + }); + + // Put the placeholder at the front of the list. + config.options.unshift({ + controlAttrs: { label: this.placeholderString }, + value: "", + }); + + return config; + }, +}); +Preferences.addSetting({ + id: "copyProfileHeader", + visible: () => SelectableProfileService.initialized, +}); + // Downloads /* * Preferences: @@ -1512,6 +1628,62 @@ SettingGroupManager.registerGroups({ }, ], }, + profilePane: { + headingLevel: 2, + id: "browserProfilesGroupPane", + l10nId: "preferences-profiles-subpane-description", + supportPage: "profile-management", + items: [ + { + id: "manageProfiles", + control: "moz-box-button", + l10nId: "preferences-manage-profiles-button", + }, + { + id: "copyProfileHeader", + l10nId: "preferences-copy-profile-header", + headingLevel: 2, + supportPage: "profile-management", + control: "moz-fieldset", + items: [ + { + id: "copyProfileBox", + l10nId: "preferences-profile-to-copy", + control: "moz-box-item", + items: [ + { + id: "copyProfileSelect", + control: "moz-select", + slot: "actions", + }, + { + id: "copyProfile", + l10nId: "preferences-copy-profile-button", + control: "moz-button", + slot: "actions", + controlAttrs: { + type: "primary", + }, + }, + ], + }, + ], + }, + ], + }, + profiles: { + id: "profilesGroup", + l10nId: "preferences-profiles-section-header", + headingLevel: 2, + supportPage: "profile-management", + items: [ + { + id: "profilesSettings", + control: "moz-box-button", + l10nId: "preferences-profiles-settings-button", + }, + ], + }, startup: { items: [ { @@ -2854,6 +3026,8 @@ var gMainPane = { initSettingGroup("startup"); initSettingGroup("networkProxy"); initSettingGroup("tabs"); + initSettingGroup("profiles"); + initSettingGroup("profilePane"); setEventListener("manageBrowserLanguagesButton", "command", function () { gMainPane.showBrowserLanguagesSubDialog({ search: false }); @@ -2922,16 +3096,6 @@ var gMainPane = { document.getElementById("dataMigrationGroup").remove(); } - if (!SelectableProfileService.isEnabled) { - // Don't want to rely on .hidden for the toplevel groupbox because - // of the pane hiding/showing code potentially interfering: - document - .getElementById("profilesGroup") - .setAttribute("style", "display: none !important"); - } else { - setEventListener("manage-profiles", "command", gMainPane.manageProfiles); - } - // Initializes the fonts dropdowns displayed in this pane. this._rebuildFonts(); diff --git a/browser/components/preferences/preferences.js b/browser/components/preferences/preferences.js @@ -281,6 +281,11 @@ const CONFIG_PANES = Object.freeze({ l10nId: "autofill-payment-methods-manage-payments-title", groupIds: ["managePayments"], }, + paneProfiles: { + parent: "general", + l10nId: "preferences-profiles-group-header", + groupIds: ["profilePane"], + }, }); var gLastCategory = { category: undefined, subcategory: undefined }; diff --git a/browser/components/preferences/preferences.xhtml b/browser/components/preferences/preferences.xhtml @@ -202,6 +202,10 @@ <richlistitem class="category hidden-category" + value="paneProfiles"/> + + <richlistitem + class="category hidden-category" value="paneTranslations"/> </richlistbox> diff --git a/browser/components/preferences/widgets/setting-control/setting-control.mjs b/browser/components/preferences/widgets/setting-control/setting-control.mjs @@ -367,7 +367,9 @@ export class SettingControl extends SettingElement { .config=${item.config} .setting=${item.setting} .getSetting=${this.getSetting} - slot=${ifDefined(ITEM_SLOT_BY_PARENT.get(control))} + slot=${ifDefined( + item.config.slot || ITEM_SLOT_BY_PARENT.get(control) + )} ></setting-control>` ); } diff --git a/browser/components/profiles/tests/browser/browser_preferences.js b/browser/components/profiles/tests/browser/browser_preferences.js @@ -7,6 +7,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ClientID: "resource://gre/modules/ClientID.sys.mjs", TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", }); // Note: copied from preferences head.js. We can remove this when we migrate @@ -92,6 +93,17 @@ function promiseLoadSubDialog(aURL) { }); } +// Note: copied from preferences head.js. We can remove this when we migrate +// this test into that component. +async function waitForPaneChange(paneId) { + let doc = gBrowser.selectedBrowser.contentDocument; + let event = await BrowserTestUtils.waitForEvent(doc, "paneshown"); + let expectId = paneId.startsWith("pane") + ? paneId + : `pane${paneId[0].toUpperCase()}${paneId.substring(1)}`; + is(event.detail.category, expectId, "Loaded the correct pane"); +} + add_task(async function testHiddenWhenDisabled() { await SpecialPowers.pushPrefEnv({ set: [["browser.profiles.enabled", false]], @@ -116,6 +128,7 @@ add_task(async function testEnabled() { leaveOpen: true, }); let doc = gBrowser.contentDocument; + let win = doc.ownerGlobal; // Verify the profiles section is shown when enabled. let profilesCategory = doc.getElementById("profilesGroup"); @@ -124,22 +137,166 @@ add_task(async function testEnabled() { ok(BrowserTestUtils.isVisible(profilesCategory), "The category is visible"); // Verify the Learn More link exists and points to the right place. - let learnMore = doc.getElementById("profile-management-learn-more"); + let profilesSettingGroup = doc.querySelector( + "setting-group[groupid='profiles']" + ).firstElementChild; + let learnMore = profilesSettingGroup.shadowRoot.querySelector( + "a[is='moz-support-link']" + ); Assert.equal( "http://127.0.0.1:8888/support-dummy/profile-management", learnMore.href, "Learn More link should have expected URL" ); - // Verify that clicking the button shows the manage screen in a subdialog. + // Verify that clicking the button opens the expected subpane. + let profilesSubPane = doc.querySelector( + "setting-pane[data-category='paneProfiles']" + ); + ok( + !BrowserTestUtils.isVisible(profilesSubPane), + "Profiles subpane should be hidden" + ); + let paneLoaded = waitForPaneChange("profiles"); + let subPaneButton = profilesSettingGroup.querySelector("#profilesSettings"); + subPaneButton.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(subPaneButton, {}, win); + await paneLoaded; + ok( + BrowserTestUtils.isVisible(profilesSubPane), + "Profiles subpane should be visible" + ); + await profilesSubPane.updateComplete; + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function subpaneContentsWithOneProfile() { + await openPreferencesViaOpenPreferencesAPI("paneGeneral", { + leaveOpen: true, + }); + let doc = gBrowser.contentDocument; + let win = doc.ownerGlobal; + + let paneLoaded = waitForPaneChange("profiles"); + win.gotoPref("paneProfiles"); + await paneLoaded; + + let profilesSubPane = doc.querySelector( + "setting-pane[data-category='paneProfiles']" + ); + await profilesSubPane.updateComplete; + + Assert.equal( + "preferences-profiles-group-header", + profilesSubPane + .querySelector("moz-page-header") + .getAttribute("data-l10n-id"), + "Subpane should have expected heading l10nId" + ); + + let manageProfilesButton = profilesSubPane.querySelector("#manageProfiles"); + ok( + BrowserTestUtils.isVisible(manageProfilesButton), + "Manage profiles button should be visible" + ); + + let copyProfilesSection = profilesSubPane.querySelector("#copyProfileHeader"); + ok( + !BrowserTestUtils.isVisible(copyProfilesSection), + "Until we create a second profile, the copy section should be hidden" + ); + + // Verify the manage profiles button opens the correct subdialog. + manageProfilesButton.scrollIntoView(); let promiseSubDialogLoaded = promiseLoadSubDialog("about:profilemanager"); - let profilesButton = doc.getElementById("manage-profiles"); - profilesButton.click(); + EventUtils.synthesizeMouseAtCenter(manageProfilesButton, {}, win); await promiseSubDialogLoaded; BrowserTestUtils.removeTab(gBrowser.selectedTab); }); +add_task(async function copyProfile() { + // Add an additional profile, then load the subpane, and the copy section should be visible. + await initGroupDatabase(); + await SelectableProfileService.createNewProfile(false); + + await openPreferencesViaOpenPreferencesAPI("paneGeneral", { + leaveOpen: true, + }); + let doc = gBrowser.contentDocument; + let win = doc.ownerGlobal; + + let paneLoaded = waitForPaneChange("profiles"); + win.gotoPref("paneProfiles"); + await paneLoaded; + let profilesSubPane = doc.querySelector( + "setting-pane[data-category='paneProfiles']" + ); + await profilesSubPane.updateComplete; + + let manageProfilesButton = profilesSubPane.querySelector("#manageProfiles"); + ok( + BrowserTestUtils.isVisible(manageProfilesButton), + "Manage profiles button should be visible" + ); + + let copyProfilesSection = profilesSubPane.querySelector("#copyProfileHeader"); + ok( + BrowserTestUtils.isVisible(copyProfilesSection), + "Copy profile section should be visible" + ); + + let profilesCopyButton = copyProfilesSection.querySelector("#copyProfile"); + let profilesSelect = copyProfilesSection.querySelector("#copyProfileSelect"); + ok( + profilesCopyButton.disabled, + "Initially the copy button should be disabled" + ); + ok(!profilesSelect.value, "Initially the select value should be unset"); + Assert.equal( + profilesSelect.options.length, + 3, + "Both profiles and the placeholder should be in the options list" + ); + Assert.equal( + profilesSelect.options[1].label, + "Original profile", + "The first profile should be listed" + ); + Assert.equal( + profilesSelect.options[2].label, + "Profile 1", + "The second profile should be listed" + ); + + profilesSelect.value = "1"; + profilesSelect.dispatchEvent(new win.Event("change", { bubbles: true })); + await profilesCopyButton.updateComplete; + ok( + !profilesCopyButton.disabled, + "When a profile is selected, the copy button should be enabled" + ); + + let copyCalled = false; + let mockGetProfile = lazy.sinon + .stub(SelectableProfileService, "getProfile") + .resolves({ + copyProfile: () => (copyCalled = true), + }); + profilesCopyButton.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(profilesCopyButton, {}, win); + await profilesSelect.updateComplete; + ok(copyCalled, "The profile copy method should have been called"); + ok( + !profilesSelect.value, + "After copy, select value should be reset to empty value" + ); + + mockGetProfile.restore(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + // Tests for the small addition to the privacy section add_task(async function testPrivacyInfoEnabled() { ok(SelectableProfileService.isEnabled, "service should be enabled"); diff --git a/browser/locales/en-US/browser/preferences/preferences.ftl b/browser/locales/en-US/browser/preferences/preferences.ftl @@ -177,11 +177,31 @@ preferences-data-migration-button = .label = Import Data .accesskey = m -preferences-profiles-header = Profiles -preferences-manage-profiles-description = Each profile has separate browsing data and settings, including history, passwords, and more. -preferences-manage-profiles-learn-more = Learn more +preferences-profiles-group-header = + .heading = Profiles +preferences-profiles-subpane-description = + .description = Each profile has separate browsing data and settings, including history, passwords, and more. +preferences-profiles-section-header = + .label = Profiles + .description = Each profile has separate browsing data and settings, including history, passwords, and more. preferences-manage-profiles-button = .label = Manage Profiles +preferences-profiles-settings-button = + .label = Settings +# This string labels the entire copy profile section in the profiles sub-pane. +preferences-copy-profile-header = + .label = Copy an existing profile + .description = The new profile will copy your settings, add-ons, history, and saved data like bookmarks and passwords — but not your account or sync info. +# This string sits next to the copy controls, both the copy-profile-select +# drop-down and the copy-profile-button, so that the user understands they +# need to first pick a profile to copy, and then click the copy button. +preferences-profile-to-copy = + .label = Profile to copy +# This string is a placeholder that will be shown in a drop-down list of +# profiles. The user will select a profile, then click the copy button +# to make a copy of that profile. +preferences-copy-profile-select = Select profile +preferences-copy-profile-button = Copy tabs-group-header2 = .label = Tabs diff --git a/python/l10n/fluent_migrations/bug_1992201_preferences_profiles.py b/python/l10n/fluent_migrations/bug_1992201_preferences_profiles.py @@ -0,0 +1,28 @@ +# Any copyright is dedicated to the Public Domain. +# http://creativecommons.org/publicdomain/zero/1.0/ + +from fluent.migrate import COPY_PATTERN +from fluent.migrate.helpers import transforms_from + + +def migrate(ctx): + """Bug 1992201 - Add settings sub-pane for profiles, part {index}.""" + source = "browser/browser/preferences/preferences.ftl" + target = source + + ctx.add_transforms( + target, + source, + transforms_from( + """ +preferences-profiles-group-header = + .heading = {COPY_PATTERN(from_path, "preferences-profiles-header")} +preferences-profiles-subpane-description = + .description = {COPY_PATTERN(from_path, "preferences-manage-profiles-description")} +preferences-profiles-section-header = + .label = {COPY_PATTERN(from_path, "preferences-profiles-header")} + .description = {COPY_PATTERN(from_path, "preferences-manage-profiles-description")} +""", + from_path=source, + ), + )