SafariProfileMigrator.sys.mjs (22258B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; 6 7 import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; 8 import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; 9 10 const lazy = {}; 11 12 ChromeUtils.defineESModuleGetters(lazy, { 13 FormHistory: "resource://gre/modules/FormHistory.sys.mjs", 14 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 15 PropertyListUtils: "resource://gre/modules/PropertyListUtils.sys.mjs", 16 }); 17 18 // NSDate epoch is Jan 1, 2001 UTC 19 const NS_DATE_EPOCH_MS = new Date("2001-01-01T00:00:00-00:00").getTime(); 20 21 // Convert NSDate timestamp to UNIX timestamp. 22 function parseNSDate(cocoaDateStr) { 23 let asDouble = parseFloat(cocoaDateStr); 24 if (!isNaN(asDouble)) { 25 return new Date(NS_DATE_EPOCH_MS + asDouble * 1000); 26 } 27 return new Date(); 28 } 29 30 // Convert UNIX timestamp to NSDate timestamp. 31 function msToNSDate(ms) { 32 return parseFloat(ms - NS_DATE_EPOCH_MS) / 1000; 33 } 34 35 function Bookmarks(aBookmarksFile) { 36 this._file = aBookmarksFile; 37 } 38 Bookmarks.prototype = { 39 type: MigrationUtils.resourceTypes.BOOKMARKS, 40 41 migrate: function B_migrate(aCallback) { 42 return (async () => { 43 let dict = await new Promise(resolve => 44 lazy.PropertyListUtils.read(this._file, resolve) 45 ); 46 if (!dict) { 47 throw new Error("Could not read Bookmarks.plist"); 48 } 49 let children = dict.get("Children"); 50 if (!children) { 51 throw new Error("Invalid Bookmarks.plist format"); 52 } 53 54 let collection = 55 dict.get("Title") == "com.apple.ReadingList" 56 ? this.READING_LIST_COLLECTION 57 : this.ROOT_COLLECTION; 58 await this._migrateRootCollection(children, collection); 59 })().then( 60 () => aCallback(true), 61 e => { 62 console.error(e); 63 aCallback(false); 64 } 65 ); 66 }, 67 68 // Bookmarks collections in Safari. Constants for migrateCollection. 69 ROOT_COLLECTION: 0, 70 MENU_COLLECTION: 1, 71 TOOLBAR_COLLECTION: 2, 72 READING_LIST_COLLECTION: 3, 73 74 /** 75 * Start the migration of a Safari collection of bookmarks by retrieving favicon data. 76 * 77 * @param {object[]} aEntries 78 * The collection's children 79 * @param {number} aCollection 80 * One of the _COLLECTION values above. 81 */ 82 async _migrateRootCollection(aEntries, aCollection) { 83 // First, try to get the favicon data of a user's bookmarks. 84 // In Safari, Favicons are stored as files with a unique name: 85 // the MD5 hash of the UUID of an SQLite entry in favicons.db. 86 // Thus, we must create a map from bookmark URLs -> their favicon entry's UUID. 87 let bookmarkURLToUUIDMap = new Map(); 88 89 const faviconFolder = FileUtils.getDir("ULibDir", [ 90 "Safari", 91 "Favicon Cache", 92 ]).path; 93 let dbPath = PathUtils.join(faviconFolder, "favicons.db"); 94 95 try { 96 // If there is an error getting favicon data, we catch the error and move on. 97 // In this case, the bookmarkURLToUUIDMap will be left empty. 98 let rows = await MigrationUtils.getRowsFromDBWithoutLocks( 99 dbPath, 100 "Safari favicons", 101 `SELECT I.uuid, I.url AS favicon_url, P.url 102 FROM icon_info I 103 INNER JOIN page_url P ON I.uuid = P.uuid;` 104 ); 105 106 if (rows) { 107 // Convert the rows from our SQLite database into a map from bookmark url to uuid 108 for (let row of rows) { 109 let uniqueURL = new URL(row.getResultByName("url")).href; 110 111 // Normalize the URL by removing any trailing slashes. We'll make sure to do 112 // the same when doing look-ups during a migration. 113 if (uniqueURL.endsWith("/")) { 114 uniqueURL = uniqueURL.replace(/\/+$/, ""); 115 } 116 bookmarkURLToUUIDMap.set(uniqueURL, row.getResultByName("uuid")); 117 } 118 } 119 } catch (ex) { 120 console.error(ex); 121 } 122 123 await this._migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap); 124 }, 125 126 /** 127 * Recursively migrate a Safari collection of bookmarks. 128 * 129 * @param {object[]} aEntries 130 * The collection's children 131 * @param {number} aCollection 132 * One of the _COLLECTION values above 133 * @param {Map} bookmarkURLToUUIDMap 134 * A map from a bookmark's URL to the UUID of its entry in the favicons.db database 135 * @returns {Promise<undefined>} 136 * Resolves after the bookmarks and favicons have been inserted into the 137 * appropriate databases. 138 */ 139 async _migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap) { 140 // A collection of bookmarks in Safari resembles places roots. In the 141 // property list files (Bookmarks.plist, ReadingList.plist) they are 142 // stored as regular bookmarks folders, and thus can only be distinguished 143 // from by their names and places in the hierarchy. 144 145 let entriesFiltered = []; 146 if (aCollection == this.ROOT_COLLECTION) { 147 for (let entry of aEntries) { 148 let type = entry.get("WebBookmarkType"); 149 if (type == "WebBookmarkTypeList" && entry.has("Children")) { 150 let title = entry.get("Title"); 151 let children = entry.get("Children"); 152 if (title == "BookmarksBar") { 153 await this._migrateCollection( 154 children, 155 this.TOOLBAR_COLLECTION, 156 bookmarkURLToUUIDMap 157 ); 158 } else if (title == "BookmarksMenu") { 159 await this._migrateCollection( 160 children, 161 this.MENU_COLLECTION, 162 bookmarkURLToUUIDMap 163 ); 164 } else if (title == "com.apple.ReadingList") { 165 await this._migrateCollection( 166 children, 167 this.READING_LIST_COLLECTION, 168 bookmarkURLToUUIDMap 169 ); 170 } else if (entry.get("ShouldOmitFromUI") !== true) { 171 entriesFiltered.push(entry); 172 } 173 } else if (type == "WebBookmarkTypeLeaf") { 174 entriesFiltered.push(entry); 175 } 176 } 177 } else { 178 entriesFiltered = aEntries; 179 } 180 181 if (!entriesFiltered.length) { 182 return; 183 } 184 185 let folderGuid = -1; 186 switch (aCollection) { 187 case this.ROOT_COLLECTION: { 188 // In Safari, it is possible (though quite cumbersome) to move 189 // bookmarks to the bookmarks root, which is the parent folder of 190 // all bookmarks "collections". That is somewhat in parallel with 191 // both the places root and the unfiled-bookmarks root. 192 // Because the former is only an implementation detail in our UI, 193 // the unfiled root seems to be the best choice. 194 folderGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; 195 break; 196 } 197 case this.MENU_COLLECTION: { 198 folderGuid = lazy.PlacesUtils.bookmarks.menuGuid; 199 break; 200 } 201 case this.TOOLBAR_COLLECTION: { 202 folderGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; 203 break; 204 } 205 case this.READING_LIST_COLLECTION: { 206 // Reading list items are imported as regular bookmarks. 207 // They are imported under their own folder, created either under the 208 // bookmarks menu (in the case of startup migration). 209 let readingListTitle = await MigrationUtils.getLocalizedString( 210 "migration-imported-safari-reading-list" 211 ); 212 folderGuid = ( 213 await MigrationUtils.insertBookmarkWrapper({ 214 parentGuid: lazy.PlacesUtils.bookmarks.menuGuid, 215 type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, 216 title: readingListTitle, 217 }) 218 ).guid; 219 break; 220 } 221 default: 222 throw new Error("Unexpected value for aCollection!"); 223 } 224 if (folderGuid == -1) { 225 throw new Error("Invalid folder GUID"); 226 } 227 228 await this._migrateEntries( 229 entriesFiltered, 230 folderGuid, 231 bookmarkURLToUUIDMap 232 ); 233 }, 234 235 /** 236 * Migrates bookmarks and favicons from Safari to Firefox. 237 * 238 * @param {object[]} entries 239 * The Safari collection's children 240 * @param {number} parentGuid 241 * GUID of the collection folder 242 * @param {Map} bookmarkURLToUUIDMap 243 * A map from a bookmark's URL to the UUID of its entry in the favicons.db database 244 */ 245 async _migrateEntries(entries, parentGuid, bookmarkURLToUUIDMap) { 246 let { convertedEntries, favicons } = await this._convertEntries( 247 entries, 248 bookmarkURLToUUIDMap 249 ); 250 251 await MigrationUtils.insertManyBookmarksWrapper( 252 convertedEntries, 253 parentGuid 254 ); 255 256 MigrationUtils.insertManyFavicons(favicons).catch(console.error); 257 }, 258 259 /** 260 * Converts Safari collection entries into a suitable format for 261 * inserting bookmarks and favicons. 262 * 263 * @param {object[]} entries 264 * The collection's children 265 * @param {Map} bookmarkURLToUUIDMap 266 * A map from a bookmark's URL to the UUID of its entry in the favicons.db database 267 * @returns {object[]} 268 * Returns an object with an array of converted bookmark entries and favicons 269 */ 270 async _convertEntries(entries, bookmarkURLToUUIDMap) { 271 let favicons = []; 272 let convertedEntries = []; 273 274 const faviconFolder = FileUtils.getDir("ULibDir", [ 275 "Safari", 276 "Favicon Cache", 277 ]).path; 278 279 for (const entry of entries) { 280 let type = entry.get("WebBookmarkType"); 281 if (type == "WebBookmarkTypeList" && entry.has("Children")) { 282 let convertedChildren = await this._convertEntries( 283 entry.get("Children"), 284 bookmarkURLToUUIDMap 285 ); 286 favicons.push(...convertedChildren.favicons); 287 convertedEntries.push({ 288 title: entry.get("Title"), 289 type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, 290 children: convertedChildren.convertedEntries, 291 }); 292 } else if (type == "WebBookmarkTypeLeaf" && entry.has("URLString")) { 293 // Check we understand this URL before adding it: 294 let url = entry.get("URLString"); 295 if (!URL.canParse(url)) { 296 console.error( 297 `Ignoring ${url} when importing from Safari because it is not a valid URL.` 298 ); 299 continue; 300 } 301 let title; 302 if (entry.has("URIDictionary")) { 303 title = entry.get("URIDictionary").get("title"); 304 } 305 convertedEntries.push({ url, title }); 306 307 try { 308 // Try to get the favicon data for each bookmark we have. 309 // We use uri.spec as our unique identifier since bookmark links 310 // don't completely match up in the Safari data. 311 let uri = Services.io.newURI(url); 312 let uriSpec = uri.spec; 313 314 // Safari's favicon database doesn't include forward slashes for 315 // the page URLs, despite adding them in the Bookmarks.plist file. 316 // We'll strip any off here for our favicon lookup. 317 if (uriSpec.endsWith("/")) { 318 uriSpec = uriSpec.replace(/\/+$/, ""); 319 } 320 321 let uuid = bookmarkURLToUUIDMap.get(uriSpec); 322 if (uuid) { 323 // Hash the UUID with md5 to give us the favicon file name. 324 let hashedUUID = lazy.PlacesUtils.md5(uuid, { 325 format: "hex", 326 }).toUpperCase(); 327 let faviconFile = PathUtils.join( 328 faviconFolder, 329 "favicons", 330 hashedUUID 331 ); 332 let faviconData = await IOUtils.read(faviconFile); 333 favicons.push({ faviconData, uri }); 334 } 335 } catch (error) { 336 // Even if we fail, still continue the import process 337 // since favicons aren't as essential as the bookmarks themselves. 338 console.error(error); 339 } 340 } 341 } 342 343 return { convertedEntries, favicons }; 344 }, 345 }; 346 347 async function GetHistoryResource() { 348 let dbPath = FileUtils.getDir("ULibDir", ["Safari", "History.db"]).path; 349 let maxAge = msToNSDate( 350 Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS 351 ); 352 353 // If we have read access to the Safari profile directory, check to 354 // see if there's any history to import. If we can't access the profile 355 // directory, let's assume that there's history to import and give the 356 // user the option to migrate it. 357 let canReadHistory = false; 358 try { 359 // 'stat' is always allowed, but reading is somehow not, if the user hasn't 360 // allowed it: 361 await IOUtils.read(dbPath, { maxBytes: 1 }); 362 canReadHistory = true; 363 } catch (ex) { 364 console.error( 365 "Cannot yet read from Safari profile directory. Will presume history exists for import." 366 ); 367 } 368 369 if (canReadHistory) { 370 let countQuery = ` 371 SELECT COUNT(*) 372 FROM history_items LEFT JOIN history_visits 373 ON history_items.id = history_visits.history_item 374 WHERE history_visits.visit_time > ${maxAge} 375 LIMIT 1;`; 376 377 let countResult = await MigrationUtils.getRowsFromDBWithoutLocks( 378 dbPath, 379 "Safari history", 380 countQuery 381 ); 382 383 if (!countResult[0].getResultByName("COUNT(*)")) { 384 return null; 385 } 386 } 387 388 let selectQuery = ` 389 SELECT 390 history_items.url as history_url, 391 history_visits.title as history_title, 392 history_visits.visit_time as history_time 393 FROM history_items LEFT JOIN history_visits 394 ON history_items.id = history_visits.history_item 395 WHERE history_visits.visit_time > ${maxAge};`; 396 397 return { 398 type: MigrationUtils.resourceTypes.HISTORY, 399 400 async migrate(callback) { 401 callback(await this._migrate()); 402 }, 403 404 async _migrate() { 405 let historyRows; 406 407 try { 408 historyRows = await MigrationUtils.getRowsFromDBWithoutLocks( 409 dbPath, 410 "Safari history", 411 selectQuery 412 ); 413 414 if (!historyRows.length) { 415 console.log("No history found"); 416 return false; 417 } 418 } catch (ex) { 419 console.error(ex); 420 return false; 421 } 422 423 let pageInfos = []; 424 for (let row of historyRows) { 425 try { 426 pageInfos.push({ 427 title: row.getResultByName("history_title"), 428 url: new URL(row.getResultByName("history_url")), 429 visits: [ 430 { 431 transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, 432 date: parseNSDate(row.getResultByName("history_time")), 433 }, 434 ], 435 }); 436 } catch (e) { 437 console.error("Could not create a history row: ", e); 438 } 439 } 440 await MigrationUtils.insertVisitsWrapper(pageInfos); 441 442 return true; 443 }, 444 }; 445 } 446 447 /** 448 * Safari's preferences property list is independently used for three purposes: 449 * (a) importation of preferences 450 * (b) importation of search strings 451 * (c) retrieving the home page. 452 * 453 * So, rather than reading it three times, it's cached and managed here. 454 * 455 * @param {nsIFile} aPreferencesFile 456 * The .plist file to be read. 457 */ 458 function MainPreferencesPropertyList(aPreferencesFile) { 459 this._file = aPreferencesFile; 460 this._callbacks = []; 461 } 462 MainPreferencesPropertyList.prototype = { 463 /** 464 * @see PropertyListUtils.read 465 * @param {Function} aCallback 466 * A callback called with an Object representing the key-value pairs 467 * read out of the .plist file. 468 */ 469 read: function MPPL_read(aCallback) { 470 if ("_dict" in this) { 471 aCallback(this._dict); 472 return; 473 } 474 475 let alreadyReading = !!this._callbacks.length; 476 this._callbacks.push(aCallback); 477 if (!alreadyReading) { 478 lazy.PropertyListUtils.read(this._file, aDict => { 479 this._dict = aDict; 480 for (let callback of this._callbacks) { 481 try { 482 callback(aDict); 483 } catch (ex) { 484 console.error(ex); 485 } 486 } 487 this._callbacks.splice(0); 488 }); 489 } 490 }, 491 }; 492 493 function SearchStrings(aMainPreferencesPropertyListInstance) { 494 this._mainPreferencesPropertyList = aMainPreferencesPropertyListInstance; 495 } 496 SearchStrings.prototype = { 497 type: MigrationUtils.resourceTypes.OTHERDATA, 498 499 migrate: function SS_migrate(aCallback) { 500 this._mainPreferencesPropertyList.read( 501 MigrationUtils.wrapMigrateFunction(function migrateSearchStrings(aDict) { 502 if (!aDict) { 503 throw new Error("Could not get preferences dictionary"); 504 } 505 506 if (aDict.has("RecentSearchStrings")) { 507 let recentSearchStrings = aDict.get("RecentSearchStrings"); 508 if (recentSearchStrings && recentSearchStrings.length) { 509 let changes = recentSearchStrings.map(searchString => ({ 510 op: "add", 511 fieldname: "searchbar-history", 512 value: searchString, 513 })); 514 lazy.FormHistory.update(changes); 515 } 516 } 517 }, aCallback) 518 ); 519 }, 520 }; 521 522 /** 523 * Safari migrator 524 */ 525 export class SafariProfileMigrator extends MigratorBase { 526 static get key() { 527 return "safari"; 528 } 529 530 static get displayNameL10nID() { 531 return "migration-wizard-migrator-display-name-safari"; 532 } 533 534 static get brandImage() { 535 return "chrome://browser/content/migration/brands/safari.png"; 536 } 537 538 async getResources() { 539 let profileDir = FileUtils.getDir("ULibDir", ["Safari"]); 540 if (!profileDir.exists()) { 541 return null; 542 } 543 544 let resources = []; 545 let pushProfileFileResource = function (aFileName, aConstructor) { 546 let file = profileDir.clone(); 547 file.append(aFileName); 548 if (file.exists()) { 549 resources.push(new aConstructor(file)); 550 } 551 }; 552 553 pushProfileFileResource("Bookmarks.plist", Bookmarks); 554 555 // The Reading List feature was introduced at the same time in Windows and 556 // Mac versions of Safari. Not surprisingly, they are stored in the same 557 // format in both versions. Surpsingly, only on Windows there is a 558 // separate property list for it. This code is used on mac too, because 559 // Apple may fix this at some point. 560 pushProfileFileResource("ReadingList.plist", Bookmarks); 561 562 let prefs = this.mainPreferencesPropertyList; 563 if (prefs) { 564 resources.push(new SearchStrings(prefs)); 565 } 566 567 resources.push(GetHistoryResource()); 568 569 resources = await Promise.all(resources); 570 571 return resources.filter(r => r != null); 572 } 573 574 async getLastUsedDate() { 575 const profileDir = FileUtils.getDir("ULibDir", ["Safari"]); 576 const dates = await Promise.all( 577 ["Bookmarks.plist", "History.db"].map(file => { 578 const path = PathUtils.join(profileDir.path, file); 579 return IOUtils.stat(path) 580 .then(info => info.lastModified) 581 .catch(() => 0); 582 }) 583 ); 584 585 return new Date(Math.max(...dates)); 586 } 587 588 async hasPermissions() { 589 if (this._hasPermissions) { 590 return true; 591 } 592 // Check if we have access to some key files, but only if they exist. 593 let historyTarget = FileUtils.getDir("ULibDir", ["Safari", "History.db"]); 594 let bookmarkTarget = FileUtils.getDir("ULibDir", [ 595 "Safari", 596 "Bookmarks.plist", 597 ]); 598 let faviconTarget = FileUtils.getDir("ULibDir", [ 599 "Safari", 600 "Favicon Cache", 601 "favicons.db", 602 ]); 603 try { 604 let historyExists = await IOUtils.exists(historyTarget.path); 605 let bookmarksExists = await IOUtils.exists(bookmarkTarget.path); 606 let faviconsExists = await IOUtils.exists(faviconTarget.path); 607 // We now know which files exist, which is always allowed. 608 // To determine if we have read permissions, try to read a single byte 609 // from each file that exists, which will throw if we need permissions. 610 if (historyExists) { 611 await IOUtils.read(historyTarget.path, { maxBytes: 1 }); 612 } 613 if (bookmarksExists) { 614 await IOUtils.read(bookmarkTarget.path, { maxBytes: 1 }); 615 } 616 if (faviconsExists) { 617 await IOUtils.read(faviconTarget.path, { maxBytes: 1 }); 618 } 619 this._hasPermissions = true; 620 return true; 621 } catch (ex) { 622 return false; 623 } 624 } 625 626 async getPermissions(win) { 627 // Keep prompting the user until they pick something that grants us access 628 // to Safari's bookmarks and favicons or they cancel out of the file open panel. 629 while (!(await this.hasPermissions())) { 630 let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); 631 // The title (second arg) is not displayed on macOS, so leave it blank. 632 fp.init(win?.browsingContext, "", Ci.nsIFilePicker.modeGetFolder); 633 fp.filterIndex = 1; 634 fp.displayDirectory = FileUtils.getDir("ULibDir", [""]); 635 // Now wait for the filepicker to open and close. If the user picks 636 // the Safari folder, macOS will grant us read access to everything 637 // inside, so we don't need to check or do anything else with what's 638 // returned by the filepicker. 639 let result = await new Promise(resolve => fp.open(resolve)); 640 // Bail if the user cancels the dialog: 641 if (result == Ci.nsIFilePicker.returnCancel) { 642 return false; 643 } 644 } 645 return true; 646 } 647 648 async canGetPermissions() { 649 if (await MigrationUtils.canGetPermissionsOnPlatform()) { 650 const profileDir = FileUtils.getDir("ULibDir", ["Safari"]); 651 if (await IOUtils.exists(profileDir.path)) { 652 return profileDir.path; 653 } 654 } 655 return false; 656 } 657 658 /** 659 * For Safari on macOS, we show a specialized flow for importing passwords 660 * from a CSV file. 661 * 662 * @returns {boolean} 663 */ 664 get showsManualPasswordImport() { 665 // Since this migrator will only ever be used on macOS, all conditions are 666 // met and we can always return true. 667 return true; 668 } 669 670 get mainPreferencesPropertyList() { 671 if (this._mainPreferencesPropertyList === undefined) { 672 let file = FileUtils.getDir("UsrPrfs", []); 673 if (file.exists()) { 674 file.append("com.apple.Safari.plist"); 675 if (file.exists()) { 676 this._mainPreferencesPropertyList = new MainPreferencesPropertyList( 677 file 678 ); 679 return this._mainPreferencesPropertyList; 680 } 681 } 682 this._mainPreferencesPropertyList = null; 683 return this._mainPreferencesPropertyList; 684 } 685 return this._mainPreferencesPropertyList; 686 } 687 }