fune/browser/components/firefoxview/firefox-view-tabs-setup-manager.sys.mjs

440 lines
13 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/. */
"use strict";
/**
* This module exports the TabsSetupFlowManager singleton, which manages the state and
* diverse inputs which drive the Firefox View synced tabs setup flow
*/
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const lazy = {};
XPCOMUtils.defineLazyModuleGetters(lazy, {
Log: "resource://gre/modules/Log.jsm",
UIState: "resource://services-sync/UIState.jsm",
SyncedTabs: "resource://services-sync/SyncedTabs.jsm",
});
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 RECENT_TABS_SYNC = "services.sync.lastTabFetch";
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";
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.registerSetupState({
uiStateIndex: 0,
name: "error-state",
exitConditions: () => {
return (
this.networkIsOnline &&
(this.syncIsWorking || this.syncHasWorked) &&
!Services.prefs.prefIsLocked(FXA_ENABLED) &&
// its only an error for sync to not be connected if we are signed-in.
(this.syncIsConnected || !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-not-ready",
enter: () => {
if (!this.didRecentTabSync) {
lazy.SyncedTabs.syncTabs();
}
},
exitConditions: () => {
return this.didRecentTabSync;
},
});
this.registerSetupState({
uiStateIndex: 5,
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);
// this.syncTabsPrefEnabled will track the value of the tabs pref
XPCOMUtils.defineLazyPreferenceGetter(
this,
"syncTabsPrefEnabled",
SYNC_TABS_PREF,
false,
() => {
this._uiUpdateNeeded = true;
this.maybeUpdateUI();
}
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"lastTabFetch",
RECENT_TABS_SYNC,
0,
() => {
this._uiUpdateNeeded = true;
this.maybeUpdateUI();
}
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"mobilePromoDismissedPref",
MOBILE_PROMO_DISMISSED_PREF,
false,
() => {
this._uiUpdateNeeded = true;
this.maybeUpdateUI();
}
);
this._uiUpdateNeeded = true;
if (this.fxaSignedIn) {
this.refreshDevices();
}
this.maybeUpdateUI();
}
resetInternalState() {
// assign initial values for all the managed internal properties
this._currentSetupStateName = "not-signed-in";
this._shouldShowSuccessConfirmation = false;
this._didShowMobilePromo = false;
this._uiUpdateNeeded = true;
this.syncHasWorked = false;
// keep track of what is connected so we can respond to changes
this._deviceStateSnapshot = {
mobileDeviceConnected: this.mobileDeviceConnected,
secondaryDeviceConnected: this.secondaryDeviceConnected,
};
}
getErrorType() {
// this ordering is important for dealing with multiple errors at once
const errorStates = {
"network-offline": !this.networkIsOnline,
"sync-error": !this.syncIsWorking && !this.syncHasWorked,
"fxa-admin-disabled": Services.prefs.prefIsLocked(FXA_ENABLED),
"sync-disconnected": !this.syncIsConnected,
};
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);
}
get currentSetupState() {
return this.setupState.get(this._currentSetupStateName);
}
get uiStateIndex() {
return this.currentSetupState.uiStateIndex;
}
get fxaSignedIn() {
let { UIState } = lazy;
return (
UIState.isReady() && UIState.get().status === UIState.STATUS_SIGNED_IN
);
}
get secondaryDeviceConnected() {
if (!this.fxaSignedIn) {
return false;
}
let recentDevices = lazy.fxAccounts.device?.recentDeviceList?.length;
return recentDevices > 1;
}
get didRecentTabSync() {
const nowSeconds = Math.floor(Date.now() / 1000);
return (
nowSeconds - this.lastTabFetch <
lazy.SyncedTabs.TABS_FRESH_ENOUGH_INTERVAL_SECONDS
);
}
get mobileDeviceConnected() {
if (!this.fxaSignedIn) {
return false;
}
let mobileClients = lazy.fxAccounts.device.recentDeviceList?.filter(
device => device.type == "mobile"
);
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;
this.maybeUpdateUI();
break;
case TOPIC_DEVICELIST_UPDATED:
this.logger.debug("Handling observer notification:", topic, data);
this.refreshDevices();
this.maybeUpdateUI();
break;
case "fxaccounts:device_connected":
case "fxaccounts:device_disconnected":
await lazy.fxAccounts.device.refreshDeviceList();
this.maybeUpdateUI();
break;
case SYNC_SERVICE_ERROR:
this.syncIsWorking = false;
this.maybeUpdateUI();
break;
case NETWORK_STATUS_CHANGED:
this.networkIsOnline = data == "online";
this.maybeUpdateUI();
break;
case SYNC_SERVICE_FINISHED:
if (!this.syncIsWorking) {
this.syncIsWorking = true;
this.syncHasWorked = true;
this.maybeUpdateUI();
}
}
}
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, call maybeUpdateUI"
);
if (!secondaryDeviceConnected) {
this.logger.debug(
"We lost a device, now claim sync hasn't worked before."
);
this.syncHasWorked = false;
}
this._uiUpdateNeeded = true;
} else {
this.logger.debug("refreshDevices: no device state change");
}
}
maybeUpdateUI() {
let nextSetupStateName = this._currentSetupStateName;
let errorState = null;
// 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;
this._uiUpdateNeeded = true;
}
this.logger.debug("maybeUpdateUI, _uiUpdateNeeded:", this._uiUpdateNeeded);
if (this._uiUpdateNeeded) {
this._uiUpdateNeeded = false;
if (this.shouldShowMobilePromo) {
this._didShowMobilePromo = true;
}
if (uiStateIndex == 0) {
errorState = this.getErrorType();
}
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._uiUpdateNeeded = true;
this.maybeUpdateUI();
}
async openFxASignup(window) {
const url = await lazy.fxAccounts.constructor.config.promiseConnectAccountURI(
"firefoxview"
);
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",
});
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);
}
forceSyncTabs() {
lazy.SyncedTabs.syncTabs(true);
}
})();