| # 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/. |
| |
| import os |
| import shutil |
| import sys |
| import tempfile |
| import urllib2 |
| import zipfile |
| from xml.dom import minidom |
| |
| import mozfile |
| from mozlog.unstructured import getLogger |
| |
| # Needed for the AMO's rest API - https://developer.mozilla.org/en/addons.mozilla.org_%28AMO%29_API_Developers%27_Guide/The_generic_AMO_API |
| AMO_API_VERSION = "1.5" |
| |
| # Logger for 'mozprofile.addons' module |
| module_logger = getLogger(__name__) |
| |
| |
| class AddonFormatError(Exception): |
| """Exception for not well-formed add-on manifest files""" |
| |
| |
| class AddonManager(object): |
| """ |
| Handles all operations regarding addons in a profile including: |
| installing and cleaning addons |
| """ |
| |
| def __init__(self, profile, restore=True): |
| """ |
| :param profile: the path to the profile for which we install addons |
| :param restore: whether to reset to the previous state on instance garbage collection |
| """ |
| self.profile = profile |
| self.restore = restore |
| |
| # Initialize all class members |
| self._internal_init() |
| |
| def _internal_init(self): |
| """Internal: Initialize all class members to their default value""" |
| |
| # Add-ons installed; needed for cleanup |
| self._addons = [] |
| |
| # Backup folder for already existing addons |
| self.backup_dir = None |
| |
| # Add-ons downloaded and which have to be removed from the file system |
| self.downloaded_addons = [] |
| |
| # Information needed for profile reset (see http://bit.ly/17JesUf) |
| self.installed_addons = [] |
| self.installed_manifests = [] |
| |
| def __del__(self): |
| # reset to pre-instance state |
| if self.restore: |
| self.clean() |
| |
| def clean(self): |
| """Clean up addons in the profile.""" |
| |
| # Remove all add-ons installed |
| for addon in self._addons: |
| # TODO (bug 934642) |
| # Once we have a proper handling of add-ons we should kill the id |
| # from self._addons once the add-on is removed. For now lets forget |
| # about the exception |
| try: |
| self.remove_addon(addon) |
| except IOError: |
| pass |
| |
| # Remove all downloaded add-ons |
| for addon in self.downloaded_addons: |
| mozfile.remove(addon) |
| |
| # restore backups |
| if self.backup_dir and os.path.isdir(self.backup_dir): |
| extensions_path = os.path.join(self.profile, 'extensions', 'staged') |
| |
| for backup in os.listdir(self.backup_dir): |
| backup_path = os.path.join(self.backup_dir, backup) |
| shutil.move(backup_path, extensions_path) |
| |
| if not os.listdir(self.backup_dir): |
| mozfile.remove(self.backup_dir) |
| |
| # reset instance variables to defaults |
| self._internal_init() |
| |
| @classmethod |
| def download(self, url, target_folder=None): |
| """ |
| Downloads an add-on from the specified URL to the target folder |
| |
| :param url: URL of the add-on (XPI file) |
| :param target_folder: Folder to store the XPI file in |
| |
| """ |
| response = urllib2.urlopen(url) |
| fd, path = tempfile.mkstemp(suffix='.xpi') |
| os.write(fd, response.read()) |
| os.close(fd) |
| |
| if not self.is_addon(path): |
| mozfile.remove(path) |
| raise AddonFormatError('Not a valid add-on: %s' % url) |
| |
| # Give the downloaded file a better name by using the add-on id |
| details = self.addon_details(path) |
| new_path = path.replace('.xpi', '_%s.xpi' % details.get('id')) |
| |
| # Move the add-on to the target folder if requested |
| if target_folder: |
| new_path = os.path.join(target_folder, os.path.basename(new_path)) |
| |
| os.rename(path, new_path) |
| |
| return new_path |
| |
| def get_addon_path(self, addon_id): |
| """Returns the path to the installed add-on |
| |
| :param addon_id: id of the add-on to retrieve the path from |
| """ |
| # By default we should expect add-ons being located under the |
| # extensions folder. Only if the application hasn't been run and |
| # installed the add-ons yet, it will be located under 'staged'. |
| # Also add-ons could have been unpacked by the application. |
| extensions_path = os.path.join(self.profile, 'extensions') |
| paths = [os.path.join(extensions_path, addon_id), |
| os.path.join(extensions_path, addon_id + '.xpi'), |
| os.path.join(extensions_path, 'staged', addon_id), |
| os.path.join(extensions_path, 'staged', addon_id + '.xpi')] |
| for path in paths: |
| if os.path.exists(path): |
| return path |
| |
| raise IOError('Add-on not found: %s' % addon_id) |
| |
| @classmethod |
| def is_addon(self, addon_path): |
| """ |
| Checks if the given path is a valid addon |
| |
| :param addon_path: path to the add-on directory or XPI |
| """ |
| try: |
| self.addon_details(addon_path) |
| return True |
| except AddonFormatError: |
| return False |
| |
| def install_addons(self, addons=None, manifests=None): |
| """ |
| Installs all types of addons |
| |
| :param addons: a list of addon paths to install |
| :param manifest: a list of addon manifests to install |
| """ |
| |
| # install addon paths |
| if addons: |
| if isinstance(addons, basestring): |
| addons = [addons] |
| for addon in set(addons): |
| self.install_from_path(addon) |
| |
| # install addon manifests |
| if manifests: |
| if isinstance(manifests, basestring): |
| manifests = [manifests] |
| for manifest in manifests: |
| self.install_from_manifest(manifest) |
| |
| def install_from_manifest(self, filepath): |
| """ |
| Installs addons from a manifest |
| :param filepath: path to the manifest of addons to install |
| """ |
| try: |
| from manifestparser import ManifestParser |
| except ImportError: |
| module_logger.critical( |
| "Installing addons from manifest requires the" |
| " manifestparser package to be installed.") |
| raise |
| |
| manifest = ManifestParser() |
| manifest.read(filepath) |
| addons = manifest.get() |
| |
| for addon in addons: |
| if '://' in addon['path'] or os.path.exists(addon['path']): |
| self.install_from_path(addon['path']) |
| continue |
| |
| # No path specified, try to grab it off AMO |
| locale = addon.get('amo_locale', 'en_US') |
| query = 'https://services.addons.mozilla.org/' + locale + '/firefox/api/' + AMO_API_VERSION + '/' |
| if 'amo_id' in addon: |
| query += 'addon/' + addon['amo_id'] # this query grabs information on the addon base on its id |
| else: |
| query += 'search/' + addon['name'] + '/default/1' # this query grabs information on the first addon returned from a search |
| install_path = AddonManager.get_amo_install_path(query) |
| self.install_from_path(install_path) |
| |
| self.installed_manifests.append(filepath) |
| |
| @classmethod |
| def get_amo_install_path(self, query): |
| """ |
| Get the addon xpi install path for the specified AMO query. |
| |
| :param query: query-documentation_ |
| |
| .. _query-documentation: https://developer.mozilla.org/en/addons.mozilla.org_%28AMO%29_API_Developers%27_Guide/The_generic_AMO_API |
| """ |
| response = urllib2.urlopen(query) |
| dom = minidom.parseString(response.read()) |
| for node in dom.getElementsByTagName('install')[0].childNodes: |
| if node.nodeType == node.TEXT_NODE: |
| return node.data |
| |
| @classmethod |
| def addon_details(cls, addon_path): |
| """ |
| Returns a dictionary of details about the addon. |
| |
| :param addon_path: path to the add-on directory or XPI |
| |
| Returns:: |
| |
| {'id': u'rainbow@colors.org', # id of the addon |
| 'version': u'1.4', # version of the addon |
| 'name': u'Rainbow', # name of the addon |
| 'unpack': False } # whether to unpack the addon |
| """ |
| |
| details = { |
| 'id': None, |
| 'unpack': False, |
| 'name': None, |
| 'version': None |
| } |
| |
| def get_namespace_id(doc, url): |
| attributes = doc.documentElement.attributes |
| namespace = "" |
| for i in range(attributes.length): |
| if attributes.item(i).value == url: |
| if ":" in attributes.item(i).name: |
| # If the namespace is not the default one remove 'xlmns:' |
| namespace = attributes.item(i).name.split(':')[1] + ":" |
| break |
| return namespace |
| |
| def get_text(element): |
| """Retrieve the text value of a given node""" |
| rc = [] |
| for node in element.childNodes: |
| if node.nodeType == node.TEXT_NODE: |
| rc.append(node.data) |
| return ''.join(rc).strip() |
| |
| if not os.path.exists(addon_path): |
| raise IOError('Add-on path does not exist: %s' % addon_path) |
| |
| try: |
| if zipfile.is_zipfile(addon_path): |
| # Bug 944361 - We cannot use 'with' together with zipFile because |
| # it will cause an exception thrown in Python 2.6. |
| try: |
| compressed_file = zipfile.ZipFile(addon_path, 'r') |
| manifest = compressed_file.read('install.rdf') |
| finally: |
| compressed_file.close() |
| elif os.path.isdir(addon_path): |
| with open(os.path.join(addon_path, 'install.rdf'), 'r') as f: |
| manifest = f.read() |
| else: |
| raise IOError('Add-on path is neither an XPI nor a directory: %s' % addon_path) |
| except (IOError, KeyError), e: |
| raise AddonFormatError, str(e), sys.exc_info()[2] |
| |
| try: |
| doc = minidom.parseString(manifest) |
| |
| # Get the namespaces abbreviations |
| em = get_namespace_id(doc, 'http://www.mozilla.org/2004/em-rdf#') |
| rdf = get_namespace_id(doc, 'http://www.w3.org/1999/02/22-rdf-syntax-ns#') |
| |
| description = doc.getElementsByTagName(rdf + 'Description').item(0) |
| for entry, value in description.attributes.items(): |
| # Remove the namespace prefix from the tag for comparison |
| entry = entry.replace(em, "") |
| if entry in details.keys(): |
| details.update({entry: value}) |
| for node in description.childNodes: |
| # Remove the namespace prefix from the tag for comparison |
| entry = node.nodeName.replace(em, "") |
| if entry in details.keys(): |
| details.update({entry: get_text(node)}) |
| except Exception, e: |
| raise AddonFormatError, str(e), sys.exc_info()[2] |
| |
| # turn unpack into a true/false value |
| if isinstance(details['unpack'], basestring): |
| details['unpack'] = details['unpack'].lower() == 'true' |
| |
| # If no ID is set, the add-on is invalid |
| if details.get('id') is None: |
| raise AddonFormatError('Add-on id could not be found.') |
| |
| return details |
| |
| def install_from_path(self, path, unpack=False): |
| """ |
| Installs addon from a filepath, url or directory of addons in the profile. |
| |
| :param path: url, path to .xpi, or directory of addons |
| :param unpack: whether to unpack unless specified otherwise in the install.rdf |
| """ |
| |
| # if the addon is a URL, download it |
| # note that this won't work with protocols urllib2 doesn't support |
| if mozfile.is_url(path): |
| path = self.download(path) |
| self.downloaded_addons.append(path) |
| |
| addons = [path] |
| |
| # if path is not an add-on, try to install all contained add-ons |
| try: |
| self.addon_details(path) |
| except AddonFormatError, e: |
| module_logger.warning('Could not install %s: %s' % (path, str(e))) |
| |
| # If the path doesn't exist, then we don't really care, just return |
| if not os.path.isdir(path): |
| return |
| |
| addons = [os.path.join(path, x) for x in os.listdir(path) if |
| self.is_addon(os.path.join(path, x))] |
| addons.sort() |
| |
| # install each addon |
| for addon in addons: |
| # determine the addon id |
| addon_details = self.addon_details(addon) |
| addon_id = addon_details.get('id') |
| |
| # if the add-on has to be unpacked force it now |
| # note: we might want to let Firefox do it in case of addon details |
| orig_path = None |
| if os.path.isfile(addon) and (unpack or addon_details['unpack']): |
| orig_path = addon |
| addon = tempfile.mkdtemp() |
| mozfile.extract(orig_path, addon) |
| |
| # copy the addon to the profile |
| extensions_path = os.path.join(self.profile, 'extensions', 'staged') |
| addon_path = os.path.join(extensions_path, addon_id) |
| |
| if os.path.isfile(addon): |
| addon_path += '.xpi' |
| |
| # move existing xpi file to backup location to restore later |
| if os.path.exists(addon_path): |
| self.backup_dir = self.backup_dir or tempfile.mkdtemp() |
| shutil.move(addon_path, self.backup_dir) |
| |
| # copy new add-on to the extension folder |
| if not os.path.exists(extensions_path): |
| os.makedirs(extensions_path) |
| shutil.copy(addon, addon_path) |
| else: |
| # move existing folder to backup location to restore later |
| if os.path.exists(addon_path): |
| self.backup_dir = self.backup_dir or tempfile.mkdtemp() |
| shutil.move(addon_path, self.backup_dir) |
| |
| # copy new add-on to the extension folder |
| shutil.copytree(addon, addon_path, symlinks=True) |
| |
| # if we had to extract the addon, remove the temporary directory |
| if orig_path: |
| mozfile.remove(addon) |
| addon = orig_path |
| |
| self._addons.append(addon_id) |
| self.installed_addons.append(addon) |
| |
| def remove_addon(self, addon_id): |
| """Remove the add-on as specified by the id |
| |
| :param addon_id: id of the add-on to be removed |
| """ |
| path = self.get_addon_path(addon_id) |
| mozfile.remove(path) |