commit 0ddf25410102e1867445b6c6f0cbc20641f3a853 parent 14586cbc060a3b4a3a0f6ff5cd92cb51695025fd Author: Dave Townsend <dtownsend@oxymoronical.com> Date: Wed, 19 Nov 2025 14:23:05 +0000 Bug 1996240: Add a method to migrate old style profiles to the new profiles system. r=profiles-reviewers,fluent-reviewers,bolsson,jhirsch,desktop-theme-reviewers,hjones,tschuster Adds a Migrate button to profiles in about:profiles that are elligible for migration. This is profiles that are not currently in use and don't already have multiple profiles grouped with them. Differential Revision: https://phabricator.services.mozilla.com/D270525 Diffstat:
13 files changed, 489 insertions(+), 50 deletions(-)
diff --git a/browser/components/profiles/SelectableProfileService.sys.mjs b/browser/components/profiles/SelectableProfileService.sys.mjs @@ -1131,22 +1131,26 @@ class SelectableProfileServiceClass extends EventEmitter { * @param {nsIFile} profileDir The root dir of the newly created profile */ async createProfileInitialFiles(profileDir) { - let timesJsonFilePath = await IOUtils.createUniqueFile( - profileDir.path, - "times.json", - 0o700 - ); + let timesJsonFilePath = PathUtils.join(profileDir.path, "times.json"); await IOUtils.writeJSON(timesJsonFilePath, { created: Date.now(), firstUse: null, }); - let prefsJsFilePath = await IOUtils.createUniqueFile( - profileDir.path, - "prefs.js", - 0o600 - ); + await IOUtils.setPermissions(timesJsonFilePath, 0o600); + + await this.updateProfilePrefs(profileDir); + } + + /** + * Create or update the prefs.js file in the given profile directory with + * all shared prefs from the database. + * + * @param {nsIFile} profileDir The root dir of the profile to update prefs for + */ + async updateProfilePrefs(profileDir) { + let prefsJsFilePath = PathUtils.join(profileDir.path, "prefs.js"); const sharedPrefs = await this.getAllDBPrefs(); @@ -1171,8 +1175,12 @@ class SelectableProfileServiceClass extends EventEmitter { const LINEBREAK = AppConstants.platform === "win" ? "\r\n" : "\n"; await IOUtils.writeUTF8( prefsJsFilePath, - Services.prefs.prefsJsPreamble + prefsJs.join(LINEBREAK) + LINEBREAK + Services.prefs.prefsJsPreamble + prefsJs.join(LINEBREAK) + LINEBREAK, + // If the file already exists then appending the prefs to the end serves to overwrite them. + { mode: "appendOrCreate" } ); + + await IOUtils.setPermissions(prefsJsFilePath, 0o600); } /** @@ -1200,28 +1208,35 @@ class SelectableProfileServiceClass extends EventEmitter { * If path is not included, new profile directories will be created. * * @param {nsIFile} existingProfilePath Optional. The path of an existing profile. + * @param {string} profileName Optional. The name for the profile. If not provided, + * a default name will be generated. * * @returns {SelectableProfile} The newly created profile object. */ - async #createProfile(existingProfilePath) { - let nextProfileNumber = Math.max( - 0, - ...(await this.getAllProfiles()).map(p => p.id) - ); - let [defaultName, originalName] = - await lazy.profilesLocalization.formatMessages([ - { id: "default-profile-name", args: { number: nextProfileNumber } }, - { id: "original-profile-name" }, - ]); + async #createProfile(existingProfilePath, profileName) { + let name; + if (profileName) { + name = profileName; + } else { + let nextProfileNumber = Math.max( + 0, + ...(await this.getAllProfiles()).map(p => p.id) + ); + let [defaultName, originalName] = + await lazy.profilesLocalization.formatMessages([ + { id: "default-profile-name", args: { number: nextProfileNumber } }, + { id: "original-profile-name" }, + ]); + + name = nextProfileNumber == 0 ? originalName.value : defaultName.value; + } let window = Services.wm.getMostRecentBrowserWindow(); let isDark = window?.matchMedia("(-moz-system-dark-theme)").matches; let randomIndex = Math.floor(Math.random() * this.#defaultAvatars.length); let profileData = { - // The original toolkit profile is added first and is assigned a - // different name. - name: nextProfileNumber == 0 ? originalName.value : defaultName.value, + name, avatar: this.#defaultAvatars[randomIndex], themeId: DEFAULT_THEME_ID, themeFg: isDark ? "rgb(255,255,255)" : "rgb(21,20,26)", @@ -1463,6 +1478,29 @@ class SelectableProfileServiceClass extends EventEmitter { } /** + * Import an existing profile directory into the selectable profiles system. + * This adds the profile to the datastore without creating new directories, + * and launches the profile in a new instance. + * + * If the user has never created a SelectableProfile before, the currently + * running toolkit profile will be added to the datastore along with the + * imported profile. + * + * @param {string} aProfileName The name for the imported profile + * @param {nsIFile} aProfileDir The existing profile directory to import + * + * @returns {SelectableProfile} The newly imported profile object. + */ + async importProfile(aProfileName, aProfileDir) { + await this.maybeSetupDataStore(); + + let profile = await this.#createProfile(aProfileDir, aProfileName); + await this.updateProfilePrefs(aProfileDir); + this.launchInstance(profile, ["about:editprofile"]); + return profile; + } + + /** * Get the complete list of profiles in the group. * * @returns {Array<SelectableProfile>} diff --git a/browser/components/profiles/tests/browser/browser.toml b/browser/components/profiles/tests/browser/browser.toml @@ -51,6 +51,8 @@ skip-if = [ ["browser_menubar_profiles.js"] head = "../unit/head.js head.js" +["browser_migrate.js"] + ["browser_moveTabToProfile.js"] ["browser_notify_changes.js"] diff --git a/browser/components/profiles/tests/browser/browser_migrate.js b/browser/components/profiles/tests/browser/browser_migrate.js @@ -0,0 +1,160 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +add_setup(() => { + registerCleanupFunction(() => { + sinon.restore(); + }); +}); + +add_task(async () => { + Assert.ok( + !SelectableProfileService.currentProfile, + "Should not have a profile yet" + ); + + await BrowserTestUtils.withNewTab("about:profiles", async browser => { + // Override the service that about:profiles uses with our mock service. + browser.contentWindow.ProfileService = gProfileService; + + // This profile will appear to be in use because it is locked. + let newProfile = new MockProfile(gProfileService); + newProfile.name = "ProfileInUse"; + newProfile.lock(); + gProfileService.profiles.push(newProfile); + + // This profile is already in a group and so cannot be migrated. + newProfile = new MockProfile(gProfileService); + newProfile.name = "ProfileInGroup"; + newProfile.storeID = "sdkjsdhf"; + gProfileService.profiles.push(newProfile); + + // This profile can be migrated. + newProfile = new MockProfile(gProfileService); + newProfile.name = "ProfileToMigrate"; + newProfile.rootDir = newProfile.rootDir.clone(); + newProfile.rootDir.append("tomigrate"); + // test-verify runs this test multiple times so the directory may already exist. + if (!newProfile.rootDir.exists()) { + newProfile.rootDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o700); + } + newProfile.localDir = newProfile.rootDir; + gProfileService.profiles.push(newProfile); + gProfileService.defaultProfile = newProfile; + + browser.contentWindow.rebuildProfileList(); + await browser.contentWindow.document.l10n.translateRoots(); + + let migrateButtons = browser.contentWindow.document.querySelectorAll( + "button[data-l10n-id='profiles-migrate-button']" + ); + Assert.equal( + migrateButtons.length, + 1, + "There should be one migrate button" + ); + Assert.equal( + migrateButtons[0].parentNode.firstChild.textContent, + "Profile: ProfileToMigrate", + "The migrate button should be for the correct profile" + ); + + let promptPromise = BrowserTestUtils.promiseAlertDialogOpen( + "cancel", + "chrome://mozapps/content/profile/profileMigrate.xhtml", + { isSubDialog: true } + ); + migrateButtons[0].click(); + await promptPromise; + + Assert.ok( + gProfileService.profiles.includes(newProfile), + "Profile not removed" + ); + + let { promise, resolve } = Promise.withResolvers(); + let execProcess = sinon.fake(resolve); + sinon.replace(SelectableProfileService, "execProcess", execProcess); + sinon.replace( + SelectableProfileService, + "sendCommandLine", + sinon.fake.throws(Cr.NS_ERROR_NOT_AVAILABLE) + ); + + promptPromise = BrowserTestUtils.promiseAlertDialogOpen( + "accept", + "chrome://mozapps/content/profile/profileMigrate.xhtml", + { isSubDialog: true } + ); + migrateButtons[0].click(); + await promptPromise; + let processArgs = await promise; + await browser.contentWindow.document.l10n.translateRoots(); + + sinon.restore(); + + Assert.ok( + !gProfileService.profiles.includes(newProfile), + "Profile was removed from the original profile service" + ); + Assert.ok( + gProfileService.profiles.includes(gProfileService.defaultProfile), + "Default profile updated" + ); + + migrateButtons = browser.contentWindow.document.querySelectorAll( + "button[data-l10n-id='profiles-migrate']" + ); + Assert.equal(migrateButtons.length, 0, "There should be no migrate button"); + + let expectedArgs = [ + "--profile", + newProfile.rootDir.path, + "-new-tab", + "about:editprofile", + ]; + + if (AppConstants.platform == "macosx") { + expectedArgs.unshift("-foreground"); + } + + Assert.deepEqual( + processArgs, + expectedArgs, + "Attempted to launch the migrated profile with the correct arguments" + ); + + let currentProfile = SelectableProfileService.currentProfile; + Assert.ok(currentProfile, "There should be a current profile now"); + + let profiles = await SelectableProfileService.getAllProfiles(); + Assert.equal(profiles.length, 2, "There should be two profiles"); + profiles = profiles.filter(p => p.id != currentProfile.id); + Assert.equal(profiles.length, 1, "The current profile was in the list"); + + Assert.equal( + profiles[0].name, + "ProfileToMigrate", + "Profile has correct name" + ); + + Assert.equal( + profiles[0].path, + newProfile.rootDir.path, + "Profile has correct path" + ); + + let prefsFile = PathUtils.join(profiles[0].path, "prefs.js"); + let prefs = (await IOUtils.readUTF8(prefsFile)).split(/\r?\n/); + Assert.ok( + prefs.includes( + `user_pref("toolkit.profiles.storeID", "${SelectableProfileService.storeID}");` + ), + "Profile contain the assigned store ID" + ); + }); +}); diff --git a/browser/components/profiles/tests/browser/head.js b/browser/components/profiles/tests/browser/head.js @@ -18,6 +18,7 @@ class MockProfile { // eslint-disable-next-line no-unused-private-class-members #service = null; #storeID = null; + #locked = false; constructor(service) { this.#service = service; @@ -28,6 +29,27 @@ class MockProfile { this.showProfileSelector = false; } + lock() { + if (this.#locked) { + throw Components.Exception("Profile already locked", Cr.NS_ERROR_FAILURE); + } + this.#locked = true; + + return { + unlock: () => { + this.#locked = false; + }, + }; + } + + remove() { + if (this.#service.defaultProfile == this) { + this.#service.defaultProfile = null; + } + + this.#service.profiles = this.#service.profiles.filter(p => p != this); + } + get storeID() { return this.#storeID; } @@ -43,6 +65,8 @@ class MockProfile { class MockProfileService { constructor() { this.currentProfile = new MockProfile(this); + this.defaultProfile = this.currentProfile; + this.profiles = [this.currentProfile]; } async asyncFlush() {} diff --git a/dom/security/nsContentSecurityUtils.cpp b/dom/security/nsContentSecurityUtils.cpp @@ -1308,6 +1308,7 @@ static nsLiteralCString sStyleSrcUnsafeInlineAllowList[] = { "chrome://mozapps/content/preferences/changemp.xhtml"_ns, "chrome://mozapps/content/preferences/removemp.xhtml"_ns, "chrome://mozapps/content/profile/profileDowngrade.xhtml"_ns, + "chrome://mozapps/content/profile/profileMigrate.xhtml"_ns, "chrome://mozapps/content/profile/profileSelection.xhtml"_ns, "chrome://mozapps/content/profile/createProfileWizard.xhtml"_ns, "chrome://mozapps/content/update/history.xhtml"_ns, diff --git a/toolkit/content/aboutProfiles.js b/toolkit/content/aboutProfiles.js @@ -8,6 +8,10 @@ const { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + XPCOMUtils.defineLazyServiceGetter( this, "ProfileService", @@ -15,6 +19,13 @@ XPCOMUtils.defineLazyServiceGetter( Ci.nsIToolkitProfileService ); +ChromeUtils.defineESModuleGetters(this, { + // Use of this must be gated on AppConstants.MOZ_SELECTABLE_PROFILES + SelectableProfileService: + // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit + "resource:///modules/profiles/SelectableProfileService.sys.mjs", +}); + async function flush() { try { await ProfileService.asyncFlush(); @@ -84,6 +95,7 @@ function rebuildProfileList() { isDefault: profile == defaultProfile, isCurrentProfile, isInUse, + storeID: profile.storeID, }); } } @@ -194,6 +206,22 @@ function display(profileData) { div.appendChild(runButton); } + if ( + AppConstants.MOZ_SELECTABLE_PROFILES && + SelectableProfileService.isEnabled && + !profileData.isInUse && + !profileData.isCurrentProfile && + !profileData.storeID + ) { + let migrateButton = document.createElement("button"); + document.l10n.setAttributes(migrateButton, "profiles-migrate-button"); + migrateButton.onclick = function () { + migrateProfile(profileData.profile); + }; + + div.appendChild(migrateButton); + } + let sep = document.createElement("hr"); div.appendChild(sep); } @@ -245,6 +273,31 @@ async function renameProfile(profile) { } } +function maybeReassignDefaultProfile(profile) { + if ( + ProfileService.defaultProfile && + ProfileService.defaultProfile != profile + ) { + return; + } + + for (let p of ProfileService.profiles) { + if (profile == p) { + continue; + } + + try { + ProfileService.defaultProfile = p; + } catch (e) { + // This can happen on dev-edition if a non-default profile is in use. + // In such a case the next time that dev-edition is started it will + // find no default profile and just create a new one. + } + + break; + } +} + async function removeProfile(profile) { let deleteFiles = false; @@ -281,32 +334,6 @@ async function removeProfile(profile) { } } - // If we are deleting the default profile we must choose a different one. - let isDefault = false; - try { - isDefault = ProfileService.defaultProfile == profile; - } catch (e) {} - - if (isDefault) { - for (let p of ProfileService.profiles) { - if (profile == p) { - continue; - } - - if (isDefault) { - try { - ProfileService.defaultProfile = p; - } catch (e) { - // This can happen on dev-edition if a non-default profile is in use. - // In such a case the next time that dev-edition is started it will - // find no default profile and just create a new one. - } - } - - break; - } - } - try { profile.removeInBackground(deleteFiles); } catch (e) { @@ -319,9 +346,57 @@ async function removeProfile(profile) { return; } + // If we are deleting the default profile we must choose a different one. + maybeReassignDefaultProfile(profile); + flush(); } +async function migrateProfile(profile) { + let out = {}; + + let tabDialogBox = + window.browsingContext.topChromeWindow.gBrowser.getTabDialogBox( + window.browsingContext.embedderElement + ); + + let { closedPromise } = tabDialogBox.open( + "chrome://mozapps/content/profile/profileMigrate.xhtml", + { + features: "resizable=no", + modalType: Ci.nsIPrompt.MODAL_TYPE_CONTENT, + }, + profile.name, + out + ); + + await closedPromise; + + if (out.button != "accept") { + return; + } + + try { + profile.remove(false); + + // If we are deleting the default profile we must choose a different one. + maybeReassignDefaultProfile(profile); + + await SelectableProfileService.importProfile(profile.name, profile.rootDir); + + flush(); + } catch (e) { + console.error("Profile migration failed:", e); + + let [errorTitle, errorMsg] = await document.l10n.formatValues([ + { id: "profiles-migrate-failed-title" }, + { id: "profiles-migrate-failed-message" }, + ]); + + Services.prompt.alert(window, errorTitle, errorMsg); + } +} + async function defaultProfile(profile) { try { ProfileService.defaultProfile = profile; diff --git a/toolkit/locales/en-US/toolkit/about/aboutProfiles.ftl b/toolkit/locales/en-US/toolkit/about/aboutProfiles.ftl @@ -72,3 +72,7 @@ profiles-opendir = [windows] Open Folder *[other] Open Directory } + +profiles-migrate-button = Move to profile menu +profiles-migrate-failed-title = Migration Failed +profiles-migrate-failed-message = There was an error while attempting to migrate this profile. diff --git a/toolkit/locales/en-US/toolkit/global/profileMigrate.ftl b/toolkit/locales/en-US/toolkit/global/profileMigrate.ftl @@ -0,0 +1,25 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# 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/. + +profile-migrate-window = + .title = Move profile + .style = min-width: 480px + +# Variables: +# $profileName (String) - Name of the profile being migrated +profile-migrate-title = Move “{ $profileName }” to the profile menu? + +profile-migrate-start = Moving this profile: + +## Bulleted list outlining what will happen if a user moves this profile. +## Appears beneath string: "Moving this profile:" (profile-migrate-start) + +profile-migrate-point1 = Will make it available in the <b>profiles</b> section of the { -brand-short-name } menu +profile-migrate-point2 = Will remove it from <b>about:profiles</b> +profile-migrate-point3 = May overwrite some settings. <a data-l10n-name="profile-migrate-link">Learn more</a> + +## + +profile-migrate-end = This move can’t be undone. +profile-migrate-accept = Move profile diff --git a/toolkit/profile/content/profileMigrate.js b/toolkit/profile/content/profileMigrate.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +document.l10n.setArgs(document.getElementById("title"), { + profileName: window.arguments[0], +}); + +document.getElementById("dialog").addEventListener("dialogclosing", event => { + window.arguments[1].button = event.detail.button; +}); diff --git a/toolkit/profile/content/profileMigrate.xhtml b/toolkit/profile/content/profileMigrate.xhtml @@ -0,0 +1,65 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - 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/. --> + +<?csp default-src chrome:; style-src chrome: 'unsafe-inline'; ?> + +<!DOCTYPE window> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + prefwidth="min-width" + data-l10n-id="profile-migrate-window" + data-l10n-attrs="title,style" +> + <dialog + id="dialog" + buttonpack="end" + buttons="accept,cancel" + defaultButton="accept" + buttonidaccept="profile-migrate-accept" + > + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://mozapps/skin/profileMigrate.css" + /> + + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="toolkit/global/profileMigrate.ftl" /> + </linkset> + + <script src="chrome://browser/content/utilityOverlay.js" /> + <script src="chrome://global/content/customElements.js" /> + + <div xmlns="http://www.w3.org/1999/xhtml" id="dialogBody"> + <div> + <xul:image class="question-icon" role="presentation" /> + </div> + <div> + <p id="title" data-l10n-id="profile-migrate-title"></p> + + <p id="descriptionStart" data-l10n-id="profile-migrate-start"></p> + <ul> + <li data-l10n-id="profile-migrate-point1"></li> + <li data-l10n-id="profile-migrate-point2"></li> + <li data-l10n-id="profile-migrate-point3"> + <a + is="moz-support-link" + data-l10n-name="profile-migrate-link" + noinitialfocus="true" + support-page="move-to-profile-menu" + ></a> + </li> + </ul> + <p id="warning" data-l10n-id="profile-migrate-end"></p> + </div> + </div> + + <script src="profileMigrate.js" /> + </dialog> +</window> diff --git a/toolkit/profile/jar.mn b/toolkit/profile/jar.mn @@ -5,6 +5,8 @@ toolkit.jar: content/mozapps/profile/createProfileWizard.js (content/createProfileWizard.js) content/mozapps/profile/createProfileWizard.xhtml (content/createProfileWizard.xhtml) + content/mozapps/profile/profileMigrate.js (content/profileMigrate.js) + content/mozapps/profile/profileMigrate.xhtml (content/profileMigrate.xhtml) content/mozapps/profile/profileSelection.js (content/profileSelection.js) content/mozapps/profile/profileSelection.xhtml (content/profileSelection.xhtml) #ifdef MOZ_BLOCK_PROFILE_DOWNGRADE diff --git a/toolkit/themes/shared/mozapps.inc.mn b/toolkit/themes/shared/mozapps.inc.mn @@ -21,6 +21,7 @@ skin/classic/mozapps/aboutProfiles.css (../../shared/aboutProfiles.css) skin/classic/mozapps/aboutServiceWorkers.css (../../shared/aboutServiceWorkers.css) skin/classic/mozapps/profileDowngrade.css (../../shared/profileDowngrade.css) + skin/classic/mozapps/profileMigrate.css (../../shared/profileMigrate.css) skin/classic/mozapps/profileSelection.css (../../shared/profileSelection.css) % override chrome://mozapps/skin/extensions/category-languages.svg chrome://mozapps/skin/extensions/localeGeneric.svg diff --git a/toolkit/themes/shared/profileMigrate.css b/toolkit/themes/shared/profileMigrate.css @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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/. */ + +#dialogBody { + font: menu; + display: flex; + flex-direction: row; + align-items: flex-start; +} + +.question-icon { + margin-block-start: 0; +} + +#title { + margin-block-start: 0; + font-weight: var(--heading-font-weight); +} + +ul { + padding-inline-start: var(--space-xxlarge); + margin-block-start: var(--space-small); +} + +#warning { + font-weight: var(--font-weight-bold); +} + +#descriptionStart { + margin-block-end: var(--space-small); +}