diff --git a/browser/components/migration/FirefoxImportMigrator.sys.mjs b/browser/components/migration/FirefoxImportMigrator.sys.mjs new file mode 100644 index 000000000000..a1b15f75a9ea --- /dev/null +++ b/browser/components/migration/FirefoxImportMigrator.sys.mjs @@ -0,0 +1,852 @@ +/* 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/. */ + +/** + * Migrates from external Firefox profiles to Waterfox. This is different from + * the FirefoxProfileMigrator which is used for profile refresh within Firefox. + * This migrator can import data from Firefox installations on the system. + * + * Supported platforms: Windows, macOS, Linux (including Flatpak and Snap) + * Supported data types: Bookmarks, History, Passwords, Form Data, Cookies + */ + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +/** + * Firefox profile migrator for importing from external Firefox profiles. + * This handles importing data from Firefox installations on the system. + */ +export class FirefoxImportMigrator extends MigratorBase { + static get key() { + return "firefox-import"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-firefox"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/firefox.png"; + } + + get enabled() { + return true; + } + + /** + * Get the Firefox profile directory paths for different platforms. + * Supports standard installations as well as Flatpak and Snap on Linux. + * + * @returns {string[]} Array of Firefox profile directory paths + */ + _getFirefoxProfilePaths() { + const paths = []; + + try { + if (AppConstants.platform == "win") { + // Windows: %APPDATA%/Mozilla/Firefox + try { + let appData = Services.dirsvc.get("AppData", Ci.nsIFile); + let firefoxDir = appData.clone(); + firefoxDir.appendRelativePath("Mozilla/Firefox"); + if (firefoxDir.exists() && firefoxDir.isDirectory()) { + paths.push(firefoxDir.path); + } + } catch (ex) { + console.warn("Failed to check Windows Firefox path:", ex); + } + } else if (AppConstants.platform == "macosx") { + // macOS: ~/Library/Application Support/Firefox + try { + let homeDir = Services.dirsvc.get("Home", Ci.nsIFile); + let firefoxDir = homeDir.clone(); + firefoxDir.appendRelativePath("Library/Application Support/Firefox"); + if (firefoxDir.exists() && firefoxDir.isDirectory()) { + paths.push(firefoxDir.path); + } + } catch (ex) { + console.warn("Failed to check macOS Firefox path:", ex); + } + } else if (AppConstants.platform == "linux") { + // Linux: ~/.mozilla/firefox + try { + let homeDir = Services.dirsvc.get("Home", Ci.nsIFile); + let firefoxDir = homeDir.clone(); + firefoxDir.appendRelativePath(".mozilla/firefox"); + if (firefoxDir.exists() && firefoxDir.isDirectory()) { + paths.push(firefoxDir.path); + } + } catch (ex) { + console.warn("Failed to check Linux Firefox path:", ex); + } + + // Also check for Flatpak installation + try { + let homeDir = Services.dirsvc.get("Home", Ci.nsIFile); + let flatpakDir = homeDir.clone(); + flatpakDir.appendRelativePath(".var/app/org.mozilla.firefox/.mozilla/firefox"); + if (flatpakDir.exists() && flatpakDir.isDirectory()) { + paths.push(flatpakDir.path); + } + } catch (ex) { + console.warn("Failed to check Flatpak Firefox path:", ex); + } + + // Check for Snap installation + try { + let homeDir = Services.dirsvc.get("Home", Ci.nsIFile); + let snapDir = homeDir.clone(); + snapDir.appendRelativePath("snap/firefox/common/.mozilla/firefox"); + if (snapDir.exists() && snapDir.isDirectory()) { + paths.push(snapDir.path); + } + } catch (ex) { + console.warn("Failed to check Snap Firefox path:", ex); + } + } + } catch (ex) { + console.error("Error getting Firefox profile paths:", ex); + } + + return paths; + } + + /** + * Parse Firefox profiles.ini file to get profile information. + * + * @param {string} firefoxDir - Path to Firefox installation directory + * @returns {Promise} Array of profile objects with name, path, and existence info + */ + async _parseProfilesIni(firefoxDir) { + let profiles = []; + let profilesIniPath; + + try { + profilesIniPath = PathUtils.join(firefoxDir, "profiles.ini"); + } catch (ex) { + console.error(`Failed to construct profiles.ini path for Firefox directory "${firefoxDir}":`, ex); + return []; + } + + try { + let exists = await IOUtils.exists(profilesIniPath); + + if (exists) { + let content = await IOUtils.readUTF8(profilesIniPath); + + let lines = content.split(/\r?\n/); + let currentProfile = null; + + for (let line of lines) { + line = line.trim(); + if (line.startsWith("[Profile")) { + if (currentProfile && currentProfile.name && currentProfile.path) { + profiles.push(currentProfile); + } + currentProfile = {}; + } else if (line.startsWith("Name=")) { + let name = line.substring(5).trim(); + if (name) { + currentProfile.name = name; + } + } else if (line.startsWith("Path=")) { + let path = line.substring(5).trim(); + // Clean up path separators and invalid characters + if (path) { + // Remove any quotes that might be around the path + path = path.replace(/^["']|["']$/g, ""); + // Normalize path separators + currentProfile.path = path.replace(/\\/g, "/"); + } + } else if (line.startsWith("IsRelative=")) { + currentProfile.isRelative = line.substring(11).trim() === "1"; + } + } + + // Add the last profile if valid + if (currentProfile && currentProfile.name && currentProfile.path) { + profiles.push(currentProfile); + } + + // Resolve paths and validate profiles + for (let profile of profiles) { + if (!profile.path || !profile.name) { + console.warn(`Profile ${profile.name || 'unnamed'} has missing name or path, skipping`); + profile.exists = false; + profile.fullPath = ""; + continue; + } + + // Skip profiles with obviously invalid characters + if (profile.path.includes('\0') || profile.name.includes('\0')) { + console.warn(`Profile ${profile.name} contains null characters, skipping`); + profile.exists = false; + profile.fullPath = ""; + continue; + } + + try { + // Use nsIFile API for more reliable path operations + let baseDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + baseDir.initWithPath(firefoxDir); + + if (profile.isRelative !== false) { + // For relative paths, append to Firefox directory + let profileDir = baseDir.clone(); + profileDir.appendRelativePath(profile.path); + profile.fullPath = profileDir.path; + } else { + // For absolute paths, use as-is but validate + try { + let profileDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + profileDir.initWithPath(profile.path); + profile.fullPath = profileDir.path; + } catch (pathEx) { + console.warn(`Invalid absolute path for profile ${profile.name}: ${profile.path}`, pathEx); + // Try treating as relative path instead + let profileDir = baseDir.clone(); + profileDir.appendRelativePath(profile.path); + profile.fullPath = profileDir.path; + } + } + + // Check if profile directory exists + try { + profile.exists = await IOUtils.exists(profile.fullPath); + } catch (ex) { + console.warn(`Cannot check existence of profile path ${profile.fullPath}:`, ex); + profile.exists = false; + } + } catch (ex) { + console.error(`Failed to resolve profile path for ${profile.name} (${profile.path}):`, ex); + profile.exists = false; + profile.fullPath = profile.path; + } + } + } + } catch (ex) { + console.error(`Error parsing profiles.ini at "${profilesIniPath}":`, ex); + console.error("This might indicate a corrupted or inaccessible profiles.ini file"); + } + + let validProfiles = profiles.filter(p => p.exists); + return validProfiles; + } + + /** + * Get all available Firefox profiles from all Firefox installations. + * Combines profiles from all detected Firefox installations. + * + * @returns {Promise} Array of all available Firefox profiles + */ + async _getAllFirefoxProfiles() { + let allProfiles = []; + let firefoxPaths = this._getFirefoxProfilePaths(); + + if (firefoxPaths.length === 0) { + console.info("No Firefox installations found on this system"); + } + + for (let firefoxPath of firefoxPaths) { + let profiles = await this._parseProfilesIni(firefoxPath); + + // Add source path info to each profile + for (let profile of profiles) { + profile.firefoxPath = firefoxPath; + profile.id = `${firefoxPath}::${profile.name}`; + allProfiles.push(profile); + } + } + + return allProfiles; + } + + async getSourceProfiles() { + let profiles = await this._getAllFirefoxProfiles(); + + // Filter out the current profile to avoid importing from ourselves + try { + // Get current profile directory from MigrationUtils + let currentProfilePath = null; + if (MigrationUtils.profileStartup && MigrationUtils.profileStartup.directory) { + currentProfilePath = MigrationUtils.profileStartup.directory.path; + + } else { + // Fallback to Services directory service + try { + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + currentProfilePath = profileDir.path; + + } catch (ex) { + console.warn("Could not get profile directory from Services:", ex); + } + } + + if (currentProfilePath) { + profiles = profiles.filter(profile => { + try { + let normalizedProfilePath = PathUtils.normalize(profile.fullPath); + let normalizedCurrentPath = PathUtils.normalize(currentProfilePath); + return normalizedProfilePath !== normalizedCurrentPath; + } catch (ex) { + console.warn(`Path comparison failed for profile ${profile.name}:`, ex); + return true; // Include profile if path comparison fails + } + }); + } + } catch (ex) { + console.warn("Could not determine current profile path for filtering:", ex); + } + + let result = profiles.map(profile => ({ + id: profile.id, + name: profile.name, + path: profile.fullPath + })).sort((a, b) => a.name.localeCompare(b.name)); + + return result; + } + + /** + * Helper method to get a file object if it exists and is readable. + * + * @param {string} dir - Directory path + * @param {string} fileName - File name + * @returns {nsIFile|null} File object or null if not accessible + */ + _getFileObject(dir, fileName) { + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(dir); + file.append(fileName); + return file.exists() && file.isReadable() ? file : null; + } + + async _getFiles(aProfile, aFileName) { + const files = []; + let file = aProfile.clone(); + file.append(aFileName); + + if (await IOUtils.exists(file.path)) { + files.push(file.path); + } else { + return []; + } + + // Also look for SQLite temporary files. + for (const suffix of ["-wal", "-shm"]) { + file = aProfile.clone(); + file.append(aFileName + suffix); + if (await IOUtils.exists(file.path)) { + files.push(file.path); + } + } + return files; + } + + async getResources(aProfile) { + if (!aProfile) { + console.warn("No profile provided to getResources"); + return []; + } + + let profilePath = aProfile.path; + + if (!profilePath || !(await IOUtils.exists(profilePath))) { + console.warn("Profile path does not exist:", profilePath); + return []; + } + + let resources = []; + + // Bookmarks and History (Places database) + let placesResource = await this._getPlacesResource(profilePath); + if (placesResource) { + resources.push(placesResource); + } + + // Passwords + let passwordsResource = await this._getPasswordsResource(profilePath); + if (passwordsResource) { + resources.push(passwordsResource); + } + + // Form data + let formDataResource = await this._getFormDataResource(profilePath); + if (formDataResource) { + resources.push(formDataResource); + } + + // Cookies + let cookiesResource = await this._getCookiesResource(profilePath); + if (cookiesResource) { + resources.push(cookiesResource); + } + + // Bookmarks (separate from Places) + let bookmarksResource = await this._getBookmarksResource(profilePath); + if (bookmarksResource) { + resources.push(bookmarksResource); + } + + return resources; + } + + async _getPlacesResource(profilePath) { + let placesPath = PathUtils.join(profilePath, "places.sqlite"); + if (!(await IOUtils.exists(placesPath))) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.HISTORY, + migrate: async (callback) => { + try { + await this._migratePlaces(placesPath); + callback(true); + } catch (ex) { + console.error("Failed to migrate places:", ex); + callback(false); + } + } + }; + } + + async _migratePlaces(placesPath) { + const sourceDB = await lazy.Sqlite.openConnection({ path: placesPath }); + try { + const historyRows = await sourceDB.execute(` + SELECT p.url, p.title, v.visit_date, v.visit_type + FROM moz_historyvisits AS v + JOIN moz_places AS p ON v.place_id = p.id + WHERE p.url NOT LIKE ? AND p.url NOT LIKE ? AND p.visit_count > 0 + `, ["place:%", "moz-anno:%"]); + + const pageInfos = new Map(); + const maxAge = Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS; + + for (const row of historyRows) { + const url = row.getResultByName("url"); + const visitDate = new Date(row.getResultByName("visit_date") / 1000); + if (!URL.canParse(url) || visitDate.getTime() < maxAge) { + continue; + } + + const visit = { + date: visitDate, + transition: row.getResultByName("visit_type"), + }; + + if (pageInfos.has(url)) { + pageInfos.get(url).visits.push(visit); + } else { + pageInfos.set(url, { + url: new URL(url), + title: row.getResultByName("title") || url, + visits: [visit], + }); + } + } + if (pageInfos.size > 0) { + await MigrationUtils.insertVisitsWrapper([...pageInfos.values()]); + } + } finally { + await sourceDB.close(); + } + } + + async _getPasswordsResource(profilePath) { + let passwordFiles = ["logins.json", "key4.db"]; + let files = []; + + for (let fileName of passwordFiles) { + let filePath = PathUtils.join(profilePath, fileName); + if (await IOUtils.exists(filePath)) { + files.push({name: fileName, path: filePath}); + } + } + + if (files.length === 0) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.PASSWORDS, + migrate: async (callback) => { + try { + // Copy password files directly to current profile directory + let currentProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + for (let file of files) { + let destPath = PathUtils.join(currentProfileDir.path, file.name); + await IOUtils.copy(file.path, destPath); + } + + callback(true); + } catch (ex) { + console.error("Failed to migrate passwords:", ex); + callback(false); + } + } + }; + } + + async _getBookmarksResource(profilePath) { + let placesPath = PathUtils.join(profilePath, "places.sqlite"); + if (!(await IOUtils.exists(placesPath))) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.BOOKMARKS, + migrate: async (callback) => { + try { + await this._migrateBookmarks(placesPath); + callback(true); + } catch (ex) { + console.error("Failed to migrate bookmarks:", ex); + callback(false); + } + } + }; + } + + async _migrateBookmarks(placesPath) { + const sourceDB = await lazy.Sqlite.openConnection({ path: placesPath }); + try { + // Get all folders first + const folders = await sourceDB.execute(` + SELECT id, parent, title, guid, type, position FROM moz_bookmarks + WHERE type = 2 + ORDER BY parent, position + `); + + // Get all bookmarks + const bookmarks = await sourceDB.execute(` + SELECT b.id, b.parent, b.title, p.url, b.dateAdded, b.lastModified, b.guid, b.position + FROM moz_bookmarks AS b + JOIN moz_places AS p ON b.fk = p.id + WHERE b.type = 1 + ORDER BY b.parent, b.position + `); + + // Create mapping of source folder IDs to target GUIDs + const folderGuidMap = new Map(); + + // Map root folders + for (const folder of folders) { + const guid = folder.getResultByName("guid"); + const id = folder.getResultByName("id"); + + if (guid === "menu________") { + folderGuidMap.set(id, lazy.PlacesUtils.bookmarks.menuGuid); + } else if (guid === "toolbar_______") { + folderGuidMap.set(id, lazy.PlacesUtils.bookmarks.toolbarGuid); + } else if (guid === "unfiled_____") { + folderGuidMap.set(id, lazy.PlacesUtils.bookmarks.unfiledGuid); + } else if (guid === "mobile______") { + folderGuidMap.set(id, lazy.PlacesUtils.bookmarks.mobileGuid); + } + } + + // Create custom folders in hierarchy order + let processedFolders = new Set([...folderGuidMap.keys()]); + let lastProcessedCount = -1; + + while (processedFolders.size < folders.length && processedFolders.size !== lastProcessedCount) { + lastProcessedCount = processedFolders.size; + + for (const folder of folders) { + const id = folder.getResultByName("id"); + const parentId = folder.getResultByName("parent"); + const title = folder.getResultByName("title"); + const guid = folder.getResultByName("guid"); + + // Skip if already processed or is a root folder + if (processedFolders.has(id)) continue; + if (["menu________", "toolbar_______", "unfiled_____", "mobile______", "tags________", "root________"].includes(guid)) { + processedFolders.add(id); + continue; + } + + // Skip tag folders (parent ID 4 is tags folder) + if (parentId === 4) { + processedFolders.add(id); + continue; + } + + // Check if parent has been processed + const parentGuid = folderGuidMap.get(parentId); + if (parentGuid) { + try { + const folderSpec = { + parentGuid, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: title || "Untitled Folder", + dateAdded: new Date(), + }; + const newFolder = await MigrationUtils.insertBookmarkWrapper(folderSpec); + folderGuidMap.set(id, newFolder.guid); + processedFolders.add(id); + } catch (ex) { + console.error("Failed to create folder:", title, ex); + } + } + } + } + + // Collect bookmarks by folder for batch insertion + const bookmarksByFolder = new Map(); + + for (const bookmark of bookmarks) { + const parentId = bookmark.getResultByName("parent"); + const url = bookmark.getResultByName("url"); + const title = bookmark.getResultByName("title"); + const dateAdded = new Date(bookmark.getResultByName("dateAdded") / 1000); + + if (!url || !URL.canParse(url)) { + continue; + } + + const parentGuid = folderGuidMap.get(parentId); + if (parentGuid) { + if (!bookmarksByFolder.has(parentGuid)) { + bookmarksByFolder.set(parentGuid, []); + } + bookmarksByFolder.get(parentGuid).push({ + url, + title: title || url, + dateAdded, + }); + } else { + // Check if this is a tagged bookmark (parent is a tag folder) + const isTaggedBookmark = folders.some(f => + f.getResultByName("id") === parentId && + f.getResultByName("parent") === 4 + ); + } + } + + // Insert bookmarks using MigrationUtils wrapper for proper counting + let totalImported = 0; + for (const [parentGuid, bookmarks] of bookmarksByFolder) { + try { + await MigrationUtils.insertManyBookmarksWrapper(bookmarks, parentGuid); + totalImported += bookmarks.length; + } catch (ex) { + console.error("Failed to insert bookmarks for folder:", parentGuid, ex); + } + } + } finally { + await sourceDB.close(); + } + } + + async _getFormDataResource(profilePath) { + let formHistoryPath = PathUtils.join(profilePath, "formhistory.sqlite"); + if (!(await IOUtils.exists(formHistoryPath))) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.FORMDATA, + migrate: async (callback) => { + try { + await this._migrateFormData(formHistoryPath); + callback(true); + } catch (ex) { + console.error("Failed to migrate form data:", ex); + callback(false); + } + } + }; + } + + /** + * Migrate form history data from Firefox formhistory.sqlite database. + * + * @param {string} formHistoryPath - Path to formhistory.sqlite file + */ + async _migrateFormData(formHistoryPath) { + let db = await lazy.Sqlite.openConnection({ path: formHistoryPath }); + + try { + let rows = await db.execute(` + SELECT fieldname, value, timesUsed, firstUsed, lastUsed + FROM moz_formhistory + WHERE fieldname IS NOT NULL AND value IS NOT NULL + ORDER BY lastUsed DESC + `); + + let addOps = []; + for (let row of rows) { + let fieldname = row.getResultByName("fieldname"); + let value = row.getResultByName("value"); + let timesUsed = row.getResultByName("timesUsed") || 1; + let firstUsed = row.getResultByName("firstUsed") || 0; + let lastUsed = row.getResultByName("lastUsed") || 0; + + if (fieldname && value) { + addOps.push({ + op: "add", + fieldname, + value, + timesUsed, + firstUsed: firstUsed / 1000, // Convert from microseconds to milliseconds + lastUsed: lastUsed / 1000, // Convert from microseconds to milliseconds + }); + } + } + + if (addOps.length > 0) { + await lazy.FormHistory.update(addOps); + } + } finally { + await db.close(); + } + } + + async _getCookiesResource(profilePath) { + let cookiesPath = PathUtils.join(profilePath, "cookies.sqlite"); + + if (!(await IOUtils.exists(cookiesPath))) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.COOKIES, + migrate: async (callback) => { + try { + await this._migrateCookies(cookiesPath); + callback(true); + } catch (ex) { + console.error("Failed to migrate cookies:", ex); + callback(false); + } + } + }; + } + + /** + * Migrate cookies from Firefox cookies.sqlite database. + * Only imports cookies that haven't expired. + * + * @param {string} cookiesPath - Path to cookies.sqlite file + */ + async _migrateCookies(cookiesPath) { + let db; + + try { + db = await lazy.Sqlite.openConnection({ path: cookiesPath }); + } catch (ex) { + console.error("Failed to open cookies database:", ex); + throw ex; + } + + try { + // First, let's see what's in the database + let allRows = await db.execute(` + SELECT COUNT(*) as total FROM moz_cookies + `); + + let rows = await db.execute(` + SELECT host, path, name, value, expiry, isSecure, isHttpOnly, sameSite + FROM moz_cookies + WHERE expiry > ${Math.floor(Date.now() / 1000)} + ORDER BY lastAccessed DESC + LIMIT 1000 + `); + + if (rows.length === 0) { + // Let's try without the expiry filter to see if there are any cookies at all + let allCookies = await db.execute(` + SELECT host, path, name, value, expiry, isSecure, isHttpOnly, sameSite + FROM moz_cookies + ORDER BY lastAccessed DESC + LIMIT 100 + `); + } + + let successCount = 0; + let failCount = 0; + + for (let row of rows) { + try { + let host = row.getResultByName("host"); + let path = row.getResultByName("path"); + let name = row.getResultByName("name"); + let value = row.getResultByName("value"); + let expiry = row.getResultByName("expiry"); + let isSecure = Boolean(row.getResultByName("isSecure")); + let isHttpOnly = Boolean(row.getResultByName("isHttpOnly")); + let sameSite = row.getResultByName("sameSite") || Ci.nsICookie.SAMESITE_NONE; + + // Skip invalid cookies + if (!host || !name) { + failCount++; + continue; + } + + Services.cookies.add( + host, + path || "/", + name, + value || "", + isSecure, + isHttpOnly, + false, // isSession + expiry, + {}, // originAttributes + sameSite, + isSecure ? Ci.nsICookie.SCHEME_HTTPS : Ci.nsICookie.SCHEME_HTTP + ); + successCount++; + } catch (ex) { + console.error("Failed to add cookie:", ex); + failCount++; + } + } + } finally { + await db.close(); + } + } + + + + async getLastUsedDate() { + let profiles = await this._getAllFirefoxProfiles(); + let dates = []; + + for (let profile of profiles) { + try { + let stat = await IOUtils.stat(profile.fullPath); + dates.push(stat.lastModified); + + // Also check places.sqlite modification time + let placesPath = PathUtils.join(profile.fullPath, "places.sqlite"); + if (await IOUtils.exists(placesPath)) { + let placesStat = await IOUtils.stat(placesPath); + dates.push(placesStat.lastModified); + } + } catch (ex) { + // Ignore errors for individual profiles + } + } + + return dates.length > 0 ? new Date(Math.max(...dates)) : new Date(0); + } +} diff --git a/browser/components/migration/MigrationUtils.sys.mjs b/browser/components/migration/MigrationUtils.sys.mjs index 50bd77edb50c..e8eacb6e83b4 100644 --- a/browser/components/migration/MigrationUtils.sys.mjs +++ b/browser/components/migration/MigrationUtils.sys.mjs @@ -52,6 +52,10 @@ const MIGRATOR_MODULES = Object.freeze({ moduleURI: "resource:///modules/FirefoxProfileMigrator.sys.mjs", platforms: ["linux", "macosx", "win"], }, + FirefoxImportMigrator: { + moduleURI: "resource:///modules/FirefoxImportMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, IEProfileMigrator: { moduleURI: "resource:///modules/IEProfileMigrator.sys.mjs", platforms: ["win"], diff --git a/browser/components/migration/MigrationWizardParent.sys.mjs b/browser/components/migration/MigrationWizardParent.sys.mjs index ca951075557c..8d887663400a 100644 --- a/browser/components/migration/MigrationWizardParent.sys.mjs +++ b/browser/components/migration/MigrationWizardParent.sys.mjs @@ -757,7 +757,7 @@ export class MigrationWizardParent extends JSWindowActorParent { } case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS: { let quantity = MigrationUtils.getImportedCount("logins"); - return lazy.gFluentStrings.formatValue( + return quantity === 0 ? "Passwords imported" : lazy.gFluentStrings.formatValue( "migration-wizard-progress-success-passwords", { quantity, diff --git a/browser/components/migration/content/brands/firefox.png b/browser/components/migration/content/brands/firefox.png new file mode 100644 index 000000000000..b92d78ca6d09 Binary files /dev/null and b/browser/components/migration/content/brands/firefox.png differ diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs index 2014aa2d38cc..f1a8d7a0152e 100644 --- a/browser/components/migration/content/migration-wizard.mjs +++ b/browser/components/migration/content/migration-wizard.mjs @@ -30,6 +30,7 @@ export class MigrationWizard extends HTMLElement { #expandedDetails = false; #extensionsSuccessLink = null; #supportTextLinks = null; + #passwordsMigrated = false; static get markup() { return ` @@ -107,6 +108,9 @@ export class MigrationWizard extends HTMLElement { + @@ -605,6 +609,9 @@ export class MigrationWizard extends HTMLElement { this.#ensureSelectionDropdown(); this.#browserProfileSelectorList.textContent = ""; + // Reset password migration flag when starting new migration + this.#passwordsMigrated = false; + let selectionPage = this.#shadowRoot.querySelector( "div[name='page-selection']" ); @@ -828,6 +835,12 @@ export class MigrationWizard extends HTMLElement { this.#extensionsSuccessLink.textContent = state.progress[resourceType].message; } + if ( + resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ) { + this.#passwordsMigrated = true; + } remainingProgressGroups--; break; } @@ -864,6 +877,12 @@ export class MigrationWizard extends HTMLElement { this.#extensionsSuccessLink.textContent = state.progress[resourceType].message; } + if ( + resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ) { + this.#passwordsMigrated = true; + } remainingProgressGroups--; break; } @@ -898,6 +917,18 @@ export class MigrationWizard extends HTMLElement { cancelButton.hidden = migrationDone; if (migrationDone) { + // If passwords were migrated, change button text to "Restart Now" + if (this.#passwordsMigrated) { + let doneButton = progressPage.querySelector(".done-button"); + let continueButton = progressPage.querySelector(".continue-button"); + if (doneButton) { + document.l10n.setAttributes(doneButton, "quickactions-restart"); + } + if (continueButton) { + document.l10n.setAttributes(continueButton, "quickactions-restart"); + } + } + // Since this might be called before the named-deck actually switches to // show the progress page, we cannot focus this button immediately. // Instead, we use a rAF to queue this up for focusing before the @@ -1254,6 +1285,8 @@ export class MigrationWizard extends HTMLElement { "migration-list-autofill-label", [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PAYMENT_METHODS]: "migration-list-payment-methods-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.COOKIES]: + "migration-cookies-option-label", }; if (MigrationWizardConstants.USES_FAVORITES.includes(key)) { @@ -1442,12 +1475,22 @@ export class MigrationWizard extends HTMLElement { ) { this.#doImport(); } else if ( - event.target.classList.contains("cancel-close") || - event.target.classList.contains("finish-button") + event.target.classList.contains("cancel-close") ) { this.dispatchEvent( new CustomEvent("MigrationWizard:Close", { bubbles: true }) ); + } else if ( + event.target.classList.contains("finish-button") + ) { + if (this.#passwordsMigrated) { + // Restart the browser when passwords were migrated + this.#restartBrowser(); + } else { + this.dispatchEvent( + new CustomEvent("MigrationWizard:Close", { bubbles: true }) + ); + } } else if ( event.currentTarget == this.#browserProfileSelectorList && event.target != this.#browserProfileSelectorList @@ -1574,6 +1617,32 @@ export class MigrationWizard extends HTMLElement { } } } + + #restartBrowser() { + try { + // Notify all windows that an application quit has been requested + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + + // Something aborted the quit process + if (cancelQuit.data) { + return; + } + + // Restart the browser + Services.startup.quit( + Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart + ); + } catch (ex) { + console.error("Failed to restart browser:", ex); + } + } } if (globalThis.customElements) { diff --git a/browser/components/migration/jar.mn b/browser/components/migration/jar.mn index 4eabe74555a5..b1d3f4d2b07c 100644 --- a/browser/components/migration/jar.mn +++ b/browser/components/migration/jar.mn @@ -22,6 +22,7 @@ browser.jar: content/browser/migration/brands/brave.png (content/brands/brave.png) content/browser/migration/brands/chrome.png (content/brands/chrome.png) content/browser/migration/brands/chromium.png (content/brands/chromium.png) + content/browser/migration/brands/firefox.png (content/brands/firefox.png) content/browser/migration/brands/opera.png (content/brands/opera.png) content/browser/migration/brands/operagx.png (content/brands/operagx.png) content/browser/migration/brands/vivaldi.png (content/brands/vivaldi.png) diff --git a/browser/components/migration/moz.build b/browser/components/migration/moz.build index 8c958ab7d4d4..979ada319504 100644 --- a/browser/components/migration/moz.build +++ b/browser/components/migration/moz.build @@ -26,6 +26,7 @@ EXTRA_JS_MODULES += [ "ChromeMigrationUtils.sys.mjs", "ChromeProfileMigrator.sys.mjs", "FileMigrators.sys.mjs", + "FirefoxImportMigrator.sys.mjs", "FirefoxProfileMigrator.sys.mjs", "InternalTestingProfileMigrator.sys.mjs", "MigrationUtils.sys.mjs",