summaryrefslogtreecommitdiffstats
path: root/browser/components/migration/MigratorBase.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/migration/MigratorBase.sys.mjs')
-rw-r--r--browser/components/migration/MigratorBase.sys.mjs599
1 files changed, 599 insertions, 0 deletions
diff --git a/browser/components/migration/MigratorBase.sys.mjs b/browser/components/migration/MigratorBase.sys.mjs
new file mode 100644
index 0000000000..52bfc87b3e
--- /dev/null
+++ b/browser/components/migration/MigratorBase.sys.mjs
@@ -0,0 +1,599 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TOPIC_WILL_IMPORT_BOOKMARKS =
+ "initial-migration-will-import-default-bookmarks";
+const TOPIC_DID_IMPORT_BOOKMARKS =
+ "initial-migration-did-import-default-bookmarks";
+const TOPIC_PLACES_DEFAULTS_FINISHED = "places-browser-init-complete";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs",
+ FirefoxProfileMigrator: "resource:///modules/FirefoxProfileMigrator.sys.mjs",
+ MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ ResponsivenessMonitor: "resource://gre/modules/ResponsivenessMonitor.sys.mjs",
+});
+
+/**
+ * @typedef {object} MigratorResource
+ * A resource returned by a subclass of MigratorBase that can migrate
+ * data to this browser.
+ * @property {number} type
+ * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate
+ * what this resource represents. A resource can represent one or more types
+ * of data, for example HISTORY and FORMDATA.
+ * @property {Function} migrate
+ * A function that will actually perform the migration of this resource's
+ * data into this browser.
+ */
+
+/**
+ * Shared prototype for migrators.
+ *
+ * To implement a migrator:
+ * 1. Import this module.
+ * 2. Create a subclass of MigratorBase for your new migrator.
+ * 3. Override the `key` static getter with a unique identifier for the browser
+ * that this migrator migrates from.
+ * 4. If the migrator supports multiple profiles, override the sourceProfiles
+ * Here we default for single-profile migrator.
+ * 5. Implement getResources(aProfile) (see below).
+ * 6. For startup-only migrators, override |startupOnlyMigrator|.
+ * 7. Add the migrator to the MIGRATOR_MODULES structure in MigrationUtils.sys.mjs.
+ */
+export class MigratorBase {
+ /**
+ * This must be overridden to return a simple string identifier for the
+ * migrator, for example "firefox", "chrome", "opera-gx". This key is what
+ * is used as an identifier when calling MigrationUtils.getMigrator.
+ *
+ * @type {string}
+ */
+ static get key() {
+ throw new Error("MigratorBase.key must be overridden.");
+ }
+
+ /**
+ * This must be overridden to return a Fluent string ID mapping to the display
+ * name for this migrator. These strings should be defined in migrationWizard.ftl.
+ *
+ * @type {string}
+ */
+ static get displayNameL10nID() {
+ throw new Error("MigratorBase.displayNameL10nID must be overridden.");
+ }
+
+ /**
+ * This method should get overridden to return an icon url of the browser
+ * to be imported from. By default, this will just use the default Favicon
+ * image.
+ *
+ * @type {string}
+ */
+ static get brandImage() {
+ return "chrome://global/skin/icons/defaultFavicon.svg";
+ }
+
+ /**
+ * OVERRIDE IF AND ONLY IF the source supports multiple profiles.
+ *
+ * Returns array of profile objects from which data may be imported. The object
+ * should have the following keys:
+ * id - a unique string identifier for the profile
+ * name - a pretty name to display to the user in the UI
+ *
+ * Only profiles from which data can be imported should be listed. Otherwise
+ * the behavior of the migration wizard isn't well-defined.
+ *
+ * For a single-profile source (e.g. safari, ie), this returns null,
+ * and not an empty array. That is the default implementation.
+ *
+ * @abstract
+ * @returns {object[]|null}
+ */
+ getSourceProfiles() {
+ return null;
+ }
+
+ /**
+ * MUST BE OVERRIDDEN.
+ *
+ * Returns an array of "migration resources" objects for the given profile,
+ * or for the "default" profile, if the migrator does not support multiple
+ * profiles.
+ *
+ * Each migration resource should provide:
+ * - a |type| getter, returning any of the migration resource types (see
+ * MigrationUtils.resourceTypes).
+ *
+ * - a |migrate| method, taking two arguments,
+ * aCallback(bool success, object details), for migrating the data for
+ * this resource. It may do its job synchronously or asynchronously.
+ * Either way, it must call aCallback(bool aSuccess, object details)
+ * when it's done. In the case of an exception thrown from |migrate|,
+ * it's taken as if aCallback(false, {}) is called. The details
+ * argument is sometimes optional, but conditional on how the
+ * migration wizard wants to display the migration state for the
+ * resource.
+ *
+ * Note: In the case of a simple asynchronous implementation, you may find
+ * MigrationUtils.wrapMigrateFunction handy for handling aCallback easily.
+ *
+ * For each migration type listed in MigrationUtils.resourceTypes, multiple
+ * migration resources may be provided. This practice is useful when the
+ * data for a certain migration type is independently stored in few
+ * locations. For example, the mac version of Safari stores its "reading list"
+ * bookmarks in a separate property list.
+ *
+ * Note that the importation of a particular migration type is reported as
+ * successful if _any_ of its resources succeeded to import (that is, called,
+ * |aCallback(true, {})|). However, completion-status for a particular migration
+ * type is reported to the UI only once all of its migrators have called
+ * aCallback.
+ *
+ * NOTE: The returned array should only include resources from which data
+ * can be imported. So, for example, before adding a resource for the
+ * BOOKMARKS migration type, you should check if you should check that the
+ * bookmarks file exists.
+ *
+ * @abstract
+ * @param {object|string} aProfile
+ * The profile from which data may be imported, or an empty string
+ * in the case of a single-profile migrator.
+ * In the case of multiple-profiles migrator, it is guaranteed that
+ * aProfile is a value returned by the sourceProfiles getter (see
+ * above).
+ * @returns {Promise<MigratorResource[]>|MigratorResource[]}
+ */
+ // eslint-disable-next-line no-unused-vars
+ getResources(aProfile) {
+ throw new Error("getResources must be overridden");
+ }
+
+ /**
+ * OVERRIDE in order to provide an estimate of when the last time was
+ * that somebody used the browser. It is OK that this is somewhat fuzzy -
+ * history may not be available (or be wiped or not present due to e.g.
+ * incognito mode).
+ *
+ * If not overridden, the promise will resolve to the Unix epoch.
+ *
+ * @returns {Promise<Date>}
+ * A Promise that resolves to the last used date.
+ */
+ getLastUsedDate() {
+ return Promise.resolve(new Date(0));
+ }
+
+ /**
+ * OVERRIDE IF AND ONLY IF the migrator is a startup-only migrator (For now,
+ * that is just the Firefox migrator, see bug 737381). Default: false.
+ *
+ * Startup-only migrators are different in two ways:
+ * - they may only be used during startup.
+ * - the user-profile is half baked during migration. The folder exists,
+ * but it's only accessible through MigrationUtils.profileStartup.
+ * The migrator can call MigrationUtils.profileStartup.doStartup
+ * at any point in order to initialize the profile.
+ *
+ * @returns {boolean}
+ * true if the migrator is start-up only.
+ */
+ get startupOnlyMigrator() {
+ return false;
+ }
+
+ /**
+ * Returns true if the migrator is configured to be enabled. This is
+ * controlled by the `browser.migrate.<BROWSER_KEY>.enabled` boolean
+ * preference.
+ *
+ * @returns {boolean}
+ * true if the migrator should be shown in the migration wizard.
+ */
+ get enabled() {
+ let key = this.constructor.key;
+ return Services.prefs.getBoolPref(`browser.migrate.${key}.enabled`, false);
+ }
+
+ /**
+ * Subclasses should implement this if special checks need to be made to determine
+ * if certain permissions need to be requested before data can be imported.
+ * The returned Promise resolves to true if the required permissions have
+ * been granted and a migration could proceed.
+ *
+ * @returns {Promise<boolean>}
+ */
+ async hasPermissions() {
+ return Promise.resolve(true);
+ }
+
+ /**
+ * Subclasses should implement this if special permissions need to be
+ * requested from the user or the operating system in order to perform
+ * a migration with this MigratorBase. This will be called only if
+ * hasPermissions resolves to false.
+ *
+ * The returned Promise will resolve to true if permissions were successfully
+ * obtained, and false otherwise. Implementors should ensure that if a call
+ * to getPermissions resolves to true, that the MigratorBase will be able to
+ * get read access to all of the resources it needs to do a migration.
+ *
+ * @param {DOMWindow} win
+ * The top-level DOM window hosting the UI that is requesting the permission.
+ * This can be used to, for example, anchor a file picker window to the
+ * same window that is hosting the migration UI.
+ * @returns {Promise<boolean>}
+ */
+ // eslint-disable-next-line no-unused-vars
+ async getPermissions(win) {
+ return Promise.resolve(true);
+ }
+
+ /**
+ * @returns {Promise<boolean|string>}
+ */
+ async canGetPermissions() {
+ return Promise.resolve(false);
+ }
+
+ /**
+ * This method returns a number that is the bitwise OR of all resource
+ * types that are available in aProfile. See MigrationUtils.resourceTypes
+ * for each resource type.
+ *
+ * @param {object|string} aProfile
+ * The profile from which data may be imported, or an empty string
+ * in the case of a single-profile migrator.
+ * @returns {number}
+ */
+ async getMigrateData(aProfile) {
+ let resources = await this.#getMaybeCachedResources(aProfile);
+ if (!resources) {
+ return 0;
+ }
+ let types = resources.map(r => r.type);
+ return types.reduce((a, b) => {
+ a |= b;
+ return a;
+ }, 0);
+ }
+
+ /**
+ * @see MigrationUtils
+ *
+ * @param {number} aItems
+ * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate
+ * what types of resources should be migrated.
+ * @param {boolean} aStartup
+ * True if this migration is occurring during startup.
+ * @param {object|string} aProfile
+ * The other browser profile that is being migrated from.
+ * @param {Function|null} aProgressCallback
+ * An optional callback that will be fired once a resourceType has finished
+ * migrating. The callback will be passed the numeric representation of the
+ * resource type followed by a boolean indicating whether or not the resource
+ * was migrated successfully and optionally an object containing additional
+ * details.
+ */
+ async migrate(aItems, aStartup, aProfile, aProgressCallback = () => {}) {
+ let resources = await this.#getMaybeCachedResources(aProfile);
+ if (!resources.length) {
+ throw new Error("migrate called for a non-existent source");
+ }
+
+ if (aItems != lazy.MigrationUtils.resourceTypes.ALL) {
+ resources = resources.filter(r => aItems & r.type);
+ }
+
+ // Used to periodically give back control to the main-thread loop.
+ let unblockMainThread = function () {
+ return new Promise(resolve => {
+ Services.tm.dispatchToMainThread(resolve);
+ });
+ };
+
+ let getHistogramIdForResourceType = (resourceType, template) => {
+ if (resourceType == lazy.MigrationUtils.resourceTypes.HISTORY) {
+ return template.replace("*", "HISTORY");
+ }
+ if (resourceType == lazy.MigrationUtils.resourceTypes.BOOKMARKS) {
+ return template.replace("*", "BOOKMARKS");
+ }
+ if (resourceType == lazy.MigrationUtils.resourceTypes.PASSWORDS) {
+ return template.replace("*", "LOGINS");
+ }
+ return null;
+ };
+
+ let browserKey = this.constructor.key;
+
+ let maybeStartTelemetryStopwatch = resourceType => {
+ let histogramId = getHistogramIdForResourceType(
+ resourceType,
+ "FX_MIGRATION_*_IMPORT_MS"
+ );
+ if (histogramId) {
+ TelemetryStopwatch.startKeyed(histogramId, browserKey);
+ }
+ return histogramId;
+ };
+
+ let maybeStartResponsivenessMonitor = resourceType => {
+ let responsivenessMonitor;
+ let responsivenessHistogramId = getHistogramIdForResourceType(
+ resourceType,
+ "FX_MIGRATION_*_JANK_MS"
+ );
+ if (responsivenessHistogramId) {
+ responsivenessMonitor = new lazy.ResponsivenessMonitor();
+ }
+ return { responsivenessMonitor, responsivenessHistogramId };
+ };
+
+ let maybeFinishResponsivenessMonitor = (
+ responsivenessMonitor,
+ histogramId
+ ) => {
+ if (responsivenessMonitor) {
+ let accumulatedDelay = responsivenessMonitor.finish();
+ if (histogramId) {
+ try {
+ Services.telemetry
+ .getKeyedHistogramById(histogramId)
+ .add(browserKey, accumulatedDelay);
+ } catch (ex) {
+ console.error(histogramId, ": ", ex);
+ }
+ }
+ }
+ };
+
+ let collectQuantityTelemetry = () => {
+ for (let resourceType of Object.keys(
+ lazy.MigrationUtils._importQuantities
+ )) {
+ let histogramId =
+ "FX_MIGRATION_" + resourceType.toUpperCase() + "_QUANTITY";
+ try {
+ Services.telemetry
+ .getKeyedHistogramById(histogramId)
+ .add(
+ browserKey,
+ lazy.MigrationUtils._importQuantities[resourceType]
+ );
+ } catch (ex) {
+ console.error(histogramId, ": ", ex);
+ }
+ }
+ };
+
+ let collectMigrationTelemetry = resourceType => {
+ // We don't want to collect this if the migration is occurring due to a
+ // profile refresh.
+ if (this.constructor.key == lazy.FirefoxProfileMigrator.key) {
+ return;
+ }
+
+ let prefKey = null;
+ switch (resourceType) {
+ case lazy.MigrationUtils.resourceTypes.BOOKMARKS: {
+ prefKey = "browser.migrate.interactions.bookmarks";
+ break;
+ }
+ case lazy.MigrationUtils.resourceTypes.HISTORY: {
+ prefKey = "browser.migrate.interactions.history";
+ break;
+ }
+ case lazy.MigrationUtils.resourceTypes.PASSWORDS: {
+ prefKey = "browser.migrate.interactions.passwords";
+ break;
+ }
+ default: {
+ return;
+ }
+ }
+
+ if (prefKey) {
+ Services.prefs.setBoolPref(prefKey, true);
+ }
+ };
+
+ // Called either directly or through the bookmarks import callback.
+ let doMigrate = async function () {
+ let resourcesGroupedByItems = new Map();
+ resources.forEach(function (resource) {
+ if (!resourcesGroupedByItems.has(resource.type)) {
+ resourcesGroupedByItems.set(resource.type, new Set());
+ }
+ resourcesGroupedByItems.get(resource.type).add(resource);
+ });
+
+ if (resourcesGroupedByItems.size == 0) {
+ throw new Error("No items to import");
+ }
+
+ let notify = function (aMsg, aItemType) {
+ Services.obs.notifyObservers(null, aMsg, aItemType);
+ };
+
+ for (let resourceType of Object.keys(
+ lazy.MigrationUtils._importQuantities
+ )) {
+ lazy.MigrationUtils._importQuantities[resourceType] = 0;
+ }
+ notify("Migration:Started");
+ for (let [migrationType, itemResources] of resourcesGroupedByItems) {
+ notify("Migration:ItemBeforeMigrate", migrationType);
+
+ let stopwatchHistogramId = maybeStartTelemetryStopwatch(migrationType);
+
+ let { responsivenessMonitor, responsivenessHistogramId } =
+ maybeStartResponsivenessMonitor(migrationType);
+
+ let itemSuccess = false;
+ for (let res of itemResources) {
+ let completeDeferred = Promise.withResolvers();
+ let resourceDone = function (aSuccess, details) {
+ itemResources.delete(res);
+ itemSuccess |= aSuccess;
+ if (itemResources.size == 0) {
+ notify(
+ itemSuccess
+ ? "Migration:ItemAfterMigrate"
+ : "Migration:ItemError",
+ migrationType
+ );
+ collectMigrationTelemetry(migrationType);
+
+ aProgressCallback(migrationType, itemSuccess, details);
+
+ resourcesGroupedByItems.delete(migrationType);
+
+ if (stopwatchHistogramId) {
+ TelemetryStopwatch.finishKeyed(
+ stopwatchHistogramId,
+ browserKey
+ );
+ }
+
+ maybeFinishResponsivenessMonitor(
+ responsivenessMonitor,
+ responsivenessHistogramId
+ );
+
+ if (resourcesGroupedByItems.size == 0) {
+ collectQuantityTelemetry();
+
+ notify("Migration:Ended");
+ }
+ }
+ completeDeferred.resolve();
+ };
+
+ // If migrate throws, an error occurred, and the callback
+ // (itemMayBeDone) might haven't been called.
+ try {
+ res.migrate(resourceDone);
+ } catch (ex) {
+ console.error(ex);
+ resourceDone(false);
+ }
+
+ await completeDeferred.promise;
+ await unblockMainThread();
+ }
+ }
+ };
+
+ if (
+ lazy.MigrationUtils.isStartupMigration &&
+ !this.startupOnlyMigrator &&
+ Services.policies.isAllowed("defaultBookmarks")
+ ) {
+ lazy.MigrationUtils.profileStartup.doStartup();
+ // First import the default bookmarks.
+ // Note: We do not need to do so for the Firefox migrator
+ // (=startupOnlyMigrator), as it just copies over the places database
+ // from another profile.
+ await (async function () {
+ // Tell nsBrowserGlue we're importing default bookmarks.
+ let browserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService(
+ Ci.nsIObserver
+ );
+ browserGlue.observe(null, TOPIC_WILL_IMPORT_BOOKMARKS, "");
+
+ // Import the default bookmarks. We ignore whether or not we succeed.
+ await lazy.BookmarkHTMLUtils.importFromURL(
+ "chrome://browser/content/default-bookmarks.html",
+ {
+ replace: true,
+ source: lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
+ }
+ ).catch(console.error);
+
+ // We'll tell nsBrowserGlue we've imported bookmarks, but before that
+ // we need to make sure we're going to know when it's finished
+ // initializing places:
+ let placesInitedPromise = new Promise(resolve => {
+ let onPlacesInited = function () {
+ Services.obs.removeObserver(
+ onPlacesInited,
+ TOPIC_PLACES_DEFAULTS_FINISHED
+ );
+ resolve();
+ };
+ Services.obs.addObserver(
+ onPlacesInited,
+ TOPIC_PLACES_DEFAULTS_FINISHED
+ );
+ });
+ browserGlue.observe(null, TOPIC_DID_IMPORT_BOOKMARKS, "");
+ await placesInitedPromise;
+ await doMigrate();
+ })();
+ return;
+ }
+ await doMigrate();
+ }
+
+ /**
+ * Checks to see if one or more profiles exist for the browser that this
+ * migrator migrates from.
+ *
+ * @returns {Promise<boolean>}
+ * True if one or more profiles exists that this migrator can migrate
+ * resources from.
+ */
+ async isSourceAvailable() {
+ if (this.startupOnlyMigrator && !lazy.MigrationUtils.isStartupMigration) {
+ return false;
+ }
+
+ // For a single-profile source, check if any data is available.
+ // For multiple-profiles source, make sure that at least one
+ // profile is available.
+ let exists = false;
+ try {
+ let profiles = await this.getSourceProfiles();
+ if (!profiles) {
+ let resources = await this.#getMaybeCachedResources("");
+ if (resources && resources.length) {
+ exists = true;
+ }
+ } else {
+ exists = !!profiles.length;
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ return exists;
+ }
+
+ /*** PRIVATE STUFF - DO NOT OVERRIDE ***/
+
+ /**
+ * Returns resources for a particular profile and then caches them for later
+ * lookups.
+ *
+ * @param {object|string} aProfile
+ * The profile that resources are being imported from.
+ * @returns {Promise<MigrationResource[]>}
+ */
+ async #getMaybeCachedResources(aProfile) {
+ let profileKey = aProfile ? aProfile.id : "";
+ if (this._resourcesByProfile) {
+ if (profileKey in this._resourcesByProfile) {
+ return this._resourcesByProfile[profileKey];
+ }
+ } else {
+ this._resourcesByProfile = {};
+ }
+ this._resourcesByProfile[profileKey] = await this.getResources(aProfile);
+ return this._resourcesByProfile[profileKey];
+ }
+}