bookmarks.sys.mjs (29529B)
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 { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs"; 6 import { 7 Changeset, 8 Store, 9 SyncEngine, 10 Tracker, 11 } from "resource://services-sync/engines.sys.mjs"; 12 import { CryptoWrapper } from "resource://services-sync/record.sys.mjs"; 13 import { Svc, Utils } from "resource://services-sync/util.sys.mjs"; 14 15 const lazy = {}; 16 17 ChromeUtils.defineESModuleGetters(lazy, { 18 Async: "resource://services-common/async.sys.mjs", 19 Observers: "resource://services-common/observers.sys.mjs", 20 PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", 21 PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs", 22 PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.sys.mjs", 23 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 24 Resource: "resource://services-sync/resource.sys.mjs", 25 SyncedBookmarksMirror: "resource://gre/modules/SyncedBookmarksMirror.sys.mjs", 26 }); 27 28 const PLACES_MAINTENANCE_INTERVAL_SECONDS = 4 * 60 * 60; // 4 hours. 29 30 const FOLDER_SORTINDEX = 1000000; 31 32 // Roots that should be deleted from the server, instead of applied locally. 33 // This matches `AndroidBrowserBookmarksRepositorySession::forbiddenGUID`, 34 // but allows tags because we don't want to reparent tag folders or tag items 35 // to "unfiled". 36 const FORBIDDEN_INCOMING_IDS = ["pinned", "places", "readinglist"]; 37 38 // Items with these parents should be deleted from the server. We allow 39 // children of the Places root, to avoid orphaning left pane queries and other 40 // descendants of custom roots. 41 const FORBIDDEN_INCOMING_PARENT_IDS = ["pinned", "readinglist"]; 42 43 // The tracker ignores changes made by import and restore, to avoid bumping the 44 // score and triggering syncs during the process, as well as changes made by 45 // Sync. 46 ChromeUtils.defineLazyGetter(lazy, "IGNORED_SOURCES", () => [ 47 lazy.PlacesUtils.bookmarks.SOURCES.SYNC, 48 lazy.PlacesUtils.bookmarks.SOURCES.IMPORT, 49 lazy.PlacesUtils.bookmarks.SOURCES.RESTORE, 50 lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, 51 lazy.PlacesUtils.bookmarks.SOURCES.SYNC_REPARENT_REMOVED_FOLDER_CHILDREN, 52 ]); 53 54 // The validation telemetry version for the engine. Version 1 is collected 55 // by `bookmark_validator.js`, and checks value as well as structure 56 // differences. Version 2 is collected by the engine as part of building the 57 // remote tree, and checks structure differences only. 58 const BOOKMARK_VALIDATOR_VERSION = 2; 59 60 // The maximum time that the engine should wait before aborting a bookmark 61 // merge. 62 const BOOKMARK_APPLY_TIMEOUT_MS = 5 * 60 * 60 * 1000; // 5 minutes 63 64 // The default frecency value to use when not known. 65 const FRECENCY_UNKNOWN = -1; 66 67 // Returns the constructor for a bookmark record type. 68 function getTypeObject(type) { 69 switch (type) { 70 case "bookmark": 71 return Bookmark; 72 case "query": 73 return BookmarkQuery; 74 case "folder": 75 return BookmarkFolder; 76 case "livemark": 77 return Livemark; 78 case "separator": 79 return BookmarkSeparator; 80 case "item": 81 return PlacesItem; 82 } 83 return null; 84 } 85 86 export function PlacesItem(collection, id, type) { 87 CryptoWrapper.call(this, collection, id); 88 this.type = type || "item"; 89 } 90 91 PlacesItem.prototype = { 92 async decrypt(keyBundle) { 93 // Do the normal CryptoWrapper decrypt, but change types before returning 94 let clear = await CryptoWrapper.prototype.decrypt.call(this, keyBundle); 95 96 // Convert the abstract places item to the actual object type 97 if (!this.deleted) { 98 Object.setPrototypeOf(this, this.getTypeObject(this.type).prototype); 99 } 100 101 return clear; 102 }, 103 104 getTypeObject: function PlacesItem_getTypeObject(type) { 105 let recordObj = getTypeObject(type); 106 if (!recordObj) { 107 throw new Error("Unknown places item object type: " + type); 108 } 109 return recordObj; 110 }, 111 112 _logName: "Sync.Record.PlacesItem", 113 114 // Converts the record to a Sync bookmark object that can be passed to 115 // `PlacesSyncUtils.bookmarks.{insert, update}`. 116 toSyncBookmark() { 117 let result = { 118 kind: this.type, 119 recordId: this.id, 120 parentRecordId: this.parentid, 121 }; 122 let dateAdded = lazy.PlacesSyncUtils.bookmarks.ratchetTimestampBackwards( 123 this.dateAdded, 124 +this.modified * 1000 125 ); 126 if (dateAdded > 0) { 127 result.dateAdded = dateAdded; 128 } 129 return result; 130 }, 131 132 // Populates the record from a Sync bookmark object returned from 133 // `PlacesSyncUtils.bookmarks.fetch`. 134 fromSyncBookmark(item) { 135 this.parentid = item.parentRecordId; 136 this.parentName = item.parentTitle; 137 if (item.dateAdded) { 138 this.dateAdded = item.dateAdded; 139 } 140 }, 141 }; 142 143 Object.setPrototypeOf(PlacesItem.prototype, CryptoWrapper.prototype); 144 145 Utils.deferGetSet(PlacesItem, "cleartext", [ 146 "hasDupe", 147 "parentid", 148 "parentName", 149 "type", 150 "dateAdded", 151 ]); 152 153 export function Bookmark(collection, id, type) { 154 PlacesItem.call(this, collection, id, type || "bookmark"); 155 } 156 157 Bookmark.prototype = { 158 _logName: "Sync.Record.Bookmark", 159 160 toSyncBookmark() { 161 let info = PlacesItem.prototype.toSyncBookmark.call(this); 162 info.title = this.title; 163 info.url = this.bmkUri; 164 info.description = this.description; 165 info.tags = this.tags; 166 info.keyword = this.keyword; 167 return info; 168 }, 169 170 fromSyncBookmark(item) { 171 PlacesItem.prototype.fromSyncBookmark.call(this, item); 172 this.title = item.title; 173 this.bmkUri = item.url.href; 174 this.description = item.description; 175 this.tags = item.tags; 176 this.keyword = item.keyword; 177 }, 178 }; 179 180 Object.setPrototypeOf(Bookmark.prototype, PlacesItem.prototype); 181 182 Utils.deferGetSet(Bookmark, "cleartext", [ 183 "title", 184 "bmkUri", 185 "description", 186 "tags", 187 "keyword", 188 ]); 189 190 export function BookmarkQuery(collection, id) { 191 Bookmark.call(this, collection, id, "query"); 192 } 193 194 BookmarkQuery.prototype = { 195 _logName: "Sync.Record.BookmarkQuery", 196 197 toSyncBookmark() { 198 let info = Bookmark.prototype.toSyncBookmark.call(this); 199 info.folder = this.folderName || undefined; // empty string -> undefined 200 info.query = this.queryId; 201 return info; 202 }, 203 204 fromSyncBookmark(item) { 205 Bookmark.prototype.fromSyncBookmark.call(this, item); 206 this.folderName = item.folder || undefined; // empty string -> undefined 207 this.queryId = item.query; 208 }, 209 }; 210 211 Object.setPrototypeOf(BookmarkQuery.prototype, Bookmark.prototype); 212 213 Utils.deferGetSet(BookmarkQuery, "cleartext", ["folderName", "queryId"]); 214 215 export function BookmarkFolder(collection, id, type) { 216 PlacesItem.call(this, collection, id, type || "folder"); 217 } 218 219 BookmarkFolder.prototype = { 220 _logName: "Sync.Record.Folder", 221 222 toSyncBookmark() { 223 let info = PlacesItem.prototype.toSyncBookmark.call(this); 224 info.description = this.description; 225 info.title = this.title; 226 return info; 227 }, 228 229 fromSyncBookmark(item) { 230 PlacesItem.prototype.fromSyncBookmark.call(this, item); 231 this.title = item.title; 232 this.description = item.description; 233 this.children = item.childRecordIds; 234 }, 235 }; 236 237 Object.setPrototypeOf(BookmarkFolder.prototype, PlacesItem.prototype); 238 239 Utils.deferGetSet(BookmarkFolder, "cleartext", [ 240 "description", 241 "title", 242 "children", 243 ]); 244 245 export function Livemark(collection, id) { 246 BookmarkFolder.call(this, collection, id, "livemark"); 247 } 248 249 Livemark.prototype = { 250 _logName: "Sync.Record.Livemark", 251 252 toSyncBookmark() { 253 let info = BookmarkFolder.prototype.toSyncBookmark.call(this); 254 info.feed = this.feedUri; 255 info.site = this.siteUri; 256 return info; 257 }, 258 259 fromSyncBookmark(item) { 260 BookmarkFolder.prototype.fromSyncBookmark.call(this, item); 261 this.feedUri = item.feed.href; 262 if (item.site) { 263 this.siteUri = item.site.href; 264 } 265 }, 266 }; 267 268 Object.setPrototypeOf(Livemark.prototype, BookmarkFolder.prototype); 269 270 Utils.deferGetSet(Livemark, "cleartext", ["siteUri", "feedUri"]); 271 272 export function BookmarkSeparator(collection, id) { 273 PlacesItem.call(this, collection, id, "separator"); 274 } 275 276 BookmarkSeparator.prototype = { 277 _logName: "Sync.Record.Separator", 278 279 fromSyncBookmark(item) { 280 PlacesItem.prototype.fromSyncBookmark.call(this, item); 281 this.pos = item.index; 282 }, 283 }; 284 285 Object.setPrototypeOf(BookmarkSeparator.prototype, PlacesItem.prototype); 286 287 Utils.deferGetSet(BookmarkSeparator, "cleartext", "pos"); 288 289 /** 290 * The bookmarks engine uses a different store that stages downloaded bookmarks 291 * in a separate database, instead of writing directly to Places. The buffer 292 * handles reconciliation, so we stub out `_reconcile`, and wait to pull changes 293 * until we're ready to upload. 294 */ 295 export function BookmarksEngine(service) { 296 SyncEngine.call(this, "Bookmarks", service); 297 } 298 299 BookmarksEngine.prototype = { 300 _recordObj: PlacesItem, 301 _trackerObj: BookmarksTracker, 302 _storeObj: BookmarksStore, 303 version: 2, 304 // Used to override the engine name in telemetry, so that we can distinguish 305 // this engine from the old, now removed non-buffered engine. 306 overrideTelemetryName: "bookmarks-buffered", 307 308 // Needed to ensure we don't miss items when resuming a sync that failed or 309 // aborted early. 310 _defaultSort: "oldest", 311 312 syncPriority: 4, 313 allowSkippedRecord: false, 314 315 async _ensureCurrentSyncID(newSyncID) { 316 await lazy.PlacesSyncUtils.bookmarks.ensureCurrentSyncId(newSyncID); 317 let buf = await this._store.ensureOpenMirror(); 318 await buf.ensureCurrentSyncId(newSyncID); 319 }, 320 321 async ensureCurrentSyncID(newSyncID) { 322 let shouldWipeRemote = 323 await lazy.PlacesSyncUtils.bookmarks.shouldWipeRemote(); 324 if (!shouldWipeRemote) { 325 this._log.debug( 326 "Checking if server sync ID ${newSyncID} matches existing", 327 { newSyncID } 328 ); 329 await this._ensureCurrentSyncID(newSyncID); 330 return newSyncID; 331 } 332 // We didn't take the new sync ID because we need to wipe the server 333 // and other clients after a restore. Send the command, wipe the 334 // server, and reset our sync ID to reupload everything. 335 this._log.debug( 336 "Ignoring server sync ID ${newSyncID} after restore; " + 337 "wiping server and resetting sync ID", 338 { newSyncID } 339 ); 340 await this.service.clientsEngine.sendCommand( 341 "wipeEngine", 342 [this.name], 343 null, 344 { reason: "bookmark-restore" } 345 ); 346 let assignedSyncID = await this.resetSyncID(); 347 return assignedSyncID; 348 }, 349 350 async getSyncID() { 351 return lazy.PlacesSyncUtils.bookmarks.getSyncId(); 352 }, 353 354 async resetSyncID() { 355 await this._deleteServerCollection(); 356 return this.resetLocalSyncID(); 357 }, 358 359 async resetLocalSyncID() { 360 let newSyncID = await lazy.PlacesSyncUtils.bookmarks.resetSyncId(); 361 this._log.debug("Assigned new sync ID ${newSyncID}", { newSyncID }); 362 let buf = await this._store.ensureOpenMirror(); 363 await buf.ensureCurrentSyncId(newSyncID); 364 return newSyncID; 365 }, 366 367 async getLastSync() { 368 let mirror = await this._store.ensureOpenMirror(); 369 return mirror.getCollectionHighWaterMark(); 370 }, 371 372 async setLastSync(lastSync) { 373 let mirror = await this._store.ensureOpenMirror(); 374 await mirror.setCollectionLastModified(lastSync); 375 // Update the last sync time in Places so that reverting to the original 376 // bookmarks engine doesn't download records we've already applied. 377 await lazy.PlacesSyncUtils.bookmarks.setLastSync(lastSync); 378 }, 379 380 async _syncStartup() { 381 await super._syncStartup(); 382 383 try { 384 // For first syncs, back up the user's bookmarks. 385 let lastSync = await this.getLastSync(); 386 if (!lastSync) { 387 this._log.debug("Bookmarks backup starting"); 388 await lazy.PlacesBackups.create(null, true); 389 this._log.debug("Bookmarks backup done"); 390 } 391 } catch (ex) { 392 // Failure to create a backup is somewhat bad, but probably not bad 393 // enough to prevent syncing of bookmarks - so just log the error and 394 // continue. 395 this._log.warn( 396 "Error while backing up bookmarks, but continuing with sync", 397 ex 398 ); 399 } 400 }, 401 402 async _sync() { 403 try { 404 await super._sync(); 405 if (this._ranMaintenanceOnLastSync) { 406 // If the last sync failed, we ran maintenance, and this sync succeeded, 407 // maintenance likely fixed the issue. 408 this._ranMaintenanceOnLastSync = false; 409 Glean.sync.maintenanceFixBookmarks.record(); 410 this.service.recordTelemetryEvent("maintenance", "fix", "bookmarks"); 411 } 412 } catch (ex) { 413 if ( 414 lazy.Async.isShutdownException(ex) || 415 ex.status > 0 || 416 ex.name == "InterruptedError" 417 ) { 418 // Don't run maintenance on shutdown or HTTP errors, or if we aborted 419 // the sync because the user changed their bookmarks during merging. 420 throw ex; 421 } 422 if (ex.name == "MergeConflictError") { 423 this._log.warn( 424 "Bookmark syncing ran into a merge conflict error...will retry later" 425 ); 426 return; 427 } 428 // Run Places maintenance periodically to try to recover from corruption 429 // that might have caused the sync to fail. We cap the interval because 430 // persistent failures likely indicate a problem that won't be fixed by 431 // running maintenance after every failed sync. 432 let elapsedSinceMaintenance = 433 Date.now() / 1000 - 434 Services.prefs.getIntPref("places.database.lastMaintenance", 0); 435 if (elapsedSinceMaintenance >= PLACES_MAINTENANCE_INTERVAL_SECONDS) { 436 this._log.error( 437 "Bookmark sync failed, ${elapsedSinceMaintenance}s " + 438 "elapsed since last run; running Places maintenance", 439 { elapsedSinceMaintenance } 440 ); 441 await lazy.PlacesDBUtils.maintenanceOnIdle(); 442 this._ranMaintenanceOnLastSync = true; 443 Glean.sync.maintenanceRunBookmarks.record(); 444 this.service.recordTelemetryEvent("maintenance", "run", "bookmarks"); 445 } else { 446 this._ranMaintenanceOnLastSync = false; 447 } 448 throw ex; 449 } 450 }, 451 452 async _syncFinish() { 453 await SyncEngine.prototype._syncFinish.call(this); 454 await lazy.PlacesSyncUtils.bookmarks.ensureMobileQuery(); 455 }, 456 457 async pullAllChanges() { 458 return this.pullNewChanges(); 459 }, 460 461 async trackRemainingChanges() { 462 let changes = this._modified.changes; 463 await lazy.PlacesSyncUtils.bookmarks.pushChanges(changes); 464 }, 465 466 _deleteId(id) { 467 this._noteDeletedId(id); 468 }, 469 470 // The bookmarks engine rarely calls this method directly, except in tests or 471 // when handling a `reset{All, Engine}` command from another client. We 472 // usually reset local Sync metadata on a sync ID mismatch, which both engines 473 // override with logic that lives in Places and the mirror. 474 async _resetClient() { 475 await super._resetClient(); 476 await lazy.PlacesSyncUtils.bookmarks.reset(); 477 let buf = await this._store.ensureOpenMirror(); 478 await buf.reset(); 479 }, 480 481 // Cleans up the Places root, reading list items (ignored in bug 762118, 482 // removed in bug 1155684), and pinned sites. 483 _shouldDeleteRemotely(incomingItem) { 484 return ( 485 FORBIDDEN_INCOMING_IDS.includes(incomingItem.id) || 486 FORBIDDEN_INCOMING_PARENT_IDS.includes(incomingItem.parentid) 487 ); 488 }, 489 490 emptyChangeset() { 491 return new BookmarksChangeset(); 492 }, 493 494 async _apply() { 495 let buf = await this._store.ensureOpenMirror(); 496 let watchdog = this._newWatchdog(); 497 watchdog.start(BOOKMARK_APPLY_TIMEOUT_MS); 498 499 try { 500 let recordsToUpload = await buf.apply({ 501 remoteTimeSeconds: lazy.Resource.serverTime, 502 signal: watchdog.signal, 503 }); 504 this._modified.replace(recordsToUpload); 505 } finally { 506 watchdog.stop(); 507 if (watchdog.abortReason) { 508 this._log.warn(`Aborting bookmark merge: ${watchdog.abortReason}`); 509 } 510 } 511 }, 512 513 async _processIncoming(newitems) { 514 await super._processIncoming(newitems); 515 await this._apply(); 516 }, 517 518 async _reconcile() { 519 return true; 520 }, 521 522 async _createRecord(id) { 523 let record = await this._doCreateRecord(id); 524 if (!record.deleted) { 525 // Set hasDupe on all (non-deleted) records since we don't use it and we 526 // want to minimize the risk of older clients corrupting records. Note 527 // that the SyncedBookmarksMirror sets it for all records that it created, 528 // but we would like to ensure that weakly uploaded records are marked as 529 // hasDupe as well. 530 record.hasDupe = true; 531 } 532 return record; 533 }, 534 535 async _doCreateRecord(id) { 536 let change = this._modified.changes[id]; 537 if (!change) { 538 this._log.error( 539 "Creating record for item ${id} not in strong changeset", 540 { id } 541 ); 542 throw new TypeError("Can't create record for unchanged item"); 543 } 544 let record = this._recordFromCleartext(id, change.cleartext); 545 record.sortindex = await this._store._calculateIndex(record); 546 return record; 547 }, 548 549 _recordFromCleartext(id, cleartext) { 550 let recordObj = getTypeObject(cleartext.type); 551 if (!recordObj) { 552 this._log.warn( 553 "Creating record for item ${id} with unknown type ${type}", 554 { id, type: cleartext.type } 555 ); 556 recordObj = PlacesItem; 557 } 558 let record = new recordObj(this.name, id); 559 record.cleartext = cleartext; 560 return record; 561 }, 562 563 async pullChanges() { 564 return {}; 565 }, 566 567 /** 568 * Writes successfully uploaded records back to the mirror, so that the 569 * mirror matches the server. We update the mirror before updating Places, 570 * which has implications for interrupted syncs. 571 * 572 * 1. Sync interrupted during upload; server doesn't support atomic uploads. 573 * We'll download and reapply everything that we uploaded before the 574 * interruption. All locally changed items retain their change counters. 575 * 2. Sync interrupted during upload; atomic uploads enabled. The server 576 * discards the batch. All changed local items retain their change 577 * counters, so the next sync resumes cleanly. 578 * 3. Sync interrupted during upload; outgoing records can't fit in a single 579 * batch. We'll download and reapply all records through the most recent 580 * committed batch. This is a variation of (1). 581 * 4. Sync interrupted after we update the mirror, but before cleanup. The 582 * mirror matches the server, but locally changed items retain their change 583 * counters. Reuploading them on the next sync should be idempotent, though 584 * unnecessary. If another client makes a conflicting remote change before 585 * we sync again, we may incorrectly prefer the local state. 586 * 5. Sync completes successfully. We'll update the mirror, and reset the 587 * change counters for all items. 588 */ 589 async _onRecordsWritten(succeeded, failed, serverModifiedTime) { 590 let records = []; 591 for (let id of succeeded) { 592 let change = this._modified.changes[id]; 593 if (!change) { 594 // TODO (Bug 1433178): Write weakly uploaded records back to the mirror. 595 this._log.info("Uploaded record not in strong changeset", id); 596 continue; 597 } 598 if (!change.synced) { 599 this._log.info("Record in strong changeset not uploaded", id); 600 continue; 601 } 602 let cleartext = change.cleartext; 603 if (!cleartext) { 604 this._log.error( 605 "Missing Sync record cleartext for ${id} in ${change}", 606 { id, change } 607 ); 608 throw new TypeError("Missing cleartext for uploaded Sync record"); 609 } 610 let record = this._recordFromCleartext(id, cleartext); 611 record.modified = serverModifiedTime; 612 records.push(record); 613 } 614 let buf = await this._store.ensureOpenMirror(); 615 await buf.store(records, { needsMerge: false }); 616 }, 617 618 async finalize() { 619 await super.finalize(); 620 await this._store.finalize(); 621 }, 622 }; 623 624 Object.setPrototypeOf(BookmarksEngine.prototype, SyncEngine.prototype); 625 626 /** 627 * The bookmarks store delegates to the mirror for staging and applying 628 * records. Most `Store` methods intentionally remain abstract, so you can't use 629 * this store to create or update bookmarks in Places. All changes must go 630 * through the mirror, which takes care of merging and producing a valid tree. 631 */ 632 function BookmarksStore(name, engine) { 633 Store.call(this, name, engine); 634 } 635 636 BookmarksStore.prototype = { 637 _openMirrorPromise: null, 638 639 // For tests. 640 _batchChunkSize: 500, 641 642 // Create a record starting from the weave id (places guid) 643 async createRecord(id, collection) { 644 let item = await lazy.PlacesSyncUtils.bookmarks.fetch(id); 645 if (!item) { 646 // deleted item 647 let record = new PlacesItem(collection, id); 648 record.deleted = true; 649 return record; 650 } 651 652 let recordObj = getTypeObject(item.kind); 653 if (!recordObj) { 654 this._log.warn("Unknown item type, cannot serialize: " + item.kind); 655 recordObj = PlacesItem; 656 } 657 let record = new recordObj(collection, id); 658 record.fromSyncBookmark(item); 659 660 record.sortindex = await this._calculateIndex(record); 661 662 return record; 663 }, 664 665 async _calculateIndex(record) { 666 // Ensure folders have a very high sort index so they're not synced last. 667 if (record.type == "folder") { 668 return FOLDER_SORTINDEX; 669 } 670 671 // For anything directly under the toolbar, give it a boost of more than an 672 // unvisited bookmark 673 let index = 0; 674 if (record.parentid == "toolbar") { 675 index += 150; 676 } 677 678 // Add in the bookmark's frecency if we have something. 679 if (record.bmkUri != null) { 680 let frecency = FRECENCY_UNKNOWN; 681 try { 682 frecency = await lazy.PlacesSyncUtils.history.fetchURLFrecency( 683 record.bmkUri 684 ); 685 } catch (ex) { 686 this._log.warn( 687 `Failed to fetch frecency for ${record.id}; assuming default`, 688 ex 689 ); 690 this._log.trace("Record {id} has invalid URL ${bmkUri}", record); 691 } 692 if (frecency != FRECENCY_UNKNOWN) { 693 index += frecency; 694 } 695 } 696 697 return index; 698 }, 699 700 async wipe() { 701 // Save a backup before clearing out all bookmarks. 702 await lazy.PlacesBackups.create(null, true); 703 await lazy.PlacesSyncUtils.bookmarks.wipe(); 704 }, 705 706 ensureOpenMirror() { 707 if (!this._openMirrorPromise) { 708 this._openMirrorPromise = this._openMirror().catch(err => { 709 // We may have failed to open the mirror temporarily; for example, if 710 // the database is locked. Clear the promise so that subsequent 711 // `ensureOpenMirror` calls can try to open the mirror again. 712 this._openMirrorPromise = null; 713 throw err; 714 }); 715 } 716 return this._openMirrorPromise; 717 }, 718 719 async _openMirror() { 720 let mirrorPath = PathUtils.join( 721 PathUtils.profileDir, 722 "weave", 723 "bookmarks.sqlite" 724 ); 725 await IOUtils.makeDirectory(PathUtils.parent(mirrorPath), { 726 createAncestors: true, 727 }); 728 729 return lazy.SyncedBookmarksMirror.open({ 730 path: mirrorPath, 731 recordStepTelemetry: (name, took, counts) => { 732 lazy.Observers.notify( 733 "weave:engine:sync:step", 734 { 735 name, 736 took, 737 counts, 738 }, 739 this.name 740 ); 741 }, 742 recordValidationTelemetry: (took, checked, problems) => { 743 lazy.Observers.notify( 744 "weave:engine:validate:finish", 745 { 746 version: BOOKMARK_VALIDATOR_VERSION, 747 took, 748 checked, 749 problems, 750 }, 751 this.name 752 ); 753 }, 754 }); 755 }, 756 757 async applyIncomingBatch(records) { 758 let buf = await this.ensureOpenMirror(); 759 for (let chunk of lazy.PlacesUtils.chunkArray( 760 records, 761 this._batchChunkSize 762 )) { 763 await buf.store(chunk); 764 } 765 // Array of failed records. 766 return []; 767 }, 768 769 async applyIncoming(record) { 770 let buf = await this.ensureOpenMirror(); 771 await buf.store([record]); 772 }, 773 774 async finalize() { 775 if (!this._openMirrorPromise) { 776 return; 777 } 778 let buf = await this._openMirrorPromise; 779 await buf.finalize(); 780 }, 781 }; 782 783 Object.setPrototypeOf(BookmarksStore.prototype, Store.prototype); 784 785 // The bookmarks tracker is a special flower. Instead of listening for changes 786 // via observer notifications, it queries Places for the set of items that have 787 // changed since the last sync. Because it's a "pull-based" tracker, it ignores 788 // all concepts of "add a changed ID." However, it still registers an observer 789 // to bump the score, so that changed bookmarks are synced immediately. 790 function BookmarksTracker(name, engine) { 791 Tracker.call(this, name, engine); 792 } 793 BookmarksTracker.prototype = { 794 onStart() { 795 this._placesListener = new PlacesWeakCallbackWrapper( 796 this.handlePlacesEvents.bind(this) 797 ); 798 lazy.PlacesUtils.observers.addListener( 799 [ 800 "bookmark-added", 801 "bookmark-removed", 802 "bookmark-moved", 803 "bookmark-guid-changed", 804 "bookmark-keyword-changed", 805 "bookmark-tags-changed", 806 "bookmark-time-changed", 807 "bookmark-title-changed", 808 "bookmark-url-changed", 809 ], 810 this._placesListener 811 ); 812 Svc.Obs.add("bookmarks-restore-begin", this); 813 Svc.Obs.add("bookmarks-restore-success", this); 814 Svc.Obs.add("bookmarks-restore-failed", this); 815 }, 816 817 onStop() { 818 lazy.PlacesUtils.observers.removeListener( 819 [ 820 "bookmark-added", 821 "bookmark-removed", 822 "bookmark-moved", 823 "bookmark-guid-changed", 824 "bookmark-keyword-changed", 825 "bookmark-tags-changed", 826 "bookmark-time-changed", 827 "bookmark-title-changed", 828 "bookmark-url-changed", 829 ], 830 this._placesListener 831 ); 832 Svc.Obs.remove("bookmarks-restore-begin", this); 833 Svc.Obs.remove("bookmarks-restore-success", this); 834 Svc.Obs.remove("bookmarks-restore-failed", this); 835 }, 836 837 async getChangedIDs() { 838 return lazy.PlacesSyncUtils.bookmarks.pullChanges(); 839 }, 840 841 observe(subject, topic, data) { 842 switch (topic) { 843 case "bookmarks-restore-begin": 844 this._log.debug("Ignoring changes from importing bookmarks."); 845 break; 846 case "bookmarks-restore-success": 847 this._log.debug("Tracking all items on successful import."); 848 849 if (data == "json") { 850 this._log.debug( 851 "Restore succeeded: wiping server and other clients." 852 ); 853 // Trigger an immediate sync. `ensureCurrentSyncID` will notice we 854 // restored, wipe the server and other clients, reset the sync ID, and 855 // upload the restored tree. 856 this.score += SCORE_INCREMENT_XLARGE; 857 } else { 858 // "html", "html-initial", or "json-append" 859 this._log.debug("Import succeeded."); 860 } 861 break; 862 case "bookmarks-restore-failed": 863 this._log.debug("Tracking all items on failed import."); 864 break; 865 } 866 }, 867 868 QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]), 869 870 /* Every add/remove/change will trigger a sync for MULTI_DEVICE */ 871 _upScore: function BMT__upScore() { 872 this.score += SCORE_INCREMENT_XLARGE; 873 }, 874 875 handlePlacesEvents(events) { 876 for (let event of events) { 877 switch (event.type) { 878 case "bookmark-added": 879 case "bookmark-removed": 880 case "bookmark-moved": 881 case "bookmark-keyword-changed": 882 case "bookmark-tags-changed": 883 case "bookmark-time-changed": 884 case "bookmark-title-changed": 885 case "bookmark-url-changed": 886 if (lazy.IGNORED_SOURCES.includes(event.source)) { 887 continue; 888 } 889 890 this._log.trace(`'${event.type}': ${event.id}`); 891 this._upScore(); 892 break; 893 case "bookmark-guid-changed": 894 if (event.source !== lazy.PlacesUtils.bookmarks.SOURCES.SYNC) { 895 this._log.warn( 896 "The source of bookmark-guid-changed event shoud be sync." 897 ); 898 continue; 899 } 900 901 this._log.trace(`'${event.type}': ${event.id}`); 902 this._upScore(); 903 break; 904 case "purge-caches": 905 this._log.trace("purge-caches"); 906 this._upScore(); 907 break; 908 } 909 } 910 }, 911 }; 912 913 Object.setPrototypeOf(BookmarksTracker.prototype, Tracker.prototype); 914 915 /** 916 * A changeset that stores extra metadata in a change record for each ID. The 917 * engine updates this metadata when uploading Sync records, and writes it back 918 * to Places in `BookmarksEngine#trackRemainingChanges`. 919 * 920 * The `synced` property on a change record means its corresponding item has 921 * been uploaded, and we should pretend it doesn't exist in the changeset. 922 */ 923 class BookmarksChangeset extends Changeset { 924 // Only `_reconcile` calls `getModifiedTimestamp` and `has`, and the engine 925 // does its own reconciliation. 926 getModifiedTimestamp() { 927 throw new Error("Don't use timestamps to resolve bookmark conflicts"); 928 } 929 930 has() { 931 throw new Error("Don't use the changeset to resolve bookmark conflicts"); 932 } 933 934 delete(id) { 935 let change = this.changes[id]; 936 if (change) { 937 // Mark the change as synced without removing it from the set. We do this 938 // so that we can update Places in `trackRemainingChanges`. 939 change.synced = true; 940 } 941 } 942 943 ids() { 944 let results = new Set(); 945 for (let id in this.changes) { 946 if (!this.changes[id].synced) { 947 results.add(id); 948 } 949 } 950 return [...results]; 951 } 952 }