firefox-desktop/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs
2022-10-26 20:50:36 +02:00

590 lines
19 KiB
JavaScript

/* 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, {
Log: "resource://gre/modules/Log.sys.mjs",
});
XPCOMUtils.defineLazyModuleGetters(lazy, {
UIState: "resource://services-sync/UIState.jsm",
SyncedTabs: "resource://services-sync/SyncedTabs.jsm",
Weave: "resource://services-sync/main.js",
});
XPCOMUtils.defineLazyGetter(lazy, "syncUtils", () => {
return ChromeUtils.import("resource://services-sync/util.js").Utils;
});
XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => {
return ChromeUtils.import(
"resource://gre/modules/FxAccounts.jsm"
).getFxAccountsSingleton();
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gNetworkLinkService",
"@mozilla.org/network/network-link-service;1",
"nsINetworkLinkService"
);
const SYNC_TABS_PREF = "services.sync.engine.tabs";
const TOPIC_TABS_CHANGED = "services.sync.tabs.changed";
const MOBILE_PROMO_DISMISSED_PREF =
"browser.tabs.firefox-view.mobilePromo.dismissed";
const LOGGING_PREF = "browser.tabs.firefox-view.logLevel";
const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.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_ENABLED = "identity.fxaccounts.enabled";
const SYNC_SERVICE_FINISHED = "weave:service:sync:finish";
const PRIMARY_PASSWORD_UNLOCKED = "passwordmgr-crypto-login";
const TAB_PICKUP_OPEN_STATE_PREF =
"browser.tabs.firefox-view.ui-state.tab-pickup.open";
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.networkIsOnline =
lazy.gNetworkLinkService.linkStatusKnown &&
lazy.gNetworkLinkService.isLinkUp;
this.syncIsWorking = true;
this.syncIsConnected = lazy.UIState.get().syncEnabled;
this.didFxaTabOpen = false;
this.registerSetupState({
uiStateIndex: 0,
name: "error-state",
exitConditions: () => {
const fxaStatus = lazy.UIState.get().status;
return (
this.networkIsOnline &&
(this.syncIsWorking || this.syncHasWorked) &&
!Services.prefs.prefIsLocked(FXA_ENABLED) &&
// it's an error for sync to not be connected if we are signed-in,
// or for sync to be connected if the FxA status is "login_failed",
// which can happen if a user updates their password on another device
((!this.syncIsConnected &&
fxaStatus !== lazy.UIState.STATUS_SIGNED_IN) ||
(this.syncIsConnected &&
fxaStatus !== lazy.UIState.STATUS_LOGIN_FAILED)) &&
// We treat a locked primary password as an error if we are signed-in.
// If the user dismisses the prompt to unlock, they can use the "Try again" button to prompt again
(!this.isPrimaryPasswordLocked || !this.fxaSignedIn)
);
},
});
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);
// this.syncTabsPrefEnabled will track the value of the tabs pref
XPCOMUtils.defineLazyPreferenceGetter(
this,
"syncTabsPrefEnabled",
SYNC_TABS_PREF,
false,
() => {
this.maybeUpdateUI(true);
}
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"mobilePromoDismissedPref",
MOBILE_PROMO_DISMISSED_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._waitingForTabs = false;
this.syncHasWorked = false;
// keep track of what is connected so we can respond to changes
this._deviceStateSnapshot = {
mobileDeviceConnected: this.mobileDeviceConnected,
secondaryDeviceConnected: this.secondaryDeviceConnected,
};
}
get isPrimaryPasswordLocked() {
return lazy.syncUtils.mpLocked();
}
getErrorType() {
// this ordering is important for dealing with multiple errors at once
const errorStates = {
"network-offline": !this.networkIsOnline,
"fxa-admin-disabled": Services.prefs.prefIsLocked(FXA_ENABLED),
"password-locked": this.isPrimaryPasswordLocked,
"sync-disconnected":
!this.syncIsConnected ||
(this.syncIsConnected &&
lazy.UIState.get().status === lazy.UIState.STATUS_LOGIN_FAILED),
"sync-error": !this.syncIsWorking && !this.syncHasWorked,
};
for (let [type, value] of Object.entries(errorStates)) {
if (value) {
return type;
}
}
return null;
}
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);
}
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 {
this.maybeUpdateUI();
}
this._lastFxASignedIn = this.fxaSignedIn;
break;
case TOPIC_DEVICELIST_UPDATED:
this.logger.debug("Handling observer notification:", topic, data);
if (this.refreshDevices()) {
this.logger.debug(
"refreshDevices made changes, calling maybeUpdateUI"
);
this.maybeUpdateUI(true);
}
break;
case "fxaccounts:device_connected":
case "fxaccounts:device_disconnected":
await lazy.fxAccounts.device.refreshDeviceList();
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._waitingForTabs = false;
this.syncIsWorking = false;
this.maybeUpdateUI(true);
}
break;
case NETWORK_STATUS_CHANGED:
this.networkIsOnline = data == "online";
this._waitingForTabs = false;
this.maybeUpdateUI(true);
break;
case SYNC_SERVICE_FINISHED:
this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`);
this._waitingForTabs = false;
if (!this.syncIsWorking) {
this.syncIsWorking = true;
this.syncHasWorked = true;
}
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;
}
}
get waitingForTabs() {
return (
// signed in & at least 1 other device is sycning indicates there's something to wait for
this.secondaryDeviceConnected &&
// last recent tabs request came back empty and we've not had a sync finish (or error) yet
this._waitingForTabs
);
}
startWaitingForTabs() {
if (!this._waitingForTabs) {
this._waitingForTabs = true;
Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
}
}
stopWaitingForTabs() {
if (this._waitingForTabs) {
this._waitingForTabs = false;
Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
}
}
async onSignedInChange() {
this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn);
// update UI to make the state change
this.maybeUpdateUI(true);
if (!this.fxaSignedIn) {
// As we just signed out, ensure the waiting flag is reset for next time around
this._waitingForTabs = false;
return;
}
// Set Tab pickup open state pref to true when signing in
Services.prefs.setBoolPref(TAB_PICKUP_OPEN_STATE_PREF, true);
// 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
if (this.refreshDevices()) {
this.logger.debug(
"onSignedInChange, after refreshDevices, calling maybeUpdateUI"
);
// give the UI an opportunity to update as secondaryDeviceConnected or
// mobileDeviceConnected have changed value
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();
}
});
}
}
refreshDevices() {
// compare new values to the previous values
const mobileDeviceConnected = this.mobileDeviceConnected;
const secondaryDeviceConnected = this.secondaryDeviceConnected;
this.logger.debug(
`refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `,
`secondaryDeviceConnected: ${secondaryDeviceConnected}`
);
let didDeviceStateChange =
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
Services.prefs.clearUserPref(MOBILE_PROMO_DISMISSED_PREF);
this._shouldShowSuccessConfirmation = false;
}
this._deviceStateSnapshot = {
mobileDeviceConnected,
secondaryDeviceConnected,
};
if (didDeviceStateChange) {
this.logger.debug("refreshDevices: device state did change");
if (!secondaryDeviceConnected) {
this.logger.debug(
"We lost a device, now claim sync hasn't worked before."
);
this.syncHasWorked = false;
}
} else {
this.logger.debug("refreshDevices: no device state change");
}
return didDeviceStateChange;
}
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) {
errorState = this.getErrorType();
this.logger.debug("maybeUpdateUI, in error state:", errorState);
}
Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED, errorState);
}
if ("function" == typeof setupState.enter) {
setupState.enter();
}
}
dismissMobilePromo() {
Services.prefs.setBoolPref(MOBILE_PROMO_DISMISSED_PREF, true);
}
dismissMobileConfirmation() {
this._shouldShowSuccessConfirmation = false;
this._didShowMobilePromo = false;
this.maybeUpdateUI(true);
}
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);
Services.telemetry.recordEvent("firefoxview", "fxa_continue", "sync", null);
}
async openFxAPairDevice(window) {
const url = await lazy.fxAccounts.constructor.config.promisePairingURI({
entrypoint: "fx-view",
});
this.didFxaTabOpen = true;
openTabInWindow(window, url, true);
Services.telemetry.recordEvent("firefoxview", "fxa_mobile", "sync", null, {
has_devices: this.secondaryDeviceConnected.toString(),
});
}
syncOpenTabs(containerElem) {
// 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();
Services.tm.dispatchToMainThread(() => {
this.logger.debug("tryToClearError: triggering new tab sync");
this.startFullTabsSync();
});
} 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);
}
startFullTabsSync() {
lazy.Weave.Service.sync({ why: "tabs", engines: ["tabs"] });
}
})();