diff options
Diffstat (limited to 'browser/components/migration')
157 files changed, 22295 insertions, 0 deletions
diff --git a/browser/components/migration/.eslintrc.js b/browser/components/migration/.eslintrc.js new file mode 100644 index 0000000000..34d8ceec2d --- /dev/null +++ b/browser/components/migration/.eslintrc.js @@ -0,0 +1,36 @@ +/* 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/. */ + +"use strict"; + +module.exports = { + extends: ["plugin:mozilla/require-jsdoc"], + rules: { + "block-scoped-var": "error", + complexity: ["error", { max: 22 }], + "max-nested-callbacks": ["error", 3], + "no-extend-native": "error", + "no-multi-str": "error", + "no-return-assign": "error", + "no-shadow": "error", + "no-unused-vars": ["error", { args: "after-used", vars: "all" }], + strict: ["error", "global"], + yoda: "error", + }, + + overrides: [ + { + files: ["head*.js"], + rules: { + "no-unused-vars": [ + "error", + { + args: "none", + vars: "local", + }, + ], + }, + }, + ], +}; diff --git a/browser/components/migration/360seMigrationUtils.sys.mjs b/browser/components/migration/360seMigrationUtils.sys.mjs new file mode 100644 index 0000000000..61d227a830 --- /dev/null +++ b/browser/components/migration/360seMigrationUtils.sys.mjs @@ -0,0 +1,190 @@ +/* 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/. */ + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "filenamesRegex", + () => /^360(?:default_ori|sefav)_([0-9_]+)\.favdb$/i +); + +const kBookmarksFileName = "360sefav.dat"; + +function Bookmarks(aProfileFolder) { + let file = aProfileFolder.clone(); + file.append(kBookmarksFileName); + + this._file = file; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get exists() { + return this._file.exists() && this._file.isReadable(); + }, + + migrate(aCallback) { + return (async () => { + let folderMap = new Map(); + let toolbarBMs = []; + + let connection = await lazy.Sqlite.openConnection({ + path: this._file.path, + }); + + try { + let rows = await connection.execute( + `WITH RECURSIVE + bookmark(id, parent_id, is_folder, title, url, pos) AS ( + VALUES(0, -1, 1, '', '', 0) + UNION + SELECT f.id, f.parent_id, f.is_folder, f.title, f.url, f.pos + FROM tb_fav AS f + JOIN bookmark AS b ON f.parent_id = b.id + ORDER BY f.pos ASC + ) + SELECT id, parent_id, is_folder, title, url FROM bookmark WHERE id` + ); + + for (let row of rows) { + let id = parseInt(row.getResultByName("id"), 10); + let parent_id = parseInt(row.getResultByName("parent_id"), 10); + let is_folder = parseInt(row.getResultByName("is_folder"), 10); + let title = row.getResultByName("title"); + let url = row.getResultByName("url"); + + let bmToInsert; + + if (is_folder) { + bmToInsert = { + children: [], + title, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + }; + folderMap.set(id, bmToInsert); + } else { + try { + new URL(url); + } catch (ex) { + console.error( + `Ignoring ${url} when importing from 360se because of exception:`, + ex + ); + continue; + } + + bmToInsert = { + title, + url, + }; + } + + if (folderMap.has(parent_id)) { + folderMap.get(parent_id).children.push(bmToInsert); + } else if (parent_id === 0) { + toolbarBMs.push(bmToInsert); + } + } + } finally { + await connection.close(); + } + + if (toolbarBMs.length) { + let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid); + } + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, +}; + +export var Qihoo360seMigrationUtils = { + async getAlternativeBookmarks({ bookmarksPath, localState }) { + let lastModificationDate = new Date(0); + let path = bookmarksPath; + let profileFolder = PathUtils.parent(bookmarksPath); + + if (await IOUtils.exists(bookmarksPath)) { + try { + let { lastModified } = await IOUtils.stat(bookmarksPath); + lastModificationDate = new Date(lastModified); + } catch (ex) { + console.error(ex); + } + } + + // Somewhat similar to source profiles, but for bookmarks only + let subDir = + (localState.sync_login_info && localState.sync_login_info.filepath) || ""; + + if (subDir) { + let legacyBookmarksPath = PathUtils.join( + profileFolder, + subDir, + kBookmarksFileName + ); + if (await IOUtils.exists(legacyBookmarksPath)) { + try { + let { lastModified } = await IOUtils.stat(legacyBookmarksPath); + lastModificationDate = new Date(lastModified); + path = legacyBookmarksPath; + } catch (ex) { + console.error(ex); + } + } + } + + let dailyBackupPath = PathUtils.join(profileFolder, subDir, "DailyBackup"); + for (const entry of await IOUtils.getChildren(dailyBackupPath, { + ignoreAbsent: true, + })) { + let filename = PathUtils.filename(entry); + let matches = lazy.filenamesRegex.exec(filename); + if (!matches) { + continue; + } + + let entryDate = new Date(matches[1].replace(/_/g, "-")); + if (entryDate < lastModificationDate) { + continue; + } + + lastModificationDate = entryDate; + path = entry; + } + + if (PathUtils.filename(path) === kBookmarksFileName) { + let resource = this.getLegacyBookmarksResource(PathUtils.parent(path)); + return { resource }; + } + return { path }; + }, + + getLegacyBookmarksResource(aParentFolder) { + let parentFolder; + try { + parentFolder = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + parentFolder.initWithPath(aParentFolder); + } catch (ex) { + console.error(ex); + return null; + } + + let bookmarks = new Bookmarks(parentFolder); + return bookmarks.exists ? bookmarks : null; + }, +}; diff --git a/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs b/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs new file mode 100644 index 0000000000..595bbc28c4 --- /dev/null +++ b/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs @@ -0,0 +1,185 @@ +/* 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/. */ + +/** + * Class to handle encryption and decryption of logins stored in Chrome/Chromium + * on macOS. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gKeychainUtils", + "@mozilla.org/profile/migrator/keychainmigrationutils;1", + "nsIKeychainMigrationUtils" +); + +const gTextEncoder = new TextEncoder(); +const gTextDecoder = new TextDecoder(); + +/** + * From macOS' CommonCrypto/CommonCryptor.h + */ +const kCCBlockSizeAES128 = 16; + +/* Chromium constants */ + +/** + * kSalt from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=43&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const SALT = "saltysalt"; + +/** + * kDerivedKeySizeInBits from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=46&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const DERIVED_KEY_SIZE_BITS = 128; + +/** + * kEncryptionIterations from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=49&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const ITERATIONS = 1003; + +/** + * kEncryptionVersionPrefix from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=61&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const ENCRYPTION_VERSION_PREFIX = "v10"; + +/** + * The initialization vector is 16 space characters (character code 32 in decimal). + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=220&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const IV = new Uint8Array(kCCBlockSizeAES128).fill(32); + +/** + * Instances of this class have a shape similar to OSCrypto so it can be dropped + * into code which uses that. This isn't implemented as OSCrypto_mac.js since + * it isn't calling into encryption functions provided by macOS but instead + * relies on OS encryption key storage in Keychain. The algorithms here are + * specific to what is needed for Chrome login storage on macOS. + */ +export class ChromeMacOSLoginCrypto { + /** + * @param {string} serviceName of the Keychain Item to use to derive a key. + * @param {string} accountName of the Keychain Item to use to derive a key. + * @param {string?} [testingPassphrase = null] A string to use as the passphrase + * to derive a key for testing purposes rather than retrieving + * it from the macOS Keychain since we don't yet have a way to + * mock the Keychain auth dialog. + */ + constructor(serviceName, accountName, testingPassphrase = null) { + // We still exercise the keychain migration utils code when using a + // `testingPassphrase` in order to get some test coverage for that + // component, even though it's expected to throw since a login item with the + // service name and account name usually won't be found. + let encKey = testingPassphrase; + try { + encKey = lazy.gKeychainUtils.getGenericPassword(serviceName, accountName); + } catch (ex) { + if (!testingPassphrase) { + throw ex; + } + } + + this.ALGORITHM = "AES-CBC"; + + this._keyPromise = crypto.subtle + .importKey("raw", gTextEncoder.encode(encKey), "PBKDF2", false, [ + "deriveKey", + ]) + .then(key => { + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: gTextEncoder.encode(SALT), + iterations: ITERATIONS, + hash: "SHA-1", + }, + key, + { name: this.ALGORITHM, length: DERIVED_KEY_SIZE_BITS }, + false, + ["decrypt", "encrypt"] + ); + }) + .catch(console.error); + } + + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + } + + stringToArray(binary_string) { + let len = binary_string.length; + let bytes = new Uint8Array(len); + for (var i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; + } + + /** + * @param {Array} ciphertextArray ciphertext prefixed by the encryption version + * (see ENCRYPTION_VERSION_PREFIX). + * @returns {string} plaintext password + */ + async decryptData(ciphertextArray) { + let ciphertext = this.arrayToString(ciphertextArray); + if (!ciphertext.startsWith(ENCRYPTION_VERSION_PREFIX)) { + throw new Error("Unknown encryption version"); + } + let key = await this._keyPromise; + if (!key) { + throw new Error("Cannot decrypt without a key"); + } + let plaintext = await crypto.subtle.decrypt( + { name: this.ALGORITHM, iv: IV }, + key, + this.stringToArray(ciphertext.substring(ENCRYPTION_VERSION_PREFIX.length)) + ); + return gTextDecoder.decode(plaintext); + } + + /** + * @param {USVString} plaintext to encrypt + * @returns {string} encrypted string consisting of UTF-16 code units prefixed + * by the ENCRYPTION_VERSION_PREFIX. + */ + async encryptData(plaintext) { + let key = await this._keyPromise; + if (!key) { + throw new Error("Cannot encrypt without a key"); + } + + let ciphertext = await crypto.subtle.encrypt( + { name: this.ALGORITHM, iv: IV }, + key, + gTextEncoder.encode(plaintext) + ); + return ( + ENCRYPTION_VERSION_PREFIX + + String.fromCharCode(...new Uint8Array(ciphertext)) + ); + } +} diff --git a/browser/components/migration/ChromeMigrationUtils.sys.mjs b/browser/components/migration/ChromeMigrationUtils.sys.mjs new file mode 100644 index 0000000000..88ae3addfc --- /dev/null +++ b/browser/components/migration/ChromeMigrationUtils.sys.mjs @@ -0,0 +1,499 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", +}); + +const S100NS_FROM1601TO1970 = 0x19db1ded53e8000; +const S100NS_PER_MS = 10; + +export var ChromeMigrationUtils = { + // Supported browsers with importable logins. + CONTEXTUAL_LOGIN_IMPORT_BROWSERS: ["chrome", "chromium-edge", "chromium"], + + _extensionVersionDirectoryNames: {}, + + // The cache for the locale strings. + // For example, the data could be: + // { + // "profile-id-1": { + // "extension-id-1": { + // "name": { + // "message": "Fake App 1" + // } + // }, + // } + _extensionLocaleStrings: {}, + + get supportsLoginsForPlatform() { + return ["macosx", "win"].includes(AppConstants.platform); + }, + + /** + * Get all extensions installed in a specific profile. + * + * @param {string} profileId - A Chrome user profile ID. For example, "Profile 1". + * @returns {Array} All installed Chrome extensions information. + */ + async getExtensionList(profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let path = await this.getExtensionPath(profileId); + let extensionList = []; + try { + for (const child of await IOUtils.getChildren(path)) { + const info = await IOUtils.stat(child); + if (info.type === "directory") { + const name = PathUtils.filename(child); + let extensionInformation = await this.getExtensionInformation( + name, + profileId + ); + if (extensionInformation) { + extensionList.push(extensionInformation); + } + } + } + } catch (ex) { + console.error(ex); + } + return extensionList; + }, + + /** + * Get information of a specific Chrome extension. + * + * @param {string} extensionId - The extension ID. + * @param {string} profileId - The user profile's ID. + * @returns {object} The Chrome extension information. + */ + async getExtensionInformation(extensionId, profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let extensionInformation = null; + try { + let manifestPath = await this.getExtensionPath(profileId); + manifestPath = PathUtils.join(manifestPath, extensionId); + // If there are multiple sub-directories in the extension directory, + // read the files in the latest directory. + let directories = await this._getSortedByVersionSubDirectoryNames( + manifestPath + ); + if (!directories[0]) { + return null; + } + + manifestPath = PathUtils.join( + manifestPath, + directories[0], + "manifest.json" + ); + let manifest = await IOUtils.readJSON(manifestPath); + // No app attribute means this is a Chrome extension not a Chrome app. + if (!manifest.app) { + const DEFAULT_LOCALE = manifest.default_locale; + let name = await this._getLocaleString( + manifest.name, + DEFAULT_LOCALE, + extensionId, + profileId + ); + let description = await this._getLocaleString( + manifest.description, + DEFAULT_LOCALE, + extensionId, + profileId + ); + if (name) { + extensionInformation = { + id: extensionId, + name, + description, + }; + } else { + throw new Error("Cannot read the Chrome extension's name property."); + } + } + } catch (ex) { + console.error(ex); + } + return extensionInformation; + }, + + /** + * Get the manifest's locale string. + * + * @param {string} key - The key of a locale string, for example __MSG_name__. + * @param {string} locale - The specific language of locale string. + * @param {string} extensionId - The extension ID. + * @param {string} profileId - The user profile's ID. + * @returns {string|null} The locale string. + */ + async _getLocaleString(key, locale, extensionId, profileId) { + if (typeof key !== "string") { + console.debug("invalid manifest key"); + return null; + } + // Return the key string if it is not a locale key. + // The key string starts with "__MSG_" and ends with "__". + // For example, "__MSG_name__". + // https://developer.chrome.com/apps/i18n + if (!key.startsWith("__MSG_") || !key.endsWith("__")) { + return key; + } + + let localeString = null; + try { + let localeFile; + if ( + this._extensionLocaleStrings[profileId] && + this._extensionLocaleStrings[profileId][extensionId] + ) { + localeFile = this._extensionLocaleStrings[profileId][extensionId]; + } else { + if (!this._extensionLocaleStrings[profileId]) { + this._extensionLocaleStrings[profileId] = {}; + } + let localeFilePath = await this.getExtensionPath(profileId); + localeFilePath = PathUtils.join(localeFilePath, extensionId); + let directories = await this._getSortedByVersionSubDirectoryNames( + localeFilePath + ); + // If there are multiple sub-directories in the extension directory, + // read the files in the latest directory. + localeFilePath = PathUtils.join( + localeFilePath, + directories[0], + "_locales", + locale, + "messages.json" + ); + localeFile = await IOUtils.readJSON(localeFilePath); + this._extensionLocaleStrings[profileId][extensionId] = localeFile; + } + const PREFIX_LENGTH = 6; + const SUFFIX_LENGTH = 2; + // Get the locale key from the string with locale prefix and suffix. + // For example, it will get the "name" sub-string from the "__MSG_name__" string. + key = key.substring(PREFIX_LENGTH, key.length - SUFFIX_LENGTH); + if (localeFile[key] && localeFile[key].message) { + localeString = localeFile[key].message; + } + } catch (ex) { + console.error(ex); + } + return localeString; + }, + + /** + * Check that a specific extension is installed or not. + * + * @param {string} extensionId - The extension ID. + * @param {string} profileId - The user profile's ID. + * @returns {boolean} Return true if the extension is installed otherwise return false. + */ + async isExtensionInstalled(extensionId, profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let extensionPath = await this.getExtensionPath(profileId); + let isInstalled = await IOUtils.exists( + PathUtils.join(extensionPath, extensionId) + ); + return isInstalled; + }, + + /** + * Get the last used user profile's ID. + * + * @returns {string} The last used user profile's ID. + */ + async getLastUsedProfileId() { + let localState = await this.getLocalState(); + return localState ? localState.profile.last_used : "Default"; + }, + + /** + * Get the local state file content. + * + * @param {string} chromeProjectName + * The type of Chrome data we're looking for (Chromium, Canary, etc.) + * @param {string} [dataPath=undefined] + * The data path that should be used as the parent directory when getting + * the local state. If not supplied, the data path is calculated using + * getDataPath and the chromeProjectName. + * @returns {object} The JSON-based content. + */ + async getLocalState(chromeProjectName = "Chrome", dataPath) { + let localState = null; + try { + if (!dataPath) { + dataPath = await this.getDataPath(chromeProjectName); + } + let localStatePath = PathUtils.join(dataPath, "Local State"); + localState = JSON.parse(await IOUtils.readUTF8(localStatePath)); + } catch (ex) { + // Don't report the error if it's just a file not existing. + if (ex.name != "NotFoundError") { + console.error(ex); + } + throw ex; + } + return localState; + }, + + /** + * Get the path of Chrome extension directory. + * + * @param {string} profileId - The user profile's ID. + * @returns {string} The path of Chrome extension directory. + */ + async getExtensionPath(profileId) { + return PathUtils.join(await this.getDataPath(), profileId, "Extensions"); + }, + + /** + * Get the path of an application data directory. + * + * @param {string} chromeProjectName - The Chrome project name, e.g. "Chrome", "Canary", etc. + * Defaults to "Chrome". + * @returns {string} The path of application data directory. + */ + async getDataPath(chromeProjectName = "Chrome") { + const SNAP_REAL_HOME = "SNAP_REAL_HOME"; + + const SUB_DIRECTORIES = { + win: { + Brave: [ + ["LocalAppData", "BraveSoftware", "Brave-Browser", "User Data"], + ], + Chrome: [["LocalAppData", "Google", "Chrome", "User Data"]], + "Chrome Beta": [["LocalAppData", "Google", "Chrome Beta", "User Data"]], + Chromium: [["LocalAppData", "Chromium", "User Data"]], + Canary: [["LocalAppData", "Google", "Chrome SxS", "User Data"]], + Edge: [["LocalAppData", "Microsoft", "Edge", "User Data"]], + "Edge Beta": [["LocalAppData", "Microsoft", "Edge Beta", "User Data"]], + "360 SE": [["AppData", "360se6", "User Data"]], + Opera: [["AppData", "Opera Software", "Opera Stable"]], + "Opera GX": [["AppData", "Opera Software", "Opera GX Stable"]], + Vivaldi: [["LocalAppData", "Vivaldi", "User Data"]], + }, + macosx: { + Brave: [ + ["ULibDir", "Application Support", "BraveSoftware", "Brave-Browser"], + ], + Chrome: [["ULibDir", "Application Support", "Google", "Chrome"]], + Chromium: [["ULibDir", "Application Support", "Chromium"]], + Canary: [["ULibDir", "Application Support", "Google", "Chrome Canary"]], + Edge: [["ULibDir", "Application Support", "Microsoft Edge"]], + "Edge Beta": [ + ["ULibDir", "Application Support", "Microsoft Edge Beta"], + ], + "Opera GX": [ + ["ULibDir", "Application Support", "com.operasoftware.OperaGX"], + ], + Opera: [["ULibDir", "Application Support", "com.operasoftware.Opera"]], + Vivaldi: [["ULibDir", "Application Support", "Vivaldi"]], + }, + linux: { + Brave: [["Home", ".config", "BraveSoftware", "Brave-Browser"]], + Chrome: [["Home", ".config", "google-chrome"]], + "Chrome Beta": [["Home", ".config", "google-chrome-beta"]], + "Chrome Dev": [["Home", ".config", "google-chrome-unstable"]], + Chromium: [ + ["Home", ".config", "chromium"], + + // If we're installed normally, we can look for Chromium installed + // as a Snap on Ubuntu Linux by looking here. + ["Home", "snap", "chromium", "common", "chromium"], + + // If we're installed as a Snap, "Home" is a special place that + // the Snap environment has given us, and the Chromium data is + // not within it. We want to, instead, start at the path set + // on the environment variable "SNAP_REAL_HOME". + // See: https://snapcraft.io/docs/environment-variables#heading--snap-real-home + [SNAP_REAL_HOME, "snap", "chromium", "common", "chromium"], + ], + // Opera GX is not available on Linux. + // Canary is not available on Linux. + // Edge is not available on Linux. + Opera: [["Home", ".config", "opera"]], + Vivaldi: [["Home", ".config", "vivaldi"]], + }, + }; + let options = SUB_DIRECTORIES[AppConstants.platform][chromeProjectName]; + if (!options) { + return null; + } + + for (let subfolders of options) { + let rootDir = subfolders[0]; + try { + let targetPath; + + if (rootDir == SNAP_REAL_HOME) { + targetPath = Services.env.get("SNAP_REAL_HOME"); + } else { + targetPath = Services.dirsvc.get(rootDir, Ci.nsIFile).path; + } + + targetPath = PathUtils.join(targetPath, ...subfolders.slice(1)); + if (await IOUtils.exists(targetPath)) { + return targetPath; + } + } catch (ex) { + // The path logic here shouldn't error, so log it: + console.error(ex); + } + } + return null; + }, + + /** + * Get the directory objects sorted by version number. + * + * @param {string} path - The path to the extension directory. + * otherwise return all file/directory object. + * @returns {Array} The file/directory object array. + */ + async _getSortedByVersionSubDirectoryNames(path) { + if (this._extensionVersionDirectoryNames[path]) { + return this._extensionVersionDirectoryNames[path]; + } + + let entries = []; + try { + for (const child of await IOUtils.getChildren(path)) { + const info = await IOUtils.stat(child); + if (info.type === "directory") { + const name = PathUtils.filename(child); + entries.push(name); + } + } + } catch (ex) { + console.error(ex); + entries = []; + } + + // The directory name is the version number string of the extension. + // For example, it could be "1.0_0", "1.0_1", "1.0_2", 1.1_0, 1.1_1, or 1.1_2. + // The "1.0_1" strings mean that the "1.0_0" directory is existed and you install the version 1.0 again. + // https://chromium.googlesource.com/chromium/src/+/0b58a813992b539a6b555ad7959adfad744b095a/chrome/common/extensions/extension_file_util_unittest.cc + entries.sort((a, b) => Services.vc.compare(b, a)); + + this._extensionVersionDirectoryNames[path] = entries; + return entries; + }, + + /** + * Convert Chrome time format to Date object. Google Chrome uses FILETIME / 10 as time. + * FILETIME is based on the same structure of Windows. + * + * @param {number} aTime Chrome time + * @param {string|number|Date} aFallbackValue a date or timestamp (valid argument + * for the Date constructor) that will be used if the chrometime value passed is + * invalid. + * @returns {Date} converted Date object + */ + chromeTimeToDate(aTime, aFallbackValue) { + // The date value may be 0 in some cases. Because of the subtraction below, + // that'd generate a date before the unix epoch, which can upset consumers + // due to the unix timestamp then being negative. Catch this case: + if (!aTime) { + return new Date(aFallbackValue); + } + return new Date((aTime * S100NS_PER_MS - S100NS_FROM1601TO1970) / 10000); + }, + + /** + * Convert Date object to Chrome time format. For details on Chrome time, see + * chromeTimeToDate. + * + * @param {Date|number} aDate Date object or integer equivalent + * @returns {number} Chrome time + */ + dateToChromeTime(aDate) { + return (aDate * 10000 + S100NS_FROM1601TO1970) / S100NS_PER_MS; + }, + + /** + * Returns an array of chromium browser ids that have importable logins. + */ + _importableLoginsCache: null, + async getImportableLogins(formOrigin) { + // Only provide importable if we actually support importing. + if (!this.supportsLoginsForPlatform) { + return undefined; + } + + // Lazily fill the cache with all importable login browsers. + if (!this._importableLoginsCache) { + this._importableLoginsCache = new Map(); + + // Just handle these chromium-based browsers for now. + for (const browserId of this.CONTEXTUAL_LOGIN_IMPORT_BROWSERS) { + // Skip if there's no profile data. + const migrator = await lazy.MigrationUtils.getMigrator(browserId); + if (!migrator) { + continue; + } + + // Check each profile for logins. + const dataPath = await migrator._getChromeUserDataPathIfExists(); + for (const profile of await migrator.getSourceProfiles()) { + const path = PathUtils.join(dataPath, profile.id, "Login Data"); + // Skip if login data is missing. + if (!(await IOUtils.exists(path))) { + console.error(`Missing file at ${path}`); + continue; + } + + try { + for (const row of await lazy.MigrationUtils.getRowsFromDBWithoutLocks( + path, + `Importable ${browserId} logins`, + `SELECT origin_url + FROM logins + WHERE blacklisted_by_user = 0` + )) { + const url = row.getString(0); + try { + // Initialize an array if it doesn't exist for the origin yet. + const origin = lazy.LoginHelper.getLoginOrigin(url); + const entries = this._importableLoginsCache.get(origin) || []; + if (!entries.length) { + this._importableLoginsCache.set(origin, entries); + } + + // Add the browser if it doesn't exist yet. + if (!entries.includes(browserId)) { + entries.push(browserId); + } + } catch (ex) { + console.error( + `Failed to process importable url ${url} from ${browserId}`, + ex + ); + } + } + } catch (ex) { + console.error( + `Failed to get importable logins from ${browserId}`, + ex + ); + } + } + } + } + return this._importableLoginsCache.get(formOrigin); + }, +}; diff --git a/browser/components/migration/ChromeProfileMigrator.sys.mjs b/browser/components/migration/ChromeProfileMigrator.sys.mjs new file mode 100644 index 0000000000..342ac3f376 --- /dev/null +++ b/browser/components/migration/ChromeProfileMigrator.sys.mjs @@ -0,0 +1,1253 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 sts=2 et */ +/* 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/. */ + +const AUTH_TYPE = { + SCHEME_HTML: 0, + SCHEME_BASIC: 1, + SCHEME_DIGEST: 2, +}; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ChromeMigrationUtils: "resource:///modules/ChromeMigrationUtils.sys.mjs", + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Qihoo360seMigrationUtils: "resource:///modules/360seMigrationUtils.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", +}); + +/** + * Converts an array of chrome bookmark objects into one our own places code + * understands. + * + * @param {object[]} items Chrome Bookmark items to be inserted on this parent + * @param {set} bookmarkURLAccumulator Accumulate all imported bookmark urls to be used for importing favicons + * @param {Function} errorAccumulator function that gets called with any errors + * thrown so we don't drop them on the floor. + * @returns {object[]} + */ +function convertBookmarks(items, bookmarkURLAccumulator, errorAccumulator) { + let itemsToInsert = []; + for (let item of items) { + try { + if (item.type == "url") { + if (item.url.trim().startsWith("chrome:")) { + // Skip invalid internal URIs. Creating an actual URI always reports + // messages to the console because Gecko has its own concept of how + // chrome:// URIs should be formed, so we avoid doing that. + continue; + } + if (item.url.trim().startsWith("edge:")) { + // Don't import internal Microsoft Edge URIs as they won't resolve within Firefox. + continue; + } + itemsToInsert.push({ url: item.url, title: item.name }); + bookmarkURLAccumulator.add({ url: item.url }); + } else if (item.type == "folder") { + let folderItem = { + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: item.name, + }; + folderItem.children = convertBookmarks( + item.children, + bookmarkURLAccumulator, + errorAccumulator + ); + itemsToInsert.push(folderItem); + } + } catch (ex) { + console.error(ex); + errorAccumulator(ex); + } + } + return itemsToInsert; +} + +/** + * Chrome profile migrator. This can also be used as a parent class for + * migrators for browsers that are variants of Chrome. + */ +export class ChromeProfileMigrator extends MigratorBase { + /** + * On Ubuntu Linux, when the browser is installed as a Snap package, + * we must request permission to read data from other browsers. We + * make that request by opening up a native file picker in folder + * selection mode and instructing the user to navigate to the folder + * that the other browser's user data resides in. + * + * For Snap packages, this gives the browser read access - but it does + * so through a temporary symlink that does not match the original user + * data path. Effectively, the user data directory is remapped to a + * temporary location on the file system. We record these remaps here, + * keyed on the original data directory. + * + * @type {Map<string, string>} + */ + #dataPathRemappings = new Map(); + + static get key() { + return "chrome"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chrome"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/chrome.png"; + } + + get _chromeUserDataPathSuffix() { + return "Chrome"; + } + + async hasPermissions() { + let dataPath = await this._getChromeUserDataPathIfExists(); + if (!dataPath) { + return true; + } + + let localStatePath = PathUtils.join(dataPath, "Local State"); + try { + // Read one byte since on snap we can check existence even without being able + // to read the file. + await IOUtils.read(localStatePath, { maxBytes: 1 }); + return true; + } catch (ex) { + console.error("No permissions for local state folder."); + } + return false; + } + + async getPermissions(win) { + // Get the original path to the user data and ignore any existing remapping. + // This allows us to set a new remapping if the user navigates the platforms + // filepicker to a different directory on a second permission request attempt. + let originalDataPath = await this._getChromeUserDataPathIfExists( + true /* noRemapping */ + ); + // Keep prompting the user until they pick something that grants us access + // to Chrome's local state directory. + while (!(await this.hasPermissions())) { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(win, "", Ci.nsIFilePicker.modeGetFolder); + fp.filterIndex = 1; + // Now wait for the filepicker to open and close. If the user picks + // the local state folder, the OS should grant us read access to everything + // inside, so we don't need to check or do anything else with what's + // returned by the filepicker. + let result = await new Promise(resolve => fp.open(resolve)); + // Bail if the user cancels the dialog: + if (result == Ci.nsIFilePicker.returnCancel) { + return false; + } + + let file = fp.file; + if (file && file.path != originalDataPath) { + this.#dataPathRemappings.set(originalDataPath, file.path); + } + } + return true; + } + + async canGetPermissions() { + if ( + !Services.prefs.getBoolPref( + "browser.migrate.chrome.get_permissions.enabled" + ) + ) { + return false; + } + + if (await MigrationUtils.canGetPermissionsOnPlatform()) { + let dataPath = await this._getChromeUserDataPathIfExists(); + if (dataPath) { + let localStatePath = PathUtils.join(dataPath, "Local State"); + if (await IOUtils.exists(localStatePath)) { + return dataPath; + } + } + } + return false; + } + + _keychainServiceName = "Chrome Safe Storage"; + + _keychainAccountName = "Chrome"; + + /** + * Returns a Promise that resolves to the data path containing the + * Local State and profile directories for this browser. + * + * @param {boolean} [noRemapping=false] + * Set to true to bypass any remapping that might have occurred on + * platforms where the data path changes once permission has been + * granted. + * @returns {Promise<string>} + */ + async _getChromeUserDataPathIfExists(noRemapping = false) { + if (this._chromeUserDataPath) { + // Skip looking up any remapping if `noRemapping` was passed. This is + // helpful if the caller needs create a new remapping and overwrite + // an old remapping, as "real" user data path is used as a key for + // the remapping. + if (noRemapping) { + return this._chromeUserDataPath; + } + + let remappedPath = this.#dataPathRemappings.get(this._chromeUserDataPath); + return remappedPath || this._chromeUserDataPath; + } + let path = await lazy.ChromeMigrationUtils.getDataPath( + this._chromeUserDataPathSuffix + ); + let exists = path && (await IOUtils.exists(path)); + if (exists) { + this._chromeUserDataPath = path; + } else { + this._chromeUserDataPath = null; + } + return this._chromeUserDataPath; + } + + async getResources(aProfile) { + if (!(await this.hasPermissions())) { + return []; + } + + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (chromeUserDataPath) { + let profileFolder = chromeUserDataPath; + if (aProfile) { + profileFolder = PathUtils.join(chromeUserDataPath, aProfile.id); + } + if (await IOUtils.exists(profileFolder)) { + let possibleResourcePromises = [ + GetBookmarksResource(profileFolder, this.constructor.key), + GetHistoryResource(profileFolder), + GetFormdataResource(profileFolder), + GetExtensionsResource(aProfile.id, this.constructor.key), + ]; + if (lazy.ChromeMigrationUtils.supportsLoginsForPlatform) { + possibleResourcePromises.push( + this._GetPasswordsResource(profileFolder), + this._GetPaymentMethodsResource(profileFolder) + ); + } + + // Some of these Promises might reject due to things like database + // corruptions. We absorb those rejections here and filter them + // out so that we only try to import the resources that don't appear + // corrupted. + let possibleResources = await Promise.allSettled( + possibleResourcePromises + ); + return possibleResources + .filter(promise => { + return promise.status == "fulfilled" && promise.value !== null; + }) + .map(promise => promise.value); + } + } + return []; + } + + async getLastUsedDate() { + let sourceProfiles = await this.getSourceProfiles(); + if (!sourceProfiles) { + return new Date(0); + } + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (!chromeUserDataPath) { + return new Date(0); + } + let datePromises = sourceProfiles.map(async profile => { + let basePath = PathUtils.join(chromeUserDataPath, profile.id); + let fileDatePromises = ["Bookmarks", "History", "Cookies"].map( + async leafName => { + let path = PathUtils.join(basePath, leafName); + let info = await IOUtils.stat(path).catch(() => null); + return info ? info.lastModified : 0; + } + ); + let dates = await Promise.all(fileDatePromises); + return Math.max(...dates); + }); + let datesOuter = await Promise.all(datePromises); + datesOuter.push(0); + return new Date(Math.max(...datesOuter)); + } + + async getSourceProfiles() { + if ("__sourceProfiles" in this) { + return this.__sourceProfiles; + } + + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (!chromeUserDataPath) { + return []; + } + + let localState; + let profiles = []; + try { + localState = await lazy.ChromeMigrationUtils.getLocalState( + this._chromeUserDataPathSuffix, + chromeUserDataPath + ); + let info_cache = localState.profile.info_cache; + for (let profileFolderName in info_cache) { + profiles.push({ + id: profileFolderName, + name: info_cache[profileFolderName].name || profileFolderName, + }); + } + } catch (e) { + // Avoid reporting NotFoundErrors from trying to get local state. + if (localState || e.name != "NotFoundError") { + console.error("Error detecting Chrome profiles: ", e); + } + + // If we didn't have permission to read the local state, return the + // empty array. The user might have the opportunity to request + // permission using `hasPermission` and `getPermission`. + if (e.name == "NotAllowedError") { + return []; + } + + // If we weren't able to detect any profiles above, fallback to the Default profile. + let defaultProfilePath = PathUtils.join(chromeUserDataPath, "Default"); + if (await IOUtils.exists(defaultProfilePath)) { + profiles = [ + { + id: "Default", + name: "Default", + }, + ]; + } + } + + let profileResources = await Promise.all( + profiles.map(async profile => ({ + profile, + resources: await this.getResources(profile), + })) + ); + + // Only list profiles from which any data can be imported + this.__sourceProfiles = profileResources + .filter(({ resources }) => { + return resources && !!resources.length; + }, this) + .map(({ profile }) => profile); + return this.__sourceProfiles; + } + + async _GetPasswordsResource(aProfileFolder) { + let loginPath = PathUtils.join(aProfileFolder, "Login Data"); + if (!(await IOUtils.exists(loginPath))) { + return null; + } + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "Login Data" + ); + await IOUtils.copy(loginPath, tempFilePath); + loginPath = tempFilePath; + } + + let { + _chromeUserDataPathSuffix, + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase = null, + } = this; + + let countQuery = `SELECT COUNT(*) FROM logins WHERE blacklisted_by_user = 0`; + + let countRows = await MigrationUtils.getRowsFromDBWithoutLocks( + loginPath, + "Chrome passwords", + countQuery + ); + + if (!countRows[0].getResultByName("COUNT(*)")) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.PASSWORDS, + + async migrate(aCallback) { + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + loginPath, + "Chrome passwords", + `SELECT origin_url, action_url, username_element, username_value, + password_element, password_value, signon_realm, scheme, date_created, + times_used FROM logins WHERE blacklisted_by_user = 0` + ) + .catch(ex => { + console.error(ex); + aCallback(false); + }) + .finally(() => { + return tempFilePath && IOUtils.remove(tempFilePath); + }); + + // If the promise was rejected we will have already called aCallback, + // so we can just return here. + if (!rows) { + return; + } + + // If there are no relevant rows, return before initializing crypto and + // thus prompting for Keychain access on macOS. + if (!rows.length) { + aCallback(true); + return; + } + + let crypto; + try { + if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + crypto = new ChromeWindowsLoginCrypto(_chromeUserDataPathSuffix); + } else if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + crypto = new ChromeMacOSLoginCrypto( + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase + ); + } else { + aCallback(false); + return; + } + } catch (ex) { + // Handle the user canceling Keychain access or other OSCrypto errors. + console.error(ex); + aCallback(false); + return; + } + + let logins = []; + let fallbackCreationDate = new Date(); + for (let row of rows) { + try { + let origin_url = lazy.NetUtil.newURI( + row.getResultByName("origin_url") + ); + // Ignore entries for non-http(s)/ftp URLs because we likely can't + // use them anyway. + const kValidSchemes = new Set(["https", "http", "ftp"]); + if (!kValidSchemes.has(origin_url.scheme)) { + continue; + } + let loginInfo = { + username: row.getResultByName("username_value"), + password: await crypto.decryptData( + row.getResultByName("password_value"), + null + ), + origin: origin_url.prePath, + formActionOrigin: null, + httpRealm: null, + usernameElement: row.getResultByName("username_element"), + passwordElement: row.getResultByName("password_element"), + timeCreated: lazy.ChromeMigrationUtils.chromeTimeToDate( + row.getResultByName("date_created") + 0, + fallbackCreationDate + ).getTime(), + timesUsed: row.getResultByName("times_used") + 0, + }; + + switch (row.getResultByName("scheme")) { + case AUTH_TYPE.SCHEME_HTML: + let action_url = row.getResultByName("action_url"); + if (!action_url) { + // If there is no action_url, store the wildcard "" value. + // See the `formActionOrigin` IDL comments. + loginInfo.formActionOrigin = ""; + break; + } + let action_uri = lazy.NetUtil.newURI(action_url); + if (!kValidSchemes.has(action_uri.scheme)) { + continue; // This continues the outer for loop. + } + loginInfo.formActionOrigin = action_uri.prePath; + break; + case AUTH_TYPE.SCHEME_BASIC: + case AUTH_TYPE.SCHEME_DIGEST: + // signon_realm format is URIrealm, so we need remove URI + loginInfo.httpRealm = row + .getResultByName("signon_realm") + .substring(loginInfo.origin.length + 1); + break; + default: + throw new Error( + "Login data scheme type not supported: " + + row.getResultByName("scheme") + ); + } + logins.push(loginInfo); + } catch (e) { + console.error(e); + } + } + try { + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + } catch (e) { + console.error(e); + } + if (crypto.finalize) { + crypto.finalize(); + } + aCallback(true); + }, + }; + } + async _GetPaymentMethodsResource(aProfileFolder) { + if ( + !Services.prefs.getBoolPref( + "browser.migrate.chrome.payment_methods.enabled", + false + ) + ) { + return null; + } + + let paymentMethodsPath = PathUtils.join(aProfileFolder, "Web Data"); + + if (!(await IOUtils.exists(paymentMethodsPath))) { + return null; + } + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "Web Data" + ); + await IOUtils.copy(paymentMethodsPath, tempFilePath); + paymentMethodsPath = tempFilePath; + } + + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + paymentMethodsPath, + "Chrome Credit Cards", + "SELECT name_on_card, card_number_encrypted, expiration_month, expiration_year FROM credit_cards" + ) + .catch(ex => { + console.error(ex); + }) + .finally(() => { + return tempFilePath && IOUtils.remove(tempFilePath); + }); + + if (!rows?.length) { + return null; + } + + let { + _chromeUserDataPathSuffix, + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase = null, + } = this; + + return { + type: MigrationUtils.resourceTypes.PAYMENT_METHODS, + + async migrate(aCallback) { + let crypto; + try { + if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + crypto = new ChromeWindowsLoginCrypto(_chromeUserDataPathSuffix); + } else if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + crypto = new ChromeMacOSLoginCrypto( + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase + ); + } else { + aCallback(false); + return; + } + } catch (ex) { + // Handle the user canceling Keychain access or other OSCrypto errors. + console.error(ex); + aCallback(false); + return; + } + + let cards = []; + for (let row of rows) { + cards.push({ + "cc-name": row.getResultByName("name_on_card"), + "cc-number": await crypto.decryptData( + row.getResultByName("card_number_encrypted"), + null + ), + "cc-exp-month": parseInt( + row.getResultByName("expiration_month"), + 10 + ), + "cc-exp-year": parseInt(row.getResultByName("expiration_year"), 10), + }); + } + + await MigrationUtils.insertCreditCardsWrapper(cards); + aCallback(true); + }, + }; + } +} + +async function GetBookmarksResource(aProfileFolder, aBrowserKey) { + let bookmarksPath = PathUtils.join(aProfileFolder, "Bookmarks"); + let faviconsPath = PathUtils.join(aProfileFolder, "Favicons"); + + if (aBrowserKey === "chromium-360se") { + let localState = {}; + try { + localState = await lazy.ChromeMigrationUtils.getLocalState("360 SE"); + } catch (ex) { + console.error(ex); + } + + let alternativeBookmarks = + await lazy.Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath, + localState, + }); + if (alternativeBookmarks.resource) { + return alternativeBookmarks.resource; + } + + bookmarksPath = alternativeBookmarks.path; + } + + if (!(await IOUtils.exists(bookmarksPath))) { + return null; + } + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "Favicons" + ); + await IOUtils.copy(faviconsPath, tempFilePath); + faviconsPath = tempFilePath; + } + + // check to read JSON bookmarks structure and see if any bookmarks exist else return null + // Parse Chrome bookmark file that is JSON format + let bookmarkJSON = await IOUtils.readJSON(bookmarksPath); + let other = bookmarkJSON.roots.other.children.length; + let bookmarkBar = bookmarkJSON.roots.bookmark_bar.children.length; + let synced = bookmarkJSON.roots.synced.children.length; + + if (!other && !bookmarkBar && !synced) { + return null; + } + return { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate(aCallback) { + return (async function () { + let gotErrors = false; + let errorGatherer = function () { + gotErrors = true; + }; + + let faviconRows = []; + try { + faviconRows = await MigrationUtils.getRowsFromDBWithoutLocks( + faviconsPath, + "Chrome Bookmark Favicons", + `select fav.id, fav.url, map.page_url, bit.image_data FROM favicons as fav + INNER JOIN favicon_bitmaps bit ON (fav.id = bit.icon_id) + INNER JOIN icon_mapping map ON (map.icon_id = bit.icon_id)` + ); + } catch (ex) { + console.error(ex); + } finally { + if (tempFilePath) { + await IOUtils.remove(tempFilePath); + } + } + + // Create Hashmap for favicons + let faviconMap = new Map(); + for (let faviconRow of faviconRows) { + // First, try to normalize the URI: + try { + let uri = lazy.NetUtil.newURI( + faviconRow.getResultByName("page_url") + ); + faviconMap.set(uri.spec, { + faviconData: faviconRow.getResultByName("image_data"), + uri, + }); + } catch (e) { + // Couldn't parse the URI, so just skip it. + continue; + } + } + + let roots = bookmarkJSON.roots; + let bookmarkURLAccumulator = new Set(); + + // Importing bookmark bar items + if (roots.bookmark_bar.children && roots.bookmark_bar.children.length) { + // Toolbar + let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + let bookmarks = convertBookmarks( + roots.bookmark_bar.children, + bookmarkURLAccumulator, + errorGatherer + ); + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + + // Importing Other Bookmarks items + if (roots.other.children && roots.other.children.length) { + // Other Bookmarks + let parentGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + let bookmarks = convertBookmarks( + roots.other.children, + bookmarkURLAccumulator, + errorGatherer + ); + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + + // Importing synced Bookmarks items + if (roots.synced.children && roots.synced.children.length) { + // Synced Bookmarks + let parentGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + let bookmarks = convertBookmarks( + roots.synced.children, + bookmarkURLAccumulator, + errorGatherer + ); + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + + // Find all favicons with associated bookmarks + let favicons = []; + for (let bookmark of bookmarkURLAccumulator) { + try { + let uri = lazy.NetUtil.newURI(bookmark.url); + let favicon = faviconMap.get(uri.spec); + if (favicon) { + favicons.push(favicon); + } + } catch (e) { + // Couldn't parse the bookmark URI, so just skip + continue; + } + } + + // Import Bookmark Favicons + MigrationUtils.insertManyFavicons(favicons); + if (gotErrors) { + throw new Error("The migration included errors."); + } + })().then( + () => aCallback(true), + () => aCallback(false) + ); + }, + }; +} + +async function GetHistoryResource(aProfileFolder) { + let historyPath = PathUtils.join(aProfileFolder, "History"); + if (!(await IOUtils.exists(historyPath))) { + return null; + } + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile(PathUtils.tempDir, "History"); + await IOUtils.copy(historyPath, tempFilePath); + historyPath = tempFilePath; + } + + let countQuery = "SELECT COUNT(*) FROM urls WHERE hidden = 0"; + + let countRows = await MigrationUtils.getRowsFromDBWithoutLocks( + historyPath, + "Chrome history", + countQuery + ); + if (!countRows[0].getResultByName("COUNT(*)")) { + return null; + } + return { + type: MigrationUtils.resourceTypes.HISTORY, + + migrate(aCallback) { + (async function () { + const LIMIT = Services.prefs.getIntPref( + "browser.migrate.chrome.history.limit" + ); + + let query = + "SELECT url, title, last_visit_time, typed_count FROM urls WHERE hidden = 0"; + let maxAge = lazy.ChromeMigrationUtils.dateToChromeTime( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + query += " AND last_visit_time > " + maxAge; + + if (LIMIT) { + query += " ORDER BY last_visit_time DESC LIMIT " + LIMIT; + } + + let rows; + try { + rows = await MigrationUtils.getRowsFromDBWithoutLocks( + historyPath, + "Chrome history", + query + ); + } finally { + if (tempFilePath) { + await IOUtils.remove(tempFilePath); + } + } + + let pageInfos = []; + let fallbackVisitDate = new Date(); + for (let row of rows) { + try { + // if having typed_count, we changes transition type to typed. + let transition = lazy.PlacesUtils.history.TRANSITIONS.LINK; + if (row.getResultByName("typed_count") > 0) { + transition = lazy.PlacesUtils.history.TRANSITIONS.TYPED; + } + + pageInfos.push({ + title: row.getResultByName("title"), + url: new URL(row.getResultByName("url")), + visits: [ + { + transition, + date: lazy.ChromeMigrationUtils.chromeTimeToDate( + row.getResultByName("last_visit_time"), + fallbackVisitDate + ), + }, + ], + }); + } catch (e) { + console.error(e); + } + } + + if (pageInfos.length) { + await MigrationUtils.insertVisitsWrapper(pageInfos); + } + })().then( + () => { + aCallback(true); + }, + ex => { + console.error(ex); + aCallback(false); + } + ); + }, + }; +} + +async function GetFormdataResource(aProfileFolder) { + let formdataPath = PathUtils.join(aProfileFolder, "Web Data"); + if (!(await IOUtils.exists(formdataPath))) { + return null; + } + let countQuery = "SELECT COUNT(*) FROM autofill"; + + let tempFilePath = null; + if (MigrationUtils.IS_LINUX_SNAP_PACKAGE) { + tempFilePath = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "Web Data" + ); + await IOUtils.copy(formdataPath, tempFilePath); + formdataPath = tempFilePath; + } + + let countRows = await MigrationUtils.getRowsFromDBWithoutLocks( + formdataPath, + "Chrome formdata", + countQuery + ); + if (!countRows[0].getResultByName("COUNT(*)")) { + return null; + } + return { + type: MigrationUtils.resourceTypes.FORMDATA, + + async migrate(aCallback) { + let query = + "SELECT name, value, count, date_created, date_last_used FROM autofill"; + let rows; + + try { + rows = await MigrationUtils.getRowsFromDBWithoutLocks( + formdataPath, + "Chrome formdata", + query + ); + } finally { + if (tempFilePath) { + await IOUtils.remove(tempFilePath); + } + } + + let addOps = []; + for (let row of rows) { + try { + let fieldname = row.getResultByName("name"); + let value = row.getResultByName("value"); + if (fieldname && value) { + addOps.push({ + op: "add", + fieldname, + value, + timesUsed: row.getResultByName("count"), + firstUsed: row.getResultByName("date_created") * 1000, + lastUsed: row.getResultByName("date_last_used") * 1000, + }); + } + } catch (e) { + console.error(e); + } + } + + try { + await lazy.FormHistory.update(addOps); + } catch (e) { + console.error(e); + aCallback(false); + return; + } + + aCallback(true); + }, + }; +} + +async function GetExtensionsResource(aProfileId, aBrowserKey = "chrome") { + if ( + !Services.prefs.getBoolPref( + "browser.migrate.chrome.extensions.enabled", + false + ) + ) { + return null; + } + let extensions = await lazy.ChromeMigrationUtils.getExtensionList(aProfileId); + if (!extensions.length || aBrowserKey !== "chrome") { + return null; + } + + return { + type: MigrationUtils.resourceTypes.EXTENSIONS, + async migrate(callback) { + let ids = extensions.map(extension => extension.id); + let [progressValue, importedExtensions] = + await MigrationUtils.installExtensionsWrapper(aBrowserKey, ids); + let details = { + progressValue, + totalExtensions: extensions, + importedExtensions, + }; + if ( + progressValue == lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO || + progressValue == lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS + ) { + callback(true, details); + } else { + callback(false); + } + }, + }; +} + +/** + * Chromium migrator + */ +export class ChromiumProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/chromium.png"; + } + + _chromeUserDataPathSuffix = "Chromium"; + _keychainServiceName = "Chromium Safe Storage"; + _keychainAccountName = "Chromium"; +} + +/** + * Chrome Canary + * Not available on Linux + */ +export class CanaryProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "canary"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-canary"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/canary.png"; + } + + get _chromeUserDataPathSuffix() { + return "Canary"; + } + + get _keychainServiceName() { + return "Chromium Safe Storage"; + } + + get _keychainAccountName() { + return "Chromium"; + } +} + +/** + * Chrome Dev - Linux only (not available in Mac and Windows) + */ +export class ChromeDevMigrator extends ChromeProfileMigrator { + static get key() { + return "chrome-dev"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chrome-dev"; + } + + _chromeUserDataPathSuffix = "Chrome Dev"; + _keychainServiceName = "Chromium Safe Storage"; + _keychainAccountName = "Chromium"; +} + +/** + * Chrome Beta migrator + */ +export class ChromeBetaMigrator extends ChromeProfileMigrator { + static get key() { + return "chrome-beta"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chrome-beta"; + } + + _chromeUserDataPathSuffix = "Chrome Beta"; + _keychainServiceName = "Chromium Safe Storage"; + _keychainAccountName = "Chromium"; +} + +/** + * Brave migrator + */ +export class BraveProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "brave"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-brave"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/brave.png"; + } + + _chromeUserDataPathSuffix = "Brave"; + _keychainServiceName = "Brave Browser Safe Storage"; + _keychainAccountName = "Brave Browser"; +} + +/** + * Edge (Chromium-based) migrator + */ +export class ChromiumEdgeMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium-edge"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium-edge"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/edge.png"; + } + + _chromeUserDataPathSuffix = "Edge"; + _keychainServiceName = "Microsoft Edge Safe Storage"; + _keychainAccountName = "Microsoft Edge"; +} + +/** + * Edge Beta (Chromium-based) migrator + */ +export class ChromiumEdgeBetaMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium-edge-beta"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium-edge-beta"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/edgebeta.png"; + } + + _chromeUserDataPathSuffix = "Edge Beta"; + _keychainServiceName = "Microsoft Edge Safe Storage"; + _keychainAccountName = "Microsoft Edge"; +} + +/** + * Chromium 360 migrator + */ +export class Chromium360seMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium-360se"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium-360se"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/360.png"; + } + + _chromeUserDataPathSuffix = "360 SE"; + _keychainServiceName = "Microsoft Edge Safe Storage"; + _keychainAccountName = "Microsoft Edge"; +} + +/** + * Opera migrator + */ +export class OperaProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "opera"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-opera"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/opera.png"; + } + + _chromeUserDataPathSuffix = "Opera"; + _keychainServiceName = "Opera Safe Storage"; + _keychainAccountName = "Opera"; + + getSourceProfiles() { + return null; + } +} + +/** + * Opera GX migrator + */ +export class OperaGXProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "opera-gx"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-opera-gx"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/operagx.png"; + } + + _chromeUserDataPathSuffix = "Opera GX"; + _keychainServiceName = "Opera Safe Storage"; + _keychainAccountName = "Opera"; + + getSourceProfiles() { + return null; + } +} + +/** + * Vivaldi migrator + */ +export class VivaldiProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "vivaldi"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-vivaldi"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/vivaldi.png"; + } + + _chromeUserDataPathSuffix = "Vivaldi"; + _keychainServiceName = "Vivaldi Safe Storage"; + _keychainAccountName = "Vivaldi"; +} diff --git a/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs b/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs new file mode 100644 index 0000000000..4d11eb5fa4 --- /dev/null +++ b/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs @@ -0,0 +1,175 @@ +/* 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/. */ + +/** + * Class to handle encryption and decryption of logins stored in Chrome/Chromium + * on Windows. + */ + +import { ChromeMigrationUtils } from "resource:///modules/ChromeMigrationUtils.sys.mjs"; + +import { OSCrypto } from "resource://gre/modules/OSCrypto_win.sys.mjs"; + +/** + * These constants should match those from Chromium. + * + * @see https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_win.cc + */ +const AEAD_KEY_LENGTH = 256 / 8; +const ALGORITHM_NAME = "AES-GCM"; +const DPAPI_KEY_PREFIX = "DPAPI"; +const ENCRYPTION_VERSION_PREFIX = "v10"; +const NONCE_LENGTH = 96 / 8; + +const gTextDecoder = new TextDecoder(); +const gTextEncoder = new TextEncoder(); + +/** + * Instances of this class have a shape similar to OSCrypto so it can be dropped + * into code which uses that. The algorithms here are + * specific to what is needed for Chrome login storage on Windows. + */ +export class ChromeWindowsLoginCrypto { + /** + * @param {string} userDataPathSuffix The unique identifier for the variant of + * Chrome that is having its logins imported. These are the keys in the + * SUB_DIRECTORIES object in ChromeMigrationUtils.getDataPath. + */ + constructor(userDataPathSuffix) { + this.osCrypto = new OSCrypto(); + + // Lazily decrypt the key from "Chrome"s local state using OSCrypto and save + // it as the master key to decrypt or encrypt passwords. + ChromeUtils.defineLazyGetter(this, "_keyPromise", async () => { + let keyData; + try { + // NB: For testing, allow directory service to be faked before getting. + const localState = await ChromeMigrationUtils.getLocalState( + userDataPathSuffix + ); + const withHeader = atob(localState.os_crypt.encrypted_key); + if (!withHeader.startsWith(DPAPI_KEY_PREFIX)) { + throw new Error("Invalid key format"); + } + const encryptedKey = withHeader.slice(DPAPI_KEY_PREFIX.length); + keyData = this.osCrypto.decryptData(encryptedKey, null, "bytes"); + } catch (ex) { + console.error(`${userDataPathSuffix} os_crypt key:`, ex); + + // Use a generic key that will fail for actually encrypted data, but for + // testing it'll be consistent for both encrypting and decrypting. + keyData = AEAD_KEY_LENGTH; + } + return crypto.subtle.importKey( + "raw", + new Uint8Array(keyData), + ALGORITHM_NAME, + false, + ["decrypt", "encrypt"] + ); + }); + } + + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this.osCrypto.finalize(); + } + + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + } + + stringToArray(binary_string) { + const len = binary_string.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; + } + + /** + * @param {string} ciphertext ciphertext optionally prefixed by the encryption version + * (see ENCRYPTION_VERSION_PREFIX). + * @returns {string} plaintext password + */ + async decryptData(ciphertext) { + const ciphertextString = this.arrayToString(ciphertext); + return ciphertextString.startsWith(ENCRYPTION_VERSION_PREFIX) + ? this._decryptV10(ciphertext) + : this._decryptUnversioned(ciphertextString); + } + + async _decryptUnversioned(ciphertext) { + return this.osCrypto.decryptData(ciphertext); + } + + async _decryptV10(ciphertext) { + const key = await this._keyPromise; + if (!key) { + throw new Error("Cannot decrypt without a key"); + } + + // Split the nonce/iv from the rest of the encrypted value and decrypt. + const nonceIndex = ENCRYPTION_VERSION_PREFIX.length; + const cipherIndex = nonceIndex + NONCE_LENGTH; + const iv = new Uint8Array(ciphertext.slice(nonceIndex, cipherIndex)); + const algorithm = { + name: ALGORITHM_NAME, + iv, + }; + const cipherArray = new Uint8Array(ciphertext.slice(cipherIndex)); + const plaintext = await crypto.subtle.decrypt(algorithm, key, cipherArray); + return gTextDecoder.decode(new Uint8Array(plaintext)); + } + + /** + * @param {USVString} plaintext to encrypt + * @param {?string} version to encrypt default unversioned + * @returns {string} encrypted string consisting of UTF-16 code units prefixed + * by the ENCRYPTION_VERSION_PREFIX. + */ + async encryptData(plaintext, version = undefined) { + return version === ENCRYPTION_VERSION_PREFIX + ? this._encryptV10(plaintext) + : this._encryptUnversioned(plaintext); + } + + async _encryptUnversioned(plaintext) { + return this.osCrypto.encryptData(plaintext); + } + + async _encryptV10(plaintext) { + const key = await this._keyPromise; + if (!key) { + throw new Error("Cannot encrypt without a key"); + } + + // Encrypt and concatenate the prefix, nonce/iv and encrypted value. + const iv = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); + const algorithm = { + name: ALGORITHM_NAME, + iv, + }; + const plainArray = gTextEncoder.encode(plaintext); + const ciphertext = await crypto.subtle.encrypt(algorithm, key, plainArray); + return ( + ENCRYPTION_VERSION_PREFIX + + this.arrayToString(iv) + + this.arrayToString(new Uint8Array(ciphertext)) + ); + } +} diff --git a/browser/components/migration/ESEDBReader.sys.mjs b/browser/components/migration/ESEDBReader.sys.mjs new file mode 100644 index 0000000000..479bc2fb9b --- /dev/null +++ b/browser/components/migration/ESEDBReader.sys.mjs @@ -0,0 +1,799 @@ +/* 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/. */ + +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevelPref: "browser.esedbreader.loglevel", + prefix: "ESEDBReader", + }; + return new ConsoleAPI(consoleOptions); +}); + +// We have a globally unique identifier for ESE instances. A new one +// is used for each different database opened. +let gESEInstanceCounter = 0; + +// We limit the length of strings that we read from databases. +const MAX_STR_LENGTH = 64 * 1024; + +// Kernel-related types: +export const KERNEL = {}; + +KERNEL.FILETIME = new ctypes.StructType("FILETIME", [ + { dwLowDateTime: ctypes.uint32_t }, + { dwHighDateTime: ctypes.uint32_t }, +]); +KERNEL.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [ + { wYear: ctypes.uint16_t }, + { wMonth: ctypes.uint16_t }, + { wDayOfWeek: ctypes.uint16_t }, + { wDay: ctypes.uint16_t }, + { wHour: ctypes.uint16_t }, + { wMinute: ctypes.uint16_t }, + { wSecond: ctypes.uint16_t }, + { wMilliseconds: ctypes.uint16_t }, +]); + +// DB column types, cribbed from the ESE header +export var COLUMN_TYPES = { + JET_coltypBit: 1 /* True, False, or NULL */, + JET_coltypUnsignedByte: 2 /* 1-byte integer, unsigned */, + JET_coltypShort: 3 /* 2-byte integer, signed */, + JET_coltypLong: 4 /* 4-byte integer, signed */, + JET_coltypCurrency: 5 /* 8 byte integer, signed */, + JET_coltypIEEESingle: 6 /* 4-byte IEEE single precision */, + JET_coltypIEEEDouble: 7 /* 8-byte IEEE double precision */, + JET_coltypDateTime: 8 /* Integral date, fractional time */, + JET_coltypBinary: 9 /* Binary data, < 255 bytes */, + JET_coltypText: 10 /* ANSI text, case insensitive, < 255 bytes */, + JET_coltypLongBinary: 11 /* Binary data, long value */, + JET_coltypLongText: 12 /* ANSI text, long value */, + + JET_coltypUnsignedLong: 14 /* 4-byte unsigned integer */, + JET_coltypLongLong: 15 /* 8-byte signed integer */, + JET_coltypGUID: 16 /* 16-byte globally unique identifier */, +}; + +// Not very efficient, but only used for error messages +function getColTypeName(numericValue) { + return ( + Object.keys(COLUMN_TYPES).find(t => COLUMN_TYPES[t] == numericValue) || + "unknown" + ); +} + +// All type constants and method wrappers go on this object: +export const ESE = {}; + +ESE.JET_ERR = ctypes.long; +ESE.JET_PCWSTR = ctypes.char16_t.ptr; +// The ESE header calls this JET_API_PTR, but because it isn't ever used as a +// pointer, I opted for a different name. +// Note that this is defined differently on 32 vs. 64-bit in the header. +ESE.JET_API_ITEM = + ctypes.voidptr_t.size == 4 ? ctypes.unsigned_long : ctypes.uint64_t; +ESE.JET_INSTANCE = ESE.JET_API_ITEM; +ESE.JET_SESID = ESE.JET_API_ITEM; +ESE.JET_TABLEID = ESE.JET_API_ITEM; +ESE.JET_COLUMNID = ctypes.unsigned_long; +ESE.JET_GRBIT = ctypes.unsigned_long; +ESE.JET_COLTYP = ctypes.unsigned_long; +ESE.JET_DBID = ctypes.unsigned_long; + +ESE.JET_COLUMNDEF = new ctypes.StructType("JET_COLUMNDEF", [ + { cbStruct: ctypes.unsigned_long }, + { columnid: ESE.JET_COLUMNID }, + { coltyp: ESE.JET_COLTYP }, + { wCountry: ctypes.unsigned_short }, // sepcifies the country/region for the column definition + { langid: ctypes.unsigned_short }, + { cp: ctypes.unsigned_short }, + { wCollate: ctypes.unsigned_short } /* Must be 0 */, + { cbMax: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, +]); + +// Track open databases +let gOpenDBs = new Map(); + +// Track open libraries +export let gLibs = {}; + +function convertESEError(errorCode) { + switch (errorCode) { + case -1213 /* JET_errPageSizeMismatch */: + case -1002 /* JET_errInvalidName*/: + case -1507 /* JET_errColumnNotFound */: + // The DB format has changed and we haven't updated this migration code: + return "The database format has changed, error code: " + errorCode; + case -1032 /* JET_errFileAccessDenied */: + case -1207 /* JET_errDatabaseLocked */: + case -1302 /* JET_errTableLocked */: + return "The database or table is locked, error code: " + errorCode; + case -1305 /* JET_errObjectNotFound */: + return "The table/object was not found."; + case -1809 /* JET_errPermissionDenied*/: + case -1907 /* JET_errAccessDenied */: + return "Access or permission denied, error code: " + errorCode; + case -1044 /* JET_errInvalidFilename */: + return "Invalid file name"; + case -1811 /* JET_errFileNotFound */: + return "File not found"; + case -550 /* JET_errDatabaseDirtyShutdown */: + return "Database in dirty shutdown state (without the requisite logs?)"; + case -514 /* JET_errBadLogVersion */: + return "Database log version does not match the version of ESE in use."; + default: + return "Unknown error: " + errorCode; + } +} + +function handleESEError( + method, + methodName, + shouldThrow = true, + errorLog = true +) { + return function () { + let rv; + try { + rv = method.apply(null, arguments); + } catch (ex) { + lazy.log.error("Error calling into ctypes method", methodName, ex); + throw ex; + } + let resultCode = parseInt(rv.toString(10), 10); + if (resultCode < 0) { + if (errorLog) { + lazy.log.error("Got error " + resultCode + " calling " + methodName); + } + if (shouldThrow) { + throw new Error(convertESEError(rv)); + } + } else if (resultCode > 0 && errorLog) { + lazy.log.warn("Got warning " + resultCode + " calling " + methodName); + } + return resultCode; + }; +} + +export function declareESEFunction(methodName, ...args) { + let declaration = ["Jet" + methodName, ctypes.winapi_abi, ESE.JET_ERR].concat( + args + ); + let ctypeMethod = gLibs.ese.declare.apply(gLibs.ese, declaration); + ESE[methodName] = handleESEError(ctypeMethod, methodName); + ESE["FailSafe" + methodName] = handleESEError(ctypeMethod, methodName, false); + ESE["Manual" + methodName] = handleESEError( + ctypeMethod, + methodName, + false, + false + ); +} + +function declareESEFunctions() { + declareESEFunction( + "GetDatabaseFileInfoW", + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long + ); + + declareESEFunction( + "GetSystemParameterW", + ESE.JET_INSTANCE, + ESE.JET_SESID, + ctypes.unsigned_long, + ESE.JET_API_ITEM.ptr, + ESE.JET_PCWSTR, + ctypes.unsigned_long + ); + declareESEFunction( + "SetSystemParameterW", + ESE.JET_INSTANCE.ptr, + ESE.JET_SESID, + ctypes.unsigned_long, + ESE.JET_API_ITEM, + ESE.JET_PCWSTR + ); + declareESEFunction("CreateInstanceW", ESE.JET_INSTANCE.ptr, ESE.JET_PCWSTR); + declareESEFunction("Init", ESE.JET_INSTANCE.ptr); + + declareESEFunction( + "BeginSessionW", + ESE.JET_INSTANCE, + ESE.JET_SESID.ptr, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR + ); + declareESEFunction( + "AttachDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_GRBIT + ); + declareESEFunction("DetachDatabaseW", ESE.JET_SESID, ESE.JET_PCWSTR); + declareESEFunction( + "OpenDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ESE.JET_DBID.ptr, + ESE.JET_GRBIT + ); + declareESEFunction( + "OpenTableW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ESE.JET_GRBIT, + ESE.JET_TABLEID.ptr + ); + + declareESEFunction( + "GetColumnInfoW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long + ); + + declareESEFunction( + "Move", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.long, + ESE.JET_GRBIT + ); + + declareESEFunction( + "RetrieveColumn", + ESE.JET_SESID, + ESE.JET_TABLEID, + ESE.JET_COLUMNID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long.ptr, + ESE.JET_GRBIT, + ctypes.voidptr_t + ); + + declareESEFunction("CloseTable", ESE.JET_SESID, ESE.JET_TABLEID); + declareESEFunction( + "CloseDatabase", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_GRBIT + ); + + declareESEFunction("EndSession", ESE.JET_SESID, ESE.JET_GRBIT); + + declareESEFunction("Term", ESE.JET_INSTANCE); +} + +function unloadLibraries() { + lazy.log.debug("Unloading"); + if (gOpenDBs.size) { + lazy.log.error("Shouldn't unload libraries before DBs are closed!"); + for (let db of gOpenDBs.values()) { + db._close(); + } + } + for (let k of Object.keys(ESE)) { + delete ESE[k]; + } + gLibs.ese.close(); + gLibs.kernel.close(); + delete gLibs.ese; + delete gLibs.kernel; +} + +export function loadLibraries() { + Services.obs.addObserver(unloadLibraries, "xpcom-shutdown"); + gLibs.ese = ctypes.open("esent.dll"); + gLibs.kernel = ctypes.open("kernel32.dll"); + KERNEL.FileTimeToSystemTime = gLibs.kernel.declare( + "FileTimeToSystemTime", + ctypes.winapi_abi, + ctypes.int, + KERNEL.FILETIME.ptr, + KERNEL.SYSTEMTIME.ptr + ); + + declareESEFunctions(); +} + +function ESEDB(rootPath, dbPath, logPath) { + lazy.log.info("Created db"); + this.rootPath = rootPath; + this.dbPath = dbPath; + this.logPath = logPath; + this._references = 0; + this._init(); +} + +ESEDB.prototype = { + rootPath: null, + dbPath: null, + logPath: null, + _opened: false, + _attached: false, + _sessionCreated: false, + _instanceCreated: false, + _dbId: null, + _sessionId: null, + _instanceId: null, + + _init() { + if (!gLibs.ese) { + loadLibraries(); + } + this.incrementReferenceCounter(); + this._internalOpen(); + }, + + _internalOpen() { + try { + let dbinfo = new ctypes.unsigned_long(); + ESE.GetDatabaseFileInfoW( + this.dbPath, + dbinfo.address(), + ctypes.unsigned_long.size, + 17 + ); + + let pageSize = ctypes.UInt64.lo(dbinfo.value); + ESE.SetSystemParameterW( + null, + 0, + 64 /* JET_paramDatabasePageSize*/, + pageSize, + null + ); + + this._instanceId = new ESE.JET_INSTANCE(); + ESE.CreateInstanceW( + this._instanceId.address(), + "firefox-dbreader-" + gESEInstanceCounter++ + ); + this._instanceCreated = true; + + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 0 /* JET_paramSystemPath*/, + 0, + this.rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 1 /* JET_paramTempPath */, + 0, + this.rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 2 /* JET_paramLogFilePath*/, + 0, + this.logPath + ); + + // Shouldn't try to call JetTerm if the following call fails. + this._instanceCreated = false; + ESE.Init(this._instanceId.address()); + this._instanceCreated = true; + this._sessionId = new ESE.JET_SESID(); + ESE.BeginSessionW( + this._instanceId, + this._sessionId.address(), + null, + null + ); + this._sessionCreated = true; + + const JET_bitDbReadOnly = 1; + ESE.AttachDatabaseW(this._sessionId, this.dbPath, JET_bitDbReadOnly); + this._attached = true; + this._dbId = new ESE.JET_DBID(); + ESE.OpenDatabaseW( + this._sessionId, + this.dbPath, + null, + this._dbId.address(), + JET_bitDbReadOnly + ); + this._opened = true; + } catch (ex) { + try { + this._close(); + } catch (innerException) { + console.error(innerException); + } + // Make sure caller knows we failed. + throw ex; + } + gOpenDBs.set(this.dbPath, this); + }, + + checkForColumn(tableName, columnName) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let columnInfo; + try { + columnInfo = this._getColumnInfo(tableName, [{ name: columnName }]); + } catch (ex) { + return null; + } + return columnInfo[0]; + }, + + tableExists(tableName) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let tableId = new ESE.JET_TABLEID(); + let rv = ESE.ManualOpenTableW( + this._sessionId, + this._dbId, + tableName, + null, + 0, + 4 /* JET_bitTableReadOnly */, + tableId.address() + ); + if (rv == -1305 /* JET_errObjectNotFound */) { + return false; + } + if (rv < 0) { + lazy.log.error("Got error " + rv + " calling OpenTableW"); + throw new Error(convertESEError(rv)); + } + + if (rv > 0) { + lazy.log.error("Got warning " + rv + " calling OpenTableW"); + } + ESE.FailSafeCloseTable(this._sessionId, tableId); + return true; + }, + + *tableItems(tableName, columns) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let tableOpened = false; + let tableId; + try { + tableId = this._openTable(tableName); + tableOpened = true; + + let columnInfo = this._getColumnInfo(tableName, columns); + + let rv = ESE.ManualMove( + this._sessionId, + tableId, + -2147483648 /* JET_MoveFirst */, + 0 + ); + if (rv == -1603 /* JET_errNoCurrentRecord */) { + // There are no rows in the table. + this._closeTable(tableId); + return; + } + if (rv != 0) { + throw new Error(convertESEError(rv)); + } + + do { + let rowContents = {}; + for (let column of columnInfo) { + let [buffer, bufferSize] = this._getBufferForColumn(column); + // We handle errors manually so we accurately deal with NULL values. + let err = ESE.ManualRetrieveColumn( + this._sessionId, + tableId, + column.id, + buffer.address(), + bufferSize, + null, + 0, + null + ); + rowContents[column.name] = this._convertResult(column, buffer, err); + } + yield rowContents; + } while ( + ESE.ManualMove(this._sessionId, tableId, 1 /* JET_MoveNext */, 0) === 0 + ); + } catch (ex) { + if (tableOpened) { + this._closeTable(tableId); + } + throw ex; + } + this._closeTable(tableId); + }, + + _openTable(tableName) { + let tableId = new ESE.JET_TABLEID(); + ESE.OpenTableW( + this._sessionId, + this._dbId, + tableName, + null, + 0, + 4 /* JET_bitTableReadOnly */, + tableId.address() + ); + return tableId; + }, + + _getBufferForColumn(column) { + let buffer; + if (column.type == "string") { + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + // size on the column is in bytes, 2 bytes to a wchar, so: + let charCount = column.dbSize >> 1; + buffer = new wchar_tArray(charCount); + } else if (column.type == "boolean") { + buffer = new ctypes.uint8_t(); + } else if (column.type == "date") { + buffer = new KERNEL.FILETIME(); + } else if (column.type == "guid") { + let byteArray = ctypes.ArrayType(ctypes.uint8_t); + buffer = new byteArray(column.dbSize); + } else { + throw new Error("Unknown type " + column.type); + } + return [buffer, buffer.constructor.size]; + }, + + _convertResult(column, buffer, err) { + if (err != 0) { + if (err == 1004) { + // Deal with null values: + buffer = null; + } else { + console.error( + "Unexpected JET error: ", + err, + "; retrieving value for column ", + column.name + ); + throw new Error(convertESEError(err)); + } + } + if (column.type == "string") { + return buffer ? buffer.readString() : ""; + } + if (column.type == "boolean") { + return buffer ? buffer.value == 255 : false; + } + if (column.type == "guid") { + if (buffer.length != 16) { + console.error( + "Buffer size for guid field ", + column.id, + " should have been 16!" + ); + return ""; + } + let rv = "{"; + for (let i = 0; i < 16; i++) { + if (i == 4 || i == 6 || i == 8 || i == 10) { + rv += "-"; + } + let byteValue = buffer.addressOfElement(i).contents; + // Ensure there's a leading 0 + rv += ("0" + byteValue.toString(16)).substr(-2); + } + return rv + "}"; + } + if (column.type == "date") { + if (!buffer) { + return null; + } + let systemTime = new KERNEL.SYSTEMTIME(); + let result = KERNEL.FileTimeToSystemTime( + buffer.address(), + systemTime.address() + ); + if (result == 0) { + throw new Error(ctypes.winLastError); + } + + // System time is in UTC, so we use Date.UTC to get milliseconds from epoch, + // then divide by 1000 to get seconds, and round down: + return new Date( + Date.UTC( + systemTime.wYear, + systemTime.wMonth - 1, + systemTime.wDay, + systemTime.wHour, + systemTime.wMinute, + systemTime.wSecond, + systemTime.wMilliseconds + ) + ); + } + return undefined; + }, + + _getColumnInfo(tableName, columns) { + let rv = []; + for (let column of columns) { + let columnInfoFromDB = new ESE.JET_COLUMNDEF(); + ESE.GetColumnInfoW( + this._sessionId, + this._dbId, + tableName, + column.name, + columnInfoFromDB.address(), + ESE.JET_COLUMNDEF.size, + 0 /* JET_ColInfo */ + ); + let dbType = parseInt(columnInfoFromDB.coltyp.toString(10), 10); + let dbSize = parseInt(columnInfoFromDB.cbMax.toString(10), 10); + if (column.type == "string") { + if ( + dbType != COLUMN_TYPES.JET_coltypLongText && + dbType != COLUMN_TYPES.JET_coltypText + ) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected text type, got type " + + getColTypeName(dbType) + ); + } + if (dbSize > MAX_STR_LENGTH) { + throw new Error( + "Column " + + column.name + + " has more than 64k data in it. This API is not designed to handle data that large." + ); + } + } else if (column.type == "boolean") { + if (dbType != COLUMN_TYPES.JET_coltypBit) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected bit type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type == "date") { + if (dbType != COLUMN_TYPES.JET_coltypLongLong) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected long long type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type == "guid") { + if (dbType != COLUMN_TYPES.JET_coltypGUID) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected guid type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type) { + throw new Error( + "Unknown column type " + + column.type + + " requested for column " + + column.name + + ", don't know what to do." + ); + } + + rv.push({ + name: column.name, + id: columnInfoFromDB.columnid, + type: column.type, + dbSize, + dbType, + }); + } + return rv; + }, + + _closeTable(tableId) { + ESE.FailSafeCloseTable(this._sessionId, tableId); + }, + + _close() { + this._internalClose(); + gOpenDBs.delete(this.dbPath); + }, + + _internalClose() { + if (this._opened) { + lazy.log.debug("close db"); + ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0); + lazy.log.debug("finished close db"); + this._opened = false; + } + if (this._attached) { + lazy.log.debug("detach db"); + ESE.FailSafeDetachDatabaseW(this._sessionId, this.dbPath); + this._attached = false; + } + if (this._sessionCreated) { + lazy.log.debug("end session"); + ESE.FailSafeEndSession(this._sessionId, 0); + this._sessionCreated = false; + } + if (this._instanceCreated) { + lazy.log.debug("term"); + ESE.FailSafeTerm(this._instanceId); + this._instanceCreated = false; + } + }, + + incrementReferenceCounter() { + this._references++; + }, + + decrementReferenceCounter() { + this._references--; + if (this._references <= 0) { + this._close(); + } + }, +}; + +export let ESEDBReader = { + openDB(rootDir, dbFile, logDir) { + let dbFilePath = dbFile.path; + if (gOpenDBs.has(dbFilePath)) { + let db = gOpenDBs.get(dbFilePath); + db.incrementReferenceCounter(); + return db; + } + // ESE is really picky about the trailing slashes according to the docs, + // so we do as we're told and ensure those are there: + return new ESEDB(rootDir.path + "\\", dbFilePath, logDir.path + "\\"); + }, + + async dbLocked(dbFile) { + const utils = Cc[ + "@mozilla.org/profile/migrator/edgemigrationutils;1" + ].createInstance(Ci.nsIEdgeMigrationUtils); + + const locked = await utils.isDbLocked(dbFile); + + if (locked) { + console.error(`ESE DB at ${dbFile.path} is locked.`); + } + + return locked; + }, + + closeDB(db) { + db.decrementReferenceCounter(); + }, + + COLUMN_TYPES, +}; diff --git a/browser/components/migration/EdgeProfileMigrator.sys.mjs b/browser/components/migration/EdgeProfileMigrator.sys.mjs new file mode 100644 index 0000000000..d495727ec9 --- /dev/null +++ b/browser/components/migration/EdgeProfileMigrator.sys.mjs @@ -0,0 +1,582 @@ +/* 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/. */ + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; +import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs"; + +const EDGE_COOKIE_PATH_OPTIONS = ["", "#!001\\", "#!002\\"]; +const EDGE_COOKIES_SUFFIX = "MicrosoftEdge\\Cookies"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ESEDBReader: "resource:///modules/ESEDBReader.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +const kEdgeRegistryRoot = + "SOFTWARE\\Classes\\Local Settings\\Software\\" + + "Microsoft\\Windows\\CurrentVersion\\AppContainer\\Storage\\" + + "microsoft.microsoftedge_8wekyb3d8bbwe\\MicrosoftEdge"; +const kEdgeDatabasePath = "AC\\MicrosoftEdge\\User\\Default\\DataStore\\Data\\"; + +ChromeUtils.defineLazyGetter(lazy, "gEdgeDatabase", function () { + let edgeDir = MSMigrationUtils.getEdgeLocalDataFolder(); + if (!edgeDir) { + return null; + } + edgeDir.appendRelativePath(kEdgeDatabasePath); + if (!edgeDir.exists() || !edgeDir.isReadable() || !edgeDir.isDirectory()) { + return null; + } + let expectedLocation = edgeDir.clone(); + expectedLocation.appendRelativePath( + "nouser1\\120712-0049\\DBStore\\spartan.edb" + ); + if ( + expectedLocation.exists() && + expectedLocation.isReadable() && + expectedLocation.isFile() + ) { + expectedLocation.normalize(); + return expectedLocation; + } + // We used to recurse into arbitrary subdirectories here, but that code + // went unused, so it likely isn't necessary, even if we don't understand + // where the magic folders above come from, they seem to be the same for + // everyone. Just return null if they're not there: + return null; +}); + +/** + * Get rows from a table in the Edge DB as an array of JS objects. + * + * @param {string} tableName the name of the table to read. + * @param {string[]|Function} columns a list of column specifiers + * (see ESEDBReader.jsm) or a function that + * generates them based on the database + * reference once opened. + * @param {nsIFile} dbFile the database file to use. Defaults to + * the main Edge database. + * @param {Function} filterFn Optional. A function that is called for each row. + * Only rows for which it returns a truthy + * value are included in the result. + * @returns {Array} An array of row objects. + */ +function readTableFromEdgeDB( + tableName, + columns, + dbFile = lazy.gEdgeDatabase, + filterFn = null +) { + let database; + let rows = []; + try { + let logFile = dbFile.parent; + logFile.append("LogFiles"); + database = lazy.ESEDBReader.openDB(dbFile.parent, dbFile, logFile); + + if (typeof columns == "function") { + columns = columns(database); + } + + let tableReader = database.tableItems(tableName, columns); + for (let row of tableReader) { + if (!filterFn || filterFn(row)) { + rows.push(row); + } + } + } catch (ex) { + console.error( + "Failed to extract items from table ", + tableName, + " in Edge database at ", + dbFile.path, + " due to the following error: ", + ex + ); + // Deliberately make this fail so we expose failure in the UI: + throw ex; + } finally { + if (database) { + lazy.ESEDBReader.closeDB(database); + } + } + return rows; +} + +function EdgeTypedURLMigrator() {} + +EdgeTypedURLMigrator.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get _typedURLs() { + if (!this.__typedURLs) { + this.__typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot); + } + return this.__typedURLs; + }, + + get exists() { + return this._typedURLs.size > 0; + }, + + migrate(aCallback) { + let typedURLs = this._typedURLs; + let pageInfos = []; + let now = new Date(); + let maxDate = new Date( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + for (let [urlString, time] of typedURLs) { + let visitDate = time ? lazy.PlacesUtils.toDate(time) : now; + if (time && visitDate < maxDate) { + continue; + } + + let url; + try { + url = new URL(urlString); + if (!["http:", "https:", "ftp:"].includes(url.protocol)) { + continue; + } + } catch (ex) { + console.error(ex); + continue; + } + + pageInfos.push({ + url, + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date: time ? lazy.PlacesUtils.toDate(time) : new Date(), + }, + ], + }); + } + + if (!pageInfos.length) { + aCallback(typedURLs.size == 0); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + }, +}; + +function EdgeTypedURLDBMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeTypedURLDBMigrator.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get db() { + return this.dbOverride || lazy.gEdgeDatabase; + }, + + get exists() { + return !!this.db; + }, + + migrate(callback) { + this._migrateTypedURLsFromDB().then( + () => callback(true), + ex => { + console.error(ex); + callback(false); + } + ); + }, + + async _migrateTypedURLsFromDB() { + if (await lazy.ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let columns = [ + { name: "URL", type: "string" }, + { name: "AccessDateTimeUTC", type: "date" }, + ]; + + let typedUrls = []; + try { + typedUrls = readTableFromEdgeDB("TypedUrls", columns, this.db); + } catch (ex) { + // Maybe the table doesn't exist (older versions of Win10). + // Just fall through and we'll return because there's no data. + // The `readTableFromEdgeDB` helper will report errors to the + // console anyway. + } + if (!typedUrls.length) { + return; + } + + let pageInfos = []; + + const kDateCutOff = new Date( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + for (let typedUrlInfo of typedUrls) { + try { + let date = typedUrlInfo.AccessDateTimeUTC; + if (!date) { + date = kDateCutOff; + } else if (date < kDateCutOff) { + continue; + } + + let url = new URL(typedUrlInfo.URL); + if (!["http:", "https:", "ftp:"].includes(url.protocol)) { + continue; + } + + pageInfos.push({ + url, + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date, + }, + ], + }); + } catch (ex) { + console.error(ex); + } + } + await MigrationUtils.insertVisitsWrapper(pageInfos); + }, +}; + +function EdgeReadingListMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeReadingListMigrator.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get db() { + return this.dbOverride || lazy.gEdgeDatabase; + }, + + get exists() { + return !!this.db; + }, + + migrate(callback) { + this._migrateReadingList(lazy.PlacesUtils.bookmarks.menuGuid).then( + () => callback(true), + ex => { + console.error(ex); + callback(false); + } + ); + }, + + async _migrateReadingList(parentGuid) { + if (await lazy.ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let columnFn = db => { + let columns = [ + { name: "URL", type: "string" }, + { name: "Title", type: "string" }, + { name: "AddedDate", type: "date" }, + ]; + + // Later versions have an IsDeleted column: + let isDeletedColumn = db.checkForColumn("ReadingList", "IsDeleted"); + if ( + isDeletedColumn && + isDeletedColumn.dbType == lazy.ESEDBReader.COLUMN_TYPES.JET_coltypBit + ) { + columns.push({ name: "IsDeleted", type: "boolean" }); + } + return columns; + }; + + let filterFn = row => { + return !row.IsDeleted; + }; + + let readingListItems = readTableFromEdgeDB( + "ReadingList", + columnFn, + this.db, + filterFn + ); + if (!readingListItems.length) { + return; + } + + let destFolderGuid = await this._ensureReadingListFolder(parentGuid); + let bookmarks = []; + for (let item of readingListItems) { + let dateAdded = item.AddedDate || new Date(); + // Avoid including broken URLs: + try { + new URL(item.URL); + } catch (ex) { + continue; + } + bookmarks.push({ url: item.URL, title: item.Title, dateAdded }); + } + await MigrationUtils.insertManyBookmarksWrapper(bookmarks, destFolderGuid); + }, + + async _ensureReadingListFolder(parentGuid) { + if (!this.__readingListFolderGuid) { + let folderTitle = await MigrationUtils.getLocalizedString( + "migration-imported-edge-reading-list" + ); + let folderSpec = { + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid, + title: folderTitle, + }; + this.__readingListFolderGuid = ( + await MigrationUtils.insertBookmarkWrapper(folderSpec) + ).guid; + } + return this.__readingListFolderGuid; + }, +}; + +function EdgeBookmarksMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeBookmarksMigrator.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get db() { + return this.dbOverride || lazy.gEdgeDatabase; + }, + + get TABLE_NAME() { + return "Favorites"; + }, + + get exists() { + if (!("_exists" in this)) { + this._exists = !!this.db; + } + return this._exists; + }, + + migrate(callback) { + this._migrateBookmarks().then( + () => callback(true), + ex => { + console.error(ex); + callback(false); + } + ); + }, + + async _migrateBookmarks() { + if (await lazy.ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let { toplevelBMs, toolbarBMs } = this._fetchBookmarksFromDB(); + if (toplevelBMs.length) { + let parentGuid = lazy.PlacesUtils.bookmarks.menuGuid; + await MigrationUtils.insertManyBookmarksWrapper(toplevelBMs, parentGuid); + } + if (toolbarBMs.length) { + let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid); + } + }, + + _fetchBookmarksFromDB() { + let folderMap = new Map(); + let columns = [ + { name: "URL", type: "string" }, + { name: "Title", type: "string" }, + { name: "DateUpdated", type: "date" }, + { name: "IsFolder", type: "boolean" }, + { name: "IsDeleted", type: "boolean" }, + { name: "ParentId", type: "guid" }, + { name: "ItemId", type: "guid" }, + ]; + let filterFn = row => { + if (row.IsDeleted) { + return false; + } + if (row.IsFolder) { + folderMap.set(row.ItemId, row); + } + return true; + }; + let bookmarks = readTableFromEdgeDB( + this.TABLE_NAME, + columns, + this.db, + filterFn + ); + let toplevelBMs = [], + toolbarBMs = []; + for (let bookmark of bookmarks) { + let bmToInsert; + // Ignore invalid URLs: + if (!bookmark.IsFolder) { + try { + new URL(bookmark.URL); + } catch (ex) { + console.error( + `Ignoring ${bookmark.URL} when importing from Edge because of exception: ${ex}` + ); + continue; + } + bmToInsert = { + dateAdded: bookmark.DateUpdated || new Date(), + title: bookmark.Title, + url: bookmark.URL, + }; + } /* bookmark.IsFolder */ else { + // Ignore the favorites bar bookmark itself. + if (bookmark.Title == "_Favorites_Bar_") { + continue; + } + if (!bookmark._childrenRef) { + bookmark._childrenRef = []; + } + bmToInsert = { + title: bookmark.Title, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + dateAdded: bookmark.DateUpdated || new Date(), + children: bookmark._childrenRef, + }; + } + + if (!folderMap.has(bookmark.ParentId)) { + toplevelBMs.push(bmToInsert); + } else { + let parent = folderMap.get(bookmark.ParentId); + if (parent.Title == "_Favorites_Bar_") { + toolbarBMs.push(bmToInsert); + continue; + } + if (!parent._childrenRef) { + parent._childrenRef = []; + } + parent._childrenRef.push(bmToInsert); + } + } + return { toplevelBMs, toolbarBMs }; + }, +}; + +function getCookiesPaths() { + let folders = []; + let edgeDir = MSMigrationUtils.getEdgeLocalDataFolder(); + if (edgeDir) { + edgeDir.append("AC"); + for (let path of EDGE_COOKIE_PATH_OPTIONS) { + let folder = edgeDir.clone(); + let fullPath = path + EDGE_COOKIES_SUFFIX; + folder.appendRelativePath(fullPath); + if (folder.exists() && folder.isReadable() && folder.isDirectory()) { + folders.push(fullPath); + } + } + } + return folders; +} + +/** + * Edge (EdgeHTML) profile migrator + */ +export class EdgeProfileMigrator extends MigratorBase { + static get key() { + return "edge"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-edge-legacy"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/edge.png"; + } + + getBookmarksMigratorForTesting(dbOverride) { + return new EdgeBookmarksMigrator(dbOverride); + } + + getReadingListMigratorForTesting(dbOverride) { + return new EdgeReadingListMigrator(dbOverride); + } + + getHistoryDBMigratorForTesting(dbOverride) { + return new EdgeTypedURLDBMigrator(dbOverride); + } + + getHistoryRegistryMigratorForTesting() { + return new EdgeTypedURLMigrator(); + } + + getResources() { + let resources = [ + new EdgeBookmarksMigrator(), + new EdgeTypedURLMigrator(), + new EdgeTypedURLDBMigrator(), + new EdgeReadingListMigrator(), + ]; + let windowsVaultFormPasswordsMigrator = + MSMigrationUtils.getWindowsVaultFormPasswordsMigrator(); + windowsVaultFormPasswordsMigrator.name = "EdgeVaultFormPasswords"; + resources.push(windowsVaultFormPasswordsMigrator); + return resources.filter(r => r.exists); + } + + async getLastUsedDate() { + // Don't do this if we don't have a single profile (see the comment for + // sourceProfiles) or if we can't find the database file: + let sourceProfiles = await this.getSourceProfiles(); + if (sourceProfiles !== null || !lazy.gEdgeDatabase) { + return Promise.resolve(new Date(0)); + } + let logFilePath = PathUtils.join( + lazy.gEdgeDatabase.parent.path, + "LogFiles", + "edb.log" + ); + let dbPath = lazy.gEdgeDatabase.path; + let datePromises = [logFilePath, dbPath, ...getCookiesPaths()].map(path => { + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }); + datePromises.push( + new Promise(resolve => { + let typedURLs = new Map(); + try { + typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot); + } catch (ex) {} + let times = [0, ...typedURLs.values()]; + // dates is an array of PRTimes, which are in microseconds - convert to milliseconds + resolve(Math.max.apply(Math, times) / 1000); + }) + ); + return Promise.all(datePromises).then(dates => { + return new Date(Math.max.apply(Math, dates)); + }); + } + + /** + * @returns {Array|null} + * Somewhat counterintuitively, this returns + * |null| to indicate "There is only 1 (default) profile". + * See MigrationUtils.sys.mjs for slightly more info on how sourceProfiles is used. + */ + getSourceProfiles() { + return null; + } +} diff --git a/browser/components/migration/FileMigrators.sys.mjs b/browser/components/migration/FileMigrators.sys.mjs new file mode 100644 index 0000000000..3384011c13 --- /dev/null +++ b/browser/components/migration/FileMigrators.sys.mjs @@ -0,0 +1,359 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs", + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + LoginCSVImport: "resource://gre/modules/LoginCSVImport.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gFluentStrings", function () { + return new Localization([ + "branding/brand.ftl", + "browser/migrationWizard.ftl", + ]); +}); + +/** + * Base class for a migration that involves reading a single file off of + * the disk that the user picks using a file picker. The file might be + * generated by another browser or some other application. + */ +export class FileMigratorBase { + /** + * This must be overridden to return a simple string identifier for the + * migrator, for example "password-csv". This key is what + * is used as an identifier when calling MigrationUtils.getFileMigrator. + * + * @type {string} + */ + static get key() { + throw new Error("FileMigrator.key must be overridden."); + } + + /** + * This must be overridden to return a Fluent string ID mapping to the display + * name for this migrator. These strings should be defined in migrationWizard.ftl. + * + * @type {string} + */ + static get displayNameL10nID() { + throw new Error("FileMigrator.displayNameL10nID must be overridden."); + } + + /** + * This getter should get overridden to return an icon url to represent the + * file to be imported from. By default, this will just use the default Favicon + * image. + * + * @type {string} + */ + static get brandImage() { + return "chrome://global/skin/icons/defaultFavicon.svg"; + } + + /** + * Returns true if the migrator is configured to be enabled. + * + * @type {boolean} + * true if the migrator should be shown in the migration wizard. + */ + get enabled() { + throw new Error("FileMigrator.enabled must be overridden."); + } + + /** + * This getter should be overridden to return a Fluent string ID for what + * the migration wizard header should be while the file migration is + * underway. + * + * @type {string} + */ + get progressHeaderL10nID() { + throw new Error("FileMigrator.progressHeaderL10nID must be overridden."); + } + + /** + * This getter should be overridden to return a Fluent string ID for what + * the migration wizard header should be while the file migration is + * done. + * + * @type {string} + */ + get successHeaderL10nID() { + throw new Error("FileMigrator.progressHeaderL10nID must be overridden."); + } + + /** + * @typedef {object} FilePickerConfiguration + * @property {string} title + * The title that should be assigned to the native file picker window. + * @property {FilePickerConfigurationFilter[]} filters + * One or more extension filters that should be applied to the native + * file picker window to make selection easier. + */ + + /** + * @typedef {object} FilePickerConfigurationFilter + * @property {string} title + * The title for the filter. Example: "CSV Files" + * @property {string} extensionPattern + * A matching pattern for the filter. Example: "*.csv" + */ + + /** + * A subclass of FileMigratorBase will eventually open a native file picker + * for the user to select the file from their file system. + * + * Subclasses need to override this method in order to configure the + * native file picker. + * + * @returns {Promise<FilePickerConfiguration>} + */ + async getFilePickerConfig() { + throw new Error("FileMigrator.getFilePickerConfig must be overridden."); + } + + /** + * Returns a list of one or more resource types that should appear to be + * in progress of migrating while the file migration occurs. Notably, + * this does not need to match the resource types that are returned by + * `FileMigratorBase.migrate`. + * + * @type {string[]} + * An array of resource types from the + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES set. + */ + get displayedResourceTypes() { + throw new Error("FileMigrator.displayedResourceTypes must be overridden"); + } + + /** + * Called to perform the file migration once the user makes a selection + * from the native file picker. This will not be called if the user + * chooses to cancel the native file picker. + * + * @param {string} filePath + * The path that the user selected from the native file picker. + */ + // eslint-disable-next-line no-unused-vars + async migrate(filePath) { + throw new Error("FileMigrator.migrate must be overridden."); + } +} + +/** + * A file migrator for importing passwords from CSV or TSV files. CSV + * files are more common, so this is what we show as the file type for + * the display name, but this FileMigrator accepts both. + */ +export class PasswordFileMigrator extends FileMigratorBase { + static get key() { + return "file-password-csv"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-file-password-csv"; + } + + static get brandImage() { + return "chrome://branding/content/document.ico"; + } + + get enabled() { + return Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled", + false + ); + } + + get displayedResourceTypes() { + return [ + lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .PASSWORDS_FROM_FILE, + ]; + } + + get progressHeaderL10nID() { + return "migration-passwords-from-file-progress-header"; + } + + get successHeaderL10nID() { + return "migration-passwords-from-file-success-header"; + } + + async getFilePickerConfig() { + let [title, csvFilterTitle, tsvFilterTitle] = + await lazy.gFluentStrings.formatValues([ + { id: "migration-passwords-from-file-picker-title" }, + { id: "migration-passwords-from-file-csv-filter-title" }, + { id: "migration-passwords-from-file-tsv-filter-title" }, + ]); + + return { + title, + filters: [ + { + title: csvFilterTitle, + extensionPattern: "*.csv", + }, + { + title: tsvFilterTitle, + extensionPattern: "*.tsv", + }, + ], + }; + } + + async migrate(filePath) { + try { + let summary = await lazy.LoginCSVImport.importFromCSV(filePath); + let newEntries = 0; + let updatedEntries = 0; + for (let entry of summary) { + if (entry.result == "added") { + newEntries++; + } else if (entry.result == "modified") { + updatedEntries++; + } + } + let [newMessage, updatedMessage] = await lazy.gFluentStrings.formatValues( + [ + { + id: "migration-wizard-progress-success-new-passwords", + args: { newEntries }, + }, + { + id: "migration-wizard-progress-success-updated-passwords", + args: { updatedEntries }, + }, + ] + ); + + Services.prefs.setBoolPref( + "browser.migrate.interactions.csvpasswords", + true + ); + + return { + [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .PASSWORDS_NEW]: newMessage, + [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .PASSWORDS_UPDATED]: updatedMessage, + }; + } catch (e) { + console.error(e); + + let errorMessage = await lazy.gFluentStrings.formatValue( + "migration-passwords-from-file-no-valid-data" + ); + throw new Error(errorMessage); + } + } +} + +/** + * A file migrator for importing bookmarks from a HTML or JSON file. + * + * @class BookmarksFileMigrator + * @augments {FileMigratorBase} + */ +export class BookmarksFileMigrator extends FileMigratorBase { + static get key() { + return "file-bookmarks"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-file-bookmarks"; + } + + static get brandImage() { + return "chrome://branding/content/document.ico"; + } + + get enabled() { + return Services.prefs.getBoolPref( + "browser.migrate.bookmarks-file.enabled", + false + ); + } + + get displayedResourceTypes() { + return [ + lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE, + ]; + } + + get progressHeaderL10nID() { + return "migration-bookmarks-from-file-progress-header"; + } + + get successHeaderL10nID() { + return "migration-bookmarks-from-file-success-header"; + } + + async getFilePickerConfig() { + let [title, htmlFilterTitle, jsonFilterTitle] = + await lazy.gFluentStrings.formatValues([ + { id: "migration-bookmarks-from-file-picker-title" }, + { id: "migration-bookmarks-from-file-html-filter-title" }, + { id: "migration-bookmarks-from-file-json-filter-title" }, + ]); + + return { + title, + filters: [ + { + title: htmlFilterTitle, + extensionPattern: "*.html", + }, + { + title: jsonFilterTitle, + extensionPattern: "*.json", + }, + ], + }; + } + + async migrate(filePath) { + try { + let pathCheck = filePath.toLowerCase(); + let importedCount; + + if (pathCheck.endsWith("html")) { + importedCount = await lazy.BookmarkHTMLUtils.importFromFile(filePath); + } else if (pathCheck.endsWith("json") || pathCheck.endsWith("jsonlz4")) { + importedCount = await lazy.BookmarkJSONUtils.importFromFile(filePath); + } + + if (!importedCount) { + // The catch will cause us to show a default error message. + throw new Error(); + } + + let importedMessage = await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-new-bookmarks", + { + newEntries: importedCount, + } + ); + return { + [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE]: importedMessage, + }; + } catch (e) { + console.error(e); + + let errorMessage = await lazy.gFluentStrings.formatValue( + "migration-bookmarks-from-file-no-valid-data" + ); + throw new Error(errorMessage); + } + } +} diff --git a/browser/components/migration/FirefoxProfileMigrator.sys.mjs b/browser/components/migration/FirefoxProfileMigrator.sys.mjs new file mode 100644 index 0000000000..27a15b65fb --- /dev/null +++ b/browser/components/migration/FirefoxProfileMigrator.sys.mjs @@ -0,0 +1,400 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sw=2 ts=2 sts=2 et */ +/* 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 a Firefox profile in a lossy manner in order to clean up a + * user's profile. Data is only migrated where the benefits outweigh the + * potential problems caused by importing undesired/invalid configurations + * from the source profile. + */ + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + SessionMigration: "resource:///modules/sessionstore/SessionMigration.sys.mjs", +}); + +/** + * Firefox profile migrator. Currently, this class only does "pave over" + * migrations, where various parts of an old profile overwrite a new + * profile. This is distinct from other migrators which attempt to import + * old profile data into the existing profile. + * + * This migrator is what powers the "Profile Refresh" mechanism. + */ +export class FirefoxProfileMigrator extends MigratorBase { + static get key() { + return "firefox"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-firefox"; + } + + static get brandImage() { + return "chrome://branding/content/icon128.png"; + } + + _getAllProfiles() { + let allProfiles = new Map(); + let profileService = Cc[ + "@mozilla.org/toolkit/profile-service;1" + ].getService(Ci.nsIToolkitProfileService); + for (let profile of profileService.profiles) { + let rootDir = profile.rootDir; + + if ( + rootDir.exists() && + rootDir.isReadable() && + !rootDir.equals(MigrationUtils.profileStartup.directory) + ) { + allProfiles.set(profile.name, rootDir); + } + } + return allProfiles; + } + + getSourceProfiles() { + let sorter = (a, b) => { + return a.id.toLocaleLowerCase().localeCompare(b.id.toLocaleLowerCase()); + }; + + return [...this._getAllProfiles().keys()] + .map(x => ({ id: x, name: x })) + .sort(sorter); + } + + _getFileObject(dir, fileName) { + let file = dir.clone(); + file.append(fileName); + + // File resources are monolithic. We don't make partial copies since + // they are not expected to work alone. Return null to avoid trying to + // copy non-existing files. + return file.exists() ? file : null; + } + + getResources(aProfile) { + let sourceProfileDir = aProfile + ? this._getAllProfiles().get(aProfile.id) + : Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ).defaultProfile.rootDir; + if ( + !sourceProfileDir || + !sourceProfileDir.exists() || + !sourceProfileDir.isReadable() + ) { + return null; + } + + // Being a startup-only migrator, we can rely on + // MigrationUtils.profileStartup being set. + let currentProfileDir = MigrationUtils.profileStartup.directory; + + // Surely data cannot be imported from the current profile. + if (sourceProfileDir.equals(currentProfileDir)) { + return null; + } + + return this._getResourcesInternal(sourceProfileDir, currentProfileDir); + } + + getLastUsedDate() { + // We always pretend we're really old, so that we don't mess + // up the determination of which browser is the most 'recent' + // to import from. + return Promise.resolve(new Date(0)); + } + + _getResourcesInternal(sourceProfileDir, currentProfileDir) { + let getFileResource = (aMigrationType, aFileNames) => { + let files = []; + for (let fileName of aFileNames) { + let file = this._getFileObject(sourceProfileDir, fileName); + if (file) { + files.push(file); + } + } + if (!files.length) { + return null; + } + return { + type: aMigrationType, + migrate(aCallback) { + for (let file of files) { + file.copyTo(currentProfileDir, ""); + } + aCallback(true); + }, + }; + }; + + let _oldRawPrefsMemoized = null; + async function readOldPrefs() { + if (!_oldRawPrefsMemoized) { + let prefsPath = PathUtils.join(sourceProfileDir.path, "prefs.js"); + if (await IOUtils.exists(prefsPath)) { + _oldRawPrefsMemoized = await IOUtils.readUTF8(prefsPath, { + encoding: "utf-8", + }); + } + } + + return _oldRawPrefsMemoized; + } + + function savePrefs() { + // If we've used the pref service to write prefs for the new profile, it's too + // early in startup for the service to have a profile directory, so we have to + // manually tell it where to save the prefs file. + let newPrefsFile = currentProfileDir.clone(); + newPrefsFile.append("prefs.js"); + Services.prefs.savePrefFile(newPrefsFile); + } + + let types = MigrationUtils.resourceTypes; + let places = getFileResource(types.HISTORY, [ + "places.sqlite", + "places.sqlite-wal", + ]); + let favicons = getFileResource(types.HISTORY, [ + "favicons.sqlite", + "favicons.sqlite-wal", + ]); + let cookies = getFileResource(types.COOKIES, [ + "cookies.sqlite", + "cookies.sqlite-wal", + ]); + let passwords = getFileResource(types.PASSWORDS, [ + "logins.json", + "key3.db", + "key4.db", + ]); + let formData = getFileResource(types.FORMDATA, [ + "formhistory.sqlite", + "autofill-profiles.json", + ]); + let bookmarksBackups = getFileResource(types.OTHERDATA, [ + lazy.PlacesBackups.profileRelativeFolderPath, + ]); + let dictionary = getFileResource(types.OTHERDATA, ["persdict.dat"]); + + let session; + if (Services.env.get("MOZ_RESET_PROFILE_MIGRATE_SESSION")) { + // We only want to restore the previous firefox session if the profile refresh was + // triggered by user. The MOZ_RESET_PROFILE_MIGRATE_SESSION would be set when a user-triggered + // profile refresh happened in nsAppRunner.cpp. Hence, we detect the MOZ_RESET_PROFILE_MIGRATE_SESSION + // to see if session data migration is required. + Services.env.set("MOZ_RESET_PROFILE_MIGRATE_SESSION", ""); + let sessionCheckpoints = this._getFileObject( + sourceProfileDir, + "sessionCheckpoints.json" + ); + let sessionFile = this._getFileObject( + sourceProfileDir, + "sessionstore.jsonlz4" + ); + if (sessionFile) { + session = { + type: types.SESSION, + migrate(aCallback) { + sessionCheckpoints.copyTo( + currentProfileDir, + "sessionCheckpoints.json" + ); + let newSessionFile = currentProfileDir.clone(); + newSessionFile.append("sessionstore.jsonlz4"); + let migrationPromise = lazy.SessionMigration.migrate( + sessionFile.path, + newSessionFile.path + ); + migrationPromise.then( + function () { + let buildID = Services.appinfo.platformBuildID; + let mstone = Services.appinfo.platformVersion; + // Force the browser to one-off resume the session that we give it: + Services.prefs.setBoolPref( + "browser.sessionstore.resume_session_once", + true + ); + // Reset the homepage_override prefs so that the browser doesn't override our + // session with the "what's new" page: + Services.prefs.setCharPref( + "browser.startup.homepage_override.mstone", + mstone + ); + Services.prefs.setCharPref( + "browser.startup.homepage_override.buildID", + buildID + ); + savePrefs(); + aCallback(true); + }, + function () { + aCallback(false); + } + ); + }, + }; + } + } + + // Sync/FxA related data + let sync = { + name: "sync", // name is used only by tests. + type: types.OTHERDATA, + migrate: async aCallback => { + // Try and parse a signedInUser.json file from the source directory and + // if we can, copy it to the new profile and set sync's username pref + // (which acts as a de-facto flag to indicate if sync is configured) + try { + let oldPath = PathUtils.join( + sourceProfileDir.path, + "signedInUser.json" + ); + let exists = await IOUtils.exists(oldPath); + if (exists) { + let data = await IOUtils.readJSON(oldPath); + if (data && data.accountData && data.accountData.email) { + let username = data.accountData.email; + // copy the file itself. + await IOUtils.copy( + oldPath, + PathUtils.join(currentProfileDir.path, "signedInUser.json") + ); + // Now we need to know whether Sync is actually configured for this + // user. The only way we know is by looking at the prefs file from + // the old profile. We avoid trying to do a full parse of the prefs + // file and even avoid parsing the single string value we care + // about. + let oldRawPrefs = await readOldPrefs(); + if (/^user_pref\("services\.sync\.username"/m.test(oldRawPrefs)) { + // sync's configured in the source profile - ensure it is in the + // new profile too. + // Write it to prefs.js and flush the file. + Services.prefs.setStringPref( + "services.sync.username", + username + ); + savePrefs(); + } + } + } + } catch (ex) { + aCallback(false); + return; + } + aCallback(true); + }, + }; + + // Telemetry related migrations. + let times = { + name: "times", // name is used only by tests. + type: types.OTHERDATA, + migrate: aCallback => { + let file = this._getFileObject(sourceProfileDir, "times.json"); + if (file) { + file.copyTo(currentProfileDir, ""); + } + // And record the fact a migration (ie, a reset) happened. + let recordMigration = async () => { + try { + let profileTimes = await lazy.ProfileAge(currentProfileDir.path); + await profileTimes.recordProfileReset(); + aCallback(true); + } catch (e) { + aCallback(false); + } + }; + + recordMigration(); + }, + }; + let telemetry = { + name: "telemetry", // name is used only by tests... + type: types.OTHERDATA, + migrate: async aCallback => { + let createSubDir = name => { + let dir = currentProfileDir.clone(); + dir.append(name); + dir.create(Ci.nsIFile.DIRECTORY_TYPE, lazy.FileUtils.PERMS_DIRECTORY); + return dir; + }; + + // If the 'datareporting' directory exists we migrate files from it. + let dataReportingDir = this._getFileObject( + sourceProfileDir, + "datareporting" + ); + if (dataReportingDir && dataReportingDir.isDirectory()) { + // Copy only specific files. + let toCopy = ["state.json", "session-state.json"]; + + let dest = createSubDir("datareporting"); + let enumerator = dataReportingDir.directoryEntries; + while (enumerator.hasMoreElements()) { + let file = enumerator.nextFile; + if (file.isDirectory() || !toCopy.includes(file.leafName)) { + continue; + } + file.copyTo(dest, ""); + } + } + + try { + let oldRawPrefs = await readOldPrefs(); + let writePrefs = false; + const PREFS = ["bookmarks", "csvpasswords", "history", "passwords"]; + + for (let pref of PREFS) { + let fullPref = `browser\.migrate\.interactions\.${pref}`; + let regex = new RegExp('^user_pref\\("' + fullPref, "m"); + if (regex.test(oldRawPrefs)) { + Services.prefs.setBoolPref(fullPref, true); + writePrefs = true; + } + } + + if (writePrefs) { + savePrefs(); + } + } catch (e) { + aCallback(false); + return; + } + + aCallback(true); + }, + }; + + return [ + places, + cookies, + passwords, + formData, + dictionary, + bookmarksBackups, + session, + sync, + times, + telemetry, + favicons, + ].filter(r => r); + } + + get startupOnlyMigrator() { + return true; + } +} diff --git a/browser/components/migration/IEProfileMigrator.sys.mjs b/browser/components/migration/IEProfileMigrator.sys.mjs new file mode 100644 index 0000000000..d0fd504e1a --- /dev/null +++ b/browser/components/migration/IEProfileMigrator.sys.mjs @@ -0,0 +1,133 @@ +/* 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/. */ + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; +import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs"; + +import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs"; + +// Resources + +function History() {} + +History.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get exists() { + return true; + }, + + migrate: function H_migrate(aCallback) { + let pageInfos = []; + let typedURLs = MSMigrationUtils.getTypedURLs( + "Software\\Microsoft\\Internet Explorer" + ); + let now = new Date(); + let maxDate = new Date( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + for (let entry of Cc[ + "@mozilla.org/profile/migrator/iehistoryenumerator;1" + ].createInstance(Ci.nsISimpleEnumerator)) { + let url = entry.get("uri").QueryInterface(Ci.nsIURI); + // MSIE stores some types of URLs in its history that we don't handle, + // like HTMLHelp and others. Since we don't properly map handling for + // all of them we just avoid importing them. + if (!["http", "https", "ftp", "file"].includes(url.scheme)) { + continue; + } + + let title = entry.get("title"); + // Embed visits have no title and don't need to be imported. + if (!title.length) { + continue; + } + + // The typed urls are already fixed-up, so we can use them for comparison. + let transition = typedURLs.has(url.spec) + ? PlacesUtils.history.TRANSITIONS.LINK + : PlacesUtils.history.TRANSITIONS.TYPED; + + let time = entry.get("time"); + + let visitDate = time ? PlacesUtils.toDate(time) : null; + if (visitDate && visitDate < maxDate) { + continue; + } + + pageInfos.push({ + url, + title, + visits: [ + { + transition, + // use the current date if we have no visits for this entry. + date: visitDate ?? now, + }, + ], + }); + } + + // Check whether there is any history to import. + if (!pageInfos.length) { + aCallback(true); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + }, +}; + +/** + * Internet Explorer profile migrator + */ +export class IEProfileMigrator extends MigratorBase { + static get key() { + return "ie"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-ie"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/ie.png"; + } + + getResources() { + let resources = [MSMigrationUtils.getBookmarksMigrator(), new History()]; + let windowsVaultFormPasswordsMigrator = + MSMigrationUtils.getWindowsVaultFormPasswordsMigrator(); + windowsVaultFormPasswordsMigrator.name = "IEVaultFormPasswords"; + resources.push(windowsVaultFormPasswordsMigrator); + return resources.filter(r => r.exists); + } + + async getLastUsedDate() { + const datePromises = ["Favs", "CookD"].map(dirId => { + const { path } = Services.dirsvc.get(dirId, Ci.nsIFile); + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }); + + const dates = await Promise.all(datePromises); + + try { + const typedURLs = MSMigrationUtils.getTypedURLs( + "Software\\Microsoft\\Internet Explorer" + ); + // typedURLs.values() returns an array of PRTimes, which are in + // microseconds - convert to milliseconds + dates.push(Math.max(0, ...typedURLs.values()) / 1000); + } catch (ex) {} + + return new Date(Math.max(...dates)); + } +} diff --git a/browser/components/migration/InternalTestingProfileMigrator.sys.mjs b/browser/components/migration/InternalTestingProfileMigrator.sys.mjs new file mode 100644 index 0000000000..7174757b96 --- /dev/null +++ b/browser/components/migration/InternalTestingProfileMigrator.sys.mjs @@ -0,0 +1,76 @@ +/* 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/. */ + +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; +import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", +}); + +/** + * A stub of a migrator used for automated testing only. + */ +export class InternalTestingProfileMigrator extends MigratorBase { + static get key() { + return "internal-testing"; + } + + static get displayNameL10nID() { + return "Internal Testing Migrator"; + } + + static get sourceID() { + return 1; + } + + getSourceProfiles() { + return Promise.resolve([InternalTestingProfileMigrator.testProfile]); + } + + // We will create a single MigratorResource for each resource type that + // just immediately reports a successful migration. + getResources(aProfile) { + if ( + !aProfile || + aProfile.id != InternalTestingProfileMigrator.testProfile.id + ) { + throw new Error( + "InternalTestingProfileMigrator.getResources expects test profile." + ); + } + return Object.values(lazy.MigrationUtils.resourceTypes).map(type => { + return { + type, + migrate: callback => { + if (type == lazy.MigrationUtils.resourceTypes.EXTENSIONS) { + callback(true, { + progressValue: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + totalExtensions: [], + importedExtensions: [], + }); + } else { + callback(true /* success */); + } + }, + }; + }); + } + + /** + * Clears the MigratorResources that are normally cached by the + * MigratorBase parent class after a call to getResources. This + * allows our automated tests to try different resource availability + * scenarios between tests. + */ + flushResourceCache() { + this._resourcesByProfile = null; + } + + static get testProfile() { + return { id: "test-profile", name: "Some test profile" }; + } +} diff --git a/browser/components/migration/MSMigrationUtils.sys.mjs b/browser/components/migration/MSMigrationUtils.sys.mjs new file mode 100644 index 0000000000..8d9a666e66 --- /dev/null +++ b/browser/components/migration/MSMigrationUtils.sys.mjs @@ -0,0 +1,749 @@ +/* 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/. */ + +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs", +}); + +const EDGE_FAVORITES = "AC\\MicrosoftEdge\\User\\Default\\Favorites"; +const FREE_CLOSE_FAILED = 0; +const INTERNET_EXPLORER_EDGE_GUID = [ + 0x3ccd5499, 0x4b1087a8, 0x886015a2, 0x553bdd88, +]; +const RESULT_SUCCESS = 0; +const VAULT_ENUMERATE_ALL_ITEMS = 512; +const WEB_CREDENTIALS_VAULT_ID = [ + 0x4bf4c442, 0x41a09b8a, 0x4add80b3, 0x28db4d70, +]; + +const wintypes = { + BOOL: ctypes.int, + DWORD: ctypes.uint32_t, + DWORDLONG: ctypes.uint64_t, + CHAR: ctypes.char, + PCHAR: ctypes.char.ptr, + LPCWSTR: ctypes.char16_t.ptr, + PDWORD: ctypes.uint32_t.ptr, + VOIDP: ctypes.voidptr_t, + WORD: ctypes.uint16_t, +}; + +// TODO: Bug 1202978 - Refactor MSMigrationUtils ctypes helpers +function CtypesKernelHelpers() { + this._structs = {}; + this._functions = {}; + this._libs = {}; + + this._structs.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [ + { wYear: wintypes.WORD }, + { wMonth: wintypes.WORD }, + { wDayOfWeek: wintypes.WORD }, + { wDay: wintypes.WORD }, + { wHour: wintypes.WORD }, + { wMinute: wintypes.WORD }, + { wSecond: wintypes.WORD }, + { wMilliseconds: wintypes.WORD }, + ]); + + this._structs.FILETIME = new ctypes.StructType("FILETIME", [ + { dwLowDateTime: wintypes.DWORD }, + { dwHighDateTime: wintypes.DWORD }, + ]); + + try { + this._libs.kernel32 = ctypes.open("Kernel32"); + + this._functions.FileTimeToSystemTime = this._libs.kernel32.declare( + "FileTimeToSystemTime", + ctypes.winapi_abi, + wintypes.BOOL, + this._structs.FILETIME.ptr, + this._structs.SYSTEMTIME.ptr + ); + } catch (ex) { + this.finalize(); + } +} + +CtypesKernelHelpers.prototype = { + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions = {}; + for (let key in this._libs) { + let lib = this._libs[key]; + try { + lib.close(); + } catch (ex) {} + } + this._libs = {}; + }, + + /** + * Converts a FILETIME struct (2 DWORDS), to a SYSTEMTIME struct, + * and then deduces the number of seconds since the epoch (which + * is the data we want for the cookie expiry date). + * + * @param {number} aTimeHi + * Least significant DWORD. + * @param {number} aTimeLo + * Most significant DWORD. + * @returns {number} the number of seconds since the epoch + */ + fileTimeToSecondsSinceEpoch(aTimeHi, aTimeLo) { + let fileTime = this._structs.FILETIME(); + fileTime.dwLowDateTime = aTimeLo; + fileTime.dwHighDateTime = aTimeHi; + let systemTime = this._structs.SYSTEMTIME(); + let result = this._functions.FileTimeToSystemTime( + fileTime.address(), + systemTime.address() + ); + if (result == 0) { + throw new Error(ctypes.winLastError); + } + + // System time is in UTC, so we use Date.UTC to get milliseconds from epoch, + // then divide by 1000 to get seconds, and round down: + return Math.floor( + Date.UTC( + systemTime.wYear, + systemTime.wMonth - 1, + systemTime.wDay, + systemTime.wHour, + systemTime.wMinute, + systemTime.wSecond, + systemTime.wMilliseconds + ) / 1000 + ); + }, +}; + +function CtypesVaultHelpers() { + this._structs = {}; + this._functions = {}; + + this._structs.GUID = new ctypes.StructType("GUID", [ + { id: wintypes.DWORD.array(4) }, + ]); + + this._structs.VAULT_ITEM_ELEMENT = new ctypes.StructType( + "VAULT_ITEM_ELEMENT", + [ + // not documented + { schemaElementId: wintypes.DWORD }, + // not documented + { unknown1: wintypes.DWORD }, + // vault type + { type: wintypes.DWORD }, + // not documented + { unknown2: wintypes.DWORD }, + // value of the item + { itemValue: wintypes.LPCWSTR }, + // not documented + { unknown3: wintypes.CHAR.array(12) }, + ] + ); + + this._structs.VAULT_ELEMENT = new ctypes.StructType("VAULT_ELEMENT", [ + // vault item schemaId + { schemaId: this._structs.GUID }, + // a pointer to the name of the browser VAULT_ITEM_ELEMENT + { pszCredentialFriendlyName: wintypes.LPCWSTR }, + // a pointer to the url VAULT_ITEM_ELEMENT + { pResourceElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // a pointer to the username VAULT_ITEM_ELEMENT + { pIdentityElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // not documented + { pAuthenticatorElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // not documented + { pPackageSid: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // time stamp in local format + { lowLastModified: wintypes.DWORD }, + { highLastModified: wintypes.DWORD }, + // not documented + { flags: wintypes.DWORD }, + // not documented + { dwPropertiesCount: wintypes.DWORD }, + // not documented + { pPropertyElements: this._structs.VAULT_ITEM_ELEMENT.ptr }, + ]); + + try { + this._vaultcliLib = ctypes.open("vaultcli.dll"); + + this._functions.VaultOpenVault = this._vaultcliLib.declare( + "VaultOpenVault", + ctypes.winapi_abi, + wintypes.DWORD, + // GUID + this._structs.GUID.ptr, + // Flags + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP.ptr + ); + this._functions.VaultEnumerateItems = this._vaultcliLib.declare( + "VaultEnumerateItems", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP, + // Flags + wintypes.DWORD, + // Items Count + wintypes.PDWORD, + // Items + ctypes.voidptr_t + ); + this._functions.VaultCloseVault = this._vaultcliLib.declare( + "VaultCloseVault", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP + ); + this._functions.VaultGetItem = this._vaultcliLib.declare( + "VaultGetItem", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP, + // Schema Id + this._structs.GUID.ptr, + // Resource + this._structs.VAULT_ITEM_ELEMENT.ptr, + // Identity + this._structs.VAULT_ITEM_ELEMENT.ptr, + // Package Sid + this._structs.VAULT_ITEM_ELEMENT.ptr, + // HWND Owner + wintypes.DWORD, + // Flags + wintypes.DWORD, + // Items + this._structs.VAULT_ELEMENT.ptr.ptr + ); + this._functions.VaultFree = this._vaultcliLib.declare( + "VaultFree", + ctypes.winapi_abi, + wintypes.DWORD, + // Memory + this._structs.VAULT_ELEMENT.ptr + ); + } catch (ex) { + this.finalize(); + } +} + +CtypesVaultHelpers.prototype = { + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions = {}; + try { + this._vaultcliLib.close(); + } catch (ex) {} + this._vaultcliLib = null; + }, +}; + +var gEdgeDir; +function getEdgeLocalDataFolder() { + if (gEdgeDir) { + return gEdgeDir.clone(); + } + let packages = Services.dirsvc.get("LocalAppData", Ci.nsIFile); + packages.append("Packages"); + let edgeDir = packages.clone(); + edgeDir.append("Microsoft.MicrosoftEdge_8wekyb3d8bbwe"); + try { + if (edgeDir.exists() && edgeDir.isReadable() && edgeDir.isDirectory()) { + gEdgeDir = edgeDir; + return edgeDir.clone(); + } + + // Let's try the long way: + let dirEntries = packages.directoryEntries; + while (dirEntries.hasMoreElements()) { + let subDir = dirEntries.nextFile; + if ( + subDir.leafName.startsWith("Microsoft.MicrosoftEdge") && + subDir.isReadable() && + subDir.isDirectory() + ) { + gEdgeDir = subDir; + return subDir.clone(); + } + } + } catch (ex) { + console.error( + "Exception trying to find the Edge favorites directory: ", + ex + ); + } + return null; +} + +function Bookmarks(migrationType) { + this._migrationType = migrationType; +} + +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get exists() { + return !!this._favoritesFolder; + }, + + get importedAppLabel() { + return this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE + ? "IE" + : "Edge"; + }, + + __favoritesFolder: null, + get _favoritesFolder() { + if (!this.__favoritesFolder) { + if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) { + let favoritesFolder = Services.dirsvc.get("Favs", Ci.nsIFile); + if (favoritesFolder.exists() && favoritesFolder.isReadable()) { + this.__favoritesFolder = favoritesFolder; + } + } else if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_EDGE) { + let edgeDir = getEdgeLocalDataFolder(); + if (edgeDir) { + edgeDir.appendRelativePath(EDGE_FAVORITES); + if ( + edgeDir.exists() && + edgeDir.isReadable() && + edgeDir.isDirectory() + ) { + this.__favoritesFolder = edgeDir; + } + } + } + } + return this.__favoritesFolder; + }, + + __toolbarFolderName: null, + get _toolbarFolderName() { + if (!this.__toolbarFolderName) { + if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) { + // Retrieve the name of IE's favorites subfolder that holds the bookmarks + // in the toolbar. This was previously stored in the registry and changed + // in IE7 to always be called "Links". + let folderName = lazy.WindowsRegistry.readRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + "Software\\Microsoft\\Internet Explorer\\Toolbar", + "LinksFolderName" + ); + this.__toolbarFolderName = folderName || "Links"; + } else { + this.__toolbarFolderName = "Links"; + } + } + return this.__toolbarFolderName; + }, + + migrate: function B_migrate(aCallback) { + return (async () => { + // Import to the bookmarks menu. + let folderGuid = lazy.PlacesUtils.bookmarks.menuGuid; + await this._migrateFolder(this._favoritesFolder, folderGuid); + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, + + async _migrateFolder(aSourceFolder, aDestFolderGuid) { + let { bookmarks, favicons } = await this._getBookmarksInFolder( + aSourceFolder + ); + if (!bookmarks.length) { + return; + } + + await MigrationUtils.insertManyBookmarksWrapper(bookmarks, aDestFolderGuid); + MigrationUtils.insertManyFavicons(favicons); + }, + + /** + * Iterates through a bookmark folder to obtain whatever information from each bookmark is needed elsewhere. This function also recurses into child folders. + * + * @param {nsIFile} aSourceFolder the folder to search for bookmarks and subfolders. + * @returns {Promise<object>} An object with the following properties: + * {Object[]} bookmarks: + * An array of Objects with these properties: + * {number} type: A type mapping to one of the types in nsINavBookmarksService + * {string} title: The title of the bookmark + * {Object[]} children: An array of objects with the same structure as this one. + * + * {Object[]} favicons + * An array of Objects with these properties: + * {Uint8Array} faviconData: The binary data of a favicon + * {nsIURI} uri: The URI of the associated bookmark + */ + async _getBookmarksInFolder(aSourceFolder) { + // TODO (bug 741993): the favorites order is stored in the Registry, at + // HCU\Software\Microsoft\Windows\CurrentVersion\Explorer\MenuOrder\Favorites + // for IE, and in a similar location for Edge. + // Until we support it, bookmarks are imported in alphabetical order. + let entries = aSourceFolder.directoryEntries; + let rv = []; + let favicons = []; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + try { + // Make sure that entry.path == entry.target to not follow .lnk folder + // shortcuts which could lead to infinite cycles. + // Don't use isSymlink(), since it would throw for invalid + // lnk files pointing to URLs or to unresolvable paths. + if (entry.path == entry.target && entry.isDirectory()) { + let isBookmarksFolder = + entry.leafName == this._toolbarFolderName && + entry.parent.equals(this._favoritesFolder); + if (isBookmarksFolder && entry.isReadable()) { + // Import to the bookmarks toolbar. + let folderGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + await this._migrateFolder(entry, folderGuid); + } else if (entry.isReadable()) { + let { bookmarks: childBookmarks, favicons: childFavicons } = + await this._getBookmarksInFolder(entry); + favicons = favicons.concat(childFavicons); + rv.push({ + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: entry.leafName, + children: childBookmarks, + }); + } + } else { + // Strip the .url extension, to both check this is a valid link file, + // and get the associated title. + let matches = entry.leafName.match(/(.+)\.url$/i); + if (matches) { + let fileHandler = Cc[ + "@mozilla.org/network/protocol;1?name=file" + ].getService(Ci.nsIFileProtocolHandler); + let uri = fileHandler.readURLFile(entry); + // Silently failing in the event that the alternative data stream for the favicon doesn't exist + try { + let faviconData = await IOUtils.read(entry.path + ":favicon"); + favicons.push({ faviconData, uri }); + } catch {} + + rv.push({ url: uri, title: matches[1] }); + } + } + } catch (ex) { + console.error( + "Unable to import ", + this.importedAppLabel, + " favorite (", + entry.leafName, + "): ", + ex + ); + } + } + return { bookmarks: rv, favicons }; + }, +}; + +function getTypedURLs(registryKeyPath) { + // The list of typed URLs is a sort of annotation stored in the registry. + // The number of entries stored is not UI-configurable, but has changed + // between different Windows versions. We just keep reading up to the first + // non-existing entry to support different limits / states of the registry. + let typedURLs = new Map(); + let typedURLKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + let typedURLTimeKey = Cc[ + "@mozilla.org/windows-registry-key;1" + ].createInstance(Ci.nsIWindowsRegKey); + let cTypes = new CtypesKernelHelpers(); + try { + try { + typedURLKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + registryKeyPath + "\\TypedURLs", + Ci.nsIWindowsRegKey.ACCESS_READ + ); + } catch (ex) { + // Ignore errors opening this registry key - if it doesn't work, there's + // no way we can get useful info here. + return typedURLs; + } + try { + typedURLTimeKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + registryKeyPath + "\\TypedURLsTime", + Ci.nsIWindowsRegKey.ACCESS_READ + ); + } catch (ex) { + typedURLTimeKey = null; + } + let entryName; + for ( + let entry = 1; + typedURLKey.hasValue((entryName = "url" + entry)); + entry++ + ) { + let url = typedURLKey.readStringValue(entryName); + // If we can't get a date for whatever reason, default to 6 months ago + let timeTyped = Date.now() - 31536000 / 2; + if (typedURLTimeKey && typedURLTimeKey.hasValue(entryName)) { + let urlTime = ""; + try { + urlTime = typedURLTimeKey.readBinaryValue(entryName); + } catch (ex) { + console.error("Couldn't read url time for ", entryName); + } + if (urlTime.length == 8) { + let urlTimeHex = []; + for (let i = 0; i < 8; i++) { + let c = urlTime.charCodeAt(i).toString(16); + if (c.length == 1) { + c = "0" + c; + } + urlTimeHex.unshift(c); + } + try { + let hi = parseInt(urlTimeHex.slice(0, 4).join(""), 16); + let lo = parseInt(urlTimeHex.slice(4, 8).join(""), 16); + // Convert to seconds since epoch: + let secondsSinceEpoch = cTypes.fileTimeToSecondsSinceEpoch(hi, lo); + + // If the date is very far in the past, just use the default + if (secondsSinceEpoch > Date.now() / 1000000) { + // Callers expect PRTime, which is microseconds since epoch: + timeTyped = secondsSinceEpoch * 1000; + } + } catch (ex) { + // Ignore conversion exceptions. Callers will have to deal + // with the fallback value. + } + } + } + typedURLs.set(url, timeTyped * 1000); + } + } catch (ex) { + console.error("Error reading typed URL history: ", ex); + } finally { + if (typedURLKey) { + typedURLKey.close(); + } + if (typedURLTimeKey) { + typedURLTimeKey.close(); + } + cTypes.finalize(); + } + return typedURLs; +} + +// Migrator for form passwords +function WindowsVaultFormPasswords() {} + +WindowsVaultFormPasswords.prototype = { + type: MigrationUtils.resourceTypes.PASSWORDS, + + get exists() { + // check if there are passwords available for migration. + return this.migrate(() => {}, true); + }, + + /** + * If aOnlyCheckExists is false, import the form passwords from the vault + * and then call the aCallback. + * Otherwise, check if there are passwords in the vault. + * + * @param {Function} aCallback - a callback called when the migration is done. + * @param {boolean} [aOnlyCheckExists=false] - if aOnlyCheckExists is true, just check if there are some + * passwords to migrate. Import the passwords from the vault and call aCallback otherwise. + * @returns {boolean} true if there are passwords in the vault and aOnlyCheckExists is set to true, + * false if there is no password in the vault and aOnlyCheckExists is set to true, undefined if + * aOnlyCheckExists is set to false. + */ + async migrate(aCallback, aOnlyCheckExists = false) { + // check if the vault item is an IE/Edge one + function _isIEOrEdgePassword(id) { + return ( + id[0] == INTERNET_EXPLORER_EDGE_GUID[0] && + id[1] == INTERNET_EXPLORER_EDGE_GUID[1] && + id[2] == INTERNET_EXPLORER_EDGE_GUID[2] && + id[3] == INTERNET_EXPLORER_EDGE_GUID[3] + ); + } + + let ctypesVaultHelpers = new CtypesVaultHelpers(); + let ctypesKernelHelpers = new CtypesKernelHelpers(); + let migrationSucceeded = true; + let successfulVaultOpen = false; + let error, vault; + try { + // web credentials vault id + let vaultGuid = new ctypesVaultHelpers._structs.GUID( + WEB_CREDENTIALS_VAULT_ID + ); + error = new wintypes.DWORD(); + // web credentials vault + vault = new wintypes.VOIDP(); + // open the current vault using the vaultGuid + error = ctypesVaultHelpers._functions.VaultOpenVault( + vaultGuid.address(), + 0, + vault.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to open Vault: " + error); + } + successfulVaultOpen = true; + + let item = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr(); + let itemCount = new wintypes.DWORD(); + // enumerate all the available items. This api is going to return a table of all the + // available items and item is going to point to the first element of this table. + error = ctypesVaultHelpers._functions.VaultEnumerateItems( + vault, + VAULT_ENUMERATE_ALL_ITEMS, + itemCount.address(), + item.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to enumerate Vault items: " + error); + } + + let logins = []; + for (let j = 0; j < itemCount.value; j++) { + try { + // if it's not an ie/edge password, skip it + if (!_isIEOrEdgePassword(item.contents.schemaId.id)) { + continue; + } + let url = + item.contents.pResourceElement.contents.itemValue.readString(); + let realURL; + try { + realURL = Services.io.newURI(url); + } catch (ex) { + /* leave realURL as null */ + } + if (!realURL || !["http", "https", "ftp"].includes(realURL.scheme)) { + // Ignore items for non-URLs or URLs that aren't HTTP(S)/FTP + continue; + } + + // if aOnlyCheckExists is set to true, the purpose of the call is to return true if there is at + // least a password which is true in this case because a password was by now already found + if (aOnlyCheckExists) { + return true; + } + let username = + item.contents.pIdentityElement.contents.itemValue.readString(); + // the current login credential object + let credential = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr(); + error = ctypesVaultHelpers._functions.VaultGetItem( + vault, + item.contents.schemaId.address(), + item.contents.pResourceElement, + item.contents.pIdentityElement, + null, + 0, + 0, + credential.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to get item: " + error); + } + + let password = + credential.contents.pAuthenticatorElement.contents.itemValue.readString(); + let creation = Date.now(); + try { + // login manager wants time in milliseconds since epoch, so convert + // to seconds since epoch and multiply to get milliseconds: + creation = + ctypesKernelHelpers.fileTimeToSecondsSinceEpoch( + item.contents.highLastModified, + item.contents.lowLastModified + ) * 1000; + } catch (ex) { + // Ignore exceptions in the dates and just create the login for right now. + } + // create a new login + logins.push({ + username, + password, + origin: realURL.prePath, + timeCreated: creation, + }); + + // close current item + error = ctypesVaultHelpers._functions.VaultFree(credential); + if (error == FREE_CLOSE_FAILED) { + throw new Error("Unable to free item: " + error); + } + } catch (e) { + migrationSucceeded = false; + console.error(e); + } finally { + // move to next item in the table returned by VaultEnumerateItems + item = item.increment(); + } + } + + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + } catch (e) { + console.error(e); + migrationSucceeded = false; + } finally { + if (successfulVaultOpen) { + // close current vault + error = ctypesVaultHelpers._functions.VaultCloseVault(vault); + if (error == FREE_CLOSE_FAILED) { + console.error("Unable to close vault: ", error); + } + } + ctypesKernelHelpers.finalize(); + ctypesVaultHelpers.finalize(); + aCallback(migrationSucceeded); + } + if (aOnlyCheckExists) { + return false; + } + return undefined; + }, +}; + +export var MSMigrationUtils = { + MIGRATION_TYPE_IE: 1, + MIGRATION_TYPE_EDGE: 2, + CtypesKernelHelpers, + getBookmarksMigrator(migrationType = this.MIGRATION_TYPE_IE) { + return new Bookmarks(migrationType); + }, + getWindowsVaultFormPasswordsMigrator() { + return new WindowsVaultFormPasswords(); + }, + getTypedURLs, + getEdgeLocalDataFolder, +}; diff --git a/browser/components/migration/MigrationUtils.sys.mjs b/browser/components/migration/MigrationUtils.sys.mjs new file mode 100644 index 0000000000..000b471ee6 --- /dev/null +++ b/browser/components/migration/MigrationUtils.sys.mjs @@ -0,0 +1,1175 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "gCanGetPermissionsOnPlatformPromise", + () => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + return fp.isModeSupported(Ci.nsIFilePicker.modeGetFolder); + } +); + +var gMigrators = null; +var gFileMigrators = null; +var gProfileStartup = null; +var gL10n = null; + +let gForceExitSpinResolve = false; +let gKeepUndoData = false; +let gUndoData = null; + +function getL10n() { + if (!gL10n) { + gL10n = new Localization(["browser/migrationWizard.ftl"]); + } + return gL10n; +} + +const MIGRATOR_MODULES = Object.freeze({ + EdgeProfileMigrator: { + moduleURI: "resource:///modules/EdgeProfileMigrator.sys.mjs", + platforms: ["win"], + }, + FirefoxProfileMigrator: { + moduleURI: "resource:///modules/FirefoxProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + IEProfileMigrator: { + moduleURI: "resource:///modules/IEProfileMigrator.sys.mjs", + platforms: ["win"], + }, + SafariProfileMigrator: { + moduleURI: "resource:///modules/SafariProfileMigrator.sys.mjs", + platforms: ["macosx"], + }, + + // The following migrators are all variants of the ChromeProfileMigrator + + BraveProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + CanaryProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + ChromeProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + ChromeBetaMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "win"], + }, + ChromeDevMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux"], + }, + ChromiumProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + Chromium360seMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["win"], + }, + ChromiumEdgeMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + ChromiumEdgeBetaMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + OperaProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + VivaldiProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + OperaGXProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + + InternalTestingProfileMigrator: { + moduleURI: "resource:///modules/InternalTestingProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, +}); + +const FILE_MIGRATOR_MODULES = Object.freeze({ + PasswordFileMigrator: { + moduleURI: "resource:///modules/FileMigrators.sys.mjs", + }, + BookmarksFileMigrator: { + moduleURI: "resource:///modules/FileMigrators.sys.mjs", + }, +}); + +/** + * The singleton MigrationUtils service. This service is the primary mechanism + * by which migrations from other browsers to this browser occur. The singleton + * instance of this class is exported from this module as `MigrationUtils`. + */ +class MigrationUtils { + constructor() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "HISTORY_MAX_AGE_IN_DAYS", + "browser.migrate.history.maxAgeInDays", + 180 + ); + + ChromeUtils.registerWindowActor("MigrationWizard", { + parent: { + esModuleURI: "resource:///actors/MigrationWizardParent.sys.mjs", + }, + + child: { + esModuleURI: "resource:///actors/MigrationWizardChild.sys.mjs", + events: { + "MigrationWizard:RequestState": { wantUntrusted: true }, + "MigrationWizard:BeginMigration": { wantUntrusted: true }, + "MigrationWizard:RequestSafariPermissions": { wantUntrusted: true }, + "MigrationWizard:SelectSafariPasswordFile": { wantUntrusted: true }, + "MigrationWizard:OpenAboutAddons": { wantUntrusted: true }, + "MigrationWizard:PermissionsNeeded": { wantUntrusted: true }, + "MigrationWizard:GetPermissions": { wantUntrusted: true }, + }, + }, + + includeChrome: true, + allFrames: true, + matches: [ + "about:welcome", + "about:welcome?*", + "about:preferences", + "chrome://browser/content/migration/migration-dialog-window.html", + "chrome://browser/content/spotlight.html", + "about:firefoxview", + ], + }); + + ChromeUtils.defineLazyGetter(this, "IS_LINUX_SNAP_PACKAGE", () => { + if ( + AppConstants.platform != "linux" || + !Cc["@mozilla.org/gio-service;1"] + ) { + return false; + } + + let gIOSvc = Cc["@mozilla.org/gio-service;1"].getService( + Ci.nsIGIOService + ); + return gIOSvc.isRunningUnderSnap; + }); + } + + resourceTypes = Object.freeze({ + ALL: 0x0000, + /* 0x01 used to be used for settings, but was removed. */ + COOKIES: 0x0002, + HISTORY: 0x0004, + FORMDATA: 0x0008, + PASSWORDS: 0x0010, + BOOKMARKS: 0x0020, + OTHERDATA: 0x0040, + SESSION: 0x0080, + PAYMENT_METHODS: 0x0100, + EXTENSIONS: 0x0200, + }); + + /** + * Helper for implementing simple asynchronous cases of migration resources' + * |migrate(aCallback)| (see MigratorBase). If your |migrate| method + * just waits for some file to be read, for example, and then migrates + * everything right away, you can wrap the async-function with this helper + * and not worry about notifying the callback. + * + * @example + * // For example, instead of writing: + * setTimeout(function() { + * try { + * .... + * aCallback(true); + * } + * catch() { + * aCallback(false); + * } + * }, 0); + * + * // You may write: + * setTimeout(MigrationUtils.wrapMigrateFunction(function() { + * if (importingFromMosaic) + * throw Cr.NS_ERROR_UNEXPECTED; + * }, aCallback), 0); + * + * // ... and aCallback will be called with aSuccess=false when importing + * // from Mosaic, or with aSuccess=true otherwise. + * + * @param {Function} aFunction + * the function that will be called sometime later. If aFunction + * throws when it's called, aCallback(false) is called, otherwise + * aCallback(true) is called. + * @param {Function} aCallback + * the callback function passed to |migrate|. + * @returns {Function} + * the wrapped function. + */ + wrapMigrateFunction(aFunction, aCallback) { + return function () { + let success = false; + try { + aFunction.apply(null, arguments); + success = true; + } catch (ex) { + console.error(ex); + } + // Do not change this to call aCallback directly in try try & catch + // blocks, because if aCallback throws, we may end up calling aCallback + // twice. + aCallback(success); + }; + } + + /** + * Gets localized string corresponding to l10n-id + * + * @param {string} aKey + * The key of the id of the localization to retrieve. + * @param {object} [aArgs=undefined] + * An optional map of arguments to the id. + * @returns {Promise<string>} + * A promise that resolves to the retrieved localization. + */ + getLocalizedString(aKey, aArgs) { + let l10n = getL10n(); + return l10n.formatValue(aKey, aArgs); + } + + /** + * Get all the rows corresponding to a select query from a database, without + * requiring a lock on the database. If fetching data fails (because someone + * else tried to write to the DB at the same time, for example), we will + * retry the fetch after a 100ms timeout, up to 10 times. + * + * @param {string} path + * The file path to the database we want to open. + * @param {string} description + * A developer-readable string identifying what kind of database we're + * trying to open. + * @param {string} selectQuery + * The SELECT query to use to fetch the rows. + * @param {Promise} [testDelayPromise] + * An optional promise to await for after the first loop, used in tests. + * + * @returns {Promise<object[]|Error>} + * A promise that resolves to an array of rows. The promise will be + * rejected if the read/fetch failed even after retrying. + */ + getRowsFromDBWithoutLocks( + path, + description, + selectQuery, + testDelayPromise = null + ) { + let dbOptions = { + readOnly: true, + ignoreLockingMode: true, + path, + }; + + const RETRYLIMIT = 10; + const RETRYINTERVAL = 100; + return (async function innerGetRows() { + let rows = null; + for (let retryCount = RETRYLIMIT; retryCount; retryCount--) { + // Attempt to get the rows. If this succeeds, we will bail out of the loop, + // close the database in a failsafe way, and pass the rows back. + // If fetching the rows throws, we will wait RETRYINTERVAL ms + // and try again. This will repeat a maximum of RETRYLIMIT times. + let db; + let didOpen = false; + let previousExceptionMessage = null; + try { + db = await lazy.Sqlite.openConnection(dbOptions); + didOpen = true; + rows = await db.execute(selectQuery); + break; + } catch (ex) { + if (previousExceptionMessage != ex.message) { + console.error(ex); + } + previousExceptionMessage = ex.message; + if (ex.name == "NS_ERROR_FILE_CORRUPTED") { + break; + } + } finally { + try { + if (didOpen) { + await db.close(); + } + } catch (ex) {} + } + await Promise.all([ + new Promise(resolve => lazy.setTimeout(resolve, RETRYINTERVAL)), + testDelayPromise, + ]); + } + if (!rows) { + throw new Error( + "Couldn't get rows from the " + description + " database." + ); + } + return rows; + })(); + } + + get #migrators() { + if (!gMigrators) { + gMigrators = new Map(); + for (let [symbol, { moduleURI, platforms }] of Object.entries( + MIGRATOR_MODULES + )) { + if (platforms.includes(AppConstants.platform)) { + let { [symbol]: migratorClass } = + ChromeUtils.importESModule(moduleURI); + if (gMigrators.has(migratorClass.key)) { + console.error( + "A pre-existing migrator exists with key " + + `${migratorClass.key}. Not registering.` + ); + continue; + } + gMigrators.set(migratorClass.key, new migratorClass()); + } + } + } + return gMigrators; + } + + get #fileMigrators() { + if (!gFileMigrators) { + gFileMigrators = new Map(); + for (let [symbol, { moduleURI }] of Object.entries( + FILE_MIGRATOR_MODULES + )) { + let { [symbol]: migratorClass } = ChromeUtils.importESModule(moduleURI); + if (gFileMigrators.has(migratorClass.key)) { + console.error( + "A pre-existing file migrator exists with key " + + `${migratorClass.key}. Not registering.` + ); + continue; + } + gFileMigrators.set(migratorClass.key, new migratorClass()); + } + } + return gFileMigrators; + } + + forceExitSpinResolve() { + gForceExitSpinResolve = true; + } + + spinResolve(promise) { + if (!(promise instanceof Promise)) { + return promise; + } + let done = false; + let result = null; + let error = null; + gForceExitSpinResolve = false; + promise + .catch(e => { + error = e; + }) + .then(r => { + result = r; + done = true; + }); + + Services.tm.spinEventLoopUntil( + "MigrationUtils.jsm:MU_spinResolve", + () => done || gForceExitSpinResolve + ); + if (!done) { + throw new Error("Forcefully exited event loop."); + } else if (error) { + throw error; + } else { + return result; + } + } + + /** + * Returns the migrator for the given source, if any data is available + * for this source, or if permissions are required in order to read + * data from this source. Returns null otherwise. + * + * @param {string} aKey + * Internal name of the migration source. See `availableMigratorKeys` + * for supported values by OS. + * @returns {Promise<MigratorBase|null>} + * A profile migrator implementing nsIBrowserProfileMigrator, if it can + * import any data, null otherwise. + */ + async getMigrator(aKey) { + let migrator = this.#migrators.get(aKey); + if (!migrator) { + console.error(`Could not find a migrator class for key ${aKey}`); + return null; + } + + try { + if (!migrator) { + return null; + } + + if ( + (await migrator.isSourceAvailable()) || + (!(await migrator.hasPermissions()) && migrator.canGetPermissions()) + ) { + return migrator; + } + + return null; + } catch (ex) { + console.error(ex); + return null; + } + } + + getFileMigrator(aKey) { + let migrator = this.#fileMigrators.get(aKey); + if (!migrator) { + console.error(`Could not find a file migrator class for key ${aKey}`); + return null; + } + return migrator; + } + + /** + * Returns true if a migrator is registered with key aKey. No check is made + * to determine if a profile exists that the migrator can migrate from. + * + * @param {string} aKey + * Internal name of the migration source. See `availableMigratorKeys` + * for supported values by OS. + * @returns {boolean} + */ + migratorExists(aKey) { + return this.#migrators.has(aKey); + } + + /** + * Figure out what is the default browser, and if there is a migrator + * for it, return that migrator's internal name. + * + * For the time being, the "internal name" of a migrator is its contract-id + * trailer (e.g. ie for @mozilla.org/profile/migrator;1?app=browser&type=ie), + * but it will soon be exposed properly. + * + * @returns {string} + */ + getMigratorKeyForDefaultBrowser() { + // Canary uses the same description as Chrome so we can't distinguish them. + // Edge Beta on macOS uses "Microsoft Edge" with no "beta" indication. + const APP_DESC_TO_KEY = { + "Internet Explorer": "ie", + "Microsoft Edge": "edge", + Safari: "safari", + Firefox: "firefox", + Nightly: "firefox", + Opera: "opera", + Vivaldi: "vivaldi", + "Opera GX": "opera-gx", + "Brave Web Browser": "brave", // Windows, Linux + Brave: "brave", // OS X + "Google Chrome": "chrome", // Windows, Linux + Chrome: "chrome", // OS X + Chromium: "chromium", // Windows, OS X + "Chromium Web Browser": "chromium", // Linux + "360\u5b89\u5168\u6d4f\u89c8\u5668": "chromium-360se", + }; + + let key = ""; + try { + let browserDesc = Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .getApplicationDescription("http"); + key = APP_DESC_TO_KEY[browserDesc] || ""; + // Handle devedition, as well as "FirefoxNightly" on OS X. + if (!key && browserDesc.startsWith("Firefox")) { + key = "firefox"; + } + } catch (ex) { + console.error("Could not detect default browser: ", ex); + } + + return key; + } + + /** + * True if we're in the process of a startup migration. + * + * @type {boolean} + */ + get isStartupMigration() { + return gProfileStartup != null; + } + + /** + * In the case of startup migration, this is set to the nsIProfileStartup + * instance passed to ProfileMigrator's migrate. + * + * @see showMigrationWizard + * @type {nsIProfileStartup|null} + */ + get profileStartup() { + return gProfileStartup; + } + + /** + * Show the migration wizard in about:preferences, or if there is not an existing + * browser window open, in a new top-level dialog window. + * + * NB: If you add new consumers, please add a migration entry point constant to + * MIGRATION_ENTRYPOINTS and supply that entrypoint with the entrypoint property + * in the aOptions argument. + * + * @param {Window} [aOpener=null] + * optional; the window that asks to open the wizard. + * @param {object} [aOptions=null] + * optional named arguments for the migration wizard. + * @param {string} [aOptions.entrypoint=undefined] + * migration entry point constant. See MIGRATION_ENTRYPOINTS. + * @param {string} [aOptions.migratorKey=undefined] + * The key for which migrator to use automatically. This is the key that is exposed + * as a static getter on the migrator class. + * @param {MigratorBase} [aOptions.migrator=undefined] + * A migrator instance to use automatically. + * @param {boolean} [aOptions.isStartupMigration=undefined] + * True if this is a startup migration. + * @param {boolean} [aOptions.skipSourceSelection=undefined] + * True if the source selection page of the wizard should be skipped. + * @param {string} [aOptions.profileId] + * An identifier for the profile to use when migrating. + * @returns {Promise<undefined>} + * If an about:preferences tab can be opened, this will resolve when + * that tab has been switched to. Otherwise, this will resolve + * just after opening the top-level dialog window. + */ + showMigrationWizard(aOpener, aOptions) { + // When migration is kicked off from about:welcome, there are + // a few different behaviors that we want to test, controlled + // by a preference that is instrumented for Nimbus. The pref + // has the following possible states: + // + // "autoclose": + // The user will be directed to the migration wizard in + // about:preferences, but once the wizard is dismissed, + // the tab will close. + // + // "standalone": + // The migration wizard will open in a new top-level content + // window. + // + // "default" / other + // The user will be directed to the migration wizard in + // about:preferences. The tab will not close once the + // user closes the wizard. + let aboutWelcomeBehavior = Services.prefs.getCharPref( + "browser.migrate.content-modal.about-welcome-behavior", + "default" + ); + + let entrypoint = aOptions.entrypoint || this.MIGRATION_ENTRYPOINTS.UNKNOWN; + Services.telemetry + .getHistogramById("FX_MIGRATION_ENTRY_POINT_CATEGORICAL") + .add(entrypoint); + + let openStandaloneWindow = blocking => { + let features = "dialog,centerscreen,resizable=no"; + + if (blocking) { + features += ",modal"; + } + + Services.ww.openWindow( + aOpener, + "chrome://browser/content/migration/migration-dialog-window.html", + "_blank", + features, + { + options: aOptions, + } + ); + return Promise.resolve(); + }; + + if (aOptions.isStartupMigration) { + // Record that the uninstaller requested a profile refresh + if (Services.env.get("MOZ_UNINSTALLER_PROFILE_REFRESH")) { + Services.env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", ""); + Services.telemetry.scalarSet( + "migration.uninstaller_profile_refresh", + true + ); + } + + openStandaloneWindow(true /* blocking */); + return Promise.resolve(); + } + + if (aOpener?.openPreferences) { + if (aOptions.entrypoint == this.MIGRATION_ENTRYPOINTS.NEWTAB) { + if (aboutWelcomeBehavior == "autoclose") { + return aOpener.openPreferences("general-migrate-autoclose"); + } else if (aboutWelcomeBehavior == "standalone") { + openStandaloneWindow(false /* blocking */); + return Promise.resolve(); + } + } + return aOpener.openPreferences("general-migrate"); + } + + // If somehow we failed to open about:preferences, fall back to opening + // the top-level window. + openStandaloneWindow(false /* blocking */); + return Promise.resolve(); + } + + /** + * Show the migration wizard for startup-migration. This should only be + * called by ProfileMigrator (see ProfileMigrator.js), which implements + * nsIProfileMigrator. This runs asynchronously if we are running an + * automigration. + * + * @param {nsIProfileStartup} aProfileStartup + * the nsIProfileStartup instance provided to ProfileMigrator.migrate. + * @param {string|null} [aMigratorKey=null] + * If set, the migration wizard will import from the corresponding + * migrator, bypassing the source-selection page. Otherwise, the + * source-selection page will be displayed, either with the default + * browser selected, if it could be detected and if there is a + * migrator for it, or with the first option selected as a fallback + * @param {string|null} [aProfileToMigrate=null] + * If set, the migration wizard will import from the profile indicated. + * @throws + * if aMigratorKey is invalid or if it points to a non-existent + * source. + */ + startupMigration(aProfileStartup, aMigratorKey, aProfileToMigrate) { + this.spinResolve( + this.asyncStartupMigration( + aProfileStartup, + aMigratorKey, + aProfileToMigrate + ) + ); + } + + async asyncStartupMigration( + aProfileStartup, + aMigratorKey, + aProfileToMigrate + ) { + if (!aProfileStartup) { + throw new Error( + "an profile-startup instance is required for startup-migration" + ); + } + gProfileStartup = aProfileStartup; + + let skipSourceSelection = false, + migrator = null, + migratorKey = ""; + if (aMigratorKey) { + migrator = await this.getMigrator(aMigratorKey); + if (!migrator) { + // aMigratorKey must point to a valid source, so, if it doesn't + // cleanup and throw. + this.finishMigration(); + throw new Error( + "startMigration was asked to open auto-migrate from " + + "a non-existent source: " + + aMigratorKey + ); + } + migratorKey = aMigratorKey; + skipSourceSelection = true; + } else { + let defaultBrowserKey = this.getMigratorKeyForDefaultBrowser(); + if (defaultBrowserKey) { + migrator = await this.getMigrator(defaultBrowserKey); + if (migrator) { + migratorKey = defaultBrowserKey; + } + } + } + + if (!migrator) { + let migrators = await Promise.all( + this.availableMigratorKeys.map(key => this.getMigrator(key)) + ); + // If there's no migrator set so far, ensure that there is at least one + // migrator available before opening the wizard. + // Note that we don't need to check the default browser first, because + // if that one existed we would have used it in the block above this one. + if (!migrators.some(m => m)) { + // None of the keys produced a usable migrator, so finish up here: + this.finishMigration(); + return; + } + } + + let isRefresh = + migrator && + skipSourceSelection && + migratorKey == AppConstants.MOZ_APP_NAME; + + let entrypoint = this.MIGRATION_ENTRYPOINTS.FIRSTRUN; + if (isRefresh) { + entrypoint = this.MIGRATION_ENTRYPOINTS.FXREFRESH; + } + + this.showMigrationWizard(null, { + entrypoint, + migratorKey, + migrator, + isStartupMigration: !!aProfileStartup, + skipSourceSelection, + profileId: aProfileToMigrate, + }); + } + + /** + * This is only pseudo-private because some tests and helper functions + * still expect to be able to directly access it. + */ + _importQuantities = { + bookmarks: 0, + logins: 0, + history: 0, + cards: 0, + extensions: 0, + }; + + getImportedCount(type) { + if (!this._importQuantities.hasOwnProperty(type)) { + throw new Error( + `Unknown import data type "${type}" passed to getImportedCount` + ); + } + return this._importQuantities[type]; + } + + insertBookmarkWrapper(bookmark) { + this._importQuantities.bookmarks++; + let insertionPromise = lazy.PlacesUtils.bookmarks.insert(bookmark); + if (!gKeepUndoData) { + return insertionPromise; + } + // If we keep undo data, add a promise handler that stores the undo data once + // the bookmark has been inserted in the DB, and then returns the bookmark. + let { parentGuid } = bookmark; + return insertionPromise.then(bm => { + let { guid, lastModified, type } = bm; + gUndoData.get("bookmarks").push({ + parentGuid, + guid, + lastModified, + type, + }); + return bm; + }); + } + + insertManyBookmarksWrapper(bookmarks, parent) { + let insertionPromise = lazy.PlacesUtils.bookmarks.insertTree({ + guid: parent, + children: bookmarks, + }); + return insertionPromise.then( + insertedItems => { + this._importQuantities.bookmarks += insertedItems.length; + if (gKeepUndoData) { + let bmData = gUndoData.get("bookmarks"); + for (let bm of insertedItems) { + let { parentGuid, guid, lastModified, type } = bm; + bmData.push({ parentGuid, guid, lastModified, type }); + } + } + if (parent == lazy.PlacesUtils.bookmarks.toolbarGuid) { + lazy.PlacesUIUtils.maybeToggleBookmarkToolbarVisibility( + true /* aForceVisible */ + ).catch(console.error); + } + }, + ex => console.error(ex) + ); + } + + insertVisitsWrapper(pageInfos) { + let now = new Date(); + // Ensure that none of the dates are in the future. If they are, rewrite + // them to be now. This means we don't loose history entries, but they will + // be valid for the history store. + for (let pageInfo of pageInfos) { + for (let visit of pageInfo.visits) { + if (visit.date && visit.date > now) { + visit.date = now; + } + } + } + this._importQuantities.history += pageInfos.length; + if (gKeepUndoData) { + this.#updateHistoryUndo(pageInfos); + } + return lazy.PlacesUtils.history.insertMany(pageInfos); + } + + async insertLoginsWrapper(logins) { + this._importQuantities.logins += logins.length; + let inserted = await lazy.LoginHelper.maybeImportLogins(logins); + // Note that this means that if we import a login that has a newer password + // than we know about, we will update the login, and an undo of the import + // will not revert this. This seems preferable over removing the login + // outright or storing the old password in the undo file. + if (gKeepUndoData) { + for (let { guid, timePasswordChanged } of inserted) { + gUndoData.get("logins").push({ guid, timePasswordChanged }); + } + } + } + + /** + * Iterates through the favicons, sniffs for a mime type, + * and uses the mime type to properly import the favicon. + * + * @param {object[]} favicons + * An array of Objects with these properties: + * {Uint8Array} faviconData: The binary data of a favicon + * {nsIURI} uri: The URI of the associated page + */ + insertManyFavicons(favicons) { + let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance( + Ci.nsIContentSniffer + ); + for (let faviconDataItem of favicons) { + let mimeType = sniffer.getMIMETypeFromContent( + null, + faviconDataItem.faviconData, + faviconDataItem.faviconData.length + ); + let fakeFaviconURI = Services.io.newURI( + "fake-favicon-uri:" + faviconDataItem.uri.spec + ); + lazy.PlacesUtils.favicons.replaceFaviconData( + fakeFaviconURI, + faviconDataItem.faviconData, + mimeType + ); + lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + faviconDataItem.uri, + fakeFaviconURI, + true, + lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } + } + + async insertCreditCardsWrapper(cards) { + this._importQuantities.cards += cards.length; + let { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + + await formAutofillStorage.initialize(); + for (let card of cards) { + try { + await formAutofillStorage.creditCards.add(card); + } catch (e) { + console.error("Failed to insert credit card due to error: ", e, card); + } + } + } + + /** + * Responsible for calling the AddonManager API that ultimately installs the + * matched add-ons. + * + * @param {string} migratorKey a migrator key that we pass to + * `AMBrowserExtensionsImport` as the "browser + * identifier" used to match add-ons + * @param {string[]} extensionIDs a list of extension IDs from another browser + */ + async installExtensionsWrapper(migratorKey, extensionIDs) { + const totalExtensions = extensionIDs.length; + + let importedAddonIDs = []; + try { + const result = await lazy.AMBrowserExtensionsImport.stageInstalls( + migratorKey, + extensionIDs + ); + importedAddonIDs = result.importedAddonIDs; + } catch (e) { + console.error(`Failed to import extensions: ${e}`); + } + + this._importQuantities.extensions += importedAddonIDs.length; + + if (!importedAddonIDs.length) { + return [ + lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING, + importedAddonIDs, + ]; + } + if (totalExtensions == importedAddonIDs.length) { + return [ + lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + importedAddonIDs, + ]; + } + return [ + lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO, + importedAddonIDs, + ]; + } + + initializeUndoData() { + gKeepUndoData = true; + gUndoData = new Map([ + ["bookmarks", []], + ["visits", []], + ["logins", []], + ]); + } + + async #postProcessUndoData(state) { + if (!state) { + return state; + } + let bookmarkFolders = state + .get("bookmarks") + .filter(b => b.type == lazy.PlacesUtils.bookmarks.TYPE_FOLDER); + + let bookmarkFolderData = []; + let bmPromises = bookmarkFolders.map(({ guid }) => { + // Ignore bookmarks where the promise doesn't resolve (ie that are missing) + // Also check that the bookmark fetch returns isn't null before adding it. + return lazy.PlacesUtils.bookmarks.fetch(guid).then( + bm => bm && bookmarkFolderData.push(bm), + () => {} + ); + }); + + await Promise.all(bmPromises); + let folderLMMap = new Map( + bookmarkFolderData.map(b => [b.guid, b.lastModified]) + ); + for (let bookmark of bookmarkFolders) { + let lastModified = folderLMMap.get(bookmark.guid); + // If the bookmark was deleted, the map will be returning null, so check: + if (lastModified) { + bookmark.lastModified = lastModified; + } + } + return state; + } + + stopAndRetrieveUndoData() { + let undoData = gUndoData; + gUndoData = null; + gKeepUndoData = false; + return this.#postProcessUndoData(undoData); + } + + #updateHistoryUndo(pageInfos) { + let visits = gUndoData.get("visits"); + let visitMap = new Map(visits.map(v => [v.url, v])); + for (let pageInfo of pageInfos) { + let visitCount = pageInfo.visits.length; + let first, last; + if (visitCount > 1) { + let dates = pageInfo.visits.map(v => v.date); + first = Math.min.apply(Math, dates); + last = Math.max.apply(Math, dates); + } else { + first = last = pageInfo.visits[0].date; + } + let url = pageInfo.url; + if (url instanceof Ci.nsIURI) { + url = pageInfo.url.spec; + } else if (typeof url != "string") { + pageInfo.url.href; + } + + try { + new URL(url); + } catch (ex) { + // This won't save and we won't need to 'undo' it, so ignore this URL. + continue; + } + if (!visitMap.has(url)) { + visitMap.set(url, { url, visitCount, first, last }); + } else { + let currentData = visitMap.get(url); + currentData.visitCount += visitCount; + currentData.first = Math.min(currentData.first, first); + currentData.last = Math.max(currentData.last, last); + } + } + gUndoData.set("visits", Array.from(visitMap.values())); + } + + /** + * Cleans up references to migrators and nsIProfileInstance instances. + */ + finishMigration() { + gMigrators = null; + gProfileStartup = null; + gL10n = null; + } + + get availableMigratorKeys() { + return [...this.#migrators.keys()]; + } + + get availableFileMigrators() { + return [...this.#fileMigrators.values()]; + } + + /** + * Enum for the entrypoint that is being used to start migration. + * Callers can use the MIGRATION_ENTRYPOINTS getter to use these. + * + * These values are what's written into the + * FX_MIGRATION_ENTRY_POINT_CATEGORICAL histogram after a migration. + * + * @see MIGRATION_ENTRYPOINTS + * @readonly + * @enum {string} + */ + #MIGRATION_ENTRYPOINTS_ENUM = Object.freeze({ + /** The entrypoint was not supplied */ + UNKNOWN: "unknown", + + /** Migration is occurring at startup */ + FIRSTRUN: "firstrun", + + /** Migration is occurring at after a profile refresh */ + FXREFRESH: "fxrefresh", + + /** Migration is being started from the Library window */ + PLACES: "places", + + /** Migration is being started from our password management UI */ + PASSWORDS: "passwords", + + /** Migration is being started from the default about:home/about:newtab */ + NEWTAB: "newtab", + + /** Migration is being started from the File menu */ + FILE_MENU: "file_menu", + + /** Migration is being started from the Help menu */ + HELP_MENU: "help_menu", + + /** Migration is being started from the Bookmarks Toolbar */ + BOOKMARKS_TOOLBAR: "bookmarks_toolbar", + + /** Migration is being started from about:preferences */ + PREFERENCES: "preferences", + + /** Migration is being started from about:firefoxview */ + FIREFOX_VIEW: "firefox_view", + }); + + /** + * Returns an enum that should be used to record the entrypoint for + * starting a migration. + * + * @returns {number} + */ + get MIGRATION_ENTRYPOINTS() { + return this.#MIGRATION_ENTRYPOINTS_ENUM; + } + + /** + * Enum for the numeric value written to the FX_MIGRATION_SOURCE_BROWSER. + * histogram + * + * @see getSourceIdForTelemetry + * @readonly + * @enum {number} + */ + #SOURCE_NAME_TO_ID_MAPPING_ENUM = Object.freeze({ + nothing: 1, + firefox: 2, + edge: 3, + ie: 4, + chrome: 5, + "chrome-beta": 5, + "chrome-dev": 5, + chromium: 6, + canary: 7, + safari: 8, + "chromium-360se": 9, + "chromium-edge": 10, + "chromium-edge-beta": 10, + brave: 11, + opera: 12, + "opera-gx": 14, + vivaldi: 13, + }); + + getSourceIdForTelemetry(sourceName) { + return this.#SOURCE_NAME_TO_ID_MAPPING_ENUM[sourceName] || 0; + } + + get HISTORY_MAX_AGE_IN_MILLISECONDS() { + return this.HISTORY_MAX_AGE_IN_DAYS * 24 * 60 * 60 * 1000; + } + + /** + * Determines whether or not the underlying platform supports creating + * native file pickers that can do folder selection, which is a + * pre-requisite for getting read-access permissions for data from other + * browsers that we can import from. + * + * @returns {Promise<boolean>} + */ + canGetPermissionsOnPlatform() { + return lazy.gCanGetPermissionsOnPlatformPromise; + } +} + +const MigrationUtilsSingleton = new MigrationUtils(); + +export { MigrationUtilsSingleton as MigrationUtils }; diff --git a/browser/components/migration/MigrationWizardChild.sys.mjs b/browser/components/migration/MigrationWizardChild.sys.mjs new file mode 100644 index 0000000000..fc8fe4b19d --- /dev/null +++ b/browser/components/migration/MigrationWizardChild.sys.mjs @@ -0,0 +1,400 @@ +/* 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/. */ + +import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SHOW_IMPORT_ALL_PREF", + "browser.migrate.content-modal.import-all.enabled", + false +); + +/** + * This class is responsible for updating the state of a <migration-wizard> + * component, and for listening for events from that component to perform + * various migration functions. + */ +export class MigrationWizardChild extends JSWindowActorChild { + #wizardEl = null; + + /** + * Retrieves the list of browsers and profiles from the parent process, and then + * puts the migration wizard onto the selection page showing the list that they + * can import from. + * + * @param {boolean} [allowOnlyFileMigrators=null] + * Set to true if showing the selection page is allowed if no browser migrators + * are found. If not true, and no browser migrators are found, then the wizard + * will be sent to the NO_BROWSERS_FOUND page. + * @param {string} [migratorKey=null] + * If set, this will automatically select the first associated migrator with that + * migratorKey in the selector. If not set, the first item in the retrieved list + * of migrators will be selected. + * @param {string} [fileImportErrorMessage=null] + * If set, this will display an error message below the browser / profile selector + * indicating that something had previously gone wrong with an import of type + * MIGRATOR_TYPES.FILE. + */ + async #populateMigrators( + allowOnlyFileMigrators, + migratorKey, + fileImportErrorMessage + ) { + let migrators = await this.sendQuery("GetAvailableMigrators"); + let hasBrowserMigrators = migrators.some(migrator => { + return migrator.type == MigrationWizardConstants.MIGRATOR_TYPES.BROWSER; + }); + let hasFileMigrators = migrators.some(migrator => { + return migrator.type == MigrationWizardConstants.MIGRATOR_TYPES.FILE; + }); + if (!hasBrowserMigrators && !allowOnlyFileMigrators) { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND, + hasFileMigrators, + }); + this.#sendTelemetryEvent("no_browsers_found"); + } else { + this.setComponentState({ + migrators, + page: MigrationWizardConstants.PAGES.SELECTION, + showImportAll: lazy.SHOW_IMPORT_ALL_PREF, + migratorKey, + fileImportErrorMessage, + }); + } + } + + /** + * General event handler function for events dispatched from the + * <migration-wizard> component. + * + * @param {Event} event + * The DOM event being handled. + * @returns {Promise} + */ + async handleEvent(event) { + this.#wizardEl = event.target; + + switch (event.type) { + case "MigrationWizard:RequestState": { + this.#sendTelemetryEvent("opened"); + await this.#requestState(event.detail?.allowOnlyFileMigrators); + break; + } + + case "MigrationWizard:BeginMigration": { + let extraArgs = this.#recordBeginMigrationEvent(event.detail); + + let hasPermissions = await this.sendQuery("CheckPermissions", { + key: event.detail.key, + type: event.detail.type, + }); + + if (!hasPermissions) { + if (event.detail.key == "safari") { + this.#sendTelemetryEvent("safari_perms"); + this.setComponentState({ + page: MigrationWizardConstants.PAGES.SAFARI_PERMISSION, + }); + } else { + console.error( + `A migrator with key ${event.detail.key} needs permissions, ` + + "and no UI exists for that right now." + ); + } + return; + } + + await this.beginMigration(event.detail, extraArgs); + break; + } + + case "MigrationWizard:RequestSafariPermissions": { + let success = await this.sendQuery("RequestSafariPermissions"); + if (success) { + let extraArgs = this.#constructExtraArgs(event.detail); + await this.beginMigration(event.detail, extraArgs); + } + break; + } + + case "MigrationWizard:SelectSafariPasswordFile": { + let path = await this.sendQuery("SelectSafariPasswordFile"); + if (path) { + event.detail.safariPasswordFilePath = path; + + let passwordResourceIndex = event.detail.resourceTypes.indexOf( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ); + event.detail.resourceTypes.splice(passwordResourceIndex, 1); + + let extraArgs = this.#constructExtraArgs(event.detail); + await this.beginMigration(event.detail, extraArgs); + } + break; + } + + case "MigrationWizard:OpenAboutAddons": { + this.sendAsyncMessage("OpenAboutAddons"); + break; + } + + case "MigrationWizard:PermissionsNeeded": { + // In theory, the migrator permissions might be requested on any + // platform - but in practice, this only happens on Linux, so that's + // why the event is named linux_perms. + this.#sendTelemetryEvent("linux_perms", { + migrator_key: event.detail.key, + }); + break; + } + + case "MigrationWizard:GetPermissions": { + let success = await this.sendQuery("GetPermissions", { + key: event.detail.key, + }); + if (success) { + await this.#requestState(true /* allowOnlyFileMigrators */); + } + break; + } + } + } + + async #requestState(allowOnlyFileMigrators) { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.LOADING, + }); + + await this.#populateMigrators(allowOnlyFileMigrators); + + this.#wizardEl.dispatchEvent( + new this.contentWindow.CustomEvent("MigrationWizard:Ready", { + bubbles: true, + }) + ); + } + + /** + * Sends a message to the parent actor to record Event Telemetry. + * + * @param {string} type + * The type of event being recorded. + * @param {object} [args=null] + * Optional extra_args to supply for the event. + */ + #sendTelemetryEvent(type, args) { + this.sendAsyncMessage("RecordEvent", { type, args }); + } + + /** + * Constructs extra arguments to pass to some Event Telemetry based + * on the MigrationDetails passed up from the MigrationWizard. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @returns {object} + */ + #constructExtraArgs(migrationDetails) { + let extraArgs = { + migrator_key: migrationDetails.key, + history: "0", + formdata: "0", + passwords: "0", + bookmarks: "0", + payment_methods: "0", + extensions: "0", + other: 0, + }; + + for (let type of migrationDetails.resourceTypes) { + switch (type) { + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY: { + extraArgs.history = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA: { + extraArgs.formdata = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS: { + extraArgs.passwords = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS: { + extraArgs.bookmarks = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS: { + extraArgs.extensions = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES + .PAYMENT_METHODS: { + extraArgs.payment_methods = "1"; + break; + } + + default: { + extraArgs.other++; + } + } + } + + // Event Telemetry extra arguments expect strings for every value, so + // now we coerce our "other" count into a string. + extraArgs.other = String(extraArgs.other); + return extraArgs; + } + + /** + * This migration wizard combines a lot of steps (selecting the browser, profile, + * resources, and starting the migration) into a single page. This helper method + * records Event Telemetry for each of those actions at the same time when a + * migration begins. + * + * This method returns the extra_args object that was constructed for the + * resources_selected and migration_started event so that a + * "migration_finished" event can use the same extra_args without + * regenerating it. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @returns {object} + */ + #recordBeginMigrationEvent(migrationDetails) { + this.#sendTelemetryEvent("browser_selected", { + migrator_key: migrationDetails.key, + }); + + if (migrationDetails.profile) { + this.#sendTelemetryEvent("profile_selected", { + migrator_key: migrationDetails.key, + }); + } + + let extraArgs = this.#constructExtraArgs(migrationDetails); + + extraArgs.configured = String(Number(migrationDetails.expandedDetails)); + this.#sendTelemetryEvent("resources_selected", extraArgs); + delete extraArgs.configured; + + this.#sendTelemetryEvent("migration_started", extraArgs); + return extraArgs; + } + + /** + * Sends a message to the parent actor to attempt a migration. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @param {object} extraArgs + * Extra argument object to pass to the Event Telemetry for finishing + * the migration. + * @returns {Promise<undefined>} + * Returns a Promise that resolves after the parent responds to the migration + * message. + */ + async beginMigration(migrationDetails, extraArgs) { + if ( + migrationDetails.key == "safari" && + migrationDetails.resourceTypes.includes( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ) && + !migrationDetails.safariPasswordFilePath + ) { + this.#sendTelemetryEvent("safari_password_file"); + this.setComponentState({ + page: MigrationWizardConstants.PAGES.SAFARI_PASSWORD_PERMISSION, + }); + return; + } + + extraArgs = await this.sendQuery("Migrate", { + migrationDetails, + extraArgs, + }); + this.#sendTelemetryEvent("migration_finished", extraArgs); + + this.#wizardEl.dispatchEvent( + new this.contentWindow.CustomEvent("MigrationWizard:DoneMigration", { + bubbles: true, + }) + ); + } + + /** + * General message handler function for messages received from the + * associated MigrationWizardParent JSWindowActor. + * + * @param {ReceiveMessageArgument} message + * The message received from the MigrationWizardParent. + */ + receiveMessage(message) { + switch (message.name) { + case "UpdateProgress": { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + progress: message.data.progress, + key: message.data.key, + }); + break; + } + case "UpdateFileImportProgress": { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS, + progress: message.data.progress, + title: message.data.title, + }); + break; + } + case "FileImportProgressError": { + this.#populateMigrators( + true, + message.data.migratorKey, + message.data.fileImportErrorMessage + ); + break; + } + } + } + + /** + * Calls the `setState` method on the <migration-wizard> component. The + * state is cloned into the execution scope of this.#wizardEl. + * + * @param {object} state The state object that a <migration-wizard> + * component expects. See the documentation for the element's setState + * method for more details. + */ + setComponentState(state) { + if (!this.#wizardEl) { + return; + } + // We waive XrayWrappers in the event that the element is embedded in + // a document without system privileges, like about:welcome. + Cu.waiveXrays(this.#wizardEl).setState( + Cu.cloneInto( + state, + // ownerGlobal doesn't exist in content windows. + // eslint-disable-next-line mozilla/use-ownerGlobal + this.#wizardEl.ownerDocument.defaultView + ) + ); + } +} diff --git a/browser/components/migration/MigrationWizardParent.sys.mjs b/browser/components/migration/MigrationWizardParent.sys.mjs new file mode 100644 index 0000000000..deb0e89007 --- /dev/null +++ b/browser/components/migration/MigrationWizardParent.sys.mjs @@ -0,0 +1,834 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "gFluentStrings", function () { + return new Localization([ + "branding/brand.ftl", + "browser/migrationWizard.ftl", + ]); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + FirefoxProfileMigrator: "resource:///modules/FirefoxProfileMigrator.sys.mjs", + InternalTestingProfileMigrator: + "resource:///modules/InternalTestingProfileMigrator.sys.mjs", + LoginCSVImport: "resource://gre/modules/LoginCSVImport.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", + PasswordFileMigrator: "resource:///modules/FileMigrators.sys.mjs", +}); + +if (AppConstants.platform == "macosx") { + ChromeUtils.defineESModuleGetters(lazy, { + SafariProfileMigrator: "resource:///modules/SafariProfileMigrator.sys.mjs", + }); +} + +/** + * Set to true once the first instance of MigrationWizardParent has received + * a "GetAvailableMigrators" message. + */ +let gHasOpenedBefore = false; + +/** + * This class is responsible for communicating with MigrationUtils to do the + * actual heavy-lifting of any kinds of migration work, based on messages from + * the associated MigrationWizardChild. + */ +export class MigrationWizardParent extends JSWindowActorParent { + constructor() { + super(); + Services.telemetry.setEventRecordingEnabled("browser.migration", true); + } + + didDestroy() { + Services.obs.notifyObservers(this, "MigrationWizard:Destroyed"); + MigrationUtils.finishMigration(); + } + + /** + * General message handler function for messages received from the + * associated MigrationWizardChild JSWindowActor. + * + * @param {ReceiveMessageArgument} message + * The message received from the MigrationWizardChild. + * @returns {Promise} + */ + async receiveMessage(message) { + // Some belt-and-suspenders here, mainly because the migration-wizard + // component can be embedded in less privileged content pages, so let's + // make sure that any messages from content are coming from the privileged + // about content process type. + if ( + !this.browsingContext.currentWindowGlobal.isInProcess && + this.browsingContext.currentRemoteType != + E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE + ) { + throw new Error( + "MigrationWizardParent: received message from the wrong content process type." + ); + } + + switch (message.name) { + case "GetAvailableMigrators": { + let start = Cu.now(); + + let availableMigrators = []; + for (const key of MigrationUtils.availableMigratorKeys) { + availableMigrators.push(this.#getMigratorAndProfiles(key)); + } + + // Wait for all getMigrator calls to resolve in parallel + let results = await Promise.all(availableMigrators); + + for (const migrator of MigrationUtils.availableFileMigrators.values()) { + results.push(await this.#serializeFileMigrator(migrator)); + } + + // Each migrator might give us a single MigratorProfileInstance, + // or an Array of them, so we flatten them out and filter out + // any that ended up going wrong and returning null from the + // #getMigratorAndProfiles call. + let filteredResults = results + .flat() + .filter(result => result) + .sort((a, b) => { + return b.lastModifiedDate - a.lastModifiedDate; + }); + + let elapsed = Cu.now() - start; + if (!gHasOpenedBefore) { + gHasOpenedBefore = true; + Services.telemetry.scalarSet( + "migration.time_to_produce_migrator_list", + elapsed + ); + } + + return filteredResults; + } + + case "Migrate": { + let { migrationDetails, extraArgs } = message.data; + if ( + migrationDetails.type == + lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER + ) { + return this.#doBrowserMigration(migrationDetails, extraArgs); + } else if ( + migrationDetails.type == + lazy.MigrationWizardConstants.MIGRATOR_TYPES.FILE + ) { + let window = this.browsingContext.topChromeWindow; + await this.#doFileMigration(window, migrationDetails.key); + return extraArgs; + } + break; + } + + case "CheckPermissions": { + if ( + message.data.type == + lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER + ) { + let migrator = await MigrationUtils.getMigrator(message.data.key); + return migrator.hasPermissions(); + } + return true; + } + + case "RequestSafariPermissions": { + let safariMigrator = await MigrationUtils.getMigrator("safari"); + return safariMigrator.getPermissions( + this.browsingContext.topChromeWindow + ); + } + + case "SelectSafariPasswordFile": { + return this.#selectSafariPasswordFile( + this.browsingContext.topChromeWindow + ); + } + + case "RecordEvent": { + this.#recordEvent(message.data.type, message.data.args); + break; + } + + case "OpenAboutAddons": { + let browser = this.browsingContext.top.embedderElement; + this.#openAboutAddons(browser); + break; + } + + case "GetPermissions": { + let migrator = await MigrationUtils.getMigrator(message.data.key); + return migrator.getPermissions(this.browsingContext.topChromeWindow); + } + } + + return null; + } + + /** + * Used for recording telemetry in the migration wizard. + * + * @param {string} type + * The type of event being recorded. + * @param {object} args + * The data to pass to telemetry when the event is recorded. + */ + #recordEvent(type, args = null) { + Services.telemetry.recordEvent( + "browser.migration", + type, + "wizard", + null, + args + ); + } + + /** + * Gets the FileMigrator associated with the passed in key, and then opens + * a native file picker configured for that migrator. Once the user selects + * a file from the native file picker, this is then passed to the + * FileMigrator.migrate method. + * + * As the migration occurs, this will send UpdateProgress messages to the + * MigrationWizardChild to show the beginning and then the ending state of + * the migration. + * + * @param {DOMWindow} window + * The window that the native file picker should be associated with. This + * cannot be null. See nsIFilePicker.init for more details. + * @param {string} key + * The unique identification key for a file migrator. + * @returns {Promise<undefined>} + * Resolves once the file migrator's migrate method has resolved. + */ + async #doFileMigration(window, key) { + let fileMigrator = MigrationUtils.getFileMigrator(key); + let filePickerConfig = await fileMigrator.getFilePickerConfig(); + + let { result, path } = await new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(window, filePickerConfig.title, Ci.nsIFilePicker.modeOpen); + + for (let filter of filePickerConfig.filters) { + fp.appendFilter(filter.title, filter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.open(async fileOpenResult => { + resolve({ result: fileOpenResult, path: fp.file.path }); + }); + }); + + if (result == Ci.nsIFilePicker.returnCancel) { + // If the user cancels out of the file picker, the migration wizard should + // still be in the state that lets the user re-open the file picker if + // they closed it by accident, so we don't have to do anything else here. + return; + } + + let progress = {}; + for (let resourceType of fileMigrator.displayedResourceTypes) { + progress[resourceType] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.LOADING, + message: "", + }; + } + + let [progressHeaderString, successHeaderString] = + await lazy.gFluentStrings.formatValues([ + fileMigrator.progressHeaderL10nID, + fileMigrator.successHeaderL10nID, + ]); + + this.sendAsyncMessage("UpdateFileImportProgress", { + title: progressHeaderString, + progress, + }); + + let migrationResult; + try { + migrationResult = await fileMigrator.migrate(path); + } catch (e) { + this.sendAsyncMessage("FileImportProgressError", { + migratorKey: key, + fileImportErrorMessage: e.message, + }); + return; + } + + let successProgress = {}; + for (let resourceType in migrationResult) { + successProgress[resourceType] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: migrationResult[resourceType], + }; + } + this.sendAsyncMessage("UpdateFileImportProgress", { + title: successHeaderString, + progress: successProgress, + }); + } + + /** + * Handles a request to open a native file picker to get the path to a + * CSV file that contains passwords exported from Safari. The returned + * path is in the form of a string, or `null` if the user cancelled the + * native picker. + * + * @param {DOMWindow} window + * The window that the native file picker should be associated with. This + * cannot be null. See nsIFilePicker.init for more details. + * @returns {Promise<string|null>} + */ + async #selectSafariPasswordFile(window) { + let fileMigrator = MigrationUtils.getFileMigrator( + lazy.PasswordFileMigrator.key + ); + let filePickerConfig = await fileMigrator.getFilePickerConfig(); + + let { result, path } = await new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(window, filePickerConfig.title, Ci.nsIFilePicker.modeOpen); + + for (let filter of filePickerConfig.filters) { + fp.appendFilter(filter.title, filter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.open(async fileOpenResult => { + resolve({ result: fileOpenResult, path: fp.file.path }); + }); + }); + + if (result == Ci.nsIFilePicker.returnCancel) { + // If the user cancels out of the file picker, the migration wizard should + // still be in the state that lets the user re-open the file picker if + // they closed it by accident, so we don't have to do anything else here. + return null; + } + + return path; + } + + /** + * Calls into MigrationUtils to perform a migration given the parameters + * sent via the wizard. + * + * @param {MigrationDetails} migrationDetails + * See migration-wizard.mjs for a definition of MigrationDetails. + * @param {object} extraArgs + * Extra argument object that will be passed to the Event Telemetry for + * finishing the migration. This was initialized in the child actor, and + * will be sent back down to it to write to Telemetry once migration + * completes. + * + * @returns {Promise<object>} + * Resolves once the Migration:Ended observer notification has fired, + * passing the extraArgs for Telemetry back with any relevant properties + * updated. + */ + async #doBrowserMigration(migrationDetails, extraArgs) { + Services.telemetry + .getHistogramById("FX_MIGRATION_SOURCE_BROWSER") + .add(MigrationUtils.getSourceIdForTelemetry(migrationDetails.key)); + + let migrator = await MigrationUtils.getMigrator(migrationDetails.key); + let availableResourceTypes = await migrator.getMigrateData( + migrationDetails.profile + ); + let resourceTypesToMigrate = 0; + let progress = {}; + let migrationUsageHist = + Services.telemetry.getKeyedHistogramById("FX_MIGRATION_USAGE"); + + for (let resourceTypeName of migrationDetails.resourceTypes) { + let resourceType = MigrationUtils.resourceTypes[resourceTypeName]; + if (availableResourceTypes & resourceType) { + resourceTypesToMigrate |= resourceType; + progress[resourceTypeName] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.LOADING, + message: "", + }; + + if (!migrationDetails.autoMigration) { + migrationUsageHist.add(migrationDetails.key, Math.log2(resourceType)); + } + } + } + + if ( + migrationDetails.key == lazy.SafariProfileMigrator?.key && + migrationDetails.safariPasswordFilePath + ) { + // The caller supplied a password export file for Safari. We're going to + // pretend that there was a PASSWORDS resource for Safari to represent + // the state of importing from that file. + progress[ + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.LOADING, + message: "", + }; + + this.sendAsyncMessage("UpdateProgress", { + key: migrationDetails.key, + progress, + }); + + try { + let summary = await lazy.LoginCSVImport.importFromCSV( + migrationDetails.safariPasswordFilePath + ); + let quantity = summary.filter(entry => entry.result == "added").length; + + progress[ + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-passwords", + { + quantity, + } + ), + }; + } catch (e) { + progress[ + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING, + message: await lazy.gFluentStrings.formatValue( + "migration-passwords-from-file-no-valid-data" + ), + }; + } + } + + this.sendAsyncMessage("UpdateProgress", { + key: migrationDetails.key, + progress, + }); + + // It's possible that only a Safari password file path was sent up, and + // there's nothing left to migrate, in which case we're done here. + if ( + migrationDetails.safariPasswordFilePath && + !migrationDetails.resourceTypes.length + ) { + return extraArgs; + } + + try { + await migrator.migrate( + resourceTypesToMigrate, + false, + migrationDetails.profile, + async (resourceTypeNum, success, details) => { + // Unfortunately, MigratorBase hands us the the numeric value of the + // MigrationUtils.resourceType for this callback. For now, we'll just + // do a look-up to map it to the right constant. + let foundResourceTypeName; + for (let resourceTypeName in MigrationUtils.resourceTypes) { + if ( + MigrationUtils.resourceTypes[resourceTypeName] == resourceTypeNum + ) { + foundResourceTypeName = resourceTypeName; + break; + } + } + + if (!foundResourceTypeName) { + console.error( + "Could not find a resource type for value: ", + resourceTypeNum + ); + } else { + if (!success) { + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_ERRORS") + .add(migrationDetails.key, Math.log2(resourceTypeNum)); + } + if ( + foundResourceTypeName == + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS + ) { + if (!success) { + // did not match any extensions + extraArgs.extensions = + lazy.MigrationWizardConstants.EXTENSIONS_IMPORT_RESULT.NONE_MATCHED; + progress[foundResourceTypeName] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING, + message: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-no-matched-extensions" + ), + linkURL: Services.urlFormatter.formatURLPref( + "extensions.getAddons.link.url" + ), + linkText: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-extensions-addons-link" + ), + }; + } else if ( + details?.progressValue == + lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS + ) { + // did match all extensions + extraArgs.extensions = + lazy.MigrationWizardConstants.EXTENSIONS_IMPORT_RESULT.ALL_MATCHED; + progress[foundResourceTypeName] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-extensions", + { + quantity: details.totalExtensions.length, + } + ), + }; + } else if ( + details?.progressValue == + lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO + ) { + // did match some extensions + extraArgs.extensions = + lazy.MigrationWizardConstants.EXTENSIONS_IMPORT_RESULT.PARTIAL_MATCH; + progress[foundResourceTypeName] = { + value: lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO, + message: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-partial-success-extensions", + { + matched: details.importedExtensions.length, + quantity: details.totalExtensions.length, + } + ), + linkURL: + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "import-data-another-browser", + linkText: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-extensions-support-link" + ), + }; + } + } else { + progress[foundResourceTypeName] = { + value: success + ? lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS + : lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING, + message: await this.#getStringForImportQuantity( + migrationDetails.key, + foundResourceTypeName + ), + }; + } + this.sendAsyncMessage("UpdateProgress", { + key: migrationDetails.key, + progress, + }); + } + } + ); + } catch (e) { + console.error(e); + } + + return extraArgs; + } + + /** + * @typedef {object} MigratorProfileInstance + * An object that describes a single user profile (or the default + * user profile) for a particular migrator. + * @property {string} key + * The unique identification key for a migrator. + * @property {string} displayName + * The display name for the migrator that will be shown to the user + * in the wizard. + * @property {string[]} resourceTypes + * An array of strings, where each string represents a resource type + * that can be imported for this migrator and profile. The strings + * should be one of the key values of + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * + * Example: ["HISTORY", "FORMDATA", "PASSWORDS", "BOOKMARKS"] + * @property {object|null} profile + * A description of the user profile that the migrator can import. + * @property {string} profile.id + * A unique ID for the user profile. + * @property {string} profile.name + * The display name for the user profile. + */ + + /** + * Asynchronously fetches a migrator for a particular key, and then + * also gets any user profiles that exist on for that migrator. Resolves + * to null if something goes wrong getting information about the migrator + * or any of the user profiles. + * + * @param {string} key + * The unique identification key for a migrator. + * @returns {Promise<MigratorProfileInstance[]|null>} + */ + async #getMigratorAndProfiles(key) { + try { + let migrator = await MigrationUtils.getMigrator(key); + if (!migrator?.enabled) { + return null; + } + + if (!(await migrator.hasPermissions())) { + // If we're unable to get permissions for this migrator, then we + // just don't bother showing it. + let permissionsPath = await migrator.canGetPermissions(); + if (!permissionsPath) { + return null; + } + return this.#serializeMigratorAndProfile( + migrator, + null, + false /* hasPermissions */, + permissionsPath + ); + } + + let sourceProfiles = await migrator.getSourceProfiles(); + if (Array.isArray(sourceProfiles)) { + if (!sourceProfiles.length) { + return null; + } + + Services.telemetry.keyedScalarAdd( + "migration.discovered_migrators", + key, + sourceProfiles.length + ); + + let result = []; + for (let profile of sourceProfiles) { + result.push( + await this.#serializeMigratorAndProfile(migrator, profile) + ); + } + return result; + } + + Services.telemetry.keyedScalarAdd( + "migration.discovered_migrators", + key, + 1 + ); + return this.#serializeMigratorAndProfile(migrator, sourceProfiles); + } catch (e) { + console.error(`Could not get migrator with key ${key}`, e); + } + return null; + } + + /** + * Asynchronously fetches information about what resource types can be + * migrated for a particular migrator and user profile, and then packages + * the migrator, user profile data, and resource type data into an object + * that can be sent down to the MigrationWizardChild. + * + * @param {MigratorBase} migrator + * A migrator subclass of MigratorBase. + * @param {object|null} profileObj + * The user profile object representing the profile to get information + * about. This object is usually gotten by calling getSourceProfiles on + * the migrator. + * @param {boolean} [hasPermissions=true] + * Whether or not the migrator has permission to read the data for the + * other browser. It is expected that the caller will have already + * computed this by calling hasPermissions() on the migrator, and + * passing the result into this method. This is true by default. + * @param {string} [permissionsPath=undefined] + * The path that the selected migrator needs read access to in order to + * do a migration, in the event that hasPermissions is false. This is + * undefined if hasPermissions is true. + * @returns {Promise<MigratorProfileInstance>} + */ + async #serializeMigratorAndProfile( + migrator, + profileObj, + hasPermissions = true, + permissionsPath + ) { + let [profileMigrationData, lastModifiedDate] = await Promise.all([ + migrator.getMigrateData(profileObj), + migrator.getLastUsedDate(), + ]); + + let availableResourceTypes = []; + + // Even if we don't have permissions, we'll show the resources available + // for Safari. For Safari, the workflow is to request permissions only + // after the resources have been selected. + if ( + hasPermissions || + migrator.constructor.key == lazy.SafariProfileMigrator?.key + ) { + for (let resourceType in MigrationUtils.resourceTypes) { + // Normally, we check each possible resourceType to see if we have one or + // more corresponding resourceTypes in profileMigrationData. The exception + // is for Safari, where the migrator does not expose a PASSWORDS resource + // type, but we allow the user to express that they'd like to import + // passwords from it anyways. This is because the Safari migration flow is + // special, and allows the user to import passwords from a file exported + // from Safari. + if ( + profileMigrationData & MigrationUtils.resourceTypes[resourceType] || + (migrator.constructor.key == lazy.SafariProfileMigrator?.key && + MigrationUtils.resourceTypes[resourceType] == + MigrationUtils.resourceTypes.PASSWORDS && + Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled", + false + )) + ) { + availableResourceTypes.push(resourceType); + } + } + } + + let displayName; + + if (migrator.constructor.key == lazy.InternalTestingProfileMigrator.key) { + // In the case of the InternalTestingProfileMigrator, which is never seen + // by users outside of testing, we don't make our localization community + // localize it's display name, and just display the ID instead. + displayName = migrator.constructor.displayNameL10nID; + } else { + displayName = await lazy.gFluentStrings.formatValue( + migrator.constructor.displayNameL10nID + ); + } + + return { + type: lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + key: migrator.constructor.key, + displayName, + brandImage: migrator.constructor.brandImage, + resourceTypes: availableResourceTypes, + profile: profileObj, + lastModifiedDate, + hasPermissions, + permissionsPath, + }; + } + + /** + * Returns the "success" string for a particular resource type after + * migration has completed. + * + * @param {string} migratorKey + * The key for the migrator being used. + * @param {string} resourceTypeStr + * A string mapping to one of the key values of + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @returns {Promise<string>} + * The success string for the resource type after migration has completed. + */ + #getStringForImportQuantity(migratorKey, resourceTypeStr) { + if (migratorKey == lazy.FirefoxProfileMigrator.key) { + return ""; + } + + switch (resourceTypeStr) { + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS: { + let quantity = MigrationUtils.getImportedCount("bookmarks"); + let stringID = "migration-wizard-progress-success-bookmarks"; + + if ( + lazy.MigrationWizardConstants.USES_FAVORITES.includes(migratorKey) + ) { + stringID = "migration-wizard-progress-success-favorites"; + } + + return lazy.gFluentStrings.formatValue(stringID, { + quantity, + }); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY: { + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-history", + { + maxAgeInDays: MigrationUtils.HISTORY_MAX_AGE_IN_DAYS, + } + ); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS: { + let quantity = MigrationUtils.getImportedCount("logins"); + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-passwords", + { + quantity, + } + ); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA: { + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-formdata" + ); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES + .PAYMENT_METHODS: { + let quantity = MigrationUtils.getImportedCount("cards"); + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-payment-methods", + { + quantity, + } + ); + } + default: { + return ""; + } + } + } + + /** + * Returns a Promise that resolves to a serializable representation of a + * FileMigrator for sending down to the MigrationWizard. + * + * @param {FileMigrator} fileMigrator + * The FileMigrator to serialize. + * @returns {Promise<object|null>} + * The serializable representation of the FileMigrator, or null if the + * migrator is disabled. + */ + async #serializeFileMigrator(fileMigrator) { + if (!fileMigrator.enabled) { + return null; + } + + return { + type: lazy.MigrationWizardConstants.MIGRATOR_TYPES.FILE, + key: fileMigrator.constructor.key, + displayName: await lazy.gFluentStrings.formatValue( + fileMigrator.constructor.displayNameL10nID + ), + brandImage: fileMigrator.constructor.brandImage, + resourceTypes: [], + }; + } + + /** + * Opens the about:addons page in a new background tab in the same window + * as the passed browser. + * + * @param {Element} browser + * The browser element requesting that about:addons opens. + */ + #openAboutAddons(browser) { + let window = browser.ownerGlobal; + window.openTrustedLinkIn("about:addons", "tab", { inBackground: true }); + } +} diff --git a/browser/components/migration/MigratorBase.sys.mjs b/browser/components/migration/MigratorBase.sys.mjs new file mode 100644 index 0000000000..52bfc87b3e --- /dev/null +++ b/browser/components/migration/MigratorBase.sys.mjs @@ -0,0 +1,599 @@ +/* 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/. */ + +const TOPIC_WILL_IMPORT_BOOKMARKS = + "initial-migration-will-import-default-bookmarks"; +const TOPIC_DID_IMPORT_BOOKMARKS = + "initial-migration-did-import-default-bookmarks"; +const TOPIC_PLACES_DEFAULTS_FINISHED = "places-browser-init-complete"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs", + FirefoxProfileMigrator: "resource:///modules/FirefoxProfileMigrator.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + ResponsivenessMonitor: "resource://gre/modules/ResponsivenessMonitor.sys.mjs", +}); + +/** + * @typedef {object} MigratorResource + * A resource returned by a subclass of MigratorBase that can migrate + * data to this browser. + * @property {number} type + * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate + * what this resource represents. A resource can represent one or more types + * of data, for example HISTORY and FORMDATA. + * @property {Function} migrate + * A function that will actually perform the migration of this resource's + * data into this browser. + */ + +/** + * Shared prototype for migrators. + * + * To implement a migrator: + * 1. Import this module. + * 2. Create a subclass of MigratorBase for your new migrator. + * 3. Override the `key` static getter with a unique identifier for the browser + * that this migrator migrates from. + * 4. If the migrator supports multiple profiles, override the sourceProfiles + * Here we default for single-profile migrator. + * 5. Implement getResources(aProfile) (see below). + * 6. For startup-only migrators, override |startupOnlyMigrator|. + * 7. Add the migrator to the MIGRATOR_MODULES structure in MigrationUtils.sys.mjs. + */ +export class MigratorBase { + /** + * This must be overridden to return a simple string identifier for the + * migrator, for example "firefox", "chrome", "opera-gx". This key is what + * is used as an identifier when calling MigrationUtils.getMigrator. + * + * @type {string} + */ + static get key() { + throw new Error("MigratorBase.key must be overridden."); + } + + /** + * This must be overridden to return a Fluent string ID mapping to the display + * name for this migrator. These strings should be defined in migrationWizard.ftl. + * + * @type {string} + */ + static get displayNameL10nID() { + throw new Error("MigratorBase.displayNameL10nID must be overridden."); + } + + /** + * This method should get overridden to return an icon url of the browser + * to be imported from. By default, this will just use the default Favicon + * image. + * + * @type {string} + */ + static get brandImage() { + return "chrome://global/skin/icons/defaultFavicon.svg"; + } + + /** + * OVERRIDE IF AND ONLY IF the source supports multiple profiles. + * + * Returns array of profile objects from which data may be imported. The object + * should have the following keys: + * id - a unique string identifier for the profile + * name - a pretty name to display to the user in the UI + * + * Only profiles from which data can be imported should be listed. Otherwise + * the behavior of the migration wizard isn't well-defined. + * + * For a single-profile source (e.g. safari, ie), this returns null, + * and not an empty array. That is the default implementation. + * + * @abstract + * @returns {object[]|null} + */ + getSourceProfiles() { + return null; + } + + /** + * MUST BE OVERRIDDEN. + * + * Returns an array of "migration resources" objects for the given profile, + * or for the "default" profile, if the migrator does not support multiple + * profiles. + * + * Each migration resource should provide: + * - a |type| getter, returning any of the migration resource types (see + * MigrationUtils.resourceTypes). + * + * - a |migrate| method, taking two arguments, + * aCallback(bool success, object details), for migrating the data for + * this resource. It may do its job synchronously or asynchronously. + * Either way, it must call aCallback(bool aSuccess, object details) + * when it's done. In the case of an exception thrown from |migrate|, + * it's taken as if aCallback(false, {}) is called. The details + * argument is sometimes optional, but conditional on how the + * migration wizard wants to display the migration state for the + * resource. + * + * Note: In the case of a simple asynchronous implementation, you may find + * MigrationUtils.wrapMigrateFunction handy for handling aCallback easily. + * + * For each migration type listed in MigrationUtils.resourceTypes, multiple + * migration resources may be provided. This practice is useful when the + * data for a certain migration type is independently stored in few + * locations. For example, the mac version of Safari stores its "reading list" + * bookmarks in a separate property list. + * + * Note that the importation of a particular migration type is reported as + * successful if _any_ of its resources succeeded to import (that is, called, + * |aCallback(true, {})|). However, completion-status for a particular migration + * type is reported to the UI only once all of its migrators have called + * aCallback. + * + * NOTE: The returned array should only include resources from which data + * can be imported. So, for example, before adding a resource for the + * BOOKMARKS migration type, you should check if you should check that the + * bookmarks file exists. + * + * @abstract + * @param {object|string} aProfile + * The profile from which data may be imported, or an empty string + * in the case of a single-profile migrator. + * In the case of multiple-profiles migrator, it is guaranteed that + * aProfile is a value returned by the sourceProfiles getter (see + * above). + * @returns {Promise<MigratorResource[]>|MigratorResource[]} + */ + // eslint-disable-next-line no-unused-vars + getResources(aProfile) { + throw new Error("getResources must be overridden"); + } + + /** + * OVERRIDE in order to provide an estimate of when the last time was + * that somebody used the browser. It is OK that this is somewhat fuzzy - + * history may not be available (or be wiped or not present due to e.g. + * incognito mode). + * + * If not overridden, the promise will resolve to the Unix epoch. + * + * @returns {Promise<Date>} + * A Promise that resolves to the last used date. + */ + getLastUsedDate() { + return Promise.resolve(new Date(0)); + } + + /** + * OVERRIDE IF AND ONLY IF the migrator is a startup-only migrator (For now, + * that is just the Firefox migrator, see bug 737381). Default: false. + * + * Startup-only migrators are different in two ways: + * - they may only be used during startup. + * - the user-profile is half baked during migration. The folder exists, + * but it's only accessible through MigrationUtils.profileStartup. + * The migrator can call MigrationUtils.profileStartup.doStartup + * at any point in order to initialize the profile. + * + * @returns {boolean} + * true if the migrator is start-up only. + */ + get startupOnlyMigrator() { + return false; + } + + /** + * Returns true if the migrator is configured to be enabled. This is + * controlled by the `browser.migrate.<BROWSER_KEY>.enabled` boolean + * preference. + * + * @returns {boolean} + * true if the migrator should be shown in the migration wizard. + */ + get enabled() { + let key = this.constructor.key; + return Services.prefs.getBoolPref(`browser.migrate.${key}.enabled`, false); + } + + /** + * Subclasses should implement this if special checks need to be made to determine + * if certain permissions need to be requested before data can be imported. + * The returned Promise resolves to true if the required permissions have + * been granted and a migration could proceed. + * + * @returns {Promise<boolean>} + */ + async hasPermissions() { + return Promise.resolve(true); + } + + /** + * Subclasses should implement this if special permissions need to be + * requested from the user or the operating system in order to perform + * a migration with this MigratorBase. This will be called only if + * hasPermissions resolves to false. + * + * The returned Promise will resolve to true if permissions were successfully + * obtained, and false otherwise. Implementors should ensure that if a call + * to getPermissions resolves to true, that the MigratorBase will be able to + * get read access to all of the resources it needs to do a migration. + * + * @param {DOMWindow} win + * The top-level DOM window hosting the UI that is requesting the permission. + * This can be used to, for example, anchor a file picker window to the + * same window that is hosting the migration UI. + * @returns {Promise<boolean>} + */ + // eslint-disable-next-line no-unused-vars + async getPermissions(win) { + return Promise.resolve(true); + } + + /** + * @returns {Promise<boolean|string>} + */ + async canGetPermissions() { + return Promise.resolve(false); + } + + /** + * This method returns a number that is the bitwise OR of all resource + * types that are available in aProfile. See MigrationUtils.resourceTypes + * for each resource type. + * + * @param {object|string} aProfile + * The profile from which data may be imported, or an empty string + * in the case of a single-profile migrator. + * @returns {number} + */ + async getMigrateData(aProfile) { + let resources = await this.#getMaybeCachedResources(aProfile); + if (!resources) { + return 0; + } + let types = resources.map(r => r.type); + return types.reduce((a, b) => { + a |= b; + return a; + }, 0); + } + + /** + * @see MigrationUtils + * + * @param {number} aItems + * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate + * what types of resources should be migrated. + * @param {boolean} aStartup + * True if this migration is occurring during startup. + * @param {object|string} aProfile + * The other browser profile that is being migrated from. + * @param {Function|null} aProgressCallback + * An optional callback that will be fired once a resourceType has finished + * migrating. The callback will be passed the numeric representation of the + * resource type followed by a boolean indicating whether or not the resource + * was migrated successfully and optionally an object containing additional + * details. + */ + async migrate(aItems, aStartup, aProfile, aProgressCallback = () => {}) { + let resources = await this.#getMaybeCachedResources(aProfile); + if (!resources.length) { + throw new Error("migrate called for a non-existent source"); + } + + if (aItems != lazy.MigrationUtils.resourceTypes.ALL) { + resources = resources.filter(r => aItems & r.type); + } + + // Used to periodically give back control to the main-thread loop. + let unblockMainThread = function () { + return new Promise(resolve => { + Services.tm.dispatchToMainThread(resolve); + }); + }; + + let getHistogramIdForResourceType = (resourceType, template) => { + if (resourceType == lazy.MigrationUtils.resourceTypes.HISTORY) { + return template.replace("*", "HISTORY"); + } + if (resourceType == lazy.MigrationUtils.resourceTypes.BOOKMARKS) { + return template.replace("*", "BOOKMARKS"); + } + if (resourceType == lazy.MigrationUtils.resourceTypes.PASSWORDS) { + return template.replace("*", "LOGINS"); + } + return null; + }; + + let browserKey = this.constructor.key; + + let maybeStartTelemetryStopwatch = resourceType => { + let histogramId = getHistogramIdForResourceType( + resourceType, + "FX_MIGRATION_*_IMPORT_MS" + ); + if (histogramId) { + TelemetryStopwatch.startKeyed(histogramId, browserKey); + } + return histogramId; + }; + + let maybeStartResponsivenessMonitor = resourceType => { + let responsivenessMonitor; + let responsivenessHistogramId = getHistogramIdForResourceType( + resourceType, + "FX_MIGRATION_*_JANK_MS" + ); + if (responsivenessHistogramId) { + responsivenessMonitor = new lazy.ResponsivenessMonitor(); + } + return { responsivenessMonitor, responsivenessHistogramId }; + }; + + let maybeFinishResponsivenessMonitor = ( + responsivenessMonitor, + histogramId + ) => { + if (responsivenessMonitor) { + let accumulatedDelay = responsivenessMonitor.finish(); + if (histogramId) { + try { + Services.telemetry + .getKeyedHistogramById(histogramId) + .add(browserKey, accumulatedDelay); + } catch (ex) { + console.error(histogramId, ": ", ex); + } + } + } + }; + + let collectQuantityTelemetry = () => { + for (let resourceType of Object.keys( + lazy.MigrationUtils._importQuantities + )) { + let histogramId = + "FX_MIGRATION_" + resourceType.toUpperCase() + "_QUANTITY"; + try { + Services.telemetry + .getKeyedHistogramById(histogramId) + .add( + browserKey, + lazy.MigrationUtils._importQuantities[resourceType] + ); + } catch (ex) { + console.error(histogramId, ": ", ex); + } + } + }; + + let collectMigrationTelemetry = resourceType => { + // We don't want to collect this if the migration is occurring due to a + // profile refresh. + if (this.constructor.key == lazy.FirefoxProfileMigrator.key) { + return; + } + + let prefKey = null; + switch (resourceType) { + case lazy.MigrationUtils.resourceTypes.BOOKMARKS: { + prefKey = "browser.migrate.interactions.bookmarks"; + break; + } + case lazy.MigrationUtils.resourceTypes.HISTORY: { + prefKey = "browser.migrate.interactions.history"; + break; + } + case lazy.MigrationUtils.resourceTypes.PASSWORDS: { + prefKey = "browser.migrate.interactions.passwords"; + break; + } + default: { + return; + } + } + + if (prefKey) { + Services.prefs.setBoolPref(prefKey, true); + } + }; + + // Called either directly or through the bookmarks import callback. + let doMigrate = async function () { + let resourcesGroupedByItems = new Map(); + resources.forEach(function (resource) { + if (!resourcesGroupedByItems.has(resource.type)) { + resourcesGroupedByItems.set(resource.type, new Set()); + } + resourcesGroupedByItems.get(resource.type).add(resource); + }); + + if (resourcesGroupedByItems.size == 0) { + throw new Error("No items to import"); + } + + let notify = function (aMsg, aItemType) { + Services.obs.notifyObservers(null, aMsg, aItemType); + }; + + for (let resourceType of Object.keys( + lazy.MigrationUtils._importQuantities + )) { + lazy.MigrationUtils._importQuantities[resourceType] = 0; + } + notify("Migration:Started"); + for (let [migrationType, itemResources] of resourcesGroupedByItems) { + notify("Migration:ItemBeforeMigrate", migrationType); + + let stopwatchHistogramId = maybeStartTelemetryStopwatch(migrationType); + + let { responsivenessMonitor, responsivenessHistogramId } = + maybeStartResponsivenessMonitor(migrationType); + + let itemSuccess = false; + for (let res of itemResources) { + let completeDeferred = Promise.withResolvers(); + let resourceDone = function (aSuccess, details) { + itemResources.delete(res); + itemSuccess |= aSuccess; + if (itemResources.size == 0) { + notify( + itemSuccess + ? "Migration:ItemAfterMigrate" + : "Migration:ItemError", + migrationType + ); + collectMigrationTelemetry(migrationType); + + aProgressCallback(migrationType, itemSuccess, details); + + resourcesGroupedByItems.delete(migrationType); + + if (stopwatchHistogramId) { + TelemetryStopwatch.finishKeyed( + stopwatchHistogramId, + browserKey + ); + } + + maybeFinishResponsivenessMonitor( + responsivenessMonitor, + responsivenessHistogramId + ); + + if (resourcesGroupedByItems.size == 0) { + collectQuantityTelemetry(); + + notify("Migration:Ended"); + } + } + completeDeferred.resolve(); + }; + + // If migrate throws, an error occurred, and the callback + // (itemMayBeDone) might haven't been called. + try { + res.migrate(resourceDone); + } catch (ex) { + console.error(ex); + resourceDone(false); + } + + await completeDeferred.promise; + await unblockMainThread(); + } + } + }; + + if ( + lazy.MigrationUtils.isStartupMigration && + !this.startupOnlyMigrator && + Services.policies.isAllowed("defaultBookmarks") + ) { + lazy.MigrationUtils.profileStartup.doStartup(); + // First import the default bookmarks. + // Note: We do not need to do so for the Firefox migrator + // (=startupOnlyMigrator), as it just copies over the places database + // from another profile. + await (async function () { + // Tell nsBrowserGlue we're importing default bookmarks. + let browserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + browserGlue.observe(null, TOPIC_WILL_IMPORT_BOOKMARKS, ""); + + // Import the default bookmarks. We ignore whether or not we succeed. + await lazy.BookmarkHTMLUtils.importFromURL( + "chrome://browser/content/default-bookmarks.html", + { + replace: true, + source: lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + } + ).catch(console.error); + + // We'll tell nsBrowserGlue we've imported bookmarks, but before that + // we need to make sure we're going to know when it's finished + // initializing places: + let placesInitedPromise = new Promise(resolve => { + let onPlacesInited = function () { + Services.obs.removeObserver( + onPlacesInited, + TOPIC_PLACES_DEFAULTS_FINISHED + ); + resolve(); + }; + Services.obs.addObserver( + onPlacesInited, + TOPIC_PLACES_DEFAULTS_FINISHED + ); + }); + browserGlue.observe(null, TOPIC_DID_IMPORT_BOOKMARKS, ""); + await placesInitedPromise; + await doMigrate(); + })(); + return; + } + await doMigrate(); + } + + /** + * Checks to see if one or more profiles exist for the browser that this + * migrator migrates from. + * + * @returns {Promise<boolean>} + * True if one or more profiles exists that this migrator can migrate + * resources from. + */ + async isSourceAvailable() { + if (this.startupOnlyMigrator && !lazy.MigrationUtils.isStartupMigration) { + return false; + } + + // For a single-profile source, check if any data is available. + // For multiple-profiles source, make sure that at least one + // profile is available. + let exists = false; + try { + let profiles = await this.getSourceProfiles(); + if (!profiles) { + let resources = await this.#getMaybeCachedResources(""); + if (resources && resources.length) { + exists = true; + } + } else { + exists = !!profiles.length; + } + } catch (ex) { + console.error(ex); + } + return exists; + } + + /*** PRIVATE STUFF - DO NOT OVERRIDE ***/ + + /** + * Returns resources for a particular profile and then caches them for later + * lookups. + * + * @param {object|string} aProfile + * The profile that resources are being imported from. + * @returns {Promise<MigrationResource[]>} + */ + async #getMaybeCachedResources(aProfile) { + let profileKey = aProfile ? aProfile.id : ""; + if (this._resourcesByProfile) { + if (profileKey in this._resourcesByProfile) { + return this._resourcesByProfile[profileKey]; + } + } else { + this._resourcesByProfile = {}; + } + this._resourcesByProfile[profileKey] = await this.getResources(aProfile); + return this._resourcesByProfile[profileKey]; + } +} diff --git a/browser/components/migration/ProfileMigrator.sys.mjs b/browser/components/migration/ProfileMigrator.sys.mjs new file mode 100644 index 0000000000..5d3b8baba7 --- /dev/null +++ b/browser/components/migration/ProfileMigrator.sys.mjs @@ -0,0 +1,15 @@ +/* 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/. */ + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +export function ProfileMigrator() {} + +ProfileMigrator.prototype = { + migrate: MigrationUtils.startupMigration.bind(MigrationUtils), + QueryInterface: ChromeUtils.generateQI(["nsIProfileMigrator"]), + classDescription: "Profile Migrator", + contractID: "@mozilla.org/toolkit/profile-migrator;1", + classID: Components.ID("6F8BB968-C14F-4D6F-9733-6C6737B35DCE"), +}; diff --git a/browser/components/migration/SafariProfileMigrator.sys.mjs b/browser/components/migration/SafariProfileMigrator.sys.mjs new file mode 100644 index 0000000000..418d2b66c8 --- /dev/null +++ b/browser/components/migration/SafariProfileMigrator.sys.mjs @@ -0,0 +1,678 @@ +/* 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/. */ + +import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PropertyListUtils: "resource://gre/modules/PropertyListUtils.sys.mjs", +}); + +// NSDate epoch is Jan 1, 2001 UTC +const NS_DATE_EPOCH_MS = new Date("2001-01-01T00:00:00-00:00").getTime(); + +// Convert NSDate timestamp to UNIX timestamp. +function parseNSDate(cocoaDateStr) { + let asDouble = parseFloat(cocoaDateStr); + if (!isNaN(asDouble)) { + return new Date(NS_DATE_EPOCH_MS + asDouble * 1000); + } + return new Date(); +} + +// Convert UNIX timestamp to NSDate timestamp. +function msToNSDate(ms) { + return parseFloat(ms - NS_DATE_EPOCH_MS) / 1000; +} + +function Bookmarks(aBookmarksFile) { + this._file = aBookmarksFile; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate: function B_migrate(aCallback) { + return (async () => { + let dict = await new Promise(resolve => + lazy.PropertyListUtils.read(this._file, resolve) + ); + if (!dict) { + throw new Error("Could not read Bookmarks.plist"); + } + let children = dict.get("Children"); + if (!children) { + throw new Error("Invalid Bookmarks.plist format"); + } + + let collection = + dict.get("Title") == "com.apple.ReadingList" + ? this.READING_LIST_COLLECTION + : this.ROOT_COLLECTION; + await this._migrateRootCollection(children, collection); + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, + + // Bookmarks collections in Safari. Constants for migrateCollection. + ROOT_COLLECTION: 0, + MENU_COLLECTION: 1, + TOOLBAR_COLLECTION: 2, + READING_LIST_COLLECTION: 3, + + /** + * Start the migration of a Safari collection of bookmarks by retrieving favicon data. + * + * @param {object[]} aEntries + * The collection's children + * @param {number} aCollection + * One of the _COLLECTION values above. + */ + async _migrateRootCollection(aEntries, aCollection) { + // First, try to get the favicon data of a user's bookmarks. + // In Safari, Favicons are stored as files with a unique name: + // the MD5 hash of the UUID of an SQLite entry in favicons.db. + // Thus, we must create a map from bookmark URLs -> their favicon entry's UUID. + let bookmarkURLToUUIDMap = new Map(); + + const faviconFolder = FileUtils.getDir("ULibDir", [ + "Safari", + "Favicon Cache", + ]).path; + let dbPath = PathUtils.join(faviconFolder, "favicons.db"); + + try { + // If there is an error getting favicon data, we catch the error and move on. + // In this case, the bookmarkURLToUUIDMap will be left empty. + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari favicons", + `SELECT I.uuid, I.url AS favicon_url, P.url + FROM icon_info I + INNER JOIN page_url P ON I.uuid = P.uuid;` + ); + + if (rows) { + // Convert the rows from our SQLite database into a map from bookmark url to uuid + for (let row of rows) { + let uniqueURL = Services.io.newURI(row.getResultByName("url")).spec; + + // Normalize the URL by removing any trailing slashes. We'll make sure to do + // the same when doing look-ups during a migration. + if (uniqueURL.endsWith("/")) { + uniqueURL = uniqueURL.replace(/\/+$/, ""); + } + bookmarkURLToUUIDMap.set(uniqueURL, row.getResultByName("uuid")); + } + } + } catch (ex) { + console.error(ex); + } + + await this._migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap); + }, + + /** + * Recursively migrate a Safari collection of bookmarks. + * + * @param {object[]} aEntries + * The collection's children + * @param {number} aCollection + * One of the _COLLECTION values above + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + * @returns {Promise<undefined>} + * Resolves after the bookmarks and favicons have been inserted into the + * appropriate databases. + */ + async _migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap) { + // A collection of bookmarks in Safari resembles places roots. In the + // property list files (Bookmarks.plist, ReadingList.plist) they are + // stored as regular bookmarks folders, and thus can only be distinguished + // from by their names and places in the hierarchy. + + let entriesFiltered = []; + if (aCollection == this.ROOT_COLLECTION) { + for (let entry of aEntries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let title = entry.get("Title"); + let children = entry.get("Children"); + if (title == "BookmarksBar") { + await this._migrateCollection( + children, + this.TOOLBAR_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (title == "BookmarksMenu") { + await this._migrateCollection( + children, + this.MENU_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (title == "com.apple.ReadingList") { + await this._migrateCollection( + children, + this.READING_LIST_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (entry.get("ShouldOmitFromUI") !== true) { + entriesFiltered.push(entry); + } + } else if (type == "WebBookmarkTypeLeaf") { + entriesFiltered.push(entry); + } + } + } else { + entriesFiltered = aEntries; + } + + if (!entriesFiltered.length) { + return; + } + + let folderGuid = -1; + switch (aCollection) { + case this.ROOT_COLLECTION: { + // In Safari, it is possible (though quite cumbersome) to move + // bookmarks to the bookmarks root, which is the parent folder of + // all bookmarks "collections". That is somewhat in parallel with + // both the places root and the unfiled-bookmarks root. + // Because the former is only an implementation detail in our UI, + // the unfiled root seems to be the best choice. + folderGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + break; + } + case this.MENU_COLLECTION: { + folderGuid = lazy.PlacesUtils.bookmarks.menuGuid; + break; + } + case this.TOOLBAR_COLLECTION: { + folderGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + break; + } + case this.READING_LIST_COLLECTION: { + // Reading list items are imported as regular bookmarks. + // They are imported under their own folder, created either under the + // bookmarks menu (in the case of startup migration). + let readingListTitle = await MigrationUtils.getLocalizedString( + "migration-imported-safari-reading-list" + ); + folderGuid = ( + await MigrationUtils.insertBookmarkWrapper({ + parentGuid: lazy.PlacesUtils.bookmarks.menuGuid, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: readingListTitle, + }) + ).guid; + break; + } + default: + throw new Error("Unexpected value for aCollection!"); + } + if (folderGuid == -1) { + throw new Error("Invalid folder GUID"); + } + + await this._migrateEntries( + entriesFiltered, + folderGuid, + bookmarkURLToUUIDMap + ); + }, + + /** + * Migrates bookmarks and favicons from Safari to Firefox. + * + * @param {object[]} entries + * The Safari collection's children + * @param {number} parentGuid + * GUID of the collection folder + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + */ + async _migrateEntries(entries, parentGuid, bookmarkURLToUUIDMap) { + let { convertedEntries, favicons } = await this._convertEntries( + entries, + bookmarkURLToUUIDMap + ); + + await MigrationUtils.insertManyBookmarksWrapper( + convertedEntries, + parentGuid + ); + + MigrationUtils.insertManyFavicons(favicons); + }, + + /** + * Converts Safari collection entries into a suitable format for + * inserting bookmarks and favicons. + * + * @param {object[]} entries + * The collection's children + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + * @returns {object[]} + * Returns an object with an array of converted bookmark entries and favicons + */ + async _convertEntries(entries, bookmarkURLToUUIDMap) { + let favicons = []; + let convertedEntries = []; + + const faviconFolder = FileUtils.getDir("ULibDir", [ + "Safari", + "Favicon Cache", + ]).path; + + for (const entry of entries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let convertedChildren = await this._convertEntries( + entry.get("Children"), + bookmarkURLToUUIDMap + ); + favicons.push(...convertedChildren.favicons); + convertedEntries.push({ + title: entry.get("Title"), + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + children: convertedChildren.convertedEntries, + }); + } else if (type == "WebBookmarkTypeLeaf" && entry.has("URLString")) { + // Check we understand this URL before adding it: + let url = entry.get("URLString"); + try { + new URL(url); + } catch (ex) { + console.error( + `Ignoring ${url} when importing from Safari because of exception:`, + ex + ); + continue; + } + let title; + if (entry.has("URIDictionary")) { + title = entry.get("URIDictionary").get("title"); + } + convertedEntries.push({ url, title }); + + try { + // Try to get the favicon data for each bookmark we have. + // We use uri.spec as our unique identifier since bookmark links + // don't completely match up in the Safari data. + let uri = Services.io.newURI(url); + let uriSpec = uri.spec; + + // Safari's favicon database doesn't include forward slashes for + // the page URLs, despite adding them in the Bookmarks.plist file. + // We'll strip any off here for our favicon lookup. + if (uriSpec.endsWith("/")) { + uriSpec = uriSpec.replace(/\/+$/, ""); + } + + let uuid = bookmarkURLToUUIDMap.get(uriSpec); + if (uuid) { + // Hash the UUID with md5 to give us the favicon file name. + let hashedUUID = lazy.PlacesUtils.md5(uuid, { + format: "hex", + }).toUpperCase(); + let faviconFile = PathUtils.join( + faviconFolder, + "favicons", + hashedUUID + ); + let faviconData = await IOUtils.read(faviconFile); + favicons.push({ faviconData, uri }); + } + } catch (error) { + // Even if we fail, still continue the import process + // since favicons aren't as essential as the bookmarks themselves. + console.error(error); + } + } + } + + return { convertedEntries, favicons }; + }, +}; + +async function GetHistoryResource() { + let dbPath = FileUtils.getDir("ULibDir", ["Safari", "History.db"]).path; + let maxAge = msToNSDate( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + // If we have read access to the Safari profile directory, check to + // see if there's any history to import. If we can't access the profile + // directory, let's assume that there's history to import and give the + // user the option to migrate it. + let canReadHistory = false; + try { + // 'stat' is always allowed, but reading is somehow not, if the user hasn't + // allowed it: + await IOUtils.read(dbPath, { maxBytes: 1 }); + canReadHistory = true; + } catch (ex) { + console.error( + "Cannot yet read from Safari profile directory. Will presume history exists for import." + ); + } + + if (canReadHistory) { + let countQuery = ` + SELECT COUNT(*) + FROM history_items LEFT JOIN history_visits + ON history_items.id = history_visits.history_item + WHERE history_visits.visit_time > ${maxAge} + LIMIT 1;`; + + let countResult = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari history", + countQuery + ); + + if (!countResult[0].getResultByName("COUNT(*)")) { + return null; + } + } + + let selectQuery = ` + SELECT + history_items.url as history_url, + history_visits.title as history_title, + history_visits.visit_time as history_time + FROM history_items LEFT JOIN history_visits + ON history_items.id = history_visits.history_item + WHERE history_visits.visit_time > ${maxAge};`; + + return { + type: MigrationUtils.resourceTypes.HISTORY, + + async migrate(callback) { + callback(await this._migrate()); + }, + + async _migrate() { + let historyRows; + + try { + historyRows = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari history", + selectQuery + ); + + if (!historyRows.length) { + console.log("No history found"); + return false; + } + } catch (ex) { + console.error(ex); + return false; + } + + let pageInfos = []; + for (let row of historyRows) { + try { + pageInfos.push({ + title: row.getResultByName("history_title"), + url: new URL(row.getResultByName("history_url")), + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date: parseNSDate(row.getResultByName("history_time")), + }, + ], + }); + } catch (e) { + console.error("Could not create a history row: ", e); + } + } + await MigrationUtils.insertVisitsWrapper(pageInfos); + + return true; + }, + }; +} + +/** + * Safari's preferences property list is independently used for three purposes: + * (a) importation of preferences + * (b) importation of search strings + * (c) retrieving the home page. + * + * So, rather than reading it three times, it's cached and managed here. + * + * @param {nsIFile} aPreferencesFile + * The .plist file to be read. + */ +function MainPreferencesPropertyList(aPreferencesFile) { + this._file = aPreferencesFile; + this._callbacks = []; +} +MainPreferencesPropertyList.prototype = { + /** + * @see PropertyListUtils.read + * @param {Function} aCallback + * A callback called with an Object representing the key-value pairs + * read out of the .plist file. + */ + read: function MPPL_read(aCallback) { + if ("_dict" in this) { + aCallback(this._dict); + return; + } + + let alreadyReading = !!this._callbacks.length; + this._callbacks.push(aCallback); + if (!alreadyReading) { + lazy.PropertyListUtils.read(this._file, aDict => { + this._dict = aDict; + for (let callback of this._callbacks) { + try { + callback(aDict); + } catch (ex) { + console.error(ex); + } + } + this._callbacks.splice(0); + }); + } + }, +}; + +function SearchStrings(aMainPreferencesPropertyListInstance) { + this._mainPreferencesPropertyList = aMainPreferencesPropertyListInstance; +} +SearchStrings.prototype = { + type: MigrationUtils.resourceTypes.OTHERDATA, + + migrate: function SS_migrate(aCallback) { + this._mainPreferencesPropertyList.read( + MigrationUtils.wrapMigrateFunction(function migrateSearchStrings(aDict) { + if (!aDict) { + throw new Error("Could not get preferences dictionary"); + } + + if (aDict.has("RecentSearchStrings")) { + let recentSearchStrings = aDict.get("RecentSearchStrings"); + if (recentSearchStrings && recentSearchStrings.length) { + let changes = recentSearchStrings.map(searchString => ({ + op: "add", + fieldname: "searchbar-history", + value: searchString, + })); + lazy.FormHistory.update(changes); + } + } + }, aCallback) + ); + }, +}; + +/** + * Safari migrator + */ +export class SafariProfileMigrator extends MigratorBase { + static get key() { + return "safari"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-safari"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/safari.png"; + } + + async getResources() { + let profileDir = FileUtils.getDir("ULibDir", ["Safari"]); + if (!profileDir.exists()) { + return null; + } + + let resources = []; + let pushProfileFileResource = function (aFileName, aConstructor) { + let file = profileDir.clone(); + file.append(aFileName); + if (file.exists()) { + resources.push(new aConstructor(file)); + } + }; + + pushProfileFileResource("Bookmarks.plist", Bookmarks); + + // The Reading List feature was introduced at the same time in Windows and + // Mac versions of Safari. Not surprisingly, they are stored in the same + // format in both versions. Surpsingly, only on Windows there is a + // separate property list for it. This code is used on mac too, because + // Apple may fix this at some point. + pushProfileFileResource("ReadingList.plist", Bookmarks); + + let prefs = this.mainPreferencesPropertyList; + if (prefs) { + resources.push(new SearchStrings(prefs)); + } + + resources.push(GetHistoryResource()); + + resources = await Promise.all(resources); + + return resources.filter(r => r != null); + } + + async getLastUsedDate() { + const profileDir = FileUtils.getDir("ULibDir", ["Safari"]); + const dates = await Promise.all( + ["Bookmarks.plist", "History.db"].map(file => { + const path = PathUtils.join(profileDir.path, file); + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }) + ); + + return new Date(Math.max(...dates)); + } + + async hasPermissions() { + if (this._hasPermissions) { + return true; + } + // Check if we have access to some key files, but only if they exist. + let historyTarget = FileUtils.getDir("ULibDir", ["Safari", "History.db"]); + let bookmarkTarget = FileUtils.getDir("ULibDir", [ + "Safari", + "Bookmarks.plist", + ]); + let faviconTarget = FileUtils.getDir("ULibDir", [ + "Safari", + "Favicon Cache", + "favicons.db", + ]); + try { + let historyExists = await IOUtils.exists(historyTarget.path); + let bookmarksExists = await IOUtils.exists(bookmarkTarget.path); + let faviconsExists = await IOUtils.exists(faviconTarget.path); + // We now know which files exist, which is always allowed. + // To determine if we have read permissions, try to read a single byte + // from each file that exists, which will throw if we need permissions. + if (historyExists) { + await IOUtils.read(historyTarget.path, { maxBytes: 1 }); + } + if (bookmarksExists) { + await IOUtils.read(bookmarkTarget.path, { maxBytes: 1 }); + } + if (faviconsExists) { + await IOUtils.read(faviconTarget.path, { maxBytes: 1 }); + } + this._hasPermissions = true; + return true; + } catch (ex) { + return false; + } + } + + async getPermissions(win) { + // Keep prompting the user until they pick something that grants us access + // to Safari's bookmarks and favicons or they cancel out of the file open panel. + while (!(await this.hasPermissions())) { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + // The title (second arg) is not displayed on macOS, so leave it blank. + fp.init(win, "", Ci.nsIFilePicker.modeGetFolder); + fp.filterIndex = 1; + fp.displayDirectory = FileUtils.getDir("ULibDir", [""]); + // Now wait for the filepicker to open and close. If the user picks + // the Safari folder, macOS will grant us read access to everything + // inside, so we don't need to check or do anything else with what's + // returned by the filepicker. + let result = await new Promise(resolve => fp.open(resolve)); + // Bail if the user cancels the dialog: + if (result == Ci.nsIFilePicker.returnCancel) { + return false; + } + } + return true; + } + + async canGetPermissions() { + if (await MigrationUtils.canGetPermissionsOnPlatform()) { + const profileDir = FileUtils.getDir("ULibDir", ["Safari"]); + if (await IOUtils.exists(profileDir.path)) { + return profileDir.path; + } + } + return false; + } + + get mainPreferencesPropertyList() { + if (this._mainPreferencesPropertyList === undefined) { + let file = FileUtils.getDir("UsrPrfs", []); + if (file.exists()) { + file.append("com.apple.Safari.plist"); + if (file.exists()) { + this._mainPreferencesPropertyList = new MainPreferencesPropertyList( + file + ); + return this._mainPreferencesPropertyList; + } + } + this._mainPreferencesPropertyList = null; + return this._mainPreferencesPropertyList; + } + return this._mainPreferencesPropertyList; + } +} diff --git a/browser/components/migration/components.conf b/browser/components/migration/components.conf new file mode 100644 index 0000000000..06b2d4b446 --- /dev/null +++ b/browser/components/migration/components.conf @@ -0,0 +1,37 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +XP_WIN = buildconfig.substs['OS_ARCH'] == 'WINNT' +XP_MACOSX = buildconfig.substs['MOZ_WIDGET_TOOLKIT'] == 'cocoa' + +Classes = [ + { + 'cid': '{6F8BB968-C14F-4D6F-9733-6C6737B35DCE}', + 'contract_ids': ['@mozilla.org/toolkit/profile-migrator;1'], + 'esModule': 'resource:///modules/ProfileMigrator.sys.mjs', + 'constructor': 'ProfileMigrator', + }, +] + +if XP_WIN: + Classes += [ + { + 'cid': '{c214cadc-2033-445e-8800-3fe25ee8d368}', + 'contract_ids': ['@mozilla.org/profile/migrator/edgemigrationutils;1'], + 'type': 'mozilla::nsEdgeMigrationUtils', + 'headers': ['nsEdgeMigrationUtils.h'], + }, + ] + +if XP_MACOSX: + Classes += [ + { + 'cid': '{647bf80c-cd35-4ce6-b904-fd586b97ae48}', + 'contract_ids': ['@mozilla.org/profile/migrator/keychainmigrationutils;1'], + 'type': 'nsKeychainMigrationUtils', + 'headers': ['nsKeychainMigrationUtils.h'], + }, + ] diff --git a/browser/components/migration/content/aboutWelcomeBack.xhtml b/browser/components/migration/content/aboutWelcomeBack.xhtml new file mode 100644 index 0000000000..0777cc56e9 --- /dev/null +++ b/browser/components/migration/content/aboutWelcomeBack.xhtml @@ -0,0 +1,126 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +# 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/. +--> +<!DOCTYPE html [ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +%htmlDTD; ]> + +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" +> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome: resource:; img-src chrome: resource: data:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="welcome-back-tab-title"></title> + <link + rel="stylesheet" + href="chrome://global/skin/in-content/info-pages.css" + media="all" + /> + <link + rel="stylesheet" + href="chrome://browser/skin/aboutWelcomeBack.css" + media="all" + /> + <link rel="icon" href="chrome://global/skin/icons/info-filled.svg" /> + <link rel="localization" href="browser/aboutSessionRestore.ftl" /> + <link rel="localization" href="branding/brand.ftl" /> + <script src="chrome://browser/content/aboutSessionRestore.js" /> + <script + type="module" + src="chrome://global/content/elements/moz-support-link.mjs" + /> + </head> + + <body> + <div class="container tab-list-tree-container"> + <div class="description-wrapper"> + <div class="title"> + <h1 class="title-text" data-l10n-id="welcome-back-page-title"></h1> + </div> + + <div class="description"> + <p data-l10n-id="welcome-back-page-info"></p> + <p data-l10n-id="welcome-back-page-info-link"> + <a + is="moz-support-link" + id="linkMoreTroubleshooting" + target="_blank" + data-l10n-name="link-more" + support-page="troubleshooting" + /> + </p> + <div> + <label class="radioRestoreContainer radio-container-with-text"> + <input + class="radioRestoreButton" + id="radioRestoreAll" + type="radio" + name="restore" + checked="checked" + /> + <span + class="radioRestoreLabel" + data-l10n-id="welcome-back-restore-all-label" + ></span> + </label> + + <label class="radioRestoreContainer radio-container-with-text"> + <input + class="radioRestoreButton" + id="radioRestoreChoose" + type="radio" + name="restore" + /> + <span + class="radioRestoreLabel" + data-l10n-id="welcome-back-restore-some-label" + ></span> + </label> + </div> + </div> + </div> + + <xul:tree + id="tabList" + flex="1" + seltype="single" + hidecolumnpicker="true" + hidden="true" + > + <xul:treecols> + <xul:treecol + cycler="true" + id="restore" + type="checkbox" + data-l10n-id="restore-page-restore-header" + /> + <xul:splitter class="tree-splitter" /> + <xul:treecol + primary="true" + id="title" + data-l10n-id="restore-page-list-header" + flex="1" + /> + </xul:treecols> + <xul:treechildren flex="1" /> + </xul:tree> + + <div class="button-container"> + <xul:button + class="primary" + id="errorTryAgain" + data-l10n-id="welcome-back-restore-button" + /> + </div> + + <input type="text" id="sessionData" hidden="true" /> + </div> + </body> +</html> diff --git a/browser/components/migration/content/brands/360.png b/browser/components/migration/content/brands/360.png Binary files differnew file mode 100644 index 0000000000..50562a0f70 --- /dev/null +++ b/browser/components/migration/content/brands/360.png diff --git a/browser/components/migration/content/brands/brave.png b/browser/components/migration/content/brands/brave.png Binary files differnew file mode 100644 index 0000000000..27d66e9a8e --- /dev/null +++ b/browser/components/migration/content/brands/brave.png diff --git a/browser/components/migration/content/brands/canary.png b/browser/components/migration/content/brands/canary.png Binary files differnew file mode 100644 index 0000000000..8d46be18c6 --- /dev/null +++ b/browser/components/migration/content/brands/canary.png diff --git a/browser/components/migration/content/brands/chrome.png b/browser/components/migration/content/brands/chrome.png Binary files differnew file mode 100644 index 0000000000..3fecf6bb02 --- /dev/null +++ b/browser/components/migration/content/brands/chrome.png diff --git a/browser/components/migration/content/brands/chromium.png b/browser/components/migration/content/brands/chromium.png Binary files differnew file mode 100644 index 0000000000..0e5bb5d0cb --- /dev/null +++ b/browser/components/migration/content/brands/chromium.png diff --git a/browser/components/migration/content/brands/edge.png b/browser/components/migration/content/brands/edge.png Binary files differnew file mode 100644 index 0000000000..b2e2ad4065 --- /dev/null +++ b/browser/components/migration/content/brands/edge.png diff --git a/browser/components/migration/content/brands/edgebeta.png b/browser/components/migration/content/brands/edgebeta.png Binary files differnew file mode 100644 index 0000000000..4e3977735e --- /dev/null +++ b/browser/components/migration/content/brands/edgebeta.png diff --git a/browser/components/migration/content/brands/ie.png b/browser/components/migration/content/brands/ie.png Binary files differnew file mode 100644 index 0000000000..e01c0f35ed --- /dev/null +++ b/browser/components/migration/content/brands/ie.png diff --git a/browser/components/migration/content/brands/opera.png b/browser/components/migration/content/brands/opera.png Binary files differnew file mode 100644 index 0000000000..71443f36a9 --- /dev/null +++ b/browser/components/migration/content/brands/opera.png diff --git a/browser/components/migration/content/brands/operagx.png b/browser/components/migration/content/brands/operagx.png Binary files differnew file mode 100644 index 0000000000..ed690aa200 --- /dev/null +++ b/browser/components/migration/content/brands/operagx.png diff --git a/browser/components/migration/content/brands/safari.png b/browser/components/migration/content/brands/safari.png Binary files differnew file mode 100644 index 0000000000..3b23caefb2 --- /dev/null +++ b/browser/components/migration/content/brands/safari.png diff --git a/browser/components/migration/content/brands/vivaldi.png b/browser/components/migration/content/brands/vivaldi.png Binary files differnew file mode 100644 index 0000000000..e9c2029006 --- /dev/null +++ b/browser/components/migration/content/brands/vivaldi.png diff --git a/browser/components/migration/content/migration-dialog-window.html b/browser/components/migration/content/migration-dialog-window.html new file mode 100644 index 0000000000..c1cf9d4597 --- /dev/null +++ b/browser/components/migration/content/migration-dialog-window.html @@ -0,0 +1,37 @@ +<!-- 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/. --> +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta name="color-scheme" content="light dark" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src 'none'; object-src 'none'; script-src chrome:; media-src chrome:; img-src chrome:; style-src chrome:;" + /> + <link + rel="icon" + type="image/png" + href="chrome://branding/content/icon32.png" + /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="chrome://browser/skin/migration/migration-dialog-window.css" + /> + <script src="chrome://global/content/customElements.js"></script> + <script + src="chrome://browser/content/migration/migration-wizard-constants.mjs" + type="module" + ></script> + <script + src="chrome://browser/content/migration/migration-wizard.mjs" + type="module" + ></script> + <script src="chrome://browser/content/migration/migration-dialog-window.js"></script> + </head> + <body> + <migration-wizard id="wizard" dialog-mode></migration-wizard> + </body> +</html> diff --git a/browser/components/migration/content/migration-dialog-window.js b/browser/components/migration/content/migration-dialog-window.js new file mode 100644 index 0000000000..b16a348a35 --- /dev/null +++ b/browser/components/migration/content/migration-dialog-window.js @@ -0,0 +1,116 @@ +/* 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/. */ + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", +}); + +/** + * This file manages a MigrationWizard embedded in a dialog that runs + * in a top-level dialog window. It's main responsibility is to listen + * for dialog-specific events from the embedded MigrationWizard and to + * respond appropriately to them. + * + * A single object argument is expected to be passed when opening + * this dialog. + * + * @param {object} window.arguments.0 + * @param {object} window.arguments.0.options + * A series of options for configuring the dialog. See + * MigrationUtils.showMigrationWizard for a description of this + * object. + */ + +const MigrationDialog = { + _wiz: null, + + init() { + addEventListener("load", this); + }, + + onLoad() { + this._wiz = document.getElementById("wizard"); + this._wiz.addEventListener("MigrationWizard:Close", this); + document.addEventListener("keypress", this); + + let args = window.arguments[0]; + // When opened via nsIWindowWatcher.openWindow, the arguments are + // passed through C++, and they arrive to us wrapped as an XPCOM + // object. We use wrappedJSObject to get at the underlying JS + // object. + if (args instanceof Ci.nsISupports) { + args = args.wrappedJSObject; + } + + let observer = new ResizeObserver(() => { + window.sizeToContent(); + }); + observer.observe(this._wiz); + + customElements.whenDefined("migration-wizard").then(() => { + if (args.options?.skipSourceSelection) { + // This is an automigration for a profile refresh, so begin migration + // automatically once ready. + this.doProfileRefresh( + args.options.migratorKey, + args.options.migrator, + args.options.profileId + ); + } else { + this._wiz.requestState(); + } + }); + }, + + handleEvent(event) { + switch (event.type) { + case "load": { + this.onLoad(); + break; + } + case "keypress": { + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + window.close(); + } + break; + } + case "MigrationWizard:Close": { + window.close(); + break; + } + } + }, + + async doProfileRefresh(migratorKey, migrator, profileId) { + let profile = { id: profileId }; + let resourceTypeData = await migrator.getMigrateData(profile); + let resourceTypeStrs = []; + for (let type in lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES) { + if (resourceTypeData & lazy.MigrationUtils.resourceTypes[type]) { + resourceTypeStrs.push( + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES[type] + ); + } + } + + this._wiz.doAutoImport(migratorKey, profile, resourceTypeStrs); + this._wiz.addEventListener( + "MigrationWizard:DoneMigration", + () => { + setTimeout(() => { + window.close(); + }, 5000); + }, + { once: true } + ); + }, +}; + +MigrationDialog.init(); diff --git a/browser/components/migration/content/migration-wizard-constants.mjs b/browser/components/migration/content/migration-wizard-constants.mjs new file mode 100644 index 0000000000..18673fc5b6 --- /dev/null +++ b/browser/components/migration/content/migration-wizard-constants.mjs @@ -0,0 +1,124 @@ +/* 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/. */ + +export const MigrationWizardConstants = Object.freeze({ + MIGRATOR_TYPES: Object.freeze({ + BROWSER: "browser", + FILE: "file", + }), + + /** + * A mapping of a page identification string to the IDs used by the + * various wizard pages. These are used by MigrationWizard.setState + * to set the current page. + * + * @type {Object<string, string>} + */ + PAGES: Object.freeze({ + LOADING: "loading", + SELECTION: "selection", + PROGRESS: "progress", + FILE_IMPORT_PROGRESS: "file-import-progress", + SAFARI_PERMISSION: "safari-permission", + SAFARI_PASSWORD_PERMISSION: "safari-password-permission", + NO_BROWSERS_FOUND: "no-browsers-found", + }), + + /** + * A mapping of a progress value string. These are used by + * MigrationWizard.#onShowingProgress to update the UI accordingly. + * + * @type {Object<string, number>} + */ + PROGRESS_VALUE: Object.freeze({ + LOADING: 1, + SUCCESS: 2, + WARNING: 3, + INFO: 4, + }), + + /** + * Returns a mapping of a resource type to a string used to identify + * the associated resource group in the wizard via a data-resource-type + * attribute. The keys are used to set which items should be shown and + * in what state in #onShowingProgress. + * + * @type {Object<string, string>} + */ + DISPLAYED_RESOURCE_TYPES: Object.freeze({ + // The DISPLAYED_RESOURCE_TYPES should have their keys match those + // in MigrationUtils.resourceTypes. + + // This is a little silly, but JavaScript doesn't have a notion of + // enums. The advantage of this set-up is that these constants values + // can be used to access the MigrationUtils.resourceTypes constants, + // are reasonably readable as DOM attributes, and easily serialize / + // deserialize. + HISTORY: "HISTORY", + FORMDATA: "FORMDATA", + PASSWORDS: "PASSWORDS", + BOOKMARKS: "BOOKMARKS", + PAYMENT_METHODS: "PAYMENT_METHODS", + EXTENSIONS: "EXTENSIONS", + + COOKIES: "COOKIES", + SESSION: "SESSION", + OTHERDATA: "OTHERDATA", + }), + + DISPLAYED_FILE_RESOURCE_TYPES: Object.freeze({ + // When migrating passwords from a file, we first show the progress + // for a single PASSWORDS_FROM_FILE resource type, and then upon + // completion, show two different resource types - one for new + // passwords imported from the file, and one for existing passwords + // that were updated from the file. + PASSWORDS_FROM_FILE: "PASSWORDS_FROM_FILE", + PASSWORDS_NEW: "PASSWORDS_NEW", + PASSWORDS_UPDATED: "PASSWORDS_UPDATED", + BOOKMARKS_FROM_FILE: "BOOKMARKS_FROM_FILE", + }), + + /** + * Returns a mapping of a resource type to a string used to identify + * the associated resource group in the wizard via a data-resource-type + * attribute. The keys are for resource types that are only ever shown + * for profile resets. + * + * @type {Object<string, string>} + */ + PROFILE_RESET_ONLY_RESOURCE_TYPES: Object.freeze({ + COOKIES: "COOKIES", + SESSION: "SESSION", + OTHERDATA: "OTHERDATA", + }), + + /** + * The set of keys that maps to migrators that use the term "favorites" + * in the place of "bookmarks". This tends to be browsers from Microsoft. + */ + USES_FAVORITES: Object.freeze([ + "chromium-edge", + "chromium-edge-beta", + "edge", + "ie", + ]), + + /** + * The values that are set on the extension extra key for the + * migration_finished telemetry event. The definition of that event + * defines it as: + * + * "3" if all extensions were matched after import. "2" if only some + * extensions were matched. "1" if none were matched, and "0" if extensions + * weren't selected for import. + * + * @type {Object<string, string>} + */ + EXTENSIONS_IMPORT_RESULT: Object.freeze({ + NOT_IMPORTED: "0", + NONE_MATCHED: "1", + PARTIAL_MATCH: "2", + ALL_MATCHED: "3", + }), +}); diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs new file mode 100644 index 0000000000..7643e4bce3 --- /dev/null +++ b/browser/components/migration/content/migration-wizard.mjs @@ -0,0 +1,1372 @@ +/* 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/. */ + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button-group.mjs"; +import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; + +/** + * This component contains the UI that steps users through migrating their + * data from other browsers to this one. This component only contains very + * basic logic and structure for the UI, and most of the state management + * occurs in the MigrationWizardChild JSWindowActor. + */ +export class MigrationWizard extends HTMLElement { + static #template = null; + + #deck = null; + #browserProfileSelector = null; + #browserProfileSelectorList = null; + #resourceTypeList = null; + #shadowRoot = null; + #importButton = null; + #importFromFileButton = null; + #chooseImportFromFile = null; + #getPermissionsButton = null; + #safariPermissionButton = null; + #safariPasswordImportSkipButton = null; + #safariPasswordImportSelectButton = null; + #selectAllCheckbox = null; + #resourceSummary = null; + #expandedDetails = false; + #extensionsSuccessLink = null; + + static get markup() { + return ` + <template> + <link rel="stylesheet" href="chrome://browser/skin/migration/migration-wizard.css"> + <named-deck id="wizard-deck" selected-view="page-loading" aria-busy="true" part="deck"> + <div name="page-loading"> + <h1 data-l10n-id="migration-wizard-selection-header" part="header"></h1> + <div class="loading-block large"></div> + <div class="loading-block small"></div> + <div class="loading-block small"></div> + <moz-button-group class="buttons" part="buttons"> + <!-- If possible, use the same button labels as the SELECTION page with the same strings. + That'll prevent flicker when the load state exits if we then enter the SELECTION page. --> + <button class="cancel-close" data-l10n-id="migration-cancel-button-label" disabled></button> + <button data-l10n-id="migration-import-button-label" disabled></button> + </moz-button-group> + </div> + + <div name="page-selection"> + <h1 data-l10n-id="migration-wizard-selection-header" part="header"></h1> + <button id="browser-profile-selector" aria-haspopup="menu" aria-labelledby="migrator-name profile-name"> + <span class="migrator-icon" role="img"></span> + <div class="migrator-description" role="presentation"> + <div id="migrator-name"> </div> + <div id="profile-name" class="deemphasized-text"></div> + </div> + <span class="dropdown-icon" role="img"></span> + </button> + <div class="no-resources-found error-message"> + <span class="error-icon" role="img"></span> + <div data-l10n-id="migration-wizard-import-browser-no-resources"></div> + </div> + + <div class="no-permissions-message"> + <p data-l10n-id="migration-no-permissions-message"> + </p> + <p data-l10n-id="migration-no-permissions-instructions"> + </p> + <ol> + <li data-l10n-id="migration-no-permissions-instructions-step1"></li> + <li class="migration-no-permissions-instructions-step2" data-l10n-id="migration-no-permissions-instructions-step2" data-l10n-args='{"permissionsPath": "" }'> + <code></code> + </li> + </ol> + </div> + + <div data-l10n-id="migration-wizard-selection-list" class="resource-selection-preamble deemphasized-text hide-on-error"></div> + <details class="resource-selection-details hide-on-error"> + <summary id="resource-selection-summary"> + <div class="selected-data-header" data-l10n-id="migration-all-available-data-label"></div> + <div class="selected-data deemphasized-text"> </div> + <span class="expand-collapse-icon" role="img"></span> + </summary> + <fieldset id="resource-type-list"> + <label id="select-all"> + <input type="checkbox" class="select-all-checkbox"/><span data-l10n-id="migration-select-all-option-label"></span> + </label> + <label id="bookmarks" data-resource-type="BOOKMARKS"/> + <input type="checkbox"/><span default-data-l10n-id="migration-bookmarks-option-label" ie-edge-data-l10n-id="migration-favorites-option-label"></span> + </label> + <label id="logins-and-passwords" data-resource-type="PASSWORDS"> + <input type="checkbox"/><span data-l10n-id="migration-passwords-option-label"></span> + </label> + <label id="history" data-resource-type="HISTORY"> + <input type="checkbox"/><span data-l10n-id="migration-history-option-label"></span> + </label> + <label id="extensions" data-resource-type="EXTENSIONS"> + <input type="checkbox"/><span data-l10n-id="migration-extensions-option-label"></span> + </label> + <label id="form-autofill" data-resource-type="FORMDATA"> + <input type="checkbox"/><span data-l10n-id="migration-form-autofill-option-label"></span> + </label> + <label id="payment-methods" data-resource-type="PAYMENT_METHODS"> + <input type="checkbox"/><span data-l10n-id="migration-payment-methods-option-label"></span> + </label> + </fieldset> + </details> + + <div class="file-import-error error-message"> + <span class="error-icon" role="img"></span> + <div id="file-import-error-message"></div> + </div> + + <moz-button-group class="buttons" part="buttons"> + <button class="cancel-close" data-l10n-id="migration-cancel-button-label"></button> + <button id="import-from-file" class="primary" data-l10n-id="migration-import-from-file-button-label"></button> + <button id="import" class="primary" data-l10n-id="migration-import-button-label"></button> + <button id="get-permissions" class="primary" data-l10n-id="migration-continue-button-label"></button> + </moz-button-group> + </div> + + <div name="page-progress"> + <h1 id="progress-header" data-l10n-id="migration-wizard-progress-header" part="header"></h1> + <div class="resource-progress"> + <div data-resource-type="BOOKMARKS" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span default-data-l10n-id="migration-bookmarks-option-label" ie-edge-data-l10n-id="migration-favorites-option-label"></span> + <span class="message-text deemphasized-text"> </span> + <a class="support-text deemphasized-text"></a> + </div> + + <div data-resource-type="PASSWORDS" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-passwords-option-label"></span> + <span class="message-text deemphasized-text"> </span> + <a class="support-text deemphasized-text"></a> + </div> + + <div data-resource-type="HISTORY" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-history-option-label"></span> + <span class="message-text deemphasized-text"> </span> + <a class="support-text deemphasized-text"></a> + </div> + + <div data-resource-type="EXTENSIONS" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-extensions-option-label"></span> + <a id="extensions-success-link" href="about:addons" class="message-text deemphasized-text"></a> + <span class="message-text deemphasized-text"></span> + <a class="support-text deemphasized-text"></a> + </div> + + <div data-resource-type="FORMDATA" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-form-autofill-option-label"></span> + <span class="message-text deemphasized-text"> </span> + <a class="support-text deemphasized-text"></a> + </div> + + <div data-resource-type="PAYMENT_METHODS" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-payment-methods-option-label"></span> + <span class="message-text deemphasized-text"> </span> + <a class="support-text deemphasized-text"></a> + </div> + + <div data-resource-type="COOKIES" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-cookies-option-label"></span> + <span class="message-text deemphasized-text"> </span> + <a class="support-text deemphasized-text"></a> + </div> + + <div data-resource-type="SESSION" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-session-option-label"></span> + <span class="message-text deemphasized-text"> </span> + <a class="support-text deemphasized-text"></a> + </div> + + <div data-resource-type="OTHERDATA" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-otherdata-option-label"></span> + <span class="message-text deemphasized-text"> </span> + <a class="support-text deemphasized-text"></a> + </div> + </div> + <moz-button-group class="buttons" part="buttons"> + <button class="cancel-close" data-l10n-id="migration-cancel-button-label" disabled></button> + <button class="primary finish-button done-button" data-l10n-id="migration-done-button-label"></button> + <button class="primary finish-button continue-button" data-l10n-id="migration-continue-button-label"></button> + </moz-button-group> + </div> + + <div name="page-file-import-progress"> + <h1 id="file-import-progress-header"part="header"></h1> + <div class="resource-progress"> + <div data-resource-type="PASSWORDS_FROM_FILE" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-passwords-from-file"></span> + <span class="message-text deemphasized-text"> </span> + </div> + + <div data-resource-type="PASSWORDS_NEW" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-passwords-new"></span> + <span class="message-text deemphasized-text"> </span> + </div> + + <div data-resource-type="PASSWORDS_UPDATED" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-passwords-updated"></span> + <span class="message-text deemphasized-text"> </span> + </div> + + <div data-resource-type="BOOKMARKS_FROM_FILE" class="resource-progress-group"> + <span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span> + <span data-l10n-id="migration-bookmarks-from-file"></span> + <span class="message-text deemphasized-text"> </span> + </div> + </div> + <moz-button-group class="buttons" part="buttons"> + <button class="cancel-close" data-l10n-id="migration-cancel-button-label" disabled></button> + <button class="primary finish-button done-button" data-l10n-id="migration-done-button-label"></button> + <button class="primary finish-button continue-button" data-l10n-id="migration-continue-button-label"></button> + </moz-button-group> + </div> + + <div name="page-safari-password-permission"> + <h1 data-l10n-id="migration-safari-password-import-header" part="header"></h1> + <span data-l10n-id="migration-safari-password-import-steps-header"></span> + <ol> + <li data-l10n-id="migration-safari-password-import-step1"></li> + <li data-l10n-id="migration-safari-password-import-step2"><img class="safari-icon-3dots" data-l10n-name="safari-icon-3dots"/></li> + <li data-l10n-id="migration-safari-password-import-step3"></li> + <li class="safari-icons-group"> + <span data-l10n-id="migration-safari-password-import-step4"></span> + <span class="page-portrait-icon"></span> + </li> + </ol> + <moz-button-group class="buttons" part="buttons"> + <button id="safari-password-import-skip" data-l10n-id="migration-safari-password-import-skip-button"></button> + <button id="safari-password-import-select" class="primary" data-l10n-id="migration-safari-password-import-select-button"></button> + </moz-button-group> + </div> + + <div name="page-safari-permission"> + <h1 data-l10n-id="migration-wizard-selection-header" part="header"></h1> + <div data-l10n-id="migration-wizard-safari-permissions-sub-header"></div> + <ol> + <li data-l10n-id="migration-wizard-safari-instructions-continue"></li> + <li data-l10n-id="migration-wizard-safari-instructions-folder"></li> + </ol> + <moz-button-group class="buttons" part="buttons"> + <button class="cancel-close" data-l10n-id="migration-cancel-button-label"></button> + <button id="safari-request-permissions" class="primary" data-l10n-id="migration-continue-button-label"></button> + </moz-button-group> + </div> + + <div name="page-no-browsers-found"> + <h1 data-l10n-id="migration-wizard-selection-header" part="header"></h1> + <div class="no-browsers-found error-message"> + <span class="error-icon" role="img"></span> + <div class="no-browsers-found-message" data-l10n-id="migration-wizard-import-browser-no-browsers"></div> + </div> + <moz-button-group class="buttons" part="buttons"> + <button class="cancel-close" data-l10n-id="migration-cancel-button-label"></button> + <button id="choose-import-from-file" class="primary" data-l10n-id="migration-choose-to-import-from-file-button-label"></button> + </moz-button-group> + </div> + </named-deck> + <slot></slot> + </template> + `; + } + + static get fragment() { + if (!MigrationWizard.#template) { + let parser = new DOMParser(); + let doc = parser.parseFromString(MigrationWizard.markup, "text/html"); + MigrationWizard.#template = document.importNode( + doc.querySelector("template"), + true + ); + } + return MigrationWizard.#template.content.cloneNode(true); + } + + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + + if (window.MozXULElement) { + window.MozXULElement.insertFTLIfNeeded("branding/brand.ftl"); + window.MozXULElement.insertFTLIfNeeded("browser/migrationWizard.ftl"); + } + document.l10n.connectRoot(shadow); + + shadow.appendChild(MigrationWizard.fragment); + + this.#deck = shadow.querySelector("#wizard-deck"); + this.#browserProfileSelector = shadow.querySelector( + "#browser-profile-selector" + ); + this.#resourceSummary = shadow.querySelector("#resource-selection-summary"); + this.#resourceSummary.addEventListener("click", this); + + let cancelCloseButtons = shadow.querySelectorAll(".cancel-close"); + for (let button of cancelCloseButtons) { + button.addEventListener("click", this); + } + + let finishButtons = shadow.querySelectorAll(".finish-button"); + for (let button of finishButtons) { + button.addEventListener("click", this); + } + + this.#importButton = shadow.querySelector("#import"); + this.#importButton.addEventListener("click", this); + this.#importFromFileButton = shadow.querySelector("#import-from-file"); + this.#importFromFileButton.addEventListener("click", this); + this.#chooseImportFromFile = shadow.querySelector( + "#choose-import-from-file" + ); + this.#chooseImportFromFile.addEventListener("click", this); + this.#getPermissionsButton = shadow.querySelector("#get-permissions"); + this.#getPermissionsButton.addEventListener("click", this); + + this.#browserProfileSelector.addEventListener("click", this); + this.#resourceTypeList = shadow.querySelector("#resource-type-list"); + this.#resourceTypeList.addEventListener("change", this); + + this.#safariPermissionButton = shadow.querySelector( + "#safari-request-permissions" + ); + this.#safariPermissionButton.addEventListener("click", this); + + this.#selectAllCheckbox = shadow.querySelector("#select-all").control; + + this.#safariPasswordImportSkipButton = shadow.querySelector( + "#safari-password-import-skip" + ); + this.#safariPasswordImportSkipButton.addEventListener("click", this); + + this.#safariPasswordImportSelectButton = shadow.querySelector( + "#safari-password-import-select" + ); + this.#safariPasswordImportSelectButton.addEventListener("click", this); + + this.#extensionsSuccessLink = shadow.querySelector( + "#extensions-success-link" + ); + this.#extensionsSuccessLink.addEventListener("click", this); + + this.#shadowRoot = shadow; + } + + connectedCallback() { + if (this.hasAttribute("auto-request-state")) { + this.requestState(); + } + } + + requestState() { + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestState", { bubbles: true }) + ); + } + + /** + * This setter can be used in the event that the MigrationWizard is being + * inserted via Lit, and the caller wants to set state declaratively using + * a property expression. + * + * @param {object} state + * The state object to pass to setState. + * @see MigrationWizard.setState. + */ + set state(state) { + this.setState(state); + } + + /** + * This is the main entrypoint for updating the state and appearance of + * the wizard. + * + * @param {object} state The state to be represented by the component. + * @param {string} state.page The page of the wizard to display. This should + * be one of the MigrationWizardConstants.PAGES constants. + */ + setState(state) { + switch (state.page) { + case MigrationWizardConstants.PAGES.SELECTION: { + this.#onShowingSelection(state); + break; + } + case MigrationWizardConstants.PAGES.PROGRESS: { + this.#onShowingProgress(state); + break; + } + case MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS: { + this.#onShowingFileImportProgress(state); + break; + } + case MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND: { + this.#onShowingNoBrowsersFound(state); + break; + } + } + + this.#deck.toggleAttribute( + "aria-busy", + state.page == MigrationWizardConstants.PAGES.LOADING + ); + this.#deck.setAttribute("selected-view", `page-${state.page}`); + + if (window.IS_STORYBOOK) { + this.#updateForStorybook(); + } + } + + get #dialogMode() { + return this.hasAttribute("dialog-mode"); + } + + #ensureSelectionDropdown() { + if (this.#browserProfileSelectorList) { + return; + } + this.#browserProfileSelectorList = document.createElement("panel-list"); + this.#browserProfileSelectorList.toggleAttribute( + "min-width-from-anchor", + true + ); + this.#browserProfileSelectorList.addEventListener("click", this); + + if (document.createXULElement) { + let panel = document.createXULElement("panel"); + panel.appendChild(this.#browserProfileSelectorList); + this.#shadowRoot.appendChild(panel); + } else { + this.#shadowRoot.appendChild(this.#browserProfileSelectorList); + } + } + + /** + * Reacts to changes to the browser / profile selector dropdown. This + * should update the list of resource types to match what's supported + * by the selected migrator and profile. + * + * @param {Element} panelItem the selected <panel-item> + */ + #onBrowserProfileSelectionChanged(panelItem) { + this.#browserProfileSelector.selectedPanelItem = panelItem; + this.#browserProfileSelector.querySelector("#migrator-name").textContent = + panelItem.displayName; + this.#browserProfileSelector.querySelector("#profile-name").textContent = + panelItem.profile?.name || ""; + + if (panelItem.brandImage) { + this.#browserProfileSelector.querySelector( + ".migrator-icon" + ).style.content = `url(${panelItem.brandImage})`; + } else { + this.#browserProfileSelector.querySelector( + ".migrator-icon" + ).style.content = "url(chrome://global/skin/icons/defaultFavicon.svg)"; + } + + let key = panelItem.getAttribute("key"); + let resourceTypes = panelItem.resourceTypes; + + for (let child of this.#resourceTypeList.querySelectorAll( + "label[data-resource-type]" + )) { + child.hidden = true; + child.control.checked = false; + } + + for (let resourceType of resourceTypes) { + let resourceLabel = this.#resourceTypeList.querySelector( + `label[data-resource-type="${resourceType}"]` + ); + if (resourceLabel) { + resourceLabel.hidden = false; + resourceLabel.control.checked = true; + + let labelSpan = resourceLabel.querySelector( + "span[default-data-l10n-id]" + ); + if (labelSpan) { + if (MigrationWizardConstants.USES_FAVORITES.includes(key)) { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("ie-edge-data-l10n-id") + ); + } else { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("default-data-l10n-id") + ); + } + } + } + } + let selectAll = this.#shadowRoot.querySelector("#select-all").control; + selectAll.checked = true; + + this.#displaySelectedResources(); + this.#browserProfileSelector.selectedPanelItem = panelItem; + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + selectionPage.setAttribute("migrator-type", panelItem.getAttribute("type")); + + // Safari currently has a special flow for requesting permissions that + // occurs _after_ resource selection, so we don't show this message + // for that migrator. + let showNoPermissionsMessage = + panelItem.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.BROWSER && + !panelItem.hasPermissions && + panelItem.getAttribute("key") != "safari"; + + selectionPage.toggleAttribute("no-permissions", showNoPermissionsMessage); + if (showNoPermissionsMessage) { + let step2 = selectionPage.querySelector( + ".migration-no-permissions-instructions-step2" + ); + step2.setAttribute( + "data-l10n-args", + JSON.stringify({ permissionsPath: panelItem.permissionsPath }) + ); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:PermissionsNeeded", { + bubbles: true, + detail: { + key, + }, + }) + ); + } + + selectionPage.toggleAttribute( + "no-resources", + panelItem.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.BROWSER && + !resourceTypes.length && + panelItem.hasPermissions + ); + } + + /** + * Called when showing the browser/profile selection page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string[]} state.migrators + * An array of source browser names that can be migrated from. + * @param {string} [state.migratorKey=null] + * The key for a migrator to automatically select in the migrators array. + * If not defined, the first item in the array will be selected. + * @param {string} [state.fileImportErrorMessage=null] + * An error message to display in the event that an attempt at doing a + * file import failed. File import failures are special in that they send + * the wizard back to the selection page with an error message. If not + * defined, it is presumed that a file import error has not occurred. + */ + #onShowingSelection(state) { + this.#ensureSelectionDropdown(); + this.#browserProfileSelectorList.textContent = ""; + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + + let details = this.#shadowRoot.querySelector("details"); + + if (this.hasAttribute("force-show-import-all")) { + let forceShowImportAll = + this.getAttribute("force-show-import-all") == "true"; + selectionPage.toggleAttribute("show-import-all", forceShowImportAll); + details.open = !forceShowImportAll; + } else { + selectionPage.toggleAttribute("show-import-all", state.showImportAll); + details.open = !state.showImportAll; + } + + this.#expandedDetails = false; + + for (let migrator of state.migrators) { + let opt = document.createElement("panel-item"); + opt.setAttribute("key", migrator.key); + opt.setAttribute("type", migrator.type); + opt.profile = migrator.profile; + opt.displayName = migrator.displayName; + opt.resourceTypes = migrator.resourceTypes; + opt.hasPermissions = migrator.hasPermissions; + opt.permissionsPath = migrator.permissionsPath; + opt.brandImage = migrator.brandImage; + + let button = opt.shadowRoot.querySelector("button"); + if (migrator.brandImage) { + button.style.backgroundImage = `url(${migrator.brandImage})`; + } + + if (migrator.profile) { + document.l10n.setAttributes( + opt, + "migration-wizard-selection-option-with-profile", + { + sourceBrowser: migrator.displayName, + profileName: migrator.profile.name, + } + ); + } else { + document.l10n.setAttributes( + opt, + "migration-wizard-selection-option-without-profile", + { + sourceBrowser: migrator.displayName, + } + ); + } + + this.#browserProfileSelectorList.appendChild(opt); + } + + if (state.migrators.length) { + this.#onBrowserProfileSelectionChanged( + this.#browserProfileSelectorList.firstElementChild + ); + } + + if (state.migratorKey) { + let panelItem = this.#browserProfileSelectorList.querySelector( + `panel-item[key="${state.migratorKey}"]` + ); + this.#onBrowserProfileSelectionChanged(panelItem); + } + + let fileImportErrorMessageEl = selectionPage.querySelector( + "#file-import-error-message" + ); + + if (state.fileImportErrorMessage) { + fileImportErrorMessageEl.textContent = state.fileImportErrorMessage; + selectionPage.toggleAttribute("file-import-error", true); + } else { + fileImportErrorMessageEl.textContent = ""; + selectionPage.toggleAttribute("file-import-error", false); + } + + // Since this is called before the named-deck actually switches to + // show the selection page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + this.#browserProfileSelector.focus({ focusVisible: false }); + }); + } + + /** + * @typedef {object} ProgressState + * The migration progress state for a resource. + * @property {number} value + * One of the values from MigrationWizardConstants.PROGRESS_VALUE. + * @property {string} [message=undefined] + * An optional message to display underneath the resource in + * the progress dialog. This message is only shown when value + * is not LOADING. + * @property {string} [linkURL=undefined] + * The URL for an optional link to appear after the status message. + * This will only be shown if linkText is also not-empty. + * @property {string} [linkText=undefined] + * The text for an optional link to appear after the status message. + * This will only be shown if linkURL is also not-empty. + */ + + /** + * Called when showing the progress / success page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.key + * The key of the migrator being used. + * @param {Object<string, ProgressState>} state.progress + * An object whose keys match one of DISPLAYED_RESOURCE_TYPES. + * + * Any resource type not included in state.progress will be hidden. + */ + #onShowingProgress(state) { + // Any resource progress group not included in state.progress is hidden. + let progressPage = this.#shadowRoot.querySelector( + "div[name='page-progress']" + ); + let resourceGroups = progressPage.querySelectorAll( + ".resource-progress-group" + ); + this.#extensionsSuccessLink.textContent = ""; + + let totalProgressGroups = Object.keys(state.progress).length; + let remainingProgressGroups = totalProgressGroups; + let totalWarnings = 0; + + for (let group of resourceGroups) { + let resourceType = group.dataset.resourceType; + if (!state.progress.hasOwnProperty(resourceType)) { + group.hidden = true; + continue; + } + group.hidden = false; + + let progressIcon = group.querySelector(".progress-icon"); + let messageText = group.querySelector("span.message-text"); + let supportLink = group.querySelector(".support-text"); + + let labelSpan = group.querySelector("span[default-data-l10n-id]"); + if (labelSpan) { + if (MigrationWizardConstants.USES_FAVORITES.includes(state.key)) { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("ie-edge-data-l10n-id") + ); + } else { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("default-data-l10n-id") + ); + } + } + messageText.textContent = ""; + + if (supportLink) { + supportLink.textContent = ""; + supportLink.removeAttribute("href"); + } + let progressValue = state.progress[resourceType].value; + switch (progressValue) { + case MigrationWizardConstants.PROGRESS_VALUE.LOADING: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-in-progress" + ); + progressIcon.setAttribute("state", "loading"); + messageText.textContent = ""; + supportLink.textContent = ""; + supportLink.removeAttribute("href"); + // With no status text, we re-insert the so that the status + // text area does not fully collapse. + messageText.appendChild(document.createTextNode("\u00A0")); + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.SUCCESS: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "success"); + messageText.textContent = state.progress[resourceType].message; + if ( + resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS + ) { + messageText.textContent = ""; + this.#extensionsSuccessLink.textContent = + state.progress[resourceType].message; + } + remainingProgressGroups--; + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.WARNING: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "warning"); + messageText.textContent = state.progress[resourceType].message; + supportLink.textContent = state.progress[resourceType].linkText; + supportLink.href = state.progress[resourceType].linkURL; + remainingProgressGroups--; + totalWarnings++; + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.INFO: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "info"); + messageText.textContent = state.progress[resourceType].message; + supportLink.textContent = state.progress[resourceType].linkText; + supportLink.href = state.progress[resourceType].linkURL; + if ( + resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS + ) { + messageText.textContent = ""; + this.#extensionsSuccessLink.textContent = + state.progress[resourceType].message; + } + remainingProgressGroups--; + break; + } + } + } + + let migrationDone = remainingProgressGroups == 0; + let headerL10nID = "migration-wizard-progress-header"; + + if (migrationDone) { + if (totalWarnings) { + headerL10nID = "migration-wizard-progress-done-with-warnings-header"; + } else { + headerL10nID = "migration-wizard-progress-done-header"; + } + } + + let header = this.#shadowRoot.getElementById("progress-header"); + document.l10n.setAttributes(header, headerL10nID); + + let finishButtons = progressPage.querySelectorAll(".finish-button"); + let cancelButton = progressPage.querySelector(".cancel-close"); + + for (let finishButton of finishButtons) { + finishButton.hidden = !migrationDone; + } + + cancelButton.hidden = migrationDone; + + if (migrationDone) { + // 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 + // next paint. + requestAnimationFrame(() => { + let button = this.#dialogMode + ? progressPage.querySelector(".done-button") + : progressPage.querySelector(".continue-button"); + button.focus({ focusVisible: false }); + }); + } + } + + /** + * Called when showing the progress / success page of the wizard for + * files. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.title + * The string to display in the header. + * @param {Object<string, ProgressState>} state.progress + * An object whose keys match one of DISPLAYED_FILE_RESOURCE_TYPES. + * + * Any resource type not included in state.progress will be hidden. + */ + #onShowingFileImportProgress(state) { + // Any resource progress group not included in state.progress is hidden. + let progressPage = this.#shadowRoot.querySelector( + "div[name='page-file-import-progress']" + ); + let resourceGroups = progressPage.querySelectorAll( + ".resource-progress-group" + ); + let totalProgressGroups = Object.keys(state.progress).length; + let remainingProgressGroups = totalProgressGroups; + + for (let group of resourceGroups) { + let resourceType = group.dataset.resourceType; + if (!state.progress.hasOwnProperty(resourceType)) { + group.hidden = true; + continue; + } + group.hidden = false; + + let progressIcon = group.querySelector(".progress-icon"); + let messageText = group.querySelector(".message-text"); + + let progressValue = state.progress[resourceType].value; + switch (progressValue) { + case MigrationWizardConstants.PROGRESS_VALUE.LOADING: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-in-progress" + ); + progressIcon.setAttribute("state", "loading"); + messageText.textContent = ""; + // With no status text, we re-insert the so that the status + // text area does not fully collapse. + messageText.appendChild(document.createTextNode("\u00A0")); + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.SUCCESS: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "success"); + messageText.textContent = state.progress[resourceType].message; + remainingProgressGroups--; + break; + } + case MigrationWizardConstants.PROGRESS_VALUE.WARNING: { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.setAttribute("state", "warning"); + messageText.textContent = state.progress[resourceType].message; + remainingProgressGroups--; + break; + } + default: { + console.error( + "Unrecognized state for file migration: ", + progressValue + ); + } + } + } + + let migrationDone = remainingProgressGroups == 0; + let header = this.#shadowRoot.getElementById("file-import-progress-header"); + header.textContent = state.title; + + let doneButton = progressPage.querySelector(".primary"); + let cancelButton = progressPage.querySelector(".cancel-close"); + doneButton.hidden = !migrationDone; + cancelButton.hidden = migrationDone; + + if (migrationDone) { + // 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 + // next paint. + requestAnimationFrame(() => { + doneButton.focus({ focusVisible: false }); + }); + } + } + + /** + * Called when showing the "no browsers found" page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.hasFileMigrators + * True if at least one FileMigrator is available for use. + */ + #onShowingNoBrowsersFound(state) { + this.#chooseImportFromFile.hidden = !state.hasFileMigrators; + } + + /** + * Certain parts of the MigrationWizard need to be modified slightly + * in order to work properly with Storybook. This method should be called + * to apply those changes after changing state. + */ + #updateForStorybook() { + // The CSS mask used for the progress spinner cannot be loaded via + // chrome:// URIs in Storybook. We work around this by exposing the + // progress elements as custom parts that the MigrationWizard story + // can style on its own. + this.#shadowRoot.querySelectorAll(".progress-icon").forEach(progressEl => { + if (progressEl.getAttribute("state") == "loading") { + progressEl.setAttribute("part", "progress-spinner"); + } else { + progressEl.removeAttribute("part"); + } + }); + } + + /** + * A public method for starting a migration without the user needing + * to choose a browser, profile or resource types. This is typically + * done only for doing a profile reset. + * + * @param {string} migratorKey + * The key associated with the migrator to use. + * @param {object|null} profile + * A representation of a browser profile. When not null, this is an + * object with a string "id" property, and a string "name" property. + * @param {string[]} resourceTypes + * An array of resource types that import should occur for. These + * strings should be from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + */ + doAutoImport(migratorKey, profile, resourceTypes) { + let migrationEventDetail = this.#gatherMigrationEventDetails({ + migratorKey, + profile, + resourceTypes, + }); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:BeginMigration", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Takes the current state of the selections page and bundles them + * up into a MigrationWizard:BeginMigration event that can be handled + * externally to perform the actual migration. + */ + #doImport() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:BeginMigration", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * @typedef {object} MigrationDetails + * @property {string} key + * The key for a MigratorBase subclass. + * @property {object|null} profile + * A representation of a browser profile. This is serialized and originally + * sent down from the parent via the GetAvailableMigrators message. + * @property {string[]} resourceTypes + * An array of resource types that the user is attempted to import. These + * strings should be from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @property {boolean} hasPermissions + * True if this MigrationWizardChild told us that the associated + * MigratorBase subclass for the key has enough permission to read + * the requested resources. + * @property {boolean} expandedDetails + * True if the user clicked on the <summary> element to expand the resource + * type list. + * @property {boolean} autoMigration + * True if the migration is occurring automatically, without the user + * having selected any items explicitly from the wizard. + * @property {string} [safariPasswordFilePath=null] + * An optional string argument that points to the path of a passwords + * export file from Safari. This file will have password imported from if + * supplied. This argument is ignored if the key is not for the + * Safari browser. + */ + + /** + * Pulls information from the DOM state of the MigrationWizard and constructs + * and returns an object that can be used to begin migration via and event + * sent to the MigrationWizardChild. If autoMigrationDetails is provided, + * this information is used to construct the object instead of the DOM state. + * + * @param {object} [autoMigrationDetails=null] + * Provided iff an automatic migration is being invoked. In that case, the + * details are constructed from this object rather than the wizard DOM state. + * @param {string} autoMigrationDetails.migratorKey + * The key of the migrator to do automatic migration from. + * @param {object|null} autoMigrationDetails.profile + * A representation of a browser profile. When not null, this is an + * object with a string "id" property, and a string "name" property. + * @param {string[]} autoMigrationDetails.resourceTypes + * An array of resource types that import should occur for. These + * strings should be from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @returns {MigrationDetails} details + */ + #gatherMigrationEventDetails(autoMigrationDetails) { + if (autoMigrationDetails?.migratorKey) { + let { migratorKey, profile, resourceTypes } = autoMigrationDetails; + + return { + key: migratorKey, + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + profile, + resourceTypes, + hasPermissions: true, + expandedDetails: this.#expandedDetails, + autoMigration: true, + }; + } + + let panelItem = this.#browserProfileSelector.selectedPanelItem; + let key = panelItem.getAttribute("key"); + let type = panelItem.getAttribute("type"); + let profile = panelItem.profile; + let hasPermissions = panelItem.hasPermissions; + + let resourceTypeFields = this.#resourceTypeList.querySelectorAll( + "label[data-resource-type]" + ); + let resourceTypes = []; + for (let resourceTypeField of resourceTypeFields) { + if (resourceTypeField.control.checked) { + resourceTypes.push(resourceTypeField.dataset.resourceType); + } + } + + return { + key, + type, + profile, + resourceTypes, + hasPermissions, + expandedDetails: this.#expandedDetails, + autoMigration: false, + }; + } + + /** + * Sends a request to gain read access to the Safari profile folder on + * macOS, and upon gaining access, performs a migration using the current + * settings as gathered by #gatherMigrationEventDetails + */ + #requestSafariPermissions() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestSafariPermissions", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Sends a request to get a string path for a passwords file exported + * from Safari. + */ + #selectSafariPasswordFile() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:SelectSafariPasswordFile", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Sends a request to get read permissions for the data associated + * with the selected browser. + */ + #getPermissions() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:GetPermissions", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Changes selected-data-header text and selected-data text based on + * how many resources are checked + */ + async #displaySelectedResources() { + let resourceTypeLabels = this.#resourceTypeList.querySelectorAll( + "label:not([hidden])[data-resource-type]" + ); + let panelItem = this.#browserProfileSelector.selectedPanelItem; + let key = panelItem.getAttribute("key"); + + let totalResources = resourceTypeLabels.length; + let checkedResources = 0; + + let selectedData = this.#shadowRoot.querySelector(".selected-data"); + let selectedDataArray = []; + let resourceTypeToLabelIDs = { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: + "migration-list-bookmark-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS]: + "migration-list-password-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY]: + "migration-list-history-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS]: + "migration-list-extensions-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA]: + "migration-list-autofill-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PAYMENT_METHODS]: + "migration-list-payment-methods-label", + }; + + if (MigrationWizardConstants.USES_FAVORITES.includes(key)) { + resourceTypeToLabelIDs[ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS + ] = "migration-list-favorites-label"; + } + + let resourceTypes = Object.keys(resourceTypeToLabelIDs); + let labelIds = Object.values(resourceTypeToLabelIDs).map(id => { + return { id }; + }); + let labels = await document.l10n.formatValues(labelIds); + let resourceTypeLabelMapping = new Map(); + for (let i = 0; i < resourceTypes.length; ++i) { + let resourceType = resourceTypes[i]; + resourceTypeLabelMapping.set(resourceType, labels[i]); + } + let formatter = new Intl.ListFormat(undefined, { + style: "long", + type: "conjunction", + }); + for (let resourceTypeLabel of resourceTypeLabels) { + if (resourceTypeLabel.control.checked) { + selectedDataArray.push( + resourceTypeLabelMapping.get(resourceTypeLabel.dataset.resourceType) + ); + checkedResources++; + } + } + if (selectedDataArray.length) { + selectedDataArray[0] = + selectedDataArray[0].charAt(0).toLocaleUpperCase() + + selectedDataArray[0].slice(1); + selectedData.textContent = formatter.format(selectedDataArray); + } else { + selectedData.textContent = "\u00A0"; + } + + let selectedDataHeader = this.#shadowRoot.querySelector( + ".selected-data-header" + ); + + let importButton = this.#shadowRoot.querySelector("#import"); + importButton.disabled = checkedResources == 0; + + if (checkedResources == 0) { + document.l10n.setAttributes( + selectedDataHeader, + "migration-no-selected-data-label" + ); + } else if (checkedResources < totalResources) { + document.l10n.setAttributes( + selectedDataHeader, + "migration-selected-data-label" + ); + } else { + document.l10n.setAttributes( + selectedDataHeader, + "migration-all-available-data-label" + ); + } + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + selectionPage.toggleAttribute("single-item", totalResources == 1); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:ResourcesUpdated", { bubbles: true }) + ); + } + + handleEvent(event) { + switch (event.type) { + case "click": { + if ( + event.target == this.#importButton || + event.target == this.#importFromFileButton + ) { + this.#doImport(); + } else if ( + event.target.classList.contains("cancel-close") || + event.target.classList.contains("finish-button") + ) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:Close", { bubbles: true }) + ); + } else if (event.target == this.#browserProfileSelector) { + this.#browserProfileSelectorList.show(event); + } else if ( + event.currentTarget == this.#browserProfileSelectorList && + event.target != this.#browserProfileSelectorList + ) { + this.#onBrowserProfileSelectionChanged(event.target); + // If the user selected a file migration type from the selector, we'll + // help the user out by immediately starting the file migration flow, + // rather than waiting for them to click the "Select File". + if ( + event.target.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.FILE + ) { + this.#doImport(); + } + } else if (event.target == this.#safariPermissionButton) { + this.#requestSafariPermissions(); + } else if (event.currentTarget == this.#resourceSummary) { + this.#expandedDetails = true; + } else if (event.target == this.#chooseImportFromFile) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestState", { + bubbles: true, + detail: { + allowOnlyFileMigrators: true, + }, + }) + ); + } else if (event.target == this.#safariPasswordImportSkipButton) { + // If the user chose to skip importing passwords from Safari, we + // programmatically uncheck the PASSWORDS resource type and re-request + // import. + let checkbox = this.#shadowRoot.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` + ).control; + checkbox.checked = false; + + // If there are no other checked checkboxes, go back to the selection + // screen. + let checked = this.#shadowRoot.querySelectorAll( + `label[data-resource-type] > input:checked` + ).length; + + if (!checked) { + this.requestState(); + } else { + this.#doImport(); + } + } else if (event.target == this.#safariPasswordImportSelectButton) { + this.#selectSafariPasswordFile(); + } else if (event.target == this.#extensionsSuccessLink) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:OpenAboutAddons", { + bubbles: true, + }) + ); + event.preventDefault(); + } else if (event.target == this.#getPermissionsButton) { + this.#getPermissions(); + } + break; + } + case "change": { + if (event.target == this.#browserProfileSelector) { + this.#onBrowserProfileSelectionChanged(); + } else if (event.target == this.#selectAllCheckbox) { + let checkboxes = this.#shadowRoot.querySelectorAll( + 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' + ); + for (let checkbox of checkboxes) { + checkbox.checked = this.#selectAllCheckbox.checked; + } + this.#displaySelectedResources(); + } else { + let checkboxes = this.#shadowRoot.querySelectorAll( + 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' + ); + + let allVisibleChecked = Array.from(checkboxes).every(checkbox => { + return checkbox.checked; + }); + + this.#selectAllCheckbox.checked = allVisibleChecked; + this.#displaySelectedResources(); + } + break; + } + } + } +} + +if (globalThis.customElements) { + customElements.define("migration-wizard", MigrationWizard); +} diff --git a/browser/components/migration/docs/index.rst b/browser/components/migration/docs/index.rst new file mode 100644 index 0000000000..be22b951cb --- /dev/null +++ b/browser/components/migration/docs/index.rst @@ -0,0 +1,16 @@ +.. _components/migration: + +========= +Migration +========= + +The migration component is responsible for bringing data from outside applications running on the same computer into Firefox. This is typically done via a wizard where users can choose what types of data to migrate over. + +The migrator is also used during a "Profile Refresh" to pave over a newly created Firefox profile with some data from an older one. + +.. toctree:: + :maxdepth: 3 + + migration-utils + migrators + migration-wizard diff --git a/browser/components/migration/docs/migration-utils.rst b/browser/components/migration/docs/migration-utils.rst new file mode 100644 index 0000000000..c1ecb41d8b --- /dev/null +++ b/browser/components/migration/docs/migration-utils.rst @@ -0,0 +1,5 @@ +======================== +MigrationUtils Reference +======================== +.. js:autoclass:: MigrationUtils + :members: diff --git a/browser/components/migration/docs/migration-wizard-architecture-diagram.svg b/browser/components/migration/docs/migration-wizard-architecture-diagram.svg new file mode 100644 index 0000000000..4c5fbf5bc5 --- /dev/null +++ b/browser/components/migration/docs/migration-wizard-architecture-diagram.svg @@ -0,0 +1,128 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -0.5 711 541"> + <path d="M707.27 385.71V540H440V360h229.09Z" fill="#FFF" stroke="#8a8a8a" stroke-miterlimit="10" pointer-events="all"/> + <path d="M669.09 360c3.11 6.02-2 12.22-13.64 16.53L710 387Z" fill="#FFF" stroke="#8a8a8a" stroke-miterlimit="10" pointer-events="all"/> + <path fill="none" pointer-events="all" d="M440 500h120v30H440z"/> + <switch transform="translate(-.5 -.5)"> + <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:118px;height:1px;padding-top:515px;margin-left:441px"> + <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);"> + <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal"> + (X)HTML Document + </div> + </div> + </div> + </foreignObject> + <text x="500" y="519" font-family="Helvetica" font-size="12" text-anchor="middle">(X)HTML Document</text> + </switch> + <path fill="#FFF" stroke="#000" pointer-events="all" d="M480 400h180v100H480z"/> + <switch transform="translate(-.5 -.5)"> + <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:450px;margin-left:481px"> + <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);"> + <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal"> + <migration-wizard> + </div> + </div> + </div> + </foreignObject> + <text x="570" y="454" font-family="Helvetica" font-size="12" text-anchor="middle"><migration-wizard></text> + </switch> + <path fill="#FFF" stroke="#8a8a8a" pointer-events="all" d="M420 160h210v100H420z"/> + <switch transform="translate(-.5 -.5)"> + <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:208px;height:1px;padding-top:210px;margin-left:421px"> + <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);"> + <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal"> + MigrationWizardChild + </div> + </div> + </div> + </foreignObject> + <text x="525" y="214" font-family="Helvetica" font-size="12" text-anchor="middle">MigrationWizardChild</text> + </switch> + <path d="M216.37 210h197.26" fill="none" stroke="#000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/> + <path d="m211.12 210 7-3.5-1.75 3.5 1.75 3.5ZM418.88 210l-7 3.5 1.75-3.5-1.75-3.5Z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/> + <switch transform="translate(-.5 -.5)"> + <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:208px;margin-left:314px"> + <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255);"> + <div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap"> + JSWindowActor messages + </div> + </div> + </div> + </foreignObject> + <text x="314" y="211" font-family="Helvetica" font-size="11" text-anchor="middle">JSWindowActor messages</text> + </switch> + <path d="M105 153.63v-47.26" fill="none" stroke="#000" stroke-miterlimit="10" pointer-events="stroke"/> + <path d="m105 158.88-3.5-7 3.5 1.75 3.5-1.75ZM105 101.12l3.5 7-3.5-1.75-3.5 1.75Z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/> + <switch transform="translate(-.5 -.5)"> + <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:130px;margin-left:105px"> + <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255);"> + <div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap"> + Direct function calls + </div> + </div> + </div> + </foreignObject> + <text x="105" y="133" font-family="Helvetica" font-size="11" text-anchor="middle">Direct function calls</text> + </switch> + <path fill="#FFF" stroke="#8a8a8a" pointer-events="all" d="M0 160h210v100H0z"/> + <switch transform="translate(-.5 -.5)"> + <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:208px;height:1px;padding-top:210px;margin-left:1px"> + <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);"> + <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal"> + MigrationWizardParent + </div> + </div> + </div> + </foreignObject> + <text x="105" y="214" font-family="Helvetica" font-size="12" text-anchor="middle">MigrationWizardParent</text> + </switch> + <path fill="#FFF" stroke="#8a8a8a" pointer-events="all" d="M0 0h210v100H0z"/> + <switch transform="translate(-.5 -.5)"> + <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:208px;height:1px;padding-top:50px;margin-left:1px"> + <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);"> + <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal"> + MigrationUtils + </div> + </div> + </div> + </foreignObject> + <text x="105" y="54" font-family="Helvetica" font-size="12" text-anchor="middle">MigrationUtils</text> + </switch> + <path d="M525 400V266.37" fill="none" stroke="#000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/> + <path d="m525 261.12 3.5 7-3.5-1.75-3.5 1.75Z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/> + <switch transform="translate(-.5 -.5)"> + <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:340px;margin-left:527px"> + <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255);"> + <div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap"> + DOM Events + </div> + </div> + </div> + </foreignObject> + <text x="527" y="343" font-family="Helvetica" font-size="11" text-anchor="middle">DOM Events</text> + </switch> + <path d="M572.22 393.64 577.5 260" fill="none" stroke="#000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/> + <path d="m572.02 398.88-3.22-7.13 3.42 1.89 3.57-1.61Z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/> + <switch transform="translate(-.5 -.5)"> + <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"> + <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:311px;margin-left:581px"> + <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255);"> + <div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap"> + Direct function calls + </div> + </div> + </div> + </foreignObject> + <text x="581" y="314" font-family="Helvetica" font-size="11" text-anchor="middle">Direct function calls</text> + </switch> +</svg> diff --git a/browser/components/migration/docs/migration-wizard.rst b/browser/components/migration/docs/migration-wizard.rst new file mode 100644 index 0000000000..320b429db5 --- /dev/null +++ b/browser/components/migration/docs/migration-wizard.rst @@ -0,0 +1,72 @@ +========================== +Migration Wizard Reference +========================== + +The migration wizard is the piece of UI that allows users to migrate from other browsers to Firefox. + +The migration wizard can be embedded in the following contexts: + +1. In a top level stand-alone dialog window +2. Within privileged ``about:`` pages, like ``about:welcome``, and ``about:preferences`` + +To accommodate these contexts, the migration wizard was developed as a reusable component using pure HTML, with an architecture that decouples the control of the wizard from how the wizard is presented to the user. This architecture not only helps to ensure that the wizard can function similarly in these different contexts, but also makes the component viewable in tools like Storybook for easier development. + + +High-level Overview +------------------- + +The following diagram tries to illustrate how the pieces of the migration wizard fit together: + +.. image:: migration-wizard-architecture-diagram.svg + +``MigrationWizard`` reusable component +====================================== + +The ``MigrationWizard`` reusable component (``<migration-wizard>``) is a custom element that can be imported from ``migration-wizard.mjs``. The module is expected to load in a DOM window context, whereupon the custom element is automatically registered for that document. + +After binding to the document, if the ``MigrationWizard`` has the ``auto-request-state`` attribute set on it, it will dispatch a ``MigrationWizard:RequestState`` custom event, which causes a ``MigrationWizardChild`` to instantiate and be associated with it. After receiving the migrator state from the ``MigrationWizardParent``, the ``MigrationWizardChild`` will dispatch a ``MigrationWizard:Ready`` event on the ``MigrationWizard``, mainly to aid in testing. The ``auto-request-state`` attribute is useful in situations where the ``MigrationWizard`` element is being used declaratively. + +If the ``auto-request-state`` attribute is not set, calling ``requestState()`` on the ``MigrationWizard`` will perform the above step. This is useful in situations where the ``MigrationWizard`` element is being constructed dynamically and the callers wants finer-grain control over when the state will be requested. + +Notably, the ``MigrationWizard`` does not contain any internal logic or privileged code to perform any migrations or to directly interact with the migration mechanisms. Its sole function is to accept input from the user and emit that input as events. The associated ``MigrationWizardChild`` will listen for those events, and take care of calling into the ``MigrationWizard`` to update the state of the reusable component. This means that the reusable component can be embedded in unprivileged contexts and have its states presented in a tool like Storybook. + +If the ``MigrationWizard`` is embedded in a dialog, it should have the ``dialog-mode`` attribute set on it so that dialog-appropriate buttons and styles are applied. + +``MigrationWizardConstants`` +============================ + +The ``MigrationWizardConstants`` module exports a single object of the same name. The properties of that object are constants that can be used to set the state of a ``MigrationWizard`` instance using ``MigrationWizard.setState``. + +``MigrationWizardChild`` +========================= + +The ``MigrationWizardChild`` is a ``JSWindowActorChild`` (see `JSActors`_) that is responsible for listening for events from a ``MigrationWizard``, and then either updating the state of that ``MigrationWizard`` immediately, or to message its paired ``MigrationWizardParent`` to perform tasks with ``MigrationUtils``. + + .. note:: + While a ``MigrationWizardChild`` can run in a content process (for out-of-process pages like ``about:welcome``), it can also run in parent-process contexts - for example, within the parent-process ``about:preferences`` page, or standalone window dialog. The same flow of events and messaging applies in all contexts. + +The ``MigrationWizardChild`` also waives Xrays so that it can directly call the ``setState`` method to update the appearance of the ``MigrationWizard``. See `XrayVision`_ for much more information on Xrays. + +.. js:autoclass:: MigrationWizardChild + :members: + +``MigrationWizardParent`` +========================= + +The ``MigrationWizardParent`` is a ``JSWindowActorParent`` (see `JSActors`_) that is responsible for listening for messages from the paired ``MigrationWizardChild`` to perform operations with ``MigrationUtils``. Effectively, all of the heavy lifting of actually performing the migrations will be kicked off by the ``MigrationWizardParent`` by calling into ``MigrationUtils``. State updates for things like migration progress will be sent back down to the ``MigrationWizardChild`` to then be reflected in the appearance of the ``MigrationWizard``. + +Since the ``MigrationWizard`` might be embedded in unprivileged documents, additional checks are placed in the message handler for ``MigrationWizardParent`` to ensure that the document is either running in the parent process or the privileged about content process. The `JSActors`_ registration for ``MigrationWizardParent`` and ``MigrationWizardChild`` also ensures that the actors only load for built-in documents. + +.. js:autoclass:: MigrationWizardParent + :members: + +``migration-dialog-window.html`` +================================ + +This document is meant for being loaded in a window dialog, and embeds the ``MigrationWizard`` reusable component, setting ``dialog-mode`` on it. It listens for dialog-specific events from the ``MigrationWizard``, such as ``MigrationWizard:Close``, which indicates that a "Cancel" button that should close the dialog was clicked. + +Pages like ``about:preferences`` or ``about:welcome`` can embed the ``MigrationWizard`` component directly, rather than use ``migration-dialog-window.html``. + + +.. _JSActors: /dom/ipc/jsactors.html +.. _XrayVision: /dom/scriptSecurity/xray_vision.html diff --git a/browser/components/migration/docs/migrators.rst b/browser/components/migration/docs/migrators.rst new file mode 100644 index 0000000000..a8694343d5 --- /dev/null +++ b/browser/components/migration/docs/migrators.rst @@ -0,0 +1,112 @@ +=================== +Migrators Reference +=================== + +There are currently two types of migrators: browser migrators, and file migrators. Browser migrators will migrate various resources from another browser. A file migrator allows the user to migrate data through an intermediary file (like passwords from a .CSV file). + +Browser migrators +================= + +MigratorBase class +------------------ +.. js:autoclass:: MigratorBase + :members: + +Chrome and Chrome variant migrators +----------------------------------- + +The ``ChromeProfileMigrator`` is subclassed ino order to provide migration capabilities for variants of the Chrome browser. + +ChromeProfileMigrator class +=========================== +.. js:autoclass:: ChromeProfileMigrator + :members: + +BraveProfileMigrator class +========================== +.. js:autoclass:: BraveProfileMigrator + :members: + +CanaryProfileMigrator class +=========================== +.. js:autoclass:: CanaryProfileMigrator + :members: + +ChromeBetaMigrator class +======================== +.. js:autoclass:: ChromeBetaMigrator + :members: + +ChromeDevMigrator class +======================= +.. js:autoclass:: ChromeDevMigrator + :members: + +Chromium360seMigrator class +=========================== +.. js:autoclass:: Chromium360seMigrator + :members: + +ChromiumEdgeMigrator class +========================== +.. js:autoclass:: ChromiumEdgeMigrator + :members: + +ChromiumEdgeBetaMigrator class +============================== +.. js:autoclass:: ChromiumEdgeBetaMigrator + :members: + +ChromiumProfileMigrator class +============================= +.. js:autoclass:: ChromiumProfileMigrator + :members: + +OperaProfileMigrator class +========================== +.. js:autoclass:: OperaProfileMigrator + :members: + +OperaGXProfileMigrator class +============================ +.. js:autoclass:: OperaGXProfileMigrator + :members: + +VivaldiProfileMigrator class +============================ +.. js:autoclass:: VivaldiProfileMigrator + :members: + +EdgeProfileMigrator class +------------------------- +.. js:autoclass:: EdgeProfileMigrator + :members: + +FirefoxProfileMigrator class +---------------------------- +.. js:autoclass:: FirefoxProfileMigrator + :members: + +IEProfileMigrator class +----------------------- +.. js:autoclass:: IEProfileMigrator + :members: + +File migrators +============== + +.. js:autofunction:: FilePickerConfigurationFilter + :short-name: + +.. js:autofunction:: FilePickerConfiguration + :short-name: + +FileMigratorBase class +---------------------- +.. js:autoclass:: FileMigratorBase + :members: + +PasswordFileMigrator class +-------------------------- +.. js:autoclass:: PasswordFileMigrator + :members: diff --git a/browser/components/migration/jar.mn b/browser/components/migration/jar.mn new file mode 100644 index 0000000000..4eabe74555 --- /dev/null +++ b/browser/components/migration/jar.mn @@ -0,0 +1,31 @@ +# 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/. + +browser.jar: + content/browser/aboutWelcomeBack.xhtml (content/aboutWelcomeBack.xhtml) + content/browser/migration/migration-dialog-window.html (content/migration-dialog-window.html) + content/browser/migration/migration-dialog-window.js (content/migration-dialog-window.js) + content/browser/migration/migration-wizard.mjs (content/migration-wizard.mjs) + content/browser/migration/migration-wizard-constants.mjs (content/migration-wizard-constants.mjs) +#ifdef XP_WIN + content/browser/migration/brands/360.png (content/brands/360.png) + content/browser/migration/brands/ie.png (content/brands/ie.png) +#endif + +#if defined(XP_MACOSX) || defined(XP_WIN) + content/browser/migration/brands/canary.png (content/brands/canary.png) + content/browser/migration/brands/edge.png (content/brands/edge.png) + content/browser/migration/brands/edgebeta.png (content/brands/edgebeta.png) +#endif + + 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/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) + +#ifdef XP_MACOSX + content/browser/migration/brands/safari.png (content/brands/safari.png) +#endif diff --git a/browser/components/migration/metrics.yaml b/browser/components/migration/metrics.yaml new file mode 100644 index 0000000000..a7d6d2481d --- /dev/null +++ b/browser/components/migration/metrics.yaml @@ -0,0 +1,42 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'Firefox :: Migration' + +browser.migration: + matched_extensions: + type: string_list + description: > + Records a list of the Chrome extension IDs that were successfully + matched to Firefox equivalents from the list downloaded from AMO. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1807023 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1807023 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + + unmatched_extensions: + type: string_list + description: > + Records a list of the Chrome extension IDs that were unsuccessfully + matched to Firefox equivalents from the list downloaded from AMO. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1807023 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1807023 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never diff --git a/browser/components/migration/moz.build b/browser/components/migration/moz.build new file mode 100644 index 0000000000..8c958ab7d4 --- /dev/null +++ b/browser/components/migration/moz.build @@ -0,0 +1,85 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.toml"] + +MARIONETTE_MANIFESTS += ["tests/marionette/manifest.toml"] + +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] + +SPHINX_TREES["docs"] = "docs" + +JAR_MANIFESTS += ["jar.mn"] + +XPIDL_SOURCES += [ + "nsIEdgeMigrationUtils.idl", +] + +XPIDL_MODULE = "migration" + +EXTRA_JS_MODULES += [ + "ChromeMigrationUtils.sys.mjs", + "ChromeProfileMigrator.sys.mjs", + "FileMigrators.sys.mjs", + "FirefoxProfileMigrator.sys.mjs", + "InternalTestingProfileMigrator.sys.mjs", + "MigrationUtils.sys.mjs", + "MigratorBase.sys.mjs", + "ProfileMigrator.sys.mjs", +] + +FINAL_TARGET_FILES.actors = [ + "MigrationWizardChild.sys.mjs", + "MigrationWizardParent.sys.mjs", +] + +if CONFIG["OS_ARCH"] == "WINNT": + if CONFIG["ENABLE_TESTS"]: + DIRS += [ + "tests/unit/insertIEHistory", + ] + EXPORTS += [ + "nsEdgeMigrationUtils.h", + ] + UNIFIED_SOURCES += [ + "nsEdgeMigrationUtils.cpp", + "nsIEHistoryEnumerator.cpp", + ] + EXTRA_JS_MODULES += [ + "360seMigrationUtils.sys.mjs", + "ChromeWindowsLoginCrypto.sys.mjs", + "EdgeProfileMigrator.sys.mjs", + "ESEDBReader.sys.mjs", + "IEProfileMigrator.sys.mjs", + "MSMigrationUtils.sys.mjs", + ] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa": + EXPORTS += [ + "nsKeychainMigrationUtils.h", + ] + EXTRA_JS_MODULES += [ + "ChromeMacOSLoginCrypto.sys.mjs", + "SafariProfileMigrator.sys.mjs", + ] + UNIFIED_SOURCES += [ + "nsKeychainMigrationUtils.mm", + ] + XPIDL_SOURCES += [ + "nsIKeychainMigrationUtils.idl", + ] + + +XPCOM_MANIFESTS += [ + "components.conf", +] + +FINAL_LIBRARY = "browsercomps" + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Migration") diff --git a/browser/components/migration/nsEdgeMigrationUtils.cpp b/browser/components/migration/nsEdgeMigrationUtils.cpp new file mode 100644 index 0000000000..a8d76c1405 --- /dev/null +++ b/browser/components/migration/nsEdgeMigrationUtils.cpp @@ -0,0 +1,61 @@ +/* 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/. */ + +#include "nsEdgeMigrationUtils.h" + +#include "mozilla/dom/Promise.h" +#include "nsCOMPtr.h" +#include "nsIEventTarget.h" + +#include <windows.h> + +namespace mozilla { + +NS_IMPL_ISUPPORTS(nsEdgeMigrationUtils, nsIEdgeMigrationUtils) + +NS_IMETHODIMP +nsEdgeMigrationUtils::IsDbLocked(nsIFile* aFile, JSContext* aCx, + dom::Promise** aPromise) { + NS_ENSURE_ARG_POINTER(aFile); + + nsString path; + nsresult rv = aFile->GetPath(path); + NS_ENSURE_SUCCESS(rv, rv); + + ErrorResult err; + RefPtr<dom::Promise> promise = + dom::Promise::Create(xpc::CurrentNativeGlobal(aCx), err); + + if (MOZ_UNLIKELY(err.Failed())) { + return err.StealNSResult(); + } + + nsMainThreadPtrHandle<dom::Promise> promiseHolder( + new nsMainThreadPtrHolder<dom::Promise>("nsEdgeMigrationUtils Promise", + promise)); + + NS_DispatchBackgroundTask(NS_NewRunnableFunction( + __func__, + [promiseHolder = std::move(promiseHolder), path = std::move(path)]() { + HANDLE file = ::CreateFileW(path.get(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, 0, nullptr); + + bool locked = true; + if (file != INVALID_HANDLE_VALUE) { + locked = false; + ::CloseHandle(file); + } + + NS_DispatchToMainThread(NS_NewRunnableFunction( + __func__, [promiseHolder = std::move(promiseHolder), locked]() { + promiseHolder.get()->MaybeResolve(locked); + })); + })); + + promise.forget(aPromise); + + return NS_OK; +} + +} // namespace mozilla diff --git a/browser/components/migration/nsEdgeMigrationUtils.h b/browser/components/migration/nsEdgeMigrationUtils.h new file mode 100644 index 0000000000..d85fff10ad --- /dev/null +++ b/browser/components/migration/nsEdgeMigrationUtils.h @@ -0,0 +1,24 @@ + +/* 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/. */ + +#ifndef nsedgemigrationutils__h__ +#define nsedgemigrationutils__h__ + +#include "nsISupportsImpl.h" +#include "nsIEdgeMigrationUtils.h" + +namespace mozilla { + +class nsEdgeMigrationUtils final : public nsIEdgeMigrationUtils { + NS_DECL_ISUPPORTS + NS_DECL_NSIEDGEMIGRATIONUTILS + + private: + ~nsEdgeMigrationUtils() = default; +}; + +} // namespace mozilla + +#endif diff --git a/browser/components/migration/nsIEHistoryEnumerator.cpp b/browser/components/migration/nsIEHistoryEnumerator.cpp new file mode 100644 index 0000000000..497b92bab7 --- /dev/null +++ b/browser/components/migration/nsIEHistoryEnumerator.cpp @@ -0,0 +1,116 @@ +/* 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/. */ + +#include "nsIEHistoryEnumerator.h" + +#include <urlhist.h> +#include <shlguid.h> + +#include "nsArrayEnumerator.h" +#include "nsComponentManagerUtils.h" +#include "nsCOMArray.h" +#include "nsIURI.h" +#include "nsNetUtil.h" +#include "nsString.h" +#include "nsWindowsMigrationUtils.h" +#include "prtime.h" + +//////////////////////////////////////////////////////////////////////////////// +//// nsIEHistoryEnumerator + +nsIEHistoryEnumerator::nsIEHistoryEnumerator() { ::CoInitialize(nullptr); } + +nsIEHistoryEnumerator::~nsIEHistoryEnumerator() { ::CoUninitialize(); } + +void nsIEHistoryEnumerator::EnsureInitialized() { + if (mURLEnumerator) return; + + HRESULT hr = + ::CoCreateInstance(CLSID_CUrlHistory, nullptr, CLSCTX_INPROC_SERVER, + IID_IUrlHistoryStg2, getter_AddRefs(mIEHistory)); + if (FAILED(hr)) return; + + hr = mIEHistory->EnumUrls(getter_AddRefs(mURLEnumerator)); + if (FAILED(hr)) return; +} + +NS_IMETHODIMP +nsIEHistoryEnumerator::HasMoreElements(bool* _retval) { + *_retval = false; + + EnsureInitialized(); + MOZ_ASSERT(mURLEnumerator, + "Should have instanced an IE History URLEnumerator"); + if (!mURLEnumerator) return NS_OK; + + STATURL statURL; + ULONG fetched; + + // First argument is not implemented, so doesn't matter what we pass. + HRESULT hr = mURLEnumerator->Next(1, &statURL, &fetched); + if (FAILED(hr) || fetched != 1UL) { + // Reached the last entry. + return NS_OK; + } + + nsCOMPtr<nsIURI> uri; + if (statURL.pwcsUrl) { + nsDependentString url(statURL.pwcsUrl); + nsresult rv = NS_NewURI(getter_AddRefs(uri), url); + ::CoTaskMemFree(statURL.pwcsUrl); + if (NS_FAILED(rv)) { + // Got a corrupt or invalid URI, continue to the next entry. + return HasMoreElements(_retval); + } + } + + nsDependentString title(statURL.pwcsTitle ? statURL.pwcsTitle : L""); + + bool lastVisitTimeIsValid; + PRTime lastVisited = WinMigrationFileTimeToPRTime(&(statURL.ftLastVisited), + &lastVisitTimeIsValid); + + mCachedNextEntry = do_CreateInstance("@mozilla.org/hash-property-bag;1"); + MOZ_ASSERT(mCachedNextEntry, "Should have instanced a new property bag"); + if (mCachedNextEntry) { + mCachedNextEntry->SetPropertyAsInterface(u"uri"_ns, uri); + mCachedNextEntry->SetPropertyAsAString(u"title"_ns, title); + if (lastVisitTimeIsValid) { + mCachedNextEntry->SetPropertyAsInt64(u"time"_ns, lastVisited); + } + + *_retval = true; + } + + if (statURL.pwcsTitle) ::CoTaskMemFree(statURL.pwcsTitle); + + return NS_OK; +} + +NS_IMETHODIMP +nsIEHistoryEnumerator::GetNext(nsISupports** _retval) { + *_retval = nullptr; + + EnsureInitialized(); + MOZ_ASSERT(mURLEnumerator, + "Should have instanced an IE History URLEnumerator"); + if (!mURLEnumerator) return NS_OK; + + if (!mCachedNextEntry) { + bool hasMore = false; + nsresult rv = this->HasMoreElements(&hasMore); + if (NS_FAILED(rv)) { + return rv; + } + if (!hasMore) { + return NS_ERROR_FAILURE; + } + } + + NS_ADDREF(*_retval = mCachedNextEntry); + // Release the cached entry, so it can't be returned twice. + mCachedNextEntry = nullptr; + + return NS_OK; +} diff --git a/browser/components/migration/nsIEHistoryEnumerator.h b/browser/components/migration/nsIEHistoryEnumerator.h new file mode 100644 index 0000000000..cd0c202bfc --- /dev/null +++ b/browser/components/migration/nsIEHistoryEnumerator.h @@ -0,0 +1,39 @@ +/* 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/. */ + +#ifndef iehistoryenumerator___h___ +#define iehistoryenumerator___h___ + +#include <urlhist.h> + +#include "mozilla/Attributes.h" +#include "nsCOMPtr.h" +#include "nsIWritablePropertyBag2.h" +#include "nsSimpleEnumerator.h" + +class nsIEHistoryEnumerator final : public nsSimpleEnumerator { + public: + NS_DECL_NSISIMPLEENUMERATOR + + nsIEHistoryEnumerator(); + + const nsID& DefaultInterface() override { + return NS_GET_IID(nsIWritablePropertyBag2); + } + + private: + ~nsIEHistoryEnumerator() override; + + /** + * Initializes the history reader, if needed. + */ + void EnsureInitialized(); + + RefPtr<IUrlHistoryStg2> mIEHistory; + RefPtr<IEnumSTATURL> mURLEnumerator; + + nsCOMPtr<nsIWritablePropertyBag2> mCachedNextEntry; +}; + +#endif diff --git a/browser/components/migration/nsIEdgeMigrationUtils.idl b/browser/components/migration/nsIEdgeMigrationUtils.idl new file mode 100644 index 0000000000..8c15d00251 --- /dev/null +++ b/browser/components/migration/nsIEdgeMigrationUtils.idl @@ -0,0 +1,23 @@ +/* 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/. */ + +#include "nsISupports.idl" +#include "nsIFile.idl" + +/** + * Utilities for migrating from legacy (non-Chromimum-based) Edge. + */ +[builtinclass, scriptable, uuid(9c7b7436-a17c-4c03-ba66-aeb5ae070126)] +interface nsIEdgeMigrationUtils : nsISupports { + /** + * Determine if the Edge database is locked for writing. + * + * @param aFile The path to the Edge database. + * + * @returns A promise that is resolved to whether or not the given database + * could be opened for writing. + */ + [implicit_jscontext] + Promise isDbLocked(in nsIFile aFile); +}; diff --git a/browser/components/migration/nsIKeychainMigrationUtils.idl b/browser/components/migration/nsIKeychainMigrationUtils.idl new file mode 100644 index 0000000000..e0a9db4ddf --- /dev/null +++ b/browser/components/migration/nsIKeychainMigrationUtils.idl @@ -0,0 +1,12 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +[scriptable, uuid(647bf80c-cd35-4ce6-b904-fd586b97ae48)] +interface nsIKeychainMigrationUtils : nsISupports +{ + ACString getGenericPassword(in ACString aServiceName, in ACString aAccountName); +}; diff --git a/browser/components/migration/nsKeychainMigrationUtils.h b/browser/components/migration/nsKeychainMigrationUtils.h new file mode 100644 index 0000000000..343c24086e --- /dev/null +++ b/browser/components/migration/nsKeychainMigrationUtils.h @@ -0,0 +1,23 @@ +/* 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/. */ + +#ifndef nsKeychainMigrationUtils_h__ +#define nsKeychainMigrationUtils_h__ + +#include <CoreFoundation/CoreFoundation.h> + +#include "nsIKeychainMigrationUtils.h" + +class nsKeychainMigrationUtils : public nsIKeychainMigrationUtils { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIKEYCHAINMIGRATIONUTILS + + nsKeychainMigrationUtils(){}; + + protected: + virtual ~nsKeychainMigrationUtils(){}; +}; + +#endif diff --git a/browser/components/migration/nsKeychainMigrationUtils.mm b/browser/components/migration/nsKeychainMigrationUtils.mm new file mode 100644 index 0000000000..85c09c9ef3 --- /dev/null +++ b/browser/components/migration/nsKeychainMigrationUtils.mm @@ -0,0 +1,68 @@ +/* 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/. */ + +#include "nsKeychainMigrationUtils.h" + +#include <Security/Security.h> + +#include "mozilla/Logging.h" + +#include "nsCocoaUtils.h" +#include "nsString.h" + +using namespace mozilla; + +LazyLogModule gKeychainUtilsLog("keychainmigrationutils"); + +NS_IMPL_ISUPPORTS(nsKeychainMigrationUtils, nsIKeychainMigrationUtils) + +NS_IMETHODIMP +nsKeychainMigrationUtils::GetGenericPassword(const nsACString& aServiceName, + const nsACString& aAccountName, + nsACString& aKey) { + // To retrieve a secret, we create a CFDictionary of the form: + // { class: generic password, + // service: the given service name + // account: the given account name, + // match limit: match one, + // return attributes: true, + // return data: true } + // This searches for and returns the attributes and data for the secret + // matching the given service and account names. We then extract the data + // (i.e. the secret) and return it. + NSDictionary* searchDictionary = @{ + (__bridge NSString*) + kSecClass : (__bridge NSString*)kSecClassGenericPassword, + (__bridge NSString*) + kSecAttrService : nsCocoaUtils::ToNSString(aServiceName), + (__bridge NSString*) + kSecAttrAccount : nsCocoaUtils::ToNSString(aAccountName), + (__bridge NSString*)kSecMatchLimit : (__bridge NSString*)kSecMatchLimitOne, + (__bridge NSString*)kSecReturnAttributes : @YES, + (__bridge NSString*)kSecReturnData : @YES + }; + + CFTypeRef item; + // https://developer.apple.com/documentation/security/1398306-secitemcopymatching + OSStatus rv = + SecItemCopyMatching((__bridge CFDictionaryRef)searchDictionary, &item); + if (rv != errSecSuccess) { + MOZ_LOG(gKeychainUtilsLog, LogLevel::Debug, + ("SecItemCopyMatching failed: %d", rv)); + return NS_ERROR_FAILURE; + } + NSDictionary* resultDict = [(__bridge NSDictionary*)item autorelease]; + NSData* secret = [resultDict objectForKey:(__bridge NSString*)kSecValueData]; + if (!secret) { + MOZ_LOG(gKeychainUtilsLog, LogLevel::Debug, ("objectForKey failed")); + return NS_ERROR_FAILURE; + } + if ([secret length] != 0) { + // We assume that the data is UTF-8 encoded since that seems to be common + // and Keychain Access shows it with that encoding. + aKey.Assign(reinterpret_cast<const char*>([secret bytes]), [secret length]); + } + + return NS_OK; +} diff --git a/browser/components/migration/nsWindowsMigrationUtils.h b/browser/components/migration/nsWindowsMigrationUtils.h new file mode 100644 index 0000000000..4541759485 --- /dev/null +++ b/browser/components/migration/nsWindowsMigrationUtils.h @@ -0,0 +1,33 @@ +/* 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/. */ + +#ifndef windowsmigrationutils__h__ +#define windowsmigrationutils__h__ + +#include "prtime.h" + +static PRTime WinMigrationFileTimeToPRTime(FILETIME* filetime, bool* isValid) { + SYSTEMTIME st; + *isValid = ::FileTimeToSystemTime(filetime, &st); + if (!*isValid) { + return 0; + } + PRExplodedTime prt; + prt.tm_year = st.wYear; + // SYSTEMTIME's day-of-month parameter is 1-based, + // PRExplodedTime's is 0-based. + prt.tm_month = st.wMonth - 1; + prt.tm_mday = st.wDay; + prt.tm_hour = st.wHour; + prt.tm_min = st.wMinute; + prt.tm_sec = st.wSecond; + prt.tm_usec = st.wMilliseconds * 1000; + prt.tm_wday = 0; + prt.tm_yday = 0; + prt.tm_params.tp_gmt_offset = 0; + prt.tm_params.tp_dst_offset = 0; + return PR_ImplodeTime(&prt); +} + +#endif diff --git a/browser/components/migration/tests/browser/browser.toml b/browser/components/migration/tests/browser/browser.toml new file mode 100644 index 0000000000..637c6da345 --- /dev/null +++ b/browser/components/migration/tests/browser/browser.toml @@ -0,0 +1,50 @@ +[DEFAULT] +head = "head.js" +prefs = [ + "browser.migrate.internal-testing.enabled=true", + "dom.window.sizeToContent.enabled=true", +] +support-files = ["../head-common.js"] + +["browser_aboutwelcome_behavior.js"] + +["browser_dialog_cancel_close.js"] + +["browser_dialog_open.js"] + +["browser_dialog_resize.js"] + +["browser_disabled_migrator.js"] + +["browser_do_migration.js"] + +["browser_entrypoint_telemetry.js"] + +["browser_extension_migration.js"] +skip-if = ["win11_2009"] # Bug 1840718 + +["browser_file_migration.js"] +skip-if = [ + "os == 'win' && debug", # Bug 1827995 + "a11y_checks", # Bug 1858037 to investigate intermittent a11y_checks results (fails on Try, passes on Autoland) +] +support-files = ["dummy_file.csv"] + +["browser_ie_edge_bookmarks_success_strings.js"] +skip-if = ["a11y_checks"] # Bug 1858037 to investigate intermittent a11y_checks results (fails on Try, passes on Autoland) + +["browser_misc_telemetry.js"] +skip-if = ["a11y_checks"] # Bug 1858037 to investigate intermittent a11y_checks results (fails on Autoland, passes on Try) + +["browser_no_browsers_state.js"] + +["browser_only_file_migrators.js"] + +["browser_permissions.js"] +skip-if = ["a11y_checks"] # Bug 1858037 and 1855492 to investigate intermittent a11y_checks results (fails on Try, passes on Autoland) + +["browser_safari_passwords.js"] +run-if = ["os == 'mac'"] + +["browser_safari_permissions.js"] +run-if = ["os == 'mac'"] diff --git a/browser/components/migration/tests/browser/browser_aboutwelcome_behavior.js b/browser/components/migration/tests/browser/browser_aboutwelcome_behavior.js new file mode 100644 index 0000000000..72c90851e2 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_aboutwelcome_behavior.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if browser.migrate.content-modal.about-welcome-behavior + * is "autoclose", that closing the migration dialog when opened with the + * NEWTAB entrypoint (which currently only occurs from about:welcome), + * will result in the about:preferences tab closing too. + */ +add_task(async function test_autoclose_from_welcome() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.migrate.content-modal.about-welcome-behavior", "autoclose"], + ], + }); + + let migrationDialogPromise = waitForMigrationWizardDialogTab(); + MigrationUtils.showMigrationWizard(window, { + entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.NEWTAB, + }); + + let prefsBrowser = await migrationDialogPromise; + let prefsTab = gBrowser.getTabForBrowser(prefsBrowser); + + let tabClosed = BrowserTestUtils.waitForTabClosing(prefsTab); + + let dialog = prefsBrowser.contentDocument.querySelector( + "#migrationWizardDialog" + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + await BrowserTestUtils.synthesizeKey("VK_ESCAPE", {}, prefsBrowser); + await dialogClosed; + await tabClosed; + Assert.ok(true, "Preferences tab closed with autoclose behavior."); +}); + +/** + * Tests that if browser.migrate.content-modal.about-welcome-behavior + * is "default", that closing the migration dialog when opened with the + * NEWTAB entrypoint (which currently only occurs from about:welcome), + * will result in the about:preferences tab still staying open. + */ +add_task(async function test_no_autoclose_from_welcome() { + // Create a new blank tab which about:preferences will open into. + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.migrate.content-modal.about-welcome-behavior", "default"]], + }); + + let migrationDialogPromise = waitForMigrationWizardDialogTab(); + MigrationUtils.showMigrationWizard(window, { + entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.NEWTAB, + }); + + let prefsBrowser = await migrationDialogPromise; + let prefsTab = gBrowser.getTabForBrowser(prefsBrowser); + + let dialog = prefsBrowser.contentDocument.querySelector( + "#migrationWizardDialog" + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + await BrowserTestUtils.synthesizeKey("VK_ESCAPE", {}, prefsBrowser); + await dialogClosed; + Assert.ok(!prefsTab.closing, "about:preferences tab is not closing."); + + BrowserTestUtils.removeTab(prefsTab); +}); + +/** + * Tests that if browser.migrate.content-modal.about-welcome-behavior + * is "standalone", that opening the migration wizard from the NEWTAB + * entrypoint opens the migration wizard in a standalone top-level + * window. + */ +add_task(async function test_no_autoclose_from_welcome() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.migrate.content-modal.about-welcome-behavior", "standalone"], + ], + }); + + let windowOpened = BrowserTestUtils.domWindowOpened(); + MigrationUtils.showMigrationWizard(window, { + entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.NEWTAB, + }); + let dialogWin = await windowOpened; + Assert.ok(dialogWin, "Top-level dialog window opened for the migrator."); + await BrowserTestUtils.waitForEvent(dialogWin, "MigrationWizard:Ready"); + + let dialogClosed = BrowserTestUtils.domWindowClosed(dialogWin); + dialogWin.close(); + await dialogClosed; +}); diff --git a/browser/components/migration/tests/browser/browser_dialog_cancel_close.js b/browser/components/migration/tests/browser/browser_dialog_cancel_close.js new file mode 100644 index 0000000000..8fe510cf30 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_dialog_cancel_close.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that pressing "Cancel" from the selection page of the migration + * dialog closes the dialog when opened in about:preferences as an HTML5 + * dialog. + */ +add_task(async function test_cancel_close() { + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let cancelButton = shadow.querySelector( + 'div[name="page-selection"] .cancel-close' + ); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + cancelButton.click(); + await dialogClosed; + Assert.ok(true, "Clicking the cancel button closed the dialog."); + }); +}); + +/** + * Tests that pressing "Cancel" from the selection page of the migration + * dialog closes the dialog when opened in stand-alone window. + */ +add_task(async function test_cancel_close() { + let promiseWinLoaded = BrowserTestUtils.domWindowOpened().then(win => { + return BrowserTestUtils.waitForEvent(win, "MigrationWizard:Ready"); + }); + + let win = Services.ww.openWindow( + window, + DIALOG_URL, + "_blank", + "dialog,centerscreen", + {} + ); + await promiseWinLoaded; + + win.sizeToContent(); + let wizard = win.document.querySelector("#wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let cancelButton = shadow.querySelector( + 'div[name="page-selection"] .cancel-close' + ); + + let windowClosed = BrowserTestUtils.windowClosed(win); + cancelButton.click(); + await windowClosed; + Assert.ok(true, "Window was closed."); +}); diff --git a/browser/components/migration/tests/browser/browser_dialog_open.js b/browser/components/migration/tests/browser/browser_dialog_open.js new file mode 100644 index 0000000000..1ec43f0ea6 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_dialog_open.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that we can open the migration dialog in an about:preferences + * HTML5 dialog when calling MigrationUtils.showMigrationWizard within a + * tabbrowser window execution context. + */ +add_task(async function test_migration_dialog_open_in_tab_dialog_box() { + let migrationDialogPromise = waitForMigrationWizardDialogTab(); + MigrationUtils.showMigrationWizard(window, {}); + let prefsBrowser = await migrationDialogPromise; + Assert.ok(true, "Migration dialog was opened"); + let dialog = prefsBrowser.contentDocument.querySelector( + "#migrationWizardDialog" + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + await BrowserTestUtils.synthesizeKey("VK_ESCAPE", {}, prefsBrowser); + await dialogClosed; + BrowserTestUtils.startLoadingURIString(prefsBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(prefsBrowser); +}); + +/** + * Tests that we can open the migration dialog in a stand-alone window + * when calling MigrationUtils.showMigrationWizard with a null opener + * argument, or a non-tabbrowser window context. + */ +add_task(async function test_migration_dialog_open_in_xul_window() { + let firstWindowOpened = BrowserTestUtils.domWindowOpened(); + MigrationUtils.showMigrationWizard(null, {}); + let firstDialogWin = await firstWindowOpened; + + await BrowserTestUtils.waitForEvent(firstDialogWin, "MigrationWizard:Ready"); + + Assert.ok(true, "Migration dialog was opened"); + + // Now open a second migration dialog, using the first as the window + // argument. + + let secondWindowOpened = BrowserTestUtils.domWindowOpened(); + MigrationUtils.showMigrationWizard(firstDialogWin, {}); + let secondDialogWin = await secondWindowOpened; + + await BrowserTestUtils.waitForEvent(secondDialogWin, "MigrationWizard:Ready"); + + for (let dialogWin of [firstDialogWin, secondDialogWin]) { + let dialogClosed = BrowserTestUtils.domWindowClosed(dialogWin); + dialogWin.close(); + await dialogClosed; + } +}); diff --git a/browser/components/migration/tests/browser/browser_dialog_resize.js b/browser/components/migration/tests/browser/browser_dialog_resize.js new file mode 100644 index 0000000000..8fb05faf2c --- /dev/null +++ b/browser/components/migration/tests/browser/browser_dialog_resize.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if the MigrationWizard resizes when opened inside of a + * XUL window, that it causes the containing XUL window to resize + * appropriately. + */ +add_task(async function test_migration_dialog_resize_in_xul_window() { + let windowOpened = BrowserTestUtils.domWindowOpened(); + MigrationUtils.showMigrationWizard(null, {}); + let dialogWin = await windowOpened; + + await BrowserTestUtils.waitForEvent(dialogWin, "MigrationWizard:Ready"); + + let wizard = dialogWin.document.body.querySelector("#wizard"); + let height = wizard.getBoundingClientRect().height; + + let windowResizePromise = BrowserTestUtils.waitForEvent(dialogWin, "resize"); + wizard.style.height = height + 100 + "px"; + await windowResizePromise; + Assert.ok(true, "Migration dialog window resized."); + + let dialogClosed = BrowserTestUtils.domWindowClosed(dialogWin); + dialogWin.close(); + await dialogClosed; +}); diff --git a/browser/components/migration/tests/browser/browser_disabled_migrator.js b/browser/components/migration/tests/browser/browser_disabled_migrator.js new file mode 100644 index 0000000000..782666f6a6 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_disabled_migrator.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MigratorBase } = ChromeUtils.importESModule( + "resource:///modules/MigratorBase.sys.mjs" +); + +/** + * Tests that the InternalTestingProfileMigrator is listed in + * the new migration wizard selector when enabled. + */ +add_task(async function test_enabled_migrator() { + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + let panelItem = shadow.querySelector( + `panel-item[key="${InternalTestingProfileMigrator.key}"]` + ); + + Assert.ok( + panelItem, + "The InternalTestingProfileMigrator panel-item exists." + ); + panelItem.click(); + + Assert.ok( + selector.innerText.includes("Internal Testing Migrator"), + "Testing for enabled internal testing migrator" + ); + }); +}); + +/** + * Tests that the InternalTestingProfileMigrator is not listed in + * the new migration wizard selector when disabled. + */ +add_task(async function test_disabling_migrator() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.migrate.internal-testing.enabled", false]], + }); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let internalTestingMigrator = new InternalTestingProfileMigrator(); + + // We create a fake migrator that we know will still be present after + // disabling the InternalTestingProfileMigrator so that we don't switch + // the wizard to the NO_BROWSERS_FOUND page, which we're not testing here. + let fakeMigrator = new FakeMigrator(); + + let getMigratorStub = sandbox.stub(MigrationUtils, "getMigrator"); + getMigratorStub + .withArgs("internal-testing") + .resolves(internalTestingMigrator); + getMigratorStub.withArgs("fake-migrator").resolves(fakeMigrator); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return ["internal-testing", "fake-migrator"]; + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + let panelItem = shadow.querySelector( + `panel-item[key="${InternalTestingProfileMigrator.key}"]` + ); + + Assert.ok( + !panelItem, + "The panel-item for the InternalTestingProfileMigrator does not exist" + ); + }); + + sandbox.restore(); +}); + +/** + * A stub of a migrator used for automated testing only. + */ +class FakeMigrator extends MigratorBase { + static get key() { + return "fake-migrator"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-firefox"; + } + + // We will create a single MigratorResource for each resource type that + // just immediately reports a successful migration. + getResources() { + return Object.values(MigrationUtils.resourceTypes).map(type => { + return { + type, + migrate: callback => { + callback(true /* success */); + }, + }; + }); + } + + // We need to override enabled() to always return true for testing purposes. + get enabled() { + return true; + } +} diff --git a/browser/components/migration/tests/browser/browser_do_migration.js b/browser/components/migration/tests/browser/browser_do_migration.js new file mode 100644 index 0000000000..fab9641960 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_do_migration.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the MigrationWizard can be used to successfully migrate + * using the InternalTestingProfileMigrator in a few scenarios. + */ +add_task(async function test_successful_migrations() { + // Scenario 1: A single resource type is available. + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.BOOKMARKS], + [MigrationUtils.resourceTypes.BOOKMARKS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal(shadow.activeElement, selector, "Selector should be focused."); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + await migration; + await wizardDone; + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal( + shadow.activeElement, + doneButton, + "Done button should be focused." + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + }); + + // We should make sure that the migration.time_to_produce_migrator_list + // scalar was set, since we know that at least one migration wizard has + // been opened. + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + Assert.ok( + scalars["migration.time_to_produce_migrator_list"] > 0, + "Non-zero scalar value recorded for migration.time_to_produce_migrator_list" + ); + + // Scenario 2: Several resource types are available, but only 1 + // is checked / expected. + migration = waitForTestMigration( + [ + MigrationUtils.resourceTypes.BOOKMARKS, + MigrationUtils.resourceTypes.PASSWORDS, + ], + [MigrationUtils.resourceTypes.PASSWORDS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal(shadow.activeElement, selector, "Selector should be focused."); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + await migration; + await wizardDone; + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal( + shadow.activeElement, + doneButton, + "Done button should be focused." + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + }); + + // Scenario 3: Several resource types are available, all are checked. + let allResourceTypeStrs = Object.values( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES + ).filter(resourceStr => { + return !MigrationWizardConstants.PROFILE_RESET_ONLY_RESOURCE_TYPES[ + resourceStr + ]; + }); + + let allResourceTypes = allResourceTypeStrs.map(resourceTypeStr => { + return MigrationUtils.resourceTypes[resourceTypeStr]; + }); + + migration = waitForTestMigration( + allResourceTypes, + allResourceTypes, + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let selector = shadow.querySelector("#browser-profile-selector"); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal(shadow.activeElement, selector, "Selector should be focused."); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, allResourceTypeStrs); + await migration; + await wizardDone; + assertQuantitiesShown(wizard, allResourceTypeStrs); + }); +}); + +/** + * Tests that if somehow the Migration Wizard requests to import a + * resource type that the migrator doesn't have the ability to import, + * that it's ignored and the migration completes normally. + */ +add_task(async function test_invalid_resource_type() { + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.BOOKMARKS], + [MigrationUtils.resourceTypes.BOOKMARKS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + // The Migration Wizard _shouldn't_ display anything except BOOKMARKS, + // since that's the only resource type that the selected migrator is + // supposed to currently support, but we'll check the other checkboxes + // even though they're hidden just to see what happens. + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA, + ]); + await migration; + await wizardDone; + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let shadow = wizard.openOrClosedShadowRoot; + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal( + shadow.activeElement, + doneButton, + "Done button should be focused." + ); + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + }); +}); diff --git a/browser/components/migration/tests/browser/browser_entrypoint_telemetry.js b/browser/components/migration/tests/browser/browser_entrypoint_telemetry.js new file mode 100644 index 0000000000..a1bb23d7fc --- /dev/null +++ b/browser/components/migration/tests/browser/browser_entrypoint_telemetry.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const HISTOGRAM_ID = "FX_MIGRATION_ENTRY_POINT_CATEGORICAL"; + +async function showThenCloseMigrationWizardViaEntrypoint(entrypoint) { + let openedPromise = BrowserTestUtils.waitForMigrationWizard(window); + + MigrationUtils.showMigrationWizard(window, { + entrypoint, + }); + + let wizardTab = await openedPromise; + Assert.ok(wizardTab, "Migration wizard opened."); + + await BrowserTestUtils.removeTab(wizardTab); +} + +add_setup(async () => { + // Load the initial tab at example.com. This makes it so that if + // when we load the wizard in about:preferences, we'll load the + // about:preferences page in a new tab rather than overtaking the + // initial one. This makes cleanup of the wizard more explicit, since + // we can just close the tab. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "https://example.com"); + await BrowserTestUtils.browserLoaded(browser); +}); + +/** + * Tests that the entrypoint passed to MigrationUtils.showMigrationWizard gets + * written to both the FX_MIGRATION_ENTRY_POINT_CATEGORICAL histogram. + */ +add_task(async function test_entrypoints() { + let histogram = TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_ID); + + // Let's arbitrarily pick the "Bookmarks" entrypoint, and make sure this + // is recorded. + await showThenCloseMigrationWizardViaEntrypoint( + MigrationUtils.MIGRATION_ENTRYPOINTS.BOOKMARKS + ); + let entrypointIndex = getEntrypointHistogramIndex( + MigrationUtils.MIGRATION_ENTRYPOINTS.BOOKMARKS + ); + + TelemetryTestUtils.assertHistogram(histogram, entrypointIndex, 1); + + histogram = TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_ID); + + // Now let's pick the "Preferences" entrypoint, and make sure this + // is recorded. + await showThenCloseMigrationWizardViaEntrypoint( + MigrationUtils.MIGRATION_ENTRYPOINTS.PREFERENCES + ); + entrypointIndex = getEntrypointHistogramIndex( + MigrationUtils.MIGRATION_ENTRYPOINTS.PREFERENCES + ); + + TelemetryTestUtils.assertHistogram(histogram, entrypointIndex, 1); + + histogram = TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_ID); + + // Finally, check the fallback by passing in something invalid as an entrypoint. + await showThenCloseMigrationWizardViaEntrypoint(undefined); + entrypointIndex = getEntrypointHistogramIndex( + MigrationUtils.MIGRATION_ENTRYPOINTS.UNKNOWN + ); + + TelemetryTestUtils.assertHistogram(histogram, entrypointIndex, 1); +}); diff --git a/browser/components/migration/tests/browser/browser_extension_migration.js b/browser/components/migration/tests/browser/browser_extension_migration.js new file mode 100644 index 0000000000..e9c3c65e6d --- /dev/null +++ b/browser/components/migration/tests/browser/browser_extension_migration.js @@ -0,0 +1,238 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let gFluentStrings = new Localization([ + "branding/brand.ftl", + "browser/migrationWizard.ftl", +]); + +/** + * Ensures that the wizard is on the progress page and that the extension + * resource group matches a particular state. + * + * @param {Element} wizard + * The <migration-wizard> element to inspect. + * @param {number} state + * One of the constants from MigrationWizardConstants.PROGRESS_VALUE, + * describing what state the resource group should be in. + * @param {object} description + * An object to express more details of how the resource group should be + * displayed. + * @param {string} description.message + * The message that should be displayed for the resource group. This message + * maybe be contained in different elements depending on the state. + * @param {string} description.linkURL + * The URL for the <a> element that should be displayed to the user for the + * particular state. + * @param {string} description.linkText + * The text content for the <a> element that should be displayed to the user + * for the particular state. + * @returns {Promise<undefined>} + */ +async function assertExtensionsProgressState(wizard, state, description) { + let shadow = wizard.openOrClosedShadowRoot; + + // Make sure that we're showing the progress page first. + let deck = shadow.querySelector("#wizard-deck"); + Assert.equal( + deck.selectedViewName, + `page-${MigrationWizardConstants.PAGES.PROGRESS}` + ); + + let progressGroup = shadow.querySelector( + `.resource-progress-group[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS}"` + ); + + let progressIcon = progressGroup.querySelector(".progress-icon"); + let messageText = progressGroup.querySelector("span.message-text"); + let supportLink = progressGroup.querySelector(".support-text"); + let extensionsSuccessLink = progressGroup.querySelector( + "#extensions-success-link" + ); + + if (state == MigrationWizardConstants.PROGRESS_VALUE.SUCCESS) { + Assert.stringMatches(progressIcon.getAttribute("state"), "success"); + Assert.stringMatches(messageText.textContent, ""); + Assert.stringMatches(supportLink.textContent, ""); + await assertSuccessLink(extensionsSuccessLink, description.message); + } else if (state == MigrationWizardConstants.PROGRESS_VALUE.WARNING) { + Assert.stringMatches(progressIcon.getAttribute("state"), "warning"); + Assert.stringMatches(messageText.textContent, description.message); + Assert.stringMatches(supportLink.textContent, description.linkText); + Assert.stringMatches(supportLink.href, description.linkURL); + await assertSuccessLink(extensionsSuccessLink, ""); + } else if (state == MigrationWizardConstants.PROGRESS_VALUE.INFO) { + Assert.stringMatches(progressIcon.getAttribute("state"), "info"); + Assert.stringMatches(supportLink.textContent, ""); + await assertSuccessLink(extensionsSuccessLink, description.message); + } +} + +/** + * Checks that the extensions migration success link has the right + * text content, and if the text content is non-blank, ensures that + * clicking on the link opens up about:addons in a background tab. + * + * The about:addons tab will be automatically closed before proceeding. + * + * @param {Element} link + * The extensions migration success link element. + * @param {string} message + * The expected string to appear in the link textContent. If the + * link is not expected to appear, this should be the empty string. + * @returns {Promise<undefined>} + */ +async function assertSuccessLink(link, message) { + Assert.stringMatches(link.textContent, message); + if (message) { + let aboutAddonsOpened = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons" + ); + EventUtils.synthesizeMouseAtCenter(link, {}, link.ownerGlobal); + let tab = await aboutAddonsOpened; + BrowserTestUtils.removeTab(tab); + } +} + +/** + * Checks the case where no extensions were matched. + */ +add_task(async function test_extension_migration_no_matched_extensions() { + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.EXTENSIONS], + [MigrationUtils.resourceTypes.EXTENSIONS], + InternalTestingProfileMigrator.testProfile, + [MigrationUtils.resourceTypes.EXTENSIONS], + 3 /* totalExtensions */, + 0 /* matchedExtensions */ + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS, + ]); + await migration; + await wizardDone; + await assertExtensionsProgressState( + wizard, + MigrationWizardConstants.PROGRESS_VALUE.WARNING, + { + message: await gFluentStrings.formatValue( + "migration-wizard-progress-no-matched-extensions" + ), + linkURL: Services.urlFormatter.formatURLPref( + "extensions.getAddons.link.url" + ), + linkText: await gFluentStrings.formatValue( + "migration-wizard-progress-extensions-addons-link" + ), + } + ); + }); +}); + +/** + * Checks the case where some but not all extensions were matched. + */ +add_task( + async function test_extension_migration_partially_matched_extensions() { + const TOTAL_EXTENSIONS = 3; + const TOTAL_MATCHES = 1; + + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.EXTENSIONS], + [MigrationUtils.resourceTypes.EXTENSIONS], + InternalTestingProfileMigrator.testProfile, + [], + TOTAL_EXTENSIONS, + TOTAL_MATCHES + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS, + ]); + await migration; + await wizardDone; + await assertExtensionsProgressState( + wizard, + MigrationWizardConstants.PROGRESS_VALUE.INFO, + { + message: await gFluentStrings.formatValue( + "migration-wizard-progress-partial-success-extensions", + { + matched: TOTAL_MATCHES, + quantity: TOTAL_EXTENSIONS, + } + ), + linkText: await gFluentStrings.formatValue( + "migration-wizard-progress-extensions-support-link" + ), + } + ); + }); + } +); + +/** + * Checks the case where all extensions were matched. + */ +add_task(async function test_extension_migration_fully_matched_extensions() { + const TOTAL_EXTENSIONS = 15; + const TOTAL_MATCHES = TOTAL_EXTENSIONS; + + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.EXTENSIONS], + [MigrationUtils.resourceTypes.EXTENSIONS], + InternalTestingProfileMigrator.testProfile, + [], + TOTAL_EXTENSIONS, + TOTAL_MATCHES + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS, + ]); + await migration; + await wizardDone; + await assertExtensionsProgressState( + wizard, + MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + { + message: await gFluentStrings.formatValue( + "migration-wizard-progress-success-extensions", + { + quantity: TOTAL_EXTENSIONS, + } + ), + linkURL: "", + linkText: "", + } + ); + }); +}); diff --git a/browser/components/migration/tests/browser/browser_file_migration.js b/browser/components/migration/tests/browser/browser_file_migration.js new file mode 100644 index 0000000000..04241d29d5 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_file_migration.js @@ -0,0 +1,306 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FileMigratorBase } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); + +const DUMMY_FILEMIGRATOR_KEY = "dummy-file-migrator"; +const DUMMY_FILEPICKER_TITLE = "Some dummy file picker title"; +const DUMMY_FILTER_TITLE = "Some file type"; +const DUMMY_EXTENSION_PATTERN = "*.test"; +const TEST_FILE_PATH = getTestFilePath("dummy_file.csv"); + +/** + * A subclass of FileMigratorBase that doesn't do anything, but + * is useful for testing. + * + * Notably, the `migrate` method is not overridden here. Tests that + * use this class should use Sinon to stub out the migrate method. + */ +class DummyFileMigrator extends FileMigratorBase { + static get key() { + return DUMMY_FILEMIGRATOR_KEY; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-file-password-csv"; + } + + static get brandImage() { + return "chrome://branding/content/document.ico"; + } + + get enabled() { + return true; + } + + get progressHeaderL10nID() { + return "migration-passwords-from-file-progress-header"; + } + + get successHeaderL10nID() { + return "migration-passwords-from-file-success-header"; + } + + async getFilePickerConfig() { + return Promise.resolve({ + title: DUMMY_FILEPICKER_TITLE, + filters: [ + { + title: DUMMY_FILTER_TITLE, + extensionPattern: DUMMY_EXTENSION_PATTERN, + }, + ], + }); + } + + get displayedResourceTypes() { + return [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS]; + } +} + +const { MockFilePicker } = SpecialPowers; + +add_setup(async () => { + // We use MockFilePicker to simulate a native file picker, and prepare it + // to return a dummy file pointed at TEST_FILE_PATH. The file at + // TEST_FILE_PATH is not required (nor expected) to exist. + MockFilePicker.init(window); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +/** + * Tests the flow of selecting a file migrator (in this case, + * the DummyFileMigrator), getting the file picker opened for it, + * and then passing the path of the selected file to the migrator. + */ +add_task(async function test_file_migration() { + let migrator = new DummyFileMigrator(); + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + // First, use Sinon to insert our DummyFileMigrator as the only available + // file migrator. + sandbox.stub(MigrationUtils, "getFileMigrator").callsFake(() => { + return migrator; + }); + sandbox.stub(MigrationUtils, "availableFileMigrators").get(() => { + return [migrator]; + }); + + // This is the expected success state that our DummyFileMigrator will + // return as the final progress update to the migration wizard. + const SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: + "2 added", + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED]: + "1 updated", + }; + + let migrateStub = sandbox.stub(migrator, "migrate").callsFake(filePath => { + Assert.equal(filePath, TEST_FILE_PATH); + return SUCCESS_STATE; + }); + + let dummyFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dummyFile.initWithPath(TEST_FILE_PATH); + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.setFiles([dummyFile]); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + // Now select our DummyFileMigrator from the list. + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + info("Waiting for panel-list shown"); + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + info("Panel list shown. Clicking on panel-item"); + let panelItem = shadow.querySelector( + `panel-item[key="${DUMMY_FILEMIGRATOR_KEY}"]` + ); + panelItem.click(); + + // Selecting a file migrator from the selector should automatically + // open the file picker, so we await it here. Once the file is + // selected, migration should begin immediately. + + info("Waiting for file picker"); + await filePickerShownPromise; + await wizardDone; + Assert.ok(migrateStub.called, "Migrate on DummyFileMigrator was called."); + + // At this point, with migration having completed, we should be showing + // the PROGRESS page with the SUCCESS_STATE represented. + let deck = shadow.querySelector("#wizard-deck"); + Assert.equal( + deck.selectedViewName, + `page-${MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS}` + ); + + // We expect only the displayed resource types in SUCCESS_STATE are + // displayed now. + let progressGroups = shadow.querySelectorAll( + "div[name='page-file-import-progress'] .resource-progress-group" + ); + for (let progressGroup of progressGroups) { + let expectedMessageText = + SUCCESS_STATE[progressGroup.dataset.resourceType]; + if (expectedMessageText) { + let progressIcon = progressGroup.querySelector(".progress-icon"); + Assert.stringMatches( + progressIcon.getAttribute("state"), + "success", + "Should be showing completed state." + ); + + let messageText = + progressGroup.querySelector(".message-text").textContent; + Assert.equal(messageText, expectedMessageText); + } else { + Assert.ok( + BrowserTestUtils.isHidden(progressGroup), + `Resource progress group for ${progressGroup.dataset.resourceType}` + + ` should be hidden.` + ); + } + } + }); + + sandbox.restore(); +}); + +/** + * Tests that the migration wizard will go back to the selection page and + * show an error message if the migration for a FileMigrator throws an + * exception. + */ +add_task(async function test_file_migration_error() { + let migrator = new DummyFileMigrator(); + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + // First, use Sinon to insert our DummyFileMigrator as the only available + // file migrator. + sandbox.stub(MigrationUtils, "getFileMigrator").callsFake(() => { + return migrator; + }); + sandbox.stub(MigrationUtils, "availableFileMigrators").get(() => { + return [migrator]; + }); + + const ERROR_MESSAGE = "This is my error message"; + + let migrateStub = sandbox.stub(migrator, "migrate").callsFake(() => { + throw new Error(ERROR_MESSAGE); + }); + + let dummyFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dummyFile.initWithPath(TEST_FILE_PATH); + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.setFiles([dummyFile]); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + // Now select our DummyFileMigrator from the list. + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + info("Waiting for panel-list shown"); + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + info("Panel list shown. Clicking on panel-item"); + let panelItem = shadow.querySelector( + `panel-item[key="${DUMMY_FILEMIGRATOR_KEY}"]` + ); + panelItem.click(); + + // Selecting a file migrator from the selector should automatically + // open the file picker, so we await it here. Once the file is + // selected, migration should begin immediately. + + info("Waiting for file picker"); + await filePickerShownPromise; + await wizardDone; + Assert.ok(migrateStub.called, "Migrate on DummyFileMigrator was called."); + + // At this point, with migration having completed, we should be showing + // the SELECTION page again with the ERROR_MESSAGE displayed. + let deck = shadow.querySelector("#wizard-deck"); + await BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.SELECTION + ); + } + ); + + Assert.equal( + selector.selectedPanelItem.getAttribute("key"), + DUMMY_FILEMIGRATOR_KEY, + "Should have the file migrator selected." + ); + + let errorMessageContainer = shadow.querySelector(".file-import-error"); + Assert.ok( + BrowserTestUtils.isVisible(errorMessageContainer), + "Should be showing the error message container" + ); + + let fileImportErrorMessage = shadow.querySelector( + "#file-import-error-message" + ).textContent; + Assert.equal(fileImportErrorMessage, ERROR_MESSAGE); + }); + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/browser/browser_ie_edge_bookmarks_success_strings.js b/browser/components/migration/tests/browser/browser_ie_edge_bookmarks_success_strings.js new file mode 100644 index 0000000000..34e8a8de2e --- /dev/null +++ b/browser/components/migration/tests/browser/browser_ie_edge_bookmarks_success_strings.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the progress strings that the Migration Wizard shows + * during migrations for IE and Edge uses the term "Favorites" rather + * then "Bookmarks". + */ +add_task(async function test_ie_edge_bookmarks_success_strings() { + for (let key of ["ie", "edge", "internal-testing"]) { + let sandbox = sinon.createSandbox(); + + sandbox.stub(InternalTestingProfileMigrator, "key").get(() => { + return key; + }); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return key; + }); + + let testingMigrator = new InternalTestingProfileMigrator(); + sandbox.stub(MigrationUtils, "getMigrator").callsFake(() => { + return Promise.resolve(testingMigrator); + }); + + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.BOOKMARKS], + [MigrationUtils.resourceTypes.BOOKMARKS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration( + wizard, + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS], + key + ); + await migration; + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let shadow = wizard.openOrClosedShadowRoot; + + // If we were using IE or Edge (EdgeHTLM), then the success message should + // include the word "favorites". Otherwise, we expect it to include + // the word "bookmarks". + let bookmarksProgressGroup = shadow.querySelector( + `.resource-progress-group[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS}"` + ); + let messageTextElement = + bookmarksProgressGroup.querySelector(".message-text"); + + await BrowserTestUtils.waitForCondition(() => { + return messageTextElement.textContent.trim(); + }); + + let messageText = messageTextElement.textContent.toLowerCase(); + + if (key == "internal-testing") { + Assert.ok( + messageText.includes("bookmarks"), + `Message text should refer to bookmarks: ${messageText}.` + ); + } else { + Assert.ok( + messageText.includes("favorites"), + `Message text should refer to favorites: ${messageText}` + ); + } + + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + await wizardDone; + }); + + sandbox.restore(); + } +}); diff --git a/browser/components/migration/tests/browser/browser_misc_telemetry.js b/browser/components/migration/tests/browser/browser_misc_telemetry.js new file mode 100644 index 0000000000..4fc6518e49 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_misc_telemetry.js @@ -0,0 +1,178 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs" +); + +/** + * Tests that if the migration wizard is opened when the + * MOZ_UNINSTALLER_PROFILE_REFRESH environment variable is defined, + * that the migration.uninstaller_profile_refresh scalar is set, + * and the environment variable is cleared. + */ +add_task(async function test_uninstaller_migration() { + if (AppConstants.platform != "win") { + return; + } + + Services.env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", "1"); + let wizardPromise = BrowserTestUtils.domWindowOpened(); + // Opening the migration wizard this way is a blocking function, so + // we delegate it to a runnable. + executeSoon(() => { + MigrationUtils.showMigrationWizard(null, { isStartupMigration: true }); + }); + let wizardWin = await wizardPromise; + + await BrowserTestUtils.waitForEvent(wizardWin, "MigrationWizard:Ready"); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar( + scalars, + "migration.uninstaller_profile_refresh", + 1 + ); + + Assert.equal( + Services.env.get("MOZ_UNINSTALLER_PROFILE_REFRESH"), + "", + "Cleared MOZ_UNINSTALLER_PROFILE_REFRESH environment variable." + ); + await BrowserTestUtils.closeWindow(wizardWin); +}); + +/** + * Tests that we populate the migration.discovered_migrators keyed scalar + * with a count of discovered browsers and profiles. + */ +add_task(async function test_discovered_migrators_keyed_scalar() { + Services.telemetry.clearScalars(); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + // We'll pretend that this system only has the + // InternalTestingProfileMigrator and ChromeProfileMigrator around to + // start + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return ["internal-testing", "chrome"]; + }); + + // The InternalTestingProfileMigrator by default returns a single profile + // from `getSourceProfiles`, and now we'll just prepare the + // ChromeProfileMigrator to return two fake profiles. + sandbox.stub(ChromeProfileMigrator.prototype, "getSourceProfiles").resolves([ + { id: "chrome-test-1", name: "Chrome test profile 1" }, + { id: "chrome-test-2", name: "Chrome test profile 2" }, + ]); + + sandbox + .stub(ChromeProfileMigrator.prototype, "hasPermissions") + .resolves(true); + + // We also need to ensure that the ChromeProfileMigrator actually has + // some resources to migrate, otherwise it won't get listed. + sandbox + .stub(ChromeProfileMigrator.prototype, "getResources") + .callsFake(() => { + return Promise.resolve( + Object.values(MigrationUtils.resourceType).map(resourceType => { + return { + type: resourceType, + migrate: () => {}, + }; + }) + ); + }); + + await withMigrationWizardDialog(async () => { + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "migration.discovered_migrators", + InternalTestingProfileMigrator.key, + 1 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "migration.discovered_migrators", + ChromeProfileMigrator.key, + 2 + ); + }); + + // Now, reset, and we'll try the case where a migrator returns `null` from + // `getSourceProfiles` using the InternalTestingProfileMigrator again. + sandbox.restore(); + + sandbox = sinon.createSandbox(); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return ["internal-testing"]; + }); + + sandbox + .stub(ChromeProfileMigrator.prototype, "getSourceProfiles") + .resolves(null); + + await withMigrationWizardDialog(async () => { + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "migration.discovered_migrators", + InternalTestingProfileMigrator.key, + 1 + ); + }); + + sandbox.restore(); +}); + +/** + * Tests that we write to the FX_MIGRATION_ERRORS histogram when a + * resource fails to migrate properly. + */ +add_task(async function test_fx_migration_errors() { + let migration = waitForTestMigration( + [ + MigrationUtils.resourceTypes.BOOKMARKS, + MigrationUtils.resourceTypes.PASSWORDS, + ], + [ + MigrationUtils.resourceTypes.BOOKMARKS, + MigrationUtils.resourceTypes.PASSWORDS, + ], + InternalTestingProfileMigrator.testProfile, + [MigrationUtils.resourceTypes.PASSWORDS] + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + await migration; + await wizardDone; + + assertQuantitiesShown( + wizard, + [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ], + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS] + ); + }); +}); diff --git a/browser/components/migration/tests/browser/browser_no_browsers_state.js b/browser/components/migration/tests/browser/browser_no_browsers_state.js new file mode 100644 index 0000000000..cd4677f31d --- /dev/null +++ b/browser/components/migration/tests/browser/browser_no_browsers_state.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the wizard switches to the NO_BROWSERS_FOUND page + * when no migrators are detected. + */ +add_task(async function test_browser_no_programs() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return []; + }); + + // Let's enable the Passwords CSV import by default so that it appears + // as a file migrator. + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.fileImport.enabled", true]], + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let deck = shadow.querySelector("#wizard-deck"); + + await BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND + ); + } + ); + + Assert.ok( + true, + "Went to no browser page after attempting to search for migrators." + ); + let chooseImportFromFile = shadow.querySelector("#choose-import-from-file"); + Assert.ok( + !chooseImportFromFile.hidden, + "Selecting a file migrator should still be possible." + ); + }); + + // Now disable all file migrators to make sure that the "Import from file" + // button is hidden. + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.management.page.fileImport.enabled", false], + ["browser.migrate.bookmarks-file.enabled", false], + ], + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let deck = shadow.querySelector("#wizard-deck"); + + await BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND + ); + } + ); + + Assert.ok( + true, + "Went to no browser page after attempting to search for migrators." + ); + let chooseImportFromFile = shadow.querySelector("#choose-import-from-file"); + Assert.ok( + chooseImportFromFile.hidden, + "Selecting a file migrator should not be possible." + ); + }); + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/browser/browser_only_file_migrators.js b/browser/components/migration/tests/browser/browser_only_file_migrators.js new file mode 100644 index 0000000000..ca18a8c0d5 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_only_file_migrators.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the NO_BROWSERS_FOUND page has a button to redirect to the + * selection page when only file migrators are found. + */ +add_task(async function test_only_file_migrators() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.fileImport.enabled", true]], + }); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + sandbox.stub(MigrationUtils, "availableMigratorKeys").get(() => { + return []; + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let wizard = dialog.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + let deck = shadow.querySelector("#wizard-deck"); + + await BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND + ); + } + ); + + let chooseImportFileButton = shadow.querySelector( + "#choose-import-from-file" + ); + + let changedToSelectionPage = BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.SELECTION + ); + } + ); + chooseImportFileButton.click(); + await changedToSelectionPage; + + // No browser migrators should be listed. + let browserMigratorItems = shadow.querySelectorAll( + `panel-item[type="${MigrationWizardConstants.MIGRATOR_TYPES.BROWSER}"]` + ); + Assert.ok(!browserMigratorItems.length, "No browser migrators listed."); + + // Check to make sure there's at least one file migrator listed. + let fileMigratorItems = shadow.querySelectorAll( + `panel-item[type="${MigrationWizardConstants.MIGRATOR_TYPES.FILE}"]` + ); + + Assert.ok(!!fileMigratorItems.length, "Listed at least one file migrator."); + }); +}); diff --git a/browser/components/migration/tests/browser/browser_permissions.js b/browser/components/migration/tests/browser/browser_permissions.js new file mode 100644 index 0000000000..35d902bb37 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_permissions.js @@ -0,0 +1,166 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.migrate.chrome.get_permissions.enabled", true]], + }); +}); + +/** + * Tests that the migration wizard can request permission from + * the user to read from other browser data directories when + * explicit permission needs to be granted. + * + * This can occur when, for example, Firefox is installed as a + * Snap on Ubuntu Linux. In this state, Firefox does not have + * direct read access to other browser's data directories (although) + * it can tell if they exist. For Chromium-based browsers, this + * means we cannot tell what profiles nor resources are available + * for Chromium-based browsers without read permissions. + * + * Note that the Safari migrator is not tested here, as it has + * its own special permission flow. This is because we can + * determine what resources Safari has before requiring permissions, + * and (as of this writing) Safari does not support multiple + * user profiles. + */ +add_task(async function test_permissions() { + Services.telemetry.clearEvents(); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + sandbox + .stub(InternalTestingProfileMigrator.prototype, "canGetPermissions") + .resolves("/some/path"); + + let hasPermissionsStub = sandbox + .stub(InternalTestingProfileMigrator.prototype, "hasPermissions") + .resolves(false); + + let testingMigrator = await MigrationUtils.getMigrator( + InternalTestingProfileMigrator.key + ); + Assert.ok( + testingMigrator, + "Got migrator, even though we don't yet have permission to read its resources." + ); + + sandbox.stub(testingMigrator, "getPermissions").callsFake(async () => { + testingMigrator.flushResourceCache(); + hasPermissionsStub.resolves(true); + return Promise.resolve(true); + }); + + let getResourcesStub = sandbox + .stub(testingMigrator, "getResources") + .resolves([]); + + let migration = waitForTestMigration( + [MigrationUtils.resourceTypes.BOOKMARKS], + [MigrationUtils.resourceTypes.BOOKMARKS], + InternalTestingProfileMigrator.testProfile + ); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let shadow = wizard.openOrClosedShadowRoot; + + // Clear out any pre-existing events events have been logged + Services.telemetry.clearEvents(); + TelemetryTestUtils.assertNumberOfEvents(0); + + let panelItem = shadow.querySelector( + `panel-item[key="${InternalTestingProfileMigrator.key}"]` + ); + panelItem.click(); + + let resourceList = shadow.querySelector(".resource-selection-details"); + Assert.ok( + BrowserTestUtils.isHidden(resourceList), + "Resources list is hidden." + ); + let importButton = shadow.querySelector("#import"); + Assert.ok(BrowserTestUtils.isHidden(importButton), "Import button hidden."); + let noPermissionsMessage = shadow.querySelector(".no-permissions-message"); + Assert.ok( + BrowserTestUtils.isVisible(noPermissionsMessage), + "No permissions message shown." + ); + let getPermissionButton = shadow.querySelector("#get-permissions"); + Assert.ok( + BrowserTestUtils.isVisible(getPermissionButton), + "Get permissions button shown." + ); + + // Now put the permissions functions back into their default + // state - which is the "permission granted" state. + getResourcesStub.restore(); + hasPermissionsStub.restore(); + + let refreshDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:Ready" + ); + + getPermissionButton.click(); + + await refreshDone; + Assert.ok(true, "Refreshed migrator list."); + + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + selectResourceTypesAndStartMigration(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + await migration; + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + + await new Promise(resolve => prefsWin.requestAnimationFrame(resolve)); + Assert.equal( + shadow.activeElement, + doneButton, + "Done button should be focused." + ); + + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + doneButton.click(); + await dialogClosed; + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "browser.migration", + method: "linux_perms", + object: "wizard", + value: null, + extra: { + migrator_key: InternalTestingProfileMigrator.key, + }, + }, + ], + { + category: "browser.migration", + method: "linux_perms", + object: "wizard", + } + ); +}); diff --git a/browser/components/migration/tests/browser/browser_safari_passwords.js b/browser/components/migration/tests/browser/browser_safari_passwords.js new file mode 100644 index 0000000000..c005342b46 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_safari_passwords.js @@ -0,0 +1,468 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SafariProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/SafariProfileMigrator.sys.mjs" +); +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); + +const TEST_FILE_PATH = getTestFilePath("dummy_file.csv"); + +// We use MockFilePicker to simulate a native file picker, and prepare it +// to return a dummy file pointed at TEST_FILE_PATH. The file at +// TEST_FILE_PATH is not required (nor expected) to exist. +const { MockFilePicker } = SpecialPowers; + +add_setup(async function () { + MockFilePicker.init(window); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.fileImport.enabled", true]], + }); +}); + +/** + * A helper function that does most of the heavy lifting for the tests in + * this file. Specfically, it takes care of: + * + * 1. Stubbing out the various hunks of the SafariProfileMigrator in order + * to simulate a migration without actually performing one, since the + * migrator itself isn't being tested here. + * 2. Stubbing out parts of MigrationUtils and LoginCSVImport to have a + * consistent reporting on how many things are imported. + * 3. Setting up the MockFilePicker if expectsFilePicker is true to return + * the TEST_FILE_PATH. + * 4. Opens up the migration wizard, and chooses to import both BOOKMARKS + * and PASSWORDS, and then clicks "Import". + * 5. Waits for the migration wizard to show the Safari password import + * instructions. + * 6. Runs taskFn + * 7. Closes the migration dialog. + * + * @param {boolean} expectsFilePicker + * True if the MockFilePicker should be set up to return TEST_FILE_PATH. + * @param {boolean} migrateBookmarks + * True if bookmarks should be migrated alongside passwords. If not, only + * passwords will be migrated. + * @param {boolean} shouldPasswordImportFail + * True if importing from the CSV file should fail. + * @param {Function} taskFn + * An asynchronous function that takes the following parameters in this + * order: + * + * {Element} wizard + * The opened migration wizard + * {Promise} filePickerShownPromise + * A Promise that resolves once the MockFilePicker has closed. This is + * undefined if expectsFilePicker was false. + * {object} importFromCSVStub + * The Sinon stub object for LoginCSVImport.importFromCSV. This can be + * used to check to see whether it was called. + * {Promise} didMigration + * A Promise that resolves to true once the migration completes. + * {Promise} wizardDone + * A Promise that resolves once the migration wizard reports that a + * migration has completed. + * @returns {Promise<undefined>} + */ +async function testSafariPasswordHelper( + expectsFilePicker, + migrateBookmarks, + shouldPasswordImportFail, + taskFn +) { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let safariMigrator = new SafariProfileMigrator(); + sandbox.stub(MigrationUtils, "getMigrator").resolves(safariMigrator); + + // We're not testing the permission flow here, so let's pretend that we + // always have permission to read resources from the disk. + sandbox + .stub(SafariProfileMigrator.prototype, "hasPermissions") + .resolves(true); + + // Have the migrator claim that only BOOKMARKS are only available. + sandbox + .stub(SafariProfileMigrator.prototype, "getMigrateData") + .resolves(MigrationUtils.resourceTypes.BOOKMARKS); + + let migrateStub; + let didMigration = new Promise(resolve => { + migrateStub = sandbox + .stub(SafariProfileMigrator.prototype, "migrate") + .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => { + if (!migrateBookmarks) { + Assert.ok( + false, + "Should not have called migrate when only migrating Safari passwords." + ); + } + + Assert.ok( + !aStartup, + "Migrator should not have been called as a startup migration." + ); + Assert.ok( + aResourceTypes & MigrationUtils.resourceTypes.BOOKMARKS, + "Should have requested to migrate the BOOKMARKS resource." + ); + Assert.ok( + !(aResourceTypes & MigrationUtils.resourceTypes.PASSWORDS), + "Should not have requested to migrate the PASSWORDS resource." + ); + + aProgressCallback(MigrationUtils.resourceTypes.BOOKMARKS, true); + Services.obs.notifyObservers(null, "Migration:Ended"); + resolve(); + }); + }); + + // We'll pretend we added EXPECTED_QUANTITY passwords from the Safari + // password file. + let results = []; + for (let i = 0; i < EXPECTED_QUANTITY; ++i) { + results.push({ result: "added" }); + } + let importFromCSVStub = sandbox.stub(LoginCSVImport, "importFromCSV"); + + if (shouldPasswordImportFail) { + importFromCSVStub.rejects(new Error("Some error message")); + } else { + importFromCSVStub.resolves(results); + } + + sandbox.stub(MigrationUtils, "_importQuantities").value({ + bookmarks: EXPECTED_QUANTITY, + }); + + let filePickerShownPromise; + + if (expectsFilePicker) { + MockFilePicker.reset(); + + let dummyFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dummyFile.initWithPath(TEST_FILE_PATH); + filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.setFiles([dummyFile]); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + } + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + let shadow = wizard.openOrClosedShadowRoot; + + info("Choosing Safari"); + let panelItem = shadow.querySelector( + `panel-item[key="${SafariProfileMigrator.key}"]` + ); + panelItem.click(); + + let resourceTypeList = shadow.querySelector("#resource-type-list"); + + // Let's choose whether to import BOOKMARKS first. + let bookmarksNode = resourceTypeList.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS}"]` + ); + bookmarksNode.control.checked = migrateBookmarks; + + // Let's make sure that PASSWORDS is displayed despite the migrator only + // (currently) returning BOOKMARKS as an available resource to migrate. + let passwordsNode = resourceTypeList.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` + ); + Assert.ok( + !passwordsNode.hidden, + "PASSWORDS should be available to import from." + ); + passwordsNode.control.checked = true; + + let deck = shadow.querySelector("#wizard-deck"); + let switchedToSafariPermissionPage = + BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.SAFARI_PASSWORD_PERMISSION + ); + } + ); + + let importButton = shadow.querySelector("#import"); + importButton.click(); + await switchedToSafariPermissionPage; + Assert.ok(true, "Went to Safari permission page after attempting import."); + + await taskFn( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ); + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + + doneButton.click(); + await dialogClosed; + }); + + sandbox.restore(); + MockFilePicker.reset(); +} + +/** + * Tests the flow of importing passwords from Safari via an + * exported CSV file. + */ +add_task(async function test_safari_password_do_import() { + await testSafariPasswordHelper( + true, + true, + false, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let safariPasswordImportSelect = shadow.querySelector( + "#safari-password-import-select" + ); + safariPasswordImportSelect.click(); + await filePickerShownPromise; + Assert.ok(true, "File picker was shown."); + + await didMigration; + Assert.ok(importFromCSVStub.called, "Importing from CSV was called."); + + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + } + ); +}); + +/** + * Tests that only passwords get imported if the user only opts + * to import passwords, and that nothing else gets imported. + */ +add_task(async function test_safari_password_only_do_import() { + await testSafariPasswordHelper( + true, + false, + false, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let safariPasswordImportSelect = shadow.querySelector( + "#safari-password-import-select" + ); + safariPasswordImportSelect.click(); + await filePickerShownPromise; + Assert.ok(true, "File picker was shown."); + + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + ]); + + Assert.ok(importFromCSVStub.called, "Importing from CSV was called."); + Assert.ok( + !migrateStub.called, + "SafariProfileMigrator.migrate was not called." + ); + } + ); +}); + +/** + * Tests the flow of importing passwords from Safari when the file + * import fails. + */ +add_task(async function test_safari_password_empty_csv_file() { + await testSafariPasswordHelper( + true, + true, + true, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let safariPasswordImportSelect = shadow.querySelector( + "#safari-password-import-select" + ); + safariPasswordImportSelect.click(); + await filePickerShownPromise; + Assert.ok(true, "File picker was shown."); + + await didMigration; + Assert.ok(importFromCSVStub.called, "Importing from CSV was called."); + + await wizardDone; + + let headerL10nID = + shadow.querySelector("#progress-header").dataset.l10nId; + Assert.equal( + headerL10nID, + "migration-wizard-progress-done-with-warnings-header" + ); + + let progressGroup = shadow.querySelector( + `.resource-progress-group[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"` + ); + let progressIcon = progressGroup.querySelector(".progress-icon"); + let messageText = + progressGroup.querySelector(".message-text").textContent; + + Assert.equal( + progressIcon.getAttribute("state"), + "warning", + "Icon should be in the warning state." + ); + Assert.stringMatches( + messageText, + /file doesn’t include any valid password data/ + ); + } + ); +}); + +/** + * Tests that the user can skip importing passwords from Safari. + */ +add_task(async function test_safari_password_skip() { + await testSafariPasswordHelper( + false, + true, + false, + async ( + wizard, + filePickerShownPromise, + importFromCSVStub, + didMigration, + migrateStub, + wizardDone + ) => { + let shadow = wizard.openOrClosedShadowRoot; + let safariPasswordImportSkip = shadow.querySelector( + "#safari-password-import-skip" + ); + safariPasswordImportSkip.click(); + + await didMigration; + Assert.ok(!MockFilePicker.shown, "Never showed the file picker."); + Assert.ok( + !importFromCSVStub.called, + "Importing from CSV was never called." + ); + + await wizardDone; + + assertQuantitiesShown(wizard, [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + ]); + } + ); +}); + +/** + * Tests that importing from passwords for Safari doesn't exist if + * signon.management.page.fileImport.enabled is false. + */ +add_task(async function test_safari_password_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.fileImport.enabled", false]], + }); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let safariMigrator = new SafariProfileMigrator(); + sandbox.stub(MigrationUtils, "getMigrator").resolves(safariMigrator); + + // We're not testing the permission flow here, so let's pretend that we + // always have permission to read resources from the disk. + sandbox + .stub(SafariProfileMigrator.prototype, "hasPermissions") + .resolves(true); + + // Have the migrator claim that only BOOKMARKS are only available. + sandbox + .stub(SafariProfileMigrator.prototype, "getMigrateData") + .resolves(MigrationUtils.resourceTypes.BOOKMARKS); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + + let shadow = wizard.openOrClosedShadowRoot; + + info("Choosing Safari"); + let panelItem = shadow.querySelector( + `panel-item[key="${SafariProfileMigrator.key}"]` + ); + panelItem.click(); + + let resourceTypeList = shadow.querySelector("#resource-type-list"); + + // Let's make sure that PASSWORDS is displayed despite the migrator only + // (currently) returning BOOKMARKS as an available resource to migrate. + let passwordsNode = resourceTypeList.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` + ); + Assert.ok( + passwordsNode.hidden, + "PASSWORDS should not be available to import from." + ); + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/migration/tests/browser/browser_safari_permissions.js b/browser/components/migration/tests/browser/browser_safari_permissions.js new file mode 100644 index 0000000000..bac56866f0 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_safari_permissions.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SafariProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/SafariProfileMigrator.sys.mjs" +); + +/** + * Tests that if we don't have permission to read the contents + * of ~/Library/Safari, that we can ask for permission to do that. + * + * This involves presenting the user with some instructions, and then + * showing a native folder picker for the user to select the + * ~/Library/Safari folder. This seems to give us read access to the + * folder contents. + * + * Revoking permissions for reading the ~/Library/Safari folder is + * not something that we know how to do just yet. It seems to be + * something involving macOS's System Integrity Protection. This test + * mocks out and simulates the actual permissions mechanism to make + * this test run reliably and repeatably. + */ +add_task(async function test_safari_permissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let hasPermissionsStub = sandbox + .stub(SafariProfileMigrator.prototype, "hasPermissions") + .resolves(false); + + let safariMigrator = await MigrationUtils.getMigrator( + SafariProfileMigrator.key + ); + Assert.ok( + safariMigrator, + "Got migrator, even though we don't yet have permission to read its resources." + ); + + sandbox.stub(MigrationUtils, "getMigrator").resolves(safariMigrator); + + sandbox.stub(safariMigrator, "getPermissions").callsFake(async () => { + hasPermissionsStub.resolves(true); + return Promise.resolve(true); + }); + + sandbox.stub(safariMigrator, "getResources").callsFake(() => { + return Promise.resolve([ + { + type: MigrationUtils.resourceTypes.BOOKMARKS, + migrate: () => {}, + }, + ]); + }); + + let didMigration = new Promise(resolve => { + sandbox + .stub(safariMigrator, "migrate") + .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => { + Assert.ok( + !aStartup, + "Migrator should not have been called as a startup migration." + ); + + aProgressCallback(MigrationUtils.resourceTypes.BOOKMARKS); + Services.obs.notifyObservers(null, "Migration:Ended"); + resolve(); + }); + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + let shadow = wizard.openOrClosedShadowRoot; + + info("Choosing Safari"); + let panelItem = shadow.querySelector( + `panel-item[key="${SafariProfileMigrator.key}"]` + ); + panelItem.click(); + + // Let's just choose "Bookmarks" for now. + let resourceTypeList = shadow.querySelector("#resource-type-list"); + let resourceNodes = resourceTypeList.querySelectorAll( + `label[data-resource-type]` + ); + for (let resourceNode of resourceNodes) { + resourceNode.control.checked = + resourceNode.dataset.resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS; + } + + let deck = shadow.querySelector("#wizard-deck"); + let switchedToSafariPermissionPage = + BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.SAFARI_PERMISSION + ); + } + ); + + let importButton = shadow.querySelector("#import"); + importButton.click(); + await switchedToSafariPermissionPage; + Assert.ok(true, "Went to Safari permission page after attempting import."); + + let requestPermissions = shadow.querySelector( + "#safari-request-permissions" + ); + requestPermissions.click(); + await didMigration; + Assert.ok(true, "Completed migration"); + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector( + "div[name='page-progress'] .done-button" + ); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + + doneButton.click(); + await dialogClosed; + await wizardDone; + }); +}); diff --git a/browser/components/migration/tests/browser/dummy_file.csv b/browser/components/migration/tests/browser/dummy_file.csv new file mode 100644 index 0000000000..48a099ab76 --- /dev/null +++ b/browser/components/migration/tests/browser/dummy_file.csv @@ -0,0 +1 @@ +This file intentionally left blank.
\ No newline at end of file diff --git a/browser/components/migration/tests/browser/head.js b/browser/components/migration/tests/browser/head.js new file mode 100644 index 0000000000..d3d188a7e1 --- /dev/null +++ b/browser/components/migration/tests/browser/head.js @@ -0,0 +1,534 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from ../head-common.js */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/migration/tests/browser/head-common.js", + this +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { InternalTestingProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/InternalTestingProfileMigrator.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const DIALOG_URL = + "chrome://browser/content/migration/migration-dialog-window.html"; + +/** + * We'll have this be our magic number of quantities of various imports. + * We will use Sinon to prepare MigrationUtils to presume that this was + * how many of each quantity-supported resource type was imported. + */ +const EXPECTED_QUANTITY = 123; + +/** + * These are the resource types that currently display their import success + * message with a quantity. + */ +const RESOURCE_TYPES_WITH_QUANTITIES = [ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PAYMENT_METHODS, + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS, +]; + +/** + * The withMigrationWizardDialog callback, called after the + * dialog has loaded and the wizard is ready. + * + * @callback withMigrationWizardDialogCallback + * @param {DOMWindow} window + * The content window of the migration wizard subdialog frame. + * @returns {Promise<undefined>} + */ + +/** + * Opens the migration wizard HTML5 dialog in about:preferences in the + * current window's selected tab, runs an async taskFn, and then + * cleans up by loading about:blank in the tab before resolving. + * + * @param {withMigrationWizardDialogCallback} taskFn + * An async test function to be called while the migration wizard + * dialog is open. + * @returns {Promise<undefined>} + */ +async function withMigrationWizardDialog(taskFn) { + let migrationDialogPromise = waitForMigrationWizardDialogTab(); + await MigrationUtils.showMigrationWizard(window, {}); + let prefsBrowser = await migrationDialogPromise; + + try { + await taskFn(prefsBrowser.contentWindow); + } finally { + if (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.getTabForBrowser(prefsBrowser)); + } else { + BrowserTestUtils.startLoadingURIString(prefsBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(prefsBrowser); + } + } +} + +/** + * Returns a Promise that resolves when an about:preferences tab opens + * in the current window which loads the migration wizard dialog. + * The Promise will wait until the migration wizard reports that it + * is ready with the "MigrationWizard:Ready" event. + * + * @returns {Promise<browser>} + * Resolves with the about:preferences browser element. + */ +async function waitForMigrationWizardDialogTab() { + let wizardReady = BrowserTestUtils.waitForEvent( + window, + "MigrationWizard:Ready" + ); + + let tab; + if (gBrowser.selectedTab.isEmpty) { + tab = gBrowser.selectedTab; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, url => { + return url.startsWith("about:preferences"); + }); + } else { + tab = await BrowserTestUtils.waitForNewTab(gBrowser, url => { + return url.startsWith("about:preferences"); + }); + } + + await wizardReady; + info("Done waiting - migration subdialog loaded and ready."); + + return tab.linkedBrowser; +} + +/** + * A helper function that prepares the InternalTestingProfileMigrator + * with some set of fake available resources, and resolves a Promise + * when the InternalTestingProfileMigrator is used for a migration. + * + * @param {number[]} availableResourceTypes + * An array of resource types from MigrationUtils.resourcesTypes. + * A single MigrationResource will be created per type, with a + * no-op migrate function. + * @param {number[]} expectedResourceTypes + * An array of resource types from MigrationUtils.resourceTypes. + * These are the resource types that are expected to be passed + * to the InternalTestingProfileMigrator.migrate function. + * @param {object|string} expectedProfile + * The profile object or string that is expected to be passed + * to the InternalTestingProfileMigrator.migrate function. + * @param {number[]} [errorResourceTypes=[]] + * Resource types that we should pretend have failed to complete + * their migration properly. + * @param {number} [totalExtensions=1] + * If migrating extensions, the total that should be reported to + * have been found from the source browser. + * @param {number} [matchedExtensions=1] + * If migrating extensions, the number of extensions that should + * be reported as having equivalent matches for this browser. + * @returns {Promise<undefined>} + */ +async function waitForTestMigration( + availableResourceTypes, + expectedResourceTypes, + expectedProfile, + errorResourceTypes = [], + totalExtensions = 1, + matchedExtensions = 1 +) { + let sandbox = sinon.createSandbox(); + let sourceHistogram = TelemetryTestUtils.getAndClearHistogram( + "FX_MIGRATION_SOURCE_BROWSER" + ); + let usageHistogram = + TelemetryTestUtils.getAndClearKeyedHistogram("FX_MIGRATION_USAGE"); + let errorHistogram = TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_MIGRATION_ERRORS" + ); + + // Fake out the getResources method of the migrator so that we return + // a single fake MigratorResource per availableResourceType. + sandbox + .stub(InternalTestingProfileMigrator.prototype, "getResources") + .callsFake(aProfile => { + Assert.deepEqual( + aProfile, + expectedProfile, + "Should have gotten the expected profile." + ); + return Promise.resolve( + availableResourceTypes.map(resourceType => { + return { + type: resourceType, + migrate: () => {}, + }; + }) + ); + }); + + sandbox.stub(MigrationUtils, "_importQuantities").value({ + bookmarks: EXPECTED_QUANTITY, + history: EXPECTED_QUANTITY, + logins: EXPECTED_QUANTITY, + cards: EXPECTED_QUANTITY, + }); + + sandbox + .stub(MigrationUtils, "getSourceIdForTelemetry") + .withArgs(InternalTestingProfileMigrator.key) + .returns(InternalTestingProfileMigrator.sourceID); + + // Fake out the migrate method of the migrator and assert that the + // next time it's called, its arguments match our expectations. + return new Promise(resolve => { + sandbox + .stub(InternalTestingProfileMigrator.prototype, "migrate") + .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => { + Assert.ok( + !aStartup, + "Migrator should not have been called as a startup migration." + ); + + let bitMask = 0; + for (let resourceType of expectedResourceTypes) { + bitMask |= resourceType; + } + + Assert.deepEqual( + aResourceTypes, + bitMask, + "Got the expected resource types" + ); + Assert.deepEqual( + aProfile, + expectedProfile, + "Got the expected profile object" + ); + + for (let resourceType of expectedResourceTypes) { + let shouldError = errorResourceTypes.includes(resourceType); + if ( + resourceType == MigrationUtils.resourceTypes.EXTENSIONS && + !shouldError + ) { + let progressValue; + if (totalExtensions == matchedExtensions) { + progressValue = MigrationWizardConstants.PROGRESS_VALUE.SUCCESS; + } else if ( + totalExtensions > matchedExtensions && + matchedExtensions + ) { + progressValue = MigrationWizardConstants.PROGRESS_VALUE.INFO; + } else { + Assert.ok( + false, + "Total and matched extensions should be greater than 0 on success." + + `Total: ${totalExtensions}, Matched: ${matchedExtensions}` + ); + } + aProgressCallback(resourceType, !shouldError, { + totalExtensions: Array(totalExtensions), + importedExtensions: Array(matchedExtensions), + progressValue, + }); + } else { + aProgressCallback(resourceType, !shouldError); + } + } + + let usageHistogramSnapshot = + usageHistogram.snapshot()[InternalTestingProfileMigrator.key]; + + let errorHistogramSnapshot = + errorHistogram.snapshot()[InternalTestingProfileMigrator.key]; + + for (let resourceTypeName in MigrationUtils.resourceTypes) { + let resourceType = MigrationUtils.resourceTypes[resourceTypeName]; + if (resourceType == MigrationUtils.resourceTypes.ALL) { + continue; + } + + if (expectedResourceTypes.includes(resourceType)) { + Assert.equal( + usageHistogramSnapshot.values[Math.log2(resourceType)], + 1, + `Should have set resource type ${resourceTypeName} on the FX_MIGRATION_USAGE keyed histogram.` + ); + + if (errorResourceTypes.includes(resourceType)) { + Assert.equal( + errorHistogramSnapshot.values[Math.log2(resourceType)], + 1, + `Should have set resource type ${resourceTypeName} on the FX_MIGRATION_ERRORS keyed histogram.` + ); + } + } else { + let value = usageHistogramSnapshot.values[Math.log2(resourceType)]; + Assert.ok( + value === 0 || value === undefined, + `Should not have set resource type ${resourceTypeName} on the FX_MIGRATION_USAGE keyed histogram.` + ); + } + } + + Services.obs.notifyObservers(null, "Migration:Ended"); + + TelemetryTestUtils.assertHistogram( + sourceHistogram, + InternalTestingProfileMigrator.sourceID, + 1 + ); + + resolve(); + }); + }).finally(async () => { + sandbox.restore(); + + // MigratorBase caches resources fetched by the getResources method + // as a performance optimization. In order to allow different tests + // to have different available resources, we call into a special + // method of InternalTestingProfileMigrator that clears that + // cache. + let migrator = await MigrationUtils.getMigrator( + InternalTestingProfileMigrator.key + ); + migrator.flushResourceCache(); + }); +} + +/** + * Takes a MigrationWizard element and chooses the + * InternalTestingProfileMigrator as the browser to migrate from. Then, it + * checks the checkboxes associated with the selectedResourceTypes and + * unchecks the rest before clicking the "Import" button. + * + * @param {Element} wizard + * The MigrationWizard element. + * @param {string[]} selectedResourceTypes + * An array of resource type strings from + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @param {string} [migratorKey=InternalTestingProfileMigrator.key] + * The key for the migrator to use. Defaults to the + * InternalTestingProfileMigrator. + */ +async function selectResourceTypesAndStartMigration( + wizard, + selectedResourceTypes, + migratorKey = InternalTestingProfileMigrator.key +) { + let shadow = wizard.openOrClosedShadowRoot; + + // First, select the InternalTestingProfileMigrator browser. + let selector = shadow.querySelector("#browser-profile-selector"); + selector.click(); + + await new Promise(resolve => { + shadow + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + + let panelItem = shadow.querySelector(`panel-item[key="${migratorKey}"]`); + panelItem.click(); + + // And then check the right checkboxes for the resource types. + let resourceTypeList = shadow.querySelector("#resource-type-list"); + for (let resourceType of getChoosableResourceTypes()) { + let node = resourceTypeList.querySelector( + `label[data-resource-type="${resourceType}"]` + ); + node.control.checked = selectedResourceTypes.includes(resourceType); + } + + let importButton = shadow.querySelector("#import"); + importButton.click(); +} + +/** + * Assert that the resource types passed in expectedResourceTypes are + * showing a success state after a migration, and if they are part of + * the RESOURCE_TYPES_WITH_QUANTITIES group, that they're showing the + * EXPECTED_QUANTITY magic number in their success message. Otherwise, + * we (currently) check that they show the empty string. + * + * @param {Element} wizard + * The MigrationWizard element. + * @param {string[]} expectedResourceTypes + * An array of resource type strings from + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @param {string[]} [warningResourceTypes=[]] + * An array of resource type strings from + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. These + * are the resources that should be showing a warning message. + */ +function assertQuantitiesShown( + wizard, + expectedResourceTypes, + warningResourceTypes = [] +) { + let shadow = wizard.openOrClosedShadowRoot; + + // Make sure that we're showing the progress page first. + let deck = shadow.querySelector("#wizard-deck"); + Assert.equal( + deck.selectedViewName, + `page-${MigrationWizardConstants.PAGES.PROGRESS}` + ); + + let headerL10nID = shadow.querySelector("#progress-header").dataset.l10nId; + if (warningResourceTypes.length) { + Assert.equal( + headerL10nID, + "migration-wizard-progress-done-with-warnings-header" + ); + } else { + Assert.equal(headerL10nID, "migration-wizard-progress-done-header"); + } + + // Go through each displayed resource and make sure that only the + // ones that are expected are shown, and are showing the right + // success message. + + let progressGroups = shadow.querySelectorAll(".resource-progress-group"); + for (let progressGroup of progressGroups) { + if (expectedResourceTypes.includes(progressGroup.dataset.resourceType)) { + let progressIcon = progressGroup.querySelector(".progress-icon"); + let messageText = + progressGroup.querySelector(".message-text").textContent; + + if (warningResourceTypes.includes(progressGroup.dataset.resourceType)) { + Assert.equal( + progressIcon.getAttribute("state"), + "warning", + "Should be showing the warning icon state." + ); + } else { + Assert.equal( + progressIcon.getAttribute("state"), + "success", + "Should be showing the success icon state." + ); + } + + if ( + RESOURCE_TYPES_WITH_QUANTITIES.includes( + progressGroup.dataset.resourceType + ) + ) { + if ( + progressGroup.dataset.resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY + ) { + // HISTORY is a special case that doesn't show the number of imported + // history entries, but instead shows the maximum number of days of history + // that might have been imported. + Assert.notEqual( + messageText.indexOf(MigrationUtils.HISTORY_MAX_AGE_IN_DAYS), + -1, + `Found expected maximum number of days of history: ${messageText}` + ); + } else if ( + progressGroup.dataset.resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA + ) { + // FORMDATA is another special case, because we simply show "Form history" as + // the message string, rather than a particular quantity. + Assert.equal( + messageText, + "Form history", + `Found expected form data string: ${messageText}` + ); + } else if ( + progressGroup.dataset.resourceType == + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS + ) { + // waitForTestMigration by default sets up a "successful" migration of 1 + // extension. + Assert.stringMatches(messageText, "1 extension"); + } else { + Assert.notEqual( + messageText.indexOf(EXPECTED_QUANTITY), + -1, + `Found expected quantity in message string: ${messageText}` + ); + } + } else { + // If you've found yourself here, and this is failing, it's probably because you've + // updated MigrationWizardParent.#getStringForImportQuantity to return a string for + // a resource type that's not in RESOURCE_TYPES_WITH_QUANTITIES, and you'll need + // to modify this function to check for that string. + Assert.equal( + messageText, + "", + "Expected the empty string if the resource type " + + "isn't in RESOURCE_TYPES_WITH_QUANTITIES" + ); + } + } else { + Assert.ok( + BrowserTestUtils.isHidden(progressGroup), + `Resource progress group for ${progressGroup.dataset.resourceType}` + + ` should be hidden.` + ); + } + } +} + +/** + * Translates an entrypoint string into the proper numeric value for the + * FX_MIGRATION_ENTRY_POINT_CATEGORICAL histogram. + * + * @param {string} entrypoint + * The entrypoint to translate from MIGRATION_ENTRYPOINTS. + * @returns {number} + * The numeric index value for the FX_MIGRATION_ENTRY_POINT_CATEGORICAL + * histogram. + */ +function getEntrypointHistogramIndex(entrypoint) { + switch (entrypoint) { + case MigrationUtils.MIGRATION_ENTRYPOINTS.FIRSTRUN: { + return 1; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.FXREFRESH: { + return 2; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.PLACES: { + return 3; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.PASSWORDS: { + return 4; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.NEWTAB: { + return 5; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.FILE_MENU: { + return 6; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.HELP_MENU: { + return 7; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.BOOKMARKS_TOOLBAR: { + return 8; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.PREFERENCES: { + return 9; + } + case MigrationUtils.MIGRATION_ENTRYPOINTS.UNKNOWN: + // Intentional fall-through + default: { + return 0; // Unknown + } + } +} diff --git a/browser/components/migration/tests/chrome/chrome.toml b/browser/components/migration/tests/chrome/chrome.toml new file mode 100644 index 0000000000..8f1c943f31 --- /dev/null +++ b/browser/components/migration/tests/chrome/chrome.toml @@ -0,0 +1,5 @@ +[DEFAULT] +skip-if = ["os == 'android'"] +support-files = ["../head-common.js"] + +["test_migration_wizard.html"] diff --git a/browser/components/migration/tests/chrome/test_migration_wizard.html b/browser/components/migration/tests/chrome/test_migration_wizard.html new file mode 100644 index 0000000000..d991cce114 --- /dev/null +++ b/browser/components/migration/tests/chrome/test_migration_wizard.html @@ -0,0 +1,1533 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>Basic tests for the Migration Wizard component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script src="head-common.js"></script> + <script + src="chrome://browser/content/migration/migration-wizard.mjs" + type="module" + ></script> + <link + rel="stylesheet" + href="chrome://mochikit/content/tests/SimpleTest/test.css" + /> + <script> + /* import-globals-from ../head-common.js */ + + "use strict"; + + const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + const MIGRATOR_PROFILE_INSTANCES = [ + { + key: "some-browser-0", + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + displayName: "Some Browser 0", + resourceTypes: ["HISTORY", "FORMDATA", "PASSWORDS", "BOOKMARKS", "EXTENSIONS"], + profile: { id: "person-2", name: "Person 2" }, + hasPermissions: true, + }, + { + key: "some-browser-1", + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + displayName: "Some Browser 1", + resourceTypes: ["HISTORY", "BOOKMARKS"], + profile: null, + hasPermissions: true, + }, + ]; + + let gWiz = null; + let gShadowRoot = null; + let gDeck = null; + + /** + * Returns the .resource-progress-group div for a particular resource + * type. + * + * @param {string} displayedResourceType + * One of the constants belonging to + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @returns {Element} + */ + function getResourceGroup(displayedResourceType) { + return gShadowRoot.querySelector( + `.resource-progress-group[data-resource-type="${displayedResourceType}"]` + ); + } + + add_setup(async function() { + gWiz = document.getElementById("test-wizard"); + gShadowRoot = gWiz.openOrClosedShadowRoot; + gDeck = gShadowRoot.querySelector("#wizard-deck"); + }); + + /** + * Tests that the MigrationWizard:RequestState event is fired when the + * <migration-wizard> is added to the DOM if the auto-request-state attribute + * is set, and then ensures that the starting page is correct. + * + * This also tests that the MigrationWizard:RequestState is not fired automatically + * if the auto-request-state attribute is not set, but is then fired upon calling + * requestState(). + * + * This uses a dynamically created <migration-wizard> instead of the one already + * in the content div to make sure that the init event is captured. + */ + add_task(async function test_init_event() { + const REQUEST_STATE_EVENT = "MigrationWizard:RequestState"; + + let wiz = document.createElement("migration-wizard"); + wiz.toggleAttribute("auto-request-state", true); + let content = document.getElementById("content"); + let promise = new Promise(resolve => { + content.addEventListener(REQUEST_STATE_EVENT, resolve, { + once: true, + }); + }); + content.appendChild(wiz); + await promise; + ok(true, `Saw ${REQUEST_STATE_EVENT} event.`); + let shadowRoot = wiz.openOrClosedShadowRoot; + let deck = shadowRoot.querySelector("#wizard-deck"); + is( + deck.selectedViewName, + "page-loading", + "Should have the loading page selected" + ); + wiz.remove(); + + wiz.toggleAttribute("auto-request-state", false); + let sawEvent = false; + let handler = () => { + sawEvent = true; + }; + content.addEventListener(REQUEST_STATE_EVENT, handler); + content.appendChild(wiz); + ok(!sawEvent, `Should not have seen ${REQUEST_STATE_EVENT} event.`); + content.removeEventListener(REQUEST_STATE_EVENT, handler); + + promise = new Promise(resolve => { + content.addEventListener(REQUEST_STATE_EVENT, resolve, { + once: true, + }); + }); + wiz.requestState(); + await promise; + ok(true, `Saw ${REQUEST_STATE_EVENT} event.`); + wiz.remove(); + }); + + /** + * Tests that the wizard can show a list of browser and profiles. + */ + add_task(async function test_selection() { + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: false, + }); + + let selector = gShadowRoot.querySelector("#browser-profile-selector"); + let preamble = gShadowRoot.querySelector(".resource-selection-preamble"); + ok(!isHidden(preamble), "preamble should shown."); + + let panelList = gShadowRoot.querySelector("panel-list"); + is(panelList.childElementCount, 2, "Should have two child elements"); + + let resourceTypeList = gShadowRoot.querySelector("#resource-type-list"); + let details = gShadowRoot.querySelector("details"); + ok(details.open, "Details should be open"); + + // Test that the resource type checkboxes are shown or hidden depending on + // which resourceTypes are included with the MigratorProfileInstance. + for (let migratorInstance of MIGRATOR_PROFILE_INSTANCES) { + selector.click(); + await new Promise(resolve => { + gShadowRoot + .querySelector("panel-list") + .addEventListener("shown", resolve, { once: true }); + }); + let panelItem = gShadowRoot.querySelector( + `panel-item[key="${migratorInstance.key}"]` + ); + ok(panelItem, "Should find panel-item."); + panelItem.click(); + + is( + selector.querySelector("#migrator-name").textContent, + migratorInstance.displayName, + "Selector should show display name" + ); + let profileName = selector.querySelector("#profile-name"); + + if (migratorInstance.profile) { + ok(!isHidden(profileName), "Profile name element should be displayed."); + is( + profileName.textContent, + migratorInstance.profile.name, + "Selector should show profile name" + ); + } else { + ok(isHidden(profileName), "Profile name element should be hidden."); + is(profileName.textContent, ""); + } + + for (let resourceType of getChoosableResourceTypes()) { + let node = resourceTypeList.querySelector( + `label[data-resource-type="${resourceType}"]` + ); + + if (migratorInstance.resourceTypes.includes(resourceType)) { + ok(!isHidden(node), `Selection for ${resourceType} should be shown.`); + ok( + node.control.checked, + `Checkbox for ${resourceType} should be checked.` + ); + } else { + ok(isHidden(node), `Selection for ${resourceType} should be hidden.`); + ok( + !node.control.checked, + `Checkbox for ${resourceType} should be unchecked.` + ); + } + } + } + + let selectAll = gShadowRoot.querySelector("#select-all"); + let summary = gShadowRoot.querySelector("summary"); + ok(isHidden(selectAll), "Selection for select-all should be hidden."); + ok(isHidden(summary), "Summary should be hidden."); + ok(!isHidden(details), "Details should be shown."); + }); + + /** + * Tests the migration wizard with no resources + */ + add_task(async function test_no_resources() { + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: [{ + key: "some-browser-0", + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + displayName: "Some Browser 0 with no resources", + resourceTypes: [], + profile: { id: "person-1", name: "Person 1" }, + hasPermissions: true, + }], + showImportAll: false, + }); + + let noResourcesFound = gShadowRoot.querySelector(".no-resources-found"); + let hideOnErrorEls = gShadowRoot.querySelectorAll(".hide-on-error"); + ok( + !isHidden(noResourcesFound), + "Error message of no reasources should be shown." + ); + for (let hideOnErrorEl of hideOnErrorEls) { + ok(isHidden(hideOnErrorEl), "Item should be hidden."); + } + }); + + /** + * Tests variant 2 of the migration wizard + */ + add_task(async function test_selection_variant_2() { + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: true, + }); + + let preamble = gShadowRoot.querySelector(".resource-selection-preamble"); + ok(isHidden(preamble), "preamble should be hidden."); + + let selector = gShadowRoot.querySelector("#browser-profile-selector"); + selector.click(); + await new Promise(resolve => { + let panelList = gShadowRoot.querySelector("panel-list"); + if (panelList) { + panelList.addEventListener("shown", resolve, { once: true }); + } + }); + + let panelItems = gShadowRoot.querySelectorAll("panel-list > panel-item"); + is(panelItems.length, 2, "Should have two panel items"); + + let details = gShadowRoot.querySelector("details"); + ok(!details.open, "Details should be closed"); + details.open = true; + + for (let i = 0; i < panelItems.length; i++) { + let migratorInstance = MIGRATOR_PROFILE_INSTANCES[i]; + let panelItem = panelItems[i]; + panelItem.click(); + for (let resourceType of getChoosableResourceTypes()) { + let node = gShadowRoot.querySelector( + `#resource-type-list label[data-resource-type="${resourceType}"]` + ); + if (migratorInstance.resourceTypes.includes(resourceType)) { + ok(!isHidden(node), `Selection for ${resourceType} should be shown.`); + ok( + node.control.checked, + `Checkbox for ${resourceType} should be checked.` + ); + } else { + ok(isHidden(node), `Selection for ${resourceType} should be hidden.`); + ok( + !node.control.checked, + `Checkbox for ${resourceType} should be unchecked.` + ); + } + } + } + + let selectAll = gShadowRoot.querySelector("#select-all"); + let summary = gShadowRoot.querySelector("summary"); + ok(!isHidden(selectAll), "Selection for select-all should be shown."); + ok(selectAll.control.checked, "Checkbox for select-all should be checked."); + ok(!isHidden(summary), "Summary should be shown."); + ok(!isHidden(details), "Details should be shown."); + + let selectAllCheckbox = gShadowRoot.querySelector(".select-all-checkbox"); + selectAllCheckbox.checked = true; + selectAllCheckbox.dispatchEvent(new CustomEvent("change")); + let resourceLabels = gShadowRoot.querySelectorAll("label[data-resource-type]"); + for (let resourceLabel of resourceLabels) { + if (resourceLabel.hidden) { + ok( + !resourceLabel.control.checked, + `Hidden checkbox for ${resourceLabel.dataset.resourceType} should be unchecked.` + + ); + } else { + ok( + resourceLabel.control.checked, + `Visible checkbox for ${resourceLabel.dataset.resourceType} should be checked.` + ); + } + } + + let selectedDataHeader = gShadowRoot.querySelector(".selected-data-header"); + let selectedData = gShadowRoot.querySelector(".selected-data"); + + let bookmarks = gShadowRoot.querySelector("#bookmarks"); + let history = gShadowRoot.querySelector("#history"); + + let selectedDataUpdated = BrowserTestUtils.waitForEvent( + gWiz, + "MigrationWizard:ResourcesUpdated" + ); + bookmarks.control.checked = true; + history.control.checked = true; + bookmarks.dispatchEvent(new CustomEvent("change")); + + ok(bookmarks.control.checked, "Bookmarks should be checked"); + ok(history.control.checked, "History should be checked"); + + await selectedDataUpdated; + + is( + selectedData.textContent, + "Bookmarks and history", + "Testing if selected-data reflects the selected resources." + ); + + is( + selectedDataHeader.dataset.l10nId, + "migration-all-available-data-label", + "Testing if selected-data-header reflects the selected resources" + ); + + let importButton = gShadowRoot.querySelector("#import"); + + ok( + !importButton.disabled, + "Testing if import button is enabled when at least one resource is selected." + ); + + let importButtonUpdated = BrowserTestUtils.waitForEvent( + gWiz, + "MigrationWizard:ResourcesUpdated" + ); + + selectAllCheckbox.checked = false; + selectAllCheckbox.dispatchEvent(new CustomEvent("change", { bubbles: true })); + await importButtonUpdated; + + ok( + importButton.disabled, + "Testing if import button is disabled when no resources are selected." + ); + }); + + /** + * Tests variant 2 of the migration wizard when there's a single resource + * item. + */ + add_task(async function test_selection_variant_2_single_item() { + let resourcesUpdated = BrowserTestUtils.waitForEvent( + gWiz, + "MigrationWizard:ResourcesUpdated" + ); + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: [{ + key: "some-browser-0", + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + displayName: "Some Browser 0 with a single resource", + resourceTypes: ["HISTORY"], + profile: { id: "person-1", name: "Person 1" }, + hasPermissions: true, + }, { + key: "some-browser-1", + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + displayName: "Some Browser 1 with a two resources", + resourceTypes: ["HISTORY", "BOOKMARKS"], + profile: { id: "person-2", name: "Person 2" }, + hasPermissions: true, + }], + showImportAll: true, + }); + await resourcesUpdated; + + let selectAll = gShadowRoot.querySelector("#select-all"); + let summary = gShadowRoot.querySelector("summary"); + let details = gShadowRoot.querySelector("details"); + ok(!details.open, "Details should be closed"); + details.open = true; + + ok(isHidden(selectAll), "Selection for select-all should be hidden."); + ok(!isHidden(summary), "Summary should be shown."); + ok(!isHidden(details), "Details should be shown."); + + resourcesUpdated = BrowserTestUtils.waitForEvent( + gWiz, + "MigrationWizard:ResourcesUpdated" + ); + let browser1Item = gShadowRoot.querySelector("panel-item[key='some-browser-1']"); + browser1Item.click(); + await resourcesUpdated; + + ok(!isHidden(selectAll), "Selection for select-all should be shown."); + ok(!isHidden(summary), "Summary should be shown."); + ok(!isHidden(details), "Details should be shown."); + }); + + /** + * Tests that the Select All checkbox is checked if all non-hidden resource + * types are checked, and unchecked otherwise. + */ + add_task(async function test_selection_variant_2_select_all() { + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: true, + }); + + let details = gShadowRoot.querySelector("details"); + ok(!details.open, "Details should be closed"); + details.open = true; + + let selectAll = gShadowRoot.querySelector("#select-all"); + ok(selectAll.control.checked, "Select all should be checked by default"); + + let bookmarksResourceLabel = gShadowRoot.querySelector( + "label[data-resource-type='BOOKMARKS']" + ); + ok(bookmarksResourceLabel.control.checked, "Bookmarks should be checked"); + + bookmarksResourceLabel.control.click(); + ok(!bookmarksResourceLabel.control.checked, "Bookmarks should no longer be checked"); + ok(!selectAll.control.checked, "Select all should not longer be checked"); + + bookmarksResourceLabel.control.click(); + ok(bookmarksResourceLabel.control.checked, "Bookmarks should be checked again"); + ok(selectAll.control.checked, "Select all should be checked"); + }); + + /** + * Tests that the wizard can show partial progress during migration. + */ + add_task(async function test_partial_progress() { + const BOOKMARKS_SUCCESS_STRING = "Some bookmarks success string"; + gWiz.setState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: BOOKMARKS_SUCCESS_STRING, + }, + // Don't include PASSWORDS to check that it's hidden. + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY]: { + value: MigrationWizardConstants.PROGRESS_VALUE.LOADING, + }, + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.LOADING, + }, + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA]: { + value: MigrationWizardConstants.PROGRESS_VALUE.LOADING, + }, + }, + }); + is( + gDeck.selectedViewName, + "page-progress", + "Should have the progress page selected" + ); + + // Bookmarks + let bookmarksGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS + ); + ok(!isHidden(bookmarksGroup), "Bookmarks group should be visible"); + let progressIcon = bookmarksGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + bookmarksGroup.querySelector(".message-text").textContent, + BOOKMARKS_SUCCESS_STRING + ); + + // Passwords + let passwordsGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ); + ok(isHidden(passwordsGroup), "Passwords group should be hidden"); + + // History + let historyGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY + ); + ok(!isHidden(historyGroup), "History group should be visible"); + progressIcon = historyGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "loading", + "Progress should be still be underway" + ); + is(historyGroup.querySelector(".message-text").textContent.trim(), ""); + + // Extensions + let extensionsGroup = getResourceGroup(MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS); + ok(!isHidden(extensionsGroup), "Extensions group should be visible"); + progressIcon = extensionsGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "loading", + "Progress should be still be underway" + ); + is(extensionsGroup.querySelector("a.message-text").textContent.trim(), ""); + is(extensionsGroup.querySelector("span.message-text").textContent.trim(), ""); + + // Form Data + let formDataGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA + ); + ok(!isHidden(formDataGroup), "Form data group should be visible"); + progressIcon = formDataGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "loading", + "Progress should be still be underway" + ); + is(formDataGroup.querySelector(".message-text").textContent.trim(), ""); + + // With progress still being underway, the header should be using the + // in progress string. + let header = gShadowRoot.querySelector("#progress-header"); + is( + header.getAttribute("data-l10n-id"), + "migration-wizard-progress-header", + "Should be showing in-progress header string" + ); + + let progressPage = gShadowRoot.querySelector("div[name='page-progress']"); + let doneButton = progressPage.querySelector(".done-button"); + ok(isHidden(doneButton), "Done button should be hidden"); + let cancelButton = progressPage.querySelector(".cancel-close"); + ok(!isHidden(cancelButton), "Cancel button should be visible"); + ok(cancelButton.disabled, "Cancel button should be disabled"); + }); + + /** + * Tests that the wizard can show completed migration progress. + */ + add_task(async function test_completed_progress() { + const BOOKMARKS_SUCCESS_STRING = "Some bookmarks success string"; + const PASSWORDS_SUCCESS_STRING = "Some passwords success string"; + const FORMDATA_SUCCESS_STRING = "Some formdata string"; + const EXTENSIONS_SUCCESS_STRING = "Some extensions string"; + const EXTENSIONS_SUCCESS_HREF = "about:addons"; + gWiz.setState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: BOOKMARKS_SUCCESS_STRING, + }, + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: PASSWORDS_SUCCESS_STRING, + }, + // Don't include HISTORY to check that it's hidden. + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: EXTENSIONS_SUCCESS_STRING, + }, + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: FORMDATA_SUCCESS_STRING, + }, + }, + }); + is( + gDeck.selectedViewName, + "page-progress", + "Should have the progress page selected" + ); + + // Bookmarks + let bookmarksGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS + ); + ok(!isHidden(bookmarksGroup), "Bookmarks group should be visible"); + let progressIcon = bookmarksGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + bookmarksGroup.querySelector(".message-text").textContent, + BOOKMARKS_SUCCESS_STRING + ); + + // Passwords + let passwordsGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ); + ok(!isHidden(passwordsGroup), "Passwords group should be visible"); + progressIcon = passwordsGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + passwordsGroup.querySelector(".message-text").textContent, + PASSWORDS_SUCCESS_STRING + ); + + // History + let historyGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY + ); + ok(isHidden(historyGroup), "History group should be hidden"); + + // Extensions + let extensionsDataGroup = getResourceGroup(MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS); + ok(!isHidden(extensionsDataGroup), "Extensions data group should be visible"); + progressIcon = extensionsDataGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + extensionsDataGroup.querySelector("a.message-text").textContent, + EXTENSIONS_SUCCESS_STRING + ); + is( + extensionsDataGroup.querySelector("a.message-text").href, + EXTENSIONS_SUCCESS_HREF + ) + // Form Data + let formDataGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA + ); + ok(!isHidden(formDataGroup), "Form data group should be visible"); + progressIcon = formDataGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + formDataGroup.querySelector(".message-text").textContent, + FORMDATA_SUCCESS_STRING + ); + + // With progress being complete, the header should be using the completed + // migration string. + let header = gShadowRoot.querySelector("#progress-header"); + is( + header.getAttribute("data-l10n-id"), + "migration-wizard-progress-done-header", + "Should be showing completed migration header string" + ); + + let progressPage = gShadowRoot.querySelector("div[name='page-progress']"); + let doneButton = progressPage.querySelector(".done-button"); + ok(!isHidden(doneButton), "Done button should be visible and enabled"); + let cancelButton = progressPage.querySelector(".cancel-close"); + ok(isHidden(cancelButton), "Cancel button should be hidden"); + }); + + add_task(async function test_extension_partial_success() { + const EXTENSIONS_INFO_STRING = "Extensions info string"; + const EXTENSIONS_INFO_HREF = "about:addons"; + const EXTENSIONS_SUPPORT_STRING = "extensions support string"; + const EXTENSIONS_SUPPORT_HREF = "about:blank"; + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.INFO, + message: EXTENSIONS_INFO_STRING, + linkText: EXTENSIONS_SUPPORT_STRING, + linkURL: EXTENSIONS_SUPPORT_HREF, + } + } + }); + is( + gDeck.selectedViewName, + "page-progress", + "Should have the progress page selected" + ); + + let extensionsGroup = getResourceGroup(MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS); + ok(!isHidden(extensionsGroup), "Extensions group should be visible"); + let progressIcon = extensionsGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "info", + "Progress should be completed, in info state" + ); + is( + extensionsGroup.querySelector("a.message-text").textContent, + EXTENSIONS_INFO_STRING + ); + is( + extensionsGroup.querySelector("a.message-text").href, + EXTENSIONS_INFO_HREF + ); + is( + extensionsGroup.querySelector(".support-text").textContent, + EXTENSIONS_SUPPORT_STRING + ); + is( + extensionsGroup.querySelector(".support-text").href, + EXTENSIONS_SUPPORT_HREF + ); + + // With progress being complete, the header should be using the completed + // migration string. + let header = gShadowRoot.querySelector("#progress-header"); + is( + header.getAttribute("data-l10n-id"), + "migration-wizard-progress-done-header", + "Should be showing completed migration header string" + ); + + let progressPage = gShadowRoot.querySelector("div[name='page-progress']"); + let doneButton = progressPage.querySelector(".done-button"); + ok(!isHidden(doneButton), "Done button should be visible and enabled"); + let cancelButton = progressPage.querySelector(".cancel-close"); + ok(isHidden(cancelButton), "Cancel button should be hidden"); + }); + + add_task(async function test_extension_error() { + const EXTENSIONS_ERROR_STRING = "Extensions error string"; + const EXTENSIONS_SUPPORT_STRING = "extensions support string"; + const EXTENSIONS_SUPPORT_HREF = "about:blank"; + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.WARNING, + message: EXTENSIONS_ERROR_STRING, + linkText: EXTENSIONS_SUPPORT_STRING, + linkURL: EXTENSIONS_SUPPORT_HREF, + } + } + }); + is( + gDeck.selectedViewName, + "page-progress", + "Should have the progress page selected" + ); + + let extensionsGroup = getResourceGroup(MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS); + ok(!isHidden(extensionsGroup), "Extensions group should be visible"); + let progressIcon = extensionsGroup.querySelector(".progress-icon"); + is(progressIcon.getAttribute("state"), "warning"); + is( + extensionsGroup.querySelector("a.message-text").textContent, + "" + ); + is( + extensionsGroup.querySelector("span.message-text").textContent, + EXTENSIONS_ERROR_STRING + ); + is( + extensionsGroup.querySelector(".support-text").textContent, + EXTENSIONS_SUPPORT_STRING + ); + is( + extensionsGroup.querySelector(".support-text").href, + EXTENSIONS_SUPPORT_HREF + ); + + // With progress being complete, the header should be using the completed + // migration string. + let header = gShadowRoot.querySelector("#progress-header"); + is( + header.getAttribute("data-l10n-id"), + "migration-wizard-progress-done-with-warnings-header", + "Should be showing completed migration header string" + ); + + let progressPage = gShadowRoot.querySelector("div[name='page-progress']"); + let doneButton = progressPage.querySelector(".done-button"); + ok(!isHidden(doneButton), "Done button should be visible and enabled"); + let cancelButton = progressPage.querySelector(".cancel-close"); + ok(isHidden(cancelButton), "Cancel button should be hidden"); + }); + + /** + * Tests that the wizard can show partial progress during file migration. + */ + add_task(async function test_partial_file_progress() { + const PASSWORDS_SUCCESS_STRING = "Some passwords success string"; + const TITLE = "Partial progress"; + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS, + title: "Partial progress", + progress: { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_FROM_FILE]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: PASSWORDS_SUCCESS_STRING, + }, + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: { + value: MigrationWizardConstants.PROGRESS_VALUE.LOADING, + }, + }, + }); + + is( + gDeck.selectedViewName, + "page-file-import-progress", + "Should have the file progress page selected" + ); + + let header = gShadowRoot.querySelector("#file-import-progress-header"); + is(header.textContent, TITLE, "Title is set correctly."); + + let passwordsFromFileGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_FROM_FILE + ); + ok(!isHidden(passwordsFromFileGroup), "Passwords from file group should be visible"); + let progressIcon = passwordsFromFileGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + passwordsFromFileGroup.querySelector(".message-text").textContent, + PASSWORDS_SUCCESS_STRING + ); + + let passwordsUpdatedGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED + ); + ok(isHidden(passwordsUpdatedGroup), "Passwords updated group should be hidden"); + + let passwordsNewGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW + ); + ok(!isHidden(passwordsNewGroup), "Passwords new group should be visible"); + progressIcon = passwordsNewGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "loading", + "Progress should be still be underway" + ); + is(passwordsNewGroup.querySelector(".message-text").textContent.trim(), ""); + + let progressPage = gShadowRoot.querySelector("div[name='page-file-import-progress']"); + let doneButton = progressPage.querySelector(".done-button"); + ok(isHidden(doneButton), "Done button should be hidden"); + let cancelButton = progressPage.querySelector(".cancel-close"); + ok(!isHidden(cancelButton), "Cancel button should be visible"); + ok(cancelButton.disabled, "Cancel button should be disabled"); + }); + + /** + * Tests that the wizard can show completed migration progress. + */ + add_task(async function test_completed_file_progress() { + const PASSWORDS_NEW_SUCCESS_STRING = "2 added"; + const PASSWORDS_UPDATED_SUCCESS_STRING = "5 updated"; + const TITLE = "Done doing file import"; + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS, + title: TITLE, + progress: { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: PASSWORDS_NEW_SUCCESS_STRING, + }, + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: PASSWORDS_UPDATED_SUCCESS_STRING, + }, + }, + }); + is( + gDeck.selectedViewName, + "page-file-import-progress", + "Should have the file progress page selected" + ); + + let header = gShadowRoot.querySelector("#file-import-progress-header"); + is(header.textContent, TITLE, "Title is set correctly."); + + let passwordsNewGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW + ); + ok(!isHidden(passwordsNewGroup), "Passwords new group should be visible"); + let progressIcon = passwordsNewGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + passwordsNewGroup.querySelector(".message-text").textContent, + PASSWORDS_NEW_SUCCESS_STRING + ); + + let passwordsUpdatedGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED + ); + ok(!isHidden(passwordsUpdatedGroup), "Passwords updated group should be visible"); + progressIcon = passwordsUpdatedGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + passwordsUpdatedGroup.querySelector(".message-text").textContent, + PASSWORDS_UPDATED_SUCCESS_STRING + ); + + let passwordsFromFileGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_FROM_FILE + ); + ok(isHidden(passwordsFromFileGroup), "Passwords from file group should be hidden"); + + let progressPage = gShadowRoot.querySelector("div[name='page-file-import-progress']"); + let doneButton = progressPage.querySelector(".done-button"); + ok(!isHidden(doneButton), "Done button should be visible and enabled"); + let cancelButton = progressPage.querySelector(".cancel-close"); + ok(isHidden(cancelButton), "Cancel button should be hidden"); + }); + + /** + * Tests that the buttons that dismiss the wizard when embedded in + * a dialog are only visible when in dialog mode, and dispatch a + * MigrationWizard:Close event when clicked. + */ + add_task(async function test_dialog_mode_close() { + gWiz.toggleAttribute("dialog-mode", true); + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + }); + + // For now, there's only a single .cancel-close button, so let's just test + // that one. Let's make this test fail if there are multiple so that we can + // then update this test to switch to the right pages to test those buttons + // too. + let buttons = gShadowRoot.querySelectorAll(".cancel-close:not([disabled])"); + ok( + buttons.length, + "This test expects at least one enabled .cancel-close button" + ); + let button = buttons[0]; + ok( + !isHidden(button), + ".cancel-close button should be visible in dialog mode." + ); + let closeEvent = BrowserTestUtils.waitForEvent(gWiz, "MigrationWizard:Close"); + button.click(); + await closeEvent; + + gWiz.toggleAttribute("dialog-mode", false); + ok( + isHidden(button), + ".cancel-close button should be hidden when not in dialog mode." + ); + }); + + /** + * Internet Explorer and Edge refer to bookmarks as "favorites", + * and we change our labels to suit when either of those browsers are + * selected as the migration source. This test tests that behavior in the + * selection page. + */ + add_task(async function test_ie_edge_favorites_selection() { + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: false, + }); + + let bookmarksCheckboxLabel = gShadowRoot.querySelector("#bookmarks"); + let span = bookmarksCheckboxLabel.querySelector("span[default-data-l10n-id]"); + ok(span, "The bookmarks selection span has a default-data-l10n-id attribute"); + is( + span.getAttribute("data-l10n-id"), + span.getAttribute("default-data-l10n-id"), + "Should be showing the default string for bookmarks" + ); + + // Now test when in Variant 2, for the string in the <summary>. + let selectedDataUpdated = BrowserTestUtils.waitForEvent( + gWiz, + "MigrationWizard:ResourcesUpdated" + ); + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: true, + }); + + await selectedDataUpdated; + + let summary = gShadowRoot.querySelector("summary"); + ok( + summary.textContent.toLowerCase().includes("bookmarks"), + "Summary should include the string 'bookmarks'" + ); + + for (let key of MigrationWizardConstants.USES_FAVORITES) { + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: [{ + key, + displayName: "Legacy Microsoft Browser", + resourceTypes: ["BOOKMARKS"], + profile: null, + hasPermissions: true, + }], + showImportAll: false, + }); + + is( + span.getAttribute("data-l10n-id"), + span.getAttribute("ie-edge-data-l10n-id"), + "Should be showing the IE/Edge string for bookmarks" + ); + + // Now test when in Variant 2, for the string in the <summary>. + selectedDataUpdated = BrowserTestUtils.waitForEvent( + gWiz, + "MigrationWizard:ResourcesUpdated" + ); + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: [{ + key, + displayName: "Legacy Microsoft Browser", + resourceTypes: ["BOOKMARKS"], + profile: null, + hasPermissions: true, + }], + showImportAll: true, + }); + + await selectedDataUpdated; + + ok( + summary.textContent.toLowerCase().includes("favorites"), + "Summary should include the string 'favorites'" + ); + } + }); + + /** + * Internet Explorer and Edge refer to bookmarks as "favorites", + * and we change our labels to suit when either of those browsers are + * selected as the migration source. This test tests that behavior in the + * progress page + */ + add_task(async function test_ie_edge_favorites_progress() { + gWiz.setState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: { + inProgress: false, + message: "A string from the parent", + }, + }, + }); + + let bookmarksGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS + ); + let span = bookmarksGroup.querySelector("span[default-data-l10n-id]"); + ok(span, "Should have found a span with default-data-l10n-id"); + is( + span.getAttribute("data-l10n-id"), + span.getAttribute("default-data-l10n-id"), + "Should be using the default string." + ); + + + for (let key of MigrationWizardConstants.USES_FAVORITES) { + gWiz.setState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + key, + progress: { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: { + inProgress: false, + message: "A string from the parent", + }, + }, + }); + + is( + span.getAttribute("data-l10n-id"), + span.getAttribute("ie-edge-data-l10n-id"), + "Should be showing the IE/Edge string for bookmarks" + ); + } + }); + + /** + * Tests that the button shown in either the browser migration success or + * file migration success pages is a "continue" button rather than a + * "done" button. + */ + add_task(async function test_embedded_continue_button() { + gWiz.toggleAttribute("dialog-mode", false); + gWiz.setState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: "A string from the parent", + }, + }, + }); + + let progressPage = gShadowRoot.querySelector("div[name='page-progress']"); + let cancelButton = progressPage.querySelector(".cancel-close"); + ok(isHidden(cancelButton), "Cancel button should be hidden"); + let doneButton = progressPage.querySelector(".done-button"); + ok(isHidden(doneButton), "Done button should be hidden when embedding"); + let continueButton = progressPage.querySelector(".continue-button"); + ok(!isHidden(continueButton), "Continue button should be displayed"); + + let content = document.getElementById("content"); + + let promise = new Promise(resolve => { + content.addEventListener("MigrationWizard:Close", resolve, { + once: true, + }); + }); + continueButton.click(); + await promise; + ok( + true, + "Clicking on the Continue button sent the MigrationWizard:Close event." + ); + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS, + title: "Done importing file data", + progress: { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: { + inProgress: false, + message: "Some message", + }, + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED]: { + inProgress: false, + message: "Some other", + }, + }, + }); + + progressPage = gShadowRoot.querySelector("div[name='page-file-import-progress']"); + cancelButton = progressPage.querySelector(".cancel-close"); + ok(isHidden(cancelButton), "Cancel button should be hidden"); + doneButton = progressPage.querySelector(".done-button"); + ok(isHidden(doneButton), "Done button should be hidden when embedding"); + continueButton = progressPage.querySelector(".continue-button"); + ok(!isHidden(continueButton), "Continue button should be displayed"); + + promise = new Promise(resolve => { + content.addEventListener("MigrationWizard:Close", resolve, { + once: true, + }); + }); + continueButton.click(); + await promise; + ok( + true, + "Clicking on the Continue button sent the MigrationWizard:Close event." + ); + + gWiz.toggleAttribute("dialog-mode", true); + }); + + /** + * Tests that if a progress update comes down which puts a resource from + * being done to loading, that the status message is cleared. + */ + add_task(async function test_clear_status_message_when_in_progress() { + const STRING_TO_CLEAR = "This string should get cleared"; + gWiz.toggleAttribute("dialog-mode", false); + gWiz.setState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: STRING_TO_CLEAR, + }, + }, + }); + + let bookmarksGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS + ); + ok(!isHidden(bookmarksGroup), "Bookmarks group should be visible"); + let progressIcon = bookmarksGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + bookmarksGroup.querySelector(".message-text").textContent, + STRING_TO_CLEAR + ); + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: { + value: MigrationWizardConstants.PROGRESS_VALUE.LOADING, + message: "", + }, + }, + }); + + ok(!isHidden(bookmarksGroup), "Bookmarks group should be visible"); + is( + progressIcon.getAttribute("state"), + "loading", + "Progress should be still be underway" + ); + is( + bookmarksGroup.querySelector(".message-text").textContent.trim(), + "" + ); + }); + + /** + * Tests that if a file progress update comes down which puts a resource + * from being done to loading, that the status message is cleared. + * + * This is extremely similar to the above test, except that it's for progress + * updates for file resources. + */ + add_task(async function test_clear_status_message_when_in_file_progress() { + const STRING_TO_CLEAR = "This string should get cleared"; + gWiz.toggleAttribute("dialog-mode", false); + gWiz.setState({ + page: MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: { + value: MigrationWizardConstants.PROGRESS_VALUE.SUCCESS, + message: STRING_TO_CLEAR, + }, + }, + }); + + let passwordsGroup = getResourceGroup( + MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW + ); + ok(!isHidden(passwordsGroup), "Passwords group should be visible"); + let progressIcon = passwordsGroup.querySelector(".progress-icon"); + is( + progressIcon.getAttribute("state"), + "success", + "Progress should be completed" + ); + is( + passwordsGroup.querySelector(".message-text").textContent, + STRING_TO_CLEAR + ); + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS, + key: "chrome", + progress: { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: { + value: MigrationWizardConstants.PROGRESS_VALUE.LOADING, + message: "", + }, + }, + }); + + ok(!isHidden(passwordsGroup), "Passwords group should be visible"); + is( + progressIcon.getAttribute("state"), + "loading", + "Progress should be still be underway" + ); + is( + passwordsGroup.querySelector(".message-text").textContent.trim(), + "" + ); + }); + + /** + * Tests that the migration wizard can be put into the selection page after + * a file migrator error and show an error message. + */ + add_task(async function test_file_migrator_error() { + const FILE_MIGRATOR_KEY = "some-file-migrator"; + const FILE_IMPORT_ERROR_MESSAGE = "This is an error message"; + const MIGRATORS = [ + { + key: "some-browser-0", + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + displayName: "Some Browser 0", + resourceTypes: ["HISTORY", "FORMDATA", "PASSWORDS", "BOOKMARKS"], + profile: { id: "person-2", name: "Person 2" }, + hasPermissions: true, + }, + { + key: "some-browser-1", + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + displayName: "Some Browser 1", + resourceTypes: ["HISTORY", "BOOKMARKS"], + profile: null, + hasPermissions: true, + }, + { + key: FILE_MIGRATOR_KEY, + type: MigrationWizardConstants.MIGRATOR_TYPES.FILE, + displayName: "Some File Migrator", + resourceTypes: [], + }, + ]; + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATORS, + showImportAll: true, + migratorKey: "some-file-migrator", + fileImportErrorMessage: FILE_IMPORT_ERROR_MESSAGE, + }); + + let errorMessageContainer = gShadowRoot.querySelector(".file-import-error"); + ok(!isHidden(errorMessageContainer), "Error message should be shown."); + let errorText = gShadowRoot.querySelector("#file-import-error-message").textContent; + is(errorText, FILE_IMPORT_ERROR_MESSAGE); + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATORS, + showImportAll: true, + }); + + ok(isHidden(errorMessageContainer), "Error message should be hidden."); + errorText = gShadowRoot.querySelector("#file-import-error-message").textContent; + is(errorText, ""); + }); + + /** + * Tests that the variant of the wizard can be forced via + * attributes on the migration-wizard element. + */ + add_task(async function force_show_import_all() { + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: true, + }); + + let selectionPage = gShadowRoot.querySelector("div[name='page-selection']"); + let details = gShadowRoot.querySelector("details"); + ok( + selectionPage.hasAttribute("show-import-all"), + "Should be paying attention to showImportAll=true on state object" + ); + ok(!details.open, "Details collapsed by default"); + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: false, + }); + + ok( + !selectionPage.hasAttribute("show-import-all"), + "Should be paying attention to showImportAll=false on state object" + ); + ok(details.open, "Details expanded by default"); + + gWiz.setAttribute("force-show-import-all", "false"); + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: true, + }); + ok( + !selectionPage.hasAttribute("show-import-all"), + "Should be paying attention to force-show-import-all=false on DOM node" + ); + ok(details.open, "Details expanded by default"); + + gWiz.setAttribute("force-show-import-all", "true"); + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: false, + }); + ok( + selectionPage.hasAttribute("show-import-all"), + "Should be paying attention to force-show-import-all=true on DOM node" + ); + ok(!details.open, "Details collapsed by default"); + + gWiz.removeAttribute("force-show-import-all"); + }); + + /** + * Tests that for non-Safari migrators without permissions, we show + * the appropriate message and the button for getting permissions. + */ + add_task(async function no_permissions() { + const SOME_FAKE_PERMISSION_PATH = "/some/fake/path"; + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: [{ + key: "some-browser-0", + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + displayName: "Some Browser 0 with no permissions", + resourceTypes: [], + profile: null, + hasPermissions: false, + permissionsPath: SOME_FAKE_PERMISSION_PATH, + }], + showImportAll: false, + }); + + let selectionPage = gShadowRoot.querySelector("div[name='page-selection']"); + ok(selectionPage.hasAttribute("no-permissions"), "no-permissions attribute set."); + + let resourceList = gShadowRoot.querySelector(".resource-selection-details"); + ok(isHidden(resourceList), "Resources list is hidden."); + + let importButton = gShadowRoot.querySelector("#import"); + ok(isHidden(importButton), "Import button hidden."); + let noPermissionsMessage = gShadowRoot.querySelector(".no-permissions-message"); + ok(!isHidden(noPermissionsMessage), "No permissions message shown."); + let getPermissionButton = gShadowRoot.querySelector("#get-permissions"); + ok(!isHidden(getPermissionButton), "Get permissions button shown."); + + let step2 = gShadowRoot.querySelector(".migration-no-permissions-instructions-step2"); + ok(step2.hasAttribute("data-l10n-args")); + is(JSON.parse(step2.getAttribute("data-l10n-args")).permissionsPath, SOME_FAKE_PERMISSION_PATH); + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: MIGRATOR_PROFILE_INSTANCES, + showImportAll: false, + }); + + ok(!selectionPage.hasAttribute("no-permissions"), "no-permissions attribute set to false."); + ok(!isHidden(resourceList), "Resources list is shown."); + ok(!isHidden(importButton), "Import button is shown."); + ok(isHidden(noPermissionsMessage), "No permissions message hidden."); + ok(isHidden(getPermissionButton), "Get permissions button hidden."); + }); + + /** + * Tests that for the Safari migrator without permissions, we show the + * normal resources list and impor tbutton instead of the no permissions + * message. Safari has a special flow where permissions are requested + * only after resource selection has occurred. + */ + add_task(async function no_permissions_safari() { + const SOME_FAKE_PERMISSION_PATH = "/some/fake/safari/path"; + + gWiz.setState({ + page: MigrationWizardConstants.PAGES.SELECTION, + migrators: [{ + key: "safari", + type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + displayName: "Safari with no permissions", + resourceTypes: ["HISTORY", "BOOKMARKS"], + profile: null, + hasPermissions: false, + permissionsPath: SOME_FAKE_PERMISSION_PATH, + }], + showImportAll: false, + }); + + let selectionPage = gShadowRoot.querySelector("div[name='page-selection']"); + ok(!selectionPage.hasAttribute("no-permissions"), "no-permissions attribute not set."); + + let resourceList = gShadowRoot.querySelector(".resource-selection-details"); + ok(!isHidden(resourceList), "Resources list is shown."); + + let importButton = gShadowRoot.querySelector("#import"); + ok(!isHidden(importButton), "Import button shown."); + let noPermissionsMessage = gShadowRoot.querySelector(".no-permissions-message"); + ok(isHidden(noPermissionsMessage), "No permissions message hidden."); + let getPermissionButton = gShadowRoot.querySelector("#get-permissions"); + ok(isHidden(getPermissionButton), "Get permissions button hiddne."); + }); + </script> + </head> + <body> + <p id="display"></p> + <div id="content"> + <migration-wizard id="test-wizard" dialog-mode=""></migration-wizard> + </div> + <pre id="test"></pre> + </body> +</html> diff --git a/browser/components/migration/tests/head-common.js b/browser/components/migration/tests/head-common.js new file mode 100644 index 0000000000..025d3e5a16 --- /dev/null +++ b/browser/components/migration/tests/head-common.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MigrationWizardConstants } = ChromeUtils.importESModule( + "chrome://browser/content/migration/migration-wizard-constants.mjs" +); + +/** + * Returns the constant strings from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES + * that aren't also part of MigrationWizardConstants.PROFILE_RESET_ONLY_RESOURCE_TYPES. + * + * This is the set of resources that the user can actually choose to migrate via + * checkboxes. + * + * @returns {string[]} + */ +function getChoosableResourceTypes() { + return Object.keys(MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES).filter( + resourceType => + !MigrationWizardConstants.PROFILE_RESET_ONLY_RESOURCE_TYPES[resourceType] + ); +} diff --git a/browser/components/migration/tests/marionette/manifest.toml b/browser/components/migration/tests/marionette/manifest.toml new file mode 100644 index 0000000000..1e3b536ee2 --- /dev/null +++ b/browser/components/migration/tests/marionette/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +run-if = ["buildapp == 'browser'"] + +["test_refresh_firefox.py"] diff --git a/browser/components/migration/tests/marionette/test_refresh_firefox.py b/browser/components/migration/tests/marionette/test_refresh_firefox.py new file mode 100644 index 0000000000..ea5d6bce99 --- /dev/null +++ b/browser/components/migration/tests/marionette/test_refresh_firefox.py @@ -0,0 +1,703 @@ +import os +import time + +from marionette_driver.errors import NoAlertPresentException +from marionette_harness import MarionetteTestCase + + +# Holds info about things we need to cleanup after the tests are done. +class PendingCleanup: + desktop_backup_path = None + reset_profile_path = None + reset_profile_local_path = None + + def __init__(self, profile_name_to_remove): + self.profile_name_to_remove = profile_name_to_remove + + +class TestFirefoxRefresh(MarionetteTestCase): + _sandbox = "firefox-refresh" + + _username = "marionette-test-login" + _password = "marionette-test-password" + _bookmarkURL = "about:mozilla" + _bookmarkText = "Some bookmark from Marionette" + + _cookieHost = "firefox-refresh.marionette-test.mozilla.org" + _cookiePath = "some/cookie/path" + _cookieName = "somecookie" + _cookieValue = "some cookie value" + + _historyURL = "http://firefox-refresh.marionette-test.mozilla.org/" + _historyTitle = "Test visit for Firefox Reset" + + _formHistoryFieldName = "some-very-unique-marionette-only-firefox-reset-field" + _formHistoryValue = "special-pumpkin-value" + + _formAutofillAvailable = False + _formAutofillAddressGuid = None + + _expectedURLs = ["about:robots", "about:mozilla"] + + def savePassword(self): + self.runAsyncCode( + """ + let [username, password, resolve] = arguments; + let myLogin = new global.LoginInfo( + "test.marionette.mozilla.com", + "http://test.marionette.mozilla.com/some/form/", + null, + username, + password, + "username", + "password" + ); + Services.logins.addLoginAsync(myLogin) + .then(() => resolve(false), resolve); + """, + script_args=(self._username, self._password), + ) + + def createBookmarkInMenu(self): + error = self.runAsyncCode( + """ + // let url = arguments[0]; + // let title = arguments[1]; + // let resolve = arguments[arguments.length - 1]; + let [url, title, resolve] = arguments; + PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, url, title + }).then(() => resolve(false), resolve); + """, + script_args=(self._bookmarkURL, self._bookmarkText), + ) + if error: + print(error) + + def createBookmarksOnToolbar(self): + error = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + let children = []; + for (let i = 1; i <= 5; i++) { + children.push({url: `about:rights?p=${i}`, title: `Bookmark ${i}`}); + } + PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children + }).then(() => resolve(false), resolve); + """ + ) + if error: + print(error) + + def createHistory(self): + error = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + PlacesUtils.history.insert({ + url: arguments[0], + title: arguments[1], + visits: [{ + date: new Date(Date.now() - 5000), + referrer: "about:mozilla" + }] + }).then(() => resolve(false), + ex => resolve("Unexpected error in adding visit: " + ex)); + """, + script_args=(self._historyURL, self._historyTitle), + ) + if error: + print(error) + + def createFormHistory(self): + error = self.runAsyncCode( + """ + let updateDefinition = { + op: "add", + fieldname: arguments[0], + value: arguments[1], + firstUsed: (Date.now() - 5000) * 1000, + }; + let resolve = arguments[arguments.length - 1]; + global.FormHistory.update(updateDefinition).then(() => { + resolve(false); + }, error => { + resolve("Unexpected error in adding formhistory: " + error); + }); + """, + script_args=(self._formHistoryFieldName, self._formHistoryValue), + ) + if error: + print(error) + + def createFormAutofill(self): + if not self._formAutofillAvailable: + return + self._formAutofillAddressGuid = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + const TEST_ADDRESS_1 = { + "given-name": "John", + "additional-name": "R.", + "family-name": "Smith", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\\\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+15195555555", + email: "user@example.com", + }; + return global.formAutofillStorage.initialize().then(() => { + return global.formAutofillStorage.addresses.add(TEST_ADDRESS_1); + }).then(resolve); + """ + ) + + def createCookie(self): + self.runCode( + """ + // Expire in 15 minutes: + let expireTime = Math.floor(Date.now() / 1000) + 15 * 60; + Services.cookies.add(arguments[0], arguments[1], arguments[2], arguments[3], + true, false, false, expireTime, {}, + Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_UNSET); + """, + script_args=( + self._cookieHost, + self._cookiePath, + self._cookieName, + self._cookieValue, + ), + ) + + def createSession(self): + self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + const COMPLETE_STATE = Ci.nsIWebProgressListener.STATE_STOP + + Ci.nsIWebProgressListener.STATE_IS_NETWORK; + let { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" + ); + let expectedURLs = Array.from(arguments[0]) + gBrowser.addTabsProgressListener({ + onStateChange(browser, webprogress, request, flags, status) { + try { + request && request.QueryInterface(Ci.nsIChannel); + } catch (ex) {} + let uriLoaded = request.originalURI && request.originalURI.spec; + if ((flags & COMPLETE_STATE == COMPLETE_STATE) && uriLoaded && + expectedURLs.includes(uriLoaded)) { + TabStateFlusher.flush(browser).then(function() { + expectedURLs.splice(expectedURLs.indexOf(uriLoaded), 1); + if (!expectedURLs.length) { + gBrowser.removeTabsProgressListener(this); + resolve(); + } + }); + } + } + }); + let expectedTabs = new Set(); + for (let url of expectedURLs) { + expectedTabs.add(gBrowser.addTab(url, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + })); + } + // Close any other tabs that might be open: + let allTabs = Array.from(gBrowser.tabs); + for (let tab of allTabs) { + if (!expectedTabs.has(tab)) { + gBrowser.removeTab(tab); + } + } + """, # NOQA: E501 + script_args=(self._expectedURLs,), + ) + + def createFxa(self): + # This script will write an entry to the login manager and create + # a signedInUser.json in the profile dir. + self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + let { FxAccountsStorageManager } = ChromeUtils.import( + "resource://gre/modules/FxAccountsStorage.jsm" + ); + let storage = new FxAccountsStorageManager(); + let data = {email: "test@test.com", uid: "uid", keyFetchToken: "top-secret"}; + storage.initialize(data); + storage.finalize().then(resolve); + """ + ) + + def createSync(self): + # This script will write the canonical preference which indicates a user + # is signed into sync. + self.marionette.execute_script( + """ + Services.prefs.setStringPref("services.sync.username", "test@test.com"); + """ + ) + + def checkPassword(self): + loginInfo = self.runAsyncCode( + """ + let [resolve] = arguments; + Services.logins.searchLoginsAsync({ + origin: "test.marionette.mozilla.com", + formActionOrigin: "http://test.marionette.mozilla.com/some/form/", + }).then(ary => resolve(ary.length ? ary : {username: "null", password: "null"})); + """ + ) + self.assertEqual(len(loginInfo), 1) + self.assertEqual(loginInfo[0]["username"], self._username) + self.assertEqual(loginInfo[0]["password"], self._password) + + loginCount = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + Services.logins.getAllLogins().then(logins => resolve(logins.length)); + """ + ) + # Note that we expect 2 logins - one from us, one from sync. + self.assertEqual(loginCount, 2, "No other logins are present") + + def checkBookmarkInMenu(self): + titleInBookmarks = self.runAsyncCode( + """ + let [url, resolve] = arguments; + PlacesUtils.bookmarks.fetch({url}).then( + bookmark => resolve(bookmark ? bookmark.title : ""), + ex => resolve(ex) + ); + """, + script_args=(self._bookmarkURL,), + ) + self.assertEqual(titleInBookmarks, self._bookmarkText) + + def checkBookmarkToolbarVisibility(self): + toolbarVisible = self.marionette.execute_script( + """ + const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL; + return Services.xulStore.getValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed"); + """ + ) + if toolbarVisible == "": + toolbarVisible = "false" + self.assertEqual(toolbarVisible, "false") + + def checkHistory(self): + historyResult = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + PlacesUtils.history.fetch(arguments[0]).then(pageInfo => { + if (!pageInfo) { + resolve("No visits found"); + } else { + resolve(pageInfo); + } + }).catch(e => { + resolve("Unexpected error in fetching page: " + e); + }); + """, + script_args=(self._historyURL,), + ) + if type(historyResult) == str: + self.fail(historyResult) + return + + self.assertEqual(historyResult["title"], self._historyTitle) + + def checkFormHistory(self): + formFieldResults = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + let results = []; + global.FormHistory.search(["value"], {fieldname: arguments[0]}) + .then(resolve); + """, + script_args=(self._formHistoryFieldName,), + ) + if type(formFieldResults) == str: + self.fail(formFieldResults) + return + + formFieldResultCount = len(formFieldResults) + self.assertEqual( + formFieldResultCount, + 1, + "Should have exactly 1 entry for this field, got %d" % formFieldResultCount, + ) + if formFieldResultCount == 1: + self.assertEqual(formFieldResults[0]["value"], self._formHistoryValue) + + formHistoryCount = self.runAsyncCode( + """ + let [resolve] = arguments; + global.FormHistory.count({}).then(resolve); + """ + ) + self.assertEqual( + formHistoryCount, 1, "There should be only 1 entry in the form history" + ) + + def checkFormAutofill(self): + if not self._formAutofillAvailable: + return + + formAutofillResults = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + return global.formAutofillStorage.initialize().then(() => { + return global.formAutofillStorage.addresses.getAll() + }).then(resolve); + """, + ) + if type(formAutofillResults) == str: + self.fail(formAutofillResults) + return + + formAutofillAddressCount = len(formAutofillResults) + self.assertEqual( + formAutofillAddressCount, + 1, + "Should have exactly 1 saved address, got %d" % formAutofillAddressCount, + ) + if formAutofillAddressCount == 1: + self.assertEqual( + formAutofillResults[0]["guid"], self._formAutofillAddressGuid + ) + + def checkCookie(self): + cookieInfo = self.runCode( + """ + try { + let cookies = Services.cookies.getCookiesFromHost(arguments[0], {}); + let cookie = null; + for (let hostCookie of cookies) { + // getCookiesFromHost returns any cookie from the BASE host. + if (hostCookie.rawHost != arguments[0]) + continue; + if (cookie != null) { + return "more than 1 cookie! That shouldn't happen!"; + } + cookie = hostCookie; + } + return {path: cookie.path, name: cookie.name, value: cookie.value}; + } catch (ex) { + return "got exception trying to fetch cookie: " + ex; + } + """, + script_args=(self._cookieHost,), + ) + if not isinstance(cookieInfo, dict): + self.fail(cookieInfo) + return + self.assertEqual(cookieInfo["path"], self._cookiePath) + self.assertEqual(cookieInfo["value"], self._cookieValue) + self.assertEqual(cookieInfo["name"], self._cookieName) + + def checkSession(self): + tabURIs = self.runCode( + """ + return [... gBrowser.browsers].map(b => b.currentURI && b.currentURI.spec) + """ + ) + self.assertSequenceEqual(tabURIs, ["about:welcomeback"]) + + # Dismiss modal dialog if any. This is mainly to dismiss the check for + # default browser dialog if it shows up. + try: + alert = self.marionette.switch_to_alert() + alert.dismiss() + except NoAlertPresentException: + pass + + tabURIs = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1] + let mm = gBrowser.selectedBrowser.messageManager; + + window.addEventListener("SSWindowStateReady", function() { + window.addEventListener("SSTabRestored", function() { + resolve(Array.from(gBrowser.browsers, b => b.currentURI?.spec)); + }, { capture: false, once: true }); + }, { capture: false, once: true }); + + let fs = function() { + if (content.document.readyState === "complete") { + content.document.getElementById("errorTryAgain").click(); + } else { + content.window.addEventListener("load", function(event) { + content.document.getElementById("errorTryAgain").click(); + }, { once: true }); + } + }; + + Services.prefs.setBoolPref("security.allow_parent_unrestricted_js_loads", true); + mm.loadFrameScript("data:application/javascript,(" + fs.toString() + ")()", true); + Services.prefs.setBoolPref("security.allow_parent_unrestricted_js_loads", false); + """ # NOQA: E501 + ) + self.assertSequenceEqual(tabURIs, self._expectedURLs) + + def checkFxA(self): + result = self.runAsyncCode( + """ + let { FxAccountsStorageManager } = ChromeUtils.import( + "resource://gre/modules/FxAccountsStorage.jsm" + ); + let resolve = arguments[arguments.length - 1]; + let storage = new FxAccountsStorageManager(); + let result = {}; + storage.initialize(); + storage.getAccountData().then(data => { + result.accountData = data; + return storage.finalize(); + }).then(() => { + resolve(result); + }).catch(err => { + resolve(err.toString()); + }); + """ + ) + if type(result) != dict: + self.fail(result) + return + self.assertEqual(result["accountData"]["email"], "test@test.com") + self.assertEqual(result["accountData"]["uid"], "uid") + self.assertEqual(result["accountData"]["keyFetchToken"], "top-secret") + + def checkSync(self, expect_sync_user): + pref_value = self.marionette.execute_script( + """ + return Services.prefs.getStringPref("services.sync.username", null); + """ + ) + expected_value = "test@test.com" if expect_sync_user else None + self.assertEqual(pref_value, expected_value) + + def checkStartupMigrationStateCleared(self): + result = self.runCode( + """ + let { MigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MigrationUtils.sys.mjs" + ); + return MigrationUtils.isStartupMigration; + """ + ) + self.assertFalse(result) + + def checkProfile(self, has_migrated=False, expect_sync_user=True): + self.checkPassword() + self.checkBookmarkInMenu() + self.checkHistory() + self.checkFormHistory() + self.checkFormAutofill() + self.checkCookie() + self.checkFxA() + self.checkSync(expect_sync_user) + if has_migrated: + self.checkBookmarkToolbarVisibility() + self.checkSession() + self.checkStartupMigrationStateCleared() + + def createProfileData(self): + self.savePassword() + self.createBookmarkInMenu() + self.createBookmarksOnToolbar() + self.createHistory() + self.createFormHistory() + self.createFormAutofill() + self.createCookie() + self.createSession() + self.createFxa() + self.createSync() + + def setUpScriptData(self): + self.marionette.set_context(self.marionette.CONTEXT_CHROME) + self.runCode( + """ + window.global = {}; + global.LoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1", "nsILoginInfo", "init"); + global.profSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(Ci.nsIToolkitProfileService); + global.Preferences = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" + ).Preferences; + global.FormHistory = ChromeUtils.import( + "resource://gre/modules/FormHistory.jsm" + ).FormHistory; + """ # NOQA: E501 + ) + self._formAutofillAvailable = self.runCode( + """ + try { + global.formAutofillStorage = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm" + ).formAutofillStorage; + } catch(e) { + return false; + } + return true; + """ # NOQA: E501 + ) + + def runCode(self, script, *args, **kwargs): + return self.marionette.execute_script( + script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs + ) + + def runAsyncCode(self, script, *args, **kwargs): + return self.marionette.execute_async_script( + script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs + ) + + def setUp(self): + MarionetteTestCase.setUp(self) + self.setUpScriptData() + + self.cleanups = [] + + def tearDown(self): + # Force yet another restart with a clean profile to disconnect from the + # profile and environment changes we've made, to leave a more or less + # blank slate for the next person. + self.marionette.restart(in_app=False, clean=True) + self.setUpScriptData() + + # Super + MarionetteTestCase.tearDown(self) + + # A helper to deal with removing a load of files + import mozfile + + for cleanup in self.cleanups: + if cleanup.desktop_backup_path: + mozfile.remove(cleanup.desktop_backup_path) + + if cleanup.reset_profile_path: + # Remove ourselves from profiles.ini + self.runCode( + """ + let name = arguments[0]; + let profile = global.profSvc.getProfileByName(name); + profile.remove(false) + global.profSvc.flush(); + """, + script_args=(cleanup.profile_name_to_remove,), + ) + # Remove the local profile dir if it's not the same as the profile dir: + different_path = ( + cleanup.reset_profile_local_path != cleanup.reset_profile_path + ) + if cleanup.reset_profile_local_path and different_path: + mozfile.remove(cleanup.reset_profile_local_path) + + # And delete all the files. + mozfile.remove(cleanup.reset_profile_path) + + def doReset(self): + profileName = "marionette-test-profile-" + str(int(time.time() * 1000)) + cleanup = PendingCleanup(profileName) + self.runCode( + """ + // Ensure the current (temporary) profile is in profiles.ini: + let profD = Services.dirsvc.get("ProfD", Ci.nsIFile); + let profileName = arguments[1]; + let myProfile = global.profSvc.createProfile(profD, profileName); + global.profSvc.flush() + + // Now add the reset parameters: + let prefsToKeep = Array.from(Services.prefs.getChildList("marionette.")); + // Add all the modified preferences set from geckoinstance.py to avoid + // non-local connections. + prefsToKeep = prefsToKeep.concat(JSON.parse( + Services.env.get("MOZ_MARIONETTE_REQUIRED_PREFS"))); + let prefObj = {}; + for (let pref of prefsToKeep) { + prefObj[pref] = global.Preferences.get(pref); + } + Services.env.set("MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS", JSON.stringify(prefObj)); + Services.env.set("MOZ_RESET_PROFILE_RESTART", "1"); + Services.env.set("XRE_PROFILE_PATH", arguments[0]); + """, + script_args=( + self.marionette.instance.profile.profile, + profileName, + ), + ) + + profileLeafName = os.path.basename( + os.path.normpath(self.marionette.instance.profile.profile) + ) + + # Now restart the browser to get it reset: + self.marionette.restart(clean=False, in_app=True) + self.setUpScriptData() + + # Determine the new profile path (we'll need to remove it when we're done) + [cleanup.reset_profile_path, cleanup.reset_profile_local_path] = self.runCode( + """ + let profD = Services.dirsvc.get("ProfD", Ci.nsIFile); + let localD = Services.dirsvc.get("ProfLD", Ci.nsIFile); + return [profD.path, localD.path]; + """ + ) + + # Determine the backup path + cleanup.desktop_backup_path = self.runCode( + """ + let container; + try { + container = Services.dirsvc.get("Desk", Ci.nsIFile); + } catch (ex) { + container = Services.dirsvc.get("Home", Ci.nsIFile); + } + let bundle = Services.strings.createBundle("chrome://mozapps/locale/profile/profileSelection.properties"); + let dirName = bundle.formatStringFromName("resetBackupDirectory", [Services.appinfo.name]); + container.append(dirName); + container.append(arguments[0]); + return container.path; + """, # NOQA: E501 + script_args=(profileLeafName,), + ) + + self.assertTrue( + os.path.isdir(cleanup.reset_profile_path), + "Reset profile path should be present", + ) + self.assertTrue( + os.path.isdir(cleanup.desktop_backup_path), + "Backup profile path should be present", + ) + self.assertIn(cleanup.profile_name_to_remove, cleanup.reset_profile_path) + return cleanup + + def testResetEverything(self): + self.createProfileData() + + self.checkProfile(expect_sync_user=True) + + this_cleanup = self.doReset() + self.cleanups.append(this_cleanup) + + # Now check that we're doing OK... + self.checkProfile(has_migrated=True, expect_sync_user=True) + + def testFxANoSync(self): + # This test doesn't need to repeat all the non-sync tests... + # Setup FxA but *not* sync + self.createFxa() + + self.checkFxA() + self.checkSync(False) + + this_cleanup = self.doReset() + self.cleanups.append(this_cleanup) + + self.checkFxA() + self.checkSync(False) diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons Binary files differnew file mode 100644 index 0000000000..fddee798b3 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data Binary files differnew file mode 100644 index 0000000000..7e6e843a03 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data Binary files differnew file mode 100644 index 0000000000..c557c9b851 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State new file mode 100644 index 0000000000..3f3fecb651 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State @@ -0,0 +1,5 @@ +{ + "os_crypt" : { + "encrypted_key" : "RFBBUEk/ThisNPAPIKeyCanOnlyBeDecryptedByTheOriginalDeviceSoThisWillThrowFromDecryptData" + } +} diff --git a/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data Binary files differnew file mode 100644 index 0000000000..fd135624c4 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat Binary files differnew file mode 100644 index 0000000000..1835c33583 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks new file mode 100644 index 0000000000..f51195f54c --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks @@ -0,0 +1 @@ +Encrypted canonical bookmarks storage, since 360 SE 10
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb new file mode 100644 index 0000000000..ea466a25bf --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb @@ -0,0 +1,3 @@ +{
+ "note": "Plain text bookmarks backup, since 360 SE 10."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb new file mode 100644 index 0000000000..ea466a25bf --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb @@ -0,0 +1,3 @@ +{
+ "note": "Plain text bookmarks backup, since 360 SE 10."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb new file mode 100644 index 0000000000..32b4002a32 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb @@ -0,0 +1 @@ +Encrypted bookmarks backup, since 360 SE 10.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb new file mode 100644 index 0000000000..32b4002a32 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb @@ -0,0 +1 @@ +Encrypted bookmarks backup, since 360 SE 10.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat new file mode 100644 index 0000000000..440e7145bd --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat @@ -0,0 +1 @@ +Bookmarks storage in legacy SQLite format.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb new file mode 100644 index 0000000000..d5d939629c --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb @@ -0,0 +1,3 @@ +{
+ "note": "Plain text bookmarks backup, 360 SE 9."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks new file mode 100644 index 0000000000..6f47e5a55c --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks @@ -0,0 +1,3 @@ +{
+ "note": "Plain text canonical bookmarks storage, 360 SE 9."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State new file mode 100644 index 0000000000..dd3fecce45 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State @@ -0,0 +1,12 @@ +{
+ "profile": {
+ "info_cache": {
+ "Default": {
+ "name": "用户1"
+ }
+ }
+ },
+ "sync_login_info": {
+ "filepath": "0f3ab103a522f4463ecacc36d34eb996"
+ }
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies Binary files differnew file mode 100644 index 0000000000..83d855cb33 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json new file mode 100644 index 0000000000..44e855edbd --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json @@ -0,0 +1,9 @@ +{ + "description": { + "description": "Extension description in manifest. Should not exceed 132 characters.", + "message": "It is the description of fake app 1." + }, + "name": { + "message": "Fake App 1" + } +} diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json new file mode 100644 index 0000000000..1550bf1c0e --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json @@ -0,0 +1,10 @@ +{ + "app": { + "launch": { + "local_path": "main.html" + } + }, + "default_locale": "en_US", + "description": "__MSG_description__", + "name": "__MSG_name__" +} diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json new file mode 100644 index 0000000000..11657460d8 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json @@ -0,0 +1,9 @@ +{ + "description": { + "description": "Extension description in manifest. Should not exceed 132 characters.", + "message": "It is the description of fake extension 1." + }, + "name": { + "message": "Fake Extension 1" + } +} diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json new file mode 100644 index 0000000000..5ceced8031 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json @@ -0,0 +1,5 @@ +{ + "default_locale": "en_US", + "description": "__MSG_description__", + "name": "__MSG_name__" +} diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json new file mode 100644 index 0000000000..983c37560c --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json @@ -0,0 +1,4 @@ +{ + "default_locale": "en_US", + "name": "Fake Extension 2" +} diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt Binary files differnew file mode 100644 index 0000000000..8585f308c5 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster Binary files differnew file mode 100644 index 0000000000..7fb19903b0 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data Binary files differnew file mode 100644 index 0000000000..19b8542b98 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State new file mode 100644 index 0000000000..01b99455e4 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State @@ -0,0 +1,22 @@ +{ + "profile" : { + "info_cache" : { + "Default" : { + "active_time" : 1430950755.65137, + "is_using_default_name" : true, + "is_ephemeral" : false, + "is_omitted_from_profile_list" : false, + "user_name" : "", + "background_apps" : false, + "is_using_default_avatar" : true, + "avatar_icon" : "chrome://theme/IDR_PROFILE_AVATAR_0", + "name" : "Person 1" + } + }, + "profiles_created" : 1, + "last_used" : "Default", + "last_active_profiles" : [ + "Default" + ] + } +} diff --git a/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist Binary files differnew file mode 100644 index 0000000000..a9c33e1b1a --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db Binary files differnew file mode 100644 index 0000000000..dd5d0c7512 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm Binary files differnew file mode 100644 index 0000000000..edd607898b --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal Binary files differnew file mode 100644 index 0000000000..e145119298 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9 Binary files differnew file mode 100644 index 0000000000..1c6741c165 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9 diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271 Binary files differnew file mode 100644 index 0000000000..47b40f707f --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271 diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42 Binary files differnew file mode 100644 index 0000000000..2a4c30b31e --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42 diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804 Binary files differnew file mode 100644 index 0000000000..f4996ba082 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804 diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80 Binary files differnew file mode 100644 index 0000000000..f519ce9ad2 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80 diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F Binary files differnew file mode 100644 index 0000000000..e70021849b --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8 Binary files differnew file mode 100644 index 0000000000..559502b02b --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8 diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A Binary files differnew file mode 100644 index 0000000000..89ed9a1c39 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC Binary files differnew file mode 100644 index 0000000000..7b86185e67 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08 Binary files differnew file mode 100644 index 0000000000..a1d03856b5 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08 diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B Binary files differnew file mode 100644 index 0000000000..ba1145ca83 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137 Binary files differnew file mode 100644 index 0000000000..82339b3b1d --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137 diff --git a/browser/components/migration/tests/unit/Library/Safari/HistoryStrangeEntries.db b/browser/components/migration/tests/unit/Library/Safari/HistoryStrangeEntries.db Binary files differnew file mode 100644 index 0000000000..533daba3df --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/HistoryStrangeEntries.db diff --git a/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db b/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db Binary files differnew file mode 100644 index 0000000000..5a317c70e8 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db diff --git a/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data Binary files differnew file mode 100644 index 0000000000..b2d425eb4a --- /dev/null +++ b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data diff --git a/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State new file mode 100644 index 0000000000..bb03d6b9a1 --- /dev/null +++ b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State @@ -0,0 +1,22 @@ +{ + "profile" : { + "info_cache" : { + "Default" : { + "active_time" : 1430950755.65137, + "is_using_default_name" : true, + "is_ephemeral" : false, + "is_omitted_from_profile_list" : false, + "user_name" : "", + "background_apps" : false, + "is_using_default_avatar" : true, + "avatar_icon" : "chrome://theme/IDR_PROFILE_AVATAR_0", + "name" : "Person With No Data" + } + }, + "profiles_created" : 1, + "last_used" : "Default", + "last_active_profiles" : [ + "Default" + ] + } +} diff --git a/browser/components/migration/tests/unit/bookmarks.exported.html b/browser/components/migration/tests/unit/bookmarks.exported.html new file mode 100644 index 0000000000..5a9ec43325 --- /dev/null +++ b/browser/components/migration/tests/unit/bookmarks.exported.html @@ -0,0 +1,32 @@ +<!DOCTYPE NETSCAPE-Bookmark-file-1> +<!-- This is an automatically generated file. + It will be read and overwritten. + DO NOT EDIT! --> +<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> +<meta http-equiv="Content-Security-Policy" + content="default-src 'self'; script-src 'none'; img-src data: *; object-src 'none'"></meta> +<TITLE>Bookmarks</TITLE> +<H1>Bookmarks Menu</H1> + +<DL><p> + <DT><H3 ADD_DATE="1685118888" LAST_MODIFIED="1685118888">Mozilla Firefox</H3> + <DL><p> + <DT><A HREF="http://en-us.www.mozilla.com/en-US/firefox/help/" ADD_DATE="1685118888" LAST_MODIFIED="1685118888" ICON_URI="fake-favicon-uri:http://en-us.www.mozilla.com/en-US/firefox/help/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==">Help and Tutorials</A> + <DT><A HREF="http://en-us.www.mozilla.com/en-US/firefox/customize/" ADD_DATE="1685118888" LAST_MODIFIED="1685118888" ICON_URI="fake-favicon-uri:http://en-us.www.mozilla.com/en-US/firefox/customize/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==">Customize Firefox</A> + <DT><A HREF="http://en-us.www.mozilla.com/en-US/firefox/community/" ADD_DATE="1685118888" LAST_MODIFIED="1685118888" ICON_URI="fake-favicon-uri:http://en-us.www.mozilla.com/en-US/firefox/community/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==">Get Involved</A> + <DT><A HREF="http://en-us.www.mozilla.com/en-US/about/" ADD_DATE="1685118888" LAST_MODIFIED="1685118888" ICON_URI="fake-favicon-uri:http://en-us.www.mozilla.com/en-US/about/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==">About Us</A> + </DL><p> + <HR> <DT><H3 ADD_DATE="1177541020" LAST_MODIFIED="1177541050">test</H3> + <DL><p> + <DT><A HREF="http://test/post" ADD_DATE="1177375336" LAST_MODIFIED="1177375423" SHORTCUTURL="test" POST_DATA="hidden1%3Dbar&text1%3D%25s" LAST_CHARSET="ISO-8859-1">test post keyword</A> + </DL><p> + <DT><H3 ADD_DATE="1685118888" LAST_MODIFIED="1685118888" PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3> + <DL><p> + <DT><A HREF="http://en-us.www.mozilla.com/en-US/firefox/central/" ADD_DATE="1685118888" LAST_MODIFIED="1685118888" ICON_URI="fake-favicon-uri:http://en-us.www.mozilla.com/en-US/firefox/central/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==">Getting Started</A> + <DT><A HREF="http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/" ADD_DATE="1177541035" LAST_MODIFIED="1177541035">Latest Headlines</A> + </DL><p> + <DT><H3 ADD_DATE="1685118888" LAST_MODIFIED="1685118888" UNFILED_BOOKMARKS_FOLDER="true">Other Bookmarks</H3> + <DL><p> + <DT><A HREF="http://example.tld/" ADD_DATE="1685118888" LAST_MODIFIED="1685118888">Example.tld</A> + </DL><p> +</DL> diff --git a/browser/components/migration/tests/unit/bookmarks.exported.json b/browser/components/migration/tests/unit/bookmarks.exported.json new file mode 100644 index 0000000000..2a73f00b31 --- /dev/null +++ b/browser/components/migration/tests/unit/bookmarks.exported.json @@ -0,0 +1,194 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1685116351936000, + "lastModified": 1685372151518000, + "id": 1, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "menu", + "index": 0, + "dateAdded": 1685116351936000, + "lastModified": 1685116352325000, + "id": 2, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "jCs_9YrgXKq7", + "title": "Firefox Nightly Resources", + "index": 0, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 7, + "typeCode": 2, + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "xwdRLsUWYFwm", + "title": "Firefox Nightly blog", + "index": 0, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 8, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://blog.nightly.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://blog.nightly.mozilla.org/" + }, + { + "guid": "uhdiDrWjH0-n", + "title": "Mozilla Bug Tracker", + "index": 1, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 9, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://bugzilla.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://bugzilla.mozilla.org/", + "keyword": "bz", + "postData": null + }, + { + "guid": "zOK7d-gjJ5Vy", + "title": "Mozilla Developer Network", + "index": 2, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 10, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://developer.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://developer.mozilla.org/", + "keyword": "mdn", + "postData": null + }, + { + "guid": "7gcb4320A_y6", + "title": "Nightly Tester Tools", + "index": 3, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 11, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://addons.mozilla.org/firefox/addon/nightly-tester-tools/", + "type": "text/x-moz-place", + "uri": "https://addons.mozilla.org/firefox/addon/nightly-tester-tools/" + }, + { + "guid": "c4753lDvJwNE", + "title": "All your crashes", + "index": 4, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 12, + "typeCode": 1, + "iconUri": "fake-favicon-uri:about:crashes", + "type": "text/x-moz-place", + "uri": "about:crashes" + }, + { + "guid": "IyYGIH9VCs2t", + "title": "Planet Mozilla", + "index": 5, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 13, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://planet.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://planet.mozilla.org/" + } + ] + } + ] + }, + { + "guid": "toolbar_____", + "title": "toolbar", + "index": 1, + "dateAdded": 1685116351936000, + "lastModified": 1685372151518000, + "id": 3, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "5jN1vdzOEnHx", + "title": "Get Involved", + "index": 0, + "dateAdded": 1685116352413000, + "lastModified": 1685116352413000, + "id": 14, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://www.mozilla.org/contribute/?utm_medium=firefox-desktop&utm_source=bookmarks-toolbar&utm_campaign=new-users-nightly&utm_content=-global", + "type": "text/x-moz-place", + "uri": "https://www.mozilla.org/contribute/?utm_medium=firefox-desktop&utm_source=bookmarks-toolbar&utm_campaign=new-users-nightly&utm_content=-global" + }, + { + "guid": "5RsMT9sWsmIe", + "title": "Why More Psychiatrists Think Mindfulness Can Help Treat ADHD", + "index": 1, + "dateAdded": 1685372143048000, + "lastModified": 1685372143048000, + "id": 15, + "typeCode": 1, + "type": "text/x-moz-place", + "uri": "https://getpocket.com/explore/item/why-more-psychiatrists-think-mindfulness-can-help-treat-adhd?utm_source=pocket-newtab" + }, + { + "guid": "ejoNUqAfEMQL", + "title": "Your New Favorite Weeknight Recipe Is Meat-Free (and Easy, Too)", + "index": 2, + "dateAdded": 1685372148200000, + "lastModified": 1685372148200000, + "id": 16, + "typeCode": 1, + "type": "text/x-moz-place", + "uri": "https://getpocket.com/collections/your-new-favorite-weeknight-recipe-is-meat-free-and-easy-too?utm_source=pocket-newtab" + }, + { + "guid": "O5QCiQ1zrqHY", + "title": "8 Natural Ways to Repel Insects Without Bug Spray", + "index": 3, + "dateAdded": 1685372151518000, + "lastModified": 1685372151518000, + "id": 17, + "typeCode": 1, + "type": "text/x-moz-place", + "uri": "https://getpocket.com/explore/item/8-natural-ways-to-repel-insects-without-bug-spray?utm_source=pocket-newtab" + } + ] + }, + { + "guid": "unfiled_____", + "title": "unfiled", + "index": 3, + "dateAdded": 1685116351936000, + "lastModified": 1685116352272000, + "id": 5, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder" + }, + { + "guid": "mobile______", + "title": "mobile", + "index": 4, + "dateAdded": 1685116351968000, + "lastModified": 1685116352272000, + "id": 6, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "mobileFolder" + } + ] +} diff --git a/browser/components/migration/tests/unit/bookmarks.invalid.html b/browser/components/migration/tests/unit/bookmarks.invalid.html new file mode 100644 index 0000000000..900ec52e1d --- /dev/null +++ b/browser/components/migration/tests/unit/bookmarks.invalid.html @@ -0,0 +1 @@ +This shouldn't cause anything to be imported. diff --git a/browser/components/migration/tests/unit/head_migration.js b/browser/components/migration/tests/unit/head_migration.js new file mode 100644 index 0000000000..9900f34232 --- /dev/null +++ b/browser/components/migration/tests/unit/head_migration.js @@ -0,0 +1,260 @@ +"use strict"; + +var { MigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MigrationUtils.sys.mjs" +); +var { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); +var { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" +); +var { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +var { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +var { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +// Initialize profile. +var gProfD = do_get_profile(); + +var { getAppInfo, newAppInfo, updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo(); + +/** + * Migrates the requested resource and waits for the migration to be complete. + * + * @param {MigratorBase} migrator + * The migrator being used to migrate the data. + * @param {number} resourceType + * This is a bitfield with bits from nsIBrowserProfileMigrator flipped to indicate what + * resources should be migrated. + * @param {object|string|null} [aProfile=null] + * The profile to be migrated. If set to null, the default profile will be + * migrated. + * @param {boolean} succeeds + * True if this migration is expected to succeed. + * @returns {Promise<Array<string[]>>} + * An array of the results from each nsIObserver topics being observed to + * verify if the migration succeeded or failed. Those results are 2-element + * arrays of [subject, data]. + */ +async function promiseMigration( + migrator, + resourceType, + aProfile = null, + succeeds = null +) { + // Ensure resource migration is available. + let availableSources = await migrator.getMigrateData(aProfile); + Assert.ok( + (availableSources & resourceType) > 0, + "Resource supported by migrator" + ); + let promises = [TestUtils.topicObserved("Migration:Ended")]; + + if (succeeds !== null) { + // Check that the specific resource type succeeded + promises.push( + TestUtils.topicObserved( + succeeds ? "Migration:ItemAfterMigrate" : "Migration:ItemError", + (_, data) => data == resourceType + ) + ); + } + + // Start the migration. + migrator.migrate(resourceType, null, aProfile); + + return Promise.all(promises); +} +/** + * Function that returns a favicon url for a given page url + * + * @param {string} uri + * The Bookmark URI + * @returns {string} faviconURI + * The Favicon URI + */ +async function getFaviconForPageURI(uri) { + let faviconURI = await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage(uri, favURI => { + resolve(favURI); + }); + }); + return faviconURI; +} + +/** + * Takes an array of page URIs and checks that the favicon was imported for each page URI + * + * @param {Array} pageURIs An array of page URIs + */ +async function assertFavicons(pageURIs) { + for (let uri of pageURIs) { + let faviconURI = await getFaviconForPageURI(uri); + Assert.ok(faviconURI, `Got favicon for ${uri.spec}`); + } +} + +/** + * Replaces a directory service entry with a given nsIFile. + * + * @param {string} key + * The nsIDirectoryService directory key to register a fake path for. + * For example: "AppData", "ULibDir". + * @param {nsIFile} file + * The nsIFile to map the key to. Note that this nsIFile should represent + * a directory and not an individual file. + * @see nsDirectoryServiceDefs.h for the list of directories that can be + * overridden. + */ +function registerFakePath(key, file) { + let dirsvc = Services.dirsvc.QueryInterface(Ci.nsIProperties); + let originalFile; + try { + // If a file is already provided save it and undefine, otherwise set will + // throw for persistent entries (ones that are cached). + originalFile = dirsvc.get(key, Ci.nsIFile); + dirsvc.undefine(key); + } catch (e) { + // dirsvc.get will throw if nothing provides for the key and dirsvc.undefine + // will throw if it's not a persistent entry, in either case we don't want + // to set the original file in cleanup. + originalFile = undefined; + } + + dirsvc.set(key, file); + registerCleanupFunction(() => { + dirsvc.undefine(key); + if (originalFile) { + dirsvc.set(key, originalFile); + } + }); +} + +function getRootPath() { + let dirKey; + if (AppConstants.platform == "win") { + dirKey = "LocalAppData"; + } else if (AppConstants.platform == "macosx") { + dirKey = "ULibDir"; + } else { + dirKey = "Home"; + } + return Services.dirsvc.get(dirKey, Ci.nsIFile).path; +} + +/** + * Returns a PRTime value for the current date minus daysAgo number + * of days. + * + * @param {number} daysAgo + * How many days in the past from now the returned date should be. + * @returns {number} + */ +function PRTimeDaysAgo(daysAgo) { + return PlacesUtils.toPRTime(Date.now() - daysAgo * 24 * 60 * 60 * 1000); +} + +/** + * Returns a Date value for the current date minus daysAgo number + * of days. + * + * @param {number} daysAgo + * How many days in the past from now the returned date should be. + * @returns {Date} + */ +function dateDaysAgo(daysAgo) { + return new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); +} + +/** + * Constructs and returns a data structure consistent with the Chrome + * browsers bookmarks storage. This data structure can then be serialized + * to JSON and written to disk to simulate a Chrome browser's bookmarks + * database. + * + * @param {number} [totalBookmarks=100] + * How many bookmarks to create. + * @returns {object} + */ +function createChromeBookmarkStructure(totalBookmarks = 100) { + let bookmarksData = { + roots: { + bookmark_bar: { children: [] }, + other: { children: [] }, + synced: { children: [] }, + }, + }; + const MAX_BMS = totalBookmarks; + let barKids = bookmarksData.roots.bookmark_bar.children; + let menuKids = bookmarksData.roots.other.children; + let syncedKids = bookmarksData.roots.synced.children; + let currentMenuKids = menuKids; + let currentBarKids = barKids; + let currentSyncedKids = syncedKids; + for (let i = 0; i < MAX_BMS; i++) { + currentBarKids.push({ + url: "https://www.chrome-bookmark-bar-bookmark" + i + ".com", + name: "bookmark " + i, + type: "url", + }); + currentMenuKids.push({ + url: "https://www.chrome-menu-bookmark" + i + ".com", + name: "bookmark for menu " + i, + type: "url", + }); + currentSyncedKids.push({ + url: "https://www.chrome-synced-bookmark" + i + ".com", + name: "bookmark for synced " + i, + type: "url", + }); + if (i % 20 == 19) { + let nextFolder = { + name: "toolbar folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentBarKids.push(nextFolder); + currentBarKids = nextFolder.children; + + nextFolder = { + name: "menu folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentMenuKids.push(nextFolder); + currentMenuKids = nextFolder.children; + + nextFolder = { + name: "synced folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentSyncedKids.push(nextFolder); + currentSyncedKids = nextFolder.children; + } + } + return bookmarksData; +} diff --git a/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp new file mode 100644 index 0000000000..dea79a9289 --- /dev/null +++ b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Insert URLs into Internet Explorer (IE) history so we can test importing + * them. + * + * See API docs at + * https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms774949(v%3dvs.85) + */ + +#include <urlhist.h> // IUrlHistoryStg +#include <shlguid.h> // SID_SUrlHistory + +int main(int argc, char** argv) { + HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(hr)) { + CoUninitialize(); + return -1; + } + IUrlHistoryStg* ieHist; + + hr = + ::CoCreateInstance(CLSID_CUrlHistory, nullptr, CLSCTX_INPROC_SERVER, + IID_IUrlHistoryStg, reinterpret_cast<void**>(&ieHist)); + if (FAILED(hr)) return -2; + + hr = ieHist->AddUrl(L"http://www.mozilla.org/1", L"Mozilla HTTP Test", 0); + if (FAILED(hr)) return -3; + + hr = + ieHist->AddUrl(L"https://www.mozilla.org/2", L"Mozilla HTTPS Test 🦊", 0); + if (FAILED(hr)) return -4; + + CoUninitialize(); + + return 0; +} diff --git a/browser/components/migration/tests/unit/insertIEHistory/moz.build b/browser/components/migration/tests/unit/insertIEHistory/moz.build new file mode 100644 index 0000000000..61ca96d48a --- /dev/null +++ b/browser/components/migration/tests/unit/insertIEHistory/moz.build @@ -0,0 +1,19 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +FINAL_TARGET = "_tests/xpcshell/browser/components/migration/tests/unit" + +Program("InsertIEHistory") +OS_LIBS += [ + "ole32", + "uuid", +] +SOURCES += [ + "InsertIEHistory.cpp", +] + +NO_PGO = True +DisableStlWrapping() diff --git a/browser/components/migration/tests/unit/test_360seMigrationUtils.js b/browser/components/migration/tests/unit/test_360seMigrationUtils.js new file mode 100644 index 0000000000..3a882b516d --- /dev/null +++ b/browser/components/migration/tests/unit/test_360seMigrationUtils.js @@ -0,0 +1,164 @@ +"use strict"; + +const { Qihoo360seMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/360seMigrationUtils.sys.mjs" +); + +const parentPath = do_get_file("AppData/Roaming/360se6/User Data").path; +const loggedInPath = "0f3ab103a522f4463ecacc36d34eb996"; +const loggedInBackup = PathUtils.join( + parentPath, + "Default", + loggedInPath, + "DailyBackup", + "360default_ori_2021_12_02.favdb" +); +const loggedOutBackup = PathUtils.join( + parentPath, + "Default", + "DailyBackup", + "360default_ori_2021_12_02.favdb" +); + +function getSqlitePath(profileId) { + return PathUtils.join(parentPath, profileId, loggedInPath, "360sefav.dat"); +} + +add_task(async function test_360se10_logged_in() { + const sqlitePath = getSqlitePath("Default"); + await IOUtils.setModificationTime(sqlitePath); + await IOUtils.copy( + PathUtils.join(parentPath, "Default", "360Bookmarks"), + PathUtils.join(parentPath, "Default", loggedInPath) + ); + await IOUtils.copy(loggedOutBackup, loggedInBackup); + + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"), + localState: { + sync_login_info: { + filepath: loggedInPath, + }, + }, + }); + Assert.ok( + alternativeBookmarks.resource && alternativeBookmarks.resource.exists, + "Should return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + undefined, + "Should not return any path to plain text bookmarks." + ); +}); + +add_task(async function test_360se10_logged_in_outdated_sqlite() { + const sqlitePath = getSqlitePath("Default"); + await IOUtils.setModificationTime( + sqlitePath, + new Date("2020-08-18").valueOf() + ); + + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"), + localState: { + sync_login_info: { + filepath: loggedInPath, + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the outdated legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + loggedInBackup, + "Should return path to the most recent plain text bookmarks backup." + ); + + await IOUtils.setModificationTime(sqlitePath); +}); + +add_task(async function test_360se10_logged_out() { + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"), + localState: { + sync_login_info: { + filepath: "", + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + loggedOutBackup, + "Should return path to the most recent plain text bookmarks backup." + ); +}); + +add_task(async function test_360se9_logged_in_outdated_sqlite() { + const sqlitePath = getSqlitePath("Default4SE9Test"); + await IOUtils.setModificationTime( + sqlitePath, + new Date("2020-08-18").valueOf() + ); + + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"), + localState: { + sync_login_info: { + filepath: loggedInPath, + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + PathUtils.join( + parentPath, + "Default4SE9Test", + loggedInPath, + "DailyBackup", + "360sefav_2020_08_28.favdb" + ), + "Should return path to the most recent plain text bookmarks backup." + ); + + await IOUtils.setModificationTime(sqlitePath); +}); + +add_task(async function test_360se9_logged_out() { + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"), + localState: { + sync_login_info: { + filepath: "", + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"), + "Should return path to the plain text canonical bookmarks." + ); +}); diff --git a/browser/components/migration/tests/unit/test_360se_bookmarks.js b/browser/components/migration/tests/unit/test_360se_bookmarks.js new file mode 100644 index 0000000000..e4f42d1880 --- /dev/null +++ b/browser/components/migration/tests/unit/test_360se_bookmarks.js @@ -0,0 +1,62 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +add_task(async function () { + registerFakePath("AppData", do_get_file("AppData/Roaming/")); + + let migrator = await MigrationUtils.getMigrator("chromium-360se"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + let importedToBookmarksToolbar = false; + let itemsSeen = { bookmarks: 0, folders: 0 }; + + let listener = events => { + for (let event of events) { + itemsSeen[ + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER + ? "folders" + : "bookmarks" + ]++; + if (event.parentGuid == PlacesUtils.bookmarks.toolbarGuid) { + importedToBookmarksToolbar = true; + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS, { + id: "Default", + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(importedToBookmarksToolbar, "Bookmarks imported in the toolbar"); + Assert.equal(itemsSeen.bookmarks, 8, "Should import all bookmarks."); + Assert.equal(itemsSeen.folders, 2, "Should import all folders."); + // Check that the telemetry matches: + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemsSeen.bookmarks + itemsSeen.folders, + "Telemetry reporting correct." + ); + Assert.ok(observerNotified, "The observer should be notified upon migration"); +}); diff --git a/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js b/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js new file mode 100644 index 0000000000..ec10097e76 --- /dev/null +++ b/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { BookmarksFileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); + +const { MigrationWizardConstants } = ChromeUtils.importESModule( + "chrome://browser/content/migration/migration-wizard-constants.mjs" +); + +/** + * Tests that the BookmarksFileMigrator properly subclasses FileMigratorBase + * and delegates to BookmarkHTMLUtils or BookmarkJSONUtils. + * + * Normally, we'd override the BookmarkHTMLUtils and BookmarkJSONUtils methods + * in our test here so that we just ensure that they're called with the + * right arguments, rather than testing their behaviour. Unfortunately, both + * BookmarkHTMLUtils and BookmarkJSONUtils are frozen with Object.freeze, which + * prevents sinon from stubbing out any of their methods. Rather than unfreezing + * those objects just for testing, we test the whole flow end-to-end, including + * the import to Places. + */ + +add_setup(() => { + Services.prefs.setBoolPref("browser.migrate.bookmarks-file.enabled", true); + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + Services.prefs.clearUserPref("browser.migrate.bookmarks-file.enabled"); + }); +}); + +/** + * First check that the BookmarksFileMigrator implements the required parts + * of the parent class. + */ +add_task(async function test_BookmarksFileMigrator_members() { + let migrator = new BookmarksFileMigrator(); + Assert.ok( + migrator.constructor.key, + `BookmarksFileMigrator implements static getter 'key'` + ); + + Assert.ok( + migrator.constructor.displayNameL10nID, + `BookmarksFileMigrator implements static getter 'displayNameL10nID'` + ); + + Assert.ok( + migrator.constructor.brandImage, + `BookmarksFileMigrator implements static getter 'brandImage'` + ); + + Assert.ok( + migrator.progressHeaderL10nID, + `BookmarksFileMigrator implements getter 'progressHeaderL10nID'` + ); + + Assert.ok( + migrator.successHeaderL10nID, + `BookmarksFileMigrator implements getter 'successHeaderL10nID'` + ); + + Assert.ok( + await migrator.getFilePickerConfig(), + `BookmarksFileMigrator implements method 'getFilePickerConfig'` + ); + + Assert.ok( + migrator.displayedResourceTypes, + `BookmarksFileMigrator implements getter 'displayedResourceTypes'` + ); + + Assert.ok(migrator.enabled, `BookmarksFileMigrator is enabled`); +}); + +add_task(async function test_BookmarksFileMigrator_HTML() { + let migrator = new BookmarksFileMigrator(); + const EXPECTED_SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE]: "8 bookmarks", + }; + + const BOOKMARKS_PATH = PathUtils.join( + do_get_cwd().path, + "bookmarks.exported.html" + ); + + let result = await migrator.migrate(BOOKMARKS_PATH); + + Assert.deepEqual( + result, + EXPECTED_SUCCESS_STATE, + "Returned the expected success state." + ); +}); + +add_task(async function test_BookmarksFileMigrator_JSON() { + let migrator = new BookmarksFileMigrator(); + + const EXPECTED_SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE]: "10 bookmarks", + }; + + const BOOKMARKS_PATH = PathUtils.join( + do_get_cwd().path, + "bookmarks.exported.json" + ); + + let result = await migrator.migrate(BOOKMARKS_PATH); + + Assert.deepEqual( + result, + EXPECTED_SUCCESS_STATE, + "Returned the expected success state." + ); +}); + +add_task(async function test_BookmarksFileMigrator_invalid() { + let migrator = new BookmarksFileMigrator(); + + const INVALID_FILE_PATH = PathUtils.join( + do_get_cwd().path, + "bookmarks.invalid.html" + ); + + await Assert.rejects( + migrator.migrate(INVALID_FILE_PATH), + /Pick another file/ + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js new file mode 100644 index 0000000000..32a8d1b4bc --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js @@ -0,0 +1,87 @@ +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +// Setup chrome user data path for all platforms. +ChromeMigrationUtils.getDataPath = () => { + return Promise.resolve( + do_get_file("Library/Application Support/Google/Chrome/").path + ); +}; + +add_task(async function test_getExtensionList_function() { + let extensionList = await ChromeMigrationUtils.getExtensionList("Default"); + Assert.equal( + extensionList.length, + 2, + "There should be 2 extensions installed." + ); + Assert.deepEqual( + extensionList.find(extension => extension.id == "fake-extension-1"), + { + id: "fake-extension-1", + name: "Fake Extension 1", + description: "It is the description of fake extension 1.", + }, + "First extension should match expectations." + ); + Assert.deepEqual( + extensionList.find(extension => extension.id == "fake-extension-2"), + { + id: "fake-extension-2", + name: "Fake Extension 2", + // There is no description in the `manifest.json` file of this extension. + description: null, + }, + "Second extension should match expectations." + ); +}); + +add_task(async function test_getExtensionInformation_function() { + let extension = await ChromeMigrationUtils.getExtensionInformation( + "fake-extension-1", + "Default" + ); + Assert.deepEqual( + extension, + { + id: "fake-extension-1", + name: "Fake Extension 1", + description: "It is the description of fake extension 1.", + }, + "Should get the extension information correctly." + ); +}); + +add_task(async function test_getLocaleString_function() { + let name = await ChromeMigrationUtils._getLocaleString( + "__MSG_name__", + "en_US", + "fake-extension-1", + "Default" + ); + Assert.deepEqual( + name, + "Fake Extension 1", + "The value of __MSG_name__ locale key is Fake Extension 1." + ); +}); + +add_task(async function test_isExtensionInstalled_function() { + let isInstalled = await ChromeMigrationUtils.isExtensionInstalled( + "fake-extension-1", + "Default" + ); + Assert.ok(isInstalled, "The fake-extension-1 extension should be installed."); +}); + +add_task(async function test_getLastUsedProfileId_function() { + let profileId = await ChromeMigrationUtils.getLastUsedProfileId(); + Assert.equal( + profileId, + "Default", + "The last used profile ID should be Default." + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js new file mode 100644 index 0000000000..ca75595ea9 --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js @@ -0,0 +1,141 @@ +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const SUB_DIRECTORIES = { + win: { + Chrome: ["Google", "Chrome", "User Data"], + Chromium: ["Chromium", "User Data"], + Canary: ["Google", "Chrome SxS", "User Data"], + }, + macosx: { + Chrome: ["Application Support", "Google", "Chrome"], + Chromium: ["Application Support", "Chromium"], + Canary: ["Application Support", "Google", "Chrome Canary"], + }, + linux: { + Chrome: [".config", "google-chrome"], + Chromium: [".config", "chromium"], + Canary: [], + }, +}; + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + + registerFakePath(pathId, do_get_file("chromefiles/", true)); +}); + +add_task(async function test_getDataPath_function() { + let projects = ["Chrome", "Chromium", "Canary"]; + let rootPath = getRootPath(); + + for (let project of projects) { + let subfolders = SUB_DIRECTORIES[AppConstants.platform][project]; + + await IOUtils.makeDirectory(PathUtils.join(rootPath, ...subfolders), { + createAncestor: true, + ignoreExisting: true, + }); + } + + let chromeUserDataPath = await ChromeMigrationUtils.getDataPath("Chrome"); + let chromiumUserDataPath = await ChromeMigrationUtils.getDataPath("Chromium"); + let canaryUserDataPath = await ChromeMigrationUtils.getDataPath("Canary"); + if (AppConstants.platform == "win") { + Assert.equal( + chromeUserDataPath, + PathUtils.join(getRootPath(), "Google", "Chrome", "User Data"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), "Chromium", "User Data"), + "Should get the path of Chromium data directory." + ); + Assert.equal( + canaryUserDataPath, + PathUtils.join(getRootPath(), "Google", "Chrome SxS", "User Data"), + "Should get the path of Canary data directory." + ); + } else if (AppConstants.platform == "macosx") { + Assert.equal( + chromeUserDataPath, + PathUtils.join(getRootPath(), "Application Support", "Google", "Chrome"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), "Application Support", "Chromium"), + "Should get the path of Chromium data directory." + ); + Assert.equal( + canaryUserDataPath, + PathUtils.join( + getRootPath(), + "Application Support", + "Google", + "Chrome Canary" + ), + "Should get the path of Canary data directory." + ); + } else { + Assert.equal( + chromeUserDataPath, + PathUtils.join(getRootPath(), ".config", "google-chrome"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), ".config", "chromium"), + "Should get the path of Chromium data directory." + ); + Assert.equal(canaryUserDataPath, null, "Should get null for Canary."); + } +}); + +add_task(async function test_getExtensionPath_function() { + let extensionPath = await ChromeMigrationUtils.getExtensionPath("Default"); + let expectedPath; + if (AppConstants.platform == "win") { + expectedPath = PathUtils.join( + getRootPath(), + "Google", + "Chrome", + "User Data", + "Default", + "Extensions" + ); + } else if (AppConstants.platform == "macosx") { + expectedPath = PathUtils.join( + getRootPath(), + "Application Support", + "Google", + "Chrome", + "Default", + "Extensions" + ); + } else { + expectedPath = PathUtils.join( + getRootPath(), + ".config", + "google-chrome", + "Default", + "Extensions" + ); + } + Assert.equal( + extensionPath, + expectedPath, + "Should get the path of extensions directory." + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js new file mode 100644 index 0000000000..5011991536 --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const SUB_DIRECTORIES = { + linux: { + Chromium: [".config", "chromium"], + SnapChromium: ["snap", "chromium", "common", "chromium"], + }, +}; + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + + registerFakePath(pathId, do_get_file("chromefiles/", true)); +}); + +add_task(async function test_getDataPath_function() { + let rootPath = getRootPath(); + let chromiumSubFolders = SUB_DIRECTORIES[AppConstants.platform].Chromium; + // must remove normal chromium path + await IOUtils.remove(PathUtils.join(rootPath, ...chromiumSubFolders), { + ignoreAbsent: true, + }); + + let snapChromiumSubFolders = + SUB_DIRECTORIES[AppConstants.platform].SnapChromium; + // must create snap chromium path + await IOUtils.makeDirectory( + PathUtils.join(rootPath, ...snapChromiumSubFolders), + { + createAncestor: true, + ignoreExisting: true, + } + ); + + let chromiumUserDataPath = await ChromeMigrationUtils.getDataPath("Chromium"); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), ...snapChromiumSubFolders), + "Should get the path of Snap Chromium data directory." + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_bookmarks.js b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js new file mode 100644 index 0000000000..d115cda412 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js @@ -0,0 +1,205 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +const { PlacesUIUtils } = ChromeUtils.importESModule( + "resource:///modules/PlacesUIUtils.sys.mjs" +); + +let rootDir = do_get_file("chromefiles/", true); + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +add_task(async function setup_initialBookmarks() { + let bookmarks = []; + for (let i = 0; i < PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + 1; i++) { + bookmarks.push({ url: "https://example.com/" + i, title: "" + i }); + } + + // Ensure we have enough items in both the menu and toolbar to trip creating a "from" folder. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: bookmarks, + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: bookmarks, + }); +}); + +async function testBookmarks(migratorKey, subDirs) { + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + + // Pretend this is the default profile + while (subDirs.length) { + target.append(subDirs.shift()); + } + + let localStatePath = PathUtils.join(target.path, "Local State"); + await IOUtils.writeJSON(localStatePath, []); + + target.append("Default"); + + await IOUtils.makeDirectory(target.path, { + createAncestor: true, + ignoreExisting: true, + }); + + // Copy Favicons database into Default profile + const sourcePath = do_get_file( + "AppData/Local/Google/Chrome/User Data/Default/Favicons" + ).path; + await IOUtils.copy(sourcePath, target.path); + + // Get page url for each favicon + let faviconURIs = await MigrationUtils.getRowsFromDBWithoutLocks( + sourcePath, + "Chrome Bookmark Favicons", + `select page_url from icon_mapping` + ); + + target.append("Bookmarks"); + await IOUtils.remove(target.path, { ignoreAbsent: true }); + + let bookmarksData = createChromeBookmarkStructure(); + await IOUtils.writeJSON(target.path, bookmarksData); + + let migrator = await MigrationUtils.getMigrator(migratorKey); + Assert.ok(await migrator.hasPermissions(), "Has permissions"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + let itemsSeen = { bookmarks: 0, folders: 0 }; + let listener = events => { + for (let event of events) { + itemsSeen[ + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER + ? "folders" + : "bookmarks" + ]++; + } + }; + + PlacesUtils.observers.addListener(["bookmark-added"], listener); + const PROFILE = { + id: "Default", + name: "Default", + }; + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + const initialToolbarCount = await getFolderItemCount( + PlacesUtils.bookmarks.toolbarGuid + ); + const initialUnfiledCount = await getFolderItemCount( + PlacesUtils.bookmarks.unfiledGuid + ); + const initialmenuCount = await getFolderItemCount( + PlacesUtils.bookmarks.menuGuid + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.BOOKMARKS, + PROFILE + ); + const postToolbarCount = await getFolderItemCount( + PlacesUtils.bookmarks.toolbarGuid + ); + const postUnfiledCount = await getFolderItemCount( + PlacesUtils.bookmarks.unfiledGuid + ); + const postmenuCount = await getFolderItemCount( + PlacesUtils.bookmarks.menuGuid + ); + + Assert.equal( + postUnfiledCount - initialUnfiledCount, + 210, + "Should have seen 210 items in unsorted bookmarks" + ); + Assert.equal( + postToolbarCount - initialToolbarCount, + 105, + "Should have seen 105 items in toolbar" + ); + Assert.equal( + postmenuCount - initialmenuCount, + 0, + "Should have seen 0 items in menu toolbar" + ); + + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + Assert.equal(itemsSeen.bookmarks, 300, "Should have seen 300 bookmarks."); + Assert.equal(itemsSeen.folders, 15, "Should have seen 15 folders."); + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemsSeen.bookmarks + itemsSeen.folders, + "Telemetry reporting correct." + ); + Assert.ok(observerNotified, "The observer should be notified upon migration"); + let pageUrls = Array.from(faviconURIs, f => + Services.io.newURI(f.getResultByName("page_url")) + ); + await assertFavicons(pageUrls); +} + +add_task(async function test_Chrome() { + // Expire all favicons before the test to make sure favicons are imported + PlacesUtils.favicons.expireAllFavicons(); + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + await testBookmarks("chrome", subDirs); +}); + +add_task(async function test_ChromiumEdge() { + PlacesUtils.favicons.expireAllFavicons(); + if (AppConstants.platform == "linux") { + // Edge isn't available on Linux. + return; + } + let subDirs = + AppConstants.platform == "macosx" + ? ["Microsoft Edge"] + : ["Microsoft", "Edge"]; + await testBookmarks("chromium-edge", subDirs); +}); + +async function getFolderItemCount(guid) { + let results = await PlacesUtils.promiseBookmarksTree(guid); + + return results.itemsCount; +} diff --git a/browser/components/migration/tests/unit/test_Chrome_corrupt_history.js b/browser/components/migration/tests/unit/test_Chrome_corrupt_history.js new file mode 100644 index 0000000000..31541f9fdb --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_corrupt_history.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let rootDir = do_get_file("chromefiles/", true); + +const SOURCE_PROFILE_DIR = "Library/Application Support/Google/Chrome/Default/"; +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +add_setup(async function setup_fake_paths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); + + let file = do_get_file(`${SOURCE_PROFILE_DIR}HistoryCorrupt`); + file.copyTo(file.parent, "History"); + + registerCleanupFunction(() => { + let historyFile = do_get_file(`${SOURCE_PROFILE_DIR}History`, true); + try { + historyFile.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } + }); + + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + // Pretend this is the default profile + subDirs.push("Default"); + while (subDirs.length) { + target.append(subDirs.shift()); + } + + await IOUtils.makeDirectory(target.path, { + createAncestor: true, + ignoreExisting: true, + }); + + target.append("Bookmarks"); + await IOUtils.remove(target.path, { ignoreAbsent: true }); + + let bookmarksData = createChromeBookmarkStructure(); + await IOUtils.writeJSON(target.path, bookmarksData); +}); + +add_task(async function test_corrupt_history() { + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok(await migrator.isSourceAvailable()); + + let data = await migrator.getMigrateData(PROFILE); + Assert.ok( + data & MigrationUtils.resourceTypes.BOOKMARKS, + "Bookmarks resource available." + ); + Assert.ok( + !(data & MigrationUtils.resourceTypes.HISTORY), + "Corrupt history resource unavailable." + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_credit_cards.js b/browser/components/migration/tests/unit/test_Chrome_credit_cards.js new file mode 100644 index 0000000000..5c4d3517d2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_credit_cards.js @@ -0,0 +1,239 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* global structuredClone */ + +const PROFILE = { + id: "Default", + name: "Default", +}; + +const PAYMENT_METHODS = [ + { + name_on_card: "Name Name", + card_number: "4532962432748929", // Visa + expiration_month: 3, + expiration_year: 2027, + }, + { + name_on_card: "Name Name Name", + card_number: "5359908373796416", // Mastercard + expiration_month: 5, + expiration_year: 2028, + }, + { + name_on_card: "Name", + card_number: "346624461807588", // AMEX + expiration_month: 4, + expiration_year: 2026, + }, +]; + +let OSKeyStoreTestUtils; +add_setup(async function os_key_store_setup() { + ({ OSKeyStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/OSKeyStoreTestUtils.sys.mjs" + )); + OSKeyStoreTestUtils.setup(); + registerCleanupFunction(async function cleanup() { + await OSKeyStoreTestUtils.cleanup(); + }); +}); + +let rootDir = do_get_file("chromefiles/", true); + +function checkCardsAreEqual(importedCard, testCard, id) { + const CC_NUMBER_RE = /^(\*+)(.{4})$/; + + Assert.equal( + importedCard["cc-name"], + testCard.name_on_card, + "The two logins ID " + id + " have the same name on card" + ); + + let matches = CC_NUMBER_RE.exec(importedCard["cc-number"]); + Assert.notEqual(matches, null); + Assert.equal(importedCard["cc-number"].length, testCard.card_number.length); + Assert.equal(testCard.card_number.endsWith(matches[2]), true); + Assert.notEqual(importedCard["cc-number-encrypted"], ""); + + Assert.equal( + importedCard["cc-exp-month"], + testCard.expiration_month, + "The two logins ID " + id + " have the same expiration month" + ); + Assert.equal( + importedCard["cc-exp-year"], + testCard.expiration_year, + "The two logins ID " + id + " have the same expiration year" + ); +} + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +add_task(async function test_credit_cards() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo_check_true( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + let loginCrypto; + let profilePathSegments; + + let mockMacOSKeychain = { + passphrase: "bW96aWxsYWZpcmVmb3g=", + serviceName: "TESTING Chrome Safe Storage", + accountName: "TESTING Chrome", + }; + if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeMacOSLoginCrypto( + mockMacOSKeychain.serviceName, + mockMacOSKeychain.accountName, + mockMacOSKeychain.passphrase + ); + profilePathSegments = [ + "Application Support", + "Google", + "Chrome", + "Default", + ]; + } else if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeWindowsLoginCrypto("Chrome"); + profilePathSegments = ["Google", "Chrome", "User Data", "Default"]; + } else { + throw new Error("Not implemented"); + } + + let target = rootDir.clone(); + let defaultFolderPath = PathUtils.join(target.path, ...profilePathSegments); + let webDataPath = PathUtils.join(defaultFolderPath, "Web Data"); + let localStatePath = defaultFolderPath.replace("Default", ""); + + await IOUtils.makeDirectory(defaultFolderPath, { + createAncestor: true, + ignoreExisting: true, + }); + + // Copy Web Data database into Default profile + const sourcePathWebData = do_get_file( + "AppData/Local/Google/Chrome/User Data/Default/Web Data" + ).path; + await IOUtils.copy(sourcePathWebData, webDataPath); + + const sourcePathLocalState = do_get_file( + "AppData/Local/Google/Chrome/User Data/Local State" + ).path; + await IOUtils.copy(sourcePathLocalState, localStatePath); + + let dbConn = await Sqlite.openConnection({ path: webDataPath }); + + for (let card of PAYMENT_METHODS) { + let encryptedCardNumber = await loginCrypto.encryptData(card.card_number); + let cardNumberEncryptedValue = new Uint8Array( + loginCrypto.stringToArray(encryptedCardNumber) + ); + + let cardCopy = structuredClone(card); + + cardCopy.card_number_encrypted = cardNumberEncryptedValue; + delete cardCopy.card_number; + + await dbConn.execute( + `INSERT INTO credit_cards + (name_on_card, card_number_encrypted, expiration_month, expiration_year) + VALUES (:name_on_card, :card_number_encrypted, :expiration_month, :expiration_year) + `, + cardCopy + ); + } + + await dbConn.close(); + + let migrator = await MigrationUtils.getMigrator("chrome"); + if (AppConstants.platform == "macosx") { + Object.assign(migrator, { + _keychainServiceName: mockMacOSKeychain.serviceName, + _keychainAccountName: mockMacOSKeychain.accountName, + _keychainMockPassphrase: mockMacOSKeychain.passphrase, + }); + } + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + Services.prefs.setBoolPref( + "browser.migrate.chrome.payment_methods.enabled", + false + ); + Assert.ok( + !( + (await migrator.getMigrateData(PROFILE)) & + MigrationUtils.resourceTypes.PAYMENT_METHODS + ), + "Should be able to disable migrating payment methods" + ); + // Clear the cached resources now so that a re-check for payment methods + // will look again. + delete migrator._resourcesByProfile[PROFILE.id]; + + Services.prefs.setBoolPref( + "browser.migrate.chrome.payment_methods.enabled", + true + ); + + Assert.ok( + (await migrator.getMigrateData(PROFILE)) & + MigrationUtils.resourceTypes.PAYMENT_METHODS, + "Should be able to enable migrating payment methods" + ); + + let { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + await formAutofillStorage.initialize(); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PAYMENT_METHODS, + PROFILE + ); + + let cards = await formAutofillStorage.creditCards.getAll(); + + Assert.equal( + cards.length, + PAYMENT_METHODS.length, + "Check there are still the same number of credit cards after re-importing the data" + ); + Assert.equal( + cards.length, + MigrationUtils._importQuantities.cards, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < PAYMENT_METHODS.length; i++) { + checkCardsAreEqual(cards[i], PAYMENT_METHODS[i], i + 1); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_extensions.js b/browser/components/migration/tests/unit/test_Chrome_extensions.js new file mode 100644 index 0000000000..7aa7a94194 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_extensions.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AMBrowserExtensionsImport, AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); +const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const TEST_SERVER = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +const IMPORTED_ADDON_1 = { + name: "A Firefox extension", + version: "1.0", + browser_specific_settings: { gecko: { id: "some-ff@extension" } }, +}; + +// Setup chrome user data path for all platforms. +ChromeMigrationUtils.getDataPath = () => { + return Promise.resolve( + do_get_file("Library/Application Support/Google/Chrome/").path + ); +}; + +const mockAddonRepository = ({ addons = [] } = {}) => { + return { + async getMappedAddons(browserID, extensionIDs) { + Assert.equal(browserID, "chrome", "expected browser ID"); + // Sort extension IDs to have a predictable order. + extensionIDs.sort(); + Assert.deepEqual( + extensionIDs, + ["fake-extension-1", "fake-extension-2"], + "expected an array of 2 extension IDs" + ); + return Promise.resolve({ + addons, + matchedIDs: [], + unmatchedIDs: [], + }); + }, + }; +}; + +add_setup(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + // Create a Firefox XPI that we can use during the import. + const xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: IMPORTED_ADDON_1, + }); + TEST_SERVER.registerFile(`/addons/addon-1.xpi`, xpi); + + // Override the `AddonRepository` in `AMBrowserExtensionsImport` with our own + // mock so that we control the add-ons that are mapped to the list of Chrome + // extension IDs. + const addons = [ + { + id: IMPORTED_ADDON_1.browser_specific_settings.gecko.id, + name: IMPORTED_ADDON_1.name, + version: IMPORTED_ADDON_1.version, + sourceURI: Services.io.newURI(`http://example.com/addons/addon-1.xpi`), + icons: {}, + }, + ]; + AMBrowserExtensionsImport._addonRepository = mockAddonRepository({ addons }); + + registerCleanupFunction(() => { + // Clear the add-on repository override. + AMBrowserExtensionsImport._addonRepository = null; + }); +}); + +add_task( + { pref_set: [["browser.migrate.chrome.extensions.enabled", true]] }, + async function test_import_extensions() { + const migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + let promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-pending" + ); + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.EXTENSIONS, + PROFILE, + true + ); + await promiseTopic; + // When this property is `true`, the UI should show a badge on the appmenu + // button, and the user can finalize the import later. + Assert.ok( + AMBrowserExtensionsImport.canCompleteOrCancelInstalls, + "expected some add-ons to have been imported" + ); + + // Let's actually complete the import programatically below. + promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-complete" + ); + await AMBrowserExtensionsImport.completeInstalls(); + await Promise.all([ + AddonTestUtils.promiseInstallEvent("onInstallEnded"), + promiseTopic, + ]); + + // The add-on should be installed and therefore it can be uninstalled. + const addon = await AddonManager.getAddonByID( + IMPORTED_ADDON_1.browser_specific_settings.gecko.id + ); + await addon.uninstall(); + } +); + +/** + * Test that, for now at least, the extension resource type is only made + * available for Chrome and none of the derivitive browsers. + */ +add_task( + { pref_set: [["browser.migrate.chrome.extensions.enabled", true]] }, + async function test_only_chrome_migrates_extensions() { + for (const key of MigrationUtils.availableMigratorKeys) { + let migrator = await MigrationUtils.getMigrator(key); + + if (migrator instanceof ChromeProfileMigrator && key != "chrome") { + info("Testing migrator with key " + key); + Assert.ok( + await migrator.isSourceAvailable(), + "First check the source exists" + ); + let resourceTypes = await migrator.getMigrateData(PROFILE); + Assert.ok( + !(resourceTypes & MigrationUtils.resourceTypes.EXTENSIONS), + "Should not offer the extension resource type" + ); + } + } + } +); diff --git a/browser/components/migration/tests/unit/test_Chrome_formdata.js b/browser/components/migration/tests/unit/test_Chrome_formdata.js new file mode 100644 index 0000000000..1dc411cb14 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_formdata.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); + +let rootDir = do_get_file("chromefiles/", true); + +add_setup(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +/** + * This function creates a testing database in the default profile, + * populates it with 10 example data entries,migrates the database, + * and then searches for each entry to ensure it exists in the FormHistory. + * + * @async + * @param {string} migratorKey + * A string that identifies the type of migrator object to be retrieved. + * @param {Array<string>} subDirs + * An array of strings that specifies the subdirectories for the target profile directory. + * @returns {Promise<undefined>} + * A Promise that resolves when the migration is completed. + */ +async function testFormdata(migratorKey, subDirs) { + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + // Pretend this is the default profile + subDirs.push("Default"); + while (subDirs.length) { + target.append(subDirs.shift()); + } + + await IOUtils.makeDirectory(target.path, { + createAncestor: true, + ignoreExisting: true, + }); + + target.append("Web Data"); + await IOUtils.remove(target.path, { ignoreAbsent: true }); + + // Clear any search history results + await FormHistory.update({ op: "remove" }); + + let dbConn = await Sqlite.openConnection({ path: target.path }); + + await dbConn.execute( + `CREATE TABLE "autofill" (name VARCHAR, value VARCHAR, value_lower VARCHAR, date_created INTEGER DEFAULT 0, date_last_used INTEGER DEFAULT 0, count INTEGER DEFAULT 1, PRIMARY KEY (name, value))` + ); + for (let i = 0; i < 10; i++) { + await dbConn.execute( + `INSERT INTO autofill VALUES (:name, :value, :value_lower, :date_created, :date_last_used, :count)`, + { + name: `name${i}`, + value: `example${i}`, + value_lower: `example${i}`, + date_created: Math.round(Date.now() / 1000) - i * 10000, + date_last_used: Date.now(), + count: i, + } + ); + } + await dbConn.close(); + + let migrator = await MigrationUtils.getMigrator(migratorKey); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.FORMDATA, { + id: "Default", + name: "Person 1", + }); + + for (let i = 0; i < 10; i++) { + let results = await FormHistory.search(["fieldname", "value"], { + fieldname: `name${i}`, + value: `example${i}`, + }); + Assert.ok(results.length, `Should have item${i} in FormHistory`); + } +} + +add_task(async function test_Chrome() { + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + await testFormdata("chrome", subDirs); +}); + +add_task(async function test_ChromiumEdge() { + if (AppConstants.platform == "linux") { + // Edge isn't available on Linux. + return; + } + let subDirs = + AppConstants.platform == "macosx" + ? ["Microsoft Edge"] + : ["Microsoft", "Edge"]; + await testFormdata("chromium-edge", subDirs); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_history.js b/browser/components/migration/tests/unit/test_Chrome_history.js new file mode 100644 index 0000000000..c88a6380c2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_history.js @@ -0,0 +1,206 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const SOURCE_PROFILE_DIR = "Library/Application Support/Google/Chrome/Default/"; + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +/** + * TEST_URLS reflects the data stored in '${SOURCE_PROFILE_DIR}HistoryMaster'. + * The main object reflects the data in the 'urls' table. The visits property + * reflects the associated data in the 'visits' table. + */ +const TEST_URLS = [ + { + id: 1, + url: "http://example.com/", + title: "test", + visit_count: 1, + typed_count: 0, + last_visit_time: 13193151310368000, + hidden: 0, + visits: [ + { + id: 1, + url: 1, + visit_time: 13193151310368000, + from_visit: 0, + transition: 805306370, + segment_id: 0, + visit_duration: 10745006, + incremented_omnibox_typed_score: 0, + }, + ], + }, + { + id: 2, + url: "http://invalid.com/", + title: "test2", + visit_count: 1, + typed_count: 0, + last_visit_time: 13193154948901000, + hidden: 0, + visits: [ + { + id: 2, + url: 2, + visit_time: 13193154948901000, + from_visit: 0, + transition: 805306376, + segment_id: 0, + visit_duration: 6568270, + incremented_omnibox_typed_score: 0, + }, + ], + }, +]; + +async function setVisitTimes(time) { + let loginDataFile = do_get_file(`${SOURCE_PROFILE_DIR}History`); + let dbConn = await Sqlite.openConnection({ path: loginDataFile.path }); + + await dbConn.execute(`UPDATE urls SET last_visit_time = :last_visit_time`, { + last_visit_time: time, + }); + await dbConn.execute(`UPDATE visits SET visit_time = :visit_time`, { + visit_time: time, + }); + + await dbConn.close(); +} + +function setExpectedVisitTimes(time) { + for (let urlInfo of TEST_URLS) { + urlInfo.last_visit_time = time; + urlInfo.visits[0].visit_time = time; + } +} + +function assertEntryMatches(entry, urlInfo, dateWasInFuture = false) { + info(`Checking url: ${urlInfo.url}`); + Assert.ok(entry, `Should have stored an entry`); + + Assert.equal(entry.url, urlInfo.url, "Should have the correct URL"); + Assert.equal(entry.title, urlInfo.title, "Should have the correct title"); + Assert.equal( + entry.visits.length, + urlInfo.visits.length, + "Should have the correct number of visits" + ); + + for (let index in urlInfo.visits) { + Assert.equal( + entry.visits[index].transition, + PlacesUtils.history.TRANSITIONS.LINK, + "Should have Link type transition" + ); + + if (dateWasInFuture) { + Assert.lessOrEqual( + entry.visits[index].date.getTime(), + new Date().getTime(), + "Should have moved the date to no later than the current date." + ); + } else { + Assert.equal( + entry.visits[index].date.getTime(), + ChromeMigrationUtils.chromeTimeToDate( + urlInfo.visits[index].visit_time, + new Date() + ).getTime(), + "Should have the correct date" + ); + } + } +} + +function setupHistoryFile() { + removeHistoryFile(); + let file = do_get_file(`${SOURCE_PROFILE_DIR}HistoryMaster`); + file.copyTo(file.parent, "History"); +} + +function removeHistoryFile() { + let file = do_get_file(`${SOURCE_PROFILE_DIR}History`, true); + try { + file.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } +} + +add_task(async function setup() { + registerFakePath("ULibDir", do_get_file("Library/")); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + removeHistoryFile(); + }); +}); + +add_task(async function test_import() { + setupHistoryFile(); + await PlacesUtils.history.clear(); + // Update to ~10 days ago since the date can't be too old or Places may expire it. + const pastDate = new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 10); + const pastChromeTime = ChromeMigrationUtils.dateToChromeTime(pastDate); + await setVisitTimes(pastChromeTime); + setExpectedVisitTimes(pastChromeTime); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.HISTORY, + PROFILE + ); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + assertEntryMatches(entry, urlInfo); + } +}); + +add_task(async function test_import_future_date() { + setupHistoryFile(); + await PlacesUtils.history.clear(); + const futureDate = new Date().getTime() + 6000 * 60 * 24; + await setVisitTimes(ChromeMigrationUtils.dateToChromeTime(futureDate)); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.HISTORY, + PROFILE + ); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + assertEntryMatches(entry, urlInfo, true); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords.js b/browser/components/migration/tests/unit/test_Chrome_passwords.js new file mode 100644 index 0000000000..374b697c75 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_passwords.js @@ -0,0 +1,373 @@ +"use strict"; + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +const TEST_LOGINS = [ + { + id: 1, // id of the row in the chrome login db + username: "username", + password: "password", + origin: "https://c9.io", + formActionOrigin: "https://c9.io", + httpRealm: null, + usernameField: "inputEmail", + passwordField: "inputPassword", + timeCreated: 1437418416037, + timePasswordChanged: 1437418416037, + timesUsed: 1, + }, + { + id: 2, + username: "username@gmail.com", + password: "password2", + origin: "https://accounts.google.com", + formActionOrigin: "https://accounts.google.com", + httpRealm: null, + usernameField: "Email", + passwordField: "Passwd", + timeCreated: 1437418446598, + timePasswordChanged: 1437418446598, + timesUsed: 6, + }, + { + id: 3, + username: "username", + password: "password3", + origin: "https://www.facebook.com", + formActionOrigin: "https://www.facebook.com", + httpRealm: null, + usernameField: "email", + passwordField: "pass", + timeCreated: 1437418478851, + timePasswordChanged: 1437418478851, + timesUsed: 1, + }, + { + id: 4, + username: "user", + password: "اقرأPÀßwörd", + origin: "http://httpbin.org", + formActionOrigin: null, + httpRealm: "me@kennethreitz.com", // Digest auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787462368, + timePasswordChanged: 1437787462368, + timesUsed: 1, + }, + { + id: 5, + username: "buser", + password: "bpassword", + origin: "http://httpbin.org", + formActionOrigin: null, + httpRealm: "Fake Realm", // Basic auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787539233, + timePasswordChanged: 1437787539233, + timesUsed: 1, + }, + { + id: 6, + username: "username", + password: "password6", + origin: "https://www.example.com", + formActionOrigin: "", // NULL `action_url` + httpRealm: null, + usernameField: "", + passwordField: "pass", + timeCreated: 1557291348878, + timePasswordChanged: 1557291348878, + timesUsed: 1, + }, + { + id: 7, + version: "v10", + username: "username", + password: "password", + origin: "https://v10.io", + formActionOrigin: "https://v10.io", + httpRealm: null, + usernameField: "inputEmail", + passwordField: "inputPassword", + timeCreated: 1437418416037, + timePasswordChanged: 1437418416037, + timesUsed: 1, + }, +]; + +var loginCrypto; +var dbConn; + +async function promiseSetPassword(login) { + const encryptedString = await loginCrypto.encryptData( + login.password, + login.version + ); + info(`promiseSetPassword: ${encryptedString}`); + const passwordValue = new Uint8Array( + loginCrypto.stringToArray(encryptedString) + ); + return dbConn.execute( + `UPDATE logins + SET password_value = :password_value + WHERE rowid = :rowid + `, + { password_value: passwordValue, rowid: login.id } + ); +} + +function checkLoginsAreEqual(passwordManagerLogin, chromeLogin, id) { + passwordManagerLogin.QueryInterface(Ci.nsILoginMetaInfo); + + Assert.equal( + passwordManagerLogin.username, + chromeLogin.username, + "The two logins ID " + id + " have the same username" + ); + Assert.equal( + passwordManagerLogin.password, + chromeLogin.password, + "The two logins ID " + id + " have the same password" + ); + Assert.equal( + passwordManagerLogin.origin, + chromeLogin.origin, + "The two logins ID " + id + " have the same origin" + ); + Assert.equal( + passwordManagerLogin.formActionOrigin, + chromeLogin.formActionOrigin, + "The two logins ID " + id + " have the same formActionOrigin" + ); + Assert.equal( + passwordManagerLogin.httpRealm, + chromeLogin.httpRealm, + "The two logins ID " + id + " have the same httpRealm" + ); + Assert.equal( + passwordManagerLogin.usernameField, + chromeLogin.usernameField, + "The two logins ID " + id + " have the same usernameElement" + ); + Assert.equal( + passwordManagerLogin.passwordField, + chromeLogin.passwordField, + "The two logins ID " + id + " have the same passwordElement" + ); + Assert.equal( + passwordManagerLogin.timeCreated, + chromeLogin.timeCreated, + "The two logins ID " + id + " have the same timeCreated" + ); + Assert.equal( + passwordManagerLogin.timePasswordChanged, + chromeLogin.timePasswordChanged, + "The two logins ID " + id + " have the same timePasswordChanged" + ); + Assert.equal( + passwordManagerLogin.timesUsed, + chromeLogin.timesUsed, + "The two logins ID " + id + " have the same timesUsed" + ); +} + +function generateDifferentLogin(login) { + const newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + + newLogin.init( + login.origin, + login.formActionOrigin, + null, + login.username, + login.password + 1, + login.usernameField + 1, + login.passwordField + 1 + ); + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + newLogin.timeCreated = login.timeCreated + 1; + newLogin.timePasswordChanged = login.timePasswordChanged + 1; + newLogin.timesUsed = login.timesUsed + 1; + return newLogin; +} + +add_task(async function setup() { + let dirSvcPath; + let pathId; + let profilePathSegments; + + // Use a mock service and account name to avoid a Keychain auth. prompt that + // would block the test from finishing if Chrome has already created a matching + // Keychain entry. This allows us to still exercise the keychain lookup code. + // The mock encryption passphrase is used when the Keychain item isn't found. + const mockMacOSKeychain = { + passphrase: "bW96aWxsYWZpcmVmb3g=", + serviceName: "TESTING Chrome Safe Storage", + accountName: "TESTING Chrome", + }; + if (AppConstants.platform == "macosx") { + const { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeMacOSLoginCrypto( + mockMacOSKeychain.serviceName, + mockMacOSKeychain.accountName, + mockMacOSKeychain.passphrase + ); + dirSvcPath = "Library/"; + pathId = "ULibDir"; + profilePathSegments = [ + "Application Support", + "Google", + "Chrome", + "Default", + "Login Data", + ]; + } else if (AppConstants.platform == "win") { + const { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeWindowsLoginCrypto("Chrome"); + dirSvcPath = "AppData/Local/"; + pathId = "LocalAppData"; + profilePathSegments = [ + "Google", + "Chrome", + "User Data", + "Default", + "Login Data", + ]; + } else { + throw new Error("Not implemented"); + } + const dirSvcFile = do_get_file(dirSvcPath); + registerFakePath(pathId, dirSvcFile); + + info(PathUtils.join(dirSvcFile.path, ...profilePathSegments)); + const loginDataFilePath = PathUtils.join( + dirSvcFile.path, + ...profilePathSegments + ); + dbConn = await Sqlite.openConnection({ path: loginDataFilePath }); + + if (AppConstants.platform == "macosx") { + const migrator = await MigrationUtils.getMigrator("chrome"); + Object.assign(migrator, { + _keychainServiceName: mockMacOSKeychain.serviceName, + _keychainAccountName: mockMacOSKeychain.accountName, + _keychainMockPassphrase: mockMacOSKeychain.passphrase, + }); + } + + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + if (loginCrypto.finalize) { + loginCrypto.finalize(); + } + return dbConn.close(); + }); +}); + +add_task(async function test_importIntoEmptyDB() { + for (const login of TEST_LOGINS) { + await promiseSetPassword(login); + } + + const migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + let logins = await Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins initially"); + + // Migrate the logins. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PASSWORDS, + PROFILE, + true + ); + + logins = await Services.logins.getAllLogins(); + Assert.equal( + logins.length, + TEST_LOGINS.length, + "Check login count after importing the data" + ); + Assert.equal( + logins.length, + MigrationUtils._importQuantities.logins, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < TEST_LOGINS.length; i++) { + checkLoginsAreEqual(logins[i], TEST_LOGINS[i], i + 1); + } +}); + +// Test that existing logins for the same primary key don't get overwritten +add_task(async function test_importExistingLogins() { + const migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + Services.logins.removeAllUserFacingLogins(); + let logins = await Services.logins.getAllLogins(); + Assert.equal( + logins.length, + 0, + "There are no logins after removing all of them" + ); + + const newLogins = []; + + // Create 3 new logins that are different but where the key properties are still the same. + for (let i = 0; i < 3; i++) { + newLogins.push(generateDifferentLogin(TEST_LOGINS[i])); + await Services.logins.addLoginAsync(newLogins[i]); + } + + logins = await Services.logins.getAllLogins(); + Assert.equal( + logins.length, + newLogins.length, + "Check login count after the insertion" + ); + + for (let i = 0; i < newLogins.length; i++) { + checkLoginsAreEqual(logins[i], newLogins[i], i + 1); + } + // Migrate the logins. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PASSWORDS, + PROFILE, + true + ); + + logins = await Services.logins.getAllLogins(); + Assert.equal( + logins.length, + TEST_LOGINS.length, + "Check there are still the same number of logins after re-importing the data" + ); + Assert.equal( + logins.length, + MigrationUtils._importQuantities.logins, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < newLogins.length; i++) { + checkLoginsAreEqual(logins[i], newLogins[i], i + 1); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js new file mode 100644 index 0000000000..0e6993fded --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js @@ -0,0 +1,43 @@ +"use strict"; + +const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +add_task(async function test_importEmptyDBWithoutAuthPrompts() { + let dirSvcPath; + let pathId; + + if (AppConstants.platform == "macosx") { + dirSvcPath = "LibraryWithNoData/"; + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + dirSvcPath = "AppData/LocalWithNoData/"; + pathId = "LocalAppData"; + } else { + throw new Error("Not implemented"); + } + let dirSvcFile = do_get_file(dirSvcPath); + registerFakePath(pathId, dirSvcFile); + + let sandbox = sinon.createSandbox(); + sandbox + .stub(ChromeProfileMigrator.prototype, "canGetPermissions") + .resolves(true); + sandbox + .stub(ChromeProfileMigrator.prototype, "hasPermissions") + .resolves(true); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + !migrator, + "Migrator should not be available since there are no passwords" + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_permissions.js b/browser/components/migration/tests/unit/test_Chrome_permissions.js new file mode 100644 index 0000000000..6dfd8bcceb --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_permissions.js @@ -0,0 +1,205 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if the migrator does not have the permission to + * read from the Chrome data directory, that it can request + * permission to read from it, if the system allows. + */ + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const { ChromeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/ChromeProfileMigrator.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const { MockFilePicker } = ChromeUtils.importESModule( + "resource://testing-common/MockFilePicker.sys.mjs" +); + +let gTempDir; + +add_setup(async () => { + Services.prefs.setBoolPref( + "browser.migrate.chrome.get_permissions.enabled", + true + ); + gTempDir = do_get_tempdir(); + await IOUtils.writeJSON(PathUtils.join(gTempDir.path, "Local State"), []); + + MockFilePicker.init(globalThis); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +/** + * Tests that canGetPermissions will return false if the platform does + * not allow for folder selection in the native file picker, and returns + * the data path otherwise. + */ +add_task(async function test_canGetPermissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new ChromeProfileMigrator(); + let canGetPermissionsStub = sandbox + .stub(MigrationUtils, "canGetPermissionsOnPlatform") + .resolves(false); + sandbox.stub(ChromeMigrationUtils, "getDataPath").resolves(gTempDir.path); + + Assert.ok( + !(await migrator.canGetPermissions()), + "Should not be able to get permissions." + ); + + canGetPermissionsStub.resolves(true); + + Assert.equal( + await migrator.canGetPermissions(), + gTempDir.path, + "Should be able to get the permissions path." + ); + + sandbox.restore(); +}); + +/** + * Tests that getPermissions will show the native file picker in a + * loop until either the user cancels or selects a folder that grants + * read permissions to the data directory. + */ +add_task(async function test_getPermissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new ChromeProfileMigrator(); + sandbox.stub(MigrationUtils, "canGetPermissionsOnPlatform").resolves(true); + sandbox.stub(ChromeMigrationUtils, "getDataPath").resolves(gTempDir.path); + let hasPermissionsStub = sandbox + .stub(migrator, "hasPermissions") + .resolves(false); + + Assert.equal( + await migrator.canGetPermissions(), + gTempDir.path, + "Should be able to get the permissions path." + ); + + let filePickerSeenCount = 0; + + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.useDirectory([gTempDir.path]); + filePickerSeenCount++; + if (filePickerSeenCount > 3) { + Assert.ok(true, "File picker looped 3 times."); + hasPermissionsStub.resolves(true); + resolve(); + } + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + Assert.ok( + await migrator.getPermissions(), + "Should report that we got permissions." + ); + + await filePickerShownPromise; + + // Make sure that the user can also hit "cancel" and that we + // file picker loop. + + hasPermissionsStub.resolves(false); + + filePickerSeenCount = 0; + filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + filePickerSeenCount++; + Assert.equal(filePickerSeenCount, 1, "Saw the picker once."); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnCancel; + Assert.ok( + !(await migrator.getPermissions()), + "Should report that we didn't get permissions." + ); + await filePickerShownPromise; + + sandbox.restore(); +}); + +/** + * Tests that if the native file picker chooses a different directory + * than the one we originally asked for, that we remap attempts to + * read profiles from that new directory. This is because Ubuntu Snaps + * will return us paths from the native file picker that are symlinks + * to the original directories. + */ +add_task(async function test_remapDirectories() { + let remapDir = new FileUtils.File( + await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "test-chrome-migration" + ) + ); + let localStatePath = PathUtils.join(remapDir.path, "Local State"); + await IOUtils.writeJSON(localStatePath, []); + + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new ChromeProfileMigrator(); + sandbox.stub(MigrationUtils, "canGetPermissionsOnPlatform").resolves(true); + sandbox.stub(ChromeMigrationUtils, "getDataPath").resolves(gTempDir.path); + let hasPermissionsStub = sandbox + .stub(migrator, "hasPermissions") + .resolves(false); + + Assert.equal( + await migrator.canGetPermissions(), + gTempDir.path, + "Should be able to get the permissions path." + ); + + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.useDirectory([remapDir.path]); + hasPermissionsStub.resolves(true); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + Assert.ok( + await migrator.getPermissions(), + "Should report that we got permissions." + ); + + Assert.equal( + PathUtils.normalize(await migrator.canGetPermissions()), + PathUtils.normalize(remapDir.path), + "Should be able to get the remapped permissions path." + ); + + await filePickerShownPromise; + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/unit/test_Edge_db_migration.js b/browser/components/migration/tests/unit/test_Edge_db_migration.js new file mode 100644 index 0000000000..3b2672d9d8 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Edge_db_migration.js @@ -0,0 +1,849 @@ +"use strict"; + +const { ctypes } = ChromeUtils.importESModule( + "resource://gre/modules/ctypes.sys.mjs" +); +const { ESE, KERNEL, gLibs, COLUMN_TYPES, declareESEFunction, loadLibraries } = + ChromeUtils.importESModule("resource:///modules/ESEDBReader.sys.mjs"); +const { EdgeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/EdgeProfileMigrator.sys.mjs" +); + +let gESEInstanceCounter = 1; + +ESE.JET_COLUMNCREATE_W = new ctypes.StructType("JET_COLUMNCREATE_W", [ + { cbStruct: ctypes.unsigned_long }, + { szColumnName: ESE.JET_PCWSTR }, + { coltyp: ESE.JET_COLTYP }, + { cbMax: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, + { pvDefault: ctypes.voidptr_t }, + { cbDefault: ctypes.unsigned_long }, + { cp: ctypes.unsigned_long }, + { columnid: ESE.JET_COLUMNID }, + { err: ESE.JET_ERR }, +]); + +function createColumnCreationWrapper({ name, type, cbMax }) { + // We use a wrapper object because we need to be sure the JS engine won't GC + // data that we're "only" pointing to. + let wrapper = {}; + wrapper.column = new ESE.JET_COLUMNCREATE_W(); + wrapper.column.cbStruct = ESE.JET_COLUMNCREATE_W.size; + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + wrapper.name = new wchar_tArray(name.length + 1); + wrapper.name.value = String(name); + wrapper.column.szColumnName = wrapper.name; + wrapper.column.coltyp = type; + let fallback = 0; + switch (type) { + case COLUMN_TYPES.JET_coltypText: + fallback = 255; + // Intentional fall-through + case COLUMN_TYPES.JET_coltypLongText: + wrapper.column.cbMax = cbMax || fallback || 64 * 1024; + break; + case COLUMN_TYPES.JET_coltypGUID: + wrapper.column.cbMax = 16; + break; + case COLUMN_TYPES.JET_coltypBit: + wrapper.column.cbMax = 1; + break; + case COLUMN_TYPES.JET_coltypLongLong: + wrapper.column.cbMax = 8; + break; + default: + throw new Error("Unknown column type!"); + } + + wrapper.column.columnid = new ESE.JET_COLUMNID(); + wrapper.column.grbit = 0; + wrapper.column.pvDefault = null; + wrapper.column.cbDefault = 0; + wrapper.column.cp = 0; + + return wrapper; +} + +// "forward declarations" of indexcreate and setinfo structs, which we don't use. +ESE.JET_INDEXCREATE = new ctypes.StructType("JET_INDEXCREATE"); +ESE.JET_SETINFO = new ctypes.StructType("JET_SETINFO"); + +ESE.JET_TABLECREATE_W = new ctypes.StructType("JET_TABLECREATE_W", [ + { cbStruct: ctypes.unsigned_long }, + { szTableName: ESE.JET_PCWSTR }, + { szTemplateTableName: ESE.JET_PCWSTR }, + { ulPages: ctypes.unsigned_long }, + { ulDensity: ctypes.unsigned_long }, + { rgcolumncreate: ESE.JET_COLUMNCREATE_W.ptr }, + { cColumns: ctypes.unsigned_long }, + { rgindexcreate: ESE.JET_INDEXCREATE.ptr }, + { cIndexes: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, + { tableid: ESE.JET_TABLEID }, + { cCreated: ctypes.unsigned_long }, +]); + +function createTableCreationWrapper(tableName, columns) { + let wrapper = {}; + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + wrapper.name = new wchar_tArray(tableName.length + 1); + wrapper.name.value = String(tableName); + wrapper.table = new ESE.JET_TABLECREATE_W(); + wrapper.table.cbStruct = ESE.JET_TABLECREATE_W.size; + wrapper.table.szTableName = wrapper.name; + wrapper.table.szTemplateTableName = null; + wrapper.table.ulPages = 1; + wrapper.table.ulDensity = 0; + let columnArrayType = ESE.JET_COLUMNCREATE_W.array(columns.length); + wrapper.columnAry = new columnArrayType(); + wrapper.table.rgcolumncreate = wrapper.columnAry.addressOfElement(0); + wrapper.table.cColumns = columns.length; + wrapper.columns = []; + for (let i = 0; i < columns.length; i++) { + let column = columns[i]; + let columnWrapper = createColumnCreationWrapper(column); + wrapper.columnAry.addressOfElement(i).contents = columnWrapper.column; + wrapper.columns.push(columnWrapper); + } + wrapper.table.rgindexcreate = null; + wrapper.table.cIndexes = 0; + return wrapper; +} + +function convertValueForWriting(value, valueType) { + let buffer; + let valueOfValueType = ctypes.UInt64.lo(valueType); + switch (valueOfValueType) { + case COLUMN_TYPES.JET_coltypLongLong: + if (value instanceof Date) { + buffer = new KERNEL.FILETIME(); + let sysTime = new KERNEL.SYSTEMTIME(); + sysTime.wYear = value.getUTCFullYear(); + sysTime.wMonth = value.getUTCMonth() + 1; + sysTime.wDay = value.getUTCDate(); + sysTime.wHour = value.getUTCHours(); + sysTime.wMinute = value.getUTCMinutes(); + sysTime.wSecond = value.getUTCSeconds(); + sysTime.wMilliseconds = value.getUTCMilliseconds(); + let rv = KERNEL.SystemTimeToFileTime( + sysTime.address(), + buffer.address() + ); + if (!rv) { + throw new Error("Failed to get FileTime."); + } + return [buffer, KERNEL.FILETIME.size]; + } + throw new Error("Unrecognized value for longlong column"); + case COLUMN_TYPES.JET_coltypLongText: + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + buffer = new wchar_tArray(value.length + 1); + buffer.value = String(value); + return [buffer, buffer.length * 2]; + case COLUMN_TYPES.JET_coltypBit: + buffer = new ctypes.uint8_t(); + // Bizarre boolean values, but whatever: + buffer.value = value ? 255 : 0; + return [buffer, 1]; + case COLUMN_TYPES.JET_coltypGUID: + let byteArray = ctypes.ArrayType(ctypes.uint8_t); + buffer = new byteArray(16); + let j = 0; + for (let i = 0; i < value.length; i++) { + if (!/[0-9a-f]/i.test(value[i])) { + continue; + } + let byteAsHex = value.substr(i, 2); + buffer[j++] = parseInt(byteAsHex, 16); + i++; + } + return [buffer, 16]; + } + + throw new Error("Unknown type " + valueType); +} + +let initializedESE = false; + +let eseDBWritingHelpers = { + setupDB(dbFile, tables) { + if (!initializedESE) { + initializedESE = true; + loadLibraries(); + + KERNEL.SystemTimeToFileTime = gLibs.kernel.declare( + "SystemTimeToFileTime", + ctypes.winapi_abi, + ctypes.bool, + KERNEL.SYSTEMTIME.ptr, + KERNEL.FILETIME.ptr + ); + + declareESEFunction( + "CreateDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ESE.JET_DBID.ptr, + ESE.JET_GRBIT + ); + declareESEFunction( + "CreateTableColumnIndexW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_TABLECREATE_W.ptr + ); + declareESEFunction("BeginTransaction", ESE.JET_SESID); + declareESEFunction("CommitTransaction", ESE.JET_SESID, ESE.JET_GRBIT); + declareESEFunction( + "PrepareUpdate", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.unsigned_long + ); + declareESEFunction( + "Update", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long.ptr + ); + declareESEFunction( + "SetColumn", + ESE.JET_SESID, + ESE.JET_TABLEID, + ESE.JET_COLUMNID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ESE.JET_GRBIT, + ESE.JET_SETINFO.ptr + ); + ESE.SetSystemParameterW( + null, + 0, + 64 /* JET_paramDatabasePageSize*/, + 8192, + null + ); + } + + let rootPath = dbFile.parent.path + "\\"; + let logPath = rootPath + "LogFiles\\"; + + try { + this._instanceId = new ESE.JET_INSTANCE(); + ESE.CreateInstanceW( + this._instanceId.address(), + "firefox-dbwriter-" + gESEInstanceCounter++ + ); + this._instanceCreated = true; + + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 0 /* JET_paramSystemPath*/, + 0, + rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 1 /* JET_paramTempPath */, + 0, + rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 2 /* JET_paramLogFilePath*/, + 0, + logPath + ); + // Shouldn't try to call JetTerm if the following call fails. + this._instanceCreated = false; + ESE.Init(this._instanceId.address()); + this._instanceCreated = true; + this._sessionId = new ESE.JET_SESID(); + ESE.BeginSessionW( + this._instanceId, + this._sessionId.address(), + null, + null + ); + this._sessionCreated = true; + + this._dbId = new ESE.JET_DBID(); + this._dbPath = rootPath + "spartan.edb"; + ESE.CreateDatabaseW( + this._sessionId, + this._dbPath, + null, + this._dbId.address(), + 0 + ); + this._opened = this._attached = true; + + for (let [tableName, data] of tables) { + let { rows, columns } = data; + let tableCreationWrapper = createTableCreationWrapper( + tableName, + columns + ); + ESE.CreateTableColumnIndexW( + this._sessionId, + this._dbId, + tableCreationWrapper.table.address() + ); + this._tableId = tableCreationWrapper.table.tableid; + + let columnIdMap = new Map(); + if (rows.length) { + // Iterate over the struct we passed into ESENT because they have the + // created column ids. + let columnCount = ctypes.UInt64.lo( + tableCreationWrapper.table.cColumns + ); + let columnsPassed = tableCreationWrapper.table.rgcolumncreate; + for (let i = 0; i < columnCount; i++) { + let column = columnsPassed.contents; + columnIdMap.set(column.szColumnName.readString(), column); + columnsPassed = columnsPassed.increment(); + } + ESE.ManualMove( + this._sessionId, + this._tableId, + -2147483648 /* JET_MoveFirst */, + 0 + ); + ESE.BeginTransaction(this._sessionId); + for (let row of rows) { + ESE.PrepareUpdate( + this._sessionId, + this._tableId, + 0 /* JET_prepInsert */ + ); + for (let columnName in row) { + let col = columnIdMap.get(columnName); + let colId = col.columnid; + let [val, valSize] = convertValueForWriting( + row[columnName], + col.coltyp + ); + /* JET_bitSetOverwriteLV */ + ESE.SetColumn( + this._sessionId, + this._tableId, + colId, + val.address(), + valSize, + 4, + null + ); + } + let actualBookmarkSize = new ctypes.unsigned_long(); + ESE.Update( + this._sessionId, + this._tableId, + null, + 0, + actualBookmarkSize.address() + ); + } + ESE.CommitTransaction( + this._sessionId, + 0 /* JET_bitWaitLastLevel0Commit */ + ); + } + } + } finally { + try { + this._close(); + } catch (ex) { + console.error(ex); + } + } + }, + + _close() { + if (this._tableId) { + ESE.FailSafeCloseTable(this._sessionId, this._tableId); + delete this._tableId; + } + if (this._opened) { + ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0); + this._opened = false; + } + if (this._attached) { + ESE.FailSafeDetachDatabaseW(this._sessionId, this._dbPath); + this._attached = false; + } + if (this._sessionCreated) { + ESE.FailSafeEndSession(this._sessionId, 0); + this._sessionCreated = false; + } + if (this._instanceCreated) { + ESE.FailSafeTerm(this._instanceId); + this._instanceCreated = false; + } + }, +}; + +add_task(async function () { + let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempFile.append("fx-xpcshell-edge-db"); + tempFile.createUnique(tempFile.DIRECTORY_TYPE, 0o600); + + let db = tempFile.clone(); + db.append("spartan.edb"); + + let logs = tempFile.clone(); + logs.append("LogFiles"); + logs.create(tempFile.DIRECTORY_TYPE, 0o600); + + let creationDate = new Date(Date.now() - 5000); + const kEdgeMenuParent = "62d07e2b-5f0d-4e41-8426-5f5ec9717beb"; + let bookmarkReferenceItems = [ + { + URL: "http://www.mozilla.org/", + Title: "Mozilla", + DateUpdated: new Date(creationDate.valueOf() + 100), + ItemId: "1c00c10a-15f6-4618-92dd-22575102a4da", + ParentId: kEdgeMenuParent, + IsFolder: false, + IsDeleted: false, + }, + { + Title: "Folder", + DateUpdated: new Date(creationDate.valueOf() + 200), + ItemId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: false, + }, + { + Title: "Item in folder", + URL: "http://www.iteminfolder.org/", + DateUpdated: new Date(creationDate.valueOf() + 300), + ItemId: "c295ddaf-04a1-424a-866c-0ebde011e7c8", + ParentId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa", + IsFolder: false, + IsDeleted: false, + }, + { + Title: "Deleted folder", + DateUpdated: new Date(creationDate.valueOf() + 400), + ItemId: "a547573c-4d4d-4406-a736-5b5462d93bca", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: true, + }, + { + Title: "Deleted item", + URL: "http://www.deleteditem.org/", + DateUpdated: new Date(creationDate.valueOf() + 500), + ItemId: "37a574bb-b44b-4bbc-a414-908615536435", + ParentId: kEdgeMenuParent, + IsFolder: false, + IsDeleted: true, + }, + { + Title: "Item in deleted folder (should be in root)", + URL: "http://www.itemindeletedfolder.org/", + DateUpdated: new Date(creationDate.valueOf() + 600), + ItemId: "74dd1cc3-4c5d-471f-bccc-7bc7c72fa621", + ParentId: "a547573c-4d4d-4406-a736-5b5462d93bca", + IsFolder: false, + IsDeleted: false, + }, + { + Title: "_Favorites_Bar_", + DateUpdated: new Date(creationDate.valueOf() + 700), + ItemId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: false, + }, + { + Title: "Item in favorites bar", + URL: "http://www.iteminfavoritesbar.org/", + DateUpdated: new Date(creationDate.valueOf() + 800), + ItemId: "9f2b1ff8-b651-46cf-8f41-16da8bcb6791", + ParentId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf", + IsFolder: false, + IsDeleted: false, + }, + ]; + + let readingListReferenceItems = [ + { + Title: "Some mozilla page", + URL: "http://www.mozilla.org/somepage/", + AddedDate: new Date(creationDate.valueOf() + 900), + ItemId: "c88426fd-52a7-419d-acbc-d2310e8afebe", + IsDeleted: false, + }, + { + Title: "Some other page", + URL: "https://www.example.org/somepage/", + AddedDate: new Date(creationDate.valueOf() + 1000), + ItemId: "a35fc843-5d5a-4d1e-9be8-45214be24b5c", + IsDeleted: false, + }, + ]; + + // The following entries are expected to be skipped as being too old to + // migrate. + let expiredTypedURLsReferenceItems = [ + { + URL: "https://expired1.invalid/", + AccessDateTimeUTC: dateDaysAgo(500), + }, + { + URL: "https://expired2.invalid/", + AccessDateTimeUTC: dateDaysAgo(300), + }, + { + URL: "https://expired3.invalid/", + AccessDateTimeUTC: dateDaysAgo(190), + }, + ]; + + // The following entries should be new enough to migrate. + let unexpiredTypedURLsReferenceItems = [ + { + URL: "https://unexpired1.invalid/", + AccessDateTimeUTC: dateDaysAgo(179), + }, + { + URL: "https://unexpired2.invalid/", + AccessDateTimeUTC: dateDaysAgo(50), + }, + { + URL: "https://unexpired3.invalid/", + }, + ]; + + let typedURLsReferenceItems = [ + ...expiredTypedURLsReferenceItems, + ...unexpiredTypedURLsReferenceItems, + ]; + + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS < 300, + "This test expects the current pref to be less than the youngest expired visit." + ); + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS > 160, + "This test expects the current pref to be greater than the oldest unexpired visit." + ); + + eseDBWritingHelpers.setupDB( + db, + new Map([ + [ + "Favorites", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongText, + name: "Title", + cbMax: 4096, + }, + { type: COLUMN_TYPES.JET_coltypLongLong, name: "DateUpdated" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsFolder" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ParentId" }, + ], + rows: bookmarkReferenceItems, + }, + ], + [ + "ReadingList", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongText, + name: "Title", + cbMax: 4096, + }, + { type: COLUMN_TYPES.JET_coltypLongLong, name: "AddedDate" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" }, + ], + rows: readingListReferenceItems, + }, + ], + [ + "TypedURLs", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongLong, + name: "AccessDateTimeUTC", + }, + ], + rows: typedURLsReferenceItems, + }, + ], + ]) + ); + + // Manually create an EdgeProfileMigrator rather than going through + // MigrationUtils.getMigrator to avoid the user data availability check, since + // we're mocking out that stuff. + let migrator = new EdgeProfileMigrator(); + let bookmarksMigrator = migrator.getBookmarksMigratorForTesting(db); + Assert.ok(bookmarksMigrator.exists, "Should recognize db we just created"); + + let seenBookmarks = []; + let listener = events => { + for (let event of events) { + let { + id, + itemType, + url, + title, + dateAdded, + guid, + index, + parentGuid, + parentId, + } = event; + if (title.startsWith("Deleted")) { + ok(false, "Should not see deleted items being bookmarked!"); + } + seenBookmarks.push({ + id, + parentId, + index, + itemType, + url, + title, + dateAdded, + guid, + parentGuid, + }); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + let migrateResult = await new Promise(resolve => + bookmarksMigrator.migrate(resolve) + ).catch(ex => { + console.error(ex); + Assert.ok(false, "Got an exception trying to migrate data! " + ex); + return false; + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.ok(migrateResult, "Migration should succeed"); + Assert.equal( + seenBookmarks.length, + 5, + "Should have seen 5 items being bookmarked." + ); + Assert.equal( + seenBookmarks.length, + MigrationUtils._importQuantities.bookmarks, + "Telemetry should have items" + ); + + let menuParents = seenBookmarks.filter( + item => item.parentGuid == PlacesUtils.bookmarks.menuGuid + ); + Assert.equal( + menuParents.length, + 3, + "Bookmarks are added to the menu without a folder" + ); + let toolbarParents = seenBookmarks.filter( + item => item.parentGuid == PlacesUtils.bookmarks.toolbarGuid + ); + Assert.equal( + toolbarParents.length, + 1, + "Should have a single item added to the toolbar" + ); + let menuParentGuid = PlacesUtils.bookmarks.menuGuid; + let toolbarParentGuid = PlacesUtils.bookmarks.toolbarGuid; + + let expectedTitlesInMenu = bookmarkReferenceItems + .filter(item => item.ParentId == kEdgeMenuParent) + .map(item => item.Title); + // Hacky, but seems like much the simplest way: + expectedTitlesInMenu.push("Item in deleted folder (should be in root)"); + let expectedTitlesInToolbar = bookmarkReferenceItems + .filter(item => item.ParentId == "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf") + .map(item => item.Title); + + for (let bookmark of seenBookmarks) { + let shouldBeInMenu = expectedTitlesInMenu.includes(bookmark.title); + let shouldBeInToolbar = expectedTitlesInToolbar.includes(bookmark.title); + if (bookmark.title == "Folder") { + Assert.equal( + bookmark.itemType, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Bookmark " + bookmark.title + " should be a folder" + ); + } else { + Assert.notEqual( + bookmark.itemType, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Bookmark " + bookmark.title + " should not be a folder" + ); + } + + if (shouldBeInMenu) { + Assert.equal( + bookmark.parentGuid, + menuParentGuid, + "Item '" + bookmark.title + "' should be in menu" + ); + } else if (shouldBeInToolbar) { + Assert.equal( + bookmark.parentGuid, + toolbarParentGuid, + "Item '" + bookmark.title + "' should be in toolbar" + ); + } else if ( + bookmark.guid == menuParentGuid || + bookmark.guid == toolbarParentGuid + ) { + Assert.ok( + true, + "Expect toolbar and menu folders to not be in menu or toolbar" + ); + } else { + // Bit hacky, but we do need to check this. + Assert.equal( + bookmark.title, + "Item in folder", + "Subfoldered item shouldn't be in menu or toolbar" + ); + let parent = seenBookmarks.find( + maybeParent => maybeParent.guid == bookmark.parentGuid + ); + Assert.equal( + parent && parent.title, + "Folder", + "Subfoldered item should be in subfolder labeled 'Folder'" + ); + } + + let dbItem = bookmarkReferenceItems.find( + someItem => bookmark.title == someItem.Title + ); + if (!dbItem) { + Assert.ok( + [menuParentGuid, toolbarParentGuid].includes(bookmark.guid), + "This item should be one of the containers" + ); + } else { + Assert.equal(dbItem.URL || "", bookmark.url, "URL is correct"); + Assert.equal( + dbItem.DateUpdated.valueOf(), + new Date(bookmark.dateAdded).valueOf(), + "Date added is correct" + ); + } + } + + MigrationUtils._importQuantities.bookmarks = 0; + seenBookmarks = []; + listener = events => { + for (let event of events) { + let { + id, + itemType, + url, + title, + dateAdded, + guid, + index, + parentGuid, + parentId, + } = event; + seenBookmarks.push({ + id, + parentId, + index, + itemType, + url, + title, + dateAdded, + guid, + parentGuid, + }); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + let readingListMigrator = migrator.getReadingListMigratorForTesting(db); + Assert.ok(readingListMigrator.exists, "Should recognize db we just created"); + migrateResult = await new Promise(resolve => + readingListMigrator.migrate(resolve) + ).catch(ex => { + console.error(ex); + Assert.ok(false, "Got an exception trying to migrate data! " + ex); + return false; + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.ok(migrateResult, "Migration should succeed"); + Assert.equal( + seenBookmarks.length, + 3, + "Should have seen 3 items being bookmarked (2 items + 1 folder)." + ); + Assert.equal( + seenBookmarks.length, + MigrationUtils._importQuantities.bookmarks, + "Telemetry should have items" + ); + let readingListContainerLabel = await MigrationUtils.getLocalizedString( + "migration-imported-edge-reading-list" + ); + + for (let bookmark of seenBookmarks) { + if (readingListContainerLabel == bookmark.title) { + continue; + } + let referenceItem = readingListReferenceItems.find( + item => item.Title == bookmark.title + ); + Assert.ok(referenceItem, "Should have imported what we expected"); + Assert.equal(referenceItem.URL, bookmark.url, "Should have the right URL"); + readingListReferenceItems.splice( + readingListReferenceItems.findIndex(item => item.Title == bookmark.title), + 1 + ); + } + Assert.ok( + !readingListReferenceItems.length, + "Should have seen all expected items." + ); + + let historyDBMigrator = migrator.getHistoryDBMigratorForTesting(db); + await new Promise(resolve => { + historyDBMigrator.migrate(resolve); + }); + Assert.ok(true, "History DB migration done!"); + for (let expiredEntry of expiredTypedURLsReferenceItems) { + let entry = await PlacesUtils.history.fetch(expiredEntry.URL, { + includeVisits: true, + }); + Assert.equal(entry, null, "Should not have found an entry."); + } + + for (let unexpiredEntry of unexpiredTypedURLsReferenceItems) { + let entry = await PlacesUtils.history.fetch(unexpiredEntry.URL, { + includeVisits: true, + }); + Assert.equal(entry.url, unexpiredEntry.URL, "Should have the correct URL"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } +}); diff --git a/browser/components/migration/tests/unit/test_Edge_registry_migration.js b/browser/components/migration/tests/unit/test_Edge_registry_migration.js new file mode 100644 index 0000000000..2a400f7858 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Edge_registry_migration.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { EdgeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/EdgeProfileMigrator.sys.mjs" +); +const { MSMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MSMigrationUtils.sys.mjs" +); + +/** + * Tests that history visits loaded from the registry from Edge (EdgeHTML) + * that have a visit date older than maxAgeInDays days do not get imported. + */ +add_task(async function test_Edge_history_past_max_days() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS < 300, + "This test expects the current pref to be less than the youngest expired visit." + ); + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS > 160, + "This test expects the current pref to be greater than the oldest unexpired visit." + ); + + const EXPIRED_VISITS = [ + ["https://test1.invalid/", dateDaysAgo(500).getTime() * 1000], + ["https://test2.invalid/", dateDaysAgo(450).getTime() * 1000], + ["https://test3.invalid/", dateDaysAgo(300).getTime() * 1000], + ]; + + const UNEXPIRED_VISITS = [ + ["https://test4.invalid/"], + ["https://test5.invalid/", dateDaysAgo(160).getTime() * 1000], + ["https://test6.invalid/", dateDaysAgo(50).getTime() * 1000], + ["https://test7.invalid/", dateDaysAgo(0).getTime() * 1000], + ]; + + const ALL_VISITS = [...EXPIRED_VISITS, ...UNEXPIRED_VISITS]; + + // Fake out the getResources method of the migrator so that we return + // a single fake MigratorResource per availableResourceType. + sandbox.stub(MSMigrationUtils, "getTypedURLs").callsFake(() => { + return new Map(ALL_VISITS); + }); + + // Manually create an EdgeProfileMigrator rather than going through + // MigrationUtils.getMigrator to avoid the user data availability check, since + // we're mocking out that stuff. + let migrator = new EdgeProfileMigrator(); + let registryTypedHistoryMigrator = + migrator.getHistoryRegistryMigratorForTesting(); + await new Promise(resolve => { + registryTypedHistoryMigrator.migrate(resolve); + }); + Assert.ok(true, "History from registry migration done!"); + + for (let expiredEntry of EXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(expiredEntry[0], { + includeVisits: true, + }); + Assert.equal(entry, null, "Should not have found an entry."); + } + + for (let unexpiredEntry of UNEXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(unexpiredEntry[0], { + includeVisits: true, + }); + Assert.equal(entry.url, unexpiredEntry[0], "Should have the correct URL"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } +}); diff --git a/browser/components/migration/tests/unit/test_IE_bookmarks.js b/browser/components/migration/tests/unit/test_IE_bookmarks.js new file mode 100644 index 0000000000..9816bb16e3 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_bookmarks.js @@ -0,0 +1,30 @@ +"use strict"; + +add_task(async function () { + let migrator = await MigrationUtils.getMigrator("ie"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable(), "Check migrator source"); + + // Since this test doesn't mock out the favorites, execution is dependent + // on the actual favorites stored on the local machine's IE favorites database. + // As such, we can't assert that bookmarks were migrated to both the bookmarks + // menu and the bookmarks toolbar. + let itemCount = 0; + let listener = events => { + for (let event of events) { + if (event.itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK) { + info("bookmark added: " + event.parentGuid); + itemCount++; + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemCount, + "Ensure telemetry matches actual number of imported items." + ); +}); diff --git a/browser/components/migration/tests/unit/test_IE_history.js b/browser/components/migration/tests/unit/test_IE_history.js new file mode 100644 index 0000000000..f9a1e719a2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_history.js @@ -0,0 +1,187 @@ +"use strict"; + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +// These match what we add to IE via InsertIEHistory.exe. +const TEST_ENTRIES = [ + { + url: "http://www.mozilla.org/1", + title: "Mozilla HTTP Test", + }, + { + url: "https://www.mozilla.org/2", + // Test character encoding with a fox emoji: + title: "Mozilla HTTPS Test 🦊", + }, +]; + +function insertIEHistory() { + let file = do_get_file("InsertIEHistory.exe", false); + let process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess); + process.init(file); + + let args = []; + process.run(true, args, args.length); + + Assert.ok(!process.isRunning, "Should be done running"); + Assert.equal(process.exitValue, 0, "Check exit code"); +} + +add_task(async function setup() { + await PlacesUtils.history.clear(); + + insertIEHistory(); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_IE_history() { + let migrator = await MigrationUtils.getMigrator("ie"); + Assert.ok(await migrator.isSourceAvailable(), "Source is available"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let { url, title } of TEST_ENTRIES) { + let entry = await PlacesUtils.history.fetch(url, { includeVisits: true }); + Assert.equal(entry.url, url, "Should have the correct URL"); + Assert.equal(entry.title, title, "Should have the correct title"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } + + await PlacesUtils.history.clear(); +}); + +/** + * Tests that history visits from IE that have a visit date older than + * maxAgeInDays days do not get imported. + */ +add_task(async function test_IE_history_past_max_days() { + // The InsertIEHistory program inserts two history visits using the MS COM + // IUrlHistoryStg interface. That interface does not allow us to dictate + // the visit times of those history visits. Thankfully, we can temporarily + // mock out the @mozilla.org/profile/migrator/iehistoryenumerator;1 to return + // some entries that we expect to expire. + + /** + * An implmentation of nsISimpleEnumerator that wraps a JavaScript Array. + */ + class nsSimpleEnumerator { + #items; + #nextIndex; + + constructor(items) { + this.#items = items; + this.#nextIndex = 0; + } + + hasMoreElements() { + return this.#nextIndex < this.#items.length; + } + + getNext() { + if (!this.hasMoreElements()) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + return this.#items[this.#nextIndex++]; + } + + [Symbol.iterator]() { + return this.#items.values(); + } + + QueryInterface = ChromeUtils.generateQI(["nsISimpleEnumerator"]); + } + + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS < 300, + "This test expects the current pref to be less than the youngest expired visit." + ); + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS > 160, + "This test expects the current pref to be greater than the oldest unexpired visit." + ); + + const EXPIRED_VISITS = [ + new Map([ + ["uri", Services.io.newURI("https://test1.invalid")], + ["title", "Test history visit 1"], + ["time", PRTimeDaysAgo(500)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test2.invalid")], + ["title", "Test history visit 2"], + ["time", PRTimeDaysAgo(450)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test3.invalid")], + ["title", "Test history visit 3"], + ["time", PRTimeDaysAgo(300)], + ]), + ]; + + const UNEXPIRED_VISITS = [ + new Map([ + ["uri", Services.io.newURI("https://test4.invalid")], + ["title", "Test history visit 4"], + ]), + new Map([ + ["uri", Services.io.newURI("https://test5.invalid")], + ["title", "Test history visit 5"], + ["time", PRTimeDaysAgo(160)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test6.invalid")], + ["title", "Test history visit 6"], + ["time", PRTimeDaysAgo(50)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test7.invalid")], + ["title", "Test history visit 7"], + ["time", PRTimeDaysAgo(0)], + ]), + ]; + + let fakeIEHistoryEnumerator = MockRegistrar.register( + "@mozilla.org/profile/migrator/iehistoryenumerator;1", + new nsSimpleEnumerator([...EXPIRED_VISITS, ...UNEXPIRED_VISITS]) + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(fakeIEHistoryEnumerator); + }); + + let migrator = await MigrationUtils.getMigrator("ie"); + Assert.ok(await migrator.isSourceAvailable(), "Source is available"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let visit of EXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(visit.get("uri").spec, { + includeVisits: true, + }); + Assert.equal(entry, null, "Should not have found an entry."); + } + + for (let visit of UNEXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(visit.get("uri"), { + includeVisits: true, + }); + Assert.equal( + entry.url, + visit.get("uri").spec, + "Should have the correct URL" + ); + Assert.equal( + entry.title, + visit.get("title"), + "Should have the correct title" + ); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js new file mode 100644 index 0000000000..83748d870d --- /dev/null +++ b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js @@ -0,0 +1,29 @@ +"use strict"; + +let tmpFile = FileUtils.getDir("TmpD", []); +let dbConn; + +add_task(async function setup() { + tmpFile.append("TestDB"); + dbConn = await Sqlite.openConnection({ path: tmpFile.path }); + + registerCleanupFunction(async () => { + await dbConn.close(); + await IOUtils.remove(tmpFile.path); + }); +}); + +add_task(async function testgetRowsFromDBWithoutLocksRetries() { + let deferred = Promise.withResolvers(); + let promise = MigrationUtils.getRowsFromDBWithoutLocks( + tmpFile.path, + "Temp DB", + "SELECT * FROM moz_temp_table", + deferred.promise + ); + await new Promise(resolve => do_timeout(50, resolve)); + dbConn + .execute("CREATE TABLE moz_temp_table (id INTEGER PRIMARY KEY)") + .then(deferred.resolve); + await promise; +}); diff --git a/browser/components/migration/tests/unit/test_PasswordFileMigrator.js b/browser/components/migration/tests/unit/test_PasswordFileMigrator.js new file mode 100644 index 0000000000..e22f207c5d --- /dev/null +++ b/browser/components/migration/tests/unit/test_PasswordFileMigrator.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PasswordFileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { MigrationWizardConstants } = ChromeUtils.importESModule( + "chrome://browser/content/migration/migration-wizard-constants.mjs" +); + +add_setup(async function () { + Services.prefs.setBoolPref("signon.management.page.fileImport.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.management.page.fileImport.enabled"); + }); +}); + +/** + * Tests that the PasswordFileMigrator properly subclasses FileMigratorBase + * and delegates to the LoginCSVImport module. + */ +add_task(async function test_PasswordFileMigrator() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new PasswordFileMigrator(); + Assert.ok( + migrator.constructor.key, + "PasswordFileMigrator implements static getter 'key'" + ); + Assert.ok( + migrator.constructor.displayNameL10nID, + "PasswordFileMigrator implements static getter 'displayNameL10nID'" + ); + Assert.ok( + await migrator.getFilePickerConfig(), + "PasswordFileMigrator returns something for getFilePickerConfig()" + ); + Assert.ok( + migrator.displayedResourceTypes, + "PasswordFileMigrator returns something for displayedResourceTypes" + ); + Assert.ok(migrator.enabled, "PasswordFileMigrator is enabled."); + + const IMPORT_SUMMARY = [ + { + result: "added", + }, + { + result: "added", + }, + { + result: "modified", + }, + ]; + const EXPECTED_SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: + "2 added", + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED]: + "1 updated", + }; + const FAKE_PATH = "some/fake/path.csv"; + + let importFromCSVStub = sandbox + .stub(LoginCSVImport, "importFromCSV") + .callsFake(somePath => { + Assert.equal(somePath, FAKE_PATH, "Got expected path"); + return Promise.resolve(IMPORT_SUMMARY); + }); + let result = await migrator.migrate(FAKE_PATH); + + Assert.ok(importFromCSVStub.called, "The stub should have been called."); + Assert.deepEqual( + result, + EXPECTED_SUCCESS_STATE, + "Got back the expected success state." + ); + + sandbox.restore(); +}); + +/** + * Tests that the PasswordFileMigrator will throw an exception with a + * consistent error message if the LoginCSVImport function rejects. + */ +add_task(async function test_PasswordFileMigrator_exception() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new PasswordFileMigrator(); + + const FAKE_PATH = "some/fake/path.csv"; + + sandbox.stub(LoginCSVImport, "importFromCSV").callsFake(() => { + return Promise.reject("Some error"); + }); + + await Assert.rejects( + migrator.migrate(FAKE_PATH), + /The file doesn’t include any valid password data/ + ); + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/unit/test_Safari_bookmarks.js b/browser/components/migration/tests/unit/test_Safari_bookmarks.js new file mode 100644 index 0000000000..85be9f0049 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_bookmarks.js @@ -0,0 +1,85 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +add_task(async function () { + registerFakePath("ULibDir", do_get_file("Library/")); + const faviconPath = do_get_file( + "Library/Safari/Favicon Cache/favicons.db" + ).path; + + let migrator = await MigrationUtils.getMigrator("safari"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + // Wait for the imported bookmarks. We don't check that "From Safari" + // folders are created on the toolbar since the profile + // we're importing to has less than 3 bookmarks in the destination + // so a "From Safari" folder isn't created. + let expectedParentGuids = [PlacesUtils.bookmarks.toolbarGuid]; + let itemCount = 0; + + let gotFolder = false; + let listener = events => { + for (let event of events) { + itemCount++; + if ( + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER && + event.title == "Food and Travel" + ) { + gotFolder = true; + } + if (expectedParentGuids.length) { + let index = expectedParentGuids.indexOf(event.parentGuid); + Assert.ok(index != -1, "Found expected parent"); + expectedParentGuids.splice(index, 1); + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(!expectedParentGuids.length, "No more expected parents"); + Assert.ok(gotFolder, "Should have seen the folder get imported"); + Assert.equal(itemCount, 14, "Should import all 14 items."); + // Check that the telemetry matches: + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemCount, + "Telemetry reporting correct." + ); + + // Check that favicons migrated + let faviconURIs = await MigrationUtils.getRowsFromDBWithoutLocks( + faviconPath, + "Safari Bookmark Favicons", + `SELECT I.uuid, I.url AS favicon_url, P.url + FROM icon_info I + INNER JOIN page_url P ON I.uuid = P.uuid;` + ); + let pageUrls = Array.from(faviconURIs, row => + Services.io.newURI(row.getResultByName("url")) + ); + await assertFavicons(pageUrls); + Assert.ok(observerNotified, "The observer should be notified upon migration"); +}); diff --git a/browser/components/migration/tests/unit/test_Safari_history.js b/browser/components/migration/tests/unit/test_Safari_history.js new file mode 100644 index 0000000000..c5b1210073 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_history.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const HISTORY_TEMPLATE_FILE_PATH = "Library/Safari/HistoryTemplate.db"; +const HISTORY_FILE_PATH = "Library/Safari/History.db"; + +// We want this to be some recent time, so we'll always add some time to our +// dates to keep them ~ five days ago. +const MS_FROM_REFERENCE_TIME = + new Date() - new Date("May 31, 2023 00:00:00 UTC"); + +const TEST_URLS = [ + { + url: "http://example.com/", + title: "Example Domain", + time: 706743588.04751, + jsTime: 1685050788047 + MS_FROM_REFERENCE_TIME, + visits: 1, + }, + { + url: "http://mozilla.org/", + title: "", + time: 706743581.133386, + jsTime: 1685050781133 + MS_FROM_REFERENCE_TIME, + visits: 1, + }, + { + url: "https://www.mozilla.org/en-CA/", + title: "Internet for people, not profit - Mozilla", + time: 706743581.133679, + jsTime: 1685050781133 + MS_FROM_REFERENCE_TIME, + visits: 1, + }, +]; + +async function setupHistoryFile() { + removeHistoryFile(); + let file = do_get_file(HISTORY_TEMPLATE_FILE_PATH); + file.copyTo(file.parent, "History.db"); + await updateVisitTimes(); +} + +function removeHistoryFile() { + let file = do_get_file(HISTORY_FILE_PATH, true); + try { + file.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } +} + +async function updateVisitTimes() { + let cocoaDifference = MS_FROM_REFERENCE_TIME / 1000; + let historyFile = do_get_file(HISTORY_FILE_PATH); + let dbConn = await Sqlite.openConnection({ path: historyFile.path }); + await dbConn.execute( + "UPDATE history_visits SET visit_time = visit_time + :difference;", + { difference: cocoaDifference } + ); + await dbConn.close(); +} + +add_setup(async function setup() { + registerFakePath("ULibDir", do_get_file("Library/")); + await setupHistoryFile(); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + removeHistoryFile(); + }); +}); + +add_task(async function testHistoryImport() { + await PlacesUtils.history.clear(); + + let migrator = await MigrationUtils.getMigrator("safari"); + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + + Assert.equal(entry.url, urlInfo.url, "Should have the correct URL"); + Assert.equal(entry.title, urlInfo.title, "Should have the correct title"); + Assert.equal( + entry.visits.length, + urlInfo.visits, + "Should have the correct number of visits" + ); + Assert.equal( + entry.visits[0].date.getTime(), + urlInfo.jsTime, + "Should have the correct date" + ); + } +}); diff --git a/browser/components/migration/tests/unit/test_Safari_history_strange_entries.js b/browser/components/migration/tests/unit/test_Safari_history_strange_entries.js new file mode 100644 index 0000000000..2578353e35 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_history_strange_entries.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PlacesQuery } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesQuery.sys.mjs" +); + +const HISTORY_FILE_PATH = "Library/Safari/History.db"; +const HISTORY_STRANGE_ENTRIES_FILE_PATH = + "Library/Safari/HistoryStrangeEntries.db"; + +// By default, our migrators will cut off migrating any history older than +// 180 days. In order to make sure this test continues to run correctly +// in the future, we copy the reference database to History.db, and then +// use Sqlite.sys.mjs to connect to it and manually update all of the visit +// times to be "now", so that they all fall within the 180 day window. The +// Nov 10th date below is right around when the reference database visit +// entries were created. +// +// This update occurs in `updateVisitTimes`. +const MS_SINCE_SNAPSHOT_TIME = + new Date() - new Date("Nov 10, 2022 00:00:00 UTC"); + +async function setupHistoryFile() { + removeHistoryFile(); + let file = do_get_file(HISTORY_STRANGE_ENTRIES_FILE_PATH); + file.copyTo(file.parent, "History.db"); + await updateVisitTimes(); +} + +function removeHistoryFile() { + let file = do_get_file(HISTORY_FILE_PATH, true); + try { + file.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } +} + +add_setup(async function setup() { + registerFakePath("ULibDir", do_get_file("Library/")); + await setupHistoryFile(); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + removeHistoryFile(); + }); +}); + +async function updateVisitTimes() { + let cocoaSnapshotDelta = MS_SINCE_SNAPSHOT_TIME / 1000; + let historyFile = do_get_file(HISTORY_FILE_PATH); + let dbConn = await Sqlite.openConnection({ path: historyFile.path }); + + await dbConn.execute( + "UPDATE history_visits SET visit_time = visit_time + :cocoaSnapshotDelta;", + { + cocoaSnapshotDelta, + } + ); + + await dbConn.close(); +} + +/** + * Tests that we can import successfully from Safari when Safari's history + * database contains malformed URLs. + */ +add_task(async function testHistoryImportStrangeEntries() { + await PlacesUtils.history.clear(); + + let placesQuery = new PlacesQuery(); + let emptyHistory = await placesQuery.getHistory(); + Assert.equal(emptyHistory.size, 0, "Empty history should indeed be empty."); + + const EXPECTED_MIGRATED_SITES = 10; + const EXPECTED_MIGRATED_VISTS = 23; + + let historyFile = do_get_file(HISTORY_FILE_PATH); + let dbConn = await Sqlite.openConnection({ path: historyFile.path }); + let [rowCountResult] = await dbConn.execute( + "SELECT COUNT(*) FROM history_visits" + ); + Assert.greater( + rowCountResult.getResultByName("COUNT(*)"), + EXPECTED_MIGRATED_VISTS, + "There are more total rows than valid rows" + ); + await dbConn.close(); + + let migrator = await MigrationUtils.getMigrator("safari"); + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + let migratedHistory = await placesQuery.getHistory({ sortBy: "site" }); + let siteCount = migratedHistory.size; + let visitCount = 0; + for (let [, visits] of migratedHistory) { + visitCount += visits.length; + } + Assert.equal( + siteCount, + EXPECTED_MIGRATED_SITES, + "Should have migrated all valid history sites" + ); + Assert.equal( + visitCount, + EXPECTED_MIGRATED_VISTS, + "Should have migrated all valid history visits" + ); + + placesQuery.close(); +}); diff --git a/browser/components/migration/tests/unit/test_Safari_permissions.js b/browser/components/migration/tests/unit/test_Safari_permissions.js new file mode 100644 index 0000000000..eaa6c7788e --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_permissions.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if the migrator does not have the permission to + * read from the Safari data directory, that it can request + * permission to read from it, if the system allows. + */ + +const { SafariProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/SafariProfileMigrator.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const { MockFilePicker } = ChromeUtils.importESModule( + "resource://testing-common/MockFilePicker.sys.mjs" +); + +let gDataDir; + +add_setup(async () => { + let tempDir = do_get_tempdir(); + gDataDir = PathUtils.join(tempDir.path, "Safari"); + await IOUtils.makeDirectory(gDataDir); + + registerFakePath("ULibDir", tempDir); + + MockFilePicker.init(globalThis); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +/** + * Tests that canGetPermissions will return false if the platform does + * not allow for folder selection in the native file picker, and returns + * the data path otherwise. + */ +add_task(async function test_canGetPermissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new SafariProfileMigrator(); + // Not being able to get a folder picker is not a problem on macOS, but + // we'll test that case anyways. + let canGetPermissionsStub = sandbox + .stub(MigrationUtils, "canGetPermissionsOnPlatform") + .resolves(false); + + Assert.ok( + !(await migrator.canGetPermissions()), + "Should not be able to get permissions." + ); + + canGetPermissionsStub.resolves(true); + + Assert.equal( + await migrator.canGetPermissions(), + gDataDir, + "Should be able to get the permissions path." + ); + + sandbox.restore(); +}); + +/** + * Tests that getPermissions will show the native file picker in a + * loop until either the user cancels or selects a folder that grants + * read permissions to the data directory. + */ +add_task(async function test_getPermissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let migrator = new SafariProfileMigrator(); + sandbox.stub(MigrationUtils, "canGetPermissionsOnPlatform").resolves(true); + let hasPermissionsStub = sandbox + .stub(migrator, "hasPermissions") + .resolves(false); + + Assert.equal( + await migrator.canGetPermissions(), + gDataDir, + "Should be able to get the permissions path." + ); + + let filePickerSeenCount = 0; + + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + MockFilePicker.useDirectory([gDataDir]); + filePickerSeenCount++; + if (filePickerSeenCount > 3) { + Assert.ok(true, "File picker looped 3 times."); + hasPermissionsStub.resolves(true); + resolve(); + } + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + // This is a little awkward, but we need to ensure that the + // filePickerShownPromise resolves first before we await + // the getPermissionsPromise in order to get the correct + // filePickerSeenCount. + let getPermissionsPromise = migrator.getPermissions(); + await filePickerShownPromise; + Assert.ok( + await getPermissionsPromise, + "Should report that we got permissions." + ); + + // Make sure that the user can also hit "cancel" and that we + // file picker loop. + + hasPermissionsStub.resolves(false); + + filePickerSeenCount = 0; + filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown."); + filePickerSeenCount++; + Assert.equal(filePickerSeenCount, 1, "Saw the picker once."); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnCancel; + Assert.ok( + !(await migrator.getPermissions()), + "Should report that we didn't get permissions." + ); + await filePickerShownPromise; + + sandbox.restore(); +}); diff --git a/browser/components/migration/tests/unit/test_fx_telemetry.js b/browser/components/migration/tests/unit/test_fx_telemetry.js new file mode 100644 index 0000000000..68e34beab3 --- /dev/null +++ b/browser/components/migration/tests/unit/test_fx_telemetry.js @@ -0,0 +1,436 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const { FirefoxProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FirefoxProfileMigrator.sys.mjs" +); +const { InternalTestingProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/InternalTestingProfileMigrator.sys.mjs" +); +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); +const { PasswordFileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// These preferences are set to true anytime MigratorBase.migrate +// successfully completes a migration of their type. +const BOOKMARKS_PREF = "browser.migrate.interactions.bookmarks"; +const CSV_PASSWORDS_PREF = "browser.migrate.interactions.csvpasswords"; +const HISTORY_PREF = "browser.migrate.interactions.history"; +const PASSWORDS_PREF = "browser.migrate.interactions.passwords"; + +function readFile(file) { + let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + stream.init(file, -1, -1, Ci.nsIFileInputStream.CLOSE_ON_EOF); + + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(stream); + let contents = sis.read(file.fileSize); + sis.close(); + return contents; +} + +function checkDirectoryContains(dir, files) { + print("checking " + dir.path + " - should contain " + Object.keys(files)); + let seen = new Set(); + for (let file of dir.directoryEntries) { + print("found file: " + file.path); + Assert.ok(file.leafName in files, file.leafName + " exists, but shouldn't"); + + let expectedContents = files[file.leafName]; + if (typeof expectedContents != "string") { + // it's a subdir - recurse! + Assert.ok(file.isDirectory(), "should be a subdir"); + let newDir = dir.clone(); + newDir.append(file.leafName); + checkDirectoryContains(newDir, expectedContents); + } else { + Assert.ok(!file.isDirectory(), "should be a regular file"); + let contents = readFile(file); + Assert.equal(contents, expectedContents); + } + seen.add(file.leafName); + } + let missing = []; + for (let x in files) { + if (!seen.has(x)) { + missing.push(x); + } + } + Assert.deepEqual(missing, [], "no missing files in " + dir.path); +} + +function getTestDirs() { + // we make a directory structure in a temp dir which mirrors what we are + // testing. + let tempDir = do_get_tempdir(); + let srcDir = tempDir.clone(); + srcDir.append("test_source_dir"); + srcDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let targetDir = tempDir.clone(); + targetDir.append("test_target_dir"); + targetDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + // no need to cleanup these dirs - the xpcshell harness will do it for us. + return [srcDir, targetDir]; +} + +function writeToFile(dir, leafName, contents) { + let file = dir.clone(); + file.append(leafName); + + let outputStream = FileUtils.openFileOutputStream(file); + outputStream.write(contents, contents.length); + outputStream.close(); +} + +function createSubDir(dir, subDirName) { + let subDir = dir.clone(); + subDir.append(subDirName); + subDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + return subDir; +} + +async function promiseMigrator(name, srcDir, targetDir) { + // As the FirefoxProfileMigrator is a startup-only migrator, we import its + // module and instantiate it directly rather than going through MigrationUtils, + // to bypass that availability check. + let migrator = new FirefoxProfileMigrator(); + let migrators = migrator._getResourcesInternal(srcDir, targetDir); + for (let m of migrators) { + if (m.name == name) { + return new Promise(resolve => m.migrate(resolve)); + } + } + throw new Error("failed to find the " + name + " migrator"); +} + +function promiseTelemetryMigrator(srcDir, targetDir) { + return promiseMigrator("telemetry", srcDir, targetDir); +} + +add_task(async function test_empty() { + let [srcDir, targetDir] = getTestDirs(); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true with empty directories"); + // check both are empty + checkDirectoryContains(srcDir, {}); + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_migrate_files() { + let [srcDir, targetDir] = getTestDirs(); + + // Set up datareporting files, some to copy, some not. + let stateContent = JSON.stringify({ + clientId: "68d5474e-19dc-45c1-8e9a-81fca592707c", + }); + let sessionStateContent = "foobar 5432"; + let subDir = createSubDir(srcDir, "datareporting"); + writeToFile(subDir, "state.json", stateContent); + writeToFile(subDir, "session-state.json", sessionStateContent); + writeToFile(subDir, "other.file", "do not copy"); + + let archived = createSubDir(subDir, "archived"); + writeToFile(archived, "other.file", "do not copy"); + + // Set up FHR files, they should not be copied. + writeToFile(srcDir, "healthreport.sqlite", "do not copy"); + writeToFile(srcDir, "healthreport.sqlite-wal", "do not copy"); + subDir = createSubDir(srcDir, "healthreport"); + writeToFile(subDir, "state.json", "do not copy"); + writeToFile(subDir, "other.file", "do not copy"); + + // Perform migration. + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok( + ok, + "callback should have been true with important telemetry files copied" + ); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": stateContent, + "session-state.json": sessionStateContent, + }, + }); +}); + +add_task(async function test_datareporting_not_dir() { + let [srcDir, targetDir] = getTestDirs(); + + writeToFile(srcDir, "datareporting", "I'm a file but should be a directory"); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok( + ok, + "callback should have been true even though the directory was a file" + ); + + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_datareporting_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with an empty 'datareporting' subdir. + createSubDir(srcDir, "datareporting"); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, { + datareporting: {}, + }); +}); + +add_task(async function test_healthreport_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with no 'datareporting' and an empty 'healthreport' subdir. + createSubDir(srcDir, "healthreport"); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_datareporting_many() { + let [srcDir, targetDir] = getTestDirs(); + + // Create some datareporting files. + let subDir = createSubDir(srcDir, "datareporting"); + let shouldBeCopied = "should be copied"; + writeToFile(subDir, "state.json", shouldBeCopied); + writeToFile(subDir, "session-state.json", shouldBeCopied); + writeToFile(subDir, "something.else", "should not"); + createSubDir(subDir, "emptyDir"); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": shouldBeCopied, + "session-state.json": shouldBeCopied, + }, + }); +}); + +add_task(async function test_no_session_state() { + let [srcDir, targetDir] = getTestDirs(); + + // Check that migration still works properly if we only have state.json. + let subDir = createSubDir(srcDir, "datareporting"); + let stateContent = "abcd984"; + writeToFile(subDir, "state.json", stateContent); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": stateContent, + }, + }); +}); + +add_task(async function test_no_state() { + let [srcDir, targetDir] = getTestDirs(); + + // Check that migration still works properly if we only have session-state.json. + let subDir = createSubDir(srcDir, "datareporting"); + let sessionStateContent = "abcd512"; + writeToFile(subDir, "session-state.json", sessionStateContent); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "session-state.json": sessionStateContent, + }, + }); +}); + +add_task(async function test_times_migration() { + let [srcDir, targetDir] = getTestDirs(); + + // create a times.json in the source directory. + let contents = JSON.stringify({ created: 1234 }); + writeToFile(srcDir, "times.json", contents); + + let earliest = Date.now(); + let ok = await promiseMigrator("times", srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + let latest = Date.now(); + + let timesFile = targetDir.clone(); + timesFile.append("times.json"); + + let raw = readFile(timesFile); + let times = JSON.parse(raw); + Assert.ok(times.reset >= earliest && times.reset <= latest); + // and it should have left the creation time alone. + Assert.equal(times.created, 1234); +}); + +/** + * Tests that when importing bookmarks, history, or passwords, we + * set interaction prefs. These preferences are sent using + * TelemetryEnvironment.sys.mjs. + */ +add_task(async function test_interaction_telemetry() { + let testingMigrator = await MigrationUtils.getMigrator( + InternalTestingProfileMigrator.key + ); + + Services.prefs.clearUserPref(BOOKMARKS_PREF); + Services.prefs.clearUserPref(HISTORY_PREF); + Services.prefs.clearUserPref(PASSWORDS_PREF); + + // Ensure that these prefs start false. + Assert.ok(!Services.prefs.getBoolPref(BOOKMARKS_PREF)); + Assert.ok(!Services.prefs.getBoolPref(HISTORY_PREF)); + Assert.ok(!Services.prefs.getBoolPref(PASSWORDS_PREF)); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.BOOKMARKS, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok(!Services.prefs.getBoolPref(HISTORY_PREF)); + Assert.ok(!Services.prefs.getBoolPref(PASSWORDS_PREF)); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.HISTORY, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(HISTORY_PREF), + "History pref should have been set." + ); + Assert.ok(!Services.prefs.getBoolPref(PASSWORDS_PREF)); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.PASSWORDS, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(HISTORY_PREF), + "History pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(PASSWORDS_PREF), + "Passwords pref should have been set." + ); + + // Now make sure that we still record these if we migrate a + // series of resources at the same time. + Services.prefs.clearUserPref(BOOKMARKS_PREF); + Services.prefs.clearUserPref(HISTORY_PREF); + Services.prefs.clearUserPref(PASSWORDS_PREF); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.ALL, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(HISTORY_PREF), + "History pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(PASSWORDS_PREF), + "Passwords pref should have been set." + ); +}); + +/** + * Tests that when importing passwords from a CSV file using the + * migration wizard, we set an interaction pref. This preference + * is sent using TelemetryEnvironment.sys.mjs. + */ +add_task(async function test_csv_password_interaction_telemetry() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + let testingMigrator = new PasswordFileMigrator(); + + Services.prefs.clearUserPref(CSV_PASSWORDS_PREF); + Assert.ok(!Services.prefs.getBoolPref(CSV_PASSWORDS_PREF)); + + sandbox.stub(LoginCSVImport, "importFromCSV").resolves([]); + await testingMigrator.migrate("some/fake/path.csv"); + + Assert.ok( + Services.prefs.getBoolPref(CSV_PASSWORDS_PREF), + "CSV import pref should have been set." + ); + + sandbox.restore(); +}); + +/** + * Tests that interaction preferences used for TelemetryEnvironment are + * persisted across profile resets. + */ +add_task(async function test_interaction_telemetry_persist_across_reset() { + const PREFS = ` +user_pref("${BOOKMARKS_PREF}", true); +user_pref("${CSV_PASSWORDS_PREF}", true); +user_pref("${HISTORY_PREF}", true); +user_pref("${PASSWORDS_PREF}", true); + `; + + let [srcDir, targetDir] = getTestDirs(); + writeToFile(srcDir, "prefs.js", PREFS); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + let prefsPath = PathUtils.join(targetDir.path, "prefs.js"); + Assert.ok(await IOUtils.exists(prefsPath), "Prefs should have been written."); + let writtenPrefsString = await IOUtils.readUTF8(prefsPath); + for (let prefKey of [ + BOOKMARKS_PREF, + CSV_PASSWORDS_PREF, + HISTORY_PREF, + PASSWORDS_PREF, + ]) { + const EXPECTED = `user_pref("${prefKey}", true);`; + Assert.ok(writtenPrefsString.includes(EXPECTED), "Found persisted pref."); + } +}); diff --git a/browser/components/migration/tests/unit/xpcshell.toml b/browser/components/migration/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..b599a64362 --- /dev/null +++ b/browser/components/migration/tests/unit/xpcshell.toml @@ -0,0 +1,95 @@ +[DEFAULT] +head = "head_migration.js" +tags = "condprof" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] # bug 1730213 +prefs = ["browser.migrate.showBookmarksToolbarAfterMigration=true"] +support-files = [ + "Library/**", + "AppData/**", + "bookmarks.exported.html", + "bookmarks.exported.json", + "bookmarks.invalid.html", +] + +["test_360seMigrationUtils.js"] +run-if = ["os == 'win'"] + +["test_360se_bookmarks.js"] +run-if = ["os == 'win'"] + +["test_BookmarksFileMigrator.js"] + +["test_ChromeMigrationUtils.js"] + +["test_ChromeMigrationUtils_path.js"] + +["test_ChromeMigrationUtils_path_chromium_snap.js"] +run-if = ["os == 'linux'"] + +["test_Chrome_bookmarks.js"] + +["test_Chrome_corrupt_history.js"] + +["test_Chrome_credit_cards.js"] +skip-if = [ + "os == 'linux'", + "os == 'android'", + "condprof", # bug 1769154 - not realistic for condprof +] + +["test_Chrome_extensions.js"] + +["test_Chrome_formdata.js"] + +["test_Chrome_history.js"] +skip-if = ["os != 'mac'"] # Relies on ULibDir + +["test_Chrome_passwords.js"] +skip-if = [ + "os == 'linux'", + "os == 'android'", + "condprof", # bug 1769154 - not realistic for condprof +] + +["test_Chrome_passwords_emptySource.js"] +skip-if = [ + "os == 'linux'", + "os == 'android'", + "condprof", # bug 1769154 - not realistic for condprof +] +support-files = ["LibraryWithNoData/**"] + +["test_Chrome_permissions.js"] + +["test_Edge_db_migration.js"] +run-if = ["os == 'win'"] + +["test_Edge_registry_migration.js"] +run-if = ["os == 'win'"] + +["test_IE_bookmarks.js"] +run-if = ["os == 'win' && bits == 64"] # bug 1392396 + +["test_IE_history.js"] +run-if = ["os == 'win'"] +skip-if = ["os == 'win' && msix"] # https://bugzilla.mozilla.org/show_bug.cgi?id=1807928 + +["test_MigrationUtils_timedRetry.js"] +skip-if = ["os == 'mac' && !debug"] #Bug 1558330 + +["test_PasswordFileMigrator.js"] + +["test_Safari_bookmarks.js"] +run-if = ["os == 'mac'"] + +["test_Safari_history.js"] +run-if = ["os == 'mac'"] + +["test_Safari_history_strange_entries.js"] +run-if = ["os == 'mac'"] + +["test_Safari_permissions.js"] +run-if = ["os == 'mac'"] + +["test_fx_telemetry.js"] |