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/. */
6 * This module exports the TabsSetupFlowManager singleton, which manages the state and
7 * diverse inputs which drive the Firefox View synced tabs setup flow
10 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
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",
22 ChromeUtils.defineLazyGetter(lazy, "syncUtils", () => {
23 return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs")
27 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
28 return ChromeUtils.importESModule(
29 "resource://gre/modules/FxAccounts.sys.mjs"
30 ).getFxAccountsSingleton();
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";
46 function openTabInWindow(window, url) {
47 const { switchToTabHavingURI } =
48 window.docShell.chromeEventHandler.ownerGlobal;
49 switchToTabHavingURI(url, true, {});
52 export const TabsSetupFlowManager = new (class {
54 this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);
56 this.setupState = new Map();
57 this.resetInternalState();
58 this._currentSetupStateName = "";
59 this.syncIsConnected = lazy.UIState.get().syncEnabled;
60 this.didFxaTabOpen = false;
62 this.registerSetupState({
65 exitConditions: () => {
66 return lazy.SyncedTabsErrorHandler.isSyncReady();
69 this.registerSetupState({
71 name: "not-signed-in",
72 exitConditions: () => {
73 return this.fxaSignedIn;
76 this.registerSetupState({
78 name: "connect-secondary-device",
79 exitConditions: () => {
80 return this.secondaryDeviceConnected;
83 this.registerSetupState({
85 name: "disabled-tab-sync",
86 exitConditions: () => {
87 return this.syncTabsPrefEnabled;
90 this.registerSetupState({
92 name: "synced-tabs-loaded",
93 exitConditions: () => {
94 // This is the end state
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);
109 // this.syncTabsPrefEnabled will track the value of the tabs pref
110 XPCOMUtils.defineLazyPreferenceGetter(
112 "syncTabsPrefEnabled",
116 this.maybeUpdateUI(true);
120 this._lastFxASignedIn = this.fxaSignedIn;
122 "TabsSetupFlowManager constructor, fxaSignedIn:",
123 this._lastFxASignedIn
125 this.onSignedInChange();
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();
136 Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED);
138 // keep track of what is connected so we can respond to changes
139 this._deviceStateSnapshot = {
140 mobileDeviceConnected: this.mobileDeviceConnected,
141 secondaryDeviceConnected: this.secondaryDeviceConnected,
143 // keep track of tab-pickup-container instance visibilities
144 this._viewVisibilityStates = new Map();
147 get isPrimaryPasswordLocked() {
148 return lazy.syncUtils.mpLocked();
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);
162 get hasVisibleViews() {
163 return Array.from(this._viewVisibilityStates.values()).reduce(
164 (hasVisible, visibility) => {
165 return hasVisible || visibility == "visible";
170 get currentSetupState() {
171 return this.setupState.get(this._currentSetupStateName);
173 get isTabSyncSetupComplete() {
174 return this.currentSetupState.uiStateIndex >= 4;
177 return this.currentSetupState.uiStateIndex;
180 let { UIState } = lazy;
181 let syncState = UIState.get();
184 syncState.status === UIState.STATUS_SIGNED_IN &&
185 // syncEnabled just checks the "services.sync.username" pref has a value
186 syncState.syncEnabled
190 get secondaryDeviceConnected() {
191 if (!this.fxaSignedIn) {
194 let recentDevices = lazy.fxAccounts.device?.recentDeviceList?.length;
195 return recentDevices > 1;
197 get mobileDeviceConnected() {
198 if (!this.fxaSignedIn) {
201 let mobileClients = lazy.fxAccounts.device.recentDeviceList?.filter(
202 device => device.type == "mobile" || device.type == "tablet"
204 return mobileClients?.length > 0;
206 get shouldShowMobilePromo() {
208 this.syncIsConnected &&
210 this.currentSetupState.uiStateIndex >= 4 &&
211 !this.mobileDeviceConnected &&
212 !this.mobilePromoDismissedPref
215 get shouldShowMobileConnectedSuccess() {
217 this.currentSetupState.uiStateIndex >= 3 &&
218 this._shouldShowSuccessConfirmation &&
219 this.mobileDeviceConnected
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())
229 this._log = setupLog;
234 registerSetupState(state) {
235 this.setupState.set(state.name, state);
238 async observe(subject, topic, data) {
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();
246 await this.maybeUpdateUI();
248 this._lastFxASignedIn = this.fxaSignedIn;
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);
256 if (deviceAdded && this.secondaryDeviceConnected) {
257 this.logger.debug("device was added");
258 this._deviceAddedResultsNeverSeen = true;
259 if (this.hasVisibleViews) {
260 this.startWaitingForNewDeviceTabs();
264 case FXA_DEVICE_CONNECTED:
265 case FXA_DEVICE_DISCONNECTED:
266 await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true });
267 await this.maybeUpdateUI(true);
269 case SYNC_SERVICE_ERROR:
270 this.logger.debug(`Handling ${SYNC_SERVICE_ERROR}`);
271 if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) {
272 this.abortWaitingForTabs();
273 await this.maybeUpdateUI(true);
276 case NETWORK_STATUS_CHANGED:
277 this.abortWaitingForTabs();
278 await this.maybeUpdateUI(true);
280 case SYNC_SERVICE_FINISHED:
281 this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`);
282 // We intentionally leave any empty-tabs timestamp
283 // as we may be still waiting for a sync that delivers some tabs
284 this._waitingForNextTabSync = false;
285 await this.maybeUpdateUI(true);
287 case TOPIC_TABS_CHANGED:
288 this.stopWaitingForTabs();
290 case PRIMARY_PASSWORD_UNLOCKED:
291 this.logger.debug(`Handling ${PRIMARY_PASSWORD_UNLOCKED}`);
292 this.tryToClearError();
297 updateViewVisibility(instanceId, visibility) {
298 const wasVisible = this.hasVisibleViews;
300 `updateViewVisibility for instance: ${instanceId}, visibility: ${visibility}`
302 if (visibility == "unloaded") {
303 this._viewVisibilityStates.delete(instanceId);
305 this._viewVisibilityStates.set(instanceId, visibility);
307 const isVisible = this.hasVisibleViews;
308 if (isVisible && !wasVisible) {
309 // If we're already timing waiting for tabs from a newly-added device
310 // we might be able to stop
311 if (this._noTabsVisibleFromAddedDeviceTimestamp) {
312 return this.stopWaitingForNewDeviceTabs();
314 if (this._deviceAddedResultsNeverSeen) {
315 // If this is the first time a view has been visible since a device was added
316 // we may want to start the empty-tabs visible timer
317 return this.startWaitingForNewDeviceTabs();
322 "Resetting timestamp and tabs pending flags as there are no visible views"
324 // if there's no view visible, we're not really waiting anymore
325 this.abortWaitingForTabs();
330 get waitingForTabs() {
332 // signed in & at least 1 other device is syncing indicates there's something to wait for
333 this.secondaryDeviceConnected && this._waitingForNextTabSync
337 abortWaitingForTabs() {
338 this._waitingForNextTabSync = false;
339 // also clear out the device-added / tabs pending flags
340 this._noTabsVisibleFromAddedDeviceTimestamp = 0;
341 this._deviceAddedResultsNeverSeen = false;
344 startWaitingForTabs() {
345 if (!this._waitingForNextTabSync) {
346 this._waitingForNextTabSync = true;
347 Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
351 async stopWaitingForTabs() {
352 const wasWaiting = this.waitingForTabs;
353 if (this.hasVisibleViews && this._deviceAddedResultsNeverSeen) {
354 await this.stopWaitingForNewDeviceTabs();
356 this._waitingForNextTabSync = false;
358 Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
362 async onSignedInChange() {
363 this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn);
364 // update UI to make the state change
365 await this.maybeUpdateUI(true);
366 if (!this.fxaSignedIn) {
367 // As we just signed out, ensure the waiting flag is reset for next time around
368 this.abortWaitingForTabs();
372 // Now we need to figure out if we have recently synced tabs to show
373 // Or, if we are going to need to trigger a tab sync for them
374 const recentTabs = await lazy.SyncedTabs.getRecentTabs(50);
376 if (!this.fxaSignedIn) {
377 // We got signed-out in the meantime. We should get an ON_UPDATE which will put us
378 // back in the right state, so we just do nothing here
382 // When SyncedTabs has resolved the getRecentTabs promise,
383 // we also know we can update devices-related internal state
384 const { deviceStateChanged } = await this.refreshDevices();
385 if (deviceStateChanged) {
387 "onSignedInChange, after refreshDevices, calling maybeUpdateUI"
389 // give the UI an opportunity to update as secondaryDeviceConnected or
390 // mobileDeviceConnected have changed value
391 await this.maybeUpdateUI(true);
394 // If we can't get recent tabs, we need to trigger a request for them
395 const tabSyncNeeded = !recentTabs?.length;
396 this.logger.debug("onSignedInChange, tabSyncNeeded:", tabSyncNeeded);
399 this.startWaitingForTabs();
401 "isPrimaryPasswordLocked:",
402 this.isPrimaryPasswordLocked
404 this.logger.debug("onSignedInChange, no recentTabs, calling syncTabs");
405 // If the syncTabs call rejects or resolves false we need to clear the waiting
406 // flag and update UI
409 this.logger.debug("onSignedInChange, syncTabs rejected:", ex);
410 this.stopWaitingForTabs();
414 this.logger.debug("onSignedInChange, no tab sync expected");
415 this.stopWaitingForTabs();
421 async startWaitingForNewDeviceTabs() {
422 // if we're already waiting for tabs, don't reset
423 if (this._noTabsVisibleFromAddedDeviceTimestamp) {
427 // take a timestamp whenever the latest device is added and we have 0 tabs to show,
428 // allowing us to track how long we show an empty list after a new device is added
429 const hasRecentTabs = (await lazy.SyncedTabs.getRecentTabs(1)).length;
430 if (this.hasVisibleViews && !hasRecentTabs) {
431 this._noTabsVisibleFromAddedDeviceTimestamp = Date.now();
433 "New device added with 0 synced tabs to show, storing timestamp:",
434 this._noTabsVisibleFromAddedDeviceTimestamp
439 async stopWaitingForNewDeviceTabs() {
440 if (!this._noTabsVisibleFromAddedDeviceTimestamp) {
443 const recentTabs = await lazy.SyncedTabs.getRecentTabs(1);
444 if (recentTabs.length) {
445 // We have been waiting for > 0 tabs after a newly-added device, record
447 const elapsed = Date.now() - this._noTabsVisibleFromAddedDeviceTimestamp;
449 "stopWaitingForTabs, resetting _noTabsVisibleFromAddedDeviceTimestamp and recording telemetry:",
450 Math.round(elapsed / 1000)
452 this._noTabsVisibleFromAddedDeviceTimestamp = 0;
453 this._deviceAddedResultsNeverSeen = false;
455 // we are still waiting for some tabs to show...
457 "stopWaitingForTabs: Still no recent tabs, we are still waiting"
462 async refreshDevices() {
463 // If current device not found in recent device list, refresh device list
465 !lazy.fxAccounts.device.recentDeviceList?.some(
466 device => device.isCurrentDevice
469 await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true });
472 // compare new values to the previous values
473 const mobileDeviceConnected = this.mobileDeviceConnected;
474 const secondaryDeviceConnected = this.secondaryDeviceConnected;
475 const oldDevicesCount = this._deviceStateSnapshot?.devicesCount ?? 0;
476 const devicesCount = lazy.fxAccounts.device?.recentDeviceList?.length ?? 0;
479 `refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `,
480 `secondaryDeviceConnected: ${secondaryDeviceConnected}`
483 let deviceStateChanged =
484 this._deviceStateSnapshot.mobileDeviceConnected !=
485 mobileDeviceConnected ||
486 this._deviceStateSnapshot.secondaryDeviceConnected !=
487 secondaryDeviceConnected;
489 mobileDeviceConnected &&
490 !this._deviceStateSnapshot.mobileDeviceConnected
492 // a mobile device was added, show success if we previously showed the promo
493 this._shouldShowSuccessConfirmation = this._didShowMobilePromo;
495 !mobileDeviceConnected &&
496 this._deviceStateSnapshot.mobileDeviceConnected
498 // no mobile device connected now, reset
499 this._shouldShowSuccessConfirmation = false;
501 this._deviceStateSnapshot = {
502 mobileDeviceConnected,
503 secondaryDeviceConnected,
506 if (deviceStateChanged) {
507 this.logger.debug("refreshDevices: device state did change");
508 if (!secondaryDeviceConnected) {
510 "We lost a device, now claim sync hasn't worked before."
512 Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED);
515 this.logger.debug("refreshDevices: no device state change");
519 deviceAdded: oldDevicesCount < devicesCount,
523 async maybeUpdateUI(forceUpdate = false) {
524 let nextSetupStateName = this._currentSetupStateName;
525 let errorState = null;
526 let stateChanged = false;
528 // state transition conditions
529 for (let state of this.setupState.values()) {
530 nextSetupStateName = state.name;
531 if (!state.exitConditions()) {
533 "maybeUpdateUI, conditions not met to exit state: ",
540 let setupState = this.currentSetupState;
541 const state = this.setupState.get(nextSetupStateName);
542 const uiStateIndex = state.uiStateIndex;
546 nextSetupStateName != this._currentSetupStateName
549 this._currentSetupStateName = nextSetupStateName;
553 "maybeUpdateUI, will notify update?:",
557 if (stateChanged || forceUpdate) {
558 if (this.shouldShowMobilePromo) {
559 this._didShowMobilePromo = true;
561 if (uiStateIndex == 0) {
562 // Use idleDispatch() to give observers a chance to resolve before
563 // determining the new state.
564 errorState = await new Promise(resolve => {
565 ChromeUtils.idleDispatch(() => {
566 resolve(lazy.SyncedTabsErrorHandler.getErrorType());
569 this.logger.debug("maybeUpdateUI, in error state:", errorState);
571 Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED, errorState);
573 if ("function" == typeof setupState.enter) {
578 async openFxASignup(window) {
579 if (!(await lazy.fxAccounts.constructor.canConnectAccount())) {
583 await lazy.fxAccounts.constructor.config.promiseConnectAccountURI(
586 this.didFxaTabOpen = true;
587 openTabInWindow(window, url, true);
590 async openFxAPairDevice(window) {
591 const url = await lazy.fxAccounts.constructor.config.promisePairingURI({
592 entrypoint: "fx-view",
594 this.didFxaTabOpen = true;
595 openTabInWindow(window, url, true);
600 // The observer should trigger re-evaluating state and advance to next step
601 Services.prefs.setBoolPref(SYNC_TABS_PREF, true);
604 async syncOnPageReload() {
605 if (lazy.UIState.isReady() && this.fxaSignedIn) {
606 this.startWaitingForTabs();
607 await this.syncTabs(true);
612 if (lazy.UIState.isReady() && this.fxaSignedIn) {
613 this.startWaitingForTabs();
614 if (this.isPrimaryPasswordLocked) {
615 lazy.syncUtils.ensureMPUnlocked();
617 this.logger.debug("tryToClearError: triggering new tab sync");
619 Services.tm.dispatchToMainThread(() => {});
622 `tryToClearError: unable to sync, isReady: ${lazy.UIState.isReady()}, fxaSignedIn: ${
628 // For easy overriding in tests
629 syncTabs(force = false) {
630 return lazy.SyncedTabs.syncTabs(force);