diff options
Diffstat (limited to 'browser/components/migration/MigratorBase.sys.mjs')
-rw-r--r-- | browser/components/migration/MigratorBase.sys.mjs | 599 |
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]; + } +} |