How to write a decent pref-manager for mozilla extensions

Today, I want to share with you my experiences in writing code to use mozilla’s pref system, that is if you write an add-on for any of mozilla’s apps (firefox, thunderbird…).
After I got my feet wet with the Thundersomething addon a while back, I recently gained some more experience rewriting TBTracer’s pref system from scratch. Thundersomething started as an adaption of Firesomething to Thunderbird (TB), which came with a pretty decent pref system meaning I didn’t need to change a lot.
TBT on the other hand, started out with flashtracer as a template, and without wanting to belittle flastracer or it’s author, the pref code sucked.
That became more and more apparent, the bigger TBT became and consequently the more options it had, up to the point where I had to rethink the pref manager code and rewrite it. Read on to see my implementation details…

First, for the un-initiated, a quick primer on how mozilla’s pref system works. It stores all prefs as a dictionary on disk, aka. a hash, or associative array if you speak javascript. A specific level of that tree is called a branch, and you can call a function to request a handle for a certain branch from the system. Each branch can be a node or a leaf, but not both; if it has sub-branches it’s a node, if it has a value it’s a leaf.
Leafs can only have one value, which can be either of four types: bool, string, number, or complex. And there are functions for set/get-ting each of these four basic types, of the form: get/set + [Int,Bool,Char,Complex] + Pref.
After you’ve acquired a handle for a branch from TB, you can enumerate sub-branches, delete the whole branch, get/set values, or get the type of a specific leaf.
Setting values on leafs that didn’t exist prior creates all the necessary sub-nodes, whereas trying to read an in-existing leaf throws an error. Trying to get the type of an in-existing leaf, or using a wrong type-function to read a value however, does not, it returns null.
You will also need an observer, that gets notified if any of your prefs have changed to update the hash. Since each window in mozilla has it’s own DOM and memory, changes made in the prefs window (and it’s copy of the pref hash) are not visible in the main window’s hash. So you need to save them to disk and that alerts all your other window to the change.
Lastly, in addons it is customary, read encouraged, to store all your prefs under the ‘extensions’ branch.

Now that we have that covered, let’s get to the meat.
Flashtracer’s pref code had a function to get a branch called getPreferences. It also had 6 functions to get/set each type of pref, each took a branch as the base, a string for the specific path under that branch and a default value in the case of getters, or the value in the case of the setters, respectively.
IMHO, there are 3 things wrong with that approach:
First, supplying each function with the branch and then with the path to a specific value is redundant, because in essence they are the same, and just get concatenated anyway. Furthermore, since you store all your prefs under the same branch, that should be implicit, leaving you to just supply the path to the specific pref you want to access.
Secondly, supplying a default value for the getter in case that pref is not there, seems like a good idea at first, but is actually really stupid since it leads to spreading knowledge of default values throughout the code.
Thirdly, and that is an idea I took from Thundersomething, you can simply use a single function for get/set-ing values, since the type of that value is inferred by either the object supplied or the pref being read.

All of this leads naturally to a pref manager object. We’ll kick of the implementation with some design requirements:
1) Implicit root-branch,
2) single getter/setter regardless of type,
3) concise representation and single code-location for knowledge of the structure, types, as well default values for all prefs,
4) simple way of populating the pref window, ie. same DOM-Id for each XUL element as the corresponding pref.

The last requirement deals with the need of a way for the user to change all these prefs, that is the addon’s preference pane. These are written in XUL and can grow quite large and wordy and thus represent yet another location where knowledge of the prefs and their structure is required. If you’re not careful you end up with a mess of names, types and default values all over again.

With that in mind, I realized that it’s smartest to store all prefs, as well as their default values in a single hash, which nicely aligns with requirement 3. This hash encodes the structure as well as the default values and types of all prefs. That hash is saved to disk in precisely the same way, that is using the same structure and types, since it translates directly to the way mozilla’s pref system works.
In other (“code”) words, my pref hash looks similar to this:

prefs = {
  gen : {
    global: {
      test: {
        binarychoice   : false,
        multiplechoice : "foo_1",
        othermultiple  : "bar_2",
        someString     : "foo",
        someNumber     : 42,
      },
    },
  },
};

You may notice, if you read TBT’s sourcecode, that I structured the hash after the structure of the pref-pane: By tab name, groupbox-name and lastly by pref-name. That gives a sense of continuity and clarity, I thought, since the pref-pane is the most visible representation of the structure inherent in the prefs.

Next, we need functions to read, that is populate this pref hash from disk with the stored values, as well as save it back to disk. I quickly realized that I needed to write an enumerator since the pref hash might have an arbitrary depth. The enumerator takes the hash, an array of paths, and a function to call back – the visitor.
Here’s the code:

function visit_prefs( pref_hash, path_array, visitor) {
  var type;
  for (var prop in pref_hash) {
    path_array.push(prop);
    type = typeof(pref_hash[prop]);
    if (type == 'object')
      //recurse
      this.visit_prefs(pref_hash[prop], path_array, visitor);
    else
      visitor(pref_hash[prop], path_array, type);

    path_array.pop();
  }
}

As you can see it’s deceptively simple: takes the hash and iterates over all the props of that hash, if it’s a hash again, it recurses one level deeper, if not it calls the visitor with the value directly, its type, and an ordered array of the paths seen so far – the names of the nodes on the way to the root.

Next up is the visitor code for populating the pref hash from disk:

function prefLoadingVisitor(obj, path, type){
  var prefName = "prefs." + path.join(".");
  var newVal = getPref(prefName);

  if (newVal != null && typeof(newVal) != 'undefined')
    setDeep(path.slice(0), newVal, prefs);
}
function setDeep (a, v, p){
  var n = a.shift();
  if (a.length == 0)
    p[n] = v;
  else
    this.setDeep(a, v, p[n]);
}

As this is the visitor it gets called with each pref in turn. As you can see, it builds the path (mine has a ‘pref’ branch as the root, right below the extensions.TBT. branch), gets the value of the pref with the new consolidated getPref function and sets it into the hash. You would call it like this:

visit_prefs(this.prefs, Array(), this.prefLoadingVisitor);

Setting the value of the passed object directly didn’t work anymore after my initial run – which I somehow suspect to have worked because at first everything was stored as strings on disk owing to a faulty getPrefType implementation on my part. This suggests that javascript passes primitive types to functions by value rather than by reference, and sadly there is no way to tell it to do otherwise. I am guessing that if you pass a string it gets passed by reference (since its a complex object) and if you set that into the hash, it just updates the pointer stored at that location in the hash, which is why it is reflected in the global hash.
So I had to write a method to do just that, manipulate the pointer instead of the actual value. I could have passed the parent hash to the visitor instead, but that would have been unclean and not up to par with my personal coding standards.

For the pref window, I did not want to use the preference tag since that is sort of wordy in my eyes and does not allow you to do more customizations on load – like setting labels depending on a format-string, or hiding/disabling elements. From flashtracer I had inherited the annoying task of maintaing a mental image of which pref translates to which XUL element.
For the rewrite, I did away with all of that once and for all, and also used the exact same structure for the XUL Ids, prefixed with some string to make them stand out more. In other words, if the pref is named gen.global.test.someNumber , then the XUL-Id of the corresponding input-field is tbt-prefs:gen_global_test_someNumber. I used underscores here instead of dots, just to have slightly more differentiation between the two uses.
Continuing the example from above, you could conceivably put the following DOM elements in your pref-window.xul:

<checkbox id="tbt-prefs:gen_global_test_binarychoice" />
<menulist id="tbt-prefs:gen_global_test_multiplechoice">
  <menupopup>
    <menuitem label="First foo" value="foo_1" />
  </menupopup>
</menulist>
<radiogroup id="tbt-prefs:gen_global_test_othermultiple">
  <radio id="blah" label="First bar" value="bar_1" />
  <radio id="blah2" label="Second bar" value="bar_2" />
</radiogroup>
<textbox id="tbt-prefs:gen_global_test_someString" ></textbox>
<menulist id="tbt-prefs:gen_global_test_someNumber">
  <menupopup id="Blah_menupopup">
    <menuitem label="FortyTwo" value="42" />
  </menupopup>
</menulist>

These DOM inputs are sensible choices for the types of values in the hash from the example. Multiple choice items can be represented as either drop-down menus or radiobuttons, depending on your preference; the number can be connected to either a drop-down, radio-buttons, or an input-field.

Armed with this, the onload and onclose functions for the pref window become really simple (excluding some init stuff in the loader eg. hiding elements). This is the loader:

function hd2winPrefLoadingVisitor (obj, path, type ){
  var prefName = "prefs." + path.join(".");
  var prefXulId = "tbt-prefs:" + path.join("_");
  var XulNode = $(prefXulId);
  var newVal = getPref(prefName);

  //safety check if the XUL id exists
  if (!XulNode || XulNode == null
    || typeof(XulNode) == "undefined")
      return;

  switch(type){
    case "boolean":
      XulNode.checked = newVal;
      break;
    case "string":
    case "number":
      if (XulNode.tagName == "radiogroup"
       || XulNode.tagName == "menulist")
         selectElInList(XulNode, newVal);
      else
        XulNode.value = newVal;
      break;
  }
}

It already accounts for the 3 different types that a pref can have, and sets accordingly.
$ is a shortcut for the quite wordy document.getElementById, as popularized by prototype/jquery and others – I have that defined in my globals, TB does not offer it by default.

This is the code for selectElInList, which selects an element in a list or radiogroup based on the value of the argument el, and the value tags of each choice:

function selectElInList(list, el){
  var childNodes = null;
  if (list.tagName == "radiogroup")
    childNodes = list.childNodes;
  if (list.tagName == "menulist")
    childNodes = list.firstChild.childNodes;

  for( a = 0; a < childNodes.length; a++) {
    if(el == childNodes[a].value) {
      list.selectedIndex = a;
      return;
    }
  }
}

A menu has a single menupopup tag as its direct descendant, whereas a radiogroup has the radio-buttons as direct descendants, as you can see in the code above.

The onSave function basically does the same in the other direction:

function win2hdPrefSavingVisitor(obj, path, type){

  var prefName = "prefs." + path.join(".");
  var prefXulId = "tbt-prefs:" + path.join("_");
  var XulNode = $(prefXulId);
  var newVal = null;

  //safety check if the XUL id exists
  if (!XulNode || XulNode == null
    || typeof(XulNode) == "undefined")
      return;

  switch(type){
    case "boolean":
      newVal = XulNode.checked;
      break;
    case "string":
    case "number":
       if (XulNode.tagName == "radiogroup"
       || XulNode.tagName == "menulist")
        newVal = XulNode.selectedItem.value;
      else
        newVal = XulNode.value;
      break;
  }
  if (newVal != null && typeof(newVal) != 'undefined')
    setPref(prefName, newVal);
}

Last but not least, the pref change observer becomes really simple as well:

observe: function(aSubject, aTopic, aData){
  if(aTopic != "nsPref:changed") return;
  //first check what has changed
  var path = aData.split(".");
  switch (path[0]){
    case "prefs":
      //a pref has changed.. reload from disk
      var newVal = getPref(aData);
      setDeep(path.slice(), newVal, prefs);
      break;
  }
}

And finally, here’s the complete code including get/setter functions for prefs, and supporting functions:

prefMan = {
	_branch: null,
	pref_service : CCGS("@mozilla.org/preferences-service;1", 
				'nsIPrefService'),
	pref_domain : "extensions.addon-name.",
	prefTypes: new Array(),

	prefs : {
		gen : {
			global: {
				test: {
					binarychoice   : false,
					multiplechoice : "foo_1",
					othermultiple  : "bar_2",
					someString     : "foo",
					someNumber     : 42,
				},
			},
		},
	},

	/* enumerator for prefs */
	visit_prefs : function( pref_hash, path_array, visitor) {
		var type;
		for (var prop in pref_hash) {
			path_array.push(prop);
			type = typeof(pref_hash[prop]);
			if (type == 'object'){
				//recurse
				this.visit_prefs(pref_hash[prop], 
				   path_array, visitor);
			}else{
				visitor(pref_hash[prop], path_array, type);
			}
			path_array.pop();
		}
	},

	/* Registers the change observer and calls the loader */
	init: function(){
		this._branch = prefService.getBranch(this.pref_domain);
		//query interface
		QI(this._branch, 'nsIPrefBranch2');
		this._branch.addObserver("", this, false);
		this.visit_prefs(this.prefs, Array(), 
				this.prefLoadingVisitor);
	},

	/*  pref observer, called when something changes */
	observe: function(aSubject, aTopic, aData){
    	if(aTopic != "nsPref:changed") return;

		//first check what has changed
		var path = aData.split(".");
		switch (path[0]){
			case "prefs":
				//a pref has changed.. reload from disk
				var newVal = this.getPref(aData);
				this.setDeep(path.slice(), newVal, this);
				break;
			}
	},

	getDeep: function (pref_hash, path_a){
		var p = path_a.shift();
		if (path_a.length == 0)
			return pref_hash[p];
		else
			return this.getDeep(pref_hash[p], path_a);
	},

	//path-array, new val, pref_hash branch
	setDeep: function (a, v, p){
		var n = a.shift();
		if (a.length == 0)
			p[n] = v;
		else
			this.setDeep(a, v, p[n]);
	},

	prefLoadingVisitor: function(obj, path, type){

		var prefName = "prefs." + path.join(".");
		var newVal = this.getPref(prefName);

		if (newVal != null && typeof(newVal) != 'undefined')
			this.setDeep(path.slice(0), newVal, this.prefs);
	},

	/* Mozilla pref-system related functions */

	getRootBranch: function() {
		if (!this._branch)
		  this._branch = 
			this.pref_service.getBranch(this.pref_domain);
		return this._branch;
	},

	getPrefType: function(strName) {
		if (strName in this.prefTypes)
		  return this.prefTypes[strName];
		var strType = "Char";
		var iPB = Ci.nsIPrefBranch;
		try{
			var pt = this.getRootBranch().getPrefType(strName);
			switch (pt) {
				case iPB.PREF_STRING: strType = "Char"; break;
				case iPB.PREF_INT: strType = "Int"; break;
				case iPB.PREF_BOOL: strType = "Bool"; break;
				case 0:
				default: return null; break;
			}
		}catch (ex){
			return null;
		}

		this.prefTypes[strName] = strType;
		return strType;
	},
	getVarType: function( val ) {
		var ret = "Char";
		switch (typeof(val)){
			case "object":
			case "string":
				ret = "Char";
				break;
			case "boolean":
				ret = "Bool";
				break;
			case "number":
				ret =  "Int";
				break;
		}
		return ret;
	},

	getPref: function(strName) {
		var strType = this.getPrefType(strName);
		var root = this.getRootBranch();

		try {
			var ret = root["get" + strType + "Pref"](strName);
			return ret;
		}
		catch(ex) { //log an error }
		return null;
	},
	setPref: function(strName, varValue) {
		if (varValue == null)
			varValue = "";

		var root = this.getRootBranch();

		var strType = this.getPrefType(strName);
		//not yet set: get the var type, and set accordingly
		if (strType == null){
			strType = this.getVarType(varValue);
			this.prefTypes[strName] = strType;
		}

		try {
			root["set" + strType + "Pref"](strName, varValue);
		} catch(ex) { //log an error }
	},
}

settings_window = {
	selectElInList: function (list, el){
		var childNodes = null;
		if (list.tagName == "radiogroup")
			childNodes = list.childNodes;
		if (list.tagName == "menulist")
			childNodes = list.firstChild.childNodes;

		for( a = 0; a < childNodes.length; a++) {
	        if(el == childNodes[a].value) {
	            list.selectedIndex = a;
	            return;
	        }
	    }
	},

	hd2winPrefLoadingVisitor : function  ( obj, path, type ){
		var prefName = "prefs." + path.join(".");
		var prefXulId = "tbt-prefs:" + path.join("_");
		var XulNode = $(prefXulId);
		var newVal = prefMan.getPref(prefName);

		//safety check if the XUL id exists
		if (!XulNode || XulNode == null || 
			typeof(XulNode) == "undefined"){
			  return;
		}

		switch(type){
			case "boolean":
				XulNode.checked = newVal;
				break;
			case "number":
			case "string":
				if (XulNode.tagName == "radiogroup"
				  || XulNode.tagName == "menulist")
					this.selectElInList(XulNode, newVal);
				else
					XulNode.value = newVal;
				break;
		}
	},

	win2hdPrefSavingVisitor: function (obj, path, type){
		var prefName = "prefs." + path.join(".");
		var prefXulId = "tbt-prefs:" + path.join("_");
		var XulNode = $(prefXulId);
		var newVal = null;

		//safety check if the XUL id exists
		if (!XulNode || XulNode == null || 
			typeof(XulNode) == "undefined"){
			  return;
		}

		switch(type){
			case "boolean":
				newVal = XulNode.checked;
				break;
			case "string":
			case "number":
				if (XulNode.tagName == "radiogroup"
				  || XulNode.tagName == "menulist")
					newVal = XulNode.selectedItem.value;
				else
					newVal = XulNode.value;
				break;
		}

		if (newVal != null && typeof(newVal) != 'undefined')
			prefMan.setPref(prefName, newVal);
	},

	onSettingsAccept: function () {
		prefMan.visit_prefs(prefMan.prefs, Array(), 
				this.win2hdPrefSavingVisitor);
	},

	onSettingsLoad: function ( ) {
		prefMan.visit_prefs(prefMan.prefs, Array(), 
				this.hd2winPrefLoadingVisitor);
	},
}

//shortcut helper functions, ideas from prototype/firebug
Cc = Components.classes;
Ci = Components.interfaces;
function $(el){
	return document.getElementById(el);
}
// Shortcut Helper function for XPCOM instanciation (from Firebug)
function CCIN (cName, iName) {
	return Cc[cName].createInstance(Ci[iName]);
}

//And these functions follow in the same spirit
function CCGS (cName, iName) {
 	return Cc[cName].getService(Ci[iName]);
}
 
function QI(o, i) {
 	return o.QueryInterface(Ci[i]);
}
Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: