VulpesOS/assets/webapps/system.gaiamobile.org/js/sync_manager.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

926 lines
30 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';
/* global asyncStorage */
/* global BaseModule */
/* global ERROR_INVALID_SYNC_ACCOUNT */
/* global ERROR_NO_KEY_FETCH_TOKEN */
/* global ERROR_REQUEST_SYNC_REGISTRATION */
/* global ERROR_SYNC_APP_GENERIC */
/* global ERROR_SYNC_APP_KILLED */
/* global ERROR_SYNC_REQUEST */
/* global ERROR_UNVERIFIED_ACCOUNT */
/* global FxAccountsClient */
/* global IACHandler */
/* global LazyLoader */
/* global Service */
/* global SyncErrors */
/* global SyncRecoverableErrors */
/* global Telemetry */
/* global uuid */
/**
* This module manages the lifecycle of Firefox Sync on Firefox OS.
* It connects the Settings application where the Firefox Sync UI is presented
* to the user with the Sync app that is responsible of doing the actual
* synchronization of the user's data.
*
* SyncManager reacts to changes done on the Settings Sync UI that triggers
* IAC requests or Settings API changes and translates these changes into
* actions. It makes use of the SyncStateMachine to control that these actions
* are possible on the current situation of Sync and communicates with the
* Sync application via IAC API messages when needed.
*
* The IAC based API that is exposed to the Settings app (or any other
* future consumer that requires to manage Sync) expects IAC messages with
* a single property 'name' that can have any of the following values:
* 'enable': requests to enable Sync.
* 'disable': requests to disable Sync.
* 'sync': requests a sync on demand. Although we have scheduled
* synchronizations, we allow on demand sync requests.
* 'getInfo': gets the current information about the state of Sync
* including the state (enabled, disabled, etc.), the
* last time a successful sync happened and the last error
* if there's any.
*
* It also triggers one message through the same IAC API with name
* 'onsyncchange' when something (state, error, lastSync) changes on Sync.
*
* SyncManager also communicates with the Sync app via IAC. Every time a
* synchronization is required (either because the requestSync timer fires or
* because the user requested a sync on demand), we wake the Sync app up and
* give it the required information to perform the synchronization that
* includes the URL of the Syncto server, a valid FxA assertion and its
* associated encryption keys and the list of collections to be synchronized.
*/
(function(exports) {
const FXA_EVENT = 'mozFxAccountsUnsolChromeEvent';
const SYNC_TASK = 'gaia::system::firefoxsync';
const SYNC_REQUEST_IAC_KEYWORD = 'gaia::sync::request';
const SYNC_MANAGEMENT_API_IAC_KEYWORD = 'gaia-sync-management';
const COLLECTIONS = ['history', 'passwords', 'bookmarks'];
// Keys of asyncStorage persisted data.
const SYNC_STATE = 'sync.state';
const SYNC_STATE_ERROR = 'sync.state.error';
const SYNC_LAST_TIME = 'sync.lastTime';
const SYNC_USER = 'sync.user';
var SyncManager = function() {};
SyncManager.STATES = [];
SyncManager.SERVICES = [];
SyncManager.SETTINGS = [
/** READ ONLY SETTINGS **/
// The user can choose the collections she wants to sync. These settings
// indicates the user choice per collection.
'sync.collections.history.enabled',
'sync.collections.passwords.enabled',
'sync.collections.bookmarks.enabled',
// Setting any of these two settings to true will make the synchronization
// of the collection readonly. That means that we will only be retrieving
// data from Firefox Sync and we won't be pushing any of the local
// modifications to the collections source.
'sync.collections.history.readonly',
'sync.collections.passwords.readonly',
'sync.collections.bookmarks.readonly',
// Limit the number of records fetched from the server in a single request.
// Once the sync engine supports pagination, the limit will be the page
// size.
'sync.collections.history.limit',
'sync.collections.passwords.limit',
'sync.collections.bookmarks.limit',
'sync.server.url',
'sync.scheduler.interval',
'sync.scheduler.wifionly',
'sync.fxa.audience'
];
SyncManager.EVENTS = [
/** IAC based sync management API **/
'iac-' + SYNC_MANAGEMENT_API_IAC_KEYWORD,
/** Sync State Machine events **/
'onsyncdisabled',
'onsyncdisabling',
'onsyncenabled',
'onsyncenabling',
'onsyncerrored',
'onsyncsyncing',
/** Sync app lifecycle **/
'appterminated'
];
SyncManager.SUB_MODULES = [
'SyncStateMachine'
];
BaseModule.create(SyncManager, {
name: 'SyncManager',
DEBUG: false,
EVENT_PREFIX: 'sync',
store: new Map(),
/** BaseModule life cycle **/
_start: function() {
return new Promise((resolve, reject) => {
LazyLoader.load('/shared/js/sync/errors.js', () => {
navigator.mozSetMessageHandler('request-sync', event => {
this.debug('Sync request');
if (event.task != SYNC_TASK) {
return;
}
Service.request('SyncStateMachine:sync');
});
this.initStore().then(() => {
this.debug('Sync.state setting initial value', this.state);
// SyncStateMachine starts by default with 'disabled', so we can
// request to enable sync or stay in the disabled state.
// We need to make sure that we didn't end up in an inconsistent
// state during the latest run (i.e. error, success, syncing,
// enabling, disabling).
switch(this.state) {
case 'enabled':
case 'success':
case 'syncing':
Service.request('SyncStateMachine:enable').then(resolve);
break;
case 'disabling':
case 'enabling':
this.updateState('disabled');
resolve();
break;
case 'errored':
if (this.error == ERROR_UNVERIFIED_ACCOUNT) {
// We try to enable Sync. This will check if the account is
// verified or not. If it's not verified, we go back to the
// errored state, otherwise, the user logs in.
Service.request('SyncStateMachine:enable').then(resolve);
break;
}
this.updateState('disabled');
resolve();
break;
case 'disabled':
resolve();
break;
default:
console.error('Unexpected sync.state value', this.state);
reject();
break;
}
});
});
});
},
_stop: function() {},
/** Event handlers **/
// We expose a very simple IAC based API to allow the Settings app to
// manage Sync.
//
// We expect IAC messages to contain a property named 'name' with any
// of the following values corresponding to the requests that we are
// able to handle:
// - enable
// - disable
// - sync
// - getInfo
//
// SyncStateMachine is responsible of checking if the request is valid in
// the current situation and progressing it accordingly by publishing the
// corresponding events.
'_handle_iac-gaia-sync-management': function(event) {
if (!event || !event.detail) {
console.error('Wrong IAC Sync event');
return;
}
var request = event.detail;
switch(request.name) {
case 'enable':
case 'disable':
case 'sync':
try {
Service.request('SyncStateMachine:' + request.name).then(() => {
return LazyLoader.load('/shared/js/sync/telemetry.js');
}).then(() => {
Telemetry.logUserAction(request.name);
}).catch(e => {
console.error(e);
});
} catch(e) {
console.error(e);
}
break;
case 'getInfo':
if (!request.id) {
console.warn('IAC request ignored. Missing id');
return;
}
this.getAccount().then(() => {
this.managementPortMessage({
id: request.id,
state: this.state,
error: this.error,
lastSync: this.lastSync,
user: this.user
});
});
break;
default:
console.warn('Unknown IAC Sync request', request.name);
break;
}
},
/** SyncStateMachine event handlers **/
_handle_onsyncdisabled: function() {
this.debug('onsyncdisabled observed');
this.updateState();
this.user = null;
this.lastSync = null;
this.error = null;
this.cleanup();
},
_handle_onsyncdisabling: function() {
this.debug('onsyncdisabling observed');
this.updateState();
// Go back to default settings, so new users won't inherit old users'
// preferences.
this.saveUserSettings().then(() => {
return this.restoreDefaultSettings();
}).then(() => {
Service.request('SyncStateMachine:success');
});
},
_handle_onsyncenabled: function(event) {
this.debug('onsyncenabled observed');
this.updateState();
// If we got to this point with no user, something went wrong, so we
// need to disable Sync.
if (!this.user) {
this.debug('No user');
Service.request('SyncStateMachine:disable');
return;
}
this.registerSyncRequest().then(() => {
this.debug('Sync request registered');
var from = event.detail && event.detail.from;
// After login in with a new account or rebooting the device,
// we trigger a sync request.
if (from === 'enabling') {
Service.request('SyncStateMachine:sync');
}
}).catch(error => {
console.error('Could not register sync request', error);
Service.request('SyncStateMachine:error',
ERROR_REQUEST_SYNC_REGISTRATION);
});
},
_handle_onsyncenabling: function() {
this.debug('onsyncenabling observed');
this.updateState();
// Because we need to bind this to the event handler, we need to save
// a reference to it so we can properly remove it later.
this.fxaEventHandler = this._handle_fxaEvent.bind(this);
window.addEventListener(FXA_EVENT, this.fxaEventHandler);
// We need to verify that we have everything that we need to start
// synchronizing data. This is: a valid FxA, the ability to obtain
// a valid assertion and encryption keys and the remote crypto/keys
// record.
var args = [];
this.updateStateDeferred.then(() => {
return this.getAssertion();
}).then(assertion =>{
args.push(assertion);
return this.getKeys();
}).then(keys => {
args.push(keys);
return this.getAccount();
}).then(() => {
// We request a sync without any collection. This should fetch the
// crypto/keys object. If it is available, we will successfully
// continue the enabling process. If it is not available, that probably
// means that this FxA has never been used before with Sync.
// In that case, until we are able to create new Sync user, we need
// to disable Sync and let the user know about this situation.
return this.trySync.apply(this, args);
}).then(() => {
// We save default settings so we can revert user changes to the
// default values when the user logs out from Sync. So new users don't
// inherit old users' preferences.
return this.saveDefaultSettings();
}).then(() => {
return this.restoreUserSettings();
}).then(() => {
Service.request('SyncStateMachine:success');
}).catch(error => {
// XXX Bug 1200284 - Normalize all Firefox Accounts error reporting.
error = error.message || error.name || error.error || error;
console.error('Could not enable sync', error);
if (error == 'UNVERIFIED_ACCOUNT') {
this.getAccount().then(() => {
Service.request('SyncStateMachine:error', ERROR_UNVERIFIED_ACCOUNT);
});
return;
}
error = SyncErrors[error] || error;
Service.request('SyncStateMachine:error', error);
});
},
_handle_onsyncerrored: function(event) {
this.debug('onsyncerrored observed');
var error = event.detail && Array.isArray(event.detail.args) ?
event.detail.args[0] : null;
if (!error) {
return;
}
this.error = error;
// We don't update the state until we set the error.
this.updateState();
var from = event.detail && event.detail.from;
// There are some cases where we want to show to the user
// the enabled state but warn her about the invalid state of her
// account. So we stay in the errored state.
if ([ERROR_UNVERIFIED_ACCOUNT].indexOf(error) > -1) {
return;
}
// If the error is recoverable and we are coming from the syncing
// state, we go back to the enabled state, otherwise, we disable Sync.
if ((SyncRecoverableErrors.indexOf(error) > -1 && from === 'syncing') ||
// XXX ERROR_INVALID_SYNC_ACCOUNT is an unrecoverable error, but we
// temporarily treat it in a different way depending on the device.
// On TVs, we show a dialog asking the user to go to Desktop or
// Android to create an account and we move to the disabled state.
// On phones, we move to the enabled state, but we show a warning to
// the user about her account being empty.
error == ERROR_INVALID_SYNC_ACCOUNT) {
Service.request('SyncStateMachine:enable');
return;
}
// If the current FxA login does not contain the token to fetch sync keys
// we need to log the user out. Unfortunately the FxA auth server does not
// allow to obtain this token outside of the initial sign in flow.
if (error == ERROR_NO_KEY_FETCH_TOKEN) {
// No need to disable Sync here as we will be doing it as soon as the
// logout is done.
FxAccountsClient.logout();
return;
}
this.debug('Unrecoverable error');
Service.request('SyncStateMachine:disable');
},
_handle_onsyncsyncing: function() {
this.debug('onsyncsyncing observed');
this.updateState();
// In case that we are syncing on user demand, we unregister the
// sync request.
// It's harmless to unregister an expired request, so it's also ok
// to do it for a periodic sync.
this.unregisterSyncRequest();
if (!navigator.onLine) {
Service.request('SyncStateMachine:success');
return;
}
var collections = {};
COLLECTIONS.forEach(name => {
if (this._settings['sync.collections.' + name + '.enabled']) {
collections[name] = {
readonly: this._settings['sync.collections.' + name + '.readonly'],
limit: this._settings['sync.collections.' + name + '.limit']
};
}
});
if (!Object.keys(collections).length) {
Service.request('SyncStateMachine:success');
return;
}
var args = [];
this.getAssertion().then(assertion => {
args.push(assertion);
return this.getKeys();
}).then(keys => {
args.push(keys);
args.push(collections);
this.doSync.apply(this, args);
}).catch(error => {
console.error('Sync error', error);
Service.request('SyncStateMachine:error', ERROR_SYNC_REQUEST);
});
},
_handle_fxaEvent: function(event) {
// We don't care about FxA events if we are already disabling.
if (this.state === 'disabling') {
return;
}
if (!event || !event.detail) {
console.error('Wrong event');
return;
}
switch (event.detail.eventName) {
case 'onlogout':
// Logging out from Firefox Accounts should disable Sync.
Service.request('SyncStateMachine:disable');
break;
case 'onverified':
// Once the user verified her account, we can continue with the
// enabling procedure.
if (this.state == 'errored' &&
this.error == ERROR_UNVERIFIED_ACCOUNT) {
this.getAccount().then(() => {
if (this.user) {
Service.request('SyncStateMachine:enable');
}
});
}
break;
}
},
_handle_appterminated(event) {
// If the Sync app is closed, we need to release the reference to
// its IAC port, so we can get a new connection on the next sync
// request. Unfortunately, the IAC API doesn't take care of
// notifying when a port is dead. So we need to do it manually.
if (!this._port) {
return;
}
this.isSyncApp(event.detail ? event.detail.origin : null).then(() => {
this._port = null;
});
},
onportclose() {
// The synchronizer app can be killed while it's handling a sync
// request. In that case, we need to record an error and notify
// the state machine about it. Otherwise we could end up on a
// permanent 'syncing' state.
if (this.state !== 'syncing') {
return;
}
this.debug('Synchronizer closed before completing the sync request');
Service.request('SyncStateMachine:error', ERROR_SYNC_APP_KILLED);
},
/** Helpers **/
initStore: function() {
var promises = [];
[SYNC_STATE,
SYNC_STATE_ERROR,
SYNC_LAST_TIME,
SYNC_USER].forEach(key => {
var promise = new Promise(resolve => {
asyncStorage.getItem(key, value => {
this.debug(key + ': ' + value);
this.store.set(key, value);
resolve();
});
});
promises.push(promise);
});
return Promise.all(promises);
},
updateState: function(state) {
this.state = state || Service.query('SyncStateMachine.state');
},
registerSyncRequest: function() {
return navigator.sync.registrations().then(registrations => {
var existingReg;
registrations.some(reg => {
if (reg.task === SYNC_TASK) {
return !!(existingReg = reg);
}
});
if (existingReg) {
this.debug('Sync request already exists');
return Promise.resolve();
}
this.debug('Registering sync request', location.href);
// XXX We need to default to the settings value (3600 and false)
// because of
// Bug 1206004 - BaseModule._settings is not be available during
// BaseModule._start
var minInterval = this.DEBUG ? 120
: this._settings['sync.scheduler.interval'] || 3600;
var wifiOnly = this._settings['sync.scheduler.wifionly'] || false;
return navigator.sync.register(SYNC_TASK, {
minInterval: minInterval, // minInterval is in seconds.
oneShot: true,
wifiOnly: wifiOnly,
data: null,
wakeUpPage: location.href
});
});
},
unregisterSyncRequest: function() {
navigator.sync.unregister(SYNC_TASK).catch(e => {
// A failure unregistering the sync task might be expected
// depending on the state we are (i.e. enabling). In any case,
// we show a warning, so a bug can be identified or discarded.
console.warn('Could not unregister sync task', e,
Service.query('SyncStateMachine.state'));
});
},
getKeys: function() {
return new Promise((resolve, reject) => {
LazyLoader.load('js/fx_accounts_client.js', () => {
FxAccountsClient.getKeys(resolve, reject);
});
});
},
getAssertion: function() {
return new Promise((resolve, reject) => {
LazyLoader.load('js/fx_accounts_client.js', () => {
FxAccountsClient.getAssertion({
audience: this._settings['sync.fxa.audience']
}, resolve, reject);
});
});
},
getAccount: function() {
return new Promise((resolve, reject) => {
LazyLoader.load('js/fx_accounts_client.js', () => {
FxAccountsClient.getAccount(account => {
this.user = account ? account.email : null;
resolve(account);
}, reject);
});
});
},
managementPortMessage: function(message) {
var port = IACHandler.getPort(SYNC_MANAGEMENT_API_IAC_KEYWORD, this);
if (!port) {
this.debug('No management API consumers');
// We may not have any consumers yet. So in that case
// we simply bail out here.
return;
}
port.postMessage(message);
},
notifyStateChange: function() {
this.managementPortMessage({
name: 'onsyncchange',
state: this.state,
error: this.error,
lastSync: this.lastSync,
user: this.user
});
},
connect: function() {
if (this._port) {
return Promise.resolve(this._port);
}
return new Promise((resolve, reject) => {
navigator.mozApps.getSelf().onsuccess = event => {
var app = event.target.result;
app.connect(SYNC_REQUEST_IAC_KEYWORD, {
// For now we only allow one synchronizer app.
pageURLs: 'app://sync.gaiamobile.org'
}).then(ports => {
if (!ports || !ports.length) {
return reject();
}
this._port = ports[0];
this._port.onclose = this.onportclose.bind(this);
resolve(this._port);
}).catch(error => {
console.error(error);
reject(error);
});
};
});
},
iacRequest: function(request) {
return new Promise((resolve, reject) => {
this.connect().then(port => {
LazyLoader.load('/shared/js/uuid.js', () => {
var id = uuid();
request.id = id;
port.postMessage(request);
port.onmessage = event => {
var message = event.data;
if (!message || (message.id != id)) {
return;
}
resolve(message);
};
});
});
});
},
doSync: function(assertion, keys, collections) {
this.debug('Syncing with', JSON.stringify(collections));
this.iacRequest({
name: 'sync',
URL: this._settings['sync.server.url'],
assertion: assertion,
keys: keys,
collections: collections
}).then(result => {
if (result.error) {
var error = result.error;
error = error.message ? error.message : error;
console.error('Error while trying to sync', error);
error = SyncErrors[error] || ERROR_SYNC_APP_GENERIC;
Service.request('SyncStateMachine:error', error);
return;
}
// If we made a successfull synchronization we are good and so
// we don't care about past errors anymore.
this.error = null;
this.debug('Sync succeded');
this.lastSync = Date.now();
Service.request('SyncStateMachine:success');
});
},
trySync: function(assertion, keys) {
return this.iacRequest({
name: 'sync',
URL: this._settings['sync.server.url'],
assertion: assertion,
keys: keys,
collections: {}
}).then(result => {
if (result && result.error) {
console.error('Error trying sync', result.error.message);
throw result.error.message;
}
});
},
cancelSync: function() {
this.iacRequest({
name: 'cancel'
});
},
cleanup: function() {
this.unregisterSyncRequest();
window.removeEventListener(FXA_EVENT, this.fxaEventHandler);
// If we disabled Sync while a sync request was in progress, we need to
// cancel the request.
if (this.state === 'syncing') {
this.cancelSync();
}
},
isSyncApp: function(origin) {
if (!origin) {
return Promise.reject();
}
return new Promise((resolve, reject) => {
navigator.mozApps.getSelf().onsuccess = event => {
var app = event.target.result;
app.getConnections().then(connections => {
connections.forEach(connection => {
if (connection.keyword != SYNC_REQUEST_IAC_KEYWORD ||
connection.subscriber.indexOf(origin) < 0) {
return;
}
resolve();
});
reject();
});
};
});
},
updateStatePreference: function() {
// Changing the value of this setting changes the value of
// the 'services.sync.enabled' preference as well.
// We don't retrieve the Sync keys unless this pref is set to true.
const CHROME_EVENT = 'mozPrefChromeEvent';
return new Promise((resolve, reject) => {
var onprefchange = event => {
this.debug('Preference change', event.detail);
if (event.detail.prefName !== 'services.sync.enabled') {
return;
}
window.removeEventListener(CHROME_EVENT, onprefchange);
resolve();
};
window.addEventListener(CHROME_EVENT, onprefchange);
navigator.mozSettings.createLock().set({
'services.sync.enabled': this.state !== 'disabled'
}).onerror = reject;
});
},
saveSettings(prefix) {
var saveSetting = setting => {
return new Promise(resolve => {
asyncStorage.getItem(prefix + setting, value => {
if (value !== null) {
this.debug('Setting value already saved', setting);
resolve();
return;
}
this.debug('Saving setting', prefix + setting,
this._settings[setting]);
asyncStorage.setItem(prefix + setting, this._settings[setting],
resolve);
});
});
};
var promises = [];
SyncManager.SETTINGS.forEach(setting => {
promises.push(saveSetting(setting));
});
return Promise.all(promises);
},
saveDefaultSettings() {
this.debug('Saving default settings');
return this.saveSettings('');
},
saveUserSettings() {
this.debug('Saving user settings');
return this.saveSettings(this.user + '.');
},
restoreSettings(prefix) {
var revertSetting = setting => {
return new Promise((resolve, reject) => {
asyncStorage.getItem(prefix + setting, value => {
this.debug('Restoring setting ' + prefix + setting, value);
if (value === null) {
resolve();
return;
}
// We need to remove the setting from asyncStorage to allow
// default setting changes within OTA updates.
asyncStorage.removeItem(prefix + setting, () => {
var settingObject = {};
settingObject[setting] = value;
var req = navigator.mozSettings.createLock().set(settingObject);
req.onerror = error => {
console.error(error);
reject();
};
req.onsuccess = resolve;
});
});
});
};
var promises = [];
SyncManager.SETTINGS.forEach(setting => {
promises.push(revertSetting(setting));
});
return Promise.all(promises);
},
restoreDefaultSettings() {
this.debug('Restoring default settings');
return this.restoreSettings('');
},
restoreUserSettings() {
this.debug('Restoring user settings');
return this.restoreSettings(this.user + '.');
}
}, {
state: {
set: function(state) {
state = state || Service.query('SyncStateMachine.state');
this.debug('Setting state', state);
this.store.set(SYNC_STATE, state);
this.updateStateDeferred = new Promise(resolve => {
asyncStorage.setItem(SYNC_STATE, state, () => {
if (state === 'disabled' || state === 'enabled' ||
state === 'enabling') {
this.updateStatePreference().then(() => {
this.notifyStateChange();
resolve();
});
return;
}
this.notifyStateChange();
});
});
},
get: function() {
var state = this.store.get(SYNC_STATE);
return state ? state : 'disabled';
}
},
error: {
set: function(error) {
this.store.set(SYNC_STATE_ERROR, error);
asyncStorage.setItem(SYNC_STATE_ERROR, error);
},
get: function() {
return this.store.get(SYNC_STATE_ERROR);
}
},
lastSync: {
set: function(now) {
var lastSync = now;
this.store.set(SYNC_LAST_TIME, lastSync);
asyncStorage.setItem(SYNC_LAST_TIME, lastSync);
},
get: function() {
return this.store.get(SYNC_LAST_TIME);
}
},
user: {
set: function(user) {
// We need to check if there's a user logged in during the enabled
// state handler, so we cannot wait for the asyncStorage write.
this.store.set(SYNC_USER, user);
asyncStorage.setItem(SYNC_USER, user, () => {}, () => {
// This should never happen, but if something goes wrong storing the
// user we delete the user from memory and disable Sync to avoid
// inconsistent states.
this.store.delete(SYNC_USER);
Service.request('SyncStateMachine:disable');
});
},
get: function() {
return this.store.get(SYNC_USER);
}
}
});
// Exported only for testing purposes.
exports.SyncManager = SyncManager;
}(window));