Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* This module exports the TabsSetupFlowManager singleton, which manages the state and
* diverse inputs which drive the Firefox View synced tabs setup flow
*/
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
SyncedTabsErrorHandler:
});
ChromeUtils.defineLazyGetter(lazy, "syncUtils", () => {
return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs")
.Utils;
});
ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
return ChromeUtils.importESModule(
).getFxAccountsSingleton();
});
const SYNC_TABS_PREF = "services.sync.engine.tabs";
const TOPIC_TABS_CHANGED = "services.sync.tabs.changed";
const LOGGING_PREF = "browser.tabs.firefox-view.logLevel";
const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed";
const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed";
const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated";
const NETWORK_STATUS_CHANGED = "network:offline-status-changed";
const SYNC_SERVICE_ERROR = "weave:service:sync:error";
const FXA_DEVICE_CONNECTED = "fxaccounts:device_connected";
const FXA_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected";
const SYNC_SERVICE_FINISHED = "weave:service:sync:finish";
const PRIMARY_PASSWORD_UNLOCKED = "passwordmgr-crypto-login";
function openTabInWindow(window, url) {
const { switchToTabHavingURI } =
window.docShell.chromeEventHandler.ownerGlobal;
switchToTabHavingURI(url, true, {});
}
export const TabsSetupFlowManager = new (class {
constructor() {
this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);
this.setupState = new Map();
this.resetInternalState();
this._currentSetupStateName = "";
this.syncIsConnected = lazy.UIState.get().syncEnabled;
this.didFxaTabOpen = false;
this.registerSetupState({
uiStateIndex: 0,
name: "error-state",
exitConditions: () => {
return lazy.SyncedTabsErrorHandler.isSyncReady();
},
});
this.registerSetupState({
uiStateIndex: 1,
name: "not-signed-in",
exitConditions: () => {
return this.fxaSignedIn;
},
});
this.registerSetupState({
uiStateIndex: 2,
name: "connect-secondary-device",
exitConditions: () => {
return this.secondaryDeviceConnected;
},
});
this.registerSetupState({
uiStateIndex: 3,
name: "disabled-tab-sync",
exitConditions: () => {
return this.syncTabsPrefEnabled;
},
});
this.registerSetupState({
uiStateIndex: 4,
name: "synced-tabs-loaded",
exitConditions: () => {
// This is the end state
return false;
},
});
Services.obs.addObserver(this, lazy.UIState.ON_UPDATE);
Services.obs.addObserver(this, TOPIC_DEVICELIST_UPDATED);
Services.obs.addObserver(this, NETWORK_STATUS_CHANGED);
Services.obs.addObserver(this, SYNC_SERVICE_ERROR);
Services.obs.addObserver(this, SYNC_SERVICE_FINISHED);
Services.obs.addObserver(this, TOPIC_TABS_CHANGED);
Services.obs.addObserver(this, PRIMARY_PASSWORD_UNLOCKED);
Services.obs.addObserver(this, FXA_DEVICE_CONNECTED);
Services.obs.addObserver(this, FXA_DEVICE_DISCONNECTED);
// this.syncTabsPrefEnabled will track the value of the tabs pref
XPCOMUtils.defineLazyPreferenceGetter(
this,
"syncTabsPrefEnabled",
SYNC_TABS_PREF,
false,
() => {
this.maybeUpdateUI(true);
}
);
this._lastFxASignedIn = this.fxaSignedIn;
this.logger.debug(
"TabsSetupFlowManager constructor, fxaSignedIn:",
this._lastFxASignedIn
);
this.onSignedInChange();
}
resetInternalState() {
// assign initial values for all the managed internal properties
delete this._lastFxASignedIn;
this._currentSetupStateName = "not-signed-in";
this._shouldShowSuccessConfirmation = false;
this._didShowMobilePromo = false;
this.abortWaitingForTabs();
Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED);
// keep track of what is connected so we can respond to changes
this._deviceStateSnapshot = {
mobileDeviceConnected: this.mobileDeviceConnected,
secondaryDeviceConnected: this.secondaryDeviceConnected,
};
// keep track of tab-pickup-container instance visibilities
this._viewVisibilityStates = new Map();
}
get isPrimaryPasswordLocked() {
return lazy.syncUtils.mpLocked();
}
uninit() {
Services.obs.removeObserver(this, lazy.UIState.ON_UPDATE);
Services.obs.removeObserver(this, TOPIC_DEVICELIST_UPDATED);
Services.obs.removeObserver(this, NETWORK_STATUS_CHANGED);
Services.obs.removeObserver(this, SYNC_SERVICE_ERROR);
Services.obs.removeObserver(this, SYNC_SERVICE_FINISHED);
Services.obs.removeObserver(this, TOPIC_TABS_CHANGED);
Services.obs.removeObserver(this, PRIMARY_PASSWORD_UNLOCKED);
Services.obs.removeObserver(this, FXA_DEVICE_CONNECTED);
Services.obs.removeObserver(this, FXA_DEVICE_DISCONNECTED);
}
get hasVisibleViews() {
return Array.from(this._viewVisibilityStates.values()).reduce(
(hasVisible, visibility) => {
return hasVisible || visibility == "visible";
},
false
);
}
get currentSetupState() {
return this.setupState.get(this._currentSetupStateName);
}
get isTabSyncSetupComplete() {
return this.currentSetupState.uiStateIndex >= 4;
}
get uiStateIndex() {
return this.currentSetupState.uiStateIndex;
}
get fxaSignedIn() {
let { UIState } = lazy;
let syncState = UIState.get();
return (
UIState.isReady() &&
syncState.status === UIState.STATUS_SIGNED_IN &&
// syncEnabled just checks the "services.sync.username" pref has a value
syncState.syncEnabled
);
}
get secondaryDeviceConnected() {
if (!this.fxaSignedIn) {
return false;
}
let recentDevices = lazy.fxAccounts.device?.recentDeviceList?.length;
return recentDevices > 1;
}
get mobileDeviceConnected() {
if (!this.fxaSignedIn) {
return false;
}
let mobileClients = lazy.fxAccounts.device.recentDeviceList?.filter(
device => device.type == "mobile" || device.type == "tablet"
);
return mobileClients?.length > 0;
}
get shouldShowMobilePromo() {
return (
this.syncIsConnected &&
this.fxaSignedIn &&
this.currentSetupState.uiStateIndex >= 4 &&
!this.mobileDeviceConnected &&
!this.mobilePromoDismissedPref
);
}
get shouldShowMobileConnectedSuccess() {
return (
this.currentSetupState.uiStateIndex >= 3 &&
this._shouldShowSuccessConfirmation &&
this.mobileDeviceConnected
);
}
get logger() {
if (!this._log) {
let setupLog = lazy.Log.repository.getLogger("FirefoxView.TabsSetup");
setupLog.manageLevelFromPref(LOGGING_PREF);
setupLog.addAppender(
new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter())
);
this._log = setupLog;
}
return this._log;
}
registerSetupState(state) {
this.setupState.set(state.name, state);
}
async observe(subject, topic, data) {
switch (topic) {
case lazy.UIState.ON_UPDATE:
this.logger.debug("Handling UIState update");
this.syncIsConnected = lazy.UIState.get().syncEnabled;
if (this._lastFxASignedIn !== this.fxaSignedIn) {
this.onSignedInChange();
} else {
await this.maybeUpdateUI();
}
this._lastFxASignedIn = this.fxaSignedIn;
break;
case TOPIC_DEVICELIST_UPDATED:
this.logger.debug("Handling observer notification:", topic, data);
const { deviceStateChanged, deviceAdded } = await this.refreshDevices();
if (deviceStateChanged) {
await this.maybeUpdateUI(true);
}
if (deviceAdded && this.secondaryDeviceConnected) {
this.logger.debug("device was added");
this._deviceAddedResultsNeverSeen = true;
if (this.hasVisibleViews) {
this.startWaitingForNewDeviceTabs();
}
}
break;
case FXA_DEVICE_CONNECTED:
case FXA_DEVICE_DISCONNECTED:
await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true });
await this.maybeUpdateUI(true);
break;
case SYNC_SERVICE_ERROR:
this.logger.debug(`Handling ${SYNC_SERVICE_ERROR}`);
if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) {
this.abortWaitingForTabs();
await this.maybeUpdateUI(true);
}
break;
case NETWORK_STATUS_CHANGED:
this.abortWaitingForTabs();
await this.maybeUpdateUI(true);
break;
case SYNC_SERVICE_FINISHED:
this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`);
// We intentionally leave any empty-tabs timestamp
// as we may be still waiting for a sync that delivers some tabs
this._waitingForNextTabSync = false;
await this.maybeUpdateUI(true);
break;
case TOPIC_TABS_CHANGED:
this.stopWaitingForTabs();
break;
case PRIMARY_PASSWORD_UNLOCKED:
this.logger.debug(`Handling ${PRIMARY_PASSWORD_UNLOCKED}`);
this.tryToClearError();
break;
}
}
updateViewVisibility(instanceId, visibility) {
const wasVisible = this.hasVisibleViews;
this.logger.debug(
`updateViewVisibility for instance: ${instanceId}, visibility: ${visibility}`
);
if (visibility == "unloaded") {
this._viewVisibilityStates.delete(instanceId);
} else {
this._viewVisibilityStates.set(instanceId, visibility);
}
const isVisible = this.hasVisibleViews;
if (isVisible && !wasVisible) {
// If we're already timing waiting for tabs from a newly-added device
// we might be able to stop
if (this._noTabsVisibleFromAddedDeviceTimestamp) {
return this.stopWaitingForNewDeviceTabs();
}
if (this._deviceAddedResultsNeverSeen) {
// If this is the first time a view has been visible since a device was added
// we may want to start the empty-tabs visible timer
return this.startWaitingForNewDeviceTabs();
}
}
if (!isVisible) {
this.logger.debug(
"Resetting timestamp and tabs pending flags as there are no visible views"
);
// if there's no view visible, we're not really waiting anymore
this.abortWaitingForTabs();
}
return null;
}
get waitingForTabs() {
return (
// signed in & at least 1 other device is syncing indicates there's something to wait for
this.secondaryDeviceConnected && this._waitingForNextTabSync
);
}
abortWaitingForTabs() {
this._waitingForNextTabSync = false;
// also clear out the device-added / tabs pending flags
this._noTabsVisibleFromAddedDeviceTimestamp = 0;
this._deviceAddedResultsNeverSeen = false;
}
startWaitingForTabs() {
if (!this._waitingForNextTabSync) {
this._waitingForNextTabSync = true;
Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
}
}
async stopWaitingForTabs() {
const wasWaiting = this.waitingForTabs;
if (this.hasVisibleViews && this._deviceAddedResultsNeverSeen) {
await this.stopWaitingForNewDeviceTabs();
}
this._waitingForNextTabSync = false;
if (wasWaiting) {
Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
}
}
async onSignedInChange() {
this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn);
// update UI to make the state change
await this.maybeUpdateUI(true);
if (!this.fxaSignedIn) {
// As we just signed out, ensure the waiting flag is reset for next time around
this.abortWaitingForTabs();
return;
}
// Now we need to figure out if we have recently synced tabs to show
// Or, if we are going to need to trigger a tab sync for them
const recentTabs = await lazy.SyncedTabs.getRecentTabs(50);
if (!this.fxaSignedIn) {
// We got signed-out in the meantime. We should get an ON_UPDATE which will put us
// back in the right state, so we just do nothing here
return;
}
// When SyncedTabs has resolved the getRecentTabs promise,
// we also know we can update devices-related internal state
const { deviceStateChanged } = await this.refreshDevices();
if (deviceStateChanged) {
this.logger.debug(
"onSignedInChange, after refreshDevices, calling maybeUpdateUI"
);
// give the UI an opportunity to update as secondaryDeviceConnected or
// mobileDeviceConnected have changed value
await this.maybeUpdateUI(true);
}
// If we can't get recent tabs, we need to trigger a request for them
const tabSyncNeeded = !recentTabs?.length;
this.logger.debug("onSignedInChange, tabSyncNeeded:", tabSyncNeeded);
if (tabSyncNeeded) {
this.startWaitingForTabs();
this.logger.debug(
"isPrimaryPasswordLocked:",
this.isPrimaryPasswordLocked
);
this.logger.debug("onSignedInChange, no recentTabs, calling syncTabs");
// If the syncTabs call rejects or resolves false we need to clear the waiting
// flag and update UI
this.syncTabs()
.catch(ex => {
this.logger.debug("onSignedInChange, syncTabs rejected:", ex);
this.stopWaitingForTabs();
})
.then(willSync => {
if (!willSync) {
this.logger.debug("onSignedInChange, no tab sync expected");
this.stopWaitingForTabs();
}
});
}
}
async startWaitingForNewDeviceTabs() {
// if we're already waiting for tabs, don't reset
if (this._noTabsVisibleFromAddedDeviceTimestamp) {
return;
}
// take a timestamp whenever the latest device is added and we have 0 tabs to show,
// allowing us to track how long we show an empty list after a new device is added
const hasRecentTabs = (await lazy.SyncedTabs.getRecentTabs(1)).length;
if (this.hasVisibleViews && !hasRecentTabs) {
this._noTabsVisibleFromAddedDeviceTimestamp = Date.now();
this.logger.debug(
"New device added with 0 synced tabs to show, storing timestamp:",
this._noTabsVisibleFromAddedDeviceTimestamp
);
}
}
async stopWaitingForNewDeviceTabs() {
if (!this._noTabsVisibleFromAddedDeviceTimestamp) {
return;
}
const recentTabs = await lazy.SyncedTabs.getRecentTabs(1);
if (recentTabs.length) {
// We have been waiting for > 0 tabs after a newly-added device, record
// the time elapsed
const elapsed = Date.now() - this._noTabsVisibleFromAddedDeviceTimestamp;
this.logger.debug(
"stopWaitingForTabs, resetting _noTabsVisibleFromAddedDeviceTimestamp and recording telemetry:",
Math.round(elapsed / 1000)
);
this._noTabsVisibleFromAddedDeviceTimestamp = 0;
this._deviceAddedResultsNeverSeen = false;
Services.telemetry.recordEvent(
"firefoxview",
"synced_tabs_empty",
"since_device_added",
Math.round(elapsed / 1000).toString()
);
} else {
// we are still waiting for some tabs to show...
this.logger.debug(
"stopWaitingForTabs: Still no recent tabs, we are still waiting"
);
}
}
async refreshDevices() {
// If current device not found in recent device list, refresh device list
if (
!lazy.fxAccounts.device.recentDeviceList?.some(
device => device.isCurrentDevice
)
) {
await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true });
}
// compare new values to the previous values
const mobileDeviceConnected = this.mobileDeviceConnected;
const secondaryDeviceConnected = this.secondaryDeviceConnected;
const oldDevicesCount = this._deviceStateSnapshot?.devicesCount ?? 0;
const devicesCount = lazy.fxAccounts.device?.recentDeviceList?.length ?? 0;
this.logger.debug(
`refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `,
`secondaryDeviceConnected: ${secondaryDeviceConnected}`
);
let deviceStateChanged =
this._deviceStateSnapshot.mobileDeviceConnected !=
mobileDeviceConnected ||
this._deviceStateSnapshot.secondaryDeviceConnected !=
secondaryDeviceConnected;
if (
mobileDeviceConnected &&
!this._deviceStateSnapshot.mobileDeviceConnected
) {
// a mobile device was added, show success if we previously showed the promo
this._shouldShowSuccessConfirmation = this._didShowMobilePromo;
} else if (
!mobileDeviceConnected &&
this._deviceStateSnapshot.mobileDeviceConnected
) {
// no mobile device connected now, reset
this._shouldShowSuccessConfirmation = false;
}
this._deviceStateSnapshot = {
mobileDeviceConnected,
secondaryDeviceConnected,
devicesCount,
};
if (deviceStateChanged) {
this.logger.debug("refreshDevices: device state did change");
if (!secondaryDeviceConnected) {
this.logger.debug(
"We lost a device, now claim sync hasn't worked before."
);
Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED);
}
} else {
this.logger.debug("refreshDevices: no device state change");
}
return {
deviceStateChanged,
deviceAdded: oldDevicesCount < devicesCount,
};
}
async maybeUpdateUI(forceUpdate = false) {
let nextSetupStateName = this._currentSetupStateName;
let errorState = null;
let stateChanged = false;
// state transition conditions
for (let state of this.setupState.values()) {
nextSetupStateName = state.name;
if (!state.exitConditions()) {
this.logger.debug(
"maybeUpdateUI, conditions not met to exit state: ",
nextSetupStateName
);
break;
}
}
let setupState = this.currentSetupState;
const state = this.setupState.get(nextSetupStateName);
const uiStateIndex = state.uiStateIndex;
if (
uiStateIndex == 0 ||
nextSetupStateName != this._currentSetupStateName
) {
setupState = state;
this._currentSetupStateName = nextSetupStateName;
stateChanged = true;
}
this.logger.debug(
"maybeUpdateUI, will notify update?:",
stateChanged,
forceUpdate
);
if (stateChanged || forceUpdate) {
if (this.shouldShowMobilePromo) {
this._didShowMobilePromo = true;
}
if (uiStateIndex == 0) {
// Use idleDispatch() to give observers a chance to resolve before
// determining the new state.
errorState = await new Promise(resolve => {
ChromeUtils.idleDispatch(() => {
resolve(lazy.SyncedTabsErrorHandler.getErrorType());
});
});
this.logger.debug("maybeUpdateUI, in error state:", errorState);
}
Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED, errorState);
}
if ("function" == typeof setupState.enter) {
setupState.enter();
}
}
async openFxASignup(window) {
if (!(await lazy.fxAccounts.constructor.canConnectAccount())) {
return;
}
const url =
await lazy.fxAccounts.constructor.config.promiseConnectAccountURI(
"fx-view"
);
this.didFxaTabOpen = true;
openTabInWindow(window, url, true);
}
async openFxAPairDevice(window) {
const url = await lazy.fxAccounts.constructor.config.promisePairingURI({
entrypoint: "fx-view",
});
this.didFxaTabOpen = true;
openTabInWindow(window, url, true);
}
syncOpenTabs() {
// Flip the pref on.
// The observer should trigger re-evaluating state and advance to next step
Services.prefs.setBoolPref(SYNC_TABS_PREF, true);
}
async syncOnPageReload() {
if (lazy.UIState.isReady() && this.fxaSignedIn) {
this.startWaitingForTabs();
await this.syncTabs(true);
}
}
tryToClearError() {
if (lazy.UIState.isReady() && this.fxaSignedIn) {
this.startWaitingForTabs();
if (this.isPrimaryPasswordLocked) {
lazy.syncUtils.ensureMPUnlocked();
}
this.logger.debug("tryToClearError: triggering new tab sync");
this.syncTabs();
Services.tm.dispatchToMainThread(() => {});
} else {
this.logger.debug(
`tryToClearError: unable to sync, isReady: ${lazy.UIState.isReady()}, fxaSignedIn: ${
this.fxaSignedIn
}`
);
}
}
// For easy overriding in tests
syncTabs(force = false) {
return lazy.SyncedTabs.syncTabs(force);
}
})();