/* 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); } }