UIState.sys.mjs (8646B)
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/. */ 4 5 /** 6 * @typedef {object} UIState 7 * @property {string} status The Sync/FxA status, see STATUS_* constants. 8 * @property {string} [email] The FxA email configured to log-in with Sync. 9 * @property {string} [displayName] The user's FxA display name. 10 * @property {string} [avatarURL] The user's FxA avatar URL. 11 * @property {Date} [lastSync] The last sync time. 12 * @property {boolean} [syncing] Whether or not we are currently syncing. 13 * @property {boolean} [hasSyncKeys] Whether the user has sync keys available. 14 */ 15 16 import { SCOPE_APP_SYNC } from "resource://gre/modules/FxAccountsCommon.sys.mjs"; 17 18 const lazy = {}; 19 ChromeUtils.defineESModuleGetters(lazy, { 20 LOGIN_FAILED_LOGIN_REJECTED: "resource://services-sync/constants.sys.mjs", 21 Weave: "resource://services-sync/main.sys.mjs", 22 }); 23 24 const TOPICS = [ 25 "weave:connected", 26 "weave:service:login:got-hashed-id", 27 "weave:service:login:error", 28 "weave:service:ready", 29 "weave:service:sync:start", 30 "weave:service:sync:finish", 31 "weave:service:sync:error", 32 "weave:service:start-over:finish", 33 "fxaccounts:onverified", 34 "fxaccounts:onlogin", // Defined in FxAccountsCommon, pulling it is expensive. 35 "fxaccounts:onlogout", 36 "fxaccounts:profilechange", 37 "fxaccounts:statechange", 38 ]; 39 40 const ON_UPDATE = "sync-ui-state:update"; 41 42 const STATUS_NOT_CONFIGURED = "not_configured"; 43 const STATUS_LOGIN_FAILED = "login_failed"; 44 const STATUS_NOT_VERIFIED = "not_verified"; 45 const STATUS_SIGNED_IN = "signed_in"; 46 47 const DEFAULT_STATE = { 48 status: STATUS_NOT_CONFIGURED, 49 }; 50 51 const UIStateInternal = { 52 _initialized: false, 53 _state: null, 54 55 // We keep _syncing out of the state object because we can only track it 56 // using sync events and we can't determine it at any point in time. 57 _syncing: false, 58 59 get state() { 60 if (!this._state) { 61 return DEFAULT_STATE; 62 } 63 return Object.assign({}, this._state, { syncing: this._syncing }); 64 }, 65 66 isReady() { 67 if (!this._initialized) { 68 this.init(); 69 return false; 70 } 71 return true; 72 }, 73 74 init() { 75 this._initialized = true; 76 // Because the FxA toolbar is usually visible, this module gets loaded at 77 // browser startup, and we want to avoid pulling in all of FxA or Sync at 78 // that time, so we refresh the state after the browser has settled. 79 Services.tm.idleDispatchToMainThread(() => { 80 this.refreshState().catch(e => { 81 console.error(e); 82 }); 83 }, 2000); 84 }, 85 86 // Used for testing. 87 reset() { 88 this._state = null; 89 this._syncing = false; 90 this._initialized = false; 91 }, 92 93 observe(subject, topic) { 94 switch (topic) { 95 case "weave:service:sync:start": 96 this.toggleSyncActivity(true); 97 break; 98 case "weave:service:sync:finish": 99 case "weave:service:sync:error": 100 this.toggleSyncActivity(false); 101 break; 102 default: 103 this.refreshState().catch(e => { 104 console.error(e); 105 }); 106 break; 107 } 108 }, 109 110 // Builds a new state from scratch. 111 async refreshState() { 112 const newState = {}; 113 await this._refreshFxAState(newState); 114 // Optimize the "not signed in" case to avoid refreshing twice just after 115 // startup - if there's currently no _state, and we still aren't configured, 116 // just early exit. 117 if (this._state == null && newState.status == DEFAULT_STATE.status) { 118 return this.state; 119 } 120 if (newState.syncEnabled) { 121 this._setLastSyncTime(newState); // We want this in case we change accounts. 122 } 123 this._state = newState; 124 125 this.notifyStateUpdated(); 126 return this.state; 127 }, 128 129 // Update the current state with the last sync time/currently syncing status. 130 toggleSyncActivity(syncing) { 131 this._syncing = syncing; 132 this._setLastSyncTime(this._state); 133 134 this.notifyStateUpdated(); 135 }, 136 137 notifyStateUpdated() { 138 Services.obs.notifyObservers(null, ON_UPDATE); 139 }, 140 141 async _refreshFxAState(newState) { 142 let userData = await this._getUserData(); 143 await this._populateWithUserData(newState, userData); 144 }, 145 146 async _populateWithUserData(state, userData) { 147 let status; 148 let syncUserName = Services.prefs.getStringPref( 149 "services.sync.username", 150 "" 151 ); 152 if (!userData) { 153 // If Sync thinks it is configured but there's no FxA user, then we 154 // want to enter the "login failed" state so the user can get 155 // reconfigured. 156 if (syncUserName) { 157 state.email = syncUserName; 158 status = STATUS_LOGIN_FAILED; 159 } else { 160 // everyone agrees nothing is configured. 161 status = STATUS_NOT_CONFIGURED; 162 } 163 } else { 164 let loginFailed = await this._loginFailed(); 165 if (loginFailed) { 166 status = STATUS_LOGIN_FAILED; 167 } else if (!userData.verified) { 168 status = STATUS_NOT_VERIFIED; 169 } else { 170 status = STATUS_SIGNED_IN; 171 } 172 state.uid = userData.uid; 173 state.email = userData.email; 174 state.displayName = userData.displayName; 175 // for better or worse, this module renames these attribues. 176 state.avatarURL = userData.avatar; 177 state.avatarIsDefault = userData.avatarDefault; 178 state.syncEnabled = !!syncUserName; 179 state.hasSyncKeys = 180 await this.fxAccounts.keys.hasKeysForScope(SCOPE_APP_SYNC); 181 } 182 state.status = status; 183 }, 184 185 async _getUserData() { 186 try { 187 return await this.fxAccounts.getSignedInUser(); 188 } catch (e) { 189 // This is most likely in tests, where we quickly log users in and out. 190 // The most likely scenario is a user logged out, so reflect that. 191 // Bug 995134 calls for better errors so we could retry if we were 192 // sure this was the failure reason. 193 console.error("Error updating FxA account info:", e); 194 return null; 195 } 196 }, 197 198 _setLastSyncTime(state) { 199 if (state?.status == UIState.STATUS_SIGNED_IN) { 200 const lastSync = Services.prefs.getStringPref( 201 "services.sync.lastSync", 202 null 203 ); 204 state.lastSync = lastSync ? new Date(lastSync) : null; 205 } 206 }, 207 208 async _loginFailed() { 209 // First ask FxA if it thinks the user needs re-authentication. In practice, 210 // this check is probably canonical (ie, we probably don't really need 211 // the check below at all as we drop local session info on the first sign 212 // of a problem) - but we keep it for now to keep the risk down. 213 let hasLocalSession = await this.fxAccounts.hasLocalSession(); 214 if (!hasLocalSession) { 215 return true; 216 } 217 218 // Referencing Weave.Service will implicitly initialize sync, and we don't 219 // want to force that - so first check if it is ready. 220 let service = Cc["@mozilla.org/weave/service;1"].getService( 221 Ci.nsISupports 222 ).wrappedJSObject; 223 if (!service.ready) { 224 return false; 225 } 226 // LOGIN_FAILED_LOGIN_REJECTED explicitly means "you must log back in". 227 // All other login failures are assumed to be transient and should go 228 // away by themselves, so aren't reflected here. 229 return lazy.Weave.Status.login == lazy.LOGIN_FAILED_LOGIN_REJECTED; 230 }, 231 232 set fxAccounts(mockFxAccounts) { 233 delete this.fxAccounts; 234 this.fxAccounts = mockFxAccounts; 235 }, 236 }; 237 238 ChromeUtils.defineLazyGetter(UIStateInternal, "fxAccounts", () => { 239 return ChromeUtils.importESModule( 240 "resource://gre/modules/FxAccounts.sys.mjs" 241 ).getFxAccountsSingleton(); 242 }); 243 244 for (let topic of TOPICS) { 245 Services.obs.addObserver(UIStateInternal, topic); 246 } 247 248 export var UIState = { 249 _internal: UIStateInternal, 250 251 ON_UPDATE, 252 253 STATUS_NOT_CONFIGURED, 254 STATUS_LOGIN_FAILED, 255 STATUS_NOT_VERIFIED, 256 STATUS_SIGNED_IN, 257 258 /** 259 * Returns true if the module has been initialized and the state set. 260 * If not, return false and trigger an init in the background. 261 */ 262 isReady() { 263 return this._internal.isReady(); 264 }, 265 266 /** 267 * @returns {UIState} The current Sync/FxA UI State. 268 */ 269 get() { 270 return this._internal.state; 271 }, 272 273 /** 274 * Refresh the state. Used for testing, don't call this directly since 275 * UIState already listens to Sync/FxA notifications to determine if the state 276 * needs to be refreshed. ON_UPDATE will be fired once the state is refreshed. 277 * 278 * @returns {Promise<UIState>} Resolved once the state is refreshed. 279 */ 280 refresh() { 281 return this._internal.refreshState(); 282 }, 283 284 /** 285 * Reset the state of the whole module. Used for testing. 286 */ 287 reset() { 288 this._internal.reset(); 289 }, 290 };