summaryrefslogtreecommitdiffstats
path: root/browser/components/migration
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/migration/.eslintrc.js43
-rw-r--r--browser/components/migration/360seMigrationUtils.sys.mjs191
-rw-r--r--browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs185
-rw-r--r--browser/components/migration/ChromeMigrationUtils.sys.mjs469
-rw-r--r--browser/components/migration/ChromeProfileMigrator.sys.mjs1018
-rw-r--r--browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs176
-rw-r--r--browser/components/migration/ESEDBReader.sys.mjs800
-rw-r--r--browser/components/migration/EdgeProfileMigrator.sys.mjs589
-rw-r--r--browser/components/migration/FileMigrators.sys.mjs329
-rw-r--r--browser/components/migration/FirefoxProfileMigrator.sys.mjs397
-rw-r--r--browser/components/migration/IEProfileMigrator.sys.mjs402
-rw-r--r--browser/components/migration/InternalTestingProfileMigrator.sys.mjs63
-rw-r--r--browser/components/migration/MSMigrationUtils.sys.mjs754
-rw-r--r--browser/components/migration/MigrationUtils.sys.mjs1171
-rw-r--r--browser/components/migration/MigrationWizardChild.sys.mjs328
-rw-r--r--browser/components/migration/MigrationWizardParent.sys.mjs651
-rw-r--r--browser/components/migration/MigratorBase.sys.mjs587
-rw-r--r--browser/components/migration/ProfileMigrator.sys.mjs15
-rw-r--r--browser/components/migration/SafariProfileMigrator.sys.mjs674
-rw-r--r--browser/components/migration/components.conf37
-rw-r--r--browser/components/migration/content/aboutWelcomeBack.xhtml126
-rw-r--r--browser/components/migration/content/brands/360.pngbin0 -> 21075 bytes
-rw-r--r--browser/components/migration/content/brands/brave.pngbin0 -> 7099 bytes
-rw-r--r--browser/components/migration/content/brands/canary.pngbin0 -> 7463 bytes
-rw-r--r--browser/components/migration/content/brands/chrome.pngbin0 -> 8353 bytes
-rw-r--r--browser/components/migration/content/brands/chromium.pngbin0 -> 6408 bytes
-rw-r--r--browser/components/migration/content/brands/edge.pngbin0 -> 11899 bytes
-rw-r--r--browser/components/migration/content/brands/edgebeta.pngbin0 -> 12273 bytes
-rw-r--r--browser/components/migration/content/brands/ie.pngbin0 -> 6871 bytes
-rw-r--r--browser/components/migration/content/brands/opera.pngbin0 -> 5403 bytes
-rw-r--r--browser/components/migration/content/brands/operagx.pngbin0 -> 8222 bytes
-rw-r--r--browser/components/migration/content/brands/safari.pngbin0 -> 20520 bytes
-rw-r--r--browser/components/migration/content/brands/vivaldi.pngbin0 -> 7535 bytes
-rw-r--r--browser/components/migration/content/migration-dialog-window.html34
-rw-r--r--browser/components/migration/content/migration-dialog-window.js82
-rw-r--r--browser/components/migration/content/migration-wizard-constants.mjs76
-rw-r--r--browser/components/migration/content/migration-wizard.mjs1088
-rw-r--r--browser/components/migration/content/migration.js812
-rw-r--r--browser/components/migration/content/migration.xhtml113
-rw-r--r--browser/components/migration/docs/index.rst16
-rw-r--r--browser/components/migration/docs/migration-utils.rst5
-rw-r--r--browser/components/migration/docs/migration-wizard-architecture-diagram.svg128
-rw-r--r--browser/components/migration/docs/migration-wizard.rst77
-rw-r--r--browser/components/migration/docs/migrators.rst112
-rw-r--r--browser/components/migration/jar.mn33
-rw-r--r--browser/components/migration/moz.build85
-rw-r--r--browser/components/migration/nsEdgeMigrationUtils.cpp61
-rw-r--r--browser/components/migration/nsEdgeMigrationUtils.h24
-rw-r--r--browser/components/migration/nsIEHistoryEnumerator.cpp116
-rw-r--r--browser/components/migration/nsIEHistoryEnumerator.h39
-rw-r--r--browser/components/migration/nsIEdgeMigrationUtils.idl23
-rw-r--r--browser/components/migration/nsIKeychainMigrationUtils.idl12
-rw-r--r--browser/components/migration/nsKeychainMigrationUtils.h23
-rw-r--r--browser/components/migration/nsKeychainMigrationUtils.mm62
-rw-r--r--browser/components/migration/nsWindowsMigrationUtils.h33
-rw-r--r--browser/components/migration/tests/browser/browser.ini26
-rw-r--r--browser/components/migration/tests/browser/browser_aboutwelcome_behavior.js100
-rw-r--r--browser/components/migration/tests/browser/browser_dialog_cancel_close.js55
-rw-r--r--browser/components/migration/tests/browser/browser_dialog_open.js55
-rw-r--r--browser/components/migration/tests/browser/browser_dialog_resize.js29
-rw-r--r--browser/components/migration/tests/browser/browser_disabled_migrator.js131
-rw-r--r--browser/components/migration/tests/browser/browser_do_migration.js195
-rw-r--r--browser/components/migration/tests/browser/browser_entrypoint_telemetry.js105
-rw-r--r--browser/components/migration/tests/browser/browser_file_migration.js185
-rw-r--r--browser/components/migration/tests/browser/browser_ie_edge_bookmarks_success_strings.js89
-rw-r--r--browser/components/migration/tests/browser/browser_no_browsers_state.js92
-rw-r--r--browser/components/migration/tests/browser/browser_only_file_migrators.js71
-rw-r--r--browser/components/migration/tests/browser/browser_safari_passwords.js401
-rw-r--r--browser/components/migration/tests/browser/browser_safari_permissions.js133
-rw-r--r--browser/components/migration/tests/browser/dummy_file.csv1
-rw-r--r--browser/components/migration/tests/browser/head.js350
-rw-r--r--browser/components/migration/tests/chrome/chrome.ini4
-rw-r--r--browser/components/migration/tests/chrome/test_migration_wizard.html1154
-rw-r--r--browser/components/migration/tests/marionette/manifest.ini4
-rw-r--r--browser/components/migration/tests/marionette/test_refresh_firefox.py690
-rw-r--r--browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Faviconsbin0 -> 49152 bytes
-rw-r--r--browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Databin0 -> 24576 bytes
-rw-r--r--browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Databin0 -> 108544 bytes
-rw-r--r--browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State5
-rw-r--r--browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Databin0 -> 24576 bytes
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.datbin0 -> 6144 bytes
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks1
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb3
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb3
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb1
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb1
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat1
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb3
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks3
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State12
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookiesbin0 -> 10240 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json9
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json10
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json9
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json5
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json5
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorruptbin0 -> 91558 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMasterbin0 -> 118784 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Databin0 -> 24576 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State22
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Bookmarks.plistbin0 -> 2252 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.dbbin0 -> 40960 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock0
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shmbin0 -> 32768 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-walbin0 -> 280192 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9bin0 -> 24838 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271bin0 -> 15086 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42bin0 -> 5558 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804bin0 -> 5558 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80bin0 -> 22382 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880Fbin0 -> 2734 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8bin0 -> 15406 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374Abin0 -> 5558 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBCbin0 -> 5558 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08bin0 -> 22382 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52Bbin0 -> 15406 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137bin0 -> 5430 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.dbbin0 -> 98304 bytes
-rw-r--r--browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Databin0 -> 24576 bytes
-rw-r--r--browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State22
-rw-r--r--browser/components/migration/tests/unit/bookmarks.exported.html32
-rw-r--r--browser/components/migration/tests/unit/bookmarks.exported.json194
-rw-r--r--browser/components/migration/tests/unit/head_migration.js261
-rw-r--r--browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp37
-rw-r--r--browser/components/migration/tests/unit/insertIEHistory/moz.build19
-rw-r--r--browser/components/migration/tests/unit/test_360seMigrationUtils.js164
-rw-r--r--browser/components/migration/tests/unit/test_360se_bookmarks.js62
-rw-r--r--browser/components/migration/tests/unit/test_BookmarksFileMigrator.js120
-rw-r--r--browser/components/migration/tests/unit/test_ChromeMigrationUtils.js86
-rw-r--r--browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js141
-rw-r--r--browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js55
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_bookmarks.js199
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_corrupt_history.js83
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_credit_cards.js239
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_formdata.js118
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_history.js206
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_passwords.js373
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js24
-rw-r--r--browser/components/migration/tests/unit/test_Edge_db_migration.js849
-rw-r--r--browser/components/migration/tests/unit/test_Edge_registry_migration.js81
-rw-r--r--browser/components/migration/tests/unit/test_IE7_passwords.js493
-rw-r--r--browser/components/migration/tests/unit/test_IE_bookmarks.js30
-rw-r--r--browser/components/migration/tests/unit/test_IE_history.js187
-rw-r--r--browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js29
-rw-r--r--browser/components/migration/tests/unit/test_PasswordFileMigrator.js83
-rw-r--r--browser/components/migration/tests/unit/test_Safari_bookmarks.js85
-rw-r--r--browser/components/migration/tests/unit/test_Safari_history.js101
-rw-r--r--browser/components/migration/tests/unit/test_fx_telemetry.js393
-rw-r--r--browser/components/migration/tests/unit/xpcshell.ini58
149 files changed, 20846 insertions, 0 deletions
diff --git a/browser/components/migration/.eslintrc.js b/browser/components/migration/.eslintrc.js
new file mode 100644
index 0000000000..236a559d13
--- /dev/null
+++ b/browser/components/migration/.eslintrc.js
@@ -0,0 +1,43 @@
+/* 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-fallthrough": [
+ "error",
+ {
+ commentPattern:
+ ".*[Ii]ntentional(?:ly)?\\s+fall(?:ing)?[\\s-]*through.*",
+ },
+ ],
+ "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: ["tests/browser/head.js", "tests/unit/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..91bf263d74
--- /dev/null
+++ b/browser/components/migration/360seMigrationUtils.sys.mjs
@@ -0,0 +1,191 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+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",
+});
+
+XPCOMUtils.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..99a8987bef
--- /dev/null
+++ b/browser/components/migration/ChromeMigrationUtils.sys.mjs
@@ -0,0 +1,469 @@
+/* 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} The locale string.
+ */
+ async _getLocaleString(key, locale, extensionId, profileId) {
+ // 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} dataPath the type of Chrome data we're looking for (Chromium, Canary, etc.)
+ * @returns {object} The JSON-based content.
+ */
+ async getLocalState(dataPath = "Chrome") {
+ let localState = null;
+ try {
+ let localStatePath = PathUtils.join(
+ await this.getDataPath(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 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"],
+ ["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 = 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..fc20dc6e94
--- /dev/null
+++ b/browser/components/migration/ChromeProfileMigrator.sys.mjs
@@ -0,0 +1,1018 @@
+/* -*- 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+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",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ Qihoo360seMigrationUtils: "resource:///modules/360seMigrationUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+/**
+ * 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 {
+ 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";
+ }
+
+ _keychainServiceName = "Chrome Safe Storage";
+
+ _keychainAccountName = "Chrome";
+
+ async _getChromeUserDataPathIfExists() {
+ if (this._chromeUserDataPath) {
+ return 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) {
+ 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),
+ ];
+ 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
+ );
+ 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 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 {
+ _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);
+ });
+ // 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 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);
+ });
+
+ 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;
+ }
+ // 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);
+ }
+ // 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 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 = await MigrationUtils.getRowsFromDBWithoutLocks(
+ historyPath,
+ "Chrome history",
+ query
+ );
+ 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 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 = await MigrationUtils.getRowsFromDBWithoutLocks(
+ formdataPath,
+ "Chrome formdata",
+ query
+ );
+ 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);
+ },
+ };
+}
+
+/**
+ * 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..41d38e52d3
--- /dev/null
+++ b/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs
@@ -0,0 +1,176 @@
+/* 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";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.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.
+ XPCOMUtils.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..53cff13636
--- /dev/null
+++ b/browser/components/migration/ESEDBReader.sys.mjs
@@ -0,0 +1,800 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.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..3483292225
--- /dev/null
+++ b/browser/components/migration/EdgeProfileMigrator.sys.mjs
@@ -0,0 +1,589 @@
+/* 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";
+
+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\\";
+
+XPCOMUtils.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(
+ "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" (on win10+)
+ * - |[]| to indicate "There are no profiles" (on <=win8.1) which will avoid
+ * using this migrator.
+ * See MigrationUtils.sys.mjs for slightly more info on how sourceProfiles is used.
+ */
+ getSourceProfiles() {
+ let isWin10OrHigher = AppConstants.isPlatformAndVersionAtLeast("win", "10");
+ return isWin10OrHigher ? null : [];
+ }
+}
diff --git a/browser/components/migration/FileMigrators.sys.mjs b/browser/components/migration/FileMigrators.sys.mjs
new file mode 100644
index 0000000000..27b8e9a618
--- /dev/null
+++ b/browser/components/migration/FileMigrators.sys.mjs
@@ -0,0 +1,329 @@
+/* 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 = {};
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+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",
+});
+
+XPCOMUtils.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) {
+ 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 },
+ },
+ ]);
+
+ return {
+ [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES
+ .PASSWORDS_NEW]: newMessage,
+ [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES
+ .PASSWORDS_UPDATED]: updatedMessage,
+ };
+ }
+}
+
+/**
+ * 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) {
+ 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);
+ }
+ 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,
+ };
+ }
+}
diff --git a/browser/components/migration/FirefoxProfileMigrator.sys.mjs b/browser/components/migration/FirefoxProfileMigrator.sys.mjs
new file mode 100644
index 0000000000..2ce57c2c7b
--- /dev/null
+++ b/browser/components/migration/FirefoxProfileMigrator.sys.mjs
@@ -0,0 +1,397 @@
+/* -*- 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";
+ }
+
+ _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, [
+ "signons.sqlite",
+ "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", "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..1a8c231b55
--- /dev/null
+++ b/browser/components/migration/IEProfileMigrator.sys.mjs
@@ -0,0 +1,402 @@
+/* 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 kLoginsKey =
+ "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2";
+
+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";
+import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ OSCrypto: "resource://gre/modules/OSCrypto_win.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ ctypes: "resource://gre/modules/ctypes.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)
+ ? lazy.PlacesUtils.history.TRANSITIONS.LINK
+ : lazy.PlacesUtils.history.TRANSITIONS.TYPED;
+
+ let time = entry.get("time");
+
+ let visitDate = time ? lazy.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)
+ );
+ },
+};
+
+// IE form password migrator supporting windows from XP until 7 and IE from 7 until 11
+function IE7FormPasswords() {
+ // used to distinguish between this migrator and other passwords migrators in tests.
+ this.name = "IE7FormPasswords";
+}
+
+IE7FormPasswords.prototype = {
+ type: MigrationUtils.resourceTypes.PASSWORDS,
+
+ get exists() {
+ // work only on windows until 7
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ return false;
+ }
+
+ try {
+ let nsIWindowsRegKey = Ci.nsIWindowsRegKey;
+ let key =
+ Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ nsIWindowsRegKey
+ );
+ key.open(
+ nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ kLoginsKey,
+ nsIWindowsRegKey.ACCESS_READ
+ );
+ let count = key.valueCount;
+ key.close();
+ return count > 0;
+ } catch (e) {
+ return false;
+ }
+ },
+
+ async migrate(aCallback) {
+ let uris = []; // the uris of the websites that are going to be migrated
+ for (let entry of Cc[
+ "@mozilla.org/profile/migrator/iehistoryenumerator;1"
+ ].createInstance(Ci.nsISimpleEnumerator)) {
+ let uri = 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 are not going to import the logins that are performed in these URLs
+ // we can just skip them.
+ if (!["http", "https", "ftp"].includes(uri.scheme)) {
+ continue;
+ }
+
+ uris.push(uri);
+ }
+ await this._migrateURIs(uris);
+ aCallback(true);
+ },
+
+ /**
+ * Migrate the logins that were saved for the uris arguments.
+ *
+ * @param {nsIURI[]} uris - the uris that are going to be migrated.
+ */
+ async _migrateURIs(uris) {
+ this.ctypesKernelHelpers = new MSMigrationUtils.CtypesKernelHelpers();
+ this._crypto = new lazy.OSCrypto();
+ let nsIWindowsRegKey = Ci.nsIWindowsRegKey;
+ let key =
+ Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ nsIWindowsRegKey
+ );
+ key.open(
+ nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ kLoginsKey,
+ nsIWindowsRegKey.ACCESS_READ
+ );
+
+ let urlsSet = new Set(); // set of the already processed urls.
+ // number of the successfully decrypted registry values
+ let successfullyDecryptedValues = 0;
+ /* The logins are stored in the registry, where the key is a hashed URL and its
+ * value contains the encrypted details for all logins for that URL.
+ *
+ * First iterate through IE history, hashing each URL and looking for a match. If
+ * found, decrypt the value, using the URL as a salt. Finally add any found logins
+ * to the Firefox password manager.
+ */
+
+ let logins = [];
+ for (let uri of uris) {
+ try {
+ // remove the query and the ref parts of the URL
+ let urlObject = new URL(uri.spec);
+ let url = urlObject.origin + urlObject.pathname;
+ // if the current url is already processed, it should be skipped
+ if (urlsSet.has(url)) {
+ continue;
+ }
+ urlsSet.add(url);
+ // hash value of the current uri
+ let hashStr = this._crypto.getIELoginHash(url);
+ if (!key.hasValue(hashStr)) {
+ continue;
+ }
+ let value = key.readBinaryValue(hashStr);
+ // if no value was found, the uri is skipped
+ if (value == null) {
+ continue;
+ }
+ let data;
+ try {
+ // the url is used as salt to decrypt the registry value
+ data = this._crypto.decryptData(value, url);
+ } catch (e) {
+ continue;
+ }
+ // extract the login details from the decrypted data
+ let ieLogins = this._extractDetails(data, uri);
+ // if at least a credential was found in the current data, successfullyDecryptedValues should
+ // be incremented by one
+ if (ieLogins.length) {
+ successfullyDecryptedValues++;
+ }
+ for (let ieLogin of ieLogins) {
+ logins.push({
+ username: ieLogin.username,
+ password: ieLogin.password,
+ origin: ieLogin.url,
+ timeCreated: ieLogin.creation,
+ });
+ }
+ } catch (e) {
+ console.error("Error while importing logins for ", uri.spec, ": ", e);
+ }
+ }
+
+ if (logins.length) {
+ await MigrationUtils.insertLoginsWrapper(logins);
+ }
+
+ // if the number of the imported values is less than the number of values in the key, it means
+ // that not all the values were imported and an error should be reported
+ if (successfullyDecryptedValues < key.valueCount) {
+ console.error(
+ "We failed to decrypt and import some logins. " +
+ "This is likely because we didn't find the URLs where these " +
+ "passwords were submitted in the IE history and which are needed to be used " +
+ "as keys in the decryption."
+ );
+ }
+
+ key.close();
+ this._crypto.finalize();
+ this.ctypesKernelHelpers.finalize();
+ },
+
+ _crypto: null,
+
+ /**
+ * Extract the details of one or more logins from the raw decrypted data.
+ *
+ * @param {string} data - the decrypted data containing raw information.
+ * @param {nsURI} uri - the nsURI of page where the login has occur.
+ * @returns {object[]} array of objects where each of them contains the username, password, URL,
+ * and creation time representing all the logins found in the data arguments.
+ */
+ _extractDetails(data, uri) {
+ // the structure of the header of the IE7 decrypted data for all the logins sharing the same URL
+ let loginData = new lazy.ctypes.StructType("loginData", [
+ // Bytes 0-3 are not needed and not documented
+ { unknown1: lazy.ctypes.uint32_t },
+ // Bytes 4-7 are the header size
+ { headerSize: lazy.ctypes.uint32_t },
+ // Bytes 8-11 are the data size
+ { dataSize: lazy.ctypes.uint32_t },
+ // Bytes 12-19 are not needed and not documented
+ { unknown2: lazy.ctypes.uint32_t },
+ { unknown3: lazy.ctypes.uint32_t },
+ // Bytes 20-23 are the data count: each username and password is considered as a data
+ { dataMax: lazy.ctypes.uint32_t },
+ // Bytes 24-35 are not needed and not documented
+ { unknown4: lazy.ctypes.uint32_t },
+ { unknown5: lazy.ctypes.uint32_t },
+ { unknown6: lazy.ctypes.uint32_t },
+ ]);
+
+ // the structure of a IE7 decrypted login item
+ let loginItem = new lazy.ctypes.StructType("loginItem", [
+ // Bytes 0-3 are the offset of the username
+ { usernameOffset: lazy.ctypes.uint32_t },
+ // Bytes 4-11 are the date
+ { loDateTime: lazy.ctypes.uint32_t },
+ { hiDateTime: lazy.ctypes.uint32_t },
+ // Bytes 12-15 are not needed and not documented
+ { foo: lazy.ctypes.uint32_t },
+ // Bytes 16-19 are the offset of the password
+ { passwordOffset: lazy.ctypes.uint32_t },
+ // Bytes 20-31 are not needed and not documented
+ { unknown1: lazy.ctypes.uint32_t },
+ { unknown2: lazy.ctypes.uint32_t },
+ { unknown3: lazy.ctypes.uint32_t },
+ ]);
+
+ let url = uri.prePath;
+ let results = [];
+ let arr = this._crypto.stringToArray(data);
+ // convert data to ctypes.unsigned_char.array(arr.length)
+ let cdata = lazy.ctypes.unsigned_char.array(arr.length)(arr);
+ // Bytes 0-35 contain the loginData data structure for all the logins sharing the same URL
+ let currentLoginData = lazy.ctypes.cast(cdata, loginData);
+ let headerSize = currentLoginData.headerSize;
+ let currentInfoIndex = loginData.size;
+ // pointer to the current login item
+ let currentLoginItemPointer = lazy.ctypes.cast(
+ cdata.addressOfElement(currentInfoIndex),
+ loginItem.ptr
+ );
+ // currentLoginData.dataMax is the data count: each username and password is considered as
+ // a data. So, the number of logins is the number of data dived by 2
+ let numLogins = currentLoginData.dataMax / 2;
+ for (let n = 0; n < numLogins; n++) {
+ // Bytes 0-31 starting from currentInfoIndex contain the loginItem data structure for the
+ // current login
+ let currentLoginItem = currentLoginItemPointer.contents;
+ let creation =
+ this.ctypesKernelHelpers.fileTimeToSecondsSinceEpoch(
+ currentLoginItem.hiDateTime,
+ currentLoginItem.loDateTime
+ ) * 1000;
+ let currentResult = {
+ creation,
+ url,
+ };
+ // The username is UTF-16 and null-terminated.
+ currentResult.username = lazy.ctypes
+ .cast(
+ cdata.addressOfElement(
+ headerSize + 12 + currentLoginItem.usernameOffset
+ ),
+ lazy.ctypes.char16_t.ptr
+ )
+ .readString();
+ // The password is UTF-16 and null-terminated.
+ currentResult.password = lazy.ctypes
+ .cast(
+ cdata.addressOfElement(
+ headerSize + 12 + currentLoginItem.passwordOffset
+ ),
+ lazy.ctypes.char16_t.ptr
+ )
+ .readString();
+ results.push(currentResult);
+ // move to the next login item
+ currentLoginItemPointer = currentLoginItemPointer.increment();
+ }
+ return results;
+ },
+};
+
+/**
+ * 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()];
+ // Only support the form password migrator for Windows XP to 7.
+ if (AppConstants.isPlatformAndVersionAtMost("win", "6.1")) {
+ resources.push(new IE7FormPasswords());
+ }
+ 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..e14b72607f
--- /dev/null
+++ b/browser/components/migration/InternalTestingProfileMigrator.sys.mjs
@@ -0,0 +1,63 @@
+/* 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";
+
+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";
+ }
+
+ 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 => {
+ 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..dcb663fe57
--- /dev/null
+++ b/browser/components/migration/MSMigrationUtils.sys.mjs
@@ -0,0 +1,754 @@
+/* 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 { 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 on Windows 8 and higher.
+function WindowsVaultFormPasswords() {}
+
+WindowsVaultFormPasswords.prototype = {
+ type: MigrationUtils.resourceTypes.PASSWORDS,
+
+ get exists() {
+ // work only on windows 8+
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ // check if there are passwords available for migration.
+ return this.migrate(() => {}, true);
+ }
+ return false;
+ },
+
+ /**
+ * If aOnlyCheckExists is false, import the form passwords on Windows 8 and higher 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..57a957d527
--- /dev/null
+++ b/browser/components/migration/MigrationUtils.sys.mjs
@@ -0,0 +1,1171 @@
+/* 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, {
+ 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",
+ WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+var gMigrators = null;
+var gFileMigrators = null;
+var gProfileStartup = null;
+var gL10n = null;
+var gPreviousDefaultBrowserKey = "";
+
+let gForceExitSpinResolve = false;
+let gKeepUndoData = false;
+let gUndoData = null;
+
+function getL10n() {
+ if (!gL10n) {
+ gL10n = new Localization(["browser/migration.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
+ );
+ }
+
+ 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,
+ });
+
+ /**
+ * 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 null otherwise.
+ *
+ * If null is returned, either no data can be imported for the given migrator,
+ * or aMigratorKey is invalid (e.g. ie on mac, or mosaic everywhere). This
+ * method should be used rather than direct getService for future compatibility
+ * (see bug 718280).
+ *
+ * @param {string} aKey
+ * Internal name of the migration source. See `availableMigratorKeys`
+ * for supported values by OS.
+ *
+ * @returns {MigratorBase}
+ * 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 {
+ return migrator && (await migrator.isSourceAvailable()) ? migrator : 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);
+ }
+
+ // "firefox" is the least useful entry here, and might just be because we've set
+ // ourselves as the default (on Windows 7 and below). In that case, check if we
+ // have a registry key that tells us where to go:
+ if (
+ key == "firefox" &&
+ AppConstants.isPlatformAndVersionAtMost("win", "6.2")
+ ) {
+ // Because we remove the registry key, reading the registry key only works once.
+ // We save the value for subsequent calls to avoid hard-to-trace bugs when multiple
+ // consumers ask for this key.
+ if (gPreviousDefaultBrowserKey) {
+ key = gPreviousDefaultBrowserKey;
+ } else {
+ // We didn't have a saved value, so check the registry.
+ const kRegPath = "Software\\Mozilla\\Firefox";
+ let oldDefault = lazy.WindowsRegistry.readRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ kRegPath,
+ "OldDefaultBrowserCommand"
+ );
+ if (oldDefault) {
+ // Remove the key:
+ lazy.WindowsRegistry.removeRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ kRegPath,
+ "OldDefaultBrowserCommand"
+ );
+ try {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsILocalFileWin
+ );
+ file.initWithCommandLine(oldDefault);
+ key =
+ APP_DESC_TO_KEY[file.getVersionInfoField("FileDescription")] ||
+ key;
+ // Save the value for future callers.
+ gPreviousDefaultBrowserKey = key;
+ } catch (ex) {
+ console.error(
+ "Could not convert old default browser value to description."
+ );
+ }
+ }
+ }
+ }
+ 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. On mac, this may just focus the wizard if it's
+ * already running, in which case aOpener and aOptions are ignored.
+ *
+ * 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 the new content-modal migration dialog is enabled and 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 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.
+ //
+ // "legacy":
+ // The legacy migration wizard will open, even if the new migration
+ // wizard is enabled by default.
+ //
+ // "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 aboutWelcomeLegacyBehavior =
+ aboutWelcomeBehavior == "legacy" &&
+ aOptions.entrypoint == this.MIGRATION_ENTRYPOINTS.NEWTAB;
+
+ if (
+ Services.prefs.getBoolPref(
+ "browser.migrate.content-modal.enabled",
+ false
+ ) &&
+ !aOptions?.isStartupMigration &&
+ !aboutWelcomeLegacyBehavior
+ ) {
+ let entrypoint =
+ aOptions.entrypoint || this.MIGRATION_ENTRYPOINTS.UNKNOWN;
+ Services.telemetry
+ .getHistogramById("FX_MIGRATION_ENTRY_POINT_CATEGORICAL")
+ .add(entrypoint);
+
+ let openStandaloneWindow = () => {
+ const FEATURES = "dialog,centerscreen,resizable=no";
+ const win = Services.ww.openWindow(
+ aOpener,
+ "chrome://browser/content/migration/migration-dialog-window.html",
+ "_blank",
+ FEATURES,
+ {
+ onResize: () => {
+ win.sizeToContent();
+ },
+ options: aOptions,
+ }
+ );
+ return Promise.resolve();
+ };
+
+ if (aOptions.isStartupMigration) {
+ openStandaloneWindow();
+ 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();
+ 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();
+ return Promise.resolve();
+ }
+ // Legacy migration dialog
+ const DIALOG_URL = "chrome://browser/content/migration/migration.xhtml";
+ let features = "chrome,dialog,modal,centerscreen,titlebar,resizable=no";
+ if (AppConstants.platform == "macosx" && !this.isStartupMigration) {
+ let win = Services.wm.getMostRecentWindow("Browser:MigrationWizard");
+ if (win) {
+ win.focus();
+ return Promise.resolve();
+ }
+ // On mac, the migration wiazrd should only be modal in the case of
+ // startup-migration.
+ features = "centerscreen,chrome,resizable=no";
+ }
+ Services.ww.openWindow(aOpener, DIALOG_URL, "_blank", features, aOptions);
+ 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
+ * (The first option is hardcoded to be the most common browser for
+ * the OS we run on. See migration.xhtml).
+ * @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,
+ };
+
+ 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);
+ }
+ }
+ }
+
+ 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
+ * histogram after a migration.
+ *
+ * @see MIGRATION_ENTRYPOINTS
+ * @readonly
+ * @enum {number}
+ */
+ #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",
+ });
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Translates an entrypoint string into the proper numeric value for the legacy
+ * FX_MIGRATION_ENTRY_POINT histogram.
+ *
+ * @param {string} entrypoint
+ * The entrypoint to translate from MIGRATION_ENTRYPOINTS.
+ * @returns {number}
+ * The numeric value for the legacy FX_MIGRATION_ENTRY_POINT histogram.
+ */
+ getLegacyMigrationEntrypoint(entrypoint) {
+ switch (entrypoint) {
+ case this.MIGRATION_ENTRYPOINTS.FIRSTRUN: {
+ return 1;
+ }
+ case this.MIGRATION_ENTRYPOINTS.FXREFRESH: {
+ return 2;
+ }
+ case this.MIGRATION_ENTRYPOINTS.PLACES: {
+ return 3;
+ }
+ case this.MIGRATION_ENTRYPOINTS.PASSWORDS: {
+ return 4;
+ }
+ case this.MIGRATION_ENTRYPOINTS.NEWTAB: {
+ return 5;
+ }
+ case this.MIGRATION_ENTRYPOINTS.FILE_MENU: {
+ return 6;
+ }
+ case this.MIGRATION_ENTRYPOINTS.HELP_MENU: {
+ return 7;
+ }
+ case this.MIGRATION_ENTRYPOINTS.BOOKMARKS_TOOLBAR: {
+ return 8;
+ }
+ case this.MIGRATION_ENTRYPOINTS.PREFERENCES: {
+ return 9;
+ }
+ case this.MIGRATION_ENTRYPOINTS.UNKNOWN:
+ // Intentional fall-through
+ default: {
+ return 0; // Unknown
+ }
+ }
+ }
+
+ /**
+ * Enum for the numeric value written to the FX_MIGRATION_SOURCE_BROWSER,
+ * and FX_STARTUP_MIGRATION_EXISTING_DEFAULT_BROWSER histograms.
+ *
+ * @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;
+ }
+}
+
+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..a3894f7e92
--- /dev/null
+++ b/browser/components/migration/MigrationWizardChild.sys.mjs
@@ -0,0 +1,328 @@
+/* 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;
+
+ /**
+ * 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) {
+ switch (event.type) {
+ case "MigrationWizard:RequestState": {
+ this.#sendTelemetryEvent("opened");
+
+ this.#wizardEl = event.target;
+ this.setComponentState({
+ page: MigrationWizardConstants.PAGES.LOADING,
+ });
+
+ 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 && !event.detail?.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,
+ });
+ }
+
+ this.#wizardEl.dispatchEvent(
+ new this.contentWindow.CustomEvent("MigrationWizard:Ready", {
+ bubbles: true,
+ })
+ );
+ 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;
+ }
+ }
+ }
+
+ /**
+ * 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",
+ 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
+ .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;
+ }
+
+ await this.sendQuery("Migrate", migrationDetails);
+ 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;
+ }
+ }
+ }
+
+ /**
+ * 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..488f29b47a
--- /dev/null
+++ b/browser/components/migration/MigrationWizardParent.sys.mjs
@@ -0,0 +1,651 @@
+/* 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";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "gFluentStrings", function () {
+ return new Localization([
+ "branding/brand.ftl",
+ "browser/migrationWizard.ftl",
+ ]);
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ InternalTestingProfileMigrator:
+ "resource:///modules/InternalTestingProfileMigrator.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",
+ });
+}
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ LoginCSVImport: "resource://gre/modules/LoginCSVImport.jsm",
+});
+
+/**
+ * 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");
+ }
+
+ /**
+ * 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 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;
+ });
+
+ for (let result of filteredResults) {
+ Services.telemetry.keyedScalarAdd(
+ "migration.discovered_migrators",
+ result.key,
+ 1
+ );
+ }
+ return filteredResults;
+ }
+
+ case "Migrate": {
+ if (
+ message.data.type ==
+ lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER
+ ) {
+ await this.#doBrowserMigration(
+ message.data.key,
+ message.data.resourceTypes,
+ message.data.profile,
+ message.data.safariPasswordFilePath
+ );
+ } else if (
+ message.data.type == lazy.MigrationWizardConstants.MIGRATOR_TYPES.FILE
+ ) {
+ let window = this.browsingContext.topChromeWindow;
+ await this.#doFileMigration(window, message.data.key);
+ }
+ 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;
+ }
+ }
+
+ 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] = {
+ inProgress: true,
+ message: "",
+ };
+ }
+
+ let [progressHeaderString, successHeaderString] =
+ await lazy.gFluentStrings.formatValues([
+ fileMigrator.progressHeaderL10nID,
+ fileMigrator.successHeaderL10nID,
+ ]);
+
+ this.sendAsyncMessage("UpdateFileImportProgress", {
+ title: progressHeaderString,
+ progress,
+ });
+ let migrationResult = await fileMigrator.migrate(path);
+ let successProgress = {};
+ for (let resourceType in migrationResult) {
+ successProgress[resourceType] = {
+ inProgress: false,
+ 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 {string} migratorKey
+ * The unique identification key for a migrator.
+ * @param {string[]} resourceTypeNames
+ * 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.
+ * @param {object|null} profileObj
+ * A description of the user profile that the migrator can import.
+ * @param {string} profileObj.id
+ * A unique ID for the user profile.
+ * @param {string} profileObj.name
+ * The display name for the user profile.
+ * @param {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 migratorKey is not for the
+ * Safari browser.
+ * @returns {Promise<undefined>}
+ * Resolves once the Migration:Ended observer notification has fired.
+ */
+ async #doBrowserMigration(
+ migratorKey,
+ resourceTypeNames,
+ profileObj,
+ safariPasswordFilePath = null
+ ) {
+ let migrator = await MigrationUtils.getMigrator(migratorKey);
+ let availableResourceTypes = await migrator.getMigrateData(profileObj);
+ let resourceTypesToMigrate = 0;
+ let progress = {};
+
+ for (let resourceTypeName of resourceTypeNames) {
+ let resourceType = MigrationUtils.resourceTypes[resourceTypeName];
+ if (availableResourceTypes & resourceType) {
+ resourceTypesToMigrate |= resourceType;
+ progress[resourceTypeName] = {
+ inProgress: true,
+ message: "",
+ };
+ }
+ }
+
+ if (
+ migratorKey == lazy.SafariProfileMigrator?.key &&
+ 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
+ ] = {
+ inProgress: true,
+ message: "",
+ };
+
+ this.sendAsyncMessage("UpdateProgress", { key: migratorKey, progress });
+
+ let summary = await lazy.LoginCSVImport.importFromCSV(
+ safariPasswordFilePath
+ );
+ let quantity = summary.filter(entry => entry.result == "added").length;
+
+ progress[
+ lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS
+ ] = {
+ inProgress: false,
+ message: await lazy.gFluentStrings.formatValue(
+ "migration-wizard-progress-success-passwords",
+ {
+ quantity,
+ }
+ ),
+ };
+ }
+
+ this.sendAsyncMessage("UpdateProgress", { key: migratorKey, 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 (safariPasswordFilePath && !resourceTypeNames.length) {
+ return;
+ }
+
+ try {
+ await migrator.migrate(
+ resourceTypesToMigrate,
+ false,
+ profileObj,
+ async resourceTypeNum => {
+ // 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 {
+ // For now, we ignore errors in migration, and simply display
+ // the success state.
+ progress[foundResourceTypeName] = {
+ inProgress: false,
+ message: await this.#getStringForImportQuantity(
+ migratorKey,
+ foundResourceTypeName
+ ),
+ };
+ this.sendAsyncMessage("UpdateProgress", {
+ key: migratorKey,
+ progress,
+ });
+ }
+ }
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ /**
+ * @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;
+ }
+
+ let sourceProfiles = await migrator.getSourceProfiles();
+ if (Array.isArray(sourceProfiles)) {
+ if (!sourceProfiles.length) {
+ return null;
+ }
+
+ let result = [];
+ for (let profile of sourceProfiles) {
+ result.push(
+ await this.#serializeMigratorAndProfile(migrator, profile)
+ );
+ }
+ return result;
+ }
+ 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.
+ * @returns {Promise<MigratorProfileInstance>}
+ */
+ async #serializeMigratorAndProfile(migrator, profileObj) {
+ let [profileMigrationData, lastModifiedDate] = await Promise.all([
+ migrator.getMigrateData(profileObj),
+ migrator.getLastUsedDate(),
+ ]);
+
+ let availableResourceTypes = [];
+
+ 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,
+ };
+ }
+
+ /**
+ * 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) {
+ 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: [],
+ };
+ }
+}
diff --git a/browser/components/migration/MigratorBase.sys.mjs b/browser/components/migration/MigratorBase.sys.mjs
new file mode 100644
index 0000000000..b7ae6de78d
--- /dev/null
+++ b/browser/components/migration/MigratorBase.sys.mjs
@@ -0,0 +1,587 @@
+/* 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",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.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 a single argument, aCallback(bool success),
+ * for migrating the data for this resource. It may do its job
+ * synchronously or asynchronously. Either way, it must call
+ * aCallback(bool aSuccess) when it's done. In the case of an exception
+ * thrown from |migrate|, it's taken as if aCallback(false) is called.
+ *
+ * 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);
+ }
+
+ /**
+ * 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.
+ */
+ 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 = lazy.PromiseUtils.defer();
+ let resourceDone = function (aSuccess) {
+ itemResources.delete(res);
+ itemSuccess |= aSuccess;
+ if (itemResources.size == 0) {
+ notify(
+ itemSuccess
+ ? "Migration:ItemAfterMigrate"
+ : "Migration:ItemError",
+ migrationType
+ );
+ collectMigrationTelemetry(migrationType);
+
+ aProgressCallback(migrationType);
+
+ 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..4a68178e4b
--- /dev/null
+++ b/browser/components/migration/SafariProfileMigrator.sys.mjs
@@ -0,0 +1,674 @@
+/* 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"],
+ false
+ ).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(
+ "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"],
+ false
+ ).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"],
+ false
+ ).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) {
+ 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")),
+ },
+ ],
+ });
+ }
+ 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"], false);
+ 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"], false);
+ 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"],
+ false
+ );
+ let bookmarkTarget = FileUtils.getDir(
+ "ULibDir",
+ ["Safari", "Bookmarks.plist"],
+ false
+ );
+ let faviconTarget = FileUtils.getDir(
+ "ULibDir",
+ ["Safari", "Favicon Cache", "favicons.db"],
+ false
+ );
+ 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", [""], false);
+ // 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;
+ }
+
+ get mainPreferencesPropertyList() {
+ if (this._mainPreferencesPropertyList === undefined) {
+ let file = FileUtils.getDir("UsrPrfs", [], false);
+ 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
new file mode 100644
index 0000000000..50562a0f70
--- /dev/null
+++ b/browser/components/migration/content/brands/360.png
Binary files differ
diff --git a/browser/components/migration/content/brands/brave.png b/browser/components/migration/content/brands/brave.png
new file mode 100644
index 0000000000..27d66e9a8e
--- /dev/null
+++ b/browser/components/migration/content/brands/brave.png
Binary files differ
diff --git a/browser/components/migration/content/brands/canary.png b/browser/components/migration/content/brands/canary.png
new file mode 100644
index 0000000000..8d46be18c6
--- /dev/null
+++ b/browser/components/migration/content/brands/canary.png
Binary files differ
diff --git a/browser/components/migration/content/brands/chrome.png b/browser/components/migration/content/brands/chrome.png
new file mode 100644
index 0000000000..3fecf6bb02
--- /dev/null
+++ b/browser/components/migration/content/brands/chrome.png
Binary files differ
diff --git a/browser/components/migration/content/brands/chromium.png b/browser/components/migration/content/brands/chromium.png
new file mode 100644
index 0000000000..0e5bb5d0cb
--- /dev/null
+++ b/browser/components/migration/content/brands/chromium.png
Binary files differ
diff --git a/browser/components/migration/content/brands/edge.png b/browser/components/migration/content/brands/edge.png
new file mode 100644
index 0000000000..b2e2ad4065
--- /dev/null
+++ b/browser/components/migration/content/brands/edge.png
Binary files differ
diff --git a/browser/components/migration/content/brands/edgebeta.png b/browser/components/migration/content/brands/edgebeta.png
new file mode 100644
index 0000000000..4e3977735e
--- /dev/null
+++ b/browser/components/migration/content/brands/edgebeta.png
Binary files differ
diff --git a/browser/components/migration/content/brands/ie.png b/browser/components/migration/content/brands/ie.png
new file mode 100644
index 0000000000..e01c0f35ed
--- /dev/null
+++ b/browser/components/migration/content/brands/ie.png
Binary files differ
diff --git a/browser/components/migration/content/brands/opera.png b/browser/components/migration/content/brands/opera.png
new file mode 100644
index 0000000000..71443f36a9
--- /dev/null
+++ b/browser/components/migration/content/brands/opera.png
Binary files differ
diff --git a/browser/components/migration/content/brands/operagx.png b/browser/components/migration/content/brands/operagx.png
new file mode 100644
index 0000000000..ed690aa200
--- /dev/null
+++ b/browser/components/migration/content/brands/operagx.png
Binary files differ
diff --git a/browser/components/migration/content/brands/safari.png b/browser/components/migration/content/brands/safari.png
new file mode 100644
index 0000000000..3b23caefb2
--- /dev/null
+++ b/browser/components/migration/content/brands/safari.png
Binary files differ
diff --git a/browser/components/migration/content/brands/vivaldi.png b/browser/components/migration/content/brands/vivaldi.png
new file mode 100644
index 0000000000..e9c2029006
--- /dev/null
+++ b/browser/components/migration/content/brands/vivaldi.png
Binary files differ
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..79f31c4606
--- /dev/null
+++ b/browser/components/migration/content/migration-dialog-window.html
@@ -0,0 +1,34 @@
+<!-- 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://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>
+ <panel-list></panel-list>
+ </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..feaa70f565
--- /dev/null
+++ b/browser/components/migration/content/migration-dialog-window.js
@@ -0,0 +1,82 @@
+/* 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";
+
+/**
+ * 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 {Function} window.arguments.0.onResize
+ * A callback to resize the container of this document when the
+ * MigrationWizard resizes.
+ * @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;
+ }
+
+ // We have to inform the container of this document that the
+ // MigrationWizard has changed size in order for it to update
+ // its dimensions too.
+ let observer = new ResizeObserver(() => {
+ args.onResize();
+ });
+ observer.observe(this._wiz);
+
+ let panelList = this._wiz.querySelector("panel-list");
+ let panel = document.createXULElement("panel");
+ panel.appendChild(panelList);
+ this._wiz.appendChild(panel);
+ 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;
+ }
+ }
+ },
+};
+
+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..30e1af3d79
--- /dev/null
+++ b/browser/components/migration/content/migration-wizard-constants.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/. */
+
+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",
+ }),
+
+ /**
+ * 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",
+
+ // We don't yet show OTHERDATA or SESSION resources.
+ }),
+
+ 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",
+ }),
+
+ /**
+ * 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",
+ ]),
+});
diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs
new file mode 100644
index 0000000000..e1462ab9d1
--- /dev/null
+++ b/browser/components/migration/content/migration-wizard.mjs
@@ -0,0 +1,1088 @@
+/* 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;
+ #safariPermissionButton = null;
+ #safariPasswordImportSkipButton = null;
+ #safariPasswordImportSelectButton = null;
+ #selectAllCheckbox = null;
+ #resourceSummary = null;
+ #expandedDetails = false;
+
+ 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"></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"></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">&nbsp;</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 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">&nbsp;</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-logins-and-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="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>
+
+ <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>
+ </moz-button-group>
+ </div>
+
+ <div name="page-progress">
+ <h1 id="progress-header" data-l10n-id="migration-wizard-progress-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="success-text deemphasized-text">&nbsp;</span>
+ </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-logins-and-passwords-option-label"></span>
+ <span class="success-text deemphasized-text">&nbsp;</span>
+ </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="success-text deemphasized-text">&nbsp;</span>
+ </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="success-text deemphasized-text">&nbsp;</span>
+ </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="success-text deemphasized-text">&nbsp;</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-file-import-progress">
+ <h1 id="file-import-progress-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="success-text deemphasized-text">&nbsp;</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="success-text deemphasized-text">&nbsp;</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="success-text deemphasized-text">&nbsp;</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="success-text deemphasized-text">&nbsp;</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"></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"></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"></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
+ );
+ }
+ let fragment = MigrationWizard.#template.content.cloneNode(true);
+ if (window.IS_STORYBOOK) {
+ // If we're using Storybook, load the CSS from the static local file
+ // system rather than chrome:// to take advantage of auto-reloading.
+ fragment.querySelector("link[rel=stylesheet]").href =
+ "./migration/migration-wizard.css";
+ }
+ return fragment;
+ }
+
+ constructor() {
+ super();
+ const shadow = this.attachShadow({ mode: "closed" });
+
+ 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.#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.#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 = this.querySelector("panel-list");
+ if (!this.#browserProfileSelectorList) {
+ throw new Error(
+ "Could not find a <panel-list> under the MigrationWizard during initialization."
+ );
+ }
+ this.#browserProfileSelectorList.toggleAttribute(
+ "min-width-from-anchor",
+ true
+ );
+ this.#browserProfileSelectorList.addEventListener("click", this);
+ // Until bug 1823489 is fixed, this is the easiest way for the
+ // migration wizard to style the selector dropdown so that it more
+ // closely lines up with the edges of the selector button.
+ this.#browserProfileSelectorList.style.boxSizing = "border-box";
+ this.#browserProfileSelectorList.style.overflowY = "auto";
+ }
+
+ /**
+ * 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"));
+ selectionPage.toggleAttribute(
+ "no-resources",
+ panelItem.getAttribute("type") ==
+ MigrationWizardConstants.MIGRATOR_TYPES.BROWSER && !resourceTypes.length
+ );
+ }
+
+ /**
+ * 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.
+ */
+ #onShowingSelection(state) {
+ this.#ensureSelectionDropdown();
+ this.#browserProfileSelectorList.textContent = "";
+
+ let selectionPage = this.#shadowRoot.querySelector(
+ "div[name='page-selection']"
+ );
+
+ let details = this.#shadowRoot.querySelector("details");
+ 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.brandImage = migrator.brandImage;
+
+ // Bug 1823489 - since the panel-list and panel-items are slotted, we
+ // cannot style them directly from migration-wizard.css. We use inline
+ // styles for now to achieve the desired appearance, but bug 1823489
+ // will investigate having MigrationWizard own the <xul:panel>,
+ // <panel-list> and <panel-item>'s so that styling can be done in the
+ // stylesheet instead.
+ let button = opt.shadowRoot.querySelector("button");
+ button.style.minHeight = "40px";
+ if (migrator.brandImage) {
+ button.style.backgroundImage = `url(${migrator.brandImage})`;
+ } else {
+ button.style.backgroundImage = `url("chrome://global/skin/icons/defaultFavicon.svg")`;
+ }
+
+ // Bug 1823489 - since the panel-list and panel-items are slotted, we
+ // cannot style them or their children in migration-wizard.css. We use
+ // inline styles for now to achieve the desired appearance, but bug 1823489
+ // will investigate having MigrationWizard own the <xul:panel>,
+ // <panel-list> and <panel-item>'s so that styling can be done in the
+ // stylesheet instead.
+ if (migrator.type == MigrationWizardConstants.MIGRATOR_TYPES.FILE) {
+ button.style.backgroundSize = "20px";
+ button.style.backgroundPosition = "6px center";
+ if (this.#browserProfileSelectorList.isDocumentRTL()) {
+ button.style.backgroundPositionX = "right 6px";
+ }
+ }
+
+ 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
+ );
+ }
+
+ // 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 {boolean} inProgress
+ * True if progress is still underway.
+ * @property {string} [message=undefined]
+ * An optional message to display underneath the resource in
+ * the progress dialog. This message is only shown when inProgress
+ * is `false`.
+ */
+
+ /**
+ * 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"
+ );
+ 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 successText = group.querySelector(".success-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")
+ );
+ }
+ }
+
+ if (state.progress[resourceType].inProgress) {
+ document.l10n.setAttributes(
+ progressIcon,
+ "migration-wizard-progress-icon-in-progress"
+ );
+ progressIcon.classList.remove("completed");
+ successText.textContent = "";
+ // With no status text, we re-insert the &nbsp; so that the status
+ // text area does not fully collapse.
+ successText.appendChild(document.createTextNode("\u00A0"));
+ } else {
+ document.l10n.setAttributes(
+ progressIcon,
+ "migration-wizard-progress-icon-completed"
+ );
+ progressIcon.classList.add("completed");
+ successText.textContent = state.progress[resourceType].message;
+ remainingProgressGroups--;
+ }
+ }
+
+ let migrationDone = remainingProgressGroups == 0;
+ let headerL10nID = migrationDone
+ ? "migration-wizard-progress-done-header"
+ : "migration-wizard-progress-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 successText = group.querySelector(".success-text");
+
+ if (state.progress[resourceType].inProgress) {
+ document.l10n.setAttributes(
+ progressIcon,
+ "migration-wizard-progress-icon-in-progress"
+ );
+ progressIcon.classList.remove("completed");
+ successText.textContent = "";
+ // With no status text, we re-insert the &nbsp; so that the status
+ // text area does not fully collapse.
+ successText.appendChild(document.createTextNode("\u00A0"));
+ } else {
+ document.l10n.setAttributes(
+ progressIcon,
+ "migration-wizard-progress-icon-completed"
+ );
+ progressIcon.classList.add("completed");
+ successText.textContent = state.progress[resourceType].message;
+ remainingProgressGroups--;
+ }
+ }
+
+ 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.classList.contains("completed")) {
+ progressEl.removeAttribute("part");
+ } else {
+ progressEl.setAttribute("part", "progress-spinner");
+ }
+ });
+ }
+
+ /**
+ * 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.
+ */
+
+ /**
+ * 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.
+ *
+ * @returns {MigrationDetails} details
+ */
+ #gatherMigrationEventDetails() {
+ 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,
+ };
+ }
+
+ /**
+ * 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,
+ })
+ );
+ }
+
+ /**
+ * 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.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();
+ }
+ 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/content/migration.js b/browser/components/migration/content/migration.js
new file mode 100644
index 0000000000..bc27bb4c9d
--- /dev/null
+++ b/browser/components/migration/content/migration.js
@@ -0,0 +1,812 @@
+/* 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 { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { MigrationUtils } = ChromeUtils.importESModule(
+ "resource:///modules/MigrationUtils.sys.mjs"
+);
+const { MigratorBase } = ChromeUtils.importESModule(
+ "resource:///modules/MigratorBase.sys.mjs"
+);
+
+/**
+ * Map from data types that match Ci.nsIBrowserProfileMigrator's types to
+ * prefixes for strings used to label these data types in the migration
+ * dialog. We use these strings with -checkbox and -label suffixes for the
+ * checkboxes on the "importItems" page, and for the labels on the "migrating"
+ * and "done" pages, respectively.
+ */
+const kDataToStringMap = new Map([
+ ["cookies", "browser-data-cookies"],
+ ["history", "browser-data-history"],
+ ["formdata", "browser-data-formdata"],
+ ["passwords", "browser-data-passwords"],
+ ["bookmarks", "browser-data-bookmarks"],
+ ["otherdata", "browser-data-otherdata"],
+ ["session", "browser-data-session"],
+ ["payment_methods", "browser-data-payment-methods"],
+]);
+
+var MigrationWizard = {
+ /* exported MigrationWizard */
+ _source: "", // Source Profile Migrator ContractID suffix
+ _itemsFlags: MigrationUtils.resourceTypes.ALL, // Selected Import Data Sources (16-bit bitfield)
+ _selectedProfile: null, // Selected Profile name to import from
+ _wiz: null,
+ _migrator: null,
+ _autoMigrate: null,
+ _receivedPermissions: new Set(),
+ _succeededMigrationEventArgs: null,
+
+ init() {
+ Services.telemetry.setEventRecordingEnabled("browser.migration", true);
+
+ let os = Services.obs;
+ os.addObserver(this, "Migration:Started");
+ os.addObserver(this, "Migration:ItemBeforeMigrate");
+ os.addObserver(this, "Migration:ItemAfterMigrate");
+ os.addObserver(this, "Migration:ItemError");
+ os.addObserver(this, "Migration:Ended");
+
+ this._wiz = document.querySelector("wizard");
+
+ let args = window.arguments[0]?.wrappedJSObject || {};
+ let entrypoint =
+ args.entrypoint || MigrationUtils.MIGRATION_ENTRYPOINTS.UNKNOWN;
+ Services.telemetry
+ .getHistogramById("FX_MIGRATION_ENTRY_POINT_CATEGORICAL")
+ .add(entrypoint);
+
+ // The legacy entrypoint Histogram wasn't categorical, so we translate to the right
+ // numeric value before writing it. We'll keep this Histogram around to ensure a
+ // smooth transition to the new FX_MIGRATION_ENTRY_POINT_CATEGORICAL categorical
+ // histogram.
+ let entryPointId = MigrationUtils.getLegacyMigrationEntrypoint(entrypoint);
+ Services.telemetry
+ .getHistogramById("FX_MIGRATION_ENTRY_POINT")
+ .add(entryPointId);
+
+ this.isInitialMigration =
+ entrypoint == MigrationUtils.MIGRATION_ENTRYPOINTS.FIRSTRUN;
+
+ // 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
+ );
+ }
+
+ this._source = args.migratorKey;
+ this._migrator =
+ args.migrator instanceof MigratorBase ? args.migrator : null;
+ this._autoMigrate = !!args.isStartupMigration;
+ this._skipImportSourcePage = !!args.skipSourceSelection;
+
+ if (this._migrator && args.profileId) {
+ let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+ this._selectedProfile = sourceProfiles.find(
+ profile => profile.id == args.profileId
+ );
+ }
+
+ if (this._autoMigrate) {
+ // Show the "nothing" option in the automigrate case to provide an
+ // easily identifiable way to avoid migration and create a new profile.
+ document.getElementById("nothing").hidden = false;
+ }
+
+ this._setSourceForDataLocalization();
+
+ document.addEventListener("wizardcancel", function () {
+ MigrationWizard.onWizardCancel();
+ });
+
+ document
+ .getElementById("selectProfile")
+ .addEventListener("pageshow", function () {
+ MigrationWizard.onSelectProfilePageShow();
+ });
+ document
+ .getElementById("importItems")
+ .addEventListener("pageshow", function () {
+ MigrationWizard.onImportItemsPageShow();
+ });
+ document
+ .getElementById("migrating")
+ .addEventListener("pageshow", function () {
+ MigrationWizard.onMigratingPageShow();
+ });
+ document.getElementById("done").addEventListener("pageshow", function () {
+ MigrationWizard.onDonePageShow();
+ });
+
+ document
+ .getElementById("selectProfile")
+ .addEventListener("pagerewound", function () {
+ MigrationWizard.onSelectProfilePageRewound();
+ });
+ document
+ .getElementById("importItems")
+ .addEventListener("pagerewound", function () {
+ MigrationWizard.onImportItemsPageRewound();
+ });
+
+ document
+ .getElementById("selectProfile")
+ .addEventListener("pageadvanced", function () {
+ MigrationWizard.onSelectProfilePageAdvanced();
+ });
+ document
+ .getElementById("importItems")
+ .addEventListener("pageadvanced", function () {
+ MigrationWizard.onImportItemsPageAdvanced();
+ });
+ document
+ .getElementById("importPermissions")
+ .addEventListener("pageadvanced", function (e) {
+ MigrationWizard.onImportPermissionsPageAdvanced(e);
+ });
+ document
+ .getElementById("importSource")
+ .addEventListener("pageadvanced", function (e) {
+ MigrationWizard.onImportSourcePageAdvanced(e);
+ });
+
+ this.recordEvent("opened");
+
+ this.onImportSourcePageShow();
+ },
+
+ uninit() {
+ var os = Services.obs;
+ os.removeObserver(this, "Migration:Started");
+ os.removeObserver(this, "Migration:ItemBeforeMigrate");
+ os.removeObserver(this, "Migration:ItemAfterMigrate");
+ os.removeObserver(this, "Migration:ItemError");
+ os.removeObserver(this, "Migration:Ended");
+ os.notifyObservers(this, "MigrationWizard:Destroyed");
+ MigrationUtils.finishMigration();
+ },
+
+ /**
+ * 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,
+ "legacy_wizard",
+ null,
+ args
+ );
+ },
+
+ spinResolve(promise) {
+ let canAdvance = this._wiz.canAdvance;
+ let canRewind = this._wiz.canRewind;
+ this._wiz.canAdvance = false;
+ this._wiz.canRewind = false;
+ let result = MigrationUtils.spinResolve(promise);
+ this._wiz.canAdvance = canAdvance;
+ this._wiz.canRewind = canRewind;
+ return result;
+ },
+
+ _setSourceForDataLocalization() {
+ this._sourceForDataLocalization = this._source;
+ // Ensure consistency for various channels, brandings and versions of
+ // Chromium and MS Edge.
+ if (this._sourceForDataLocalization) {
+ this._sourceForDataLocalization = this._sourceForDataLocalization
+ .replace(/^(chromium-edge-beta|chromium-edge)$/, "edge")
+ .replace(/^(canary|chromium|chrome-beta|chrome-dev)$/, "chrome");
+ }
+ },
+
+ onWizardCancel() {
+ MigrationUtils.forceExitSpinResolve();
+ return true;
+ },
+
+ // 1 - Import Source
+ onImportSourcePageShow() {
+ this._wiz.canRewind = false;
+
+ var selectedMigrator = null;
+ this._availableMigrators = [];
+
+ // Figure out what source apps are are available to import from:
+ var group = document.getElementById("importSourceGroup");
+ for (var i = 0; i < group.childNodes.length; ++i) {
+ var migratorKey = group.childNodes[i].id;
+ if (migratorKey != "nothing") {
+ var migrator = this.spinResolve(
+ MigrationUtils.getMigrator(migratorKey)
+ );
+
+ if (migrator?.enabled) {
+ // Save this as the first selectable item, if we don't already have
+ // one, or if it is the migrator that was passed to us.
+ if (!selectedMigrator || this._source == migratorKey) {
+ selectedMigrator = group.childNodes[i];
+ }
+
+ let profiles = this.spinResolve(migrator.getSourceProfiles());
+ if (profiles?.length) {
+ Services.telemetry.keyedScalarAdd(
+ "migration.discovered_migrators",
+ migratorKey,
+ profiles.length
+ );
+ } else {
+ Services.telemetry.keyedScalarAdd(
+ "migration.discovered_migrators",
+ migratorKey,
+ 1
+ );
+ }
+
+ this._availableMigrators.push([migratorKey, migrator]);
+ } else {
+ // Hide this option
+ group.childNodes[i].hidden = true;
+ }
+ }
+ }
+ if (this.isInitialMigration) {
+ Services.telemetry
+ .getHistogramById("FX_STARTUP_MIGRATION_BROWSER_COUNT")
+ .add(this._availableMigrators.length);
+ let defaultBrowser = MigrationUtils.getMigratorKeyForDefaultBrowser();
+ // This will record 0 for unknown default browser IDs.
+ defaultBrowser = MigrationUtils.getSourceIdForTelemetry(defaultBrowser);
+ Services.telemetry
+ .getHistogramById("FX_STARTUP_MIGRATION_EXISTING_DEFAULT_BROWSER")
+ .add(defaultBrowser);
+ }
+
+ if (selectedMigrator) {
+ group.selectedItem = selectedMigrator;
+ } else {
+ this.recordEvent("no_browsers_found");
+ // We didn't find a migrator, notify the user
+ document.getElementById("noSources").hidden = false;
+
+ this._wiz.canAdvance = false;
+
+ document.getElementById("importAll").hidden = true;
+ }
+
+ // Advance to the next page if the caller told us to.
+ if (this._migrator && this._skipImportSourcePage) {
+ this._wiz.advance();
+ this._wiz.canRewind = false;
+ }
+ },
+
+ onImportSourcePageAdvanced(event) {
+ var newSource =
+ document.getElementById("importSourceGroup").selectedItem.id;
+
+ this.recordEvent("browser_selected", { migrator_key: newSource });
+
+ if (newSource == "nothing") {
+ // Need to do telemetry here because we're closing the dialog before we get to
+ // do actual migration. For actual migration, this doesn't happen until after
+ // migration takes place.
+ Services.telemetry
+ .getHistogramById("FX_MIGRATION_SOURCE_BROWSER")
+ .add(MigrationUtils.getSourceIdForTelemetry("nothing"));
+ this._wiz.cancel();
+ event.preventDefault();
+ }
+
+ if (!this._migrator || newSource != this._source) {
+ // Create the migrator for the selected source.
+ this._migrator = this.spinResolve(MigrationUtils.getMigrator(newSource));
+
+ this._itemsFlags = MigrationUtils.resourceTypes.ALL;
+ this._selectedProfile = null;
+ }
+ this._source = newSource;
+ this._setSourceForDataLocalization();
+
+ // check for more than one source profile
+ var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+ if (this._skipImportSourcePage) {
+ this._updateNextPageForPermissions();
+ } else if (sourceProfiles && sourceProfiles.length > 1) {
+ this._wiz.currentPage.next = "selectProfile";
+ } else {
+ if (this._autoMigrate) {
+ this._updateNextPageForPermissions();
+ } else {
+ this._wiz.currentPage.next = "importItems";
+ }
+
+ if (sourceProfiles && sourceProfiles.length == 1) {
+ this._selectedProfile = sourceProfiles[0];
+ } else {
+ this._selectedProfile = null;
+ }
+ }
+ },
+
+ // 2 - [Profile Selection]
+ onSelectProfilePageShow() {
+ // Disabling this for now, since we ask about import sources in automigration
+ // too and don't want to disable the back button
+ // if (this._autoMigrate)
+ // document.documentElement.getButton("back").disabled = true;
+
+ var profiles = document.getElementById("profiles");
+ while (profiles.hasChildNodes()) {
+ profiles.firstChild.remove();
+ }
+
+ // Note that this block is still reached even if the user chose 'From File'
+ // and we canceled the dialog. When that happens, _migrator will be null.
+ if (this._migrator) {
+ var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+
+ for (let profile of sourceProfiles) {
+ var item = document.createXULElement("radio");
+ item.id = profile.id;
+ item.setAttribute("label", profile.name);
+ profiles.appendChild(item);
+ }
+ }
+
+ profiles.selectedItem = this._selectedProfile
+ ? document.getElementById(this._selectedProfile.id)
+ : profiles.firstChild;
+ },
+
+ onSelectProfilePageRewound() {
+ var profiles = document.getElementById("profiles");
+ let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+ this._selectedProfile =
+ sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) ||
+ null;
+ },
+
+ onSelectProfilePageAdvanced() {
+ this.recordEvent("profile_selected", {
+ migrator_key: this._source,
+ });
+ var profiles = document.getElementById("profiles");
+ let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+ this._selectedProfile =
+ sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) ||
+ null;
+
+ // If we're automigrating or just doing bookmarks don't show the item selection page
+ if (this._autoMigrate) {
+ this._updateNextPageForPermissions();
+ }
+ },
+
+ // 3 - ImportItems
+ onImportItemsPageShow() {
+ var dataSources = document.getElementById("dataSources");
+ while (dataSources.hasChildNodes()) {
+ dataSources.firstChild.remove();
+ }
+
+ var items = this.spinResolve(
+ this._migrator.getMigrateData(this._selectedProfile)
+ );
+
+ for (let itemType of kDataToStringMap.keys()) {
+ let itemValue = MigrationUtils.resourceTypes[itemType.toUpperCase()];
+ if (items & itemValue) {
+ let checkbox = document.createXULElement("checkbox");
+ checkbox.id = itemValue;
+ checkbox.setAttribute("native", true);
+ document.l10n.setAttributes(
+ checkbox,
+ kDataToStringMap.get(itemType) + "-checkbox",
+ { browser: this._sourceForDataLocalization }
+ );
+ dataSources.appendChild(checkbox);
+ if (!this._itemsFlags || this._itemsFlags & itemValue) {
+ checkbox.checked = true;
+ }
+ }
+ }
+ },
+
+ onImportItemsPageRewound() {
+ this._wiz.canAdvance = true;
+ this.onImportItemsPageAdvanced(true /* viaRewind */);
+ },
+
+ onImportItemsPageAdvanced(viaRewind = false) {
+ let extraKeys = {
+ migrator_key: this._source,
+ history: "0",
+ formdata: "0",
+ passwords: "0",
+ bookmarks: "0",
+ payment_methods: "0",
+
+ // "other" will get incremented, so we keep this as a number for
+ // now, and will cast to a string before submitting to Event telemetry.
+ other: 0,
+
+ configured: "0",
+ };
+
+ var dataSources = document.getElementById("dataSources");
+ this._itemsFlags = 0;
+
+ for (var i = 0; i < dataSources.childNodes.length; ++i) {
+ var checkbox = dataSources.childNodes[i];
+ if (checkbox.localName == "checkbox" && checkbox.checked) {
+ let flag = parseInt(checkbox.id);
+
+ switch (flag) {
+ case MigrationUtils.resourceTypes.HISTORY:
+ extraKeys.history = "1";
+ break;
+ case MigrationUtils.resourceTypes.FORMDATA:
+ extraKeys.formdata = "1";
+ break;
+ case MigrationUtils.resourceTypes.PASSWORDS:
+ extraKeys.passwords = "1";
+ break;
+ case MigrationUtils.resourceTypes.BOOKMARKS:
+ extraKeys.bookmarks = "1";
+ break;
+ case MigrationUtils.resourceTypes.PAYMENT_METHODS:
+ extraKeys.payment_methods = "1";
+ break;
+ default:
+ extraKeys.other++;
+ }
+
+ this._itemsFlags |= parseInt(checkbox.id);
+ }
+ }
+
+ extraKeys.other = String(extraKeys.other);
+
+ if (!viaRewind) {
+ this.recordEvent("resources_selected", extraKeys);
+ }
+
+ this._updateNextPageForPermissions();
+ },
+
+ onImportItemCommand() {
+ var items = document.getElementById("dataSources");
+ var checkboxes = items.getElementsByTagName("checkbox");
+
+ var oneChecked = false;
+ for (var i = 0; i < checkboxes.length; ++i) {
+ if (checkboxes[i].checked) {
+ oneChecked = true;
+ break;
+ }
+ }
+
+ this._wiz.canAdvance = oneChecked;
+
+ this._updateNextPageForPermissions();
+ },
+
+ _updateNextPageForPermissions() {
+ // We would like to just go straight to work:
+ this._wiz.currentPage.next = "migrating";
+ // If we already have permissions, this is easy:
+ if (this._receivedPermissions.has(this._source)) {
+ return;
+ }
+
+ // Otherwise, if we're on mojave or later and importing from
+ // Safari, prompt for the bookmarks file.
+ // We may add other browser/OS combos here in future.
+ if (
+ this._source == "safari" &&
+ AppConstants.isPlatformAndVersionAtLeast("macosx", "18") &&
+ (this._itemsFlags & MigrationUtils.resourceTypes.BOOKMARKS ||
+ this._itemsFlags == MigrationUtils.resourceTypes.ALL)
+ ) {
+ let havePermissions = this.spinResolve(this._migrator.hasPermissions());
+
+ if (!havePermissions) {
+ this._wiz.currentPage.next = "importPermissions";
+ this.recordEvent("safari_perms");
+ }
+ }
+ },
+
+ // 3b: permissions. This gets invoked when the user clicks "Next"
+ async onImportPermissionsPageAdvanced(event) {
+ // We're done if we have permission:
+ if (this._receivedPermissions.has(this._source)) {
+ return;
+ }
+ // The wizard helper is sync, and we need to check some stuff, so just stop
+ // advancing for now and prompt the user, then advance the wizard if everything
+ // worked.
+ event.preventDefault();
+
+ await this._migrator.getPermissions(window);
+ if (await this._migrator.hasPermissions()) {
+ this._receivedPermissions.add(this._source);
+ // Re-enter (we'll then allow the advancement through the early return above)
+ this._wiz.advance();
+ }
+ // if we didn't have permissions after the `getPermissions` call, the user
+ // cancelled the dialog. Just no-op out now; the user can re-try by clicking
+ // the 'Continue' button again, or go back and pick a different browser.
+ },
+
+ // 4 - Migrating
+ onMigratingPageShow() {
+ this._wiz.getButton("cancel").disabled = true;
+ this._wiz.canRewind = false;
+ this._wiz.canAdvance = false;
+
+ // When automigrating, show all of the data that can be received from this source.
+ if (this._autoMigrate) {
+ this._itemsFlags = this.spinResolve(
+ this._migrator.getMigrateData(this._selectedProfile)
+ );
+ }
+
+ this._listItems("migratingItems");
+ setTimeout(() => this.onMigratingMigrate(), 0);
+ },
+
+ async onMigratingMigrate() {
+ await this._migrator.migrate(
+ this._itemsFlags,
+ this._autoMigrate,
+ this._selectedProfile
+ );
+
+ Services.telemetry
+ .getHistogramById("FX_MIGRATION_SOURCE_BROWSER")
+ .add(MigrationUtils.getSourceIdForTelemetry(this._source));
+ if (!this._autoMigrate) {
+ let hist = Services.telemetry.getKeyedHistogramById("FX_MIGRATION_USAGE");
+ let exp = 0;
+ let items = this._itemsFlags;
+ while (items) {
+ if (items & 1) {
+ hist.add(this._source, exp);
+ }
+ items = items >> 1;
+ exp++;
+ }
+ }
+ },
+
+ _listItems(aID) {
+ var items = document.getElementById(aID);
+ while (items.hasChildNodes()) {
+ items.firstChild.remove();
+ }
+
+ for (let itemType of kDataToStringMap.keys()) {
+ let itemValue = MigrationUtils.resourceTypes[itemType.toUpperCase()];
+ if (this._itemsFlags & itemValue) {
+ var label = document.createXULElement("label");
+ label.id = itemValue + "_migrated";
+ try {
+ document.l10n.setAttributes(
+ label,
+ kDataToStringMap.get(itemType) + "-label",
+ { browser: this._sourceForDataLocalization }
+ );
+ items.appendChild(label);
+ } catch (e) {
+ // if the block above throws, we've enumerated all the import data types we
+ // currently support and are now just wasting time, break.
+ break;
+ }
+ }
+ }
+ },
+
+ recordResourceMigration(obj, resourceType) {
+ // Sometimes, the resourceType that gets passed here is a string, which
+ // is bizarre. We'll hold our nose and accept either a string or a
+ // number.
+ resourceType = parseInt(resourceType, 10);
+
+ switch (resourceType) {
+ case MigrationUtils.resourceTypes.HISTORY:
+ obj.history = "1";
+ break;
+ case MigrationUtils.resourceTypes.FORMDATA:
+ obj.formdata = "1";
+ break;
+ case MigrationUtils.resourceTypes.PASSWORDS:
+ obj.passwords = "1";
+ break;
+ case MigrationUtils.resourceTypes.BOOKMARKS:
+ obj.bookmarks = "1";
+ break;
+ case MigrationUtils.resourceTypes.PAYMENT_METHODS:
+ obj.payment_methods = "1";
+ break;
+ default:
+ obj.other++;
+ }
+ },
+
+ recordMigrationStartEvent(resourceFlags) {
+ let extraKeys = {
+ migrator_key: this._source,
+ history: "0",
+ formdata: "0",
+ passwords: "0",
+ bookmarks: "0",
+ payment_methods: "0",
+ // "other" will get incremented, so we keep this as a number for
+ // now, and will cast to a string before submitting to Event telemetry.
+ other: 0,
+ };
+
+ for (let resourceTypeKey in MigrationUtils.resourceTypes) {
+ let resourceType = MigrationUtils.resourceTypes[resourceTypeKey];
+ if (resourceFlags & resourceType) {
+ this.recordResourceMigration(extraKeys, resourceType);
+ }
+ }
+
+ extraKeys.other = String(extraKeys.other);
+ this.recordEvent("migration_started", extraKeys);
+ },
+
+ observe(aSubject, aTopic, aData) {
+ var label;
+ switch (aTopic) {
+ case "Migration:Started":
+ this._succeededMigrationEventArgs = {
+ migrator_key: this._source,
+ history: "0",
+ formdata: "0",
+ passwords: "0",
+ bookmarks: "0",
+ payment_methods: "0",
+ // "other" will get incremented, so we keep this as a number for
+ // now, and will cast to a string before submitting to Event telemetry.
+ other: 0,
+ };
+ this.recordMigrationStartEvent(this._itemsFlags);
+ break;
+ case "Migration:ItemBeforeMigrate":
+ label = document.getElementById(aData + "_migrated");
+ if (label) {
+ label.setAttribute("style", "font-weight: bold");
+ }
+ break;
+ case "Migration:ItemAfterMigrate":
+ this.recordResourceMigration(this._succeededMigrationEventArgs, aData);
+ label = document.getElementById(aData + "_migrated");
+ if (label) {
+ label.removeAttribute("style");
+ }
+ break;
+ case "Migration:Ended":
+ this._succeededMigrationEventArgs.other = String(
+ this._succeededMigrationEventArgs.other
+ );
+ this.recordEvent(
+ "migration_finished",
+ this._succeededMigrationEventArgs
+ );
+
+ if (this.isInitialMigration) {
+ // Ensure errors in reporting data recency do not affect the rest of the migration.
+ try {
+ this.reportDataRecencyTelemetry();
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ if (this._autoMigrate) {
+ // We're done now.
+ this._wiz.canAdvance = true;
+ this._wiz.advance();
+
+ setTimeout(close, 5000);
+ } else {
+ this._wiz.canAdvance = true;
+ var nextButton = this._wiz.getButton("next");
+ nextButton.click();
+ }
+ break;
+ case "Migration:ItemError":
+ let type = "undefined";
+ let numericType = parseInt(aData);
+ switch (numericType) {
+ case MigrationUtils.resourceTypes.COOKIES:
+ type = "cookies";
+ break;
+ case MigrationUtils.resourceTypes.HISTORY:
+ type = "history";
+ break;
+ case MigrationUtils.resourceTypes.FORMDATA:
+ type = "form data";
+ break;
+ case MigrationUtils.resourceTypes.PASSWORDS:
+ type = "passwords";
+ break;
+ case MigrationUtils.resourceTypes.BOOKMARKS:
+ type = "bookmarks";
+ break;
+ case MigrationUtils.resourceTypes.PAYMENT_METHODS:
+ type = "payment methods";
+ break;
+ case MigrationUtils.resourceTypes.OTHERDATA:
+ type = "misc. data";
+ break;
+ }
+ Services.console.logStringMessage(
+ "some " + type + " did not successfully migrate."
+ );
+ Services.telemetry
+ .getKeyedHistogramById("FX_MIGRATION_ERRORS")
+ .add(this._source, Math.log2(numericType));
+ break;
+ }
+ },
+
+ onDonePageShow() {
+ this._wiz.getButton("cancel").disabled = true;
+ this._wiz.canRewind = false;
+ this._listItems("doneItems");
+ },
+
+ reportDataRecencyTelemetry() {
+ let histogram = Services.telemetry.getKeyedHistogramById(
+ "FX_STARTUP_MIGRATION_DATA_RECENCY"
+ );
+ let lastUsedPromises = [];
+ for (let [key, migrator] of this._availableMigrators) {
+ // No block-scoped let in for...of loop conditions, so get the source:
+ let localKey = key;
+ lastUsedPromises.push(
+ migrator.getLastUsedDate().then(date => {
+ const ONE_YEAR = 24 * 365;
+ let diffInHours = Math.round((Date.now() - date) / (60 * 60 * 1000));
+ if (diffInHours > ONE_YEAR) {
+ diffInHours = ONE_YEAR;
+ }
+ histogram.add(localKey, diffInHours);
+ return [localKey, diffInHours];
+ })
+ );
+ }
+ Promise.all(lastUsedPromises).then(migratorUsedTimeDiff => {
+ // Sort low to high.
+ migratorUsedTimeDiff.sort(
+ ([keyA, diffA], [keyB, diffB]) => diffA - diffB
+ ); /* eslint no-unused-vars: off */
+ let usedMostRecentBrowser =
+ migratorUsedTimeDiff.length &&
+ this._source == migratorUsedTimeDiff[0][0];
+ let usedRecentBrowser = Services.telemetry.getKeyedHistogramById(
+ "FX_STARTUP_MIGRATION_USED_RECENT_BROWSER"
+ );
+ usedRecentBrowser.add(this._source, usedMostRecentBrowser);
+ });
+ },
+};
diff --git a/browser/components/migration/content/migration.xhtml b/browser/components/migration/content/migration.xhtml
new file mode 100644
index 0000000000..be6c06c6fb
--- /dev/null
+++ b/browser/components/migration/content/migration.xhtml
@@ -0,0 +1,113 @@
+<?xml version="1.0"?>
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<window id="migrationWizard"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="migration-wizard"
+ windowtype="Browser:MigrationWizard"
+ onload="MigrationWizard.init()"
+ onunload="MigrationWizard.uninit()"
+ style="min-width: 40em;"
+ buttons="accept,cancel">
+<linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="toolkit/global/wizard.ftl"/>
+ <html:link rel="localization" href="browser/migration.ftl"/>
+</linkset>
+
+<script src="chrome://global/content/customElements.js"/>
+<script src="chrome://browser/content/migration/migration.js"/>
+
+<wizard data-branded="true">
+ <wizardpage id="importSource" pageid="importSource" next="selectProfile"
+ data-header-label-id="import-source-page-title">
+ <description id="importAll" control="importSourceGroup" data-l10n-id="import-from"></description>
+ <description id="importBookmarks" control="importSourceGroup" data-l10n-id="import-from-bookmarks" hidden="true" ></description>
+
+ <radiogroup id="importSourceGroup" align="start">
+# NB: if you add items to this list, please also assign them a unique migrator ID in MigrationUtils.jsm
+ <radio id="firefox" data-l10n-id="import-from-firefox"/>
+#ifdef XP_WIN
+ <radio id="chromium-edge" data-l10n-id="import-from-edge"/>
+ <radio id="edge" data-l10n-id="import-from-edge-legacy" />
+ <radio id="chromium-edge-beta" data-l10n-id="import-from-edge-beta"/>
+ <radio id="ie" data-l10n-id="import-from-ie"/>
+ <radio id="opera" data-l10n-id="import-from-opera"/>
+ <radio id="brave" data-l10n-id="import-from-brave"/>
+ <radio id="chrome" data-l10n-id="import-from-chrome"/>
+ <radio id="chrome-beta" data-l10n-id="import-from-chrome-beta"/>
+ <radio id="chromium" data-l10n-id="import-from-chromium"/>
+ <radio id="canary" data-l10n-id="import-from-canary" />
+ <radio id="vivaldi" data-l10n-id="import-from-vivaldi"/>
+ <radio id="chromium-360se" data-l10n-id="import-from-360se"/>
+ <radio id="opera-gx" data-l10n-id="import-from-opera-gx"/>
+#elifdef XP_MACOSX
+ <radio id="safari" data-l10n-id="import-from-safari"/>
+ <radio id="opera" data-l10n-id="import-from-opera"/>
+ <radio id="brave" data-l10n-id="import-from-brave"/>
+ <radio id="chrome" data-l10n-id="import-from-chrome"/>
+ <radio id="chromium-edge" data-l10n-id="import-from-edge"/>
+ <radio id="chromium-edge-beta" data-l10n-id="import-from-edge-beta"/>
+ <radio id="chromium" data-l10n-id="import-from-chromium"/>
+ <radio id="canary" data-l10n-id="import-from-canary"/>
+ <radio id="vivaldi" data-l10n-id="import-from-vivaldi"/>
+ <radio id="opera-gx" data-l10n-id="import-from-opera-gx"/>
+#elifdef XP_UNIX
+ <radio id="opera" data-l10n-id="import-from-opera"/>
+ <radio id="vivaldi" data-l10n-id="import-from-vivaldi"/>
+ <radio id="brave" data-l10n-id="import-from-brave"/>
+ <radio id="chrome" data-l10n-id="import-from-chrome"/>
+ <radio id="chrome-beta" data-l10n-id="import-from-chrome-beta"/>
+ <radio id="chrome-dev" data-l10n-id="import-from-chrome-dev"/>
+ <radio id="chromium" data-l10n-id="import-from-chromium"/>
+#endif
+ <radio id="nothing" data-l10n-id="import-from-nothing" hidden="true"/>
+ </radiogroup>
+ <label id="noSources" hidden="true" data-l10n-id="no-migration-sources"></label>
+ </wizardpage>
+
+ <wizardpage id="selectProfile" pageid="selectProfile"
+ data-header-label-id="import-select-profile-page-title"
+ next="importItems">
+ <description control="profiles" data-l10n-id="import-select-profile-description"></description>
+
+ <radiogroup id="profiles" align="start"/>
+ </wizardpage>
+
+ <wizardpage id="importItems" pageid="importItems"
+ data-header-label-id="import-items-page-title"
+ next="migrating"
+ oncommand="MigrationWizard.onImportItemCommand();">
+ <description control="dataSources" data-l10n-id="import-items-description"></description>
+
+ <vbox id="dataSources" style="overflow: auto; appearance: auto; -moz-default-appearance: listbox" align="start" flex="1" role="group"/>
+ </wizardpage>
+
+ <wizardpage id="importPermissions" pageid="importPermissions"
+ data-header-label-id="import-permissions-page-title"
+ next="migrating">
+ <description data-l10n-id="import-safari-permissions-string"></description>
+ </wizardpage>
+
+ <wizardpage id="migrating" pageid="migrating"
+ data-header-label-id="import-migrating-page-title"
+ next="done">
+ <description control="migratingItems" data-l10n-id="import-migrating-description"></description>
+
+ <vbox id="migratingItems" style="overflow: auto;" align="start" role="group"/>
+ </wizardpage>
+
+ <wizardpage id="done" pageid="done"
+ data-header-label-id="import-done-page-title">
+ <description control="doneItems" data-l10n-id="import-done-description"></description>
+
+ <vbox id="doneItems" style="overflow: auto;" align="start" role="group"/>
+ </wizardpage>
+
+</wizard>
+</window>
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">
+ &lt;migration-wizard&gt;
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="570" y="454" font-family="Helvetica" font-size="12" text-anchor="middle">&lt;migration-wizard&gt;</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..dd5905443c
--- /dev/null
+++ b/browser/components/migration/docs/migration-wizard.rst
@@ -0,0 +1,77 @@
+==========================
+Migration Wizard Reference
+==========================
+
+The migration wizard is the piece of UI that allows users to migrate from other browsers to Firefox.
+
+ .. note::
+ Firefox has had a legacy migration wizard for many years, and this has historically been a top-level XUL dialog window. **This documentation is not for the legacy migration wizard**, but is instead for an in-progress replacement migration wizard.
+
+ The new migration wizard can be enabled by setting ``browser.migrate.content-modal.enabled`` to ``true``.
+
+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..8122bcf524
--- /dev/null
+++ b/browser/components/migration/jar.mn
@@ -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/.
+
+browser.jar:
+* content/browser/migration/migration.xhtml (content/migration.xhtml)
+ content/browser/migration/migration.js (content/migration.js)
+ 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/moz.build b/browser/components/migration/moz.build
new file mode 100644
index 0000000000..020bf15802
--- /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.ini"]
+
+MARIONETTE_UNIT_MANIFESTS += ["tests/marionette/manifest.ini"]
+
+MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.ini"]
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
+
+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",
+ ]
+ 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",
+ ]
+ 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..3b0f662914
--- /dev/null
+++ b/browser/components/migration/nsKeychainMigrationUtils.mm
@@ -0,0 +1,62 @@
+/* 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.ini b/browser/components/migration/tests/browser/browser.ini
new file mode 100644
index 0000000000..e8ac0d5995
--- /dev/null
+++ b/browser/components/migration/tests/browser/browser.ini
@@ -0,0 +1,26 @@
+[DEFAULT]
+head = head.js
+prefs =
+ browser.migrate.content-modal.enabled=true
+ browser.migrate.internal-testing.enabled=true
+
+[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_file_migration.js]
+skip-if = os == "win" && debug # Bug 1827995
+support-files =
+ dummy_file.csv
+[browser_ie_edge_bookmarks_success_strings.js]
+[browser_no_browsers_state.js]
+[browser_only_file_migrators.js]
+[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..87f27cdb8d
--- /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",
+ { onResize: () => {} }
+ );
+ 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..e332e1ed4d
--- /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.loadURIString(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..a1c8540c35
--- /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 => {
+ wizard
+ .querySelector("panel-list")
+ .addEventListener("shown", resolve, { once: true });
+ });
+
+ let panelItem = wizard.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 => {
+ wizard
+ .querySelector("panel-list")
+ .addEventListener("shown", resolve, { once: true });
+ });
+
+ let panelItem = wizard.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..b1a5e9ad60
--- /dev/null
+++ b/browser/components/migration/tests/browser/browser_do_migration.js
@@ -0,0 +1,195 @@
+/* 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,
+ ]);
+ });
+
+ // 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
+ );
+ 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..bdeca0fdb5
--- /dev/null
+++ b/browser/components/migration/tests/browser/browser_entrypoint_telemetry.js
@@ -0,0 +1,105 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+const CONTENT_MODAL_ENABLED_PREF = "browser.migrate.content-modal.enabled";
+const HISTOGRAM_ID = "FX_MIGRATION_ENTRY_POINT_CATEGORICAL";
+const LEGACY_HISTOGRAM_ID = "FX_MIGRATION_ENTRY_POINT";
+
+async function showThenCloseMigrationWizardViaEntrypoint(entrypoint) {
+ let openedPromise = BrowserTestUtils.waitForMigrationWizard(window);
+
+ // On some platforms, this call blocks, so in order to let the test proceed, we
+ // run it on the next tick of the event loop.
+ executeSoon(() => {
+ MigrationUtils.showMigrationWizard(window, {
+ entrypoint,
+ });
+ });
+
+ let wizard = await openedPromise;
+ Assert.ok(wizard, "Migration wizard opened.");
+ await BrowserTestUtils.closeMigrationWizard(wizard);
+}
+
+add_setup(async () => {
+ // Load the initial tab at example.com. This makes it so that if
+ // we're using the new migration wizard, we'll load the about:preferences
+ // page in a new tab rather than overtaking the initial one. This
+ // makes it easier to be consistent with closing and opening
+ // behaviours between the two kinds of migration wizards.
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(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, as well
+ * as the legacy FX_MIGRATION_ENTRY_POINT histogram (but only if using the old
+ * wizard window).
+ */
+add_task(async function test_legacy_wizard() {
+ for (let contentModalEnabled of [true, false]) {
+ info("Testing with content modal enabled: " + contentModalEnabled);
+ await SpecialPowers.pushPrefEnv({
+ set: [[CONTENT_MODAL_ENABLED_PREF, contentModalEnabled]],
+ });
+
+ let histogram = TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_ID);
+ let legacyHistogram =
+ TelemetryTestUtils.getAndClearHistogram(LEGACY_HISTOGRAM_ID);
+
+ // Let's arbitrarily pick the "Bookmarks" entrypoint, and make sure this
+ // is recorded.
+ await showThenCloseMigrationWizardViaEntrypoint(
+ MigrationUtils.MIGRATION_ENTRYPOINTS.BOOKMARKS
+ );
+ let entrypointId = MigrationUtils.getLegacyMigrationEntrypoint(
+ MigrationUtils.MIGRATION_ENTRYPOINTS.BOOKMARKS
+ );
+
+ TelemetryTestUtils.assertHistogram(histogram, entrypointId, 1);
+
+ if (!contentModalEnabled) {
+ TelemetryTestUtils.assertHistogram(legacyHistogram, entrypointId, 1);
+ }
+
+ histogram = TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_ID);
+ legacyHistogram =
+ TelemetryTestUtils.getAndClearHistogram(LEGACY_HISTOGRAM_ID);
+
+ // Now let's pick the "Preferences" entrypoint, and make sure this
+ // is recorded.
+ await showThenCloseMigrationWizardViaEntrypoint(
+ MigrationUtils.MIGRATION_ENTRYPOINTS.PREFERENCES
+ );
+ entrypointId = MigrationUtils.getLegacyMigrationEntrypoint(
+ MigrationUtils.MIGRATION_ENTRYPOINTS.PREFERENCES
+ );
+
+ TelemetryTestUtils.assertHistogram(histogram, entrypointId, 1);
+ if (!contentModalEnabled) {
+ TelemetryTestUtils.assertHistogram(legacyHistogram, entrypointId, 1);
+ }
+
+ histogram = TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_ID);
+ legacyHistogram =
+ TelemetryTestUtils.getAndClearHistogram(LEGACY_HISTOGRAM_ID);
+
+ // Finally, check the fallback by passing in something invalid as an entrypoint.
+ await showThenCloseMigrationWizardViaEntrypoint(undefined);
+ entrypointId = MigrationUtils.getLegacyMigrationEntrypoint(
+ MigrationUtils.MIGRATION_ENTRYPOINTS.UNKNOWN
+ );
+
+ TelemetryTestUtils.assertHistogram(histogram, entrypointId, 1);
+ if (!contentModalEnabled) {
+ TelemetryTestUtils.assertHistogram(legacyHistogram, entrypointId, 1);
+ }
+ }
+});
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..774ff25425
--- /dev/null
+++ b/browser/components/migration/tests/browser/browser_file_migration.js
@@ -0,0 +1,185 @@
+/* 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];
+ }
+}
+
+/**
+ * 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;
+ });
+
+ // 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;
+ MockFilePicker.init(window);
+ registerCleanupFunction(() => {
+ MockFilePicker.cleanup();
+ });
+
+ 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 => {
+ wizard
+ .querySelector("panel-list")
+ .addEventListener("shown", resolve, { once: true });
+ });
+
+ info("Panel list shown. Clicking on panel-item");
+ let panelItem = wizard.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-page-file-import-progress'] .resource-progress-group"
+ );
+ for (let progressGroup of progressGroups) {
+ let expectedSuccessText =
+ SUCCESS_STATE[progressGroup.dataset.resourceType];
+ if (expectedSuccessText) {
+ let successText =
+ progressGroup.querySelector(".success-text").textContent;
+ Assert.equal(successText, expectedSuccessText);
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(progressGroup),
+ `Resource progress group for ${progressGroup.dataset.resourceType}` +
+ ` should be hidden.`
+ );
+ }
+ }
+ });
+});
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..ab0b705678
--- /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 successTextElement =
+ bookmarksProgressGroup.querySelector(".success-text");
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return successTextElement.textContent.trim();
+ });
+
+ let successText = successTextElement.textContent.toLowerCase();
+
+ if (key == "internal-testing") {
+ Assert.ok(
+ successText.includes("bookmarks"),
+ `Success test should refer to bookmarks: ${successText}.`
+ );
+ } else {
+ Assert.ok(
+ successText.includes("favorites"),
+ `Success test should refer to favorites: ${successText}`
+ );
+ }
+
+ 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_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..80c09e9a09
--- /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 = wizard.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 = wizard.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_safari_passwords.js b/browser/components/migration/tests/browser/browser_safari_passwords.js
new file mode 100644
index 0000000000..299695f9e3
--- /dev/null
+++ b/browser/components/migration/tests/browser/browser_safari_passwords.js
@@ -0,0 +1,401 @@
+/* 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 {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,
+ 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);
+ 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")
+ .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 = wizard.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,
+ 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,
+ 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 that the user can skip importing passwords from Safari.
+ */
+add_task(async function test_safari_password_skip() {
+ await testSafariPasswordHelper(
+ false,
+ true,
+ 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 = wizard.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..924d6cc4d4
--- /dev/null
+++ b/browser/components/migration/tests/browser/browser_safari_permissions.js
@@ -0,0 +1,133 @@
+/* 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 safariMigrator = new SafariProfileMigrator();
+ sandbox.stub(MigrationUtils, "getMigrator").resolves(safariMigrator);
+
+ sandbox
+ .stub(SafariProfileMigrator.prototype, "hasPermissions")
+ .onFirstCall()
+ .resolves(false)
+ .onSecondCall()
+ .resolves(true);
+
+ sandbox
+ .stub(SafariProfileMigrator.prototype, "getPermissions")
+ .resolves(true);
+
+ sandbox
+ .stub(SafariProfileMigrator.prototype, "getResources")
+ .callsFake(() => {
+ return Promise.resolve([
+ {
+ type: MigrationUtils.resourceTypes.BOOKMARKS,
+ migrate: () => {},
+ },
+ ]);
+ });
+
+ let didMigration = new Promise(resolve => {
+ sandbox
+ .stub(SafariProfileMigrator.prototype, "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 = wizard.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..772dd8bd29
--- /dev/null
+++ b/browser/components/migration/tests/browser/head.js
@@ -0,0 +1,350 @@
+/* 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 { MigrationWizardConstants } = ChromeUtils.importESModule(
+ "chrome://browser/content/migration/migration-wizard-constants.mjs"
+);
+const { InternalTestingProfileMigrator } = ChromeUtils.importESModule(
+ "resource:///modules/InternalTestingProfileMigrator.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,
+];
+
+/**
+ * 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.loadURIString(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.
+ * @returns {Promise<undefined>}
+ */
+async function waitForTestMigration(
+ availableResourceTypes,
+ expectedResourceTypes,
+ expectedProfile
+) {
+ let sandbox = sinon.createSandbox();
+
+ // 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,
+ });
+
+ // 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) {
+ aProgressCallback(resourceType);
+ }
+ Services.obs.notifyObservers(null, "Migration:Ended");
+ 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 => {
+ wizard
+ .querySelector("panel-list")
+ .addEventListener("shown", resolve, { once: true });
+ });
+
+ let panelItem = wizard.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 in MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES) {
+ 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.
+ */
+function assertQuantitiesShown(wizard, expectedResourceTypes) {
+ 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}`
+ );
+
+ // 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 successText =
+ progressGroup.querySelector(".success-text").textContent;
+
+ Assert.ok(
+ progressIcon.classList.contains("completed"),
+ "Should be showing completed 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(
+ successText.indexOf(MigrationUtils.HISTORY_MAX_AGE_IN_DAYS),
+ -1,
+ `Found expected maximum number of days of history: ${successText}`
+ );
+ } else if (
+ progressGroup.dataset.resourceType ==
+ MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA
+ ) {
+ // FORMDATA is another special case, because we simply show "Form history" as
+ // the success string, rather than a particular quantity.
+ Assert.equal(
+ successText,
+ "Form history",
+ `Found expected form data string: ${successText}`
+ );
+ } else {
+ Assert.notEqual(
+ successText.indexOf(EXPECTED_QUANTITY),
+ -1,
+ `Found expected quantity in success string: ${successText}`
+ );
+ }
+ } 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(
+ successText,
+ "",
+ "Expected the empty string if the resource type " +
+ "isn't in RESOURCE_TYPES_WITH_QUANTITIES"
+ );
+ }
+ } else {
+ Assert.ok(
+ BrowserTestUtils.is_hidden(progressGroup),
+ `Resource progress group for ${progressGroup.dataset.resourceType}` +
+ ` should be hidden.`
+ );
+ }
+ }
+}
diff --git a/browser/components/migration/tests/chrome/chrome.ini b/browser/components/migration/tests/chrome/chrome.ini
new file mode 100644
index 0000000000..2df25e8304
--- /dev/null
+++ b/browser/components/migration/tests/chrome/chrome.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+skip-if = os == 'android'
+
+[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..7f3f4ab9a9
--- /dev/null
+++ b/browser/components/migration/tests/chrome/test_migration_wizard.html
@@ -0,0 +1,1154 @@
+<!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="chrome://browser/content/migration/migration-wizard.mjs"
+ type="module"
+ ></script>
+ <link
+ rel="stylesheet"
+ href="chrome://mochikit/content/tests/SimpleTest/test.css"
+ />
+ <script>
+ "use strict";
+
+ const { MigrationWizardConstants } = ChromeUtils.importESModule(
+ "chrome://browser/content/migration/migration-wizard-constants.mjs"
+ );
+
+ 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"],
+ profile: { id: "person-2", name: "Person 2" },
+ },
+ {
+ key: "some-browser-1",
+ type: MigrationWizardConstants.MIGRATOR_TYPES.BROWSER,
+ displayName: "Some Browser 1",
+ resourceTypes: ["HISTORY", "BOOKMARKS"],
+ profile: null,
+ },
+ ];
+
+ 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 panelList = document.createElement("panel-list");
+ wiz.appendChild(panelList);
+ 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 = gWiz.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 => {
+ gWiz
+ .querySelector("panel-list")
+ .addEventListener("shown", resolve, { once: true });
+ });
+ let panelItem = gWiz.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 in MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES) {
+ 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" },
+ }],
+ 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 = gWiz.querySelector("panel-list");
+ if (panelList) {
+ panelList.addEventListener("shown", resolve, { once: true });
+ }
+ });
+
+ let panelItems = gWiz.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 in MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES) {
+ 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" },
+ }, {
+ 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" },
+ }],
+ 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 = gWiz.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]: {
+ inProgress: false,
+ message: BOOKMARKS_SUCCESS_STRING,
+ },
+ // Don't include PASSWORDS to check that it's hidden.
+ [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY]: {
+ inProgress: true,
+ },
+ [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA]: {
+ inProgress: true,
+ },
+ },
+ });
+ 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");
+ ok(
+ progressIcon.classList.contains("completed"),
+ "Progress should be completed"
+ );
+ is(
+ bookmarksGroup.querySelector(".success-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");
+ ok(
+ !progressIcon.classList.contains("completed"),
+ "Progress should be still be underway"
+ );
+ is(historyGroup.querySelector(".success-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");
+ ok(
+ !progressIcon.classList.contains("completed"),
+ "Progress should be still be underway"
+ );
+ is(formDataGroup.querySelector(".success-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";
+ gWiz.setState({
+ page: MigrationWizardConstants.PAGES.PROGRESS,
+ key: "chrome",
+ progress: {
+ [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: {
+ inProgress: false,
+ message: BOOKMARKS_SUCCESS_STRING,
+ },
+ [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS]: {
+ inProgress: false,
+ message: PASSWORDS_SUCCESS_STRING,
+ },
+ // Don't include HISTORY to check that it's hidden.
+ [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA]: {
+ inProgress: false,
+ 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");
+ ok(
+ progressIcon.classList.contains("completed"),
+ "Progress should be completed"
+ );
+ is(
+ bookmarksGroup.querySelector(".success-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");
+ ok(
+ progressIcon.classList.contains("completed"),
+ "Progress should be completed"
+ );
+ is(
+ passwordsGroup.querySelector(".success-text").textContent,
+ PASSWORDS_SUCCESS_STRING
+ );
+
+ // History
+ let historyGroup = getResourceGroup(
+ MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY
+ );
+ ok(isHidden(historyGroup), "History group should be hidden");
+
+ // Form Data
+ let formDataGroup = getResourceGroup(
+ MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA
+ );
+ ok(!isHidden(formDataGroup), "Form data group should be visible");
+ progressIcon = formDataGroup.querySelector(".progress-icon");
+ ok(
+ progressIcon.classList.contains("completed"),
+ "Progress should be completed"
+ );
+ is(
+ formDataGroup.querySelector(".success-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");
+ });
+
+ /**
+ * 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]: {
+ inProgress: false,
+ message: PASSWORDS_SUCCESS_STRING,
+ },
+ [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: {
+ inProgress: true,
+ },
+ },
+ });
+
+ 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");
+ ok(
+ progressIcon.classList.contains("completed"),
+ "Progress should be completed"
+ );
+ is(
+ passwordsFromFileGroup.querySelector(".success-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");
+ ok(
+ !progressIcon.classList.contains("completed"),
+ "Progress should be still be underway"
+ );
+ is(passwordsNewGroup.querySelector(".success-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]: {
+ inProgress: false,
+ message: PASSWORDS_NEW_SUCCESS_STRING,
+ },
+ [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED]: {
+ inProgress: false,
+ 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");
+ ok(
+ progressIcon.classList.contains("completed"),
+ "Progress should be completed"
+ );
+ is(
+ passwordsNewGroup.querySelector(".success-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");
+ ok(
+ progressIcon.classList.contains("completed"),
+ "Progress should be completed"
+ );
+ is(
+ passwordsUpdatedGroup.querySelector(".success-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");
+ synthesizeMouseAtCenter(button, {});
+ 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,
+ }],
+ 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,
+ }],
+ 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]: {
+ inProgress: false,
+ 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 being back "inProgress", 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]: {
+ inProgress: false,
+ 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");
+ ok(
+ progressIcon.classList.contains("completed"),
+ "Progress should be completed"
+ );
+ is(
+ bookmarksGroup.querySelector(".success-text").textContent,
+ STRING_TO_CLEAR
+ );
+
+ gWiz.setState({
+ page: MigrationWizardConstants.PAGES.PROGRESS,
+ key: "chrome",
+ progress: {
+ [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: {
+ inProgress: true,
+ message: "",
+ },
+ },
+ });
+
+ ok(!isHidden(bookmarksGroup), "Bookmarks group should be visible");
+ ok(
+ !progressIcon.classList.contains("completed"),
+ "Progress should still be underway"
+ );
+ is(
+ bookmarksGroup.querySelector(".success-text").textContent.trim(),
+ ""
+ );
+ });
+
+ /**
+ * Tests that if a file progress update comes down which puts a resource
+ * from being done to being back "inProgress", 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]: {
+ inProgress: false,
+ 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");
+ ok(
+ progressIcon.classList.contains("completed"),
+ "Progress should be completed"
+ );
+ is(
+ passwordsGroup.querySelector(".success-text").textContent,
+ STRING_TO_CLEAR
+ );
+
+ gWiz.setState({
+ page: MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS,
+ key: "chrome",
+ progress: {
+ [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: {
+ inProgress: true,
+ message: "",
+ },
+ },
+ });
+
+ ok(!isHidden(passwordsGroup), "Passwords group should be visible");
+ ok(
+ !progressIcon.classList.contains("completed"),
+ "Progress should still be underway"
+ );
+ is(
+ passwordsGroup.querySelector(".success-text").textContent.trim(),
+ ""
+ );
+ });
+ </script>
+ </head>
+ <body>
+ <p id="display"></p>
+ <div id="content">
+ <migration-wizard id="test-wizard" dialog-mode="">
+ <panel-list></panel-list>
+ </migration-wizard>
+ </div>
+ <pre id="test"></pre>
+ </body>
+</html>
diff --git a/browser/components/migration/tests/marionette/manifest.ini b/browser/components/migration/tests/marionette/manifest.ini
new file mode 100644
index 0000000000..afba7ead73
--- /dev/null
+++ b/browser/components/migration/tests/marionette/manifest.ini
@@ -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..603534ff17
--- /dev/null
+++ b/browser/components/migration/tests/marionette/test_refresh_firefox.py
@@ -0,0 +1,690 @@
+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.marionette.execute_script(
+ """
+ let ary = Services.logins.findLogins(
+ "test.marionette.mozilla.com",
+ "http://test.marionette.mozilla.com/some/form/",
+ null, {});
+ return 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.marionette.execute_script(
+ """
+ return Services.logins.getAllLogins().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 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()
+
+ 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
new file mode 100644
index 0000000000..fddee798b3
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons
Binary files differ
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
new file mode 100644
index 0000000000..7e6e843a03
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data
Binary files differ
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
new file mode 100644
index 0000000000..c557c9b851
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data
Binary files differ
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
new file mode 100644
index 0000000000..fd135624c4
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data
Binary files differ
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
new file mode 100644
index 0000000000..1835c33583
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat
Binary files differ
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
new file mode 100644
index 0000000000..83d855cb33
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies
Binary files differ
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..0333a91e56
--- /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,5 @@
+{
+ "default_locale": "en_US",
+ "description": "It is the description of fake extension 2.",
+ "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
new file mode 100644
index 0000000000..8585f308c5
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt
Binary files differ
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
new file mode 100644
index 0000000000..7fb19903b0
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster
Binary files differ
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
new file mode 100644
index 0000000000..19b8542b98
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data
Binary files differ
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
new file mode 100644
index 0000000000..a9c33e1b1a
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist
Binary files differ
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
new file mode 100644
index 0000000000..dd5d0c7512
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db
Binary files differ
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
new file mode 100644
index 0000000000..edd607898b
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm
Binary files differ
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
new file mode 100644
index 0000000000..e145119298
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal
Binary files differ
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
new file mode 100644
index 0000000000..1c6741c165
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9
Binary files differ
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
new file mode 100644
index 0000000000..47b40f707f
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271
Binary files differ
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
new file mode 100644
index 0000000000..2a4c30b31e
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42
Binary files differ
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
new file mode 100644
index 0000000000..f4996ba082
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804
Binary files differ
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
new file mode 100644
index 0000000000..f519ce9ad2
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80
Binary files differ
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
new file mode 100644
index 0000000000..e70021849b
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F
Binary files differ
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
new file mode 100644
index 0000000000..559502b02b
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8
Binary files differ
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
new file mode 100644
index 0000000000..89ed9a1c39
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A
Binary files differ
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
new file mode 100644
index 0000000000..7b86185e67
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC
Binary files differ
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
new file mode 100644
index 0000000000..a1d03856b5
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08
Binary files differ
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
new file mode 100644
index 0000000000..ba1145ca83
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B
Binary files differ
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
new file mode 100644
index 0000000000..82339b3b1d
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137
Binary files differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db b/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db
new file mode 100644
index 0000000000..5a317c70e8
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db
Binary files differ
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
new file mode 100644
index 0000000000..b2d425eb4a
--- /dev/null
+++ b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data
Binary files differ
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&amp;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/head_migration.js b/browser/components/migration/tests/unit/head_migration.js
new file mode 100644
index 0000000000..00db0734a0
--- /dev/null
+++ b/browser/components/migration/tests/unit/head_migration.js
@@ -0,0 +1,261 @@
+"use strict";
+
+var { MigrationUtils } = ChromeUtils.importESModule(
+ "resource:///modules/MigrationUtils.sys.mjs"
+);
+var { LoginHelper } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginHelper.sys.mjs"
+);
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+var { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+var { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.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..bd4f69657a
--- /dev/null
+++ b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp
@@ -0,0 +1,37 @@
+/* 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..a30221b5bd
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js
@@ -0,0 +1,120 @@
+/* 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."
+ );
+});
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..bf76863fe2
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js
@@ -0,0 +1,86 @@
+"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",
+ description: "It is the description of fake extension 2.",
+ },
+ "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..c10ab6dcd6
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js
@@ -0,0 +1,199 @@
+"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
+ subDirs.push("Default");
+ while (subDirs.length) {
+ target.append(subDirs.shift());
+ }
+
+ 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);
+ // 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_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..16f98e7038
--- /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 = 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 = 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 = 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 = 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 = 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..02b721fd5c
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js
@@ -0,0 +1,24 @@
+"use strict";
+
+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 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_Edge_db_migration.js b/browser/components/migration/tests/unit/test_Edge_db_migration.js
new file mode 100644
index 0000000000..e342c9be60
--- /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(
+ "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_IE7_passwords.js b/browser/components/migration/tests/unit/test_IE7_passwords.js
new file mode 100644
index 0000000000..2c372fa343
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_IE7_passwords.js
@@ -0,0 +1,493 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ OSCrypto: "resource://gre/modules/OSCrypto_win.sys.mjs",
+});
+
+const IE7_FORM_PASSWORDS_MIGRATOR_NAME = "IE7FormPasswords";
+const LOGINS_KEY =
+ "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2";
+const EXTENSION = "-backup";
+const TESTED_WEBSITES = {
+ twitter: {
+ uri: makeURI("https://twitter.com"),
+ hash: "A89D42BC6406E27265B1AD0782B6F376375764A301",
+ data: [
+ 12, 0, 0, 0, 56, 0, 0, 0, 38, 0, 0, 0, 87, 73, 67, 75, 24, 0, 0, 0, 2, 0,
+ 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 68, 36, 67, 124,
+ 118, 212, 208, 1, 8, 0, 0, 0, 18, 0, 0, 0, 68, 36, 67, 124, 118, 212, 208,
+ 1, 9, 0, 0, 0, 97, 0, 98, 0, 99, 0, 100, 0, 101, 0, 102, 0, 103, 0, 104,
+ 0, 0, 0, 49, 0, 50, 0, 51, 0, 52, 0, 53, 0, 54, 0, 55, 0, 56, 0, 57, 0, 0,
+ 0,
+ ],
+ logins: [
+ {
+ username: "abcdefgh",
+ password: "123456789",
+ origin: "https://twitter.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439325854000,
+ timeLastUsed: 1439325854000,
+ timePasswordChanged: 1439325854000,
+ timesUsed: 1,
+ },
+ ],
+ },
+ facebook: {
+ uri: makeURI("https://www.facebook.com/"),
+ hash: "EF44D3E034009CB0FD1B1D81A1FF3F3335213BD796",
+ data: [
+ 12, 0, 0, 0, 152, 0, 0, 0, 160, 0, 0, 0, 87, 73, 67, 75, 24, 0, 0, 0, 8,
+ 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 88, 182, 125, 18,
+ 121, 212, 208, 1, 9, 0, 0, 0, 20, 0, 0, 0, 88, 182, 125, 18, 121, 212,
+ 208, 1, 9, 0, 0, 0, 40, 0, 0, 0, 134, 65, 33, 37, 121, 212, 208, 1, 9, 0,
+ 0, 0, 60, 0, 0, 0, 134, 65, 33, 37, 121, 212, 208, 1, 9, 0, 0, 0, 80, 0,
+ 0, 0, 45, 242, 246, 62, 121, 212, 208, 1, 9, 0, 0, 0, 100, 0, 0, 0, 45,
+ 242, 246, 62, 121, 212, 208, 1, 9, 0, 0, 0, 120, 0, 0, 0, 28, 10, 193, 80,
+ 121, 212, 208, 1, 9, 0, 0, 0, 140, 0, 0, 0, 28, 10, 193, 80, 121, 212,
+ 208, 1, 9, 0, 0, 0, 117, 0, 115, 0, 101, 0, 114, 0, 110, 0, 97, 0, 109, 0,
+ 101, 0, 48, 0, 0, 0, 112, 0, 97, 0, 115, 0, 115, 0, 119, 0, 111, 0, 114,
+ 0, 100, 0, 48, 0, 0, 0, 117, 0, 115, 0, 101, 0, 114, 0, 110, 0, 97, 0,
+ 109, 0, 101, 0, 49, 0, 0, 0, 112, 0, 97, 0, 115, 0, 115, 0, 119, 0, 111,
+ 0, 114, 0, 100, 0, 49, 0, 0, 0, 117, 0, 115, 0, 101, 0, 114, 0, 110, 0,
+ 97, 0, 109, 0, 101, 0, 50, 0, 0, 0, 112, 0, 97, 0, 115, 0, 115, 0, 119, 0,
+ 111, 0, 114, 0, 100, 0, 50, 0, 0, 0, 117, 0, 115, 0, 101, 0, 114, 0, 110,
+ 0, 97, 0, 109, 0, 101, 0, 51, 0, 0, 0, 112, 0, 97, 0, 115, 0, 115, 0, 119,
+ 0, 111, 0, 114, 0, 100, 0, 51, 0, 0, 0,
+ ],
+ logins: [
+ {
+ username: "username0",
+ password: "password0",
+ origin: "https://www.facebook.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439326966000,
+ timeLastUsed: 1439326966000,
+ timePasswordChanged: 1439326966000,
+ timesUsed: 1,
+ },
+ {
+ username: "username1",
+ password: "password1",
+ origin: "https://www.facebook.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439326997000,
+ timeLastUsed: 1439326997000,
+ timePasswordChanged: 1439326997000,
+ timesUsed: 1,
+ },
+ {
+ username: "username2",
+ password: "password2",
+ origin: "https://www.facebook.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439327040000,
+ timeLastUsed: 1439327040000,
+ timePasswordChanged: 1439327040000,
+ timesUsed: 1,
+ },
+ {
+ username: "username3",
+ password: "password3",
+ origin: "https://www.facebook.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439327070000,
+ timeLastUsed: 1439327070000,
+ timePasswordChanged: 1439327070000,
+ timesUsed: 1,
+ },
+ ],
+ },
+ live: {
+ uri: makeURI("https://login.live.com/"),
+ hash: "7B506F2D6B81D939A8E0456F036EE8970856FF705E",
+ data: [
+ 12, 0, 0, 0, 56, 0, 0, 0, 44, 0, 0, 0, 87, 73, 67, 75, 24, 0, 0, 0, 2, 0,
+ 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 212, 17, 219, 140,
+ 148, 212, 208, 1, 9, 0, 0, 0, 20, 0, 0, 0, 212, 17, 219, 140, 148, 212,
+ 208, 1, 11, 0, 0, 0, 114, 0, 105, 0, 97, 0, 100, 0, 104, 0, 49, 6, 74, 6,
+ 39, 6, 54, 6, 0, 0, 39, 6, 66, 6, 49, 6, 35, 6, 80, 0, 192, 0, 223, 0,
+ 119, 0, 246, 0, 114, 0, 100, 0, 0, 0,
+ ],
+ logins: [
+ {
+ username: "riadhرياض",
+ password: "اقرأPÀßwörd",
+ origin: "https://login.live.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439338767000,
+ timeLastUsed: 1439338767000,
+ timePasswordChanged: 1439338767000,
+ timesUsed: 1,
+ },
+ ],
+ },
+ reddit: {
+ uri: makeURI("http://www.reddit.com/"),
+ hash: "B644028D1C109A91EC2C4B9D1F145E55A1FAE42065",
+ data: [
+ 12, 0, 0, 0, 152, 0, 0, 0, 212, 0, 0, 0, 87, 73, 67, 75, 24, 0, 0, 0, 8,
+ 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 8, 234, 114,
+ 153, 212, 208, 1, 1, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 6, 0, 0, 0, 97, 93, 131, 116, 153, 212, 208, 1, 3, 0, 0, 0, 14, 0, 0,
+ 0, 97, 93, 131, 116, 153, 212, 208, 1, 16, 0, 0, 0, 48, 0, 0, 0, 88, 150,
+ 78, 174, 153, 212, 208, 1, 4, 0, 0, 0, 58, 0, 0, 0, 88, 150, 78, 174, 153,
+ 212, 208, 1, 29, 0, 0, 0, 118, 0, 0, 0, 79, 102, 137, 34, 154, 212, 208,
+ 1, 15, 0, 0, 0, 150, 0, 0, 0, 79, 102, 137, 34, 154, 212, 208, 1, 30, 0,
+ 0, 0, 97, 0, 0, 0, 0, 0, 252, 140, 173, 138, 146, 48, 0, 0, 66, 0, 105, 0,
+ 116, 0, 116, 0, 101, 0, 32, 0, 98, 0, 101, 0, 115, 0, 116, 0, 228, 0, 116,
+ 0, 105, 0, 103, 0, 101, 0, 110, 0, 0, 0, 205, 145, 110, 127, 198, 91, 1,
+ 120, 0, 0, 31, 4, 48, 4, 64, 4, 62, 4, 59, 4, 76, 4, 32, 0, 67, 4, 65, 4,
+ 63, 4, 53, 4, 72, 4, 61, 4, 62, 4, 32, 0, 65, 4, 49, 4, 64, 4, 62, 4, 72,
+ 4, 53, 4, 61, 4, 46, 0, 32, 0, 18, 4, 62, 4, 57, 4, 66, 4, 56, 4, 0, 0,
+ 40, 6, 51, 6, 69, 6, 32, 0, 39, 6, 68, 6, 68, 6, 71, 6, 32, 0, 39, 6, 68,
+ 6, 49, 6, 45, 6, 69, 6, 70, 6, 0, 0, 118, 0, 101, 0, 117, 0, 105, 0, 108,
+ 0, 108, 0, 101, 0, 122, 0, 32, 0, 108, 0, 101, 0, 32, 0, 118, 0, 233, 0,
+ 114, 0, 105, 0, 102, 0, 105, 0, 101, 0, 114, 0, 32, 0, 224, 0, 32, 0, 110,
+ 0, 111, 0, 117, 0, 118, 0, 101, 0, 97, 0, 117, 0, 0, 0,
+ ],
+ logins: [
+ // This login is present in the data, but should be stripped out
+ // by the validation rules of the importer:
+ // {
+ // "username": "a",
+ // "password": "",
+ // "origin": "http://www.reddit.com",
+ // "formActionOrigin": "",
+ // "httpRealm": null,
+ // "usernameField": "",
+ // "passwordField": ""
+ // },
+ {
+ username: "購読を",
+ password: "Bitte bestätigen",
+ origin: "http://www.reddit.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439340874000,
+ timeLastUsed: 1439340874000,
+ timePasswordChanged: 1439340874000,
+ timesUsed: 1,
+ },
+ {
+ username: "重置密码",
+ password: "Пароль успешно сброшен. Войти",
+ origin: "http://www.reddit.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439340971000,
+ timeLastUsed: 1439340971000,
+ timePasswordChanged: 1439340971000,
+ timesUsed: 1,
+ },
+ {
+ username: "بسم الله الرحمن",
+ password: "veuillez le vérifier à nouveau",
+ origin: "http://www.reddit.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439341166000,
+ timeLastUsed: 1439341166000,
+ timePasswordChanged: 1439341166000,
+ timesUsed: 1,
+ },
+ ],
+ },
+};
+
+const TESTED_URLS = [
+ "http://a.foo.com",
+ "http://b.foo.com",
+ "http://c.foo.com",
+ "http://www.test.net",
+ "http://www.test.net/home",
+ "http://www.test.net/index",
+ "https://a.bar.com",
+ "https://b.bar.com",
+ "https://c.bar.com",
+];
+
+var nsIWindowsRegKey = Ci.nsIWindowsRegKey;
+var Storage2Key;
+
+/*
+ * If the key value exists, it's going to be backed up and replaced, so the value could be restored.
+ * Otherwise a new value is going to be created.
+ */
+function backupAndStore(key, name, value) {
+ if (key.hasValue(name)) {
+ // backup the the current value
+ let type = key.getValueType(name);
+ // create a new value using use the current value name followed by EXTENSION as its new name
+ switch (type) {
+ case nsIWindowsRegKey.TYPE_STRING:
+ key.writeStringValue(name + EXTENSION, key.readStringValue(name));
+ break;
+ case nsIWindowsRegKey.TYPE_BINARY:
+ key.writeBinaryValue(name + EXTENSION, key.readBinaryValue(name));
+ break;
+ case nsIWindowsRegKey.TYPE_INT:
+ key.writeIntValue(name + EXTENSION, key.readIntValue(name));
+ break;
+ case nsIWindowsRegKey.TYPE_INT64:
+ key.writeInt64Value(name + EXTENSION, key.readInt64Value(name));
+ break;
+ }
+ }
+ key.writeBinaryValue(name, value);
+}
+
+// Remove all values where their names are members of the names array from the key of registry
+function removeAllValues(key, names) {
+ for (let name of names) {
+ key.removeValue(name);
+ }
+}
+
+// Restore all the backed up values
+function restore(key) {
+ let count = key.valueCount;
+ let names = []; // the names of the key values
+ for (let i = 0; i < count; ++i) {
+ names.push(key.getValueName(i));
+ }
+
+ for (let name of names) {
+ // backed up values have EXTENSION at the end of their names
+ if (name.lastIndexOf(EXTENSION) == name.length - EXTENSION.length) {
+ let valueName = name.substr(0, name.length - EXTENSION.length);
+ let type = key.getValueType(name);
+ // create a new value using the name before the backup and removed the backed up one
+ switch (type) {
+ case nsIWindowsRegKey.TYPE_STRING:
+ key.writeStringValue(valueName, key.readStringValue(name));
+ key.removeValue(name);
+ break;
+ case nsIWindowsRegKey.TYPE_BINARY:
+ key.writeBinaryValue(valueName, key.readBinaryValue(name));
+ key.removeValue(name);
+ break;
+ case nsIWindowsRegKey.TYPE_INT:
+ key.writeIntValue(valueName, key.readIntValue(name));
+ key.removeValue(name);
+ break;
+ case nsIWindowsRegKey.TYPE_INT64:
+ key.writeInt64Value(valueName, key.readInt64Value(name));
+ key.removeValue(name);
+ break;
+ }
+ }
+ }
+}
+
+function checkLoginsAreEqual(passwordManagerLogin, IELogin, id) {
+ passwordManagerLogin.QueryInterface(Ci.nsILoginMetaInfo);
+ for (let attribute in IELogin) {
+ Assert.equal(
+ passwordManagerLogin[attribute],
+ IELogin[attribute],
+ "The two logins ID " + id + " have the same " + attribute
+ );
+ }
+}
+
+function createRegistryPath(path) {
+ let loginPath = path.split("\\");
+ let parentKey =
+ Cc["@mozilla.org/windows-registry-key;1"].createInstance(nsIWindowsRegKey);
+ let currentPath = [];
+ for (let currentKey of loginPath) {
+ parentKey.open(
+ nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ currentPath.join("\\"),
+ nsIWindowsRegKey.ACCESS_ALL
+ );
+
+ if (!parentKey.hasChild(currentKey)) {
+ parentKey.createChild(currentKey, 0);
+ }
+ currentPath.push(currentKey);
+ parentKey.close();
+ }
+}
+
+async function getFirstResourceOfType(type) {
+ let migrator = await MigrationUtils.getMigrator("ie");
+ let migrators = migrator.getResources();
+ for (let m of migrators) {
+ if (m.name == IE7_FORM_PASSWORDS_MIGRATOR_NAME && m.type == type) {
+ return m;
+ }
+ }
+ throw new Error("failed to find the " + type + " migrator");
+}
+
+function makeURI(aURL) {
+ return Services.io.newURI(aURL);
+}
+
+add_task(async function setup() {
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ await Assert.rejects(
+ getFirstResourceOfType(MigrationUtils.resourceTypes.PASSWORDS),
+ /failed to find/,
+ "The migrator doesn't exist for win8+"
+ );
+ return;
+ }
+ // create the path to Storage2 in the registry if it doest exist.
+ createRegistryPath(LOGINS_KEY);
+ Storage2Key =
+ Cc["@mozilla.org/windows-registry-key;1"].createInstance(nsIWindowsRegKey);
+ Storage2Key.open(
+ nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ LOGINS_KEY,
+ nsIWindowsRegKey.ACCESS_ALL
+ );
+
+ // create a dummy value otherwise the migrator doesn't exist
+ if (!Storage2Key.hasValue("dummy")) {
+ Storage2Key.writeBinaryValue("dummy", "dummy");
+ }
+});
+
+add_task(async function test_passwordsNotAvailable() {
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ return;
+ }
+
+ let migrator = await getFirstResourceOfType(
+ MigrationUtils.resourceTypes.PASSWORDS
+ );
+ Assert.ok(migrator.exists, "The migrator has to exist");
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ 0,
+ "There are no logins at the beginning of the test"
+ );
+
+ let uris = []; // the uris of the migrated logins
+ for (let url of TESTED_URLS) {
+ uris.push(makeURI(url));
+ // in this test, there is no IE login data in the registry, so after the migration, the number
+ // of logins in the store should be 0
+ await migrator._migrateURIs(uris);
+ logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ 0,
+ "There are no logins after doing the migration without adding values to the registry"
+ );
+ }
+});
+
+add_task(async function test_passwordsAvailable() {
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ return;
+ }
+
+ let crypto = new OSCrypto();
+ let hashes = []; // the hashes of all migrator websites, this is going to be used for the clean up
+
+ registerCleanupFunction(() => {
+ Services.logins.removeAllUserFacingLogins();
+ logins = Services.logins.getAllLogins();
+ Assert.equal(logins.length, 0, "There are no logins after the cleanup");
+ // remove all the values created in this test from the registry
+ removeAllValues(Storage2Key, hashes);
+ // restore all backed up values
+ restore(Storage2Key);
+
+ // clean the dummy value
+ if (Storage2Key.hasValue("dummy")) {
+ Storage2Key.removeValue("dummy");
+ }
+ Storage2Key.close();
+ crypto.finalize();
+ });
+
+ let migrator = await getFirstResourceOfType(
+ MigrationUtils.resourceTypes.PASSWORDS
+ );
+ Assert.ok(migrator.exists, "The migrator has to exist");
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ 0,
+ "There are no logins at the beginning of the test"
+ );
+
+ let uris = []; // the uris of the migrated logins
+
+ let loginCount = 0;
+ for (let current in TESTED_WEBSITES) {
+ let website = TESTED_WEBSITES[current];
+ // backup the current the registry value if it exists and replace the existing value/create a
+ // new value with the encrypted data
+ backupAndStore(
+ Storage2Key,
+ website.hash,
+ crypto.encryptData(crypto.arrayToString(website.data), website.uri.spec)
+ );
+ Assert.ok(migrator.exists, "The migrator has to exist");
+ uris.push(website.uri);
+ hashes.push(website.hash);
+
+ await migrator._migrateURIs(uris);
+ logins = Services.logins.getAllLogins();
+ // check that the number of logins in the password manager has increased as expected which means
+ // that all the values for the current website were imported
+ loginCount += website.logins.length;
+ Assert.equal(
+ logins.length,
+ loginCount,
+ "The number of logins has increased after the migration"
+ );
+ // NB: because telemetry records any login data passed to the login manager, it
+ // also gets told about logins that are duplicates or invalid (for one reason
+ // or another) and so its counts might exceed those of the login manager itself.
+ Assert.greaterOrEqual(
+ MigrationUtils._importQuantities.logins,
+ loginCount,
+ "Telemetry quantities equal or exceed the actual import."
+ );
+ // Reset - this normally happens at the start of a new migration, but we're calling
+ // the migrator directly so can't rely on that:
+ MigrationUtils._importQuantities.logins = 0;
+
+ let startIndex = loginCount - website.logins.length;
+ // compares the imported password manager logins with their expected logins
+ for (let i = 0; i < website.logins.length; i++) {
+ checkLoginsAreEqual(
+ logins[startIndex + i],
+ website.logins[i],
+ " " + current + " - " + i + " "
+ );
+ }
+ }
+});
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..12ca5a7b1d
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js
@@ -0,0 +1,29 @@
+"use strict";
+
+let tmpFile = FileUtils.getDir("TmpD", [], true);
+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 = PromiseUtils.defer();
+ 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..5f09080877
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_PasswordFileMigrator.js
@@ -0,0 +1,83 @@
+/* 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"
+);
+
+/**
+ * Tests that the PasswordFileMigrator properly subclasses FileMigratorBase
+ * and delegates to the LoginCSVImport module.
+ */
+add_task(async function test_PasswordFileMigrator() {
+ Services.prefs.setBoolPref("signon.management.page.fileImport.enabled", true);
+ let sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("signon.management.page.fileImport.enabled");
+ 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."
+ );
+});
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_fx_telemetry.js b/browser/components/migration/tests/unit/test_fx_telemetry.js
new file mode 100644
index 0000000000..2660998588
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_fx_telemetry.js
@@ -0,0 +1,393 @@
+/* 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"
+);
+
+// These preferences are set to true anytime MigratorBase.migrate
+// successfully completes a migration of their type.
+const BOOKMARKS_PREF = "browser.migrate.interactions.bookmarks";
+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 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("${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, 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.ini b/browser/components/migration/tests/unit/xpcshell.ini
new file mode 100644
index 0000000000..d35b021c61
--- /dev/null
+++ b/browser/components/migration/tests/unit/xpcshell.ini
@@ -0,0 +1,58 @@
+[DEFAULT]
+head = head_migration.js
+tags = condprof
+firefox-appdir = browser
+skip-if = toolkit == 'android' # bug 1730213
+prefs =
+ browser.migrate.showBookmarksToolbarAfterMigration=true
+support-files =
+ Library/**
+ AppData/**
+ bookmarks.exported.html
+ bookmarks.exported.json
+
+[test_360se_bookmarks.js]
+skip-if = os != "win"
+[test_360seMigrationUtils.js]
+run-if = os == "win"
+[test_BookmarksFileMigrator.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 != "win" && os != "mac"
+ condprof # bug 1769154 - not realistic for condprof
+[test_Chrome_formdata.js]
+[test_Chrome_history.js]
+skip-if = os != "mac" # Relies on ULibDir
+[test_Chrome_passwords.js]
+skip-if = os != "win" && os != "mac"
+ condprof # bug 1769154 - not realistic for condprof
+[test_Chrome_passwords_emptySource.js]
+skip-if = os != "win" && os != "mac"
+ condprof # bug 1769154 - not realistic for condprof
+support-files =
+ LibraryWithNoData/**
+[test_ChromeMigrationUtils.js]
+[test_ChromeMigrationUtils_path.js]
+[test_Edge_db_migration.js]
+skip-if = os != "win"
+[test_Edge_registry_migration.js]
+skip-if = os != "win"
+[test_PasswordFileMigrator.js]
+[test_fx_telemetry.js]
+[test_IE_bookmarks.js]
+skip-if = !(os == "win" && bits == 64) # bug 1392396
+[test_IE_history.js]
+skip-if =
+ os != "win"
+ os == "win" && msix # https://bugzilla.mozilla.org/show_bug.cgi?id=1807928
+[test_IE7_passwords.js]
+skip-if = os != "win"
+[test_MigrationUtils_timedRetry.js]
+skip-if = !debug && os == "mac" #Bug 1558330
+[test_Safari_bookmarks.js]
+skip-if = os != "mac"
+[test_Safari_history.js]
+skip-if = os != "mac"