gecko-dev/browser/components/migration/AutoMigrate.jsm

702 строки
25 KiB
JavaScript

/* 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";
var EXPORTED_SYMBOLS = ["AutoMigrate"];
const kAutoMigrateEnabledPref = "browser.migrate.automigrate.enabled";
const kUndoUIEnabledPref = "browser.migrate.automigrate.ui.enabled";
const kInPageUIEnabledPref = "browser.migrate.automigrate.inpage.ui.enabled";
const kAutoMigrateBrowserPref = "browser.migrate.automigrate.browser";
const kAutoMigrateImportedItemIds = "browser.migrate.automigrate.imported-items";
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";
ChromeUtils.import("resource:///modules/MigrationUtils.jsm");
ChromeUtils.import("resource://gre/modules/Preferences.jsm");
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.defineModuleGetter(this, "AsyncShutdown",
"resource://gre/modules/AsyncShutdown.jsm");
ChromeUtils.defineModuleGetter(this, "LoginHelper",
"resource://gre/modules/LoginHelper.jsm");
ChromeUtils.defineModuleGetter(this, "NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm");
ChromeUtils.defineModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
ChromeUtils.defineModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
ChromeUtils.defineModuleGetter(this, "TelemetryStopwatch",
"resource://gre/modules/TelemetryStopwatch.jsm");
XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() {
const kBrandBundle = "chrome://branding/locale/brand.properties";
return Services.strings.createBundle(kBrandBundle);
});
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
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.
*/
async migrate(profileStartup, migratorKey, profileToMigrate) {
let histogram = Services.telemetry.getHistogramById(
"FX_STARTUP_MIGRATION_AUTOMATED_IMPORT_PROCESS_SUCCESS");
histogram.add(0);
let {migrator, pickedKey} = await this.pickMigrator(migratorKey);
histogram.add(5);
profileToMigrate = await this.pickProfile(migrator, profileToMigrate);
histogram.add(10);
let resourceTypes = await 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");
Services.obs.addObserver(migrationObserver, "Migration:ItemError");
await 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.
*/
async 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 = await 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.
*/
async pickProfile(migrator, suggestedId) {
let profiles = await migrator.getSourceProfiles();
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,
async canUndo() {
if (this._savingPromise) {
await this._savingPromise;
}
if (this._pendingUndoTasks) {
return false;
}
let fileExists = false;
try {
fileExists = await OS.File.exists(kUndoStateFullPath);
} catch (ex) {
Cu.reportError(ex);
}
return fileExists;
},
async undo() {
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 (!(await 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(await 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");
await 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");
await 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");
await 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() {
for (let win of Services.wm.getEnumerator("navigator:browser")) {
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;
},
/**
* Decide if we need to show [the user] a prompt indicating we automatically
* imported their data.
* @param target (xul:browser)
* The browser in which we should show the notification.
* @returns {Boolean} return true when need to show the prompt.
*/
async shouldShowMigratePrompt(target) {
if (!(await this.canUndo())) {
return false;
}
// 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 false;
}
// 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.isMigratePromptExpired()) {
this._purgeUndoState(this.UNDO_REMOVED_REASON_OFFER_EXPIRED);
this._removeNotificationBars();
return false;
}
let remainingDays = Preferences.get(kAutoMigrateDaysToOfferUndoPref, 0);
Services.telemetry.getHistogramById("FX_STARTUP_MIGRATION_UNDO_OFFERED").add(4 - remainingDays);
return true;
},
/**
* Return the message that denotes the user data is migrated from the other browser.
* @returns {String} imported message with the brand and the browser name
*/
getUndoMigrationMessage() {
let browserName = this.getBrowserUsedForMigration();
if (!browserName) {
browserName = MigrationUtils.getLocalizedString("automigration.undo.unknownbrowser");
}
const kMessageId = "automigration.undo.message2." +
Preferences.get(kAutoMigrateImportedItemIds, "all");
const kBrandShortName = gBrandBundle.GetStringFromName("brandShortName");
return MigrationUtils.getLocalizedString(kMessageId,
[kBrandShortName, browserName]);
},
/**
* Show the user a notification bar indicating we automatically imported
* their data and offering them the possibility of removing it.
* @param target (xul:browser)
* The browser in which we should show the notification.
*/
showUndoNotificationBar(target) {
let isInPage = Preferences.get(kInPageUIEnabledPref, false);
let win = target.ownerGlobal;
let notificationBox = win.gBrowser.getNotificationBox(target);
if (isInPage || !notificationBox || notificationBox.getNotificationWithValue(kNotificationId)) {
return;
}
let message = this.getUndoMigrationMessage();
let buttons = [
{
label: MigrationUtils.getLocalizedString("automigration.undo.keep2.label"),
accessKey: MigrationUtils.getLocalizedString("automigration.undo.keep2.accesskey"),
callback: () => {
this.keepAutoMigration();
this._removeNotificationBars();
},
},
{
label: MigrationUtils.getLocalizedString("automigration.undo.dontkeep2.label"),
accessKey: MigrationUtils.getLocalizedString("automigration.undo.dontkeep2.accesskey"),
callback: () => {
this.undoAutoMigration(win);
},
},
];
notificationBox.appendNotification(
message, kNotificationId, null, notificationBox.PRIORITY_INFO_HIGH, buttons
);
},
/**
* Return true if we have shown the prompt to user several days.
* (defined in kAutoMigrateDaysToOfferUndoPref)
*/
isMigratePromptExpired() {
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 true;
}
}
return false;
},
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],
]);
},
/**
* Store the items we've saved into a pref. We use this to be able to show
* a detailed message to the user indicating what we've imported.
* @param state (Map)
* The 'undo' state for the import, which contains info about
* how many items of each kind we've (tried to) import.
*/
_setImportedItemPrefFromState(state) {
let itemsWithData = [];
if (state) {
for (let itemType of state.keys()) {
if (state.get(itemType).length) {
itemsWithData.push(itemType);
}
}
}
if (itemsWithData.length == 3) {
itemsWithData = "all";
} else {
itemsWithData = itemsWithData.sort().join(".");
}
if (itemsWithData) {
Preferences.set(kAutoMigrateImportedItemIds, itemsWithData);
}
},
/**
* Used for the shutdown blocker's information field.
*/
_saveUndoStateTrackerForShutdown: "not running",
/**
* Store the information required for using 'undo' of the automatic
* migration in the user's profile.
*/
async saveUndoState() {
let resolveSavingPromise;
this._saveUndoStateTrackerForShutdown = "processing undo history";
this._savingPromise = new Promise(resolve => { resolveSavingPromise = resolve; });
let state = await 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 = "saving imported item list";
this._setImportedItemPrefFromState(state);
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;
},
async _removeUnchangedBookmarks(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).
await 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 {
await PlacesUtils.bookmarks.remove(guid, {preventRemovalOfNonEmptyFolders: true});
} catch (err) {
if (err && err.message != "Cannot remove a non-empty folder.") {
this._errorMap.bookmarks++;
Cu.reportError(err);
}
}
}
},
async _removeUnchangedLogins(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++;
}
}
}
}
},
async _removeSomeVisits(visits) {
// It is necessary to recreate URL and date objects because the data
// can be serialized through JSON that destroys those objects.
for (let urlVisits of visits) {
let urlObj;
try {
urlObj = new URL(urlVisits.url);
} catch (ex) {
continue;
}
let visitData = {
url: urlObj,
beginDate: new Date(urlVisits.first),
endDate: new Date(urlVisits.last),
limit: urlVisits.visitCount,
};
try {
await 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));
canDoSurveyInLocale =
surveyLocales.has(Services.locale.getAppLocaleAsLangTag());
} 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.openTrustedLinkIn(url, "tab");
},
QueryInterface: ChromeUtils.generateQI(
[Ci.nsIObserver, Ci.nsINavBookmarkObserver, Ci.nsISupportsWeakReference]
),
/**
* Undo action called by the UndoNotification or by the newtab
* @param chromeWindow A reference to the window in which to open a link.
*/
undoAutoMigration(chromeWindow) {
this._maybeOpenUndoSurveyTab(chromeWindow);
this.undo();
},
/**
* Keep the automigration result and not prompt anymore
*/
keepAutoMigration() {
this._purgeUndoState(this.UNDO_REMOVED_REASON_OFFER_REJECTED);
},
};
AutoMigrate.init();