browser/components/migration/AutoMigrate.jsm
author Gijs Kruitbosch <gijskruitbosch@gmail.com>
Fri, 03 Feb 2017 14:04:23 +0000
changeset 482223 f4ac27b2d9e26b8d9330e7b4839ccd05286c1098
parent 480074 9b7386fe607a3a05ad2c0cb8583a4e4b330c4e76
child 482224 6e3818fe8f0e739ba01852f4e152950ed880aea3
child 492373 67d78fdd865dedbc6049cb8da3ab2e4841ea88ca
permissions -rw-r--r--
Bug 1335442 - deal correctly with not importing anything, r=jaws MozReview-Commit-ID: 3WZCxXV48Ms

/* 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.EXPORTED_SYMBOLS = ["AutoMigrate"];

const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;

const kAutoMigrateEnabledPref = "browser.migrate.automigrate.enabled";
const kUndoUIEnabledPref = "browser.migrate.automigrate.ui.enabled";

const kAutoMigrateBrowserPref = "browser.migrate.automigrate.browser";

const kAutoMigrateLastUndoPromptDateMsPref = "browser.migrate.automigrate.lastUndoPromptDateMs";
const kAutoMigrateDaysToOfferUndoPref = "browser.migrate.automigrate.daysToOfferUndo";

const kAutoMigrateUndoSurveyPref = "browser.migrate.automigrate.undo-survey";
const kAutoMigrateUndoSurveyLocalePref = "browser.migrate.automigrate.undo-survey-locales";

const kNotificationId = "automigration-undo";

Cu.import("resource:///modules/MigrationUtils.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
                                  "resource://gre/modules/AsyncShutdown.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
                                  "resource://gre/modules/LoginHelper.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
                                  "resource://gre/modules/NewTabUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                  "resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                  "resource://gre/modules/PlacesUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch",
                                  "resource://gre/modules/TelemetryStopwatch.jsm");

Cu.importGlobalProperties(["URL"]);

/* globals kUndoStateFullPath */
XPCOMUtils.defineLazyGetter(this, "kUndoStateFullPath", function() {
  return OS.Path.join(OS.Constants.Path.profileDir, "initialMigrationMetadata.jsonlz4");
});

const AutoMigrate = {
  get resourceTypesToUse() {
    let {BOOKMARKS, HISTORY, PASSWORDS} = Ci.nsIBrowserProfileMigrator;
    return BOOKMARKS | HISTORY | PASSWORDS;
  },

  _checkIfEnabled() {
    let pref = Preferences.get(kAutoMigrateEnabledPref, false);
    // User-set values should take precedence:
    if (Services.prefs.prefHasUserValue(kAutoMigrateEnabledPref)) {
      return pref;
    }
    // If we're using the default value, make sure the distribution.ini
    // value is taken into account even early on startup.
    try {
      let distributionFile = Services.dirsvc.get("XREAppDist", Ci.nsIFile);
      distributionFile.append("distribution.ini");
      let parser = Cc["@mozilla.org/xpcom/ini-parser-factory;1"].
                 getService(Ci.nsIINIParserFactory).
                 createINIParser(distributionFile);
      return JSON.parse(parser.getString("Preferences", kAutoMigrateEnabledPref));
    } catch (ex) { /* ignore exceptions (file doesn't exist, invalid value, etc.) */ }

    return pref;
  },

  init() {
    this.enabled = this._checkIfEnabled();
  },

  /**
   * Automatically pick a migrator and resources to migrate,
   * then migrate those and start up.
   *
   * @throws if automatically deciding on migrators/data
   *         failed for some reason.
   */
  migrate(profileStartup, migratorKey, profileToMigrate) {
    let histogram = Services.telemetry.getHistogramById(
      "FX_STARTUP_MIGRATION_AUTOMATED_IMPORT_PROCESS_SUCCESS");
    histogram.add(0);
    let {migrator, pickedKey} = this.pickMigrator(migratorKey);
    histogram.add(5);

    profileToMigrate = this.pickProfile(migrator, profileToMigrate);
    histogram.add(10);

    let resourceTypes = migrator.getMigrateData(profileToMigrate, profileStartup);
    if (!(resourceTypes & this.resourceTypesToUse)) {
      throw new Error("No usable resources were found for the selected browser!");
    }
    histogram.add(15);

    let sawErrors = false;
    let migrationObserver = (subject, topic) => {
      if (topic == "Migration:ItemError") {
        sawErrors = true;
      } else if (topic == "Migration:Ended") {
        histogram.add(25);
        if (sawErrors) {
          histogram.add(26);
        }
        Services.obs.removeObserver(migrationObserver, "Migration:Ended");
        Services.obs.removeObserver(migrationObserver, "Migration:ItemError");
        Services.prefs.setCharPref(kAutoMigrateBrowserPref, pickedKey);
        // Save the undo history and block shutdown on that save completing.
        AsyncShutdown.profileBeforeChange.addBlocker(
          "AutoMigrate Undo saving", this.saveUndoState(), () => {
            return {state: this._saveUndoStateTrackerForShutdown};
          });
      }
    };

    MigrationUtils.initializeUndoData();
    Services.obs.addObserver(migrationObserver, "Migration:Ended", false);
    Services.obs.addObserver(migrationObserver, "Migration:ItemError", false);
    migrator.migrate(this.resourceTypesToUse, profileStartup, profileToMigrate);
    histogram.add(20);
  },

  /**
   * Pick and return a migrator to use for automatically migrating.
   *
   * @param {String} migratorKey   optional, a migrator key to prefer/pick.
   * @returns {Object}             an object with the migrator to use for migrating, as
   *                               well as the key we eventually ended up using to obtain it.
   */
  pickMigrator(migratorKey) {
    if (!migratorKey) {
      let defaultKey = MigrationUtils.getMigratorKeyForDefaultBrowser();
      if (!defaultKey) {
        throw new Error("Could not determine default browser key to migrate from");
      }
      migratorKey = defaultKey;
    }
    if (migratorKey == "firefox") {
      throw new Error("Can't automatically migrate from Firefox.");
    }

    let migrator = MigrationUtils.getMigrator(migratorKey);
    if (!migrator) {
      throw new Error("Migrator specified or a default was found, but the migrator object is not available (or has no data).");
    }
    return {migrator, pickedKey: migratorKey};
  },

  /**
   * Pick a source profile (from the original browser) to use.
   *
   * @param {Migrator} migrator     the migrator object to use
   * @param {String}   suggestedId  the id of the profile to migrate, if pre-specified, or null
   * @returns                       the profile to migrate, or null if migrating
   *                                from the default profile.
   */
  pickProfile(migrator, suggestedId) {
    let profiles = migrator.sourceProfiles;
    if (profiles && !profiles.length) {
      throw new Error("No profile data found to migrate.");
    }
    if (suggestedId) {
      if (!profiles) {
        throw new Error("Profile specified but only a default profile found.");
      }
      let suggestedProfile = profiles.find(profile => profile.id == suggestedId);
      if (!suggestedProfile) {
        throw new Error("Profile specified was not found.");
      }
      return suggestedProfile;
    }
    if (profiles && profiles.length > 1) {
      throw new Error("Don't know how to pick a profile when more than 1 profile is present.");
    }
    return profiles ? profiles[0] : null;
  },

  _pendingUndoTasks: false,
  canUndo: Task.async(function* () {
    if (this._savingPromise) {
      yield this._savingPromise;
    }
    if (this._pendingUndoTasks) {
      return false;
    }
    let fileExists = false;
    try {
      fileExists = yield OS.File.exists(kUndoStateFullPath);
    } catch (ex) {
      Cu.reportError(ex);
    }
    return fileExists;
  }),

  undo: Task.async(function* () {
    let browserId = Preferences.get(kAutoMigrateBrowserPref, "unknown");
    TelemetryStopwatch.startKeyed("FX_STARTUP_MIGRATION_UNDO_TOTAL_MS", browserId);
    let histogram = Services.telemetry.getHistogramById("FX_STARTUP_MIGRATION_AUTOMATED_IMPORT_UNDO");
    histogram.add(0);
    if (!(yield this.canUndo())) {
      histogram.add(5);
      throw new Error("Can't undo!");
    }

    this._pendingUndoTasks = true;
    this._removeNotificationBars();
    histogram.add(10);

    let readPromise = OS.File.read(kUndoStateFullPath, {
      encoding: "utf-8",
      compression: "lz4",
    });
    let stateData = this._dejsonifyUndoState(yield readPromise);
    histogram.add(12);

    this._errorMap = {bookmarks: 0, visits: 0, logins: 0};
    let reportErrorTelemetry = (type) => {
      let histogramId = `FX_STARTUP_MIGRATION_UNDO_${type.toUpperCase()}_ERRORCOUNT`;
      Services.telemetry.getKeyedHistogramById(histogramId).add(browserId, this._errorMap[type]);
    };

    let startTelemetryStopwatch = resourceType => {
      let histogramId = `FX_STARTUP_MIGRATION_UNDO_${resourceType.toUpperCase()}_MS`;
      TelemetryStopwatch.startKeyed(histogramId, browserId);
    };
    let stopTelemetryStopwatch = resourceType => {
      let histogramId = `FX_STARTUP_MIGRATION_UNDO_${resourceType.toUpperCase()}_MS`;
      TelemetryStopwatch.finishKeyed(histogramId, browserId);
    };
    startTelemetryStopwatch("bookmarks");
    yield this._removeUnchangedBookmarks(stateData.get("bookmarks")).catch(ex => {
      Cu.reportError("Uncaught exception when removing unchanged bookmarks!");
      Cu.reportError(ex);
    });
    stopTelemetryStopwatch("bookmarks");
    reportErrorTelemetry("bookmarks");
    histogram.add(15);

    startTelemetryStopwatch("visits");
    yield this._removeSomeVisits(stateData.get("visits")).catch(ex => {
      Cu.reportError("Uncaught exception when removing history visits!");
      Cu.reportError(ex);
    });
    stopTelemetryStopwatch("visits");
    reportErrorTelemetry("visits");
    histogram.add(20);

    startTelemetryStopwatch("logins");
    yield this._removeUnchangedLogins(stateData.get("logins")).catch(ex => {
      Cu.reportError("Uncaught exception when removing unchanged logins!");
      Cu.reportError(ex);
    });
    stopTelemetryStopwatch("logins");
    reportErrorTelemetry("logins");
    histogram.add(25);

    // This is async, but no need to wait for it.
    NewTabUtils.links.populateCache(() => {
      NewTabUtils.allPages.update();
    }, true);

    this._purgeUndoState(this.UNDO_REMOVED_REASON_UNDO_USED);
    histogram.add(30);
    TelemetryStopwatch.finishKeyed("FX_STARTUP_MIGRATION_UNDO_TOTAL_MS", browserId);
  }),

  _removeNotificationBars() {
    let browserWindows = Services.wm.getEnumerator("navigator:browser");
    while (browserWindows.hasMoreElements()) {
      let win = browserWindows.getNext();
      if (!win.closed) {
        for (let browser of win.gBrowser.browsers) {
          let nb = win.gBrowser.getNotificationBox(browser);
          let notification = nb.getNotificationWithValue(kNotificationId);
          if (notification) {
            nb.removeNotification(notification);
          }
        }
      }
    }
  },

  _purgeUndoState(reason) {
    // We don't wait for the off-main-thread removal to complete. OS.File will
    // ensure it happens before shutdown.
    OS.File.remove(kUndoStateFullPath, {ignoreAbsent: true}).then(() => {
      this._pendingUndoTasks = false;
    });

    let migrationBrowser = Preferences.get(kAutoMigrateBrowserPref, "unknown");
    Services.prefs.clearUserPref(kAutoMigrateBrowserPref);

    let histogram =
      Services.telemetry.getKeyedHistogramById("FX_STARTUP_MIGRATION_UNDO_REASON");
    histogram.add(migrationBrowser, reason);
  },

  getBrowserUsedForMigration() {
    let browserId = Services.prefs.getCharPref(kAutoMigrateBrowserPref);
    if (browserId) {
      return MigrationUtils.getBrowserName(browserId);
    }
    return null;
  },

  maybeShowUndoNotification: Task.async(function* (target) {
    if (!(yield this.canUndo())) {
      return;
    }

    // The tab might have navigated since we requested the undo state:
    let canUndoFromThisPage = ["about:home", "about:newtab"].includes(target.currentURI.spec);
    if (!canUndoFromThisPage ||
        !Preferences.get(kUndoUIEnabledPref, false)) {
      return;
    }

    let win = target.ownerGlobal;
    let notificationBox = win.gBrowser.getNotificationBox(target);
    if (!notificationBox || notificationBox.getNotificationWithValue(kNotificationId)) {
      return;
    }

    // At this stage we're committed to show the prompt - unless we shouldn't,
    // in which case we remove the undo prefs (which will cause canUndo() to
    // return false from now on.):
    if (!this.shouldStillShowUndoPrompt()) {
      this._purgeUndoState(this.UNDO_REMOVED_REASON_OFFER_EXPIRED);
      this._removeNotificationBars();
      return;
    }

    let browserName = this.getBrowserUsedForMigration();
    let message;
    if (browserName) {
      message = MigrationUtils.getLocalizedString("automigration.undo.message",
                                                  [browserName]);
    } else {
      message = MigrationUtils.getLocalizedString("automigration.undo.unknownBrowserMessage");
    }

    let buttons = [
      {
        label: MigrationUtils.getLocalizedString("automigration.undo.keep.label"),
        accessKey: MigrationUtils.getLocalizedString("automigration.undo.keep.accesskey"),
        callback: () => {
          this._purgeUndoState(this.UNDO_REMOVED_REASON_OFFER_REJECTED);
          this._removeNotificationBars();
        },
      },
      {
        label: MigrationUtils.getLocalizedString("automigration.undo.dontkeep.label"),
        accessKey: MigrationUtils.getLocalizedString("automigration.undo.dontkeep.accesskey"),
        callback: () => {
          this._maybeOpenUndoSurveyTab(win);
          this.undo();
        },
      },
    ];
    notificationBox.appendNotification(
      message, kNotificationId, null, notificationBox.PRIORITY_INFO_HIGH, buttons
    );
    let remainingDays = Preferences.get(kAutoMigrateDaysToOfferUndoPref, 0);
    Services.telemetry.getHistogramById("FX_STARTUP_MIGRATION_UNDO_OFFERED").add(4 - remainingDays);
  }),

  shouldStillShowUndoPrompt() {
    let today = new Date();
    // Round down to midnight:
    today = new Date(today.getFullYear(), today.getMonth(), today.getDate());
    // We store the unix timestamp corresponding to midnight on the last day
    // on which we prompted. Fetch that and compare it to today's date.
    // (NB: stored as a string because int prefs are too small for unix
    // timestamps.)
    let previousPromptDateMsStr = Preferences.get(kAutoMigrateLastUndoPromptDateMsPref, "0");
    let previousPromptDate = new Date(parseInt(previousPromptDateMsStr, 10));
    if (previousPromptDate < today) {
      let remainingDays = Preferences.get(kAutoMigrateDaysToOfferUndoPref, 4) - 1;
      Preferences.set(kAutoMigrateDaysToOfferUndoPref, remainingDays);
      Preferences.set(kAutoMigrateLastUndoPromptDateMsPref, today.valueOf().toString());
      if (remainingDays <= 0) {
        return false;
      }
    }
    return true;
  },

  UNDO_REMOVED_REASON_UNDO_USED: 0,
  UNDO_REMOVED_REASON_SYNC_SIGNIN: 1,
  UNDO_REMOVED_REASON_PASSWORD_CHANGE: 2,
  UNDO_REMOVED_REASON_BOOKMARK_CHANGE: 3,
  UNDO_REMOVED_REASON_OFFER_EXPIRED: 4,
  UNDO_REMOVED_REASON_OFFER_REJECTED: 5,

  _jsonifyUndoState(state) {
    if (!state) {
      return "null";
    }
    // Deal with date serialization.
    let bookmarks = state.get("bookmarks");
    for (let bm of bookmarks) {
      bm.lastModified = bm.lastModified.getTime();
    }
    let serializableState = {
      bookmarks,
      logins: state.get("logins"),
      visits: state.get("visits"),
    };
    return JSON.stringify(serializableState);
  },

  _dejsonifyUndoState(state) {
    state = JSON.parse(state);
    if (!state) {
      return new Map();
    }
    for (let bm of state.bookmarks) {
      bm.lastModified = new Date(bm.lastModified);
    }
    return new Map([
      ["bookmarks", state.bookmarks],
      ["logins", state.logins],
      ["visits", state.visits],
    ]);
  },

  _saveUndoStateTrackerForShutdown: "not running",
  saveUndoState: Task.async(function* () {
    let resolveSavingPromise;
    this._saveUndoStateTrackerForShutdown = "processing undo history";
    this._savingPromise = new Promise(resolve => { resolveSavingPromise = resolve });
    let state = yield MigrationUtils.stopAndRetrieveUndoData();

    if (!state || ![...state.values()].some(ary => ary.length > 0)) {
      // If we didn't import anything, abort now.
      resolveSavingPromise();
      return Promise.resolve();
    }

    this._saveUndoStateTrackerForShutdown = "writing undo history";
    this._undoSavePromise = OS.File.writeAtomic(
      kUndoStateFullPath, this._jsonifyUndoState(state), {
        encoding: "utf-8",
        compression: "lz4",
        tmpPath: kUndoStateFullPath + ".tmp",
      });
    this._undoSavePromise.then(
      rv => {
        resolveSavingPromise(rv);
        delete this._savingPromise;
      },
      e => {
        Cu.reportError("Could not write undo state for automatic migration.");
        throw e;
      });
    return this._undoSavePromise;
  }),

  _removeUnchangedBookmarks: Task.async(function* (bookmarks) {
    if (!bookmarks.length) {
      return;
    }

    let guidToLMMap = new Map(bookmarks.map(b => [b.guid, b.lastModified]));
    let bookmarksFromDB = [];
    let bmPromises = Array.from(guidToLMMap.keys()).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.
      try {
        return PlacesUtils.bookmarks.fetch(guid).then(bm => bm && bookmarksFromDB.push(bm), () => {});
      } catch (ex) {
        // Ignore immediate exceptions, too.
      }
      return Promise.resolve();
    });
    // We can't use the result of Promise.all because that would include nulls
    // for bookmarks that no longer exist (which we're catching above).
    yield Promise.all(bmPromises);
    let unchangedBookmarks = bookmarksFromDB.filter(bm => {
      return bm.lastModified.getTime() == guidToLMMap.get(bm.guid).getTime();
    });

    // We need to remove items without children first, followed by their
    // parents, etc. In order to do this, find out how many ancestors each item
    // has that also appear in our list of things to remove, and sort the items
    // by those numbers. This ensures that children are always removed before
    // their parents.
    function determineAncestorCount(bm) {
      if (bm._ancestorCount) {
        return bm._ancestorCount;
      }
      let myCount = 0;
      let parentBM = unchangedBookmarks.find(item => item.guid == bm.parentGuid);
      if (parentBM) {
        myCount = determineAncestorCount(parentBM) + 1;
      }
      bm._ancestorCount = myCount;
      return myCount;
    }
    unchangedBookmarks.forEach(determineAncestorCount);
    unchangedBookmarks.sort((a, b) => b._ancestorCount - a._ancestorCount);
    for (let {guid} of unchangedBookmarks) {
      // Can't just use a .catch() because Bookmarks.remove() can throw (rather
      // than returning rejected promises).
      try {
        yield PlacesUtils.bookmarks.remove(guid, {preventRemovalOfNonEmptyFolders: true});
      } catch (err) {
        if (err && err.message != "Cannot remove a non-empty folder.") {
          this._errorMap.bookmarks++;
          Cu.reportError(err);
        }
      }
    }
  }),

  _removeUnchangedLogins: Task.async(function* (logins) {
    for (let login of logins) {
      let foundLogins = LoginHelper.searchLoginsWithObject({guid: login.guid});
      if (foundLogins.length) {
        let foundLogin = foundLogins[0];
        foundLogin.QueryInterface(Ci.nsILoginMetaInfo);
        if (foundLogin.timePasswordChanged == login.timePasswordChanged) {
          try {
            Services.logins.removeLogin(foundLogin);
          } catch (ex) {
            Cu.reportError("Failed to remove a login for " + foundLogins.hostname);
            Cu.reportError(ex);
            this._errorMap.logins++;
          }
        }
      }
    }
  }),

  _removeSomeVisits: Task.async(function* (visits) {
    for (let urlVisits of visits) {
      let urlObj;
      try {
        urlObj = new URL(urlVisits.url);
      } catch (ex) {
        continue;
      }
      let visitData = {
        url: urlObj,
        beginDate: PlacesUtils.toDate(urlVisits.first),
        endDate: PlacesUtils.toDate(urlVisits.last),
        limit: urlVisits.visitCount,
      };
      try {
        yield PlacesUtils.history.removeVisitsByFilter(visitData);
      } catch (ex) {
        this._errorMap.visits++;
        try {
          visitData.url = visitData.url.href;
        } catch (ignoredEx) {}
        Cu.reportError("Failed to remove a visit: " + JSON.stringify(visitData));
        Cu.reportError(ex);
      }
    }
  }),

  /**
   * Maybe open a new tab with a survey. The tab will only be opened if all of
   * the following are true:
   * - the 'browser.migrate.automigrate.undo-survey' pref is not empty.
   *   It should contain the URL of the survey to open.
   * - the 'browser.migrate.automigrate.undo-survey-locales' pref, a
   *   comma-separated list of language codes, contains the language code
   *   that is currently in use for the 'global' chrome pacakge (ie the
   *   locale in which the user is currently using Firefox).
   *   The URL will be passed through nsIURLFormatter to allow including
   *   build ids etc. The special additional formatting variable
   *   "%IMPORTEDBROWSER" is also replaced with the name of the browser
   *   from which we imported data.
   *
   * @param {Window} chromeWindow   A reference to the window in which to open a link.
   */
  _maybeOpenUndoSurveyTab(chromeWindow) {
    let canDoSurveyInLocale = false;
    try {
      let surveyLocales = Preferences.get(kAutoMigrateUndoSurveyLocalePref, "");
      surveyLocales = surveyLocales.split(",").map(str => str.trim());
      // Strip out any empty elements, so an empty pref doesn't
      // lead to a an array with 1 empty string in it.
      surveyLocales = new Set(surveyLocales.filter(str => !!str));
      let chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"]
                             .getService(Ci.nsIXULChromeRegistry);
      canDoSurveyInLocale =
        surveyLocales.has(chromeRegistry.getSelectedLocale("global"));
    } catch (ex) {
      /* ignore exceptions and just don't do the survey. */
    }

    let migrationBrowser = this.getBrowserUsedForMigration();
    let rawURL = Preferences.get(kAutoMigrateUndoSurveyPref, "");
    if (!canDoSurveyInLocale || !migrationBrowser || !rawURL) {
      return;
    }

    let url = Services.urlFormatter.formatURL(rawURL);
    url = url.replace("%IMPORTEDBROWSER%", encodeURIComponent(migrationBrowser));
    chromeWindow.openUILinkIn(url, "tab");
  },

  QueryInterface: XPCOMUtils.generateQI(
    [Ci.nsIObserver, Ci.nsINavBookmarkObserver, Ci.nsISupportsWeakReference]
  ),
};

AutoMigrate.init();