diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/migration/SafariProfileMigrator.sys.mjs | 467 |
1 files changed, 467 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..e27a302c56 --- /dev/null +++ b/browser/components/migration/SafariProfileMigrator.sys.mjs @@ -0,0 +1,467 @@ +/* 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"; + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +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", +}); + +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._migrateCollection(children, collection); + })().then( + () => aCallback(true), + e => { + Cu.reportError(e); + aCallback(false); + } + ); + }, + + // Bookmarks collections in Safari. Constants for migrateCollection. + ROOT_COLLECTION: 0, + MENU_COLLECTION: 1, + TOOLBAR_COLLECTION: 2, + READING_LIST_COLLECTION: 3, + + /** + * Recursively migrate a Safari collection of bookmarks. + * + * @param {object[]} aEntries + * The collection's children + * @param {number} aCollection + * One of the _COLLECTION values above. + */ + async _migrateCollection(aEntries, aCollection) { + // 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); + } else if (title == "BookmarksMenu") { + await this._migrateCollection(children, this.MENU_COLLECTION); + } else if (title == "com.apple.ReadingList") { + await this._migrateCollection( + children, + this.READING_LIST_COLLECTION + ); + } 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); + }, + + // migrate the given array of safari bookmarks to the given places + // folder. + _migrateEntries(entries, parentGuid) { + let convertedEntries = this._convertEntries(entries); + return MigrationUtils.insertManyBookmarksWrapper( + convertedEntries, + parentGuid + ); + }, + + _convertEntries(entries) { + return entries + .map(function(entry) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + return { + title: entry.get("Title"), + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + children: this._convertEntries(entry.get("Children")), + }; + } + 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) { + Cu.reportError( + `Ignoring ${url} when importing from Safari because of exception: ${ex}` + ); + return null; + } + let title; + if (entry.has("URIDictionary")) { + title = entry.get("URIDictionary").get("title"); + } + return { url, title }; + } + return null; + }, this) + .filter(e => !!e); + }, +}; + +function History(aHistoryFile) { + this._file = aHistoryFile; +} +History.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + // Helper method for converting the visit date property to a PRTime value. + // The visit date is stored as a string, so it's not read as a Date + // object by PropertyListUtils. + _parseCocoaDate: function H___parseCocoaDate(aCocoaDateStr) { + let asDouble = parseFloat(aCocoaDateStr); + if (!isNaN(asDouble)) { + // reference date of NSDate. + let date = new Date("1 January 2001, GMT"); + date.setMilliseconds(asDouble * 1000); + return date; + } + return new Date(); + }, + + migrate: function H_migrate(aCallback) { + lazy.PropertyListUtils.read(this._file, aDict => { + try { + if (!aDict) { + throw new Error("Could not read history property list"); + } + if (!aDict.has("WebHistoryDates")) { + throw new Error("Unexpected history-property list format"); + } + + let pageInfos = []; + let entries = aDict.get("WebHistoryDates"); + let failedOnce = false; + for (let entry of entries) { + if (entry.has("lastVisitedDate")) { + let date = this._parseCocoaDate(entry.get("lastVisitedDate")); + try { + pageInfos.push({ + url: new URL(entry.get("")), + title: entry.get("title"), + visits: [ + { + // Safari's History file contains only top-level urls. It does not + // distinguish between typed urls and linked urls. + transition: lazy.PlacesUtils.history.TRANSITIONS.LINK, + date, + }, + ], + }); + } catch (ex) { + // Safari's History file may contain malformed URIs which + // will be ignored. + Cu.reportError(ex); + failedOnce = true; + } + } + } + if (!pageInfos.length) { + // If we failed at least once, then we didn't succeed in importing, + // otherwise we didn't actually have anything to import, so we'll + // report it as a success. + aCallback(!failedOnce); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + } catch (ex) { + Cu.reportError(ex); + aCallback(false); + } + }); + }, +}; + +/** + * 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) { + Cu.reportError(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"; + } + + 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("History.plist", History); + 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)); + } + + return resources; + } + + getLastUsedDate() { + let profileDir = FileUtils.getDir("ULibDir", ["Safari"], false); + let datePromises = ["Bookmarks.plist", "History.plist"].map(file => { + let path = OS.Path.join(profileDir.path, file); + return OS.File.stat(path) + .catch(() => null) + .then(info => { + return info ? info.lastModificationDate : 0; + }); + }); + return Promise.all(datePromises).then(dates => { + return new Date(Math.max.apply(Math, dates)); + }); + } + + async hasPermissions() { + if (this._hasPermissions) { + return true; + } + // Check if we have access: + let target = FileUtils.getDir( + "ULibDir", + ["Safari", "Bookmarks.plist"], + false + ); + try { + // 'stat' is always allowed, but reading is somehow not, if the user hasn't + // allowed it: + await IOUtils.read(target.path, { maxBytes: 1 }); + this._hasPermissions = true; + return true; + } catch (ex) { + return false; + } + } + + async getPermissions(win) { + // Keep prompting the user until they pick a file that grants us access, + // 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.modeOpen); + // This is a little weird. You'd expect that it matters which file + // the user picks, but it doesn't really, as long as it's in this + // directory. Anyway, let's not confuse the user: the sensible idea + // here is to ask for permissions for Bookmarks.plist, and we'll + // silently accept whatever input as long as we can then read the plist. + fp.appendFilter("plist", "*.plist"); + fp.filterIndex = 1; + fp.displayDirectory = FileUtils.getDir("ULibDir", ["Safari"], false); + // Now wait for the filepicker to open and close. If the user picks + // any file in this directory, macOS will grant us read access, so + // we don't need to check or do anything else with the file 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; + } +} |