/* Copyright 2015 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file. */
/**
* @fileoverview
* 'settings-prefs' exposes a singleton model of Chrome settings and
* preferences, which listens to changes to Chrome prefs whitelisted in
* chrome.settingsPrivate. When changing prefs in this element's 'prefs'
* property via the UI, the singleton model tries to set those preferences in
* Chrome. Whether or not the calls to settingsPrivate.setPref succeed, 'prefs'
* is eventually consistent with the Chrome pref store.
*
* Example:
*
*
*
*
*
* @group Chrome Settings Elements
* @element settings-prefs
*/
(function() {
'use strict';
/**
* Checks whether two values are recursively equal. Only compares serializable
* data (primitives, serializable arrays and serializable objects).
* @param {*} val1 Value to compare.
* @param {*} val2 Value to compare with val1.
* @return {boolean} True if the values are recursively equal.
*/
function deepEqual(val1, val2) {
if (val1 === val2)
return true;
if (Array.isArray(val1) || Array.isArray(val2)) {
if (!Array.isArray(val1) || !Array.isArray(val2))
return false;
return arraysEqual(/** @type {!Array} */(val1),
/** @type {!Array} */(val2));
}
if (val1 instanceof Object && val2 instanceof Object)
return objectsEqual(val1, val2);
return false;
}
/**
* @param {!Array} arr1
* @param {!Array} arr2
* @return {boolean} True if the arrays are recursively equal.
*/
function arraysEqual(arr1, arr2) {
if (arr1.length != arr2.length)
return false;
for (var i = 0; i < arr1.length; i++) {
if (!deepEqual(arr1[i], arr2[i]))
return false;
}
return true;
}
/**
* @param {!Object} obj1
* @param {!Object} obj2
* @return {boolean} True if the objects are recursively equal.
*/
function objectsEqual(obj1, obj2) {
var keys1 = Object.keys(obj1);
var keys2 = Object.keys(obj2);
if (keys1.length != keys2.length)
return false;
for (var i = 0; i < keys1.length; i++) {
var key = keys1[i];
if (!deepEqual(obj1[key], obj2[key]))
return false;
}
return true;
}
/**
* Returns a recursive copy of the value.
* @param {*} val Value to copy. Should be a primitive or only contain
* serializable data (primitives, serializable arrays and
* serializable objects).
* @return {*} A deep copy of the value.
*/
function deepCopy(val) {
if (!(val instanceof Object))
return val;
return Array.isArray(val) ? deepCopyArray(/** @type {!Array} */(val)) :
deepCopyObject(val);
};
/**
* @param {!Array} arr
* @return {!Array} Deep copy of the array.
*/
function deepCopyArray(arr) {
var copy = [];
for (var i = 0; i < arr.length; i++)
copy.push(deepCopy(arr[i]));
return copy;
}
/**
* @param {!Object} obj
* @return {!Object} Deep copy of the object.
*/
function deepCopyObject(obj) {
var copy = {};
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
copy[key] = deepCopy(obj[key]);
}
return copy;
}
Polymer({
is: 'settings-prefs',
properties: {
/**
* Object containing all preferences, for use by Polymer controls.
*/
prefs: {
type: Object,
notify: true,
},
/**
* Singleton element created at startup which provides the prefs model.
* @type {!Element}
*/
singleton_: {
type: Object,
value: document.createElement('settings-prefs-singleton'),
},
},
observers: [
'prefsChanged_(prefs.*)',
],
/** @override */
ready: function() {
// Register a callback on CrSettingsPrefs.initialized immediately so prefs
// is set as soon as the settings API returns. This enables other elements
// dependent on |prefs| to add their own callbacks to
// CrSettingsPrefs.initialized.
this.startListening_();
if (!CrSettingsPrefs.deferInitialization)
this.initialize();
},
/**
* Binds this.prefs to the settings-prefs-singleton's shared prefs once
* preferences are initialized.
* @private
*/
startListening_: function() {
CrSettingsPrefs.initialized.then(function() {
// Ignore changes to prevent prefsChanged_ from notifying singleton_.
this.runWhileIgnoringChanges_(function() {
this.prefs = this.singleton_.prefs;
this.stopListening_();
this.listen(
this.singleton_, 'prefs-changed', 'singletonPrefsChanged_');
});
}.bind(this));
},
/**
* Stops listening for changes to settings-prefs-singleton's shared
* prefs.
* @private
*/
stopListening_: function() {
this.unlisten(
this.singleton_, 'prefs-changed', 'singletonPrefsChanged_');
},
/**
* Handles changes reported by singleton_ by forwarding them to the host.
* @private
*/
singletonPrefsChanged_: function(e) {
// Ignore changes because we've defeated Polymer's dirty-checking.
this.runWhileIgnoringChanges_(function() {
// Forward notification to host.
this.fire(e.type, e.detail, {bubbles: false});
});
},
/**
* Forwards changes to this.prefs to settings-prefs-singleton.
* @private
*/
prefsChanged_: function(info) {
// Ignore changes that came from singleton_ so we don't re-process
// changes made in other instances of this element.
if (!this.ignoreChanges_)
this.singleton_.fire('shared-prefs-changed', info, {bubbles: false});
},
/**
* Sets ignoreChanged_ before calling the function to suppress change
* events that are manually handled.
* @param {!function()} fn
* @private
*/
runWhileIgnoringChanges_: function(fn) {
assert(!this.ignoreChanges_,
'Nested calls to runWhileIgnoringChanges_ are not supported');
this.ignoreChanges_ = true;
fn.call(this);
// We can unset ignoreChanges_ now because change notifications
// are synchronous.
this.ignoreChanges_ = false;
},
/** Initializes the singleton, which will fetch the prefs. */
initialize: function() {
this.singleton_.initialize();
},
/**
* Used to initialize the singleton with a fake SettingsPrivate.
* @param {SettingsPrivate} settingsApi Fake implementation to use.
*/
initializeForTesting: function(settingsApi) {
this.singleton_.initialize(settingsApi);
},
/**
* Uninitializes this element to remove it from tests. Also resets
* settings-prefs-singleton, allowing newly created elements to
* re-initialize it.
*/
resetForTesting: function() {
this.singleton_.resetForTesting();
},
});
/**
* Privately used element that contains, listens to and updates the shared
* prefs state.
*/
Polymer({
is: 'settings-prefs-singleton',
properties: {
/**
* Object containing all preferences, for use by Polymer controls.
* @type {Object|undefined}
*/
prefs: {
type: Object,
notify: true,
},
/**
* Map of pref keys to values representing the state of the Chrome
* pref store as of the last update from the API.
* @type {Object<*>}
* @private
*/
lastPrefValues_: {
type: Object,
value: function() { return {}; },
},
},
// Listen for the manually fired shared-prefs-changed event, fired when
// a shared-prefs instance is changed by another element.
listeners: {
'shared-prefs-changed': 'sharedPrefsChanged_',
},
/** @type {SettingsPrivate} */
settingsApi_: /** @type {SettingsPrivate} */(chrome.settingsPrivate),
/**
* @param {SettingsPrivate=} opt_settingsApi SettingsPrivate implementation
* to use (chrome.settingsPrivate by default).
*/
initialize: function(opt_settingsApi) {
// Only initialize once (or after resetForTesting() is called).
if (this.initialized_)
return;
this.initialized_ = true;
if (opt_settingsApi)
this.settingsApi_ = opt_settingsApi;
/** @private {function(!Array)} */
this.boundPrefsChanged_ = this.onSettingsPrivatePrefsChanged_.bind(this);
this.settingsApi_.onPrefsChanged.addListener(this.boundPrefsChanged_);
this.settingsApi_.getAllPrefs(
this.onSettingsPrivatePrefsFetched_.bind(this));
},
/**
* Polymer callback for changes to prefs.* from a shared-prefs element.
* @param {!CustomEvent} e
* @param {!{path: string}} change
* @private
*/
sharedPrefsChanged_: function(e, change) {
if (!CrSettingsPrefs.isInitialized)
return;
var key = this.getPrefKeyFromPath_(change.path);
var prefStoreValue = this.lastPrefValues_[key];
var prefObj = /** @type {chrome.settingsPrivate.PrefObject} */(
this.get(key, this.prefs));
// If settingsPrivate already has this value, ignore it. (Otherwise,
// a change event from settingsPrivate could make us call
// settingsPrivate.setPref and potentially trigger an IPC loop.)
if (!deepEqual(prefStoreValue, prefObj.value)) {
this.settingsApi_.setPref(
key,
prefObj.value,
/* pageId */ '',
/* callback */ this.setPrefCallback_.bind(this, key));
}
// Package the event as a prefs-changed event for other elements.
this.fire('prefs-changed', change);
},
/**
* Called when prefs in the underlying Chrome pref store are changed.
* @param {!Array} prefs
* The prefs that changed.
* @private
*/
onSettingsPrivatePrefsChanged_: function(prefs) {
if (CrSettingsPrefs.isInitialized)
this.updatePrefs_(prefs);
},
/**
* Called when prefs are fetched from settingsPrivate.
* @param {!Array} prefs
* @private
*/
onSettingsPrivatePrefsFetched_: function(prefs) {
this.updatePrefs_(prefs);
CrSettingsPrefs.setInitialized();
},
/**
* Checks the result of calling settingsPrivate.setPref.
* @param {string} key The key used in the call to setPref.
* @param {boolean} success True if setting the pref succeeded.
* @private
*/
setPrefCallback_: function(key, success) {
if (success)
return;
// Get the current pref value from chrome.settingsPrivate to ensure the
// UI stays up to date.
this.settingsApi_.getPref(key, function(pref) {
this.updatePrefs_([pref]);
}.bind(this));
},
/**
* Updates the prefs model with the given prefs.
* @param {!Array} newPrefs
* @private
*/
updatePrefs_: function(newPrefs) {
// Use the existing prefs object or create it.
var prefs = this.prefs || {};
newPrefs.forEach(function(newPrefObj) {
// Use the PrefObject from settingsPrivate to create a copy in
// lastPrefValues_ at the pref's key.
this.lastPrefValues_[newPrefObj.key] = deepCopy(newPrefObj.value);
if (!deepEqual(this.get(newPrefObj.key, prefs), newPrefObj)) {
// Add the pref to |prefs|.
cr.exportPath(newPrefObj.key, newPrefObj, prefs);
// If this.prefs already exists, notify listeners of the change.
if (prefs == this.prefs)
this.notifyPath('prefs.' + newPrefObj.key, newPrefObj);
}
}, this);
if (!this.prefs)
this.prefs = prefs;
},
/**
* Given a 'property-changed' path, returns the key of the preference the
* path refers to. E.g., if the path of the changed property is
* 'prefs.search.suggest_enabled.value', the key of the pref that changed is
* 'search.suggest_enabled'.
* @param {string} path
* @return {string}
* @private
*/
getPrefKeyFromPath_: function(path) {
// Skip the first token, which refers to the member variable (this.prefs).
var parts = path.split('.');
assert(parts.shift() == 'prefs');
for (let i = 1; i <= parts.length; i++) {
let key = parts.slice(0, i).join('.');
// The lastPrefValues_ keys match the pref keys.
if (this.lastPrefValues_.hasOwnProperty(key))
return key;
}
return '';
},
/**
* Resets the element so it can be re-initialized with a new prefs state.
*/
resetForTesting: function() {
if (!this.initialized_)
return;
this.prefs = undefined;
this.lastPrefValues_ = {};
this.initialized_ = false;
// Remove the listener added in initialize().
this.settingsApi_.onPrefsChanged.removeListener(this.boundPrefsChanged_);
this.settingsApi_ =
/** @type {SettingsPrivate} */(chrome.settingsPrivate);
},
});
})();