diff options
Diffstat (limited to 'services/sync/modules/UIState.sys.mjs')
-rw-r--r-- | services/sync/modules/UIState.sys.mjs | 285 |
1 files changed, 285 insertions, 0 deletions
diff --git a/services/sync/modules/UIState.sys.mjs b/services/sync/modules/UIState.sys.mjs new file mode 100644 index 0000000000..8981d81f7d --- /dev/null +++ b/services/sync/modules/UIState.sys.mjs @@ -0,0 +1,285 @@ +/* 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/. */ + +/** + * @typedef {Object} UIState + * @property {string} status The Sync/FxA status, see STATUS_* constants. + * @property {string} [email] The FxA email configured to log-in with Sync. + * @property {string} [displayName] The user's FxA display name. + * @property {string} [avatarURL] The user's FxA avatar URL. + * @property {Date} [lastSync] The last sync time. + * @property {boolean} [syncing] Whether or not we are currently syncing. + */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + LOGIN_FAILED_LOGIN_REJECTED: "resource://services-sync/constants.sys.mjs", + Weave: "resource://services-sync/main.sys.mjs", +}); + +const TOPICS = [ + "weave:connected", + "weave:service:login:got-hashed-id", + "weave:service:login:error", + "weave:service:ready", + "weave:service:sync:start", + "weave:service:sync:finish", + "weave:service:sync:error", + "weave:service:start-over:finish", + "fxaccounts:onverified", + "fxaccounts:onlogin", // Defined in FxAccountsCommon, pulling it is expensive. + "fxaccounts:onlogout", + "fxaccounts:profilechange", + "fxaccounts:statechange", +]; + +const ON_UPDATE = "sync-ui-state:update"; + +const STATUS_NOT_CONFIGURED = "not_configured"; +const STATUS_LOGIN_FAILED = "login_failed"; +const STATUS_NOT_VERIFIED = "not_verified"; +const STATUS_SIGNED_IN = "signed_in"; + +const DEFAULT_STATE = { + status: STATUS_NOT_CONFIGURED, +}; + +const UIStateInternal = { + _initialized: false, + _state: null, + + // We keep _syncing out of the state object because we can only track it + // using sync events and we can't determine it at any point in time. + _syncing: false, + + get state() { + if (!this._state) { + return DEFAULT_STATE; + } + return Object.assign({}, this._state, { syncing: this._syncing }); + }, + + isReady() { + if (!this._initialized) { + this.init(); + return false; + } + return true; + }, + + init() { + this._initialized = true; + // Because the FxA toolbar is usually visible, this module gets loaded at + // browser startup, and we want to avoid pulling in all of FxA or Sync at + // that time, so we refresh the state after the browser has settled. + Services.tm.idleDispatchToMainThread(() => { + this.refreshState().catch(e => { + console.error(e); + }); + }, 2000); + }, + + // Used for testing. + reset() { + this._state = null; + this._syncing = false; + this._initialized = false; + }, + + observe(subject, topic, data) { + switch (topic) { + case "weave:service:sync:start": + this.toggleSyncActivity(true); + break; + case "weave:service:sync:finish": + case "weave:service:sync:error": + this.toggleSyncActivity(false); + break; + default: + this.refreshState().catch(e => { + console.error(e); + }); + break; + } + }, + + // Builds a new state from scratch. + async refreshState() { + const newState = {}; + await this._refreshFxAState(newState); + // Optimize the "not signed in" case to avoid refreshing twice just after + // startup - if there's currently no _state, and we still aren't configured, + // just early exit. + if (this._state == null && newState.status == DEFAULT_STATE.status) { + return this.state; + } + if (newState.syncEnabled) { + this._setLastSyncTime(newState); // We want this in case we change accounts. + } + this._state = newState; + + this.notifyStateUpdated(); + return this.state; + }, + + // Update the current state with the last sync time/currently syncing status. + toggleSyncActivity(syncing) { + this._syncing = syncing; + this._setLastSyncTime(this._state); + + this.notifyStateUpdated(); + }, + + notifyStateUpdated() { + Services.obs.notifyObservers(null, ON_UPDATE); + }, + + async _refreshFxAState(newState) { + let userData = await this._getUserData(); + await this._populateWithUserData(newState, userData); + }, + + async _populateWithUserData(state, userData) { + let status; + let syncUserName = Services.prefs.getStringPref( + "services.sync.username", + "" + ); + if (!userData) { + // If Sync thinks it is configured but there's no FxA user, then we + // want to enter the "login failed" state so the user can get + // reconfigured. + if (syncUserName) { + state.email = syncUserName; + status = STATUS_LOGIN_FAILED; + } else { + // everyone agrees nothing is configured. + status = STATUS_NOT_CONFIGURED; + } + } else { + let loginFailed = await this._loginFailed(); + if (loginFailed) { + status = STATUS_LOGIN_FAILED; + } else if (!userData.verified) { + status = STATUS_NOT_VERIFIED; + } else { + status = STATUS_SIGNED_IN; + } + state.uid = userData.uid; + state.email = userData.email; + state.displayName = userData.displayName; + // for better or worse, this module renames these attribues. + state.avatarURL = userData.avatar; + state.avatarIsDefault = userData.avatarDefault; + state.syncEnabled = !!syncUserName; + } + state.status = status; + }, + + async _getUserData() { + try { + return await this.fxAccounts.getSignedInUser(); + } catch (e) { + // This is most likely in tests, where we quickly log users in and out. + // The most likely scenario is a user logged out, so reflect that. + // Bug 995134 calls for better errors so we could retry if we were + // sure this was the failure reason. + console.error("Error updating FxA account info:", e); + return null; + } + }, + + _setLastSyncTime(state) { + if (state?.status == UIState.STATUS_SIGNED_IN) { + const lastSync = Services.prefs.getStringPref( + "services.sync.lastSync", + null + ); + state.lastSync = lastSync ? new Date(lastSync) : null; + } + }, + + async _loginFailed() { + // First ask FxA if it thinks the user needs re-authentication. In practice, + // this check is probably canonical (ie, we probably don't really need + // the check below at all as we drop local session info on the first sign + // of a problem) - but we keep it for now to keep the risk down. + let hasLocalSession = await this.fxAccounts.hasLocalSession(); + if (!hasLocalSession) { + return true; + } + + // Referencing Weave.Service will implicitly initialize sync, and we don't + // want to force that - so first check if it is ready. + let service = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + if (!service.ready) { + return false; + } + // LOGIN_FAILED_LOGIN_REJECTED explicitly means "you must log back in". + // All other login failures are assumed to be transient and should go + // away by themselves, so aren't reflected here. + return lazy.Weave.Status.login == lazy.LOGIN_FAILED_LOGIN_REJECTED; + }, + + set fxAccounts(mockFxAccounts) { + delete this.fxAccounts; + this.fxAccounts = mockFxAccounts; + }, +}; + +ChromeUtils.defineLazyGetter(UIStateInternal, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +for (let topic of TOPICS) { + Services.obs.addObserver(UIStateInternal, topic); +} + +export var UIState = { + _internal: UIStateInternal, + + ON_UPDATE, + + STATUS_NOT_CONFIGURED, + STATUS_LOGIN_FAILED, + STATUS_NOT_VERIFIED, + STATUS_SIGNED_IN, + + /** + * Returns true if the module has been initialized and the state set. + * If not, return false and trigger an init in the background. + */ + isReady() { + return this._internal.isReady(); + }, + + /** + * @returns {UIState} The current Sync/FxA UI State. + */ + get() { + return this._internal.state; + }, + + /** + * Refresh the state. Used for testing, don't call this directly since + * UIState already listens to Sync/FxA notifications to determine if the state + * needs to be refreshed. ON_UPDATE will be fired once the state is refreshed. + * + * @returns {Promise<UIState>} Resolved once the state is refreshed. + */ + refresh() { + return this._internal.refreshState(); + }, + + /** + * Reset the state of the whole module. Used for testing. + */ + reset() { + this._internal.reset(); + }, +}; |