diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/migration/SafariProfileMigrator.sys.mjs | 674 |
1 files changed, 674 insertions, 0 deletions
diff --git a/browser/components/migration/SafariProfileMigrator.sys.mjs b/browser/components/migration/SafariProfileMigrator.sys.mjs new file mode 100644 index 0000000000..4a68178e4b --- /dev/null +++ b/browser/components/migration/SafariProfileMigrator.sys.mjs @@ -0,0 +1,674 @@ +/* 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/. */ + +import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PropertyListUtils: "resource://gre/modules/PropertyListUtils.sys.mjs", +}); + +// NSDate epoch is Jan 1, 2001 UTC +const NS_DATE_EPOCH_MS = new Date("2001-01-01T00:00:00-00:00").getTime(); + +// Convert NSDate timestamp to UNIX timestamp. +function parseNSDate(cocoaDateStr) { + let asDouble = parseFloat(cocoaDateStr); + if (!isNaN(asDouble)) { + return new Date(NS_DATE_EPOCH_MS + asDouble * 1000); + } + return new Date(); +} + +// Convert UNIX timestamp to NSDate timestamp. +function msToNSDate(ms) { + return parseFloat(ms - NS_DATE_EPOCH_MS) / 1000; +} + +function Bookmarks(aBookmarksFile) { + this._file = aBookmarksFile; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate: function B_migrate(aCallback) { + return (async () => { + let dict = await new Promise(resolve => + lazy.PropertyListUtils.read(this._file, resolve) + ); + if (!dict) { + throw new Error("Could not read Bookmarks.plist"); + } + let children = dict.get("Children"); + if (!children) { + throw new Error("Invalid Bookmarks.plist format"); + } + + let collection = + dict.get("Title") == "com.apple.ReadingList" + ? this.READING_LIST_COLLECTION + : this.ROOT_COLLECTION; + await this._migrateRootCollection(children, collection); + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, + + // Bookmarks collections in Safari. Constants for migrateCollection. + ROOT_COLLECTION: 0, + MENU_COLLECTION: 1, + TOOLBAR_COLLECTION: 2, + READING_LIST_COLLECTION: 3, + + /** + * Start the migration of a Safari collection of bookmarks by retrieving favicon data. + * + * @param {object[]} aEntries + * The collection's children + * @param {number} aCollection + * One of the _COLLECTION values above. + */ + async _migrateRootCollection(aEntries, aCollection) { + // First, try to get the favicon data of a user's bookmarks. + // In Safari, Favicons are stored as files with a unique name: + // the MD5 hash of the UUID of an SQLite entry in favicons.db. + // Thus, we must create a map from bookmark URLs -> their favicon entry's UUID. + let bookmarkURLToUUIDMap = new Map(); + + const faviconFolder = FileUtils.getDir( + "ULibDir", + ["Safari", "Favicon Cache"], + false + ).path; + let dbPath = PathUtils.join(faviconFolder, "favicons.db"); + + try { + // If there is an error getting favicon data, we catch the error and move on. + // In this case, the bookmarkURLToUUIDMap will be left empty. + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari favicons", + `SELECT I.uuid, I.url AS favicon_url, P.url + FROM icon_info I + INNER JOIN page_url P ON I.uuid = P.uuid;` + ); + + if (rows) { + // Convert the rows from our SQLite database into a map from bookmark url to uuid + for (let row of rows) { + let uniqueURL = Services.io.newURI(row.getResultByName("url")).spec; + + // Normalize the URL by removing any trailing slashes. We'll make sure to do + // the same when doing look-ups during a migration. + if (uniqueURL.endsWith("/")) { + uniqueURL = uniqueURL.replace(/\/+$/, ""); + } + bookmarkURLToUUIDMap.set(uniqueURL, row.getResultByName("uuid")); + } + } + } catch (ex) { + console.error(ex); + } + + await this._migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap); + }, + + /** + * Recursively migrate a Safari collection of bookmarks. + * + * @param {object[]} aEntries + * The collection's children + * @param {number} aCollection + * One of the _COLLECTION values above + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + * @returns {Promise<undefined>} + * Resolves after the bookmarks and favicons have been inserted into the + * appropriate databases. + */ + async _migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap) { + // A collection of bookmarks in Safari resembles places roots. In the + // property list files (Bookmarks.plist, ReadingList.plist) they are + // stored as regular bookmarks folders, and thus can only be distinguished + // from by their names and places in the hierarchy. + + let entriesFiltered = []; + if (aCollection == this.ROOT_COLLECTION) { + for (let entry of aEntries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let title = entry.get("Title"); + let children = entry.get("Children"); + if (title == "BookmarksBar") { + await this._migrateCollection( + children, + this.TOOLBAR_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (title == "BookmarksMenu") { + await this._migrateCollection( + children, + this.MENU_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (title == "com.apple.ReadingList") { + await this._migrateCollection( + children, + this.READING_LIST_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (entry.get("ShouldOmitFromUI") !== true) { + entriesFiltered.push(entry); + } + } else if (type == "WebBookmarkTypeLeaf") { + entriesFiltered.push(entry); + } + } + } else { + entriesFiltered = aEntries; + } + + if (!entriesFiltered.length) { + return; + } + + let folderGuid = -1; + switch (aCollection) { + case this.ROOT_COLLECTION: { + // In Safari, it is possible (though quite cumbersome) to move + // bookmarks to the bookmarks root, which is the parent folder of + // all bookmarks "collections". That is somewhat in parallel with + // both the places root and the unfiled-bookmarks root. + // Because the former is only an implementation detail in our UI, + // the unfiled root seems to be the best choice. + folderGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + break; + } + case this.MENU_COLLECTION: { + folderGuid = lazy.PlacesUtils.bookmarks.menuGuid; + break; + } + case this.TOOLBAR_COLLECTION: { + folderGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + break; + } + case this.READING_LIST_COLLECTION: { + // Reading list items are imported as regular bookmarks. + // They are imported under their own folder, created either under the + // bookmarks menu (in the case of startup migration). + let readingListTitle = await MigrationUtils.getLocalizedString( + "imported-safari-reading-list" + ); + folderGuid = ( + await MigrationUtils.insertBookmarkWrapper({ + parentGuid: lazy.PlacesUtils.bookmarks.menuGuid, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: readingListTitle, + }) + ).guid; + break; + } + default: + throw new Error("Unexpected value for aCollection!"); + } + if (folderGuid == -1) { + throw new Error("Invalid folder GUID"); + } + + await this._migrateEntries( + entriesFiltered, + folderGuid, + bookmarkURLToUUIDMap + ); + }, + + /** + * Migrates bookmarks and favicons from Safari to Firefox. + * + * @param {object[]} entries + * The Safari collection's children + * @param {number} parentGuid + * GUID of the collection folder + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + */ + async _migrateEntries(entries, parentGuid, bookmarkURLToUUIDMap) { + let { convertedEntries, favicons } = await this._convertEntries( + entries, + bookmarkURLToUUIDMap + ); + + await MigrationUtils.insertManyBookmarksWrapper( + convertedEntries, + parentGuid + ); + + MigrationUtils.insertManyFavicons(favicons); + }, + + /** + * Converts Safari collection entries into a suitable format for + * inserting bookmarks and favicons. + * + * @param {object[]} entries + * The collection's children + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + * @returns {object[]} + * Returns an object with an array of converted bookmark entries and favicons + */ + async _convertEntries(entries, bookmarkURLToUUIDMap) { + let favicons = []; + let convertedEntries = []; + + const faviconFolder = FileUtils.getDir( + "ULibDir", + ["Safari", "Favicon Cache"], + false + ).path; + + for (const entry of entries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let convertedChildren = await this._convertEntries( + entry.get("Children"), + bookmarkURLToUUIDMap + ); + favicons.push(...convertedChildren.favicons); + convertedEntries.push({ + title: entry.get("Title"), + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + children: convertedChildren.convertedEntries, + }); + } else if (type == "WebBookmarkTypeLeaf" && entry.has("URLString")) { + // Check we understand this URL before adding it: + let url = entry.get("URLString"); + try { + new URL(url); + } catch (ex) { + console.error( + `Ignoring ${url} when importing from Safari because of exception: ${ex}` + ); + continue; + } + let title; + if (entry.has("URIDictionary")) { + title = entry.get("URIDictionary").get("title"); + } + convertedEntries.push({ url, title }); + + try { + // Try to get the favicon data for each bookmark we have. + // We use uri.spec as our unique identifier since bookmark links + // don't completely match up in the Safari data. + let uri = Services.io.newURI(url); + let uriSpec = uri.spec; + + // Safari's favicon database doesn't include forward slashes for + // the page URLs, despite adding them in the Bookmarks.plist file. + // We'll strip any off here for our favicon lookup. + if (uriSpec.endsWith("/")) { + uriSpec = uriSpec.replace(/\/+$/, ""); + } + + let uuid = bookmarkURLToUUIDMap.get(uriSpec); + if (uuid) { + // Hash the UUID with md5 to give us the favicon file name. + let hashedUUID = lazy.PlacesUtils.md5(uuid, { + format: "hex", + }).toUpperCase(); + let faviconFile = PathUtils.join( + faviconFolder, + "favicons", + hashedUUID + ); + let faviconData = await IOUtils.read(faviconFile); + favicons.push({ faviconData, uri }); + } + } catch (error) { + // Even if we fail, still continue the import process + // since favicons aren't as essential as the bookmarks themselves. + console.error(error); + } + } + } + + return { convertedEntries, favicons }; + }, +}; + +async function GetHistoryResource() { + let dbPath = FileUtils.getDir( + "ULibDir", + ["Safari", "History.db"], + false + ).path; + let maxAge = msToNSDate( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + // If we have read access to the Safari profile directory, check to + // see if there's any history to import. If we can't access the profile + // directory, let's assume that there's history to import and give the + // user the option to migrate it. + let canReadHistory = false; + try { + // 'stat' is always allowed, but reading is somehow not, if the user hasn't + // allowed it: + await IOUtils.read(dbPath, { maxBytes: 1 }); + canReadHistory = true; + } catch (ex) { + console.error( + "Cannot yet read from Safari profile directory. Will presume history exists for import." + ); + } + + if (canReadHistory) { + let countQuery = ` + SELECT COUNT(*) + FROM history_items LEFT JOIN history_visits + ON history_items.id = history_visits.history_item + WHERE history_visits.visit_time > ${maxAge} + LIMIT 1;`; + + let countResult = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari history", + countQuery + ); + + if (!countResult[0].getResultByName("COUNT(*)")) { + return null; + } + } + + let selectQuery = ` + SELECT + history_items.url as history_url, + history_visits.title as history_title, + history_visits.visit_time as history_time + FROM history_items LEFT JOIN history_visits + ON history_items.id = history_visits.history_item + WHERE history_visits.visit_time > ${maxAge};`; + + return { + type: MigrationUtils.resourceTypes.HISTORY, + + async migrate(callback) { + callback(await this._migrate()); + }, + + async _migrate() { + let historyRows; + + try { + historyRows = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari history", + selectQuery + ); + + if (!historyRows.length) { + console.log("No history found"); + return false; + } + } catch (ex) { + console.error(ex); + return false; + } + + let pageInfos = []; + for (let row of historyRows) { + pageInfos.push({ + title: row.getResultByName("history_title"), + url: new URL(row.getResultByName("history_url")), + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date: parseNSDate(row.getResultByName("history_time")), + }, + ], + }); + } + await MigrationUtils.insertVisitsWrapper(pageInfos); + + return true; + }, + }; +} + +/** + * Safari's preferences property list is independently used for three purposes: + * (a) importation of preferences + * (b) importation of search strings + * (c) retrieving the home page. + * + * So, rather than reading it three times, it's cached and managed here. + * + * @param {nsIFile} aPreferencesFile + * The .plist file to be read. + */ +function MainPreferencesPropertyList(aPreferencesFile) { + this._file = aPreferencesFile; + this._callbacks = []; +} +MainPreferencesPropertyList.prototype = { + /** + * @see PropertyListUtils.read + * @param {Function} aCallback + * A callback called with an Object representing the key-value pairs + * read out of the .plist file. + */ + read: function MPPL_read(aCallback) { + if ("_dict" in this) { + aCallback(this._dict); + return; + } + + let alreadyReading = !!this._callbacks.length; + this._callbacks.push(aCallback); + if (!alreadyReading) { + lazy.PropertyListUtils.read(this._file, aDict => { + this._dict = aDict; + for (let callback of this._callbacks) { + try { + callback(aDict); + } catch (ex) { + console.error(ex); + } + } + this._callbacks.splice(0); + }); + } + }, +}; + +function SearchStrings(aMainPreferencesPropertyListInstance) { + this._mainPreferencesPropertyList = aMainPreferencesPropertyListInstance; +} +SearchStrings.prototype = { + type: MigrationUtils.resourceTypes.OTHERDATA, + + migrate: function SS_migrate(aCallback) { + this._mainPreferencesPropertyList.read( + MigrationUtils.wrapMigrateFunction(function migrateSearchStrings(aDict) { + if (!aDict) { + throw new Error("Could not get preferences dictionary"); + } + + if (aDict.has("RecentSearchStrings")) { + let recentSearchStrings = aDict.get("RecentSearchStrings"); + if (recentSearchStrings && recentSearchStrings.length) { + let changes = recentSearchStrings.map(searchString => ({ + op: "add", + fieldname: "searchbar-history", + value: searchString, + })); + lazy.FormHistory.update(changes); + } + } + }, aCallback) + ); + }, +}; + +/** + * Safari migrator + */ +export class SafariProfileMigrator extends MigratorBase { + static get key() { + return "safari"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-safari"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/safari.png"; + } + + async getResources() { + let profileDir = FileUtils.getDir("ULibDir", ["Safari"], false); + if (!profileDir.exists()) { + return null; + } + + let resources = []; + let pushProfileFileResource = function (aFileName, aConstructor) { + let file = profileDir.clone(); + file.append(aFileName); + if (file.exists()) { + resources.push(new aConstructor(file)); + } + }; + + pushProfileFileResource("Bookmarks.plist", Bookmarks); + + // The Reading List feature was introduced at the same time in Windows and + // Mac versions of Safari. Not surprisingly, they are stored in the same + // format in both versions. Surpsingly, only on Windows there is a + // separate property list for it. This code is used on mac too, because + // Apple may fix this at some point. + pushProfileFileResource("ReadingList.plist", Bookmarks); + + let prefs = this.mainPreferencesPropertyList; + if (prefs) { + resources.push(new SearchStrings(prefs)); + } + + resources.push(GetHistoryResource()); + + resources = await Promise.all(resources); + + return resources.filter(r => r != null); + } + + async getLastUsedDate() { + const profileDir = FileUtils.getDir("ULibDir", ["Safari"], false); + const dates = await Promise.all( + ["Bookmarks.plist", "History.db"].map(file => { + const path = PathUtils.join(profileDir.path, file); + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }) + ); + + return new Date(Math.max(...dates)); + } + + async hasPermissions() { + if (this._hasPermissions) { + return true; + } + // Check if we have access to some key files, but only if they exist. + let historyTarget = FileUtils.getDir( + "ULibDir", + ["Safari", "History.db"], + false + ); + let bookmarkTarget = FileUtils.getDir( + "ULibDir", + ["Safari", "Bookmarks.plist"], + false + ); + let faviconTarget = FileUtils.getDir( + "ULibDir", + ["Safari", "Favicon Cache", "favicons.db"], + false + ); + try { + let historyExists = await IOUtils.exists(historyTarget.path); + let bookmarksExists = await IOUtils.exists(bookmarkTarget.path); + let faviconsExists = await IOUtils.exists(faviconTarget.path); + // We now know which files exist, which is always allowed. + // To determine if we have read permissions, try to read a single byte + // from each file that exists, which will throw if we need permissions. + if (historyExists) { + await IOUtils.read(historyTarget.path, { maxBytes: 1 }); + } + if (bookmarksExists) { + await IOUtils.read(bookmarkTarget.path, { maxBytes: 1 }); + } + if (faviconsExists) { + await IOUtils.read(faviconTarget.path, { maxBytes: 1 }); + } + this._hasPermissions = true; + return true; + } catch (ex) { + return false; + } + } + + async getPermissions(win) { + // Keep prompting the user until they pick something that grants us access + // to Safari's bookmarks and favicons or they cancel out of the file open panel. + while (!(await this.hasPermissions())) { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + // The title (second arg) is not displayed on macOS, so leave it blank. + fp.init(win, "", Ci.nsIFilePicker.modeGetFolder); + fp.filterIndex = 1; + fp.displayDirectory = FileUtils.getDir("ULibDir", [""], false); + // Now wait for the filepicker to open and close. If the user picks + // the Safari folder, macOS will grant us read access to everything + // inside, so we don't need to check or do anything else with what's + // returned by the filepicker. + let result = await new Promise(resolve => fp.open(resolve)); + // Bail if the user cancels the dialog: + if (result == Ci.nsIFilePicker.returnCancel) { + return false; + } + } + return true; + } + + get mainPreferencesPropertyList() { + if (this._mainPreferencesPropertyList === undefined) { + let file = FileUtils.getDir("UsrPrfs", [], false); + if (file.exists()) { + file.append("com.apple.Safari.plist"); + if (file.exists()) { + this._mainPreferencesPropertyList = new MainPreferencesPropertyList( + file + ); + return this._mainPreferencesPropertyList; + } + } + this._mainPreferencesPropertyList = null; + return this._mainPreferencesPropertyList; + } + return this._mainPreferencesPropertyList; + } +} |