tabs.sys.mjs (21467B)
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 const STORAGE_VERSION = 1; // This needs to be kept in-sync with the rust storage version 6 7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 8 import { SyncEngine, Tracker } from "resource://services-sync/engines.sys.mjs"; 9 import { Svc, Utils } from "resource://services-sync/util.sys.mjs"; 10 import { Log } from "resource://gre/modules/Log.sys.mjs"; 11 import { 12 SCORE_INCREMENT_SMALL, 13 STATUS_OK, 14 URI_LENGTH_MAX, 15 } from "resource://services-sync/constants.sys.mjs"; 16 import { CommonUtils } from "resource://services-common/utils.sys.mjs"; 17 import { Async } from "resource://services-common/async.sys.mjs"; 18 import { 19 SyncRecord, 20 SyncTelemetry, 21 } from "resource://services-sync/telemetry.sys.mjs"; 22 import { BridgedEngine } from "resource://services-sync/bridged_engine.sys.mjs"; 23 24 const FAR_FUTURE = 4102405200000; // 2100/01/01 25 26 const lazy = {}; 27 28 ChromeUtils.defineESModuleGetters(lazy, { 29 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 30 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 31 ReaderMode: "moz-src:///toolkit/components/reader/ReaderMode.sys.mjs", 32 getTabsStore: "resource://services-sync/TabsStore.sys.mjs", 33 RemoteTabRecord: 34 "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustTabs.sys.mjs", 35 setupLoggerForTarget: "resource://gre/modules/AppServicesTracing.sys.mjs", 36 }); 37 38 XPCOMUtils.defineLazyPreferenceGetter( 39 lazy, 40 "TABS_FILTERED_SCHEMES", 41 "services.sync.engine.tabs.filteredSchemes", 42 "", 43 null, 44 val => { 45 return new Set(val.split("|")); 46 } 47 ); 48 49 XPCOMUtils.defineLazyPreferenceGetter( 50 lazy, 51 "SYNC_AFTER_DELAY_MS", 52 "services.sync.syncedTabs.syncDelayAfterTabChange", 53 0 54 ); 55 56 // A "bridged engine" to our tabs component. 57 export function TabEngine(service) { 58 BridgedEngine.call(this, "Tabs", service); 59 } 60 61 TabEngine.prototype = { 62 _trackerObj: TabTracker, 63 syncPriority: 3, 64 65 async prepareTheBridge(isQuickWrite) { 66 let clientsEngine = this.service.clientsEngine; 67 // Tell the bridged engine about clients. 68 // This is the same shape as ClientData in app-services. 69 // schema: https://github.com/mozilla/application-services/blob/a1168751231ed4e88c44d85f6dccc09c3b412bd2/components/sync15/src/client_types.rs#L14 70 let clientData = { 71 local_client_id: clientsEngine.localID, 72 recent_clients: {}, 73 }; 74 75 // We shouldn't upload tabs past what the server will accept 76 let tabs = await this.getTabsWithinPayloadSize(); 77 await this._rustStore.setLocalTabs( 78 tabs.map(tab => { 79 // rust wants lastUsed in MS but the provider gives it in seconds 80 tab.lastUsed = tab.lastUsed * 1000; 81 return new lazy.RemoteTabRecord(tab); 82 }) 83 ); 84 85 for (let remoteClient of clientsEngine.remoteClients) { 86 let id = remoteClient.id; 87 if (!id) { 88 throw new Error("Remote client somehow did not have an id"); 89 } 90 let client = { 91 fxa_device_id: remoteClient.fxaDeviceId, 92 // device_name and device_type are soft-deprecated - every client 93 // prefers what's in the FxA record. But fill them correctly anyway. 94 device_name: clientsEngine.getClientName(id) ?? "", 95 device_type: clientsEngine.getClientType(id), 96 }; 97 clientData.recent_clients[id] = client; 98 } 99 100 // put ourself in there too so we record the correct device info in our sync record. 101 clientData.recent_clients[clientsEngine.localID] = { 102 fxa_device_id: await clientsEngine.fxAccounts.device.getLocalId(), 103 device_name: clientsEngine.localName, 104 device_type: clientsEngine.localType, 105 }; 106 107 // Quick write needs to adjust the lastSync so we can POST to the server 108 // see quickWrite() for details 109 if (isQuickWrite) { 110 await this.setLastSync(FAR_FUTURE); 111 await this._bridge.prepareForSync(JSON.stringify(clientData)); 112 return; 113 } 114 115 // Just incase we crashed while the lastSync timestamp was FAR_FUTURE, we 116 // reset it to zero 117 if ((await this.getLastSync()) === FAR_FUTURE) { 118 await this._bridge.setLastSync(0); 119 } 120 await this._bridge.prepareForSync(JSON.stringify(clientData)); 121 }, 122 123 async _syncStartup() { 124 await super._syncStartup(); 125 await this.prepareTheBridge(); 126 }, 127 128 async initialize() { 129 await SyncEngine.prototype.initialize.call(this); 130 131 lazy.setupLoggerForTarget("tabs", "Sync.Engine.Tabs"); 132 // highlights problems with simple logs - we get everyone's sql-support 133 lazy.setupLoggerForTarget("sql_support", "Sync.Engine.Tabs"); 134 this._rustStore = await lazy.getTabsStore(); 135 this._bridge = await this._rustStore.bridgedEngine(); 136 137 // Uniffi doesn't currently only support async methods, so we'll need to hardcode 138 // these values for now (which is fine for now as these hardly ever change) 139 this._bridge.storageVersion = STORAGE_VERSION; 140 this._bridge.allowSkippedRecord = true; 141 142 this._log.info("Got a bridged engine!"); 143 this._tracker.modified = true; 144 }, 145 146 async getChangedIDs() { 147 // No need for a proper timestamp (no conflict resolution needed). 148 let changedIDs = {}; 149 if (this._tracker.modified) { 150 changedIDs[this.service.clientsEngine.localID] = 0; 151 } 152 return changedIDs; 153 }, 154 155 // API for use by Sync UI code to give user choices of tabs to open. 156 async getAllClients() { 157 let remoteTabs = await this._rustStore.getAll(); 158 let remoteClientTabs = []; 159 for (let remoteClient of this.service.clientsEngine.remoteClients) { 160 // We get the some client info from the rust tabs engine and some from 161 // the clients engine. 162 let rustClient = remoteTabs.find( 163 x => x.clientId === remoteClient.fxaDeviceId 164 ); 165 if (!rustClient) { 166 continue; 167 } 168 let client = { 169 // rust gives us ms but js uses seconds, so fix them up. 170 tabs: rustClient.remoteTabs.map(tab => { 171 tab.lastUsed = tab.lastUsed / 1000; 172 return tab; 173 }), 174 lastModified: rustClient.lastModified / 1000, 175 ...remoteClient, 176 }; 177 remoteClientTabs.push(client); 178 } 179 return remoteClientTabs; 180 }, 181 182 async removeClientData() { 183 let url = this.engineURL + "/" + this.service.clientsEngine.localID; 184 await this.service.resource(url).delete(); 185 }, 186 187 async trackRemainingChanges() { 188 if (this._modified.count() > 0) { 189 this._tracker.modified = true; 190 } 191 }, 192 193 async getTabsWithinPayloadSize() { 194 const maxPayloadSize = this.service.getMaxRecordPayloadSize(); 195 // See bug 535326 comment 8 for an explanation of the estimation 196 const maxSerializedSize = (maxPayloadSize / 4) * 3 - 1500; 197 return TabProvider.getAllTabsWithEstimatedMax(true, maxSerializedSize); 198 }, 199 200 // Support for "quick writes" 201 _engineLock: Utils.lock, 202 _engineLocked: false, 203 204 // Tabs has a special lock to help support its "quick write" 205 get locked() { 206 return this._engineLocked; 207 }, 208 lock() { 209 if (this._engineLocked) { 210 return false; 211 } 212 this._engineLocked = true; 213 return true; 214 }, 215 unlock() { 216 this._engineLocked = false; 217 }, 218 219 // Quickly do a POST of our current tabs if possible. 220 // This does things that would be dangerous for other engines - eg, posting 221 // without checking what's on the server could cause data-loss for other 222 // engines, but because each device exclusively owns exactly 1 tabs record 223 // with a known ID, it's safe here. 224 // Returns true if we successfully synced, false otherwise (either on error 225 // or because we declined to sync for any reason.) The return value is 226 // primarily for tests. 227 async quickWrite() { 228 if (!this.enabled) { 229 // this should be very rare, and only if tabs are disabled after the 230 // timer is created. 231 this._log.info("Can't do a quick-sync as tabs is disabled"); 232 return false; 233 } 234 // This quick-sync doesn't drive the login state correctly, so just 235 // decline to sync if out status is bad 236 if (this.service.status.checkSetup() != STATUS_OK) { 237 this._log.info( 238 "Can't do a quick-sync due to the service status", 239 this.service.status.toString() 240 ); 241 return false; 242 } 243 if (!this.service.serverConfiguration) { 244 this._log.info("Can't do a quick sync before the first full sync"); 245 return false; 246 } 247 try { 248 return await this._engineLock("tabs.js: quickWrite", async () => { 249 // We want to restore the lastSync timestamp when complete so next sync 250 // takes tabs written by other devices since our last real sync. 251 // And for this POST we don't want the protections offered by 252 // X-If-Unmodified-Since - we want the POST to work even if the remote 253 // has moved on and we will catch back up next full sync. 254 const origLastSync = await this.getLastSync(); 255 try { 256 return this._doQuickWrite(); 257 } finally { 258 // set the lastSync to it's original value for regular sync 259 await this.setLastSync(origLastSync); 260 } 261 })(); 262 } catch (ex) { 263 if (!Utils.isLockException(ex)) { 264 throw ex; 265 } 266 this._log.info( 267 "Can't do a quick-write as another tab sync is in progress" 268 ); 269 return false; 270 } 271 }, 272 273 // The guts of the quick-write sync, after we've taken the lock, checked 274 // the service status etc. 275 async _doQuickWrite() { 276 // We need to track telemetry for these syncs too! 277 const name = "tabs"; 278 let telemetryRecord = new SyncRecord( 279 SyncTelemetry.allowedEngines, 280 "quick-write" 281 ); 282 telemetryRecord.onEngineStart(name); 283 try { 284 Async.checkAppReady(); 285 // We need to prep the bridge before we try to POST since it grabs 286 // the most recent local client id and properly sets a lastSync 287 // which is needed for a proper POST request 288 await this.prepareTheBridge(true); 289 this._tracker.clearChangedIDs(); 290 this._tracker.resetScore(); 291 292 Async.checkAppReady(); 293 // now just the "upload" part of a sync, 294 // which for a rust engine is not obvious. 295 // We need to do is ask the rust engine for the changes. Although 296 // this is kinda abusing the bridged-engine interface, we know the tabs 297 // implementation of it works ok 298 let outgoing = await this._bridge.apply(); 299 // We know we always have exactly 1 record. 300 let mine = outgoing[0]; 301 this._log.trace("outgoing bso", mine); 302 // `this._recordObj` is a `BridgedRecord`, which isn't exported. 303 let record = this._recordObj.fromOutgoingBso(this.name, JSON.parse(mine)); 304 let changeset = {}; 305 changeset[record.id] = { synced: false, record }; 306 this._modified.replace(changeset); 307 308 Async.checkAppReady(); 309 await this._uploadOutgoing(); 310 telemetryRecord.onEngineStop(name, null); 311 return true; 312 } catch (ex) { 313 this._log.warn("quicksync sync failed", ex); 314 telemetryRecord.onEngineStop(name, ex); 315 return false; 316 } finally { 317 // The top-level sync is never considered to fail here, just the engine 318 telemetryRecord.finished(null); 319 SyncTelemetry.takeTelemetryRecord(telemetryRecord); 320 } 321 }, 322 323 async _sync() { 324 try { 325 await this._engineLock("tabs.js: fullSync", async () => { 326 await super._sync(); 327 })(); 328 } catch (ex) { 329 if (!Utils.isLockException(ex)) { 330 throw ex; 331 } 332 this._log.info( 333 "Can't do full tabs sync as a quick-write is currently running" 334 ); 335 } 336 }, 337 }; 338 Object.setPrototypeOf(TabEngine.prototype, BridgedEngine.prototype); 339 340 export const TabProvider = { 341 getWindowEnumerator() { 342 return Services.wm.getEnumerator("navigator:browser"); 343 }, 344 345 shouldSkipWindow(win) { 346 return win.closed || lazy.PrivateBrowsingUtils.isWindowPrivate(win); 347 }, 348 349 getAllBrowserTabs() { 350 let tabs = []; 351 for (let win of this.getWindowEnumerator()) { 352 if (this.shouldSkipWindow(win)) { 353 continue; 354 } 355 // Get all the tabs from the browser 356 for (let tab of win.gBrowser.tabs) { 357 tabs.push(tab); 358 } 359 } 360 361 return tabs.sort(function (a, b) { 362 return b.lastAccessed - a.lastAccessed; 363 }); 364 }, 365 366 // This function creates tabs records up to a specified amount of bytes 367 // It is an "estimation" since we don't accurately calculate how much the 368 // favicon and JSON overhead is and give a rough estimate (for optimization purposes) 369 async getAllTabsWithEstimatedMax(filter, bytesMax) { 370 let log = Log.repository.getLogger(`Sync.Engine.Tabs.Provider`); 371 let tabRecords = []; 372 let iconPromises = []; 373 let runningByteLength = 0; 374 let encoder = new TextEncoder(); 375 376 // Fetch all the tabs the user has open 377 let winTabs = this.getAllBrowserTabs(); 378 379 for (let tab of winTabs) { 380 // We don't want to process any more tabs than we can sync 381 if (runningByteLength >= bytesMax) { 382 log.warn( 383 `Can't fit all tabs in sync payload: have ${winTabs.length}, 384 but can only fit ${tabRecords.length}.` 385 ); 386 break; 387 } 388 389 // Note that we used to sync "tab history" (ie, the "back button") state, 390 // but in practice this hasn't been used - only the current URI is of 391 // interest to clients. 392 // We stopped recording this in bug 1783991. 393 if (!tab?.linkedBrowser) { 394 continue; 395 } 396 let acceptable = !filter 397 ? url => url 398 : url => 399 url && 400 !lazy.TABS_FILTERED_SCHEMES.has(Services.io.extractScheme(url)); 401 402 let url = tab.linkedBrowser.currentURI?.spec; 403 // Special case for reader mode. 404 if (url && url.startsWith("about:reader?")) { 405 url = lazy.ReaderMode.getOriginalUrl(url); 406 } 407 // We ignore the tab completely if the current entry url is 408 // not acceptable (we need something accurate to open). 409 if (!acceptable(url)) { 410 continue; 411 } 412 413 if (url.length > URI_LENGTH_MAX) { 414 log.trace("Skipping over-long URL."); 415 continue; 416 } 417 418 let thisTab = new lazy.RemoteTabRecord({ 419 title: tab.linkedBrowser.contentTitle || "", 420 urlHistory: [url], 421 icon: "", 422 lastUsed: Math.floor((tab.lastAccessed || 0) / 1000), 423 }); 424 tabRecords.push(thisTab); 425 426 // we don't want to wait for each favicon to resolve to get the bytes 427 // so we estimate a conservative 100 chars for the favicon and json overhead 428 // Rust will further optimize and trim if we happened to be wildly off 429 runningByteLength += 430 encoder.encode(thisTab.title + thisTab.lastUsed + url).byteLength + 100; 431 432 // Use the favicon service for the icon url - we can wait for the promises at the end. 433 let iconPromise = lazy.PlacesUtils.favicons 434 .getFaviconForPage(lazy.PlacesUtils.toURI(url)) 435 .then(favicon => { 436 thisTab.icon = favicon.uri.spec; 437 }) 438 .catch(() => { 439 log.trace( 440 `Failed to fetch favicon for ${url}`, 441 thisTab.urlHistory[0] 442 ); 443 }); 444 iconPromises.push(iconPromise); 445 } 446 447 await Promise.allSettled(iconPromises); 448 return tabRecords; 449 }, 450 }; 451 452 function TabTracker(name, engine) { 453 Tracker.call(this, name, engine); 454 455 // Make sure "this" pointer is always set correctly for event listeners. 456 this.onTab = Utils.bind2(this, this.onTab); 457 this._unregisterListeners = Utils.bind2(this, this._unregisterListeners); 458 } 459 TabTracker.prototype = { 460 QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), 461 462 clearChangedIDs() { 463 this.modified = false; 464 }, 465 466 // We do not track TabSelect because that almost always triggers 467 // the web progress listeners (onLocationChange), which we already track 468 _topics: ["TabOpen", "TabClose"], 469 470 _registerListenersForWindow(window) { 471 this._log.trace("Registering tab listeners in window"); 472 for (let topic of this._topics) { 473 window.addEventListener(topic, this.onTab); 474 } 475 window.addEventListener("unload", this._unregisterListeners); 476 // If it's got a tab browser we can listen for things like navigation. 477 if (window.gBrowser) { 478 window.gBrowser.addProgressListener(this); 479 } 480 }, 481 482 _unregisterListeners(event) { 483 this._unregisterListenersForWindow(event.target); 484 }, 485 486 _unregisterListenersForWindow(window) { 487 this._log.trace("Removing tab listeners in window"); 488 window.removeEventListener("unload", this._unregisterListeners); 489 for (let topic of this._topics) { 490 window.removeEventListener(topic, this.onTab); 491 } 492 if (window.gBrowser) { 493 window.gBrowser.removeProgressListener(this); 494 } 495 }, 496 497 onStart() { 498 Svc.Obs.add("domwindowopened", this.asyncObserver); 499 for (let win of Services.wm.getEnumerator("navigator:browser")) { 500 this._registerListenersForWindow(win); 501 } 502 }, 503 504 onStop() { 505 Svc.Obs.remove("domwindowopened", this.asyncObserver); 506 for (let win of Services.wm.getEnumerator("navigator:browser")) { 507 this._unregisterListenersForWindow(win); 508 } 509 }, 510 511 async observe(subject, topic) { 512 switch (topic) { 513 case "domwindowopened": { 514 let onLoad = () => { 515 subject.removeEventListener("load", onLoad); 516 // Only register after the window is done loading to avoid unloads. 517 this._registerListenersForWindow(subject); 518 }; 519 520 // Add tab listeners now that a window has opened. 521 subject.addEventListener("load", onLoad); 522 break; 523 } 524 } 525 }, 526 527 onTab(event) { 528 if (event.originalTarget.linkedBrowser) { 529 let browser = event.originalTarget.linkedBrowser; 530 if ( 531 lazy.PrivateBrowsingUtils.isBrowserPrivate(browser) && 532 !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing 533 ) { 534 this._log.trace("Ignoring tab event from private browsing."); 535 return; 536 } 537 } 538 this._log.trace("onTab event: " + event.type); 539 540 switch (event.type) { 541 case "TabOpen": 542 /* We do not have a reliable way of checking the URI on the TabOpen 543 * so we will rely on the other methods (onLocationChange, getAllTabsWithEstimatedMax) 544 * to filter these when going through sync 545 */ 546 this.callScheduleSync(SCORE_INCREMENT_SMALL); 547 break; 548 case "TabClose": { 549 // If event target has `linkedBrowser`, the event target can be assumed <tab> element. 550 // Else, event target is assumed <browser> element, use the target as it is. 551 const tab = event.target.linkedBrowser || event.target; 552 553 // TabClose means the tab has already loaded and we can check the URI 554 // and ignore if it's a scheme we don't care about 555 if (lazy.TABS_FILTERED_SCHEMES.has(tab.currentURI.scheme)) { 556 return; 557 } 558 this.callScheduleSync(SCORE_INCREMENT_SMALL); 559 break; 560 } 561 } 562 }, 563 564 // web progress listeners. 565 onLocationChange(webProgress, request, locationURI, flags) { 566 // We only care about top-level location changes. We do want location changes in the 567 // same document because if a page uses the `pushState()` API, they *appear* as though 568 // they are in the same document even if the URL changes. It also doesn't hurt to accurately 569 // reflect the fragment changing - so we allow LOCATION_CHANGE_SAME_DOCUMENT 570 if ( 571 flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD || 572 !webProgress.isTopLevel || 573 !locationURI 574 ) { 575 return; 576 } 577 578 // We can't filter out tabs that we don't sync here, because we might be 579 // navigating from a tab that we *did* sync to one we do not, and that 580 // tab we *did* sync should no longer be synced. 581 this.callScheduleSync(); 582 }, 583 584 callScheduleSync(scoreIncrement) { 585 this.modified = true; 586 let { scheduler } = this.engine.service; 587 let delayInMs = lazy.SYNC_AFTER_DELAY_MS; 588 589 // Schedule a sync once we detect a tab change 590 // to ensure the server always has the most up to date tabs 591 if ( 592 delayInMs > 0 && 593 scheduler.numClients > 1 // Only schedule quick syncs for multi client users 594 ) { 595 if (this.tabsQuickWriteTimer) { 596 this._log.debug( 597 "Detected a tab change, but a quick-write is already scheduled" 598 ); 599 return; 600 } 601 this._log.debug( 602 "Detected a tab change: scheduling a quick-write in " + delayInMs + "ms" 603 ); 604 CommonUtils.namedTimer( 605 () => { 606 this._log.trace("tab quick-sync timer fired."); 607 this.engine 608 .quickWrite() 609 .then(() => { 610 this._log.trace("tab quick-sync done."); 611 }) 612 .catch(ex => { 613 this._log.error("tab quick-sync failed.", ex); 614 }); 615 }, 616 delayInMs, 617 this, 618 "tabsQuickWriteTimer" 619 ); 620 } else if (scoreIncrement) { 621 this._log.debug( 622 "Detected a tab change, but conditions aren't met for a quick write - bumping score" 623 ); 624 this.score += scoreIncrement; 625 } else { 626 this._log.debug( 627 "Detected a tab change, but conditions aren't met for a quick write or a score bump" 628 ); 629 } 630 }, 631 }; 632 Object.setPrototypeOf(TabTracker.prototype, Tracker.prototype);