firefox-view-tabs-setup-manager.sys.mjs (21188B)
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 /** 6 * This module exports the TabsSetupFlowManager singleton, which manages the state and 7 * diverse inputs which drive the Firefox View synced tabs setup flow 8 */ 9 10 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 11 12 const lazy = {}; 13 14 ChromeUtils.defineESModuleGetters(lazy, { 15 Log: "resource://gre/modules/Log.sys.mjs", 16 SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", 17 SyncedTabsErrorHandler: 18 "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs", 19 UIState: "resource://services-sync/UIState.sys.mjs", 20 }); 21 22 ChromeUtils.defineLazyGetter(lazy, "syncUtils", () => { 23 return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs") 24 .Utils; 25 }); 26 27 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { 28 return ChromeUtils.importESModule( 29 "resource://gre/modules/FxAccounts.sys.mjs" 30 ).getFxAccountsSingleton(); 31 }); 32 33 const SYNC_TABS_PREF = "services.sync.engine.tabs"; 34 const TOPIC_TABS_CHANGED = "services.sync.tabs.changed"; 35 const LOGGING_PREF = "browser.tabs.firefox-view.logLevel"; 36 const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; 37 const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed"; 38 const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated"; 39 const NETWORK_STATUS_CHANGED = "network:offline-status-changed"; 40 const SYNC_SERVICE_ERROR = "weave:service:sync:error"; 41 const FXA_DEVICE_CONNECTED = "fxaccounts:device_connected"; 42 const FXA_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected"; 43 const SYNC_SERVICE_FINISHED = "weave:service:sync:finish"; 44 const PRIMARY_PASSWORD_UNLOCKED = "passwordmgr-crypto-login"; 45 46 function openTabInWindow(window, url) { 47 // Null checks as the passed window might be closing, particularly in tests. 48 const ownerGlobal = window.docShell?.chromeEventHandler?.ownerGlobal; 49 ownerGlobal?.switchToTabHavingURI(url, true, {}); 50 } 51 52 export const TabsSetupFlowManager = new (class { 53 constructor() { 54 this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]); 55 56 this.setupState = new Map(); 57 this.resetInternalState(); 58 this._currentSetupStateName = ""; 59 this.syncIsConnected = lazy.UIState.get().syncEnabled; 60 this.didFxaTabOpen = false; 61 62 this.registerSetupState({ 63 uiStateIndex: 0, 64 name: "error-state", 65 exitConditions: () => { 66 return lazy.SyncedTabsErrorHandler.isSyncReady(); 67 }, 68 }); 69 this.registerSetupState({ 70 uiStateIndex: 1, 71 name: "not-signed-in", 72 exitConditions: () => { 73 return this.fxaSignedIn; 74 }, 75 }); 76 this.registerSetupState({ 77 uiStateIndex: 2, 78 name: "connect-secondary-device", 79 exitConditions: () => { 80 return this.secondaryDeviceConnected; 81 }, 82 }); 83 this.registerSetupState({ 84 uiStateIndex: 3, 85 name: "disabled-tab-sync", 86 exitConditions: () => { 87 return this.syncTabsPrefEnabled; 88 }, 89 }); 90 this.registerSetupState({ 91 uiStateIndex: 4, 92 name: "synced-tabs-loaded", 93 exitConditions: () => { 94 // This is the end state 95 return false; 96 }, 97 }); 98 99 Services.obs.addObserver(this, lazy.UIState.ON_UPDATE); 100 Services.obs.addObserver(this, TOPIC_DEVICELIST_UPDATED); 101 Services.obs.addObserver(this, NETWORK_STATUS_CHANGED); 102 Services.obs.addObserver(this, SYNC_SERVICE_ERROR); 103 Services.obs.addObserver(this, SYNC_SERVICE_FINISHED); 104 Services.obs.addObserver(this, TOPIC_TABS_CHANGED); 105 Services.obs.addObserver(this, PRIMARY_PASSWORD_UNLOCKED); 106 Services.obs.addObserver(this, FXA_DEVICE_CONNECTED); 107 Services.obs.addObserver(this, FXA_DEVICE_DISCONNECTED); 108 109 // this.syncTabsPrefEnabled will track the value of the tabs pref 110 XPCOMUtils.defineLazyPreferenceGetter( 111 this, 112 "syncTabsPrefEnabled", 113 SYNC_TABS_PREF, 114 false, 115 () => { 116 this.maybeUpdateUI(true); 117 } 118 ); 119 120 this._lastFxASignedIn = this.fxaSignedIn; 121 this.logger.debug( 122 "TabsSetupFlowManager constructor, fxaSignedIn:", 123 this._lastFxASignedIn 124 ); 125 this.onSignedInChange(); 126 } 127 128 resetInternalState() { 129 // assign initial values for all the managed internal properties 130 delete this._lastFxASignedIn; 131 this._currentSetupStateName = "not-signed-in"; 132 this._shouldShowSuccessConfirmation = false; 133 this._didShowMobilePromo = false; 134 this.abortWaitingForTabs(); 135 136 Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED); 137 138 // keep track of what is connected so we can respond to changes 139 this._deviceStateSnapshot = { 140 mobileDeviceConnected: this.mobileDeviceConnected, 141 secondaryDeviceConnected: this.secondaryDeviceConnected, 142 }; 143 // keep track of tab-pickup-container instance visibilities 144 this._viewVisibilityStates = new Map(); 145 } 146 147 get isPrimaryPasswordLocked() { 148 return lazy.syncUtils.mpLocked(); 149 } 150 151 uninit() { 152 Services.obs.removeObserver(this, lazy.UIState.ON_UPDATE); 153 Services.obs.removeObserver(this, TOPIC_DEVICELIST_UPDATED); 154 Services.obs.removeObserver(this, NETWORK_STATUS_CHANGED); 155 Services.obs.removeObserver(this, SYNC_SERVICE_ERROR); 156 Services.obs.removeObserver(this, SYNC_SERVICE_FINISHED); 157 Services.obs.removeObserver(this, TOPIC_TABS_CHANGED); 158 Services.obs.removeObserver(this, PRIMARY_PASSWORD_UNLOCKED); 159 Services.obs.removeObserver(this, FXA_DEVICE_CONNECTED); 160 Services.obs.removeObserver(this, FXA_DEVICE_DISCONNECTED); 161 } 162 get hasVisibleViews() { 163 return Array.from(this._viewVisibilityStates.values()).reduce( 164 (hasVisible, visibility) => { 165 return hasVisible || visibility == "visible"; 166 }, 167 false 168 ); 169 } 170 get currentSetupState() { 171 return this.setupState.get(this._currentSetupStateName); 172 } 173 get isTabSyncSetupComplete() { 174 return this.currentSetupState.uiStateIndex >= 4; 175 } 176 get uiStateIndex() { 177 return this.currentSetupState.uiStateIndex; 178 } 179 get fxaSignedIn() { 180 let { UIState } = lazy; 181 let syncState = UIState.get(); 182 return ( 183 UIState.isReady() && 184 syncState.status === UIState.STATUS_SIGNED_IN && 185 // syncEnabled just checks the "services.sync.username" pref has a value 186 syncState.syncEnabled 187 ); 188 } 189 190 get secondaryDeviceConnected() { 191 if (!this.fxaSignedIn) { 192 return false; 193 } 194 let recentDevices = lazy.fxAccounts.device?.recentDeviceList?.length; 195 return recentDevices > 1; 196 } 197 get mobileDeviceConnected() { 198 if (!this.fxaSignedIn) { 199 return false; 200 } 201 let mobileClients = lazy.fxAccounts.device.recentDeviceList?.filter( 202 device => device.type == "mobile" || device.type == "tablet" 203 ); 204 return mobileClients?.length > 0; 205 } 206 get shouldShowMobilePromo() { 207 return ( 208 this.syncIsConnected && 209 this.fxaSignedIn && 210 this.currentSetupState.uiStateIndex >= 4 && 211 !this.mobileDeviceConnected && 212 !this.mobilePromoDismissedPref 213 ); 214 } 215 get shouldShowMobileConnectedSuccess() { 216 return ( 217 this.currentSetupState.uiStateIndex >= 3 && 218 this._shouldShowSuccessConfirmation && 219 this.mobileDeviceConnected 220 ); 221 } 222 get logger() { 223 if (!this._log) { 224 let setupLog = lazy.Log.repository.getLogger("FirefoxView.TabsSetup"); 225 setupLog.manageLevelFromPref(LOGGING_PREF); 226 setupLog.addAppender( 227 new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter()) 228 ); 229 this._log = setupLog; 230 } 231 return this._log; 232 } 233 234 registerSetupState(state) { 235 this.setupState.set(state.name, state); 236 } 237 238 async observe(subject, topic, data) { 239 switch (topic) { 240 case lazy.UIState.ON_UPDATE: 241 this.logger.debug("Handling UIState update"); 242 this.syncIsConnected = lazy.UIState.get().syncEnabled; 243 if (this._lastFxASignedIn !== this.fxaSignedIn) { 244 this.onSignedInChange(); 245 } else { 246 await this.maybeUpdateUI(); 247 } 248 this._lastFxASignedIn = this.fxaSignedIn; 249 break; 250 case TOPIC_DEVICELIST_UPDATED: { 251 this.logger.debug("Handling observer notification:", topic, data); 252 const { deviceStateChanged, deviceAdded } = await this.refreshDevices(); 253 if (deviceStateChanged) { 254 await this.maybeUpdateUI(true); 255 } 256 if (deviceAdded && this.secondaryDeviceConnected) { 257 this.logger.debug("device was added"); 258 this._deviceAddedResultsNeverSeen = true; 259 if (this.hasVisibleViews) { 260 this.startWaitingForNewDeviceTabs(); 261 } 262 } 263 break; 264 } 265 case FXA_DEVICE_CONNECTED: 266 case FXA_DEVICE_DISCONNECTED: 267 await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true }); 268 await this.maybeUpdateUI(true); 269 break; 270 case SYNC_SERVICE_ERROR: 271 this.logger.debug(`Handling ${SYNC_SERVICE_ERROR}`); 272 if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) { 273 this.abortWaitingForTabs(); 274 await this.maybeUpdateUI(true); 275 } 276 break; 277 case NETWORK_STATUS_CHANGED: 278 this.abortWaitingForTabs(); 279 await this.maybeUpdateUI(true); 280 break; 281 case SYNC_SERVICE_FINISHED: 282 this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`); 283 // We intentionally leave any empty-tabs timestamp 284 // as we may be still waiting for a sync that delivers some tabs 285 this._waitingForNextTabSync = false; 286 await this.maybeUpdateUI(true); 287 break; 288 case TOPIC_TABS_CHANGED: 289 this.stopWaitingForTabs(); 290 break; 291 case PRIMARY_PASSWORD_UNLOCKED: 292 this.logger.debug(`Handling ${PRIMARY_PASSWORD_UNLOCKED}`); 293 this.tryToClearError(); 294 break; 295 } 296 } 297 298 updateViewVisibility(instanceId, visibility) { 299 const wasVisible = this.hasVisibleViews; 300 this.logger.debug( 301 `updateViewVisibility for instance: ${instanceId}, visibility: ${visibility}` 302 ); 303 if (visibility == "unloaded") { 304 this._viewVisibilityStates.delete(instanceId); 305 } else { 306 this._viewVisibilityStates.set(instanceId, visibility); 307 } 308 const isVisible = this.hasVisibleViews; 309 if (isVisible && !wasVisible) { 310 // If we're already timing waiting for tabs from a newly-added device 311 // we might be able to stop 312 if (this._noTabsVisibleFromAddedDeviceTimestamp) { 313 return this.stopWaitingForNewDeviceTabs(); 314 } 315 if (this._deviceAddedResultsNeverSeen) { 316 // If this is the first time a view has been visible since a device was added 317 // we may want to start the empty-tabs visible timer 318 return this.startWaitingForNewDeviceTabs(); 319 } 320 } 321 if (!isVisible) { 322 this.logger.debug( 323 "Resetting timestamp and tabs pending flags as there are no visible views" 324 ); 325 // if there's no view visible, we're not really waiting anymore 326 this.abortWaitingForTabs(); 327 } 328 return null; 329 } 330 331 get waitingForTabs() { 332 return ( 333 // signed in & at least 1 other device is syncing indicates there's something to wait for 334 this.secondaryDeviceConnected && this._waitingForNextTabSync 335 ); 336 } 337 338 abortWaitingForTabs() { 339 this._waitingForNextTabSync = false; 340 // also clear out the device-added / tabs pending flags 341 this._noTabsVisibleFromAddedDeviceTimestamp = 0; 342 this._deviceAddedResultsNeverSeen = false; 343 } 344 345 startWaitingForTabs() { 346 if (!this._waitingForNextTabSync) { 347 this._waitingForNextTabSync = true; 348 Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); 349 } 350 } 351 352 async stopWaitingForTabs() { 353 const wasWaiting = this.waitingForTabs; 354 if (this.hasVisibleViews && this._deviceAddedResultsNeverSeen) { 355 await this.stopWaitingForNewDeviceTabs(); 356 } 357 this._waitingForNextTabSync = false; 358 if (wasWaiting) { 359 Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED); 360 } 361 } 362 363 async onSignedInChange() { 364 this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn); 365 // update UI to make the state change 366 await this.maybeUpdateUI(true); 367 if (!this.fxaSignedIn) { 368 // As we just signed out, ensure the waiting flag is reset for next time around 369 this.abortWaitingForTabs(); 370 return; 371 } 372 373 // Now we need to figure out if we have recently synced tabs to show 374 // Or, if we are going to need to trigger a tab sync for them 375 const recentTabs = await lazy.SyncedTabs.getRecentTabs(50); 376 377 if (!this.fxaSignedIn) { 378 // We got signed-out in the meantime. We should get an ON_UPDATE which will put us 379 // back in the right state, so we just do nothing here 380 return; 381 } 382 383 // When SyncedTabs has resolved the getRecentTabs promise, 384 // we also know we can update devices-related internal state 385 const { deviceStateChanged } = await this.refreshDevices(); 386 if (deviceStateChanged) { 387 this.logger.debug( 388 "onSignedInChange, after refreshDevices, calling maybeUpdateUI" 389 ); 390 // give the UI an opportunity to update as secondaryDeviceConnected or 391 // mobileDeviceConnected have changed value 392 await this.maybeUpdateUI(true); 393 } 394 395 // If we can't get recent tabs, we need to trigger a request for them 396 const tabSyncNeeded = !recentTabs?.length; 397 this.logger.debug("onSignedInChange, tabSyncNeeded:", tabSyncNeeded); 398 399 if (tabSyncNeeded) { 400 this.startWaitingForTabs(); 401 this.logger.debug( 402 "isPrimaryPasswordLocked:", 403 this.isPrimaryPasswordLocked 404 ); 405 this.logger.debug("onSignedInChange, no recentTabs, calling syncTabs"); 406 // If the syncTabs call rejects or resolves false we need to clear the waiting 407 // flag and update UI 408 this.syncTabs() 409 .catch(ex => { 410 this.logger.debug("onSignedInChange, syncTabs rejected:", ex); 411 this.stopWaitingForTabs(); 412 }) 413 .then(willSync => { 414 if (!willSync) { 415 this.logger.debug("onSignedInChange, no tab sync expected"); 416 this.stopWaitingForTabs(); 417 } 418 }); 419 } 420 } 421 422 async startWaitingForNewDeviceTabs() { 423 // if we're already waiting for tabs, don't reset 424 if (this._noTabsVisibleFromAddedDeviceTimestamp) { 425 return; 426 } 427 428 // take a timestamp whenever the latest device is added and we have 0 tabs to show, 429 // allowing us to track how long we show an empty list after a new device is added 430 const hasRecentTabs = (await lazy.SyncedTabs.getRecentTabs(1)).length; 431 if (this.hasVisibleViews && !hasRecentTabs) { 432 this._noTabsVisibleFromAddedDeviceTimestamp = Date.now(); 433 this.logger.debug( 434 "New device added with 0 synced tabs to show, storing timestamp:", 435 this._noTabsVisibleFromAddedDeviceTimestamp 436 ); 437 } 438 } 439 440 async stopWaitingForNewDeviceTabs() { 441 if (!this._noTabsVisibleFromAddedDeviceTimestamp) { 442 return; 443 } 444 const recentTabs = await lazy.SyncedTabs.getRecentTabs(1); 445 if (recentTabs.length) { 446 // We have been waiting for > 0 tabs after a newly-added device, record 447 // the time elapsed 448 const elapsed = Date.now() - this._noTabsVisibleFromAddedDeviceTimestamp; 449 this.logger.debug( 450 "stopWaitingForTabs, resetting _noTabsVisibleFromAddedDeviceTimestamp and recording telemetry:", 451 Math.round(elapsed / 1000) 452 ); 453 this._noTabsVisibleFromAddedDeviceTimestamp = 0; 454 this._deviceAddedResultsNeverSeen = false; 455 } else { 456 // we are still waiting for some tabs to show... 457 this.logger.debug( 458 "stopWaitingForTabs: Still no recent tabs, we are still waiting" 459 ); 460 } 461 } 462 463 async refreshDevices() { 464 // If current device not found in recent device list, refresh device list 465 if ( 466 !lazy.fxAccounts.device.recentDeviceList?.some( 467 device => device.isCurrentDevice 468 ) 469 ) { 470 await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true }); 471 } 472 473 // compare new values to the previous values 474 const mobileDeviceConnected = this.mobileDeviceConnected; 475 const secondaryDeviceConnected = this.secondaryDeviceConnected; 476 const oldDevicesCount = this._deviceStateSnapshot?.devicesCount ?? 0; 477 const devicesCount = lazy.fxAccounts.device?.recentDeviceList?.length ?? 0; 478 479 this.logger.debug( 480 `refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `, 481 `secondaryDeviceConnected: ${secondaryDeviceConnected}` 482 ); 483 484 let deviceStateChanged = 485 this._deviceStateSnapshot.mobileDeviceConnected != 486 mobileDeviceConnected || 487 this._deviceStateSnapshot.secondaryDeviceConnected != 488 secondaryDeviceConnected; 489 if ( 490 mobileDeviceConnected && 491 !this._deviceStateSnapshot.mobileDeviceConnected 492 ) { 493 // a mobile device was added, show success if we previously showed the promo 494 this._shouldShowSuccessConfirmation = this._didShowMobilePromo; 495 } else if ( 496 !mobileDeviceConnected && 497 this._deviceStateSnapshot.mobileDeviceConnected 498 ) { 499 // no mobile device connected now, reset 500 this._shouldShowSuccessConfirmation = false; 501 } 502 this._deviceStateSnapshot = { 503 mobileDeviceConnected, 504 secondaryDeviceConnected, 505 devicesCount, 506 }; 507 if (deviceStateChanged) { 508 this.logger.debug("refreshDevices: device state did change"); 509 if (!secondaryDeviceConnected) { 510 this.logger.debug( 511 "We lost a device, now claim sync hasn't worked before." 512 ); 513 Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED); 514 } 515 } else { 516 this.logger.debug("refreshDevices: no device state change"); 517 } 518 return { 519 deviceStateChanged, 520 deviceAdded: oldDevicesCount < devicesCount, 521 }; 522 } 523 524 async maybeUpdateUI(forceUpdate = false) { 525 let nextSetupStateName = this._currentSetupStateName; 526 let errorState = null; 527 let stateChanged = false; 528 529 // state transition conditions 530 for (let state of this.setupState.values()) { 531 nextSetupStateName = state.name; 532 if (!state.exitConditions()) { 533 this.logger.debug( 534 "maybeUpdateUI, conditions not met to exit state: ", 535 nextSetupStateName 536 ); 537 break; 538 } 539 } 540 541 let setupState = this.currentSetupState; 542 const state = this.setupState.get(nextSetupStateName); 543 const uiStateIndex = state.uiStateIndex; 544 545 if ( 546 uiStateIndex == 0 || 547 nextSetupStateName != this._currentSetupStateName 548 ) { 549 setupState = state; 550 this._currentSetupStateName = nextSetupStateName; 551 stateChanged = true; 552 } 553 this.logger.debug( 554 "maybeUpdateUI, will notify update?:", 555 stateChanged, 556 forceUpdate 557 ); 558 if (stateChanged || forceUpdate) { 559 if (this.shouldShowMobilePromo) { 560 this._didShowMobilePromo = true; 561 } 562 if (uiStateIndex == 0) { 563 // Use idleDispatch() to give observers a chance to resolve before 564 // determining the new state. 565 errorState = await new Promise(resolve => { 566 ChromeUtils.idleDispatch(() => { 567 resolve(lazy.SyncedTabsErrorHandler.getErrorType()); 568 }); 569 }); 570 this.logger.debug("maybeUpdateUI, in error state:", errorState); 571 } 572 Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED, errorState); 573 } 574 if ("function" == typeof setupState.enter) { 575 setupState.enter(); 576 } 577 } 578 579 async openFxASignup(window) { 580 if (!(await lazy.fxAccounts.constructor.canConnectAccount())) { 581 return; 582 } 583 const url = 584 await lazy.fxAccounts.constructor.config.promiseConnectAccountURI( 585 "fx-view" 586 ); 587 this.didFxaTabOpen = true; 588 openTabInWindow(window, url, true); 589 } 590 591 async openFxAPairDevice(window) { 592 const url = await lazy.fxAccounts.constructor.config.promisePairingURI({ 593 entrypoint: "fx-view", 594 }); 595 this.didFxaTabOpen = true; 596 openTabInWindow(window, url, true); 597 } 598 599 syncOpenTabs() { 600 // Flip the pref on. 601 // The observer should trigger re-evaluating state and advance to next step 602 Services.prefs.setBoolPref(SYNC_TABS_PREF, true); 603 } 604 605 async syncOnPageReload() { 606 if (lazy.UIState.isReady() && this.fxaSignedIn) { 607 this.startWaitingForTabs(); 608 await this.syncTabs(true); 609 } 610 } 611 612 tryToClearError() { 613 if (lazy.UIState.isReady() && this.fxaSignedIn) { 614 this.startWaitingForTabs(); 615 if (this.isPrimaryPasswordLocked) { 616 lazy.syncUtils.ensureMPUnlocked(); 617 } 618 this.logger.debug("tryToClearError: triggering new tab sync"); 619 this.syncTabs(); 620 Services.tm.dispatchToMainThread(() => {}); 621 } else { 622 this.logger.debug( 623 `tryToClearError: unable to sync, isReady: ${lazy.UIState.isReady()}, fxaSignedIn: ${ 624 this.fxaSignedIn 625 }` 626 ); 627 } 628 } 629 // For easy overriding in tests 630 syncTabs(force = false) { 631 return lazy.SyncedTabs.syncTabs(force); 632 } 633 })();