gecko/toolkit/mozapps/extensions/AddonRepository.jsm

590 lines
18 KiB
JavaScript

/*
# ***** BEGIN LICENSE BLOCK *****
# Version: MPL 1.1/GPL 2.0/LGPL 2.1
#
# The contents of this file are subject to the Mozilla Public License Version
# 1.1 (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
# http://www.mozilla.org/MPL/
#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
# for the specific language governing rights and limitations under the
# License.
#
# The Original Code is the Extension Manager.
#
# The Initial Developer of the Original Code is mozilla.org
# Portions created by the Initial Developer are Copyright (C) 2008
# the Initial Developer. All Rights Reserved.
#
# Contributor(s):
# Dave Townsend <dtownsend@oxymoronical.com>
#
# Alternatively, the contents of this file may be used under the terms of
# either the GNU General Public License Version 2 or later (the "GPL"), or
# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
# in which case the provisions of the GPL or the LGPL are applicable instead
# of those above. If you wish to allow use of your version of this file only
# under the terms of either the GPL or the LGPL, and not to allow others to
# use your version of this file under the terms of the MPL, indicate your
# decision by deleting the provisions above and replace them with the notice
# and other provisions required by the GPL or the LGPL. If you do not delete
# the provisions above, a recipient may use your version of this file under
# the terms of any one of the MPL, the GPL or the LGPL.
#
# ***** END LICENSE BLOCK *****
*/
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
Components.utils.import("resource://gre/modules/Services.jsm");
Components.utils.import("resource://gre/modules/AddonManager.jsm");
var EXPORTED_SYMBOLS = [ "AddonRepository" ];
const PREF_GETADDONS_BROWSEADDONS = "extensions.getAddons.browseAddons";
const PREF_GETADDONS_BROWSERECOMMENDED = "extensions.getAddons.recommended.browseURL";
const PREF_GETADDONS_GETRECOMMENDED = "extensions.getAddons.recommended.url";
const PREF_GETADDONS_BROWSESEARCHRESULTS = "extensions.getAddons.search.browseURL";
const PREF_GETADDONS_GETSEARCHRESULTS = "extensions.getAddons.search.url";
const XMLURI_PARSE_ERROR = "http://www.mozilla.org/newlayout/xml/parsererror.xml";
const API_VERSION = "1.2";
function AddonSearchResult(aId) {
this.id = aId;
this.screenshots = [];
}
AddonSearchResult.prototype = {
/**
* The ID of the add-on
*/
id: null,
/**
* The name of the add-on
*/
name: null,
/**
* The version of the add-on
*/
version: null,
/**
* A short description of the add-on
*/
description: null,
/**
* The full description of the add-on
*/
fullDescription: null,
/**
* The rating of the add-on, 0-5 or -1 if unrated
*/
rating: -1,
/**
* The url of the add-ons icon or empty if there is no icon
*/
iconURL: null,
/**
* An array of screenshot urls for the add-on
*/
screenshots: null,
/**
* The homepage for the add-on
*/
homepageURL: null,
/**
* The add-on type (e.g. "extension" or "theme")
*/
type: null,
/**
* AddonInstall object generated from the add-on XPI url
*/
install: null,
/**
* True or false depending on whether the add-on is compatible with the
* current version and platform of the application
*/
isCompatible: true,
/**
* True if the add-on has a secure means of updating
*/
providesUpdatesSecurely: true,
/**
* The current blocklist state of the add-on
*/
blocklistState: Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
/**
* True if this add-on cannot be used in the application based on version
* compatibility, dependencies and blocklisting
*/
appDisabled: false,
/**
* True if the user wants this add-on to be disabled
*/
userDisabled: false,
/**
* The size of the add-on's files in bytes. For an add-on that have not yet
* been downloaded this may be an estimated value.
*/
size: null,
/**
* Indicates what scope the add-on is installed in, per profile, user,
* system or application
*/
scope: AddonManager.SCOPE_PROFILE,
/**
* True if the add-on is currently functional
*/
isActive: true,
/**
* The creator of the add-on
*/
creator: null,
/**
* A bitfield holding all of the current operations that are waiting to be
* performed for this add-on
*/
pendingOperations: AddonManager.PENDING_NONE,
/**
* A bitfield holding all the the operations that can be performed on
* this add-on
*/
permissions: 0
}
/**
* The add-on repository is a source of add-ons that can be installed. It can
* be searched in two ways. One returns a list of add-ons that come highly
* recommended, this list should change frequently. The other way is to
* search for specific search terms entered by the user. Searches are
* asynchronous and results should be passed to the provided callback object
* when complete. The results passed to the callback should only include add-ons
* that are compatible with the current application and are not already
* installed. Searches are always asynchronous and should be passed to the
* callback object provided.
*/
var AddonRepository = {
// The current set of results
_results: null,
// Whether we are currently searching or not
_searching: false,
// Is this a search for recommended add-ons
_recommended: false,
// XHR associated with the current request
_request: null,
/*
* Addon search results callback object that contains two functions
*
* searchSucceeded - Called when a search has suceeded.
*
* @param aAddons an array of the add-on results. In the case of
* searching for specific terms the ordering of results
* may be determined by the search provider.
* @param aAddonCount The length of aAddons
* @param aTotalResults The total results actually available in the
* repository
*
*
* searchFailed - Called when an error occurred when performing a search.
*/
_callback: null,
// Maximum number of results to return
_maxResults: null,
/**
* The homepage for visiting this repository. This may be null or an empty
* string.
*/
get homepageURL() {
var url = this._formatURLPref(PREF_GETADDONS_BROWSEADDONS, {});
return (url != null) ? url : "about:blank";
},
/**
* Returns whether this instance is currently performing a search. New
* searches will not be performed while this is the case.
*/
get isSearching() {
return this._searching;
},
/**
* The url that can be visited to see recommended add-ons in this repository.
*/
getRecommendedURL: function() {
var url = this._formatURLPref(PREF_GETADDONS_BROWSERECOMMENDED, {});
return (url != null) ? url : "about:blank";
},
/**
* Retrieves the url that can be visited to see search results for the given
* terms.
*
* @param aSearchTerms search terms used to search the repository
*/
getSearchURL: function(aSearchTerms) {
var url = this._formatURLPref(PREF_GETADDONS_BROWSESEARCHRESULTS, {
TERMS : encodeURIComponent(aSearchTerms)
});
return (url != null) ? url : "about:blank";
},
/**
* Cancels the search in progress. If there is no search in progress this
* does nothing.
*/
cancelSearch: function() {
this._searching = false;
if (this._request) {
this._request.abort();
this._request = null;
}
this._callback = null;
this._results = null;
},
/**
* Begins a search for recommended add-ons in this repository. Results will
* be passed to the given callback.
*
* @param aMaxResults the maximum number of results to return
* @param aCallback the callback to pass results to
*/
retrieveRecommendedAddons: function(aMaxResults, aCallback) {
if (this._searching)
return;
this._searching = true;
this._results = [];
this._callback = aCallback;
this._recommended = true;
this._maxResults = aMaxResults;
var url = this._formatURLPref(PREF_GETADDONS_GETRECOMMENDED, {
API_VERSION : API_VERSION,
// Get twice as many results to account for potential filtering
MAX_RESULTS : 2 * aMaxResults
});
(url != null) ? this._loadList(url) : this.reportFailure();
},
/**
* Begins a search for add-ons in this repository. Results will be passed to
* the given callback.
*
* @param aSearchTerms the terms to search for
* @param aMaxResults the maximum number of results to return
* @param aCallback the callback to pass results to
*/
searchAddons: function(aSearchTerms, aMaxResults, aCallback) {
if (this._searching)
return;
this._searching = true;
this._results = [];
this._callback = aCallback;
this._recommended = false;
this._maxResults = aMaxResults;
// Get twice as many results to account for potential filtering
var url = this._formatURLPref(PREF_GETADDONS_GETSEARCHRESULTS, {
API_VERSION : API_VERSION,
// Get twice as many results to account for potential filtering
MAX_RESULTS : 2 * aMaxResults,
// We double encode due to bug 427155
TERMS : encodeURIComponent(encodeURIComponent(aSearchTerms))
});
(url != null) ? this._loadList(url) : this.reportFailure();
},
// Posts results to the callback
_reportSuccess: function(aTotalResults) {
this._searching = false;
this._request = null;
// The callback may want to trigger a new search so clear references early
var addons = [result.addon for each(result in this._results)];
var callback = this._callback;
this._callback = null;
this._results = null;
callback.searchSucceeded(addons, addons.length, this._recommended ? -1 : aTotalResults);
},
// Notifies the callback of a failure
_reportFailure: function() {
this._searching = false;
this._request = null;
// The callback may want to trigger a new search so clear references early
var callback = this._callback;
this._callback = null;
this._results = null;
callback.searchFailed();
},
// Parses an add-on entry from an <addon> element
_parseAddon: function(aElement, aSkip) {
var guidList = aElement.getElementsByTagName("guid");
if (guidList.length != 1)
return;
var guid = guidList[0].textContent.trim();
// Ignore add-ons already seen in the results
for (var i = 0; i < this._results.length; i++)
if (this._results[i].addon.id == guid)
return;
// Ignore installed add-ons
if (aSkip.ids.indexOf(guid) != -1)
return;
// Ignore sandboxed add-ons
var status = aElement.getElementsByTagName("status");
// The status element has a unique id for each status type. 4 is Public.
if (status.length != 1 || status[0].getAttribute("id") != 4)
return;
// Ignore add-ons not compatible with this OS
var osList = aElement.getElementsByTagName("compatible_os");
// Only the version 0 schema included compatible_os if it isn't there then
// we will see os compatibility on the install elements.
if (osList.length > 0) {
var compatible = false;
var i = 0;
while (i < osList.length && !compatible) {
var os = osList[i].textContent.trim();
if (os == "ALL" || os == Services.appinfo.OS) {
compatible = true;
break;
}
i++;
}
if (!compatible)
return;
}
// Ignore add-ons not compatible with this Application
compatible = false;
var tags = aElement.getElementsByTagName("compatible_applications");
if (tags.length != 1)
return;
var apps = tags[0].getElementsByTagName("appID");
var i = 0;
while (i < apps.length) {
if (apps[i].textContent.trim() == Services.appinfo.ID) {
var parent = apps[i].parentNode;
var minversion = parent.getElementsByTagName("min_version")[0].textContent.trim();
var maxversion = parent.getElementsByTagName("max_version")[0].textContent.trim();
if ((Services.vc.compare(minversion, Services.appinfo.version) > 0) ||
(Services.vc.compare(Services.appinfo.version, maxversion) > 0))
return;
compatible = true;
break;
}
i++;
}
if (!compatible)
return;
var addon = new AddonSearchResult(guid);
var result = {
addon: addon,
xpiURL: null,
xpiHash: null
};
var node = aElement.firstChild;
while (node) {
if (node instanceof Ci.nsIDOMElement) {
switch (node.localName) {
case "name":
case "version":
addon[node.localName] = node.textContent.trim();
break;
case "summary":
addon.description = node.textContent.trim();
break;
case "description":
addon.fullDescription = node.textContent.trim();
break;
case "rating":
if (node.textContent.length > 0) {
var rating = parseInt(node.textContent);
if (rating >= 0)
addon.rating = Math.min(5, rating);
}
break;
case "thumbnail":
addon.screenshots.push(node.textContent.trim());
break;
case "icon":
addon.iconURL = node.textContent.trim();
break;
case "learnmore":
addon.homepageURL = node.textContent.trim();
break;
case "type":
// The type element has an id attribute that is the id from AMO's
// database. This doesn't match our type values to perform a mapping
addon.type = (node.getAttribute("id") == 2) ? "theme" : "extension";
break;
case "install":
// No os attribute means the xpi is compatible with any os
if (node.hasAttribute("os")) {
var os = node.getAttribute("os").toLowerCase();
// If the os is not ALL and not the current OS then ignore this xpi
if (os != "all" && os != Services.appinfo.OS.toLowerCase())
break;
}
result.xpiURL = node.textContent.trim();
if (node.hasAttribute("size"))
addon.size = node.getAttribute("size");
// Ignore add-on installs
if (aSkip.sourceURIs.indexOf(result.xpiURL) != -1)
return;
result.xpiHash = node.hasAttribute("hash") ? node.getAttribute("hash") : null;
break;
}
}
node = node.nextSibling;
}
// Add only if there was an xpi compatible with this os
if (result.xpiURL)
this._results.push(result);
},
_parseAddons: function(aElements, aTotalResults, aSkip) {
for (var i = 0; i < aElements.length && this._results.length < this._maxResults; i++)
this._parseAddon(aElements[i], aSkip);
var pendingResults = this._results.length;
if (pendingResults == 0) {
this._reportSuccess(aTotalResults);
return;
}
var self = this;
this._results.forEach(function(aResult) {
var addon = aResult.addon;
var callback = function(aInstall) {
addon.install = aInstall;
pendingResults--;
if (pendingResults == 0)
self._reportSuccess(aTotalResults);
}
AddonManager.getInstallForURL(aResult.xpiURL, callback,
"application/x-xpinstall", aResult.xpiHash,
addon.name, addon.iconURL, addon.version);
});
},
// Called when a single request has completed, parses out any add-ons and
// either notifies the callback or does a new request for more results
_listLoaded: function(aEvent) {
var request = aEvent.target;
var responseXML = request.responseXML;
if (!responseXML || responseXML.documentElement.namespaceURI == XMLURI_PARSE_ERROR ||
(request.status != 200 && request.status != 0)) {
this._reportFailure();
return;
}
var elements = responseXML.documentElement.getElementsByTagName("addon");
if (responseXML.documentElement.hasAttribute("total_results"))
var totalResults = responseXML.documentElement.getAttribute("total_results");
else
var totalResults = elements.length;
var self = this;
var skip = {ids: null, sourceURIs: null};
AddonManager.getAllAddons(function(aAddons) {
skip.ids = [a.id for each (a in aAddons)];
if (skip.sourceURIs)
self._parseAddons(elements, totalResults, skip);
});
AddonManager.getAllInstalls(function(aInstalls) {
skip.sourceURIs = [];
aInstalls.forEach(function(aInstall) {
if (aInstall.state != AddonManager.STATE_AVAILABLE)
skip.sourceURIs.push(aInstall.sourceURI.spec);
});
if (skip.ids)
self._parseAddons(elements, totalResults, skip);
});
},
// Performs a new request for results
_loadList: function(aURI) {
this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
createInstance(Ci.nsIXMLHttpRequest);
this._request.open("GET", aURI, true);
this._request.overrideMimeType("text/xml");
var self = this;
this._request.onerror = function(aEvent) { self._reportFailure(); };
this._request.onload = function(aEvent) { self._listLoaded(aEvent); };
this._request.send(null);
},
// Create url from pref, returning null if pref does not exist
_formatURLPref: function(aPref, aSubstitutions) {
var url = null;
try {
url = Services.prefs.getCharPref(aPref);
} catch(e) {
Cu.reportError("_formatURLPref: Couldn't get pref: " + aPref);
return null;
}
url = url.replace(/%([A-Z_]+)%/g, function(aMatch, aKey) {
return (aKey in aSubstitutions) ? aSubstitutions[aKey] : aMatch;
});
return Services.urlFormatter.formatURL(url);
}
}