forked from mirrors/gecko-dev
Add importableLogins autocomplete items that show for a site when there's chromium-based logins, no saved logins, and appropriate experiment state. Default behavior is unchanged with default "" pref value, and new behavior can be turned on with "import" pref value. Differential Revision: https://phabricator.services.mozilla.com/D72096
428 lines
14 KiB
JavaScript
428 lines
14 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 = ["ChromeMigrationUtils"];
|
|
|
|
const { XPCOMUtils } = ChromeUtils.import(
|
|
"resource://gre/modules/XPCOMUtils.jsm"
|
|
);
|
|
XPCOMUtils.defineLazyModuleGetters(this, {
|
|
AppConstants: "resource://gre/modules/AppConstants.jsm",
|
|
LoginHelper: "resource://gre/modules/LoginHelper.jsm",
|
|
MigrationUtils: "resource:///modules/MigrationUtils.jsm",
|
|
OS: "resource://gre/modules/osfile.jsm",
|
|
Services: "resource://gre/modules/Services.jsm",
|
|
});
|
|
|
|
const S100NS_FROM1601TO1970 = 0x19db1ded53e8000;
|
|
const S100NS_PER_MS = 10;
|
|
|
|
var ChromeMigrationUtils = {
|
|
_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 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 = this.getExtensionPath(profileId);
|
|
let iterator = new OS.File.DirectoryIterator(path);
|
|
let extensionList = [];
|
|
await iterator
|
|
.forEach(async entry => {
|
|
if (entry.isDir) {
|
|
let extensionInformation = await this.getExtensionInformation(
|
|
entry.name,
|
|
profileId
|
|
);
|
|
if (extensionInformation) {
|
|
extensionList.push(extensionInformation);
|
|
}
|
|
}
|
|
})
|
|
.catch(ex => Cu.reportError(ex));
|
|
return extensionList;
|
|
},
|
|
|
|
/**
|
|
* Get information of a specific Chrome extension.
|
|
* @param {String} extensionId - The extension ID.
|
|
* @param {String} profileId - The user profile's ID.
|
|
* @retruns {Object} The Chrome extension information.
|
|
*/
|
|
async getExtensionInformation(extensionId, profileId) {
|
|
if (profileId === undefined) {
|
|
profileId = await this.getLastUsedProfileId();
|
|
}
|
|
let extensionInformation = null;
|
|
try {
|
|
let manifestPath = this.getExtensionPath(profileId);
|
|
manifestPath = OS.Path.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 = OS.Path.join(
|
|
manifestPath,
|
|
directories[0],
|
|
"manifest.json"
|
|
);
|
|
let manifest = await OS.File.read(manifestPath, { encoding: "utf-8" });
|
|
manifest = JSON.parse(manifest);
|
|
// 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) {
|
|
Cu.reportError(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.
|
|
* @retruns {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 = this.getExtensionPath(profileId);
|
|
localeFilePath = OS.Path.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 = OS.Path.join(
|
|
localeFilePath,
|
|
directories[0],
|
|
"_locales",
|
|
locale,
|
|
"messages.json"
|
|
);
|
|
localeFile = await OS.File.read(localeFilePath, { encoding: "utf-8" });
|
|
localeFile = JSON.parse(localeFile);
|
|
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) {
|
|
Cu.reportError(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 = this.getExtensionPath(profileId);
|
|
let isInstalled = await OS.File.exists(
|
|
OS.Path.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 = OS.Path.join(
|
|
this.getDataPath(dataPath),
|
|
"Local State"
|
|
);
|
|
let localStateJson = await OS.File.read(localStatePath, {
|
|
encoding: "utf-8",
|
|
});
|
|
localState = JSON.parse(localStateJson);
|
|
} catch (ex) {
|
|
Cu.reportError(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.
|
|
*/
|
|
getExtensionPath(profileId) {
|
|
return OS.Path.join(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.
|
|
*/
|
|
getDataPath(chromeProjectName = "Chrome") {
|
|
const SUB_DIRECTORIES = {
|
|
win: {
|
|
Chrome: ["Google", "Chrome"],
|
|
"Chrome Beta": ["Google", "Chrome Beta"],
|
|
Chromium: ["Chromium"],
|
|
Canary: ["Google", "Chrome SxS"],
|
|
Edge: ["Microsoft", "Edge"],
|
|
"Edge Beta": ["Microsoft", "Edge Beta"],
|
|
},
|
|
macosx: {
|
|
Chrome: ["Google", "Chrome"],
|
|
Chromium: ["Chromium"],
|
|
Canary: ["Google", "Chrome Canary"],
|
|
Edge: ["Microsoft Edge"],
|
|
"Edge Beta": ["Microsoft Edge Beta"],
|
|
},
|
|
linux: {
|
|
Chrome: ["google-chrome"],
|
|
"Chrome Beta": ["google-chrome-beta"],
|
|
"Chrome Dev": ["google-chrome-unstable"],
|
|
Chromium: ["chromium"],
|
|
// Canary is not available on Linux.
|
|
// Edge is not available on Linux.
|
|
},
|
|
};
|
|
let dirKey, subfolders;
|
|
subfolders = SUB_DIRECTORIES[AppConstants.platform][chromeProjectName];
|
|
if (!subfolders) {
|
|
return null;
|
|
}
|
|
|
|
if (AppConstants.platform == "win") {
|
|
dirKey = "winLocalAppDataDir";
|
|
subfolders = subfolders.concat(["User Data"]);
|
|
} else if (AppConstants.platform == "macosx") {
|
|
dirKey = "macUserLibDir";
|
|
subfolders = ["Application Support"].concat(subfolders);
|
|
} else {
|
|
dirKey = "homeDir";
|
|
subfolders = [".config"].concat(subfolders);
|
|
}
|
|
subfolders.unshift(OS.Constants.Path[dirKey]);
|
|
return OS.Path.join(...subfolders);
|
|
},
|
|
|
|
/**
|
|
* 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 iterator = new OS.File.DirectoryIterator(path);
|
|
let entries = [];
|
|
await iterator
|
|
.forEach(async entry => {
|
|
if (entry.isDir) {
|
|
entries.push(entry.name);
|
|
}
|
|
})
|
|
.catch(ex => {
|
|
Cu.reportError(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
|
|
*
|
|
* @param aTime
|
|
* Chrome time
|
|
* @param aFallbackValue
|
|
* a date or timestamp (valid argument for the Date constructor)
|
|
* that will be used if the chrometime value passed is invalid.
|
|
* @return converted Date object
|
|
* @note Google Chrome uses FILETIME / 10 as time.
|
|
* FILETIME is based on same structure of Windows.
|
|
*/
|
|
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
|
|
*
|
|
* @param aDate
|
|
* Date object or integer equivalent
|
|
* @return Chrome time
|
|
* @note For details on Chrome time, see chromeTimeToDate.
|
|
*/
|
|
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) {
|
|
// 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 ["chrome", "chromium-edge", "chromium"]) {
|
|
// Skip if there's no profile data.
|
|
const migrator = await MigrationUtils.getMigrator(browserId);
|
|
if (!migrator) {
|
|
continue;
|
|
}
|
|
|
|
// Check each profile for logins.
|
|
const dataPath = await migrator.wrappedJSObject._getChromeUserDataPathIfExists();
|
|
for (const profile of await migrator.getSourceProfiles()) {
|
|
const path = OS.Path.join(dataPath, profile.id, "Login Data");
|
|
// Skip if login data is missing.
|
|
if (!(await OS.File.exists(path))) {
|
|
Cu.reportError(`Missing file at ${path}`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
for (const row of await 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 = 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) {
|
|
Cu.reportError(
|
|
`Failed to process importable url ${url} from ${browserId} ${ex}`
|
|
);
|
|
}
|
|
}
|
|
} catch (ex) {
|
|
Cu.reportError(
|
|
`Failed to get importable logins from ${browserId} ${ex}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return this._importableLoginsCache.get(formOrigin);
|
|
},
|
|
};
|