From 7f90679c8453e3aefc42f8bf03b00694864db55f Mon Sep 17 00:00:00 2001 From: Tomeu Vizoso Date: Thu, 06 Aug 2009 11:22:14 +0000 Subject: Rewrite activity updater for: - Drop unused functionality - Not use threads - Improve the class design - Use the same coding style as the rest of Sugar --- (limited to 'extensions') diff --git a/extensions/cpsection/updater/backends/aslo.py b/extensions/cpsection/updater/backends/aslo.py index be76b4c..8c01ec2 100644 --- a/extensions/cpsection/updater/backends/aslo.py +++ b/extensions/cpsection/updater/backends/aslo.py @@ -15,7 +15,7 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -"""Activity information microformat parser. +'''Activity information microformat parser. Activity information is embedded in HTML/XHTML/XML pages using a Resource Description Framework (RDF) http://www.w3.org/RDF/ . @@ -46,13 +46,14 @@ An example:: -""" +''' import logging -import urllib2 from xml.etree.ElementTree import XML import traceback +import gio + from jarabe import config _FIND_DESCRIPTION = \ @@ -63,38 +64,94 @@ _FIND_SIZE = './/{http://www.mozilla.org/2004/em-rdf#}updateSize' _UPDATE_PATH = 'http://activities.sugarlabs.org/services/update.php' -def _parse_url(url, bundle_id): - response = urllib2.urlopen(url) - document = XML(response.read()) - - if document.find(_FIND_DESCRIPTION) is None: - logging.debug('Bundle %s not available in the server for the version' - ' %s' % (bundle_id, config.version)) - return '0', None, 0 - - version = document.find(_FIND_VERSION).text - link = document.find(_FIND_LINK).text - size = long(document.find(_FIND_SIZE).text) * 1024 - - return version, link, size - -def fetch_update_info(bundle): - """Return a tuple of (version, link, size_in_bytes) for new version. - - Returns ('0', None, 0) if no update is found for the platform version. - """ - - url = '%s?id=%s&appVersion=%s' % \ - (_UPDATE_PATH, bundle.get_bundle_id(), '0.84.1') #config.version) - - return _parse_url(url, bundle.get_bundle_id()) - -######################################################################### -# Self-test code. -def _main(): - """Self-test.""" - print _parse_url('%s?id=bounce' % _UPDATE_PATH, 'bounce') - -if __name__ == '__main__': - _main() - +_fetcher = None + + +class _UpdateFetcher(object): + + _CHUNK_SIZE = 10240 + + def __init__(self, bundle, completion_cb): + + url = '%s?id=%s&appVersion=%s' % \ + (_UPDATE_PATH, bundle.get_bundle_id(), config.version) + + self._completion_cb = completion_cb + self._file = gio.File(url) + self._stream = None + self._xml_data = '' + self._bundle = bundle + + self._file.read_async(self.__file_read_async_cb) + + def __file_read_async_cb(self, gfile, result): + try: + self._stream = self._file.read_finish(result) + except: + global _fetcher + _fetcher = None + self._completion_cb(None, None, None, None, traceback.format_exc()) + return + + self._stream.read_async(self._CHUNK_SIZE, self.__stream_read_async_cb) + + def __stream_read_async_cb(self, stream, result): + xml_data = self._stream.read_finish(result) + if xml_data is None: + global _fetcher + _fetcher = None + self._completion_cb(self._bundle, None, None, None, + 'Error reading update information for %s from ' + 'server.' % self._bundle.get_bundle_id()) + return + elif not xml_data: + self._process_result() + else: + self._xml_data += xml_data + self._stream.read_async(self._CHUNK_SIZE, + self.__stream_read_async_cb) + + def _process_result(self): + document = XML(self._xml_data) + + if document.find(_FIND_DESCRIPTION) is None: + logging.debug('Bundle %s not available in the server for the ' + 'version %s' % (self._bundle.get_bundle_id(), + config.version)) + version = None + link = None + size = None + else: + try: + version = int(document.find(_FIND_VERSION).text) + except ValueError: + logging.error(traceback.format_exc()) + version = 0 + + link = document.find(_FIND_LINK).text + + try: + size = long(document.find(_FIND_SIZE).text) * 1024 + except ValueError: + logging.error(traceback.format_exc()) + size = 0 + + global _fetcher + _fetcher = None + self._completion_cb(self._bundle, version, link, size, None) + + +def fetch_update_info(bundle, completion_cb): + '''Queries the server for a newer version of the ActivityBundle. + + completion_cb receives bundle, version, link, size and possibly an error + message: + + def completion_cb(bundle, version, link, size, error_message): + ''' + global _fetcher + + if _fetcher is not None: + raise RuntimeError('Multiple simultaneous requests are not supported') + + _fetcher = _UpdateFetcher(bundle, completion_cb) diff --git a/extensions/cpsection/updater/model.py b/extensions/cpsection/updater/model.py index 7a4a908..489cfa5 100755 --- a/extensions/cpsection/updater/model.py +++ b/extensions/cpsection/updater/model.py @@ -1,5 +1,5 @@ -#!/usr/bin/python # Copyright (C) 2009, Sugar Labs +# Copyright (C) 2009, Tomeu Vizoso # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,263 +14,279 @@ # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -"""Sugar bundle updater: model. +'''Sugar bundle updater: model. This module implements the non-GUI portions of the bundle updater, including list of installed bundls, whether updates are needed, and the URL at which to find the bundle updated. - -`UpdateList` inherits from `gtk.ListStore` in order to work closely with the -view pane. This module requires `gtk`. -""" +''' import os -import tempfile -import locale import logging -import urllib -from gettext import gettext as _ +import tempfile +from urlparse import urlparse import traceback import gobject -import gtk +import gio -from sugar.bundle.activitybundle import ActivityBundle +from sugar import env from sugar.datastore import datastore +from sugar.bundle.activitybundle import ActivityBundle from jarabe.model import bundleregistry + from backends import aslo -"""List of columns in the `UpdateList`.""" -BUNDLE_ID, \ - BUNDLE, \ - ICON, \ - NAME, \ - CURRENT_VERSION, \ - UPDATE_VERSION, \ - UPDATE_SIZE, \ - UPDATE_URL, \ - DESCRIPTION, \ - UPDATE_SELECTED, \ - UPDATE_AVAILABLE, \ - IS_HEADER = xrange(12) - -class UpdateList(gtk.ListStore): - """Model which provides backing storage for the BUNDLE list treeview.""" - - __gproperties__ = { - 'is_valid': (gobject.TYPE_BOOLEAN, 'is valid', - 'true iff the UpdateList has been properly refreshed', - False, gobject.PARAM_READABLE), + +class UpdateModel(gobject.GObject): + __gtype_name__ = 'SugarUpdateModel' + + __gsignals__ = { + 'progress': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([int, str, float, int])), } + ACTION_CHECKING = 0 + ACTION_UPDATING = 1 + ACTION_DOWNLOADING = 2 + def __init__(self): - logging.debug('STARTUP: Loading the bundle updater') + gobject.GObject.__init__(self) + + self.updates = None + self._bundles_to_check = None + self._bundles_to_update = None + self._total_bundles_to_update = 0 + self._downloader = None + + def check_updates(self): + self.updates = [] + self._bundles_to_check = \ + [bundle for bundle in bundleregistry.get_registry()] + self._check_next_update() + + def _check_next_update(self): + total = len(bundleregistry.get_registry()) + current = total - len(self._bundles_to_check) + + bundle = self._bundles_to_check.pop() + self.emit('progress', UpdateModel.ACTION_CHECKING, bundle.get_name(), + current, total) + + aslo.fetch_update_info(bundle, self.__check_completed_cb) + + def __check_completed_cb(self, bundle, version, link, size, error_message): + if error_message is not None: + logging.error('Error getting update information from server:\n' + '%s' % error_message) + + if version is not None and version > bundle.get_activity_version(): + self.updates.append(BundleUpdate(bundle, version, link, size)) + + if self._bundles_to_check: + gobject.idle_add(self._check_next_update) + else: + total = len(bundleregistry.get_registry()) + if bundle is None: + name = '' + else: + name = bundle.get_name() + self.emit('progress', UpdateModel.ACTION_CHECKING, name, total, + total) + + def update(self, bundle_ids): + self._bundles_to_update = [] + for bundle_update in self.updates: + if bundle_update.bundle.get_bundle_id() in bundle_ids: + self._bundles_to_update.append(bundle_update) + + self._total_bundles_to_update = len(self._bundles_to_update) + self._download_next_update() + + def _download_next_update(self): + bundle_update = self._bundles_to_update.pop() - gtk.ListStore.__init__(self, - str, object, gtk.gdk.Pixbuf, str, - long, long, long, str, - str, bool, bool, bool) + total = self._total_bundles_to_update * 2 + current = total - len(self._bundles_to_update) * 2 - 2 - self._cancel = False - self._is_valid = True - self._registry = bundleregistry.get_registry() - self._steps_count = 0 - self._steps_total = 0 - self._progress_cb = None + self.emit('progress', UpdateModel.ACTION_DOWNLOADING, + bundle_update.bundle.get_name(), current, total) - def refresh_list(self, progress_callback=lambda n, extra: None, - clear_cache=True): - self._cancel = False - self._progress_cb = progress_callback - self._progress_cb(None, _('Looking for local actvities...')) + self._downloader = _Downloader(bundle_update) + self._downloader.connect('progress', self.__downloader_progress_cb) + self._downloader.connect('error', self.__downloader_error_cb) - self.clear() - self._steps_total = len([i for i in self._registry]) - self._steps_count = 0 + def __downloader_progress_cb(self, downloader, progress): + logging.debug('__downloader_progress_cb %r' % progress) + total = self._total_bundles_to_update * 2 + current = total - len(self._bundles_to_update) * 2 - 2 + progress - row_map = {} + self.emit('progress', UpdateModel.ACTION_DOWNLOADING, + self._downloader.bundle_update.bundle.get_name(), + current, total) - for bundle in self._registry: - self._make_progress(_('Checking %s...') % bundle.get_name()) + if progress == 1: + self._install_update(self._downloader.bundle_update, + self._downloader.get_local_file_path()) + self._downloader = None - if self._cancel: - break # Cancel bundle refresh + def __downloader_error_cb(self, downloader, error_message): + logging.error('Error downloading update:\n%s' % error_message) - row = [None, None, None, None, - 0, 0, 0, None, - None, True, False, False] - row[BUNDLE] = bundle - row[BUNDLE_ID] = bundle.get_bundle_id() + total = self._total_bundles_to_update * 2 + current = total - len(self._bundles_to_update) * 2 + self.emit('progress', UpdateModel.ACTION_UPDATING, '', current, total) - if self._refresh_row(row): - row_map[row[BUNDLE_ID]] = self.get_path(self.append(row)) + if self._bundles_to_update: + # do it in idle so the UI has a chance to refresh + gobject.idle_add(self._download_next_update) - def cancel(self): - self._cancel = True + def _install_update(self, bundle_update, local_file_path): - def _refresh_row(self, row): - logging.debug('Looking for %s' % row[BUNDLE].get_name()) + total = self._total_bundles_to_update * 2 + current = total - len(self._bundles_to_update) * 2 - 1 + self.emit('progress', UpdateModel.ACTION_UPDATING, + bundle_update.bundle.get_name(), + current, total) + + # TODO: Should we first expand the zip async so we can provide progress + # and only then copy to the journal? + jobject = datastore.create() + try: + title = '%s-%s' % (bundle_update.bundle.get_name(), + bundle_update.version) + jobject.metadata['title'] = title + jobject.metadata['mime_type'] = ActivityBundle.MIME_TYPE + jobject.file_path = local_file_path + datastore.write(jobject, transfer_ownership=True) + finally: + jobject.destroy() + + current += 1 + self.emit('progress', UpdateModel.ACTION_UPDATING, + bundle_update.bundle.get_name(), + current, total) + + if self._bundles_to_update: + # do it in idle so the UI has a chance to refresh + gobject.idle_add(self._download_next_update) + + def get_total_bundles_to_update(self): + return self._total_bundles_to_update + + +class BundleUpdate(object): + + def __init__(self, bundle, version, link, size): + self.bundle = bundle + self.version = version + self.link = link + self.size = size + + +class _Downloader(gobject.GObject): + _CHUNK_SIZE = 10240 # 10K + __gsignals__ = { + 'progress': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([float])), + 'error': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([str])), + } + + def __init__(self, bundle_update): + gobject.GObject.__init__(self) + + self.bundle_update = bundle_update + self._input_stream = None + self._output_stream = None + self._pending_buffers = [] + self._input_file = gio.File(bundle_update.link) + self._output_file = None + self._downloaded_size = 0 + + self._input_file.read_async(self.__file_read_async_cb) + + def __file_read_async_cb(self, gfile, result): try: - new_ver, new_url, new_size = aslo.fetch_update_info(row[BUNDLE]) - except Exception: - logging.warning('Failure updating %s\n%s' % \ - (row[BUNDLE].get_name(), traceback.format_exc())) - return False - - row[CURRENT_VERSION] = row[BUNDLE].get_activity_version() - row[UPDATE_VERSION] = long(new_ver) - - if row[CURRENT_VERSION] >= row[UPDATE_VERSION]: - logging.debug('Skip %s update' % row[BUNDLE].get_name()) - return False - - row[ICON] = gtk.gdk.pixbuf_new_from_file_at_size( - row[BUNDLE].get_icon(), 32, 32) - row[NAME] = row[BUNDLE].get_name() - row[UPDATE_URL] = new_url - row[UPDATE_AVAILABLE] = True - row[UPDATE_SIZE] = new_size - row[DESCRIPTION] = \ - _('From version %(current)d to %(new)s (Size: %(size)s)') % \ - {'current': row[CURRENT_VERSION], - 'new': row[UPDATE_VERSION], - 'size': _humanize_size(row[UPDATE_SIZE])} - row[UPDATE_SELECTED] = True - - return True - - def install_updates(self, progress_cb=(lambda n, row: None)): - self._cancel = False - self._progress_cb = progress_cb - self._steps_total = len([0 for row in self if row[UPDATE_SELECTED]]) * 2 - self._steps_count = 0 - - installed = 0 - - for row in self: - if self._cancel: - return installed - if row[IS_HEADER]: - continue - if not row[UPDATE_SELECTED]: - continue - - logging.debug('Downloading %s from %s' % \ - (row[NAME], row[UPDATE_URL])) - self._make_progress(_('Downloading %s...') % row[NAME]) - - fd, xofile = tempfile.mkstemp(suffix='.xo') - os.close(fd) - try: - try: - urllib.urlretrieve(row[UPDATE_URL], xofile) - except Exception, e: - logging.warning("Can't download %s: %s" % \ - (row[UPDATE_URL], e)) - continue - - if self._cancel: - return installed - - logging.debug('Installing %s' % row[NAME]) - self._make_progress(_('Installing %s...') % row[NAME]) - - jobject = datastore.create() - jobject.metadata['title'] = \ - '%s-%s' % (row[NAME], row[UPDATE_VERSION]) - jobject.metadata['mime_type'] = ActivityBundle.MIME_TYPE - jobject.file_path = xofile - datastore.write(jobject, transfer_ownership=True) - - installed += 1 - finally: - if os.path.exists(xofile): - os.unlink(xofile) - - return installed - -############################################################################### - - def _make_progress(self, msg=None): #FIXME needs better name - """Helper function to do progress update.""" - self._steps_count += 1 - self._progress_cb(float(self._steps_count)/self._steps_total, msg) - - def _sum_rows(self, row_func): - """Sum the values returned by row_func called on all non-header - rows.""" - return sum(row_func(r) for r in self if not r[IS_HEADER]) - -############################################################################### - - def updates_available(self): - """Return the number of updates available. - - Updated by `refresh`.""" - return self._sum_rows(lambda r: 1 if r[UPDATE_AVAILABLE] else 0) - - def updates_selected(self): - """Return the number of updates selected.""" - return self._sum_rows(lambda r: - 1 if r[UPDATE_AVAILABLE] and r[UPDATE_SELECTED] else 0) - - def updates_size(self): - """Returns the size (in bytes) of the selected updates available. - - Updated by `refresh`.""" - return self._sum_rows(lambda r: - r[UPDATE_SIZE] if r[UPDATE_AVAILABLE] and \ - r[UPDATE_SELECTED] else 0) - def is_valid(self): - """The UpdateList is invalidated before it is refreshed, and when - the group information is modified without refreshing.""" - return self._is_valid - -############################################################################### -# Utility Funtions - -def _humanize_size(bytes_): - """ - Convert a given size in bytes to a nicer better readable unit - """ - if bytes_ == 0: - # TRANS: download size is 0 - return _('None') - elif bytes_ < 1024: - # TRANS: download size of very small updates - return _('1 KB') - elif bytes_ < 1024 * 1024: - # TRANS: download size of small updates, e.g. '250 KB' - return locale.format(_('%.0f KB'), bytes_ / 1024) - else: - # TRANS: download size of updates, e.g. '2.3 MB' - return locale.format(_('%.1f MB'), bytes_ / 1024 / 1024) - -def print_available(ul):#FIXME this should onlu return available updates - def opt(x): - if x is None or x == '': - return '' - return ': %s' % x - for row in ul: - if row[IS_HEADER]: - print row[NAME] + opt(row[DESCRIPTION]) + self._input_stream = self._input_file.read_finish(result) + except: + self.emit('error', traceback.format_exc()) + return + + temp_file_path = self._get_temp_file_path(self.bundle_update.link) + self._output_file = gio.File(temp_file_path) + self._output_stream = self._output_file.create() + + self._input_stream.read_async(self._CHUNK_SIZE, self.__read_async_cb, + gobject.PRIORITY_LOW) + + def __read_async_cb(self, input_stream, result): + data = input_stream.read_finish(result) + + if data is None: + # TODO + pass + elif not data: + logging.debug('closing input stream') + self._input_stream.close() + self._check_if_finished_writing() else: - print '*', row[NAME] + opt(row[DESCRIPTION]) - print - #print _('%(number)d updates available. Size: %(size)s') % \ - # { 'number': ul.updates_available(), - # 'size': _humanize_size(ul.updates_size()) } - -############################################################################### -# Self-test code. -def _main(): - """Self-test.""" - update_list = UpdateList() - update_list.refresh_list() - print_available(update_list) - update_list.install_updates() - -if __name__ == '__main__': - _main() + self._pending_buffers.append(data) + self._input_stream.read_async(self._CHUNK_SIZE, + self.__read_async_cb, + gobject.PRIORITY_LOW) + + self._write_next_buffer() + + def __write_async_cb(self, output_stream, result, user_data): + count = output_stream.write_finish(result) + + self._downloaded_size += count + progress = self._downloaded_size / float(self.bundle_update.size) + self.emit('progress', progress) + + self._check_if_finished_writing() + + if self._pending_buffers: + self._write_next_buffer() + + def _write_next_buffer(self): + if self._pending_buffers and not self._output_stream.has_pending(): + data = self._pending_buffers.pop(0) + # TODO: we pass the buffer as user_data because of + # http://bugzilla.gnome.org/show_bug.cgi?id=564102 + self._output_stream.write_async(data, self.__write_async_cb, + gobject.PRIORITY_LOW, + user_data=data) + + def _get_temp_file_path(self, uri): + # TODO: Should we use the HTTP headers for the file name? + scheme_, netloc_, path, params_, query_, fragment_ = \ + urlparse(uri) + path = os.path.basename(path) + + base_name, extension_ = os.path.splitext(path) + fd, file_path = tempfile.mkstemp(dir=env.get_user_activities_path(), + prefix=base_name, suffix='.xo') + os.close(fd) + os.unlink(file_path) + + return file_path + + def get_local_file_path(self): + return self._output_file.get_path() + + def _check_if_finished_writing(self): + if not self._pending_buffers and \ + not self._output_stream.has_pending() and \ + self._input_stream.is_closed(): + + logging.debug('closing output stream') + self._output_stream.close() + + self.emit('progress', 1.0) diff --git a/extensions/cpsection/updater/view.py b/extensions/cpsection/updater/view.py index 0b5062e..9a77743 100644 --- a/extensions/cpsection/updater/view.py +++ b/extensions/cpsection/updater/view.py @@ -1,3 +1,6 @@ +# Copyright (C) 2008, One Laptop Per Child +# Copyright (C) 2009, Tomeu Vizoso +# # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or @@ -12,381 +15,368 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -import logging -import gtk -import gobject -from threading import Thread from gettext import gettext as _ from gettext import ngettext +import locale -from sugar.graphics import style -from jarabe.controlpanel.sectionview import SectionView +import gobject +import gtk -import model +from sugar.graphics import style +from sugar.graphics.icon import Icon, CellRendererIcon -gtk.gdk.threads_init() +from jarabe.controlpanel.sectionview import SectionView -_e = gobject.markup_escape_text +from model import UpdateModel _DEBUG_VIEW_ALL = True + class ActivityUpdater(SectionView): - def __init__(self, modelwrapper, alerts): + + def __init__(self, model, alerts): SectionView.__init__(self) + + self._model = UpdateModel() + self._model.connect('progress', self.__progress_cb) + self.set_spacing(style.DEFAULT_SPACING) self.set_border_width(style.DEFAULT_SPACING * 2) - # top label # self._top_label = gtk.Label() self._top_label.set_line_wrap(True) self._top_label.set_justify(gtk.JUSTIFY_LEFT) - self._top_label.set_property('xalign', 0) + self._top_label.props.xalign = 0 + self.pack_start(self._top_label, expand=False) + self._top_label.show() + + separator = gtk.HSeparator() + self.pack_start(separator, expand=False) + separator.show() - # bottom label # bottom_label = gtk.Label() bottom_label.set_line_wrap(True) bottom_label.set_justify(gtk.JUSTIFY_LEFT) - bottom_label.set_property('xalign', 0) + bottom_label.props.xalign = 0 bottom_label.set_markup( - _('Software updates correct errors, eliminate security' \ + _('Software updates correct errors, eliminate security ' \ 'vulnerabilities, and provide new features.')) - - # main canvas # - self.pack_start(self._top_label, expand=False) - self.pack_start(gtk.HSeparator(), expand=False) self.pack_start(bottom_label, expand=False) + bottom_label.show() - # bundle pane # - self.bundle_list = model.UpdateList() - self.bundle_pane = BundlePane(self) - self.pack_start(self.bundle_pane, expand=True) - - # progress pane # - self.progress_pane = ProgressPane(self) - self.pack_start(self.progress_pane, expand=True, fill=False) - - self.show_all() - - self.refresh_cb(None, None) - - # refresh # - def refresh_cb(self, widget, event): - self._top_label.set_markup('%s' % \ - _('Checking for updates...')) - self.progress_pane.switch_to_check_progress() - self.bundle_list.freeze_notify() - Thread(target=self._do_refresh).start() - - def _do_refresh(self): #@inhibit_suspend - self.bundle_list.refresh_list(self._refresh_progress_cb) - gobject.idle_add(self._refresh_done_cb) - - def _refresh_progress_cb(self, n, extra=None): - gobject.idle_add(self._progress, n, extra) - - # refresh done # - def _refresh_done_cb(self): - """Invoked in main thread when the refresh is complete.""" - self.bundle_list.thaw_notify() - avail = self.bundle_list.updates_available() - if avail == 0: - header = _("Your software is up-to-date") - self.progress_pane.switch_to_complete_message() - else: - header = ngettext("You can install %s update", - "You can install %s updates", avail) \ - % avail - self.bundle_pane.switch() - self._top_label.set_markup('%s' % _e(header)) - self.bundle_pane.refresh_update_size() - - def install_clicked_cb(self, widget, event, data=None): - """Invoked when the 'ok' button is clicked.""" - self._top_label.set_markup('%s' % - _('Installing updates...')) - self.progress_pane.switch_to_download_progress() - self.bundle_list.freeze_notify() - Thread(target=self._do_install).start() - - def _do_install(self): #@inhibit_suspend - installed = self.bundle_list.install_updates(self._refresh_progress_cb) - gobject.idle_add(self._install_done, installed) - - def _install_done(self, installed): - self.bundle_list.thaw_notify() - header = ngettext("%s update was installed", - "%s updates were installed", installed) \ - % installed - self._top_label.set_markup('%s' % _e(header)) - self.progress_pane.update(0) - self.progress_pane.switch_to_complete_message() - - def _progress(self, n, extra=None, icon=None): - """Invoked in main thread during a refresh operation.""" - self.progress_pane.update(n, extra, icon) - - def cancel_cb(self, widget, event, data=None): - """Callback when the cancel button is clicked.""" - self.bundle_list.cancel() - self.progress_pane.cancelling() - -class BundlePane(gtk.VBox): - """Container for the activity and group lists.""" - - def __init__(self, update_activity): - gtk.VBox.__init__(self) - self._updater_activity = update_activity - self.set_spacing(style.DEFAULT_PADDING) + self._update_box = None + self._progress_pane = None - # activity list # - vpaned = gtk.VPaned() - bundles = BundleListView(update_activity, self) - vpaned.pack1(bundles, resize=True, shrink=False) - self.pack_start(vpaned, expand=True) - - # Install/refresh buttons # - button_box = gtk.HBox() - button_box.set_spacing(style.DEFAULT_SPACING) - hbox = gtk.HBox() - hbox.pack_end(button_box, expand=False) - self._size_label = gtk.Label() - self._size_label.set_property('xalign', 0) - self._size_label.set_justify(gtk.JUSTIFY_LEFT) - hbox.pack_start(self._size_label, expand=True) - self.pack_end(hbox, expand=False) - check_button = gtk.Button(stock=gtk.STOCK_REFRESH) - check_button.connect('clicked', update_activity.refresh_cb, self) - button_box.pack_start(check_button, expand=False) - - self._install_button = _make_button(_("Install selected"), - name='emblem-downloads') - self._install_button.connect('clicked', - update_activity.install_clicked_cb, self) - button_box.pack_start(self._install_button, expand=False) - - def is_valid_cb(bundle_list, __): - self._install_button.set_sensitive(bundle_list.is_valid()) - update_activity.bundle_list.connect('notify::is-valid', is_valid_cb) - - def refresh_update_size(self): - """Update the 'download size' label.""" - bundle_list = self._updater_activity.bundle_list - size = bundle_list.updates_size() - self._size_label.set_markup(_('Download size: %s') % - model._humanize_size(size)) - self._install_button.set_sensitive(bundle_list.updates_selected()!=0) - - def switch(self): - """Make the bundle list visible and the progress pane invisible.""" - for widget, v in [(self, True), - (self._updater_activity.progress_pane, False)]:# , - #(self.activity_updater.expander, False)]: - widget.set_property('visible', v) - -class BundleListView(gtk.ScrolledWindow): - """List view at the top, showing activities, versions, and sizes.""" - def __init__(self, update_activity, bundle_pane): - gtk.ScrolledWindow.__init__(self) - self._update_activity = update_activity - self._bundle_pane = bundle_pane - - # create TreeView using a filtered treestore - self._treeview = gtk.TreeView(self._update_activity.bundle_list) - - # toggle cell renderers # - crbool = gtk.CellRendererToggle() - crbool.set_property('activatable', True) - crbool.set_property('xpad', style.DEFAULT_PADDING) - crbool.set_property('indicator_size', style.zoom(26)) - crbool.connect('toggled', self.__toggled_cb) - - # icon cell renderers # - cricon = gtk.CellRendererPixbuf() - cricon.set_property('width', style.STANDARD_ICON_SIZE) - cricon.set_property('height', style.STANDARD_ICON_SIZE) - - # text cell renderers # - crtext = gtk.CellRendererText() - crtext.set_property('xpad', style.DEFAULT_PADDING) - crtext.set_property('ypad', style.DEFAULT_PADDING) - - #create the TreeViewColumn to display the data - def view_func_maker(propname): - def view_func(cell_layout, renderer, m, it): - renderer.set_property(propname, - not m.get_value(it, model.IS_HEADER)) - return view_func - hide_func = view_func_maker('visible') - insens_func = view_func_maker('sensitive') - column_install = gtk.TreeViewColumn('Install', crbool) - column_install.add_attribute(crbool, 'active', - model.UPDATE_SELECTED) - column_install.set_cell_data_func(crbool, hide_func) - column = gtk.TreeViewColumn('Name') - column.pack_start(cricon, expand=False) - column.pack_start(crtext, expand=True) - column.add_attribute(cricon, 'pixbuf', model.ICON) - column.set_resizable(True) - column.set_cell_data_func(cricon, hide_func) - def markup_func(cell_layout, renderer, m, it): - s = '%s' % _e(m.get_value(it, model.NAME)) - if m.get_value(it, model.IS_HEADER): - s = '%s' % s - desc = m.get_value(it, model.DESCRIPTION) - if desc is not None and desc != '': - s += '\n%s' % _e(desc) - renderer.set_property('markup', s) - insens_func(cell_layout, renderer, m, it) - column.set_cell_data_func(crtext, markup_func) - - # add tvcolumn to treeview - self._treeview.append_column(column_install) - self._treeview.append_column(column) - - self._treeview.set_reorderable(False) - self._treeview.set_enable_search(False) - self._treeview.set_headers_visible(False) - self._treeview.set_rules_hint(True) - self._treeview.connect('button-press-event', - self.__button_press_event_cb) - - def is_valid_cb(activity_list, __): - self._treeview.set_sensitive( - self._update_activity.bundle_list.is_valid()) - self._update_activity.bundle_list.connect('notify::is-valid', - is_valid_cb) - is_valid_cb(self._update_activity.bundle_list, None) - - self.add_with_viewport(self._treeview) - self.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) - - def __toggled_cb(self, crbool, path): - row = self._treeview.props.model[path] - row[model.UPDATE_SELECTED] = not row[model.UPDATE_SELECTED] - self._bundle_pane.refresh_update_size() - - def __button_press_event_cb(self, widget, event): - """ - Show a context menu if a right click was performed on an update entry - """ - if not (event.type == gtk.gdk.BUTTON_PRESS and event.button == 3): + self._refresh() + + def _switch_to_update_box(self): + if self._update_box in self.get_children(): return - menu = gtk.Menu() + if self._progress_pane in self.get_children(): + self.remove(self._progress_pane) + self._progress_pane = None - item = gtk.MenuItem(_("_Uncheck All")) - item.connect("activate", self.__check_activate_cb, False) - if self._update_activity.bundle_list.updates_available() == 0: - item.set_property("sensitive", False) - menu.add(item) + if self._update_box is None: + self._update_box = UpdateBox(self._model) + self._update_box.refresh_button.connect('clicked', + self.__refresh_button_clicked_cb) + self._update_box.install_button.connect('clicked', + self.__install_button_clicked_cb) - item = gtk.MenuItem(_("_Check All")) - item.connect("activate", self.__check_activate_cb, True) - if self._update_activity.bundle_list.updates_available() == 0: - item.set_property("sensitive", False) - menu.add(item) + self.pack_start(self._update_box, expand=True, fill=True) + self._update_box.show() - menu.popup(None, None, None, 0, event.time) - menu.show_all() + def _switch_to_progress_pane(self): + if self._progress_pane in self.get_children(): + return + + if self._update_box in self.get_children(): + self.remove(self._update_box) + self._update_box = None + + if self._progress_pane is None: + self._progress_pane = ProgressPane() + + self.pack_start(self._progress_pane, expand=True, fill=False) + self._progress_pane.show() + + def _clear_center(self): + if self._progress_pane in self.get_children(): + self.remove(self._progress_pane) + self._progress_pane = None + + if self._update_box in self.get_children(): + self.remove(self._update_box) + self._update_box = None + + def __progress_cb(self, model, action, bundle_name, current, total): + if current == total and action == UpdateModel.ACTION_CHECKING: + self._finished_checking() + return + elif current == total: + self._finished_updating() + return + + if action == UpdateModel.ACTION_CHECKING: + message = _('Checking %s...') % bundle_name + elif action == UpdateModel.ACTION_DOWNLOADING: + message = _('Downloading %s...') % bundle_name + elif action == UpdateModel.ACTION_UPDATING: + message = _('Updating %s...') % bundle_name + + self._switch_to_progress_pane() + self._progress_pane.set_message(message) + self._progress_pane.set_progress(current / float(total)) + + def _finished_checking(self): + available_updates = len(self._model.updates) + if not available_updates: + top_message = _('Your software is up-to-date') + else: + top_message = ngettext('You can install %s update', + 'You can install %s updates', + available_updates) + top_message = top_message % available_updates + top_message = gobject.markup_escape_text(top_message) + + self._top_label.set_markup('%s' % top_message) + + if not available_updates: + self._clear_center() + else: + self._switch_to_update_box() + self._update_box.refresh() + + def __refresh_button_clicked_cb(self, button): + self._refresh() + + def _refresh(self): + top_message = _('Checking for updates...') + self._top_label.set_markup('%s' % top_message) + self._model.check_updates() + + def __install_button_clicked_cb(self, button): + self._top_label.set_markup('%s' % _('Installing updates...')) + self._model.update(self._update_box.get_bundles_to_update()) + + def _finished_updating(self): + installed_updates = self._model.get_total_bundles_to_update() + top_message = ngettext('%s update was installed', + '%s updates were installed', installed_updates) + top_message = top_message % installed_updates + top_message = gobject.markup_escape_text(top_message) + self._top_label.set_markup('%s' % top_message) + self._clear_center() - def __check_activate_cb(self, sender, state): - for i in self._update_activity.bundle_list: - i[model.UPDATE_SELECTED] = state - self._bundle_pane.refresh_update_size() class ProgressPane(gtk.VBox): - """Container which replaces the `ActivityPane` during refresh or - install.""" + '''Container which replaces the `ActivityPane` during refresh or + install.''' - def __init__(self, update_activity): - self._update_activity = update_activity + def __init__(self): gtk.VBox.__init__(self) self.set_spacing(style.DEFAULT_PADDING) self.set_border_width(style.DEFAULT_SPACING * 2) self._progress = gtk.ProgressBar() + self.pack_start(self._progress) + self._progress.show() + self._label = gtk.Label() self._label.set_line_wrap(True) self._label.set_property('xalign', 0.5) self._label.modify_fg(gtk.STATE_NORMAL, - style.COLOR_BUTTON_GREY.get_gdk_color()) - self._icon = gtk.Image() - self._icon.set_property('height-request', style.STANDARD_ICON_SIZE) - # make an HBox to center the various buttons. - hbox = gtk.HBox() - self._cancel_button = gtk.Button(stock=gtk.STOCK_CANCEL) - self._refresh_button = gtk.Button(stock=gtk.STOCK_REFRESH) - self._try_again_button = _make_button(_('Try again'), - stock=gtk.STOCK_REFRESH) - for widget, cb in [(self._cancel_button, update_activity.cancel_cb), - (self._refresh_button, update_activity.refresh_cb), - (self._try_again_button, update_activity.refresh_cb)]: - widget.connect('clicked', cb, update_activity) - hbox.pack_start(widget, expand=True, fill=False) - - self.pack_start(self._icon) - self.pack_start(self._progress) + style.COLOR_BUTTON_GREY.get_gdk_color()) self.pack_start(self._label) - self.pack_start(hbox) + self._label.show() - def update(self, n, extra=None, icon=None): - """Update the status of the progress pane. `n` should be a float - in [0, 1], or else None. You can optionally provide extra information - in `extra` or an icon in `icon`.""" + alignment_box = gtk.Alignment(xalign=0.5, yalign=0.5) + self.pack_start(alignment_box) + alignment_box.show() - if n is None: - self._progress.pulse() - else: - self._progress.set_fraction(n) - extra = _e(extra) if extra is not None else '' - self._label.set_markup(extra) - self._icon.set_property('visible', icon is not None) - if icon is not None: - self._icon.set_from_pixbuf(icon) - - def switch_to_check_progress(self): - self._switch(show_cancel=True, show_bar=True) - self._label.set_markup(_('Checking for updates...')) - - def switch_to_download_progress(self): - self._switch(show_cancel=True, show_bar=True) - self._label.set_markup(_('Starting download...')) - - def switch_to_complete_message(self): - self._switch(show_cancel=False, show_bar=False) - self._label.set_markup('') - - def cancelling(self): - self._cancel_button.set_sensitive(False) - self._label.set_markup(_('Cancelling...')) - - def _switch(self, show_cancel, show_bar, show_try_again=False): - """Make the progress pane visible and the activity pane invisible.""" - self._update_activity.bundle_pane.set_property('visible', False) - self.set_property('visible', True) - for widget, v in [(self._progress, show_bar), - (self._cancel_button, show_cancel), - (self._refresh_button, - not show_cancel and not show_try_again), - (self._try_again_button, show_try_again), - #(self._update_activity.expander, False) - ]: - widget.set_property('visible', v) - self._cancel_button.set_sensitive(True) - #self._update_activity.expander.set_expanded(False) - -def _make_button(label_text, stock=None, name=None): - """Convenience function to make labelled buttons with images.""" - b = gtk.Button() - hbox = gtk.HBox() - hbox.set_spacing(style.DEFAULT_PADDING) - i = gtk.Image() - if stock is not None: - i.set_from_stock(stock, gtk.ICON_SIZE_BUTTON) - if name is not None: - i.set_from_icon_name(name, gtk.ICON_SIZE_BUTTON) - hbox.pack_start(i, expand=False) - l = gtk.Label(label_text) - hbox.pack_start(l, expand=False) - b.add(hbox) - return b + cancel_button = gtk.Button(stock=gtk.STOCK_CANCEL) + alignment_box.add(cancel_button) + cancel_button.show() + + def set_message(self, message): + self._label.set_text(message) + + def set_progress(self, fraction): + self._progress.props.fraction = fraction + + +class UpdateBox(gtk.VBox): + + def __init__(self, model): + gtk.VBox.__init__(self) + + self._model = model + + self.set_spacing(style.DEFAULT_PADDING) + + scrolled_window = gtk.ScrolledWindow() + scrolled_window.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + self.pack_start(scrolled_window) + scrolled_window.show() + + self._update_list = UpdateList(model) + self._update_list.props.model.connect('row-changed', + self.__row_changed_cb) + scrolled_window.add(self._update_list) + self._update_list.show() + + bottom_box = gtk.HBox() + bottom_box.set_spacing(style.DEFAULT_SPACING) + self.pack_start(bottom_box, expand=False) + bottom_box.show() + + self._size_label = gtk.Label() + self._size_label.props.xalign = 0 + self._size_label.set_justify(gtk.JUSTIFY_LEFT) + bottom_box.pack_start(self._size_label, expand=True) + self._size_label.show() + + self.refresh_button = gtk.Button(stock=gtk.STOCK_REFRESH) + bottom_box.pack_start(self.refresh_button, expand=False) + self.refresh_button.show() + + self.install_button = gtk.Button(_('Install selected')) + self.install_button.props.image = Icon(icon_name='emblem-downloads', + icon_size=gtk.ICON_SIZE_BUTTON) + bottom_box.pack_start(self.install_button, expand=False) + self.install_button.show() + + self._update_total_size_label() + + def refresh(self): + self._update_list.refresh() + + def __row_changed_cb(self, list_model, path, iterator): + self._update_total_size_label() + self._update_install_button() + + def _update_total_size_label(self): + total_size = 0 + for row in self._update_list.props.model: + if row[UpdateListModel.SELECTED]: + total_size += row[UpdateListModel.SIZE] + + markup = _('Download size: %s') % _format_size(total_size) + self._size_label.set_markup(markup) + + def _update_install_button(self): + for row in self._update_list.props.model: + if row[UpdateListModel.SELECTED]: + self.install_button.props.sensitive = True + return + self.install_button.props.sensitive = False + + def get_bundles_to_update(self): + bundles_to_update = [] + for row in self._update_list.props.model: + if row[UpdateListModel.SELECTED]: + bundles_to_update.append(row[UpdateListModel.BUNDLE_ID]) + return bundles_to_update + + +class UpdateList(gtk.TreeView): + + def __init__(self, model): + list_model = UpdateListModel(model) + gtk.TreeView.__init__(self, list_model) + + self.set_reorderable(False) + self.set_enable_search(False) + self.set_headers_visible(False) + + toggle_renderer = gtk.CellRendererToggle() + toggle_renderer.props.activatable = True + toggle_renderer.props.xpad = style.DEFAULT_PADDING + toggle_renderer.props.indicator_size = style.zoom(26) + toggle_renderer.connect('toggled', self.__toggled_cb) + + toggle_column = gtk.TreeViewColumn() + toggle_column.pack_start(toggle_renderer) + toggle_column.add_attribute(toggle_renderer, 'active', + UpdateListModel.SELECTED) + self.append_column(toggle_column) + + icon_renderer = CellRendererIcon(self) + icon_renderer.props.width = style.STANDARD_ICON_SIZE + icon_renderer.props.height = style.STANDARD_ICON_SIZE + icon_renderer.props.size = style.STANDARD_ICON_SIZE + icon_renderer.props.xpad = style.DEFAULT_PADDING + icon_renderer.props.ypad = style.DEFAULT_PADDING + icon_renderer.props.stroke_color = style.COLOR_TOOLBAR_GREY.get_svg() + icon_renderer.props.fill_color = style.COLOR_TRANSPARENT.get_svg() + + icon_column = gtk.TreeViewColumn() + icon_column.pack_start(icon_renderer) + icon_column.add_attribute(icon_renderer, 'file-name', + UpdateListModel.ICON_FILE_NAME) + self.append_column(icon_column) + + text_renderer = gtk.CellRendererText() + + description_column = gtk.TreeViewColumn() + description_column.pack_start(text_renderer) + description_column.add_attribute(text_renderer, 'markup', + UpdateListModel.DESCRIPTION) + self.append_column(description_column) + + def __toggled_cb(self, cell_renderer, path): + row = self.props.model[path] + row[UpdateListModel.SELECTED] = not row[UpdateListModel.SELECTED] + + def refresh(self): + pass + + +class UpdateListModel(gtk.ListStore): + + BUNDLE_ID = 0 + SELECTED = 1 + ICON_FILE_NAME = 2 + DESCRIPTION = 3 + SIZE = 4 + + def __init__(self, model): + gtk.ListStore.__init__(self, str, bool, str, str, int) + + for bundle_update in model.updates: + row = [None] * 5 + row[self.BUNDLE_ID] = bundle_update.bundle.get_bundle_id() + row[self.SELECTED] = True + row[self.ICON_FILE_NAME] = bundle_update.bundle.get_icon() + + details = _('From version %(current)d to %(new)s (Size: %(size)s)') + details = details % \ + {'current': bundle_update.bundle.get_activity_version(), + 'new': bundle_update.version, + 'size': _format_size(bundle_update.size)} + + row[self.DESCRIPTION] = '%s\n%s' % \ + (bundle_update.bundle.get_name(), details) + + row[self.SIZE] = bundle_update.size + + self.append(row) + + +def _format_size(size): + ''' + Convert a given size in bytes to a nicer better readable unit + ''' + if size == 0: + # TRANS: download size is 0 + return _('None') + elif size < 1024: + # TRANS: download size of very small updates + return _('1 KB') + elif size < 1024 * 1024: + # TRANS: download size of small updates, e.g. '250 KB' + return locale.format(_('%.0f KB'), size / 1024.0) + else: + # TRANS: download size of updates, e.g. '2.3 MB' + return locale.format(_('%.1f MB'), size / 1024.0 / 1024) -- cgit v0.9.1