toolkit/mozapps/extensions/LightweightThemeManager.jsm
author Phil Ringnalda <philringnalda@gmail.com>
Thu, 20 Feb 2014 21:44:23 -0800
changeset 170206 a9af498c077df6d881ebc44fc05bba4075c74a07
parent 170203 400723105403f57a0b90a6b73425c564a3ccfa62
child 170480 70df8261030ad1f018a4b1e880b1e0a1fd5bb182
permissions -rw-r--r--
Backed out changeset 400723105403 (bug 952307) CLOSED TREE

/* 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";

this.EXPORTED_SYMBOLS = ["LightweightThemeManager"];

const Cc = Components.classes;
const Ci = Components.interfaces;

Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
Components.utils.import("resource://gre/modules/AddonManager.jsm");
Components.utils.import("resource://gre/modules/Services.jsm");

const ID_SUFFIX              = "@personas.mozilla.org";
const PREF_LWTHEME_TO_SELECT = "extensions.lwThemeToSelect";
const PREF_GENERAL_SKINS_SELECTEDSKIN = "general.skins.selectedSkin";
const PREF_EM_DSS_ENABLED    = "extensions.dss.enabled";
const ADDON_TYPE             = "theme";

const URI_EXTENSION_STRINGS  = "chrome://mozapps/locale/extensions/extensions.properties";

const STRING_TYPE_NAME       = "type.%ID%.name";

const DEFAULT_MAX_USED_THEMES_COUNT = 30;

const MAX_PREVIEW_SECONDS = 30;

const MANDATORY = ["id", "name", "headerURL"];
const OPTIONAL = ["footerURL", "textcolor", "accentcolor", "iconURL",
                  "previewURL", "author", "description", "homepageURL",
                  "updateURL", "version"];

const PERSIST_ENABLED = true;
const PERSIST_BYPASS_CACHE = false;
const PERSIST_FILES = {
  headerURL: "lightweighttheme-header",
  footerURL: "lightweighttheme-footer"
};

XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeImageOptimizer",
  "resource://gre/modules/LightweightThemeImageOptimizer.jsm");

this.__defineGetter__("_prefs", function prefsGetter() {
  delete this._prefs;
  return this._prefs = Services.prefs.getBranch("lightweightThemes.");
});

this.__defineGetter__("_maxUsedThemes", function maxUsedThemesGetter() {
  delete this._maxUsedThemes;
  try {
    this._maxUsedThemes = _prefs.getIntPref("maxUsedThemes");
  }
  catch (e) {
    this._maxUsedThemes = DEFAULT_MAX_USED_THEMES_COUNT;
  }
  return this._maxUsedThemes;
});

this.__defineSetter__("_maxUsedThemes", function maxUsedThemesSetter(aVal) {
  delete this._maxUsedThemes;
  return this._maxUsedThemes = aVal;
});

// Holds the ID of the theme being enabled or disabled while sending out the
// events so cached AddonWrapper instances can return correct values for
// permissions and pendingOperations
var _themeIDBeingEnabled = null;
var _themeIDBeingDisabled = null;

this.LightweightThemeManager = {
  get usedThemes () {
    try {
      return JSON.parse(_prefs.getComplexValue("usedThemes",
                                               Ci.nsISupportsString).data);
    } catch (e) {
      return [];
    }
  },

  get currentTheme () {
    try {
      if (_prefs.getBoolPref("isThemeSelected"))
        var data = this.usedThemes[0];
    } catch (e) {}

    return data || null;
  },

  get currentThemeForDisplay () {
    var data = this.currentTheme;

    if (data && PERSIST_ENABLED) {
      for (let key in PERSIST_FILES) {
        try {
          if (data[key] && _prefs.getBoolPref("persisted." + key))
            data[key] = _getLocalImageURI(PERSIST_FILES[key]).spec
                        + "?" + data.id + ";" + _version(data);
        } catch (e) {}
      }
    }

    return data;
  },

  set currentTheme (aData) {
    return _setCurrentTheme(aData, false);
  },

  setLocalTheme: function LightweightThemeManager_setLocalTheme(aData) {
    _setCurrentTheme(aData, true);
  },

  getUsedTheme: function LightweightThemeManager_getUsedTheme(aId) {
    var usedThemes = this.usedThemes;
    for (let usedTheme of usedThemes) {
      if (usedTheme.id == aId)
        return usedTheme;
    }
    return null;
  },

  forgetUsedTheme: function LightweightThemeManager_forgetUsedTheme(aId) {
    let theme = this.getUsedTheme(aId);
    if (!theme)
      return;

    let wrapper = new AddonWrapper(theme);
    AddonManagerPrivate.callAddonListeners("onUninstalling", wrapper, false);

    var currentTheme = this.currentTheme;
    if (currentTheme && currentTheme.id == aId) {
      this.themeChanged(null);
      AddonManagerPrivate.notifyAddonChanged(null, ADDON_TYPE, false);
    }

    _updateUsedThemes(_usedThemesExceptId(aId));
    AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper);
  },

  previewTheme: function LightweightThemeManager_previewTheme(aData) {
    if (!aData)
      return;

    let cancel = Cc["@mozilla.org/supports-PRBool;1"].createInstance(Ci.nsISupportsPRBool);
    cancel.data = false;
    Services.obs.notifyObservers(cancel, "lightweight-theme-preview-requested",
                                 JSON.stringify(aData));
    if (cancel.data)
      return;

    if (_previewTimer)
      _previewTimer.cancel();
    else
      _previewTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    _previewTimer.initWithCallback(_previewTimerCallback,
                                   MAX_PREVIEW_SECONDS * 1000,
                                   _previewTimer.TYPE_ONE_SHOT);

    _notifyWindows(aData);
  },

  resetPreview: function LightweightThemeManager_resetPreview() {
    if (_previewTimer) {
      _previewTimer.cancel();
      _previewTimer = null;
      _notifyWindows(this.currentThemeForDisplay);
    }
  },

  parseTheme: function LightweightThemeManager_parseTheme(aString, aBaseURI) {
    try {
      return _sanitizeTheme(JSON.parse(aString), aBaseURI, false);
    } catch (e) {
      return null;
    }
  },

  updateCurrentTheme: function LightweightThemeManager_updateCurrentTheme() {
    try {
      if (!_prefs.getBoolPref("update.enabled"))
        return;
    } catch (e) {
      return;
    }

    var theme = this.currentTheme;
    if (!theme || !theme.updateURL)
      return;

    var req = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
                .createInstance(Ci.nsIXMLHttpRequest);

    req.mozBackgroundRequest = true;
    req.overrideMimeType("text/plain");
    req.open("GET", theme.updateURL, true);
    // Prevent the request from reading from the cache.
    req.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
    // Prevent the request from writing to the cache.
    req.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;

    var self = this;
    req.addEventListener("load", function loadEventListener() {
      if (req.status != 200)
        return;

      let newData = self.parseTheme(req.responseText, theme.updateURL);
      if (!newData ||
          newData.id != theme.id ||
          _version(newData) == _version(theme))
        return;

      var currentTheme = self.currentTheme;
      if (currentTheme && currentTheme.id == theme.id)
        self.currentTheme = newData;
    }, false);

    req.send(null);
  },

  /**
   * Switches to a new lightweight theme.
   *
   * @param  aData
   *         The lightweight theme to switch to
   */
  themeChanged: function LightweightThemeManager_themeChanged(aData) {
    if (_previewTimer) {
      _previewTimer.cancel();
      _previewTimer = null;
    }

    if (aData) {
      let usedThemes = _usedThemesExceptId(aData.id);
      usedThemes.unshift(aData);
      _updateUsedThemes(usedThemes);
      if (PERSIST_ENABLED) {
        LightweightThemeImageOptimizer.purge();
        _persistImages(aData, function themeChanged_persistImages() {
          _notifyWindows(this.currentThemeForDisplay);
        }.bind(this));
      }
    }

    _prefs.setBoolPref("isThemeSelected", aData != null);
    _notifyWindows(aData);
    Services.obs.notifyObservers(null, "lightweight-theme-changed", null);
  },

  /**
   * Starts the Addons provider and enables the new lightweight theme if
   * necessary.
   */
  startup: function LightweightThemeManager_startup() {
    if (Services.prefs.prefHasUserValue(PREF_LWTHEME_TO_SELECT)) {
      let id = Services.prefs.getCharPref(PREF_LWTHEME_TO_SELECT);
      if (id)
        this.themeChanged(this.getUsedTheme(id));
      else
        this.themeChanged(null);
      Services.prefs.clearUserPref(PREF_LWTHEME_TO_SELECT);
    }

    _prefs.addObserver("", _prefObserver, false);
  },

  /**
   * Shuts down the provider.
   */
  shutdown: function LightweightThemeManager_shutdown() {
    _prefs.removeObserver("", _prefObserver);
  },

  /**
   * Called when a new add-on has been enabled when only one add-on of that type
   * can be enabled.
   *
   * @param  aId
   *         The ID of the newly enabled add-on
   * @param  aType
   *         The type of the newly enabled add-on
   * @param  aPendingRestart
   *         true if the newly enabled add-on will only become enabled after a
   *         restart
   */
  addonChanged: function LightweightThemeManager_addonChanged(aId, aType, aPendingRestart) {
    if (aType != ADDON_TYPE)
      return;

    let id = _getInternalID(aId);
    let current = this.currentTheme;

    try {
      let next = Services.prefs.getCharPref(PREF_LWTHEME_TO_SELECT);
      if (id == next && aPendingRestart)
        return;

      Services.prefs.clearUserPref(PREF_LWTHEME_TO_SELECT);
      if (next) {
        AddonManagerPrivate.callAddonListeners("onOperationCancelled",
                                               new AddonWrapper(this.getUsedTheme(next)));
      }
      else {
        if (id == current.id) {
          AddonManagerPrivate.callAddonListeners("onOperationCancelled",
                                                 new AddonWrapper(current));
          return;
        }
      }
    }
    catch (e) {
    }

    if (current) {
      if (current.id == id)
        return;
      _themeIDBeingDisabled = current.id;
      let wrapper = new AddonWrapper(current);
      if (aPendingRestart) {
        Services.prefs.setCharPref(PREF_LWTHEME_TO_SELECT, "");
        AddonManagerPrivate.callAddonListeners("onDisabling", wrapper, true);
      }
      else {
        AddonManagerPrivate.callAddonListeners("onDisabling", wrapper, false);
        this.themeChanged(null);
        AddonManagerPrivate.callAddonListeners("onDisabled", wrapper);
      }
      _themeIDBeingDisabled = null;
    }

    if (id) {
      let theme = this.getUsedTheme(id);
      _themeIDBeingEnabled = id;
      let wrapper = new AddonWrapper(theme);
      if (aPendingRestart) {
        AddonManagerPrivate.callAddonListeners("onEnabling", wrapper, true);
        Services.prefs.setCharPref(PREF_LWTHEME_TO_SELECT, id);

        // Flush the preferences to disk so they survive any crash
        Services.prefs.savePrefFile(null);
      }
      else {
        AddonManagerPrivate.callAddonListeners("onEnabling", wrapper, false);
        this.themeChanged(theme);
        AddonManagerPrivate.callAddonListeners("onEnabled", wrapper);
      }
      _themeIDBeingEnabled = null;
    }
  },

  /**
   * Called to get an Addon with a particular ID.
   *
   * @param  aId
   *         The ID of the add-on to retrieve
   * @param  aCallback
   *         A callback to pass the Addon to
   */
  getAddonByID: function LightweightThemeManager_getAddonByID(aId, aCallback) {
    let id = _getInternalID(aId);
    if (!id) {
      aCallback(null);
      return;
     }

    let theme = this.getUsedTheme(id);
    if (!theme) {
      aCallback(null);
      return;
    }

    aCallback(new AddonWrapper(theme));
  },

  /**
   * Called to get Addons of a particular type.
   *
   * @param  aTypes
   *         An array of types to fetch. Can be null to get all types.
   * @param  aCallback
   *         A callback to pass an array of Addons to
   */
  getAddonsByTypes: function LightweightThemeManager_getAddonsByTypes(aTypes, aCallback) {
    if (aTypes && aTypes.indexOf(ADDON_TYPE) == -1) {
      aCallback([]);
      return;
    }

    aCallback([new AddonWrapper(a) for each (a in this.usedThemes)]);
  },
};

/**
 * The AddonWrapper wraps lightweight theme to provide the data visible to
 * consumers of the AddonManager API.
 */
function AddonWrapper(aTheme) {
  this.__defineGetter__("id", function AddonWrapper_idGetter() aTheme.id + ID_SUFFIX);
  this.__defineGetter__("type", function AddonWrapper_typeGetter() ADDON_TYPE);
  this.__defineGetter__("isActive", function AddonWrapper_isActiveGetter() {
    let current = LightweightThemeManager.currentTheme;
    if (current)
      return aTheme.id == current.id;
    return false;
  });

  this.__defineGetter__("name", function AddonWrapper_nameGetter() aTheme.name);
  this.__defineGetter__("version", function AddonWrapper_versionGetter() {
    return "version" in aTheme ? aTheme.version : "";
  });

  ["description", "homepageURL", "iconURL"].forEach(function(prop) {
    this.__defineGetter__(prop, function AddonWrapper_optionalPropGetter() {
      return prop in aTheme ? aTheme[prop] : null;
    });
  }, this);

  ["installDate", "updateDate"].forEach(function(prop) {
    this.__defineGetter__(prop, function AddonWrapper_datePropGetter() {
      return prop in aTheme ? new Date(aTheme[prop]) : null;
    });
  }, this);

  this.__defineGetter__("creator", function AddonWrapper_creatorGetter() {
    return new AddonManagerPrivate.AddonAuthor(aTheme.author);
  });

  this.__defineGetter__("screenshots", function AddonWrapper_screenshotsGetter() {
    let url = aTheme.previewURL;
    return [new AddonManagerPrivate.AddonScreenshot(url)];
  });

  this.__defineGetter__("pendingOperations",
                       function AddonWrapper_pendingOperationsGetter() {
    let pending = AddonManager.PENDING_NONE;
    if (this.isActive == this.userDisabled)
      pending |= this.isActive ? AddonManager.PENDING_DISABLE : AddonManager.PENDING_ENABLE;
    return pending;
  });

  this.__defineGetter__("operationsRequiringRestart", 
               function AddonWrapper_operationsRequiringRestartGetter() {
    // If a non-default theme is in use then a restart will be required to
    // enable lightweight themes unless dynamic theme switching is enabled
    if (Services.prefs.prefHasUserValue(PREF_GENERAL_SKINS_SELECTEDSKIN)) {
      try {
        if (Services.prefs.getBoolPref(PREF_EM_DSS_ENABLED))
          return AddonManager.OP_NEEDS_RESTART_NONE;
      }
      catch (e) {
      }
      return AddonManager.OP_NEEDS_RESTART_ENABLE;
    }

    return AddonManager.OP_NEEDS_RESTART_NONE;
  });

  this.__defineGetter__("size", function AddonWrapper_sizeGetter() {
    // The size changes depending on whether the theme is in use or not, this is
    // probably not worth exposing.
    return null;
  });

  this.__defineGetter__("permissions", function AddonWrapper_permissionsGetter() {
    let permissions = AddonManager.PERM_CAN_UNINSTALL;
    if (this.userDisabled)
      permissions |= AddonManager.PERM_CAN_ENABLE;
    else
      permissions |= AddonManager.PERM_CAN_DISABLE;
    return permissions;
  });

  this.__defineGetter__("userDisabled", function AddonWrapper_userDisabledGetter() {
    if (_themeIDBeingEnabled == aTheme.id)
      return false;
    if (_themeIDBeingDisabled == aTheme.id)
      return true;

    try {
      let toSelect = Services.prefs.getCharPref(PREF_LWTHEME_TO_SELECT);
      return aTheme.id != toSelect;
    }
    catch (e) {
      let current = LightweightThemeManager.currentTheme;
      return !current || current.id != aTheme.id;
    }
  });

  this.__defineSetter__("userDisabled", function AddonWrapper_userDisabledSetter(val) {
    if (val == this.userDisabled)
      return val;

    if (val)
      LightweightThemeManager.currentTheme = null;
    else
      LightweightThemeManager.currentTheme = aTheme;

    return val;
  });

  this.uninstall = function AddonWrapper_uninstall() {
    LightweightThemeManager.forgetUsedTheme(aTheme.id);
  };

  this.cancelUninstall = function AddonWrapper_cancelUninstall() {
    throw new Error("Theme is not marked to be uninstalled");
  };

  this.findUpdates = function AddonWrapper_findUpdates(listener, reason, appVersion, platformVersion) {
    if ("onNoCompatibilityUpdateAvailable" in listener)
      listener.onNoCompatibilityUpdateAvailable(this);
    if ("onNoUpdateAvailable" in listener)
      listener.onNoUpdateAvailable(this);
    if ("onUpdateFinished" in listener)
      listener.onUpdateFinished(this);
  };
}

AddonWrapper.prototype = {
  // Lightweight themes are never disabled by the application
  get appDisabled() {
    return false;
  },

  // Lightweight themes are always compatible
  get isCompatible() {
    return true;
  },

  get isPlatformCompatible() {
    return true;
  },

  get scope() {
    return AddonManager.SCOPE_PROFILE;
  },

  get foreignInstall() {
    return false;
  },

  // Lightweight themes are always compatible
  isCompatibleWith: function AddonWrapper_isCompatibleWith(appVersion, platformVersion) {
    return true;
  },

  // Lightweight themes are always securely updated
  get providesUpdatesSecurely() {
    return true;
  },

  // Lightweight themes are never blocklisted
  get blocklistState() {
    return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
  }
};

/**
 * Converts the ID used by the public AddonManager API to an lightweight theme
 * ID.
 *
 * @param   id
 *          The ID to be converted
 *
 * @return  the lightweight theme ID or null if the ID was not for a lightweight
 *          theme.
 */
function _getInternalID(id) {
  if (!id)
    return null;
  let len = id.length - ID_SUFFIX.length;
  if (len > 0 && id.substring(len) == ID_SUFFIX)
    return id.substring(0, len);
  return null;
}

function _setCurrentTheme(aData, aLocal) {
  aData = _sanitizeTheme(aData, null, aLocal);

  let needsRestart = (ADDON_TYPE == "theme") &&
                     Services.prefs.prefHasUserValue(PREF_GENERAL_SKINS_SELECTEDSKIN);

  let cancel = Cc["@mozilla.org/supports-PRBool;1"].createInstance(Ci.nsISupportsPRBool);
  cancel.data = false;
  Services.obs.notifyObservers(cancel, "lightweight-theme-change-requested",
                               JSON.stringify(aData));

  if (aData) {
    let theme = LightweightThemeManager.getUsedTheme(aData.id);
    let isInstall = !theme || theme.version != aData.version;
    if (isInstall) {
      aData.updateDate = Date.now();
      if (theme && "installDate" in theme)
        aData.installDate = theme.installDate;
      else
        aData.installDate = aData.updateDate;

      var oldWrapper = theme ? new AddonWrapper(theme) : null;
      var wrapper = new AddonWrapper(aData);
      AddonManagerPrivate.callInstallListeners("onExternalInstall", null,
                                               wrapper, oldWrapper, false);
      AddonManagerPrivate.callAddonListeners("onInstalling", wrapper, false);
    }

    let current = LightweightThemeManager.currentTheme;
    let usedThemes = _usedThemesExceptId(aData.id);
    if (current && current.id != aData.id)
      usedThemes.splice(1, 0, aData);
    else
      usedThemes.unshift(aData);
    _updateUsedThemes(usedThemes);

    if (isInstall)
       AddonManagerPrivate.callAddonListeners("onInstalled", wrapper);
  }

  if (cancel.data)
    return null;

  AddonManagerPrivate.notifyAddonChanged(aData ? aData.id + ID_SUFFIX : null,
                                         ADDON_TYPE, needsRestart);

  return LightweightThemeManager.currentTheme;
}

function _sanitizeTheme(aData, aBaseURI, aLocal) {
  if (!aData || typeof aData != "object")
    return null;

  var resourceProtocols = ["http", "https"];
  if (aLocal)
    resourceProtocols.push("file");
  var resourceProtocolExp = new RegExp("^(" + resourceProtocols.join("|") + "):");

  function sanitizeProperty(prop) {
    if (!(prop in aData))
      return null;
    if (typeof aData[prop] != "string")
      return null;
    let val = aData[prop].trim();
    if (!val)
      return null;

    if (!/URL$/.test(prop))
      return val;

    try {
      val = _makeURI(val, aBaseURI ? _makeURI(aBaseURI) : null).spec;
      if ((prop == "updateURL" ? /^https:/ : resourceProtocolExp).test(val))
        return val;
      return null;
    }
    catch (e) {
      return null;
    }
  }

  let result = {};
  for (let mandatoryProperty of MANDATORY) {
    let val = sanitizeProperty(mandatoryProperty);
    if (!val)
      throw Components.results.NS_ERROR_INVALID_ARG;
    result[mandatoryProperty] = val;
  }

  for (let optionalProperty of OPTIONAL) {
    let val = sanitizeProperty(optionalProperty);
    if (!val)
      continue;
    result[optionalProperty] = val;
  }

  return result;
}

function _usedThemesExceptId(aId)
  LightweightThemeManager.usedThemes.filter(
       function usedThemesExceptId_filterID(t) "id" in t && t.id != aId);

function _version(aThemeData)
  aThemeData.version || "";

function _makeURI(aURL, aBaseURI)
  Services.io.newURI(aURL, null, aBaseURI);

function _updateUsedThemes(aList) {
  // Send uninstall events for all themes that need to be removed.
  while (aList.length > _maxUsedThemes) {
    let wrapper = new AddonWrapper(aList[aList.length - 1]);
    AddonManagerPrivate.callAddonListeners("onUninstalling", wrapper, false);
    aList.pop();
    AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper);
  }

  var str = Cc["@mozilla.org/supports-string;1"]
              .createInstance(Ci.nsISupportsString);
  str.data = JSON.stringify(aList);
  _prefs.setComplexValue("usedThemes", Ci.nsISupportsString, str);

  Services.obs.notifyObservers(null, "lightweight-theme-list-changed", null);
}

function _notifyWindows(aThemeData) {
  Services.obs.notifyObservers(null, "lightweight-theme-styling-update",
                               JSON.stringify(aThemeData));
}

var _previewTimer;
var _previewTimerCallback = {
  notify: function _previewTimerCallback_notify() {
    LightweightThemeManager.resetPreview();
  }
};

/**
 * Called when any of the lightweightThemes preferences are changed.
 */
function _prefObserver(aSubject, aTopic, aData) {
  switch (aData) {
    case "maxUsedThemes":
      try {
        _maxUsedThemes = _prefs.getIntPref(aData);
      }
      catch (e) {
        _maxUsedThemes = DEFAULT_MAX_USED_THEMES_COUNT;
      }
      // Update the theme list to remove any themes over the number we keep
      _updateUsedThemes(LightweightThemeManager.usedThemes);
      break;
  }
}

function _persistImages(aData, aCallback) {
  function onSuccess(key) function () {
    let current = LightweightThemeManager.currentTheme;
    if (current && current.id == aData.id) {
      _prefs.setBoolPref("persisted." + key, true);
    }
    if (--numFilesToPersist == 0 && aCallback) {
      aCallback();
    }
  };

  let numFilesToPersist = 0;
  for (let key in PERSIST_FILES) {
    _prefs.setBoolPref("persisted." + key, false);
    if (aData[key]) {
      numFilesToPersist++;
      _persistImage(aData[key], PERSIST_FILES[key], onSuccess(key));
    }
  }
}

function _getLocalImageURI(localFileName) {
  var localFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
  localFile.append(localFileName);
  return Services.io.newFileURI(localFile);
}

function _persistImage(sourceURL, localFileName, successCallback) {
  if (/^file:/.test(sourceURL))
    return;

  var targetURI = _getLocalImageURI(localFileName);
  var sourceURI = _makeURI(sourceURL);

  var persist = Cc["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
                  .createInstance(Ci.nsIWebBrowserPersist);

  persist.persistFlags =
    Ci.nsIWebBrowserPersist.PERSIST_FLAGS_REPLACE_EXISTING_FILES |
    Ci.nsIWebBrowserPersist.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION |
    (PERSIST_BYPASS_CACHE ?
       Ci.nsIWebBrowserPersist.PERSIST_FLAGS_BYPASS_CACHE :
       Ci.nsIWebBrowserPersist.PERSIST_FLAGS_FROM_CACHE);

  persist.progressListener = new _persistProgressListener(successCallback);

  persist.saveURI(sourceURI, null, null, null, null, targetURI, null);
}

function _persistProgressListener(successCallback) {
  this.onLocationChange = function persistProgressListener_onLocationChange() {};
  this.onProgressChange = function persistProgressListener_onProgressChange() {};
  this.onStatusChange   = function persistProgressListener_onStatusChange() {};
  this.onSecurityChange = function persistProgressListener_onSecurityChange() {};
  this.onStateChange    = function persistProgressListener_onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
    if (aRequest &&
        aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK &&
        aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
      try {
        if (aRequest.QueryInterface(Ci.nsIHttpChannel).requestSucceeded) {
          // success
          successCallback();
          return;
        }
      } catch (e) { }
      // failure
    }
  };
}

AddonManagerPrivate.registerProvider(LightweightThemeManager, [
  new AddonManagerPrivate.AddonType("theme", URI_EXTENSION_STRINGS,
                                    STRING_TYPE_NAME,
                                    AddonManager.VIEW_TYPE_LIST, 5000)
]);