[Commits] sugar-update-control branch master updated.

Daniel Drake dsd at laptop.org
Fri Dec 5 17:56:23 EST 2008


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "/home/cscott/public_git/sugar-update-control".

The branch, master has been updated
       via  bf43f5062152b6d3bc3b15f53dc2d29fddea3146 (commit)
      from  494c6e59a2d7558439ad30131dd3b72d0dff60d8 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

 Makefile                         |    2 +-
 bitfrost/update/actutils.py      |   21 ++++++++++++---------
 setup.py                         |    6 ++----
 src/__init__.py                  |   23 +++++++++++++++++++++++
 model/updater.py => src/model.py |    4 ++--
 view/updater.py => src/view.py   |   28 ++++++----------------------
 sugar-update-control.spec        |    2 +-
 7 files changed, 47 insertions(+), 39 deletions(-)
 delete mode 100644 bitfrost/update/__init__.py
 create mode 100644 src/__init__.py
 rename model/updater.py => src/model.py (99%)
 rename view/updater.py => src/view.py (97%)

- Log -----------------------------------------------------------------
commit bf43f5062152b6d3bc3b15f53dc2d29fddea3146
Author: Daniel Drake <dsd at laptop.org>
Date:   Fri Dec 5 18:18:23 2008 +0000

    Update for sugar-0.83
    
    Things moved around.

diff --git a/Makefile b/Makefile
index d14b73e..2e809cb 100644
--- a/Makefile
+++ b/Makefile
@@ -14,7 +14,7 @@ update-version:
 	grep -q $(VERSION_MAJOR).$(VERSION_MINOR) debian/changelog
 
 # update the translation template
-po/sugar-update-control.pot: model/updater.py view/updater.py
+po/sugar-update-control.pot: src/__init__.py src/view.py src/model.py
 	xgettext -o $@ \
 	  --copyright-holder="One Laptop per Child Association, Inc." \
 	  --package-name="$(PACKAGE)" \
diff --git a/bitfrost/update/__init__.py b/bitfrost/update/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/bitfrost/update/actutils.py b/bitfrost/update/actutils.py
index 7e0efcc..1146064 100644
--- a/bitfrost/update/actutils.py
+++ b/bitfrost/update/actutils.py
@@ -111,10 +111,13 @@ class BundleHelper(object):
             self.bundle = ContentBundle(filename)
             self.is_activity = False
 
-    def is_installed(self):
+    def is_installed(self, registry):
         """Return True if (an older version of) the bundle is already
         installed."""
-        return self.bundle.is_installed()
+        if self.is_activity:
+            return registry.is_installed(self.bundle)
+        else:
+            return self.bundle.is_installed()
 
     def get_name(self):
         """Get the name of the activity or content from the bundle."""
@@ -134,7 +137,7 @@ class BundleHelper(object):
 
     def _activity_install_or_upgrade(self, registry):
         """Install/upgrade an activity bundle, preserving favorites state."""
-        if self.is_installed():
+        if self.is_installed(registry):
             # upgraded activities are favorites if the pre-upgrade
             # activity was a favorite.
             ainfo = registry.get_activity(self.bundle.get_bundle_id())
@@ -145,12 +148,12 @@ class BundleHelper(object):
             favorite = True
             self.bundle.install()
         # set favorites flag for the new bundle-id/version.
-        registry.set_activity_favorite(self.bundle.get_bundle_id(),
-                                       self.bundle.get_activity_version(),
-                                       favorite)
-    def _library_install_or_upgrade(self):
+        registry.set_bundle_favorite(self.bundle.get_bundle_id(),
+                                     self.bundle.get_activity_version(),
+                                     favorite)
+    def _library_install_or_upgrade(self, registry):
         """Install/upgrade a content bundle."""
-        if self.is_installed():
+        if self.is_installed(registry):
             self.bundle.uninstall()
         self.bundle.install()
 
@@ -160,4 +163,4 @@ class BundleHelper(object):
         if self.is_activity:
             self._activity_install_or_upgrade(registry)
         else:
-            self._library_install_or_upgrade()
+            self._library_install_or_upgrade(registry)
diff --git a/model/updater.py b/model/updater.py
deleted file mode 100755
index 9851753..0000000
--- a/model/updater.py
+++ /dev/null
@@ -1,861 +0,0 @@
-#!/usr/bin/python2.5
-# Copyright (C) 2008 One Laptop Per Child Association, Inc.
-# Licensed under the terms of the GNU GPL v2 or later; see COPYING for details.
-# Written by C. Scott Ananian <cscott at laptop.org>
-"""Activity updater: backing model.
-
-This module implements the non-GUI portions of the activity updater,
-including in particular the master list of groups, activities, whether
-updates are needed, and the URL at which to find the updated activity.
-
-Because `UpdateList` inherits from `gtk.ListStore` so that it plays
-nicely with a GUI, this module requires `gtk`.  Those of you without
-DISPLAY set will have to put up with a GtkWarning that the display
-couldn't be opened.  Sorry.
-"""
-from __future__ import with_statement
-from __future__ import division
-
-# for testing
-_DEBUG_MAKE_ALL_OLD = False
-_DEBUG_CHECK_VERSIONS = False
-
-# default timeout for HTTP connections, in seconds
-HTTP_TIMEOUT=30
-
-import gtk
-import gobject
-
-import locale
-import os
-import os.path
-import socket
-import sys
-import traceback
-import zipfile
-from gettext import gettext as _
-from HTMLParser import HTMLParseError
-from urllib2 import HTTPError
-
-import bitfrost.update.actinfo as actinfo
-import bitfrost.update.actutils as actutils
-import bitfrost.update.microformat as microformat
-import bitfrost.util.urlrange as urlrange
-
-# weak dependency on inhibit_suspend from olpc-update package
-try:
-    from bitfrost.update import inhibit_suspend
-except ImportError:
-    # use a no-op substitude for the inhibit_suspend decorator.
-    inhibit_suspend = lambda f: f
-
-# lifted from gnome-update-manager/Common/utils
-def _humanize_size(bytes):
-    """
-    Convert a given size in bytes to a nicer better readable unit
-    """
-    if bytes == 0:
-        # TRANSLATORS: download size is 0
-        return _("None")
-    elif bytes < 1024:
-        # TRANSLATORS: download size of very small updates
-        return _("1 KB")
-    elif bytes < 1024 * 1024:
-        # TRANSLATORS: download size of small updates, e.g. "250 KB"
-        return locale.format(_("%.0f KB"), bytes/1024)
-    else:
-        # TRANSLATORS: download size of updates, e.g. "2.3 MB"
-        return locale.format(_("%.1f MB"), bytes / 1024 / 1024)
-
-def _svg2pixbuf(icon_data):
-    """Convert the given `icon_data` SVG string to a `gtk.gdk.Pixbuf`
-    with maximum size 55x55."""
-    import re, rsvg
-    # substitute black/white for icon color entities.
-    for entity, value in [('stroke_color','#808080'),
-                          ('fill_color','#eee')]:
-        xml = '<!ENTITY %s "%s">' % (entity, value) 
-        icon_data = re.sub('<!ENTITY %s .*>' % entity, xml, icon_data) 
-    h = rsvg.Handle(data=icon_data)
-    if h.get_property('width') > 55 or h.get_property('height') > 55:
-        # lame! scale it.
-        print "WARNING: oversize icon (%dx%d), scaling." % \
-              (h.get_property('width'), h.get_property('height'))
-        del h
-        pbl = gtk.gdk.PixbufLoader()
-        pbl.set_size(55, 55)
-        pbl.write(icon_data)
-        pbl.close()
-        return pbl.get_pixbuf()
-    return h.get_pixbuf()
-
-_parse_cache = {}
-def _check_for_updates(url, activity_id):
-    """Downloads the given URL, parses it (caching the result), and returns
-    a list of (version, url) pairs present for the given `activity_id`.
-    Returns `None` if there was a problem downloading the URL.
-    Returns a zero-length list if the given URL is unparsable or does not
-    contain information for the desired activity_id."""
-    global _parse_cache
-    if url not in _parse_cache:
-        try:
-            __, __, _parse_cache[url] = \
-                microformat.parse_url(url, timeout=HTTP_TIMEOUT)
-            if _DEBUG_CHECK_VERSIONS:
-                # for kicks and giggles, verify these version #s!
-                for n_activity_id, versions in _parse_cache[url].items():
-                    for ver, url2 in versions:
-                        actual_id, actual_ver = \
-                                   actutils.id_and_version_from_url(url2)
-                        if actual_id != n_activity_id:
-                            print "ACTIVITY ID SHOULD BE", n_activity_id, \
-                                  "BUT ACTUALLY IS", actual_id, ("(%s)"%url2)
-                        if actual_ver != ver:
-                            print "VERSION SHOULD BE", ver, \
-                                  "BUT ACTUALLY IS", actual_ver, ("(%s)"%url2)
-        except HTMLParseError:
-            _parse_cache[url] = {} # parse error
-        except (IOError, socket.error):
-            _parse_cache[url] = None # network error
-    activity_map = _parse_cache[url]
-    if activity_map is None: return None # error attempting to check.
-    if activity_id not in activity_map: return [] # no versions found.
-    return activity_map[activity_id]
-
-def _retrieve_update_version(actbun,
-                             network_success_cb=(lambda url: None),
-                             network_failure_cb=(lambda url: None)):
-    """Return a tuple of current version, new version, url for new
-    version, size of new version, given a current activity bundle.
-    All the information about the new version is `None` if no newer
-    update can be found.  The optional `network_success_cb` is invoked
-    with a single `url` parameter whenever a network operation
-    succeeds, and the optional `network_failure_cb` is invoked with a
-    single `url` parameter if all fallback network operations for a
-    given activity bundle fail."""
-    update_url = actbun.get_update_url()
-    oldv = 0 if _DEBUG_MAKE_ALL_OLD else actbun.get_activity_version()
-    # here we implement the search for a release-specific version
-    # XXX: would be nice to use retrieve_first_variant here
-    vlist = None
-    for uu in actinfo.url_variants(update_url):
-        vlist = _check_for_updates(uu, actbun.get_bundle_id())
-        if vlist is not None:
-            network_success_cb(uu)
-            if len(vlist) > 0:
-                break # found some info
-    if vlist is None:
-        # all url variants for the given actbun have failed.
-        network_failure_cb(update_url)
-        return oldv, None, None, 0
-    newv, newu = microformat.only_best_update(vlist + [(oldv, None)])
-    if newu is None or newv==oldv: return oldv, None, None, 0 # no updates
-    # find size of new version.
-    try:
-        size = urlrange.urlopen(newu, timeout=HTTP_TIMEOUT).length()
-        return oldv, newv, newu, size
-    except (HTTPError, IOError, socket.error):
-        # hmm, i guess that url isn't valid after all.  bail.
-        return oldv, None, None, 0 # there are no *actual* updates.
-
-##########################################################################
-# Fundamental data object: the real work gets done here.
-
-_column_name_map = dict(globals())
-ACTIVITY_ID, ACTIVITY_BUNDLE, ACTIVITY_ICON, \
-             UPDATE_URL, UPDATE_VERSION, UPDATE_SIZE, \
-             UPDATE_EXISTS, UPDATE_SELECTED, \
-             DESCRIPTION_BIG, DESCRIPTION_SMALL, IS_HEADER, GROUP_NUM = \
-             xrange(12)
-"""List of columns in the `UpdateList`."""
-_column_name_map = dict((k,v) for k,v in globals().items()
-                        if k not in _column_name_map and k!='_column_name_map')
-"""Mapping from column names to indices."""
-
-class UpdateList(gtk.ListStore):
-    """Model which provides backing storage for the activity list treeview."""
-    __gproperties__ = {
-        'is_valid': (gobject.TYPE_BOOLEAN, 'is valid',
-                     'true iff the UpdateList has been properly refreshed',
-                     False, gobject.PARAM_READABLE),
-        'saw_network_failure': (gobject.TYPE_BOOLEAN, 'saw network failure',
-                                'true iff at least one network IO error '+
-                                'occurred when the UpdateList was last '+
-                                'refreshed',
-                                False, gobject.PARAM_READABLE),
-        'saw_network_success': (gobject.TYPE_BOOLEAN, 'saw network success',
-                                'true iff at least one network operation '+
-                                'completed successfully when the UpdateList '+
-                                'was last refreshed',
-                                False, gobject.PARAM_READABLE),
-    }
-
-    def __init__(self, skip_icons=False):
-        gtk.ListStore.__init__(self,
-                               # column types
-                               str, object, gtk.gdk.Pixbuf,
-                               str, long, long,
-                               bool, bool, str, str, bool, int)
-        self._skip_icons = skip_icons
-        self._is_valid = False
-        self._network_failures = []
-        self._saw_network_success = False
-
-    def __del__(self):
-        """Free up any memory held by the cache in urlrange."""
-        urlrange.urlcleanup()
-
-    def _append(self, at=None, **kwargs):
-        """Utility function to make it easier to add rows and get paths."""
-        global _column_name_map
-        # defaults for each column
-        row = [None, None, None,
-               None, 0, 0,
-               True, True, None, None, False, 0]
-        # set entries in the row based on kwargs
-        for k,v in kwargs.items():
-            row[_column_name_map[k]] = v
-        if at is not None:
-            it = self.insert(at, row)
-        else:
-            it = self.append(row)
-        return self.get_path(it)
-
-    def toggle_select(self, path):
-        """Toggle whether the given update will be installed."""
-        row = self[path]
-        row[UPDATE_SELECTED] = not row[UPDATE_SELECTED]
-
-    # don't touch the UI in refresh, it needs to be thread-safe.
-    # (model will be disconnected from the view before invoking refresh
-    #  in another thread)
-    def refresh(self, progress_callback=lambda n, extra: None,
-                clear_cache=True):
-        """Perform network operations to find available updates.
-
-        The `progress_callback` is invoked with numbers between 0 and 1
-        or `None` as the network queries complete.  The last callback will be
-        `progress_callback(1)`.  Passing `None` to `progress_callback`
-        requests "pulse mode" from the progress bar.
-        """
-        global _parse_cache
-        if clear_cache:
-            _parse_cache = {} # clear microformat parse cache
-            urlrange.urlcleanup() # clean url cache
-        self._cancel = False
-        self._invalidate()
-        # don't notify for the following; we'll notify at the end when we
-        # know what the new values ought to be.
-        self._saw_network_success = False
-        self._network_failures = []
-        # bookkeeping
-        progress_callback(None, None)
-        self.clear()
-        # find all activities already installed.
-        progress_callback(None, _('Looking for local activities and content...')) # pulse
-        activities = actinfo.get_activities() + actinfo.get_libraries()
-        # enumerate all group urls
-        progress_callback(None, _('Loading groups...'))
-        group_urls = actinfo.get_activity_group_urls()
-        # now we've got enough information to allow us to compute a
-        # reasonable completion percentage.
-        steps_total = [ len(activities) + len(group_urls) + 3 ]
-        steps_count = [ 0 ] # box this to allow update from mkprog.
-        def mkprog(msg=None):
-            """Helper function to do progress update."""
-            steps_count[0] += 1
-            progress_callback(steps_count[0]/steps_total[0], msg)
-        mkprog(_('Loading groups...'))
-        # okay, first load up any group definitions; these take precedence
-        # if present.
-        groups = []
-        def group_parser(f, url):
-            name, desc, groups = microformat.parse_html(f.read(), url)
-            if len(groups) > 0 or (name is not None and desc is not None):
-                return name, desc, groups
-            return None # hmm, not a successful parse.
-        for gurl in group_urls:
-            mkprog(_('Fetching %s...') % gurl)
-            if self._cancel: break # bail!
-            gdata = actinfo.retrieve_first_variant(gurl, group_parser,
-                                                   timeout=HTTP_TIMEOUT)
-            if gdata is not None:
-                gname, gdesc, gactmap = gdata
-                groups.append((gname, gdesc, gurl, gactmap))
-                self._saw_network_success = True
-            else:
-                # headers even for failed groups.
-                groups.append((None, gurl, gurl, {}))
-                self._network_failures.append(gurl)
-        # now start filling up the liststore, keeping a map from activity id
-        # to liststore path
-        row_map = {}
-        group_num = 0
-        for gname, gdesc, gurl, gactmap in groups:
-            # add group header.
-            if gname is None: gname = _('Activity Group')
-            self._append(IS_HEADER=True,
-                         UPDATE_URL=gurl,
-                         GROUP_NUM=group_num,
-                         DESCRIPTION_BIG=gname,
-                         DESCRIPTION_SMALL=gdesc)
-            # now add entries for all activities in the group, whether
-            # currently installed or not.
-            for act_id, version_list in sorted(gactmap.items()):
-                version, url = microformat.only_best_update(version_list)
-                if act_id not in row_map:
-                    # temporary description in case user cancels the refresh
-                    tmp_desc = act_id.replace('sugar-is-lame',
-                                              'lame-is-the-new-cool')
-                    row_map[act_id] = self._append(ACTIVITY_ID=act_id,
-                                                   GROUP_NUM=group_num,
-                                                   UPDATE_EXISTS=True,
-                                                   UPDATE_URL=url,
-                                                   UPDATE_VERSION=version,
-                                                   DESCRIPTION_BIG=tmp_desc)
-                    steps_total[0] += 1 # new activity?
-                else:
-                    # allow for a later version in a different group
-                    row = self[row_map[act_id]]
-                    if version > row[UPDATE_VERSION]:
-                        row[UPDATE_URL] = url
-                # XXX: deal with pinned updates.
-            group_num += 1
-        # add in information from local activities.
-        self._append(IS_HEADER=True, GROUP_NUM=group_num,
-                     DESCRIPTION_BIG=_('Local activities'))
-        for act in activities:
-            act_id = act.get_bundle_id()
-            if act_id not in row_map:
-                row_map[act_id] = self._append(ACTIVITY_ID=act_id,
-                                               GROUP_NUM=group_num,
-                                               UPDATE_EXISTS=False)
-            else:
-                steps_total[0] -= 1 # correct double-counting.
-            # update icon, and bundle
-            row = self[row_map[act_id]]
-            row[ACTIVITY_BUNDLE] = act
-            row[DESCRIPTION_BIG] = act.get_name()
-            if not self._skip_icons:
-                try:
-                    row[ACTIVITY_ICON] = _svg2pixbuf(act.get_icon_data())
-                except IOError:
-                    # dlo trac #8149: don't kill updater if existing icon
-                    # bundle is malformed.
-                    pass
-        group_num += 1
-        # now do extra network traffic to look for actual updates.
-        def refresh_existing(row):
-            """Look for updates to an existing activity."""
-            act = row[ACTIVITY_BUNDLE]
-            def net_good(url_): self._saw_network_success = True
-            def net_bad(url): self._network_failures.append(url)
-            oldver, newver, newurl, size = \
-                    _retrieve_update_version(act, net_good, net_bad)
-            # merge w/ info from the activity group
-            # (activity group entries have UPDATE_EXISTS=True)
-            if row[UPDATE_EXISTS] and \
-               ((act.get_activity_version() if newver is None else newver) \
-                < row[UPDATE_VERSION]):
-                newver, newurl = row[UPDATE_VERSION], row[UPDATE_URL]
-                size = urlrange.urlopen(row[UPDATE_URL], timeout=HTTP_TIMEOUT)\
-                       .length()
-            row[UPDATE_EXISTS] = (newver is not None)
-            row[UPDATE_URL] = newurl
-            row[UPDATE_SIZE] = size
-            if newver is None:
-                description = _('At version %s') % oldver
-            else:
-                description = \
-                    _('From version %(old)d to %(new)d (Size: %(size)s)') % \
-                    { 'old':oldver, 'new':newver, 'size':_humanize_size(size) }
-                row[UPDATE_SELECTED] = True
-            row[DESCRIPTION_SMALL] = description
-        def refresh_new(row):
-            """Look for updates to a new activity in the group."""
-            uo = urlrange.urlopen(row[UPDATE_URL], timeout=HTTP_TIMEOUT)
-            row[UPDATE_SIZE] = uo.length()
-            zf = zipfile.ZipFile(uo)
-            # grab data from activity.info file
-            activity_base = actutils.bundle_base_from_zipfile(zf)
-            try:
-                zf.getinfo('%s/activity/activity.info' % activity_base)
-                is_activity = True
-            except KeyError:
-                is_activity = False
-            if is_activity:
-                cp = actutils.activity_info_from_zipfile(zf)
-                SECTION = 'Activity'
-            else:
-                cp = actutils.library_info_from_zipfile(zf)
-                SECTION = 'Library'
-            act_id = None
-            for fieldname in ('bundle_id', 'service_name', 'global_name'):
-                if cp.has_option(SECTION, fieldname):
-                    act_id = cp.get(SECTION, fieldname)
-                    break
-            if not act_id:
-                raise RuntimeError("bundle_id not found for %s" %
-                                   row[UPDATE_URL])
-            name = act_id
-            if cp.has_option(SECTION, 'name'):
-                name = cp.get(SECTION, 'name')
-            # okay, try to get an appropriately translated name.
-            if is_activity:
-                lcp = actutils.locale_activity_info_from_zipfile(zf)
-                if lcp is not None:
-                    name = lcp.get(SECTION, 'name')
-            else:
-                s = actutils.locale_section_for_content_bundle(cp)
-                if s is not None and cp.has_option(s, 'name'):
-                    name = cp.get(s, 'name')
-            version = None
-            for fieldname in ('activity_version', 'library_version'):
-                if cp.has_option(SECTION, fieldname):
-                    version = int(cp.get(SECTION, fieldname))
-                    break
-            if version is None:
-                raise RuntimeError("can't find version for %s" %
-                                   row[UPDATE_URL])
-            row[DESCRIPTION_BIG] = name
-            row[DESCRIPTION_SMALL] = \
-                _('New version %(version)s (Size: %(size)s)') % \
-                {'version':version, 'size':_humanize_size(row[UPDATE_SIZE])}
-            # okay, let's try to update the icon!
-            if not self._skip_icons:
-                if is_activity:
-                    # XXX should failures here kill the upgrade?
-                    icon_file = cp.get(SECTION, 'icon')
-                    icon_filename = '%s/activity/%s.svg'%(activity_base, icon_file)
-                    row[ACTIVITY_ICON] = _svg2pixbuf(zf.read(icon_filename))
-                else:
-                    row[ACTIVITY_ICON] = _svg2pixbuf(actinfo.DEFAULT_LIBRARY_ICON)
-        # go through activities and do network traffic
-        for row in self:
-            if self._cancel: break # bail!
-            if row[IS_HEADER]: continue # skip
-            mkprog(_('Checking %s...') % row[DESCRIPTION_BIG])
-            try:
-                if row[ACTIVITY_BUNDLE] is None:
-                    refresh_new(row)
-                    self._saw_network_success = True
-                else:
-                    refresh_existing(row)
-            except:
-                row[UPDATE_EXISTS] = False # something wrong, can't update
-                if row[UPDATE_URL] is not None:
-                    self._network_failures.append(row[UPDATE_URL])
-                # log the problem for later debugging.
-                print "Failure updating", row[DESCRIPTION_BIG], \
-                      row[DESCRIPTION_SMALL], row[UPDATE_URL]
-                traceback.print_exc()
-        mkprog('Sorting...') # all done
-        # hide headers if all children are hidden
-        sawone, last_header = False, None
-        for row in self:
-            if row[IS_HEADER]:
-                if last_header is not None:
-                    last_header[UPDATE_EXISTS] = sawone
-                sawone, last_header = False, row
-            elif row[UPDATE_EXISTS]:
-                sawone = True
-        if last_header is not None:
-            last_header[UPDATE_EXISTS] = sawone
-        # finally, sort all rows.
-        self._sort()
-        mkprog() # all done
-        # XXX: check for base os update, and add an entry here?
-        self._is_valid = True
-        self.notify('is-valid')
-        self.notify('saw-network-failure')
-        self.notify('saw-network-success')
-
-    def _sort(self):
-        """Sort rows by group number, then case-insensitively by description."""
-        l = lambda s: s.decode('utf-8','ignore').lower() \
-            if s is not None else None
-        def sort_value(row_num):
-            row = self[row_num]
-            return (row[GROUP_NUM], 0 if row[IS_HEADER] else 1,
-                    l(row[DESCRIPTION_BIG]), l(row[DESCRIPTION_SMALL]))
-        row_nums = range(len(self))
-        row_nums.sort(key=sort_value)
-        self.reorder(row_nums)
-
-    def cancel_refresh(self):
-        """Asynchronously cancel a `refresh` operation."""
-        self._cancel = True
-
-    def cancel_download(self):
-        """Asynchronously cancel a `download_selected_updates` operation."""
-        self._cancel = True
-
-    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_EXISTS] else 0)
-
-    def updates_selected(self):
-        """Return the number of updates selected."""
-        return self._sum_rows(lambda r: 1 if
-                              r[UPDATE_EXISTS] 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_EXISTS] and r[UPDATE_SELECTED] else 0)
-    def unselect_all(self):
-        """Unselect all available updates."""
-        for row in self:
-            if not row[IS_HEADER]:
-                row[UPDATE_SELECTED] = False
-
-    def select_all(self):
-        """Select all available updates."""
-        for row in self:
-            if not row[IS_HEADER]:
-                row[UPDATE_SELECTED] = True
-
-    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
-
-    def _invalidate(self):
-        """Set the 'is-valid' property to False and notify listeners."""
-        if self._is_valid:
-            # don't notify if already invalid
-            self._is_valid = False
-            self.notify('is-valid')
-
-    def saw_network_failure(self):
-        """Returns true iff there was at least one network failure during the
-        last refresh operation."""
-        return len(self._network_failures) > 0
-
-    def saw_network_success(self):
-        """Returns true iff there was at least one successful network
-        transaction during the last refresh operation."""
-        return self._saw_network_success
-
-    def do_get_property(self, prop):
-        """Standard interface to access the 'is-valid' property."""
-        if prop.name == 'is-valid': return self.is_valid()
-        if prop.name == 'saw-network-failure': return self.saw_network_failure()
-        if prop.name == 'saw-network-success': return self.saw_network_success()
-        raise AttributeError, 'unknown property %s' % prop.name
-
-    def add_group(self, group_url):
-        """Add the group referenced by the given `group_url` to the end of the
-        groups list.  This invalidates the UpdateList; you'll have to call
-        the refresh method to revalidate."""
-        # sanity check
-        if not group_url.strip():
-            return False # nothing to it
-        # double check that group not already present.
-        for row in self:
-            if row[IS_HEADER] and row[UPDATE_URL] == group_url:
-                return False # already there.
-        # find the group number we should use for this new group.
-        new_gnum = None
-        for row in self:
-            if row[IS_HEADER] and row[UPDATE_URL] is None:
-                new_gnum = row[GROUP_NUM]
-            if new_gnum is not None and row[GROUP_NUM] >= new_gnum:
-                # renumber to make room.
-                row[GROUP_NUM] += 1
-        # add the new group!
-        self._append(at=0,
-                     IS_HEADER=True,
-                     GROUP_NUM = new_gnum,
-                     DESCRIPTION_BIG=_('New group'),
-                     DESCRIPTION_SMALL=group_url,
-                     UPDATE_URL=group_url)
-        self._sort()
-        self._write_groups()
-        # invalid: need to refresh to update activities.
-        self._invalidate()
-        return True
-
-    def del_group(self, group_url):
-        """Delete all entries associated with the group identified by the
-        given `group_url`.  This invalidates the UpdateList; you'll have to
-        call the refresh method to revalidate."""
-        group_num = None
-        # YUCK!  Removing a group of rows in a tree model is *ugly*.
-        row = self.get_iter_first()
-        while row is not None:
-            remove = False
-            if group_num is None:
-                if self[row][IS_HEADER] and self[row][UPDATE_URL] == group_url:
-                    group_num = self[row][GROUP_NUM]
-                    remove = True
-            elif self[row][GROUP_NUM] == group_num:
-                remove = True
-            else:
-                self[row][GROUP_NUM] -= 1
-            if not remove:
-                row = self.iter_next(row)
-            elif not self.remove(row):
-                row = None # removed last row.
-        if group_num is None:
-            return False # not found.
-        self._sort()
-        self._write_groups()
-        self._invalidate()
-        return True
-
-    def move_group(self, group_url, desired_num=-1):
-        """Move the group with the given UPDATE_URL to the specified location.
-        The location is specified *before* the existing row for the group is
-        deleted, so the final location may vary.  If desired_num is less than
-        0, then the row is moved to the very end."""
-        initial_num, max_num = None, None
-        # find the row, first.
-        for row in self:
-            if row[IS_HEADER]:
-                max_num = row[GROUP_NUM]
-                if row[UPDATE_URL] == group_url:
-                    initial_num = row[GROUP_NUM]
-        if initial_num is None or max_num is None:
-            return False # can't find it, or there are no rows.
-        # now make a mapping to define the desired reordering.
-        if desired_num < 0: desired_num = max_num
-        if desired_num == initial_num: return False # no change
-        if desired_num > initial_num: desired_num -= 1
-        def make_mapping():
-            new_nums = list(xrange(max_num))
-            del new_nums[initial_num]
-            new_nums.insert(desired_num, initial_num)
-            new_nums.append(max_num)
-            return new_nums
-        m = dict(zip(make_mapping(), xrange(max_num+1)))
-        # reset the group numbers appropriately
-        for row in self:
-            row[GROUP_NUM] = m[row[GROUP_NUM]]
-        # sort to actually reorder
-        self._sort()
-        # save these changes
-        self._write_groups()
-        # not valid because activities may need group reassignment.
-        self._invalidate()
-        return True
-
-    def _write_groups(self):
-        """Write a new user groups file based on the current model."""
-        with open(actinfo.USER_GROUPS_FILE, 'w') as f:
-            for row in self:
-                if row[IS_HEADER] and row[UPDATE_URL] is not None:
-                    print >>f, row[UPDATE_URL]
-
-    def download_selected_updates(self, progress_cb=(lambda n, row: None),
-                                  dir=None):
-        """Return a generator giving (row, local filename) pairs for
-        each selected update.  Caller is responsible for unlinking
-        these files when they are through.  The `progress_cb` gets a
-        floating point number in [0, 1] indicating the amount of the
-        download which is complete, and a reference to the row in this
-        `UpdateList` describing the current download.  The files will
-        be created in the directory specified by `dir`, if it is
-        given, otherwise they will be created in the user's activity
-        directory.  If the remote HTTP server does not provide information
-        about the length of some of the downloads, the first parameter
-        to the `progress_cb` will be `None` during those downloads.
-        """
-        if dir is None: dir=actinfo.USER_ACTIVITY_DIR
-        if not os.path.isdir(dir): os.makedirs(dir)
-        self._cancel = False
-        sizes = [ 0, self.updates_size() ]
-        for row in self:
-            if self._cancel: return # bail.
-            if row[IS_HEADER]: continue
-            if not (row[UPDATE_EXISTS] and row[UPDATE_SELECTED]): continue
-            def report(cursize, totalsize_):
-                if totalsize_ is None:
-                    progress_cb(None, row)
-                else:
-                    progress_cb((sizes[0]+cursize) / sizes[1], row)
-            try:
-                yield row, urlrange.urlretrieve(row[UPDATE_URL], dir=dir,
-                                                reporthook=report,
-                                                cancelhook=lambda:self._cancel,
-                                                store_cache=False,
-                                                timeout=HTTP_TIMEOUT)
-            except urlrange.CancelException:
-                return # bail.
-            except (HTMLParseError, IOError, socket.error):
-                yield row, None # network error
-            # XXX: if rows eventually have 'indeterminate size', then we
-            #      should update this progress computation, probably based
-            #      on the size of the file we just yielded above!
-            sizes[0] += row[UPDATE_SIZE]
-            progress_cb(sizes[0] / sizes[1], row)
-
-
-#########################################################################
-# Control panel keys for command-line and GUI use.
-
-# note that the *presence* of a key is defined by the presence of a
-# get_<key> method, the *documentation* for a key is given by the
-# doc string of the set_<key> method, but actually looking at
-# values from the command-line is performed using print_<key>.
-
-def _print_status(n, extra):
-    """Returns a helper function which print a human-readable
-    completion percentage."""
-    sys.stdout.write(' '*79)
-    sys.stdout.write('\r')
-    if extra is None: extra=''
-    if n is None:
-        sys.stdout.write('%s\r' % extra[:77].ljust(77))
-    else:
-        sys.stdout.write('%s %5.1f%%\r' % (extra[:70].ljust(70), n*100))
-    sys.stdout.flush()
-
-# 'available_updates' key -----------------------------
-def get_available_updates():
-    # only here to get this key to show up for '-l'
-    raise ValueError('This method not used by the GUI')
-
- at inhibit_suspend
-def print_available_updates():
-    """Print the set of available updates in nice human-readable fashion."""
-    ul = UpdateList(skip_icons=True)
-    ul.refresh(_print_status)
-    print
-    def opt(x):
-        if x is None or x == '': return ''
-        return ': %s' % x
-    for row in ul:
-        if not row[UPDATE_EXISTS]: continue # skip
-        if row[IS_HEADER]:
-            print row[DESCRIPTION_BIG] + opt(row[DESCRIPTION_SMALL])
-        else:
-            print '*', row[DESCRIPTION_BIG] + opt(row[DESCRIPTION_SMALL])
-    print
-    print _('%(number)d updates available.  Size: %(size)s') % \
-          { 'number': ul.updates_available(),
-            'size': _humanize_size(ul.updates_size()) }
-
-def set_available_updates():
-    # NOTE slightly odd way to document this control panel key
-    """Retrieve the list of available activity updates."""
-    raise ValueError(_("Setting the list of updates is not permitted."))
-
-# 'install_updates' key -----------------------------
-def get_install_update():
-    # this func is needed to make the 'install_update' key show up for '-l'
-    raise ValueError(_("Only the 'set' operation for this key is defined."))
-
- at inhibit_suspend
-def set_install_update(which):
-    """
-    Install any available updates for the activity identified by the
-    given (localized) name or bundle id, or all available updates if
-    'all' is given.
-    """
-    import os
-    ul = UpdateList(skip_icons=True)
-    ul.refresh(_print_status)
-    def too_many():
-        raise ValueError(_('More than one match found for the given activity name or id.'))
-    def no_updates():
-        raise ValueError(_('The given activity is already up-to-date.'))
-    if which != 'all':
-        found = False
-        for row in ul:
-            row[UPDATE_SELECTED] = False
-        for row in ul:
-            if row[IS_HEADER]: continue
-            if which==row[ACTIVITY_ID]:
-                if found: too_many()
-                found = True
-                if not row[UPDATE_EXISTS]: no_updates()
-                row[UPDATE_SELECTED] = True
-        if not found:
-            found_but_uptodate=False
-            for row in ul:
-                if row[IS_HEADER]: continue
-                if which in row[DESCRIPTION_BIG]:
-                    if not row[UPDATE_EXISTS]: # could be false match
-                        found_but_uptodate=True
-                        continue
-                    if found: too_many()
-                    found = True
-                    row[UPDATE_SELECTED] = True
-        if not found:
-            if found_but_uptodate: no_updates()
-            raise ValueError(_('No activity found with the given name or id.'))
-        assert ul.updates_selected() == 1
-    # okay, now we've selected only our desired updates.  Download and
-    # install them!
-    # we need to set up a glib event loop in order to connect to the
-    # activity registry (sigh) in ActivityBundle.upgrade() below.
-    from dbus.mainloop.glib import DBusGMainLoop
-    DBusGMainLoop(set_as_default=True)
-    # we'll fetch the main loop, but we don't actually have to run it.
-    loop = gobject.MainLoop()
-    from sugar.activity.registry import get_registry
-    registry = get_registry()
-    def reporthook(n, row):
-        _print_status(n, _('Downloading %s...') % row[DESCRIPTION_BIG])
-    for row, f in ul.download_selected_updates(reporthook):
-        if f is None: continue # cancelled or network error
-        try:
-            _print_status(None, _('Examining %s...') % row[DESCRIPTION_BIG])
-            b = actutils.BundleHelper(f)
-            if b.is_installed():
-                _print_status(None, _('Upgrading %s...') % row[DESCRIPTION_BIG])
-            else:
-                _print_status(None, _('Installing %s...')% row[DESCRIPTION_BIG])
-            b.install_or_upgrade(registry)
-        except:
-            print
-            print _('Error installing %s.') % row[DESCRIPTION_BIG]
-            import traceback
-            traceback.print_exc() # complain!  but go on.
-        if os.path.exists(f):
-            os.unlink(f)
-        else:
-            print "Failed trying to clean up", f
-
-# 'update_groups' key -----------------------------
-def get_update_groups():
-    # only here to get this key to show up for '-l'
-    raise ValueError('This method not used by the GUI')
-
-def print_update_groups():
-    group_urls = actinfo.get_activity_group_urls()
-    for gurl in group_urls:
-        # we could do a network operation to get a prettier name for
-        # the group here, but let's keep the cmd-line interface simple.
-        print gurl
-
-def set_update_groups(groups):
-    """
-    Set URLs for update groups used by the software update control panel.
-    Activities referenced by the given URLs will be suggested for installation
-    on this laptop, if they are not already present.  This allows deployments
-    to update the set of activities installed, as well as the actual activities.
-
-    The parameter should be a white-space separated list of group URLs.
-    """
-    with open(actinfo.USER_GROUPS_FILE, 'w') as f:
-        for gurl in groups.split():
-            print >>f, gurl
-
-#########################################################################
-# Self-test code.
-def _main():
-    """Self-test."""
-    print_available_updates()
-    #set_install_update('all')
-
-if __name__ == '__main__': _main ()
diff --git a/setup.py b/setup.py
index ebdace0..f686c00 100644
--- a/setup.py
+++ b/setup.py
@@ -7,10 +7,8 @@ setup(name='sugar-update-control',
       author_email='cscott at laptop.org',
       url='http://dev.laptop.org/git/users/cscott/sugar-update-control',
       packages=['bitfrost','bitfrost.update','bitfrost.util'],
-      data_files=[('/usr/share/sugar/shell/controlpanel/model',
-                   ['model/updater.py']),
-                  ('/usr/share/sugar/shell/controlpanel/view',
-                   ['view/updater.py']),
+      data_files=[('/usr/share/sugar/extensions/cpsection/updater',
+                   ['src/__init__.py', 'src/model.py', 'src/view.py']),
                   ('/usr/share/sugar/data/icons',
                    ['module-updater.svg']),
                   ],
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..a509dfe
--- /dev/null
+++ b/src/__init__.py
@@ -0,0 +1,23 @@
+# Copyright (C) 2008, OLPC
+#
+# 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
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# 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
+
+from gettext import gettext as _
+
+CLASS = 'ActivityUpdater'
+ICON = 'module-updater'
+TITLE = _('Software update')
+KEYWORDS = ['software', 'activity', 'update']
+
diff --git a/src/model.py b/src/model.py
new file mode 100755
index 0000000..1461c62
--- /dev/null
+++ b/src/model.py
@@ -0,0 +1,861 @@
+#!/usr/bin/python2.5
+# Copyright (C) 2008 One Laptop Per Child Association, Inc.
+# Licensed under the terms of the GNU GPL v2 or later; see COPYING for details.
+# Written by C. Scott Ananian <cscott at laptop.org>
+"""Activity updater: backing model.
+
+This module implements the non-GUI portions of the activity updater,
+including in particular the master list of groups, activities, whether
+updates are needed, and the URL at which to find the updated activity.
+
+Because `UpdateList` inherits from `gtk.ListStore` so that it plays
+nicely with a GUI, this module requires `gtk`.  Those of you without
+DISPLAY set will have to put up with a GtkWarning that the display
+couldn't be opened.  Sorry.
+"""
+from __future__ import with_statement
+from __future__ import division
+
+# for testing
+_DEBUG_MAKE_ALL_OLD = False
+_DEBUG_CHECK_VERSIONS = False
+
+# default timeout for HTTP connections, in seconds
+HTTP_TIMEOUT=30
+
+import gtk
+import gobject
+
+import locale
+import os
+import os.path
+import socket
+import sys
+import traceback
+import zipfile
+from gettext import gettext as _
+from HTMLParser import HTMLParseError
+from urllib2 import HTTPError
+
+import bitfrost.update.actinfo as actinfo
+import bitfrost.update.actutils as actutils
+import bitfrost.update.microformat as microformat
+import bitfrost.util.urlrange as urlrange
+
+# weak dependency on inhibit_suspend from olpc-update package
+try:
+    from bitfrost.update import inhibit_suspend
+except ImportError:
+    # use a no-op substitude for the inhibit_suspend decorator.
+    inhibit_suspend = lambda f: f
+
+# lifted from gnome-update-manager/Common/utils
+def _humanize_size(bytes):
+    """
+    Convert a given size in bytes to a nicer better readable unit
+    """
+    if bytes == 0:
+        # TRANSLATORS: download size is 0
+        return _("None")
+    elif bytes < 1024:
+        # TRANSLATORS: download size of very small updates
+        return _("1 KB")
+    elif bytes < 1024 * 1024:
+        # TRANSLATORS: download size of small updates, e.g. "250 KB"
+        return locale.format(_("%.0f KB"), bytes/1024)
+    else:
+        # TRANSLATORS: download size of updates, e.g. "2.3 MB"
+        return locale.format(_("%.1f MB"), bytes / 1024 / 1024)
+
+def _svg2pixbuf(icon_data):
+    """Convert the given `icon_data` SVG string to a `gtk.gdk.Pixbuf`
+    with maximum size 55x55."""
+    import re, rsvg
+    # substitute black/white for icon color entities.
+    for entity, value in [('stroke_color','#808080'),
+                          ('fill_color','#eee')]:
+        xml = '<!ENTITY %s "%s">' % (entity, value) 
+        icon_data = re.sub('<!ENTITY %s .*>' % entity, xml, icon_data) 
+    h = rsvg.Handle(data=icon_data)
+    if h.get_property('width') > 55 or h.get_property('height') > 55:
+        # lame! scale it.
+        print "WARNING: oversize icon (%dx%d), scaling." % \
+              (h.get_property('width'), h.get_property('height'))
+        del h
+        pbl = gtk.gdk.PixbufLoader()
+        pbl.set_size(55, 55)
+        pbl.write(icon_data)
+        pbl.close()
+        return pbl.get_pixbuf()
+    return h.get_pixbuf()
+
+_parse_cache = {}
+def _check_for_updates(url, activity_id):
+    """Downloads the given URL, parses it (caching the result), and returns
+    a list of (version, url) pairs present for the given `activity_id`.
+    Returns `None` if there was a problem downloading the URL.
+    Returns a zero-length list if the given URL is unparsable or does not
+    contain information for the desired activity_id."""
+    global _parse_cache
+    if url not in _parse_cache:
+        try:
+            __, __, _parse_cache[url] = \
+                microformat.parse_url(url, timeout=HTTP_TIMEOUT)
+            if _DEBUG_CHECK_VERSIONS:
+                # for kicks and giggles, verify these version #s!
+                for n_activity_id, versions in _parse_cache[url].items():
+                    for ver, url2 in versions:
+                        actual_id, actual_ver = \
+                                   actutils.id_and_version_from_url(url2)
+                        if actual_id != n_activity_id:
+                            print "ACTIVITY ID SHOULD BE", n_activity_id, \
+                                  "BUT ACTUALLY IS", actual_id, ("(%s)"%url2)
+                        if actual_ver != ver:
+                            print "VERSION SHOULD BE", ver, \
+                                  "BUT ACTUALLY IS", actual_ver, ("(%s)"%url2)
+        except HTMLParseError:
+            _parse_cache[url] = {} # parse error
+        except (IOError, socket.error):
+            _parse_cache[url] = None # network error
+    activity_map = _parse_cache[url]
+    if activity_map is None: return None # error attempting to check.
+    if activity_id not in activity_map: return [] # no versions found.
+    return activity_map[activity_id]
+
+def _retrieve_update_version(actbun,
+                             network_success_cb=(lambda url: None),
+                             network_failure_cb=(lambda url: None)):
+    """Return a tuple of current version, new version, url for new
+    version, size of new version, given a current activity bundle.
+    All the information about the new version is `None` if no newer
+    update can be found.  The optional `network_success_cb` is invoked
+    with a single `url` parameter whenever a network operation
+    succeeds, and the optional `network_failure_cb` is invoked with a
+    single `url` parameter if all fallback network operations for a
+    given activity bundle fail."""
+    update_url = actbun.get_update_url()
+    oldv = 0 if _DEBUG_MAKE_ALL_OLD else actbun.get_activity_version()
+    # here we implement the search for a release-specific version
+    # XXX: would be nice to use retrieve_first_variant here
+    vlist = None
+    for uu in actinfo.url_variants(update_url):
+        vlist = _check_for_updates(uu, actbun.get_bundle_id())
+        if vlist is not None:
+            network_success_cb(uu)
+            if len(vlist) > 0:
+                break # found some info
+    if vlist is None:
+        # all url variants for the given actbun have failed.
+        network_failure_cb(update_url)
+        return oldv, None, None, 0
+    newv, newu = microformat.only_best_update(vlist + [(oldv, None)])
+    if newu is None or newv==oldv: return oldv, None, None, 0 # no updates
+    # find size of new version.
+    try:
+        size = urlrange.urlopen(newu, timeout=HTTP_TIMEOUT).length()
+        return oldv, newv, newu, size
+    except (HTTPError, IOError, socket.error):
+        # hmm, i guess that url isn't valid after all.  bail.
+        return oldv, None, None, 0 # there are no *actual* updates.
+
+##########################################################################
+# Fundamental data object: the real work gets done here.
+
+_column_name_map = dict(globals())
+ACTIVITY_ID, ACTIVITY_BUNDLE, ACTIVITY_ICON, \
+             UPDATE_URL, UPDATE_VERSION, UPDATE_SIZE, \
+             UPDATE_EXISTS, UPDATE_SELECTED, \
+             DESCRIPTION_BIG, DESCRIPTION_SMALL, IS_HEADER, GROUP_NUM = \
+             xrange(12)
+"""List of columns in the `UpdateList`."""
+_column_name_map = dict((k,v) for k,v in globals().items()
+                        if k not in _column_name_map and k!='_column_name_map')
+"""Mapping from column names to indices."""
+
+class UpdateList(gtk.ListStore):
+    """Model which provides backing storage for the activity list treeview."""
+    __gproperties__ = {
+        'is_valid': (gobject.TYPE_BOOLEAN, 'is valid',
+                     'true iff the UpdateList has been properly refreshed',
+                     False, gobject.PARAM_READABLE),
+        'saw_network_failure': (gobject.TYPE_BOOLEAN, 'saw network failure',
+                                'true iff at least one network IO error '+
+                                'occurred when the UpdateList was last '+
+                                'refreshed',
+                                False, gobject.PARAM_READABLE),
+        'saw_network_success': (gobject.TYPE_BOOLEAN, 'saw network success',
+                                'true iff at least one network operation '+
+                                'completed successfully when the UpdateList '+
+                                'was last refreshed',
+                                False, gobject.PARAM_READABLE),
+    }
+
+    def __init__(self, skip_icons=False):
+        gtk.ListStore.__init__(self,
+                               # column types
+                               str, object, gtk.gdk.Pixbuf,
+                               str, long, long,
+                               bool, bool, str, str, bool, int)
+        self._skip_icons = skip_icons
+        self._is_valid = False
+        self._network_failures = []
+        self._saw_network_success = False
+
+    def __del__(self):
+        """Free up any memory held by the cache in urlrange."""
+        urlrange.urlcleanup()
+
+    def _append(self, at=None, **kwargs):
+        """Utility function to make it easier to add rows and get paths."""
+        global _column_name_map
+        # defaults for each column
+        row = [None, None, None,
+               None, 0, 0,
+               True, True, None, None, False, 0]
+        # set entries in the row based on kwargs
+        for k,v in kwargs.items():
+            row[_column_name_map[k]] = v
+        if at is not None:
+            it = self.insert(at, row)
+        else:
+            it = self.append(row)
+        return self.get_path(it)
+
+    def toggle_select(self, path):
+        """Toggle whether the given update will be installed."""
+        row = self[path]
+        row[UPDATE_SELECTED] = not row[UPDATE_SELECTED]
+
+    # don't touch the UI in refresh, it needs to be thread-safe.
+    # (model will be disconnected from the view before invoking refresh
+    #  in another thread)
+    def refresh(self, progress_callback=lambda n, extra: None,
+                clear_cache=True):
+        """Perform network operations to find available updates.
+
+        The `progress_callback` is invoked with numbers between 0 and 1
+        or `None` as the network queries complete.  The last callback will be
+        `progress_callback(1)`.  Passing `None` to `progress_callback`
+        requests "pulse mode" from the progress bar.
+        """
+        global _parse_cache
+        if clear_cache:
+            _parse_cache = {} # clear microformat parse cache
+            urlrange.urlcleanup() # clean url cache
+        self._cancel = False
+        self._invalidate()
+        # don't notify for the following; we'll notify at the end when we
+        # know what the new values ought to be.
+        self._saw_network_success = False
+        self._network_failures = []
+        # bookkeeping
+        progress_callback(None, None)
+        self.clear()
+        # find all activities already installed.
+        progress_callback(None, _('Looking for local activities and content...')) # pulse
+        activities = actinfo.get_activities() + actinfo.get_libraries()
+        # enumerate all group urls
+        progress_callback(None, _('Loading groups...'))
+        group_urls = actinfo.get_activity_group_urls()
+        # now we've got enough information to allow us to compute a
+        # reasonable completion percentage.
+        steps_total = [ len(activities) + len(group_urls) + 3 ]
+        steps_count = [ 0 ] # box this to allow update from mkprog.
+        def mkprog(msg=None):
+            """Helper function to do progress update."""
+            steps_count[0] += 1
+            progress_callback(steps_count[0]/steps_total[0], msg)
+        mkprog(_('Loading groups...'))
+        # okay, first load up any group definitions; these take precedence
+        # if present.
+        groups = []
+        def group_parser(f, url):
+            name, desc, groups = microformat.parse_html(f.read(), url)
+            if len(groups) > 0 or (name is not None and desc is not None):
+                return name, desc, groups
+            return None # hmm, not a successful parse.
+        for gurl in group_urls:
+            mkprog(_('Fetching %s...') % gurl)
+            if self._cancel: break # bail!
+            gdata = actinfo.retrieve_first_variant(gurl, group_parser,
+                                                   timeout=HTTP_TIMEOUT)
+            if gdata is not None:
+                gname, gdesc, gactmap = gdata
+                groups.append((gname, gdesc, gurl, gactmap))
+                self._saw_network_success = True
+            else:
+                # headers even for failed groups.
+                groups.append((None, gurl, gurl, {}))
+                self._network_failures.append(gurl)
+        # now start filling up the liststore, keeping a map from activity id
+        # to liststore path
+        row_map = {}
+        group_num = 0
+        for gname, gdesc, gurl, gactmap in groups:
+            # add group header.
+            if gname is None: gname = _('Activity Group')
+            self._append(IS_HEADER=True,
+                         UPDATE_URL=gurl,
+                         GROUP_NUM=group_num,
+                         DESCRIPTION_BIG=gname,
+                         DESCRIPTION_SMALL=gdesc)
+            # now add entries for all activities in the group, whether
+            # currently installed or not.
+            for act_id, version_list in sorted(gactmap.items()):
+                version, url = microformat.only_best_update(version_list)
+                if act_id not in row_map:
+                    # temporary description in case user cancels the refresh
+                    tmp_desc = act_id.replace('sugar-is-lame',
+                                              'lame-is-the-new-cool')
+                    row_map[act_id] = self._append(ACTIVITY_ID=act_id,
+                                                   GROUP_NUM=group_num,
+                                                   UPDATE_EXISTS=True,
+                                                   UPDATE_URL=url,
+                                                   UPDATE_VERSION=version,
+                                                   DESCRIPTION_BIG=tmp_desc)
+                    steps_total[0] += 1 # new activity?
+                else:
+                    # allow for a later version in a different group
+                    row = self[row_map[act_id]]
+                    if version > row[UPDATE_VERSION]:
+                        row[UPDATE_URL] = url
+                # XXX: deal with pinned updates.
+            group_num += 1
+        # add in information from local activities.
+        self._append(IS_HEADER=True, GROUP_NUM=group_num,
+                     DESCRIPTION_BIG=_('Local activities'))
+        for act in activities:
+            act_id = act.get_bundle_id()
+            if act_id not in row_map:
+                row_map[act_id] = self._append(ACTIVITY_ID=act_id,
+                                               GROUP_NUM=group_num,
+                                               UPDATE_EXISTS=False)
+            else:
+                steps_total[0] -= 1 # correct double-counting.
+            # update icon, and bundle
+            row = self[row_map[act_id]]
+            row[ACTIVITY_BUNDLE] = act
+            row[DESCRIPTION_BIG] = act.get_name()
+            if not self._skip_icons:
+                try:
+                    row[ACTIVITY_ICON] = _svg2pixbuf(act.get_icon_data())
+                except IOError:
+                    # dlo trac #8149: don't kill updater if existing icon
+                    # bundle is malformed.
+                    pass
+        group_num += 1
+        # now do extra network traffic to look for actual updates.
+        def refresh_existing(row):
+            """Look for updates to an existing activity."""
+            act = row[ACTIVITY_BUNDLE]
+            def net_good(url_): self._saw_network_success = True
+            def net_bad(url): self._network_failures.append(url)
+            oldver, newver, newurl, size = \
+                    _retrieve_update_version(act, net_good, net_bad)
+            # merge w/ info from the activity group
+            # (activity group entries have UPDATE_EXISTS=True)
+            if row[UPDATE_EXISTS] and \
+               ((act.get_activity_version() if newver is None else newver) \
+                < row[UPDATE_VERSION]):
+                newver, newurl = row[UPDATE_VERSION], row[UPDATE_URL]
+                size = urlrange.urlopen(row[UPDATE_URL], timeout=HTTP_TIMEOUT)\
+                       .length()
+            row[UPDATE_EXISTS] = (newver is not None)
+            row[UPDATE_URL] = newurl
+            row[UPDATE_SIZE] = size
+            if newver is None:
+                description = _('At version %s') % oldver
+            else:
+                description = \
+                    _('From version %(old)d to %(new)d (Size: %(size)s)') % \
+                    { 'old':oldver, 'new':newver, 'size':_humanize_size(size) }
+                row[UPDATE_SELECTED] = True
+            row[DESCRIPTION_SMALL] = description
+        def refresh_new(row):
+            """Look for updates to a new activity in the group."""
+            uo = urlrange.urlopen(row[UPDATE_URL], timeout=HTTP_TIMEOUT)
+            row[UPDATE_SIZE] = uo.length()
+            zf = zipfile.ZipFile(uo)
+            # grab data from activity.info file
+            activity_base = actutils.bundle_base_from_zipfile(zf)
+            try:
+                zf.getinfo('%s/activity/activity.info' % activity_base)
+                is_activity = True
+            except KeyError:
+                is_activity = False
+            if is_activity:
+                cp = actutils.activity_info_from_zipfile(zf)
+                SECTION = 'Activity'
+            else:
+                cp = actutils.library_info_from_zipfile(zf)
+                SECTION = 'Library'
+            act_id = None
+            for fieldname in ('bundle_id', 'service_name', 'global_name'):
+                if cp.has_option(SECTION, fieldname):
+                    act_id = cp.get(SECTION, fieldname)
+                    break
+            if not act_id:
+                raise RuntimeError("bundle_id not found for %s" %
+                                   row[UPDATE_URL])
+            name = act_id
+            if cp.has_option(SECTION, 'name'):
+                name = cp.get(SECTION, 'name')
+            # okay, try to get an appropriately translated name.
+            if is_activity:
+                lcp = actutils.locale_activity_info_from_zipfile(zf)
+                if lcp is not None:
+                    name = lcp.get(SECTION, 'name')
+            else:
+                s = actutils.locale_section_for_content_bundle(cp)
+                if s is not None and cp.has_option(s, 'name'):
+                    name = cp.get(s, 'name')
+            version = None
+            for fieldname in ('activity_version', 'library_version'):
+                if cp.has_option(SECTION, fieldname):
+                    version = int(cp.get(SECTION, fieldname))
+                    break
+            if version is None:
+                raise RuntimeError("can't find version for %s" %
+                                   row[UPDATE_URL])
+            row[DESCRIPTION_BIG] = name
+            row[DESCRIPTION_SMALL] = \
+                _('New version %(version)s (Size: %(size)s)') % \
+                {'version':version, 'size':_humanize_size(row[UPDATE_SIZE])}
+            # okay, let's try to update the icon!
+            if not self._skip_icons:
+                if is_activity:
+                    # XXX should failures here kill the upgrade?
+                    icon_file = cp.get(SECTION, 'icon')
+                    icon_filename = '%s/activity/%s.svg'%(activity_base, icon_file)
+                    row[ACTIVITY_ICON] = _svg2pixbuf(zf.read(icon_filename))
+                else:
+                    row[ACTIVITY_ICON] = _svg2pixbuf(actinfo.DEFAULT_LIBRARY_ICON)
+        # go through activities and do network traffic
+        for row in self:
+            if self._cancel: break # bail!
+            if row[IS_HEADER]: continue # skip
+            mkprog(_('Checking %s...') % row[DESCRIPTION_BIG])
+            try:
+                if row[ACTIVITY_BUNDLE] is None:
+                    refresh_new(row)
+                    self._saw_network_success = True
+                else:
+                    refresh_existing(row)
+            except:
+                row[UPDATE_EXISTS] = False # something wrong, can't update
+                if row[UPDATE_URL] is not None:
+                    self._network_failures.append(row[UPDATE_URL])
+                # log the problem for later debugging.
+                print "Failure updating", row[DESCRIPTION_BIG], \
+                      row[DESCRIPTION_SMALL], row[UPDATE_URL]
+                traceback.print_exc()
+        mkprog('Sorting...') # all done
+        # hide headers if all children are hidden
+        sawone, last_header = False, None
+        for row in self:
+            if row[IS_HEADER]:
+                if last_header is not None:
+                    last_header[UPDATE_EXISTS] = sawone
+                sawone, last_header = False, row
+            elif row[UPDATE_EXISTS]:
+                sawone = True
+        if last_header is not None:
+            last_header[UPDATE_EXISTS] = sawone
+        # finally, sort all rows.
+        self._sort()
+        mkprog() # all done
+        # XXX: check for base os update, and add an entry here?
+        self._is_valid = True
+        self.notify('is-valid')
+        self.notify('saw-network-failure')
+        self.notify('saw-network-success')
+
+    def _sort(self):
+        """Sort rows by group number, then case-insensitively by description."""
+        l = lambda s: s.decode('utf-8','ignore').lower() \
+            if s is not None else None
+        def sort_value(row_num):
+            row = self[row_num]
+            return (row[GROUP_NUM], 0 if row[IS_HEADER] else 1,
+                    l(row[DESCRIPTION_BIG]), l(row[DESCRIPTION_SMALL]))
+        row_nums = range(len(self))
+        row_nums.sort(key=sort_value)
+        self.reorder(row_nums)
+
+    def cancel_refresh(self):
+        """Asynchronously cancel a `refresh` operation."""
+        self._cancel = True
+
+    def cancel_download(self):
+        """Asynchronously cancel a `download_selected_updates` operation."""
+        self._cancel = True
+
+    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_EXISTS] else 0)
+
+    def updates_selected(self):
+        """Return the number of updates selected."""
+        return self._sum_rows(lambda r: 1 if
+                              r[UPDATE_EXISTS] 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_EXISTS] and r[UPDATE_SELECTED] else 0)
+    def unselect_all(self):
+        """Unselect all available updates."""
+        for row in self:
+            if not row[IS_HEADER]:
+                row[UPDATE_SELECTED] = False
+
+    def select_all(self):
+        """Select all available updates."""
+        for row in self:
+            if not row[IS_HEADER]:
+                row[UPDATE_SELECTED] = True
+
+    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
+
+    def _invalidate(self):
+        """Set the 'is-valid' property to False and notify listeners."""
+        if self._is_valid:
+            # don't notify if already invalid
+            self._is_valid = False
+            self.notify('is-valid')
+
+    def saw_network_failure(self):
+        """Returns true iff there was at least one network failure during the
+        last refresh operation."""
+        return len(self._network_failures) > 0
+
+    def saw_network_success(self):
+        """Returns true iff there was at least one successful network
+        transaction during the last refresh operation."""
+        return self._saw_network_success
+
+    def do_get_property(self, prop):
+        """Standard interface to access the 'is-valid' property."""
+        if prop.name == 'is-valid': return self.is_valid()
+        if prop.name == 'saw-network-failure': return self.saw_network_failure()
+        if prop.name == 'saw-network-success': return self.saw_network_success()
+        raise AttributeError, 'unknown property %s' % prop.name
+
+    def add_group(self, group_url):
+        """Add the group referenced by the given `group_url` to the end of the
+        groups list.  This invalidates the UpdateList; you'll have to call
+        the refresh method to revalidate."""
+        # sanity check
+        if not group_url.strip():
+            return False # nothing to it
+        # double check that group not already present.
+        for row in self:
+            if row[IS_HEADER] and row[UPDATE_URL] == group_url:
+                return False # already there.
+        # find the group number we should use for this new group.
+        new_gnum = None
+        for row in self:
+            if row[IS_HEADER] and row[UPDATE_URL] is None:
+                new_gnum = row[GROUP_NUM]
+            if new_gnum is not None and row[GROUP_NUM] >= new_gnum:
+                # renumber to make room.
+                row[GROUP_NUM] += 1
+        # add the new group!
+        self._append(at=0,
+                     IS_HEADER=True,
+                     GROUP_NUM = new_gnum,
+                     DESCRIPTION_BIG=_('New group'),
+                     DESCRIPTION_SMALL=group_url,
+                     UPDATE_URL=group_url)
+        self._sort()
+        self._write_groups()
+        # invalid: need to refresh to update activities.
+        self._invalidate()
+        return True
+
+    def del_group(self, group_url):
+        """Delete all entries associated with the group identified by the
+        given `group_url`.  This invalidates the UpdateList; you'll have to
+        call the refresh method to revalidate."""
+        group_num = None
+        # YUCK!  Removing a group of rows in a tree model is *ugly*.
+        row = self.get_iter_first()
+        while row is not None:
+            remove = False
+            if group_num is None:
+                if self[row][IS_HEADER] and self[row][UPDATE_URL] == group_url:
+                    group_num = self[row][GROUP_NUM]
+                    remove = True
+            elif self[row][GROUP_NUM] == group_num:
+                remove = True
+            else:
+                self[row][GROUP_NUM] -= 1
+            if not remove:
+                row = self.iter_next(row)
+            elif not self.remove(row):
+                row = None # removed last row.
+        if group_num is None:
+            return False # not found.
+        self._sort()
+        self._write_groups()
+        self._invalidate()
+        return True
+
+    def move_group(self, group_url, desired_num=-1):
+        """Move the group with the given UPDATE_URL to the specified location.
+        The location is specified *before* the existing row for the group is
+        deleted, so the final location may vary.  If desired_num is less than
+        0, then the row is moved to the very end."""
+        initial_num, max_num = None, None
+        # find the row, first.
+        for row in self:
+            if row[IS_HEADER]:
+                max_num = row[GROUP_NUM]
+                if row[UPDATE_URL] == group_url:
+                    initial_num = row[GROUP_NUM]
+        if initial_num is None or max_num is None:
+            return False # can't find it, or there are no rows.
+        # now make a mapping to define the desired reordering.
+        if desired_num < 0: desired_num = max_num
+        if desired_num == initial_num: return False # no change
+        if desired_num > initial_num: desired_num -= 1
+        def make_mapping():
+            new_nums = list(xrange(max_num))
+            del new_nums[initial_num]
+            new_nums.insert(desired_num, initial_num)
+            new_nums.append(max_num)
+            return new_nums
+        m = dict(zip(make_mapping(), xrange(max_num+1)))
+        # reset the group numbers appropriately
+        for row in self:
+            row[GROUP_NUM] = m[row[GROUP_NUM]]
+        # sort to actually reorder
+        self._sort()
+        # save these changes
+        self._write_groups()
+        # not valid because activities may need group reassignment.
+        self._invalidate()
+        return True
+
+    def _write_groups(self):
+        """Write a new user groups file based on the current model."""
+        with open(actinfo.USER_GROUPS_FILE, 'w') as f:
+            for row in self:
+                if row[IS_HEADER] and row[UPDATE_URL] is not None:
+                    print >>f, row[UPDATE_URL]
+
+    def download_selected_updates(self, progress_cb=(lambda n, row: None),
+                                  dir=None):
+        """Return a generator giving (row, local filename) pairs for
+        each selected update.  Caller is responsible for unlinking
+        these files when they are through.  The `progress_cb` gets a
+        floating point number in [0, 1] indicating the amount of the
+        download which is complete, and a reference to the row in this
+        `UpdateList` describing the current download.  The files will
+        be created in the directory specified by `dir`, if it is
+        given, otherwise they will be created in the user's activity
+        directory.  If the remote HTTP server does not provide information
+        about the length of some of the downloads, the first parameter
+        to the `progress_cb` will be `None` during those downloads.
+        """
+        if dir is None: dir=actinfo.USER_ACTIVITY_DIR
+        if not os.path.isdir(dir): os.makedirs(dir)
+        self._cancel = False
+        sizes = [ 0, self.updates_size() ]
+        for row in self:
+            if self._cancel: return # bail.
+            if row[IS_HEADER]: continue
+            if not (row[UPDATE_EXISTS] and row[UPDATE_SELECTED]): continue
+            def report(cursize, totalsize_):
+                if totalsize_ is None:
+                    progress_cb(None, row)
+                else:
+                    progress_cb((sizes[0]+cursize) / sizes[1], row)
+            try:
+                yield row, urlrange.urlretrieve(row[UPDATE_URL], dir=dir,
+                                                reporthook=report,
+                                                cancelhook=lambda:self._cancel,
+                                                store_cache=False,
+                                                timeout=HTTP_TIMEOUT)
+            except urlrange.CancelException:
+                return # bail.
+            except (HTMLParseError, IOError, socket.error):
+                yield row, None # network error
+            # XXX: if rows eventually have 'indeterminate size', then we
+            #      should update this progress computation, probably based
+            #      on the size of the file we just yielded above!
+            sizes[0] += row[UPDATE_SIZE]
+            progress_cb(sizes[0] / sizes[1], row)
+
+
+#########################################################################
+# Control panel keys for command-line and GUI use.
+
+# note that the *presence* of a key is defined by the presence of a
+# get_<key> method, the *documentation* for a key is given by the
+# doc string of the set_<key> method, but actually looking at
+# values from the command-line is performed using print_<key>.
+
+def _print_status(n, extra):
+    """Returns a helper function which print a human-readable
+    completion percentage."""
+    sys.stdout.write(' '*79)
+    sys.stdout.write('\r')
+    if extra is None: extra=''
+    if n is None:
+        sys.stdout.write('%s\r' % extra[:77].ljust(77))
+    else:
+        sys.stdout.write('%s %5.1f%%\r' % (extra[:70].ljust(70), n*100))
+    sys.stdout.flush()
+
+# 'available_updates' key -----------------------------
+def get_available_updates():
+    # only here to get this key to show up for '-l'
+    raise ValueError('This method not used by the GUI')
+
+ at inhibit_suspend
+def print_available_updates():
+    """Print the set of available updates in nice human-readable fashion."""
+    ul = UpdateList(skip_icons=True)
+    ul.refresh(_print_status)
+    print
+    def opt(x):
+        if x is None or x == '': return ''
+        return ': %s' % x
+    for row in ul:
+        if not row[UPDATE_EXISTS]: continue # skip
+        if row[IS_HEADER]:
+            print row[DESCRIPTION_BIG] + opt(row[DESCRIPTION_SMALL])
+        else:
+            print '*', row[DESCRIPTION_BIG] + opt(row[DESCRIPTION_SMALL])
+    print
+    print _('%(number)d updates available.  Size: %(size)s') % \
+          { 'number': ul.updates_available(),
+            'size': _humanize_size(ul.updates_size()) }
+
+def set_available_updates():
+    # NOTE slightly odd way to document this control panel key
+    """Retrieve the list of available activity updates."""
+    raise ValueError(_("Setting the list of updates is not permitted."))
+
+# 'install_updates' key -----------------------------
+def get_install_update():
+    # this func is needed to make the 'install_update' key show up for '-l'
+    raise ValueError(_("Only the 'set' operation for this key is defined."))
+
+ at inhibit_suspend
+def set_install_update(which):
+    """
+    Install any available updates for the activity identified by the
+    given (localized) name or bundle id, or all available updates if
+    'all' is given.
+    """
+    import os
+    ul = UpdateList(skip_icons=True)
+    ul.refresh(_print_status)
+    def too_many():
+        raise ValueError(_('More than one match found for the given activity name or id.'))
+    def no_updates():
+        raise ValueError(_('The given activity is already up-to-date.'))
+    if which != 'all':
+        found = False
+        for row in ul:
+            row[UPDATE_SELECTED] = False
+        for row in ul:
+            if row[IS_HEADER]: continue
+            if which==row[ACTIVITY_ID]:
+                if found: too_many()
+                found = True
+                if not row[UPDATE_EXISTS]: no_updates()
+                row[UPDATE_SELECTED] = True
+        if not found:
+            found_but_uptodate=False
+            for row in ul:
+                if row[IS_HEADER]: continue
+                if which in row[DESCRIPTION_BIG]:
+                    if not row[UPDATE_EXISTS]: # could be false match
+                        found_but_uptodate=True
+                        continue
+                    if found: too_many()
+                    found = True
+                    row[UPDATE_SELECTED] = True
+        if not found:
+            if found_but_uptodate: no_updates()
+            raise ValueError(_('No activity found with the given name or id.'))
+        assert ul.updates_selected() == 1
+    # okay, now we've selected only our desired updates.  Download and
+    # install them!
+    # we need to set up a glib event loop in order to connect to the
+    # activity registry (sigh) in ActivityBundle.upgrade() below.
+    from dbus.mainloop.glib import DBusGMainLoop
+    DBusGMainLoop(set_as_default=True)
+    # we'll fetch the main loop, but we don't actually have to run it.
+    loop = gobject.MainLoop()
+    from jarabe.model.bundleregistry import get_registry
+    registry = get_registry()
+    def reporthook(n, row):
+        _print_status(n, _('Downloading %s...') % row[DESCRIPTION_BIG])
+    for row, f in ul.download_selected_updates(reporthook):
+        if f is None: continue # cancelled or network error
+        try:
+            _print_status(None, _('Examining %s...') % row[DESCRIPTION_BIG])
+            b = actutils.BundleHelper(f)
+            if b.is_installed(registry):
+                _print_status(None, _('Upgrading %s...') % row[DESCRIPTION_BIG])
+            else:
+                _print_status(None, _('Installing %s...')% row[DESCRIPTION_BIG])
+            b.install_or_upgrade(registry)
+        except:
+            print
+            print _('Error installing %s.') % row[DESCRIPTION_BIG]
+            import traceback
+            traceback.print_exc() # complain!  but go on.
+        if os.path.exists(f):
+            os.unlink(f)
+        else:
+            print "Failed trying to clean up", f
+
+# 'update_groups' key -----------------------------
+def get_update_groups():
+    # only here to get this key to show up for '-l'
+    raise ValueError('This method not used by the GUI')
+
+def print_update_groups():
+    group_urls = actinfo.get_activity_group_urls()
+    for gurl in group_urls:
+        # we could do a network operation to get a prettier name for
+        # the group here, but let's keep the cmd-line interface simple.
+        print gurl
+
+def set_update_groups(groups):
+    """
+    Set URLs for update groups used by the software update control panel.
+    Activities referenced by the given URLs will be suggested for installation
+    on this laptop, if they are not already present.  This allows deployments
+    to update the set of activities installed, as well as the actual activities.
+
+    The parameter should be a white-space separated list of group URLs.
+    """
+    with open(actinfo.USER_GROUPS_FILE, 'w') as f:
+        for gurl in groups.split():
+            print >>f, gurl
+
+#########################################################################
+# Self-test code.
+def _main():
+    """Self-test."""
+    print_available_updates()
+    #set_install_update('all')
+
+if __name__ == '__main__': _main ()
diff --git a/src/view.py b/src/view.py
new file mode 100755
index 0000000..6cb65ce
--- /dev/null
+++ b/src/view.py
@@ -0,0 +1,688 @@
+#!/usr/bin/python2.5
+# Copyright (C) 2008 One Laptop Per Child Association, Inc.
+# Licensed under the terms of the GNU GPL v2 or later; see COPYING for details.
+# Written by C. Scott Ananian <cscott at laptop.org>
+"""Activity updater.
+
+Checks for updates to activities and installs them."""
+from __future__ import with_statement
+from __future__ import division
+import pygtk
+pygtk.require('2.0')
+import gtk, gobject
+gtk.gdk.threads_init()
+
+import gettext
+import os
+import re
+from threading import Thread
+from gettext import gettext as _
+
+import bitfrost.update.actutils as actutils
+from sugar.graphics import style
+
+from jarabe.controlpanel.sectionview import SectionView
+from jarabe.controlpanel.inlinealert import InlineAlert
+
+import model
+from model import _humanize_size, _svg2pixbuf, inhibit_suspend
+
+# COMPATIBILITY HACK: work around trac #8532 by forcibly removing the
+# SIGCHLD handler (and the necessity for one)
+import sugar.activity.activityfactory
+if hasattr(sugar.activity.activityfactory, '_sigchild_handler'):
+    from ctypes import CDLL, Structure, c_voidp, c_int, c_ulong, pointer
+    import signal
+    libc = CDLL("libc.so.6")
+    SA_NOCLDWAIT = 2 # according to /usr/include/bits/sigaction.h
+    class SIGACTION(Structure):
+        _fields_ = [('sa_handler', c_voidp),
+                    ('sa_sigaction', c_voidp),
+                    ('sa_mask', c_ulong), # sigset_t
+                    ('sa_flags', c_int),
+                    ('sa_restorer', c_voidp)]
+    desired = SIGACTION(None, # SIG_DFL, according to /usr/include/asm-generic/signal.h
+                        None,
+                        0,
+                        SA_NOCLDWAIT,
+                        None)
+    libc.sigaction(signal.SIGCHLD, pointer(desired), None)
+# END COMPATIBILITY HACK
+
+
+# configuration constants needed for control panel framework
+CLASS = 'ActivityUpdater'
+ICON = 'module-updater'
+TITLE = _('Software update')
+
+# for debugging
+_DEBUG_VIEW_ALL=False
+"""View even activities with no pending updates."""
+
+def _escape_markup(s):
+    """Escape special characters in `s` so that it is safe to use in
+    Pango markup.  Equivalent to `g_markup_escape_text` in glib."""
+    entities = { '&': '&amp;',
+                 '<': '&lt;',
+                 '>': '&gt;',
+                 '"': '&quot;',
+                 "'": '&apos;', }
+    if s is None: return None
+    return re.sub("[&<>\"']", lambda m: entities[m.group(0)],
+                  s.decode('utf-8','ignore')).encode('utf-8')
+_e = _escape_markup
+"""Useful abbreviation."""
+
+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
+
+### Pieces of the activity updater view; factored to make the UI structure
+### more apparent in `ActivityUpdater.__init__`.
+
+class ActivityListView(gtk.ScrolledWindow):
+    """List view at the top, showing activities, versions, and sizes."""
+    def __init__(self, activity_updater, activity_pane):
+        gtk.ScrolledWindow.__init__(self)
+        self.activity_updater = activity_updater
+        self.activity_pane = activity_pane
+
+        # create the TreeView using a filtered treestore
+        self.ftreestore = self.activity_updater.activity_list.filter_new()
+        if not _DEBUG_VIEW_ALL:
+            self.ftreestore.set_visible_column(model.UPDATE_EXISTS)
+        self.treeview = gtk.TreeView(self.ftreestore)
+   
+        # create some cell renderers.
+        crbool = gtk.CellRendererToggle()
+        crbool.set_property('activatable', True)
+        crbool.set_property('xpad', style.DEFAULT_PADDING)
+        # indicator size should be themeable, but is not.
+        # if we're in sugar, use the larger indicator size.
+        # otherwise, use the hard-coded GTK default.
+        if self.activity_updater._in_sugar:
+            crbool.set_property('indicator_size', style.zoom(26))
+        def toggled_cb(crbool, path, self):
+            path = self.ftreestore.convert_path_to_child_path(path)
+            self.activity_updater.activity_list.toggle_select(path)
+            self.activity_pane._refresh_update_size()
+        crbool.connect('toggled', toggled_cb, self)
+
+        cricon = gtk.CellRendererPixbuf()
+        cricon.set_property('width', style.STANDARD_ICON_SIZE)
+        cricon.set_property('height', style.STANDARD_ICON_SIZE)
+
+        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')
+        self.column_install = gtk.TreeViewColumn('Install', crbool)
+        self.column_install.add_attribute(crbool, 'active', model.UPDATE_SELECTED)
+        self.column_install.set_cell_data_func(crbool, hide_func)
+        self.column = gtk.TreeViewColumn('Name')
+        self.column.pack_start(cricon, expand=False)
+        self.column.pack_start(crtext, expand=True)
+        self.column.add_attribute(cricon, 'pixbuf', model.ACTIVITY_ICON)
+        self.column.set_resizable(True)
+        self.column.set_cell_data_func(cricon, hide_func)
+        def markup_func(cell_layout, renderer, m, it):
+            s = '<b>%s</b>' % _e(m.get_value(it, model.DESCRIPTION_BIG))
+            if m.get_value(it, model.IS_HEADER):
+                s = '<big>%s</big>' % s
+            desc = m.get_value(it, model.DESCRIPTION_SMALL)
+            if desc is not None and desc != '':
+                s += '\n<small>%s</small>' % _e(desc)
+            renderer.set_property('markup', s)
+            insens_func(cell_layout, renderer, m, it)
+        self.column.set_cell_data_func(crtext, markup_func)
+   
+        # add tvcolumn to treeview
+        self.treeview.append_column(self.column_install)
+        self.treeview.append_column(self.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.show_context_menu)
+
+        def is_valid_cb(activity_list, __):
+            self.treeview.set_sensitive(activity_list.is_valid())
+        self.activity_updater.activity_list.connect('notify::is-valid',
+                                                    is_valid_cb)
+        is_valid_cb(self.activity_updater.activity_list, None)
+
+        # now put this all inside ourself (a gtk.ScrolledWindow)
+        self.add_with_viewport(self.treeview)
+        self.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
+
+    def unlink_model(self):
+        self.treeview.set_model(None)
+    def relink_model(self):
+        self.ftreestore.refilter()
+        self.treeview.set_model(self.ftreestore)
+
+    def show_context_menu(self, widget, event):
+        """
+        Show a context menu if a right click was performed on an update entry
+        """
+        if event.type == gtk.gdk.BUTTON_PRESS and event.button == 3:
+            activity_list = self.activity_updater.activity_list
+            def cb(__, f):
+                f()
+                self.activity_pane._refresh_update_size()
+            menu = gtk.Menu()
+            item_select_none = gtk.MenuItem(_("_Uncheck All"))
+            item_select_none.connect("activate", cb,
+                                     activity_list.unselect_all)
+            menu.add(item_select_none)
+            if activity_list.updates_available() == 0:
+                item_select_none.set_property("sensitive", False)
+            item_select_all = gtk.MenuItem(_("_Check All"))
+            item_select_all.connect("activate", cb,
+                                    activity_list.select_all)
+            menu.add(item_select_all)
+            menu.popup(None, None, None, 0, event.time)
+            menu.show_all()
+            return True
+        return False
+
+class GroupListView(gtk.VBox):
+    """List view in expander at bottom, showing groups and urls."""
+    def __init__(self, activity_updater):
+        gtk.VBox.__init__(self)
+        self.set_spacing(style.DEFAULT_PADDING)
+        self.activity_updater = activity_updater
+
+        self.fgroupstore = self.activity_updater.activity_list.filter_new()
+        def group_visibility(m, it, user_data=None):
+            # only group header rows, but not the 'local activities' group.
+            return m.get_value(it, model.IS_HEADER) and \
+                   m.get_value(it, model.UPDATE_URL) is not None
+        self.fgroupstore.set_visible_func(group_visibility)
+        self.groupview = gtk.TreeView(self.fgroupstore)
+        self.groupview.set_headers_visible(False)
+        self.groupview.set_rules_hint(True)
+        self.groupview.set_enable_search(False)
+        self.groupview.set_reorderable(False) # we'll write own DnD funcs below
+        TARGETS = gtk.target_list_add_text_targets(info=0)
+        TARGETS = gtk.target_list_add_uri_targets(list=TARGETS, info=1)
+        self.groupview.enable_model_drag_source( gtk.gdk.BUTTON1_MASK,
+                                                 TARGETS,
+                                                 gtk.gdk.ACTION_DEFAULT
+                                                 |gtk.gdk.ACTION_COPY
+                                                 |gtk.gdk.ACTION_MOVE)
+        self.groupview.enable_model_drag_dest(TARGETS,
+                                              gtk.gdk.ACTION_DEFAULT
+                                              |gtk.gdk.ACTION_COPY
+                                              |gtk.gdk.ACTION_MOVE)
+        def drag_data_get_data(groupview, context, drag_selection, target_id, etime):
+            selection = groupview.get_selection()
+            m, it = selection.get_selected()
+            gurl = m.get_value(it, model.UPDATE_URL)
+            drag_selection.set_text(gurl)
+            drag_selection.set_uris([gurl])
+        # for info on our DnD strategy here, see
+        # http://lists-archives.org/gtk/06954-treemodelfilter-drag-and-drop.html
+        def drag_drop(groupview, context, x, y, time, data=None):
+            targets = groupview.drag_dest_get_target_list()
+            t = groupview.drag_dest_find_target(context, targets)
+            drag_selection = groupview.drag_get_data(context, t)
+            return True # don't invoke parent.
+        def drag_data_received_data(groupview, context, x, y, drag_selection,
+                                    info, etime):
+            # weird gtk workaround; see mailing list post referenced above.
+            groupview.emit_stop_by_name('drag_data_received')
+            # ok, now see if this is a move from the same widget
+            same_widget = (context.get_source_widget() == groupview)
+            m = groupview.get_model()
+            gurl = drag_selection.data
+            drop_info = groupview.get_dest_row_at_pos(x, y)
+            if drop_info is None:
+                desired_group_num = -1 # at end.
+            else:
+                path, position = drop_info
+                path = self.fgroupstore.convert_path_to_child_path(path)
+                desired_group_num = self.activity_updater\
+                                    .activity_list[path][model.GROUP_NUM]
+                if position == gtk.TREE_VIEW_DROP_AFTER \
+                   or position == gtk.TREE_VIEW_DROP_INTO_OR_AFTER:
+                    desired_group_num += 1
+            if not same_widget:
+                self.activity_updater.activity_list.add_group(gurl)
+            success = self.activity_updater.activity_list\
+                      .move_group(gurl, desired_group_num)
+            want_delete = (context.action == gtk.gdk.ACTION_MOVE) and \
+                          not same_widget # we'll handle the delete ourselves
+            context.finish(success, want_delete and success, etime)
+            return True # don't invoke parent.
+        self.groupview.connect("drag_data_get", drag_data_get_data)
+        self.groupview.connect("drag_data_received", drag_data_received_data)
+        self.groupview.connect("drag_drop", drag_drop)
+        crtext = gtk.CellRendererText()
+        column = gtk.TreeViewColumn('Name', crtext)
+        def group_name_markup(cell_layout, renderer, m, it):
+            renderer.set_property('markup', '<b>%s</b>\n<small>%s</small>' % \
+                                  (_e(m.get_value(it, model.DESCRIPTION_BIG)),
+                                   _e(m.get_value(it, model.UPDATE_URL))))
+        column.set_cell_data_func(crtext, group_name_markup)
+        self.groupview.append_column(column)
+        scrolled_window = gtk.ScrolledWindow()
+        scrolled_window.add_with_viewport(self.groupview)
+        scrolled_window.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
+
+        hbox2 = gtk.HBox()
+        hbox2.set_spacing(style.DEFAULT_PADDING)
+        label = gtk.Label(_('Group URL:'))
+        hbox2.pack_start(label, expand=False)
+        self.group_entry = gtk.Entry()
+        hbox2.pack_start(self.group_entry, expand=True)
+        self.group_add_button = gtk.Button(stock=gtk.STOCK_ADD)
+        self.group_del_button = gtk.Button(stock=gtk.STOCK_REMOVE)
+        self.group_del_button.set_sensitive(False)
+        hbox2.pack_start(self.group_add_button, expand=False)
+        hbox2.pack_start(self.group_del_button, expand=False)
+
+        selection = self.groupview.get_selection()
+        selection.set_mode(gtk.SELECTION_SINGLE)
+        def group_select_cb(selection):
+            (m, it) = selection.get_selected()
+            if it is None: return
+            self.group_entry.set_text(m.get_value(it, model.UPDATE_URL))
+        def group_entry_changed_cb(group_entry):
+            selection = self.groupview.get_selection()
+            (m, it) = selection.get_selected()
+            if it is not None and \
+               group_entry.get_text() == m.get_value(it, model.UPDATE_URL):
+                is_add = False
+            else:
+                is_add = True
+                selection.unselect_all()
+            self.group_add_button.set_sensitive(is_add)
+            self.group_del_button.set_sensitive(not is_add)
+        selection.connect('changed', group_select_cb)
+        self.group_entry.connect('changed', group_entry_changed_cb)
+        self.group_entry.connect('activate', self._add_group_cb, self)
+        self.group_add_button.connect('clicked', self._add_group_cb, self)
+        self.group_del_button.connect('clicked', self._del_group_cb, self)
+
+        self.pack_start(scrolled_window, expand=True)
+        self.pack_start(hbox2, expand=False)
+
+    def _add_group_cb(self, widget, event, data=None):
+        success = self.activity_updater.activity_list\
+                  .add_group(self.group_entry.get_text())
+        if success:
+            self.group_entry.set_text('')
+            self.groupview.scroll_to_cell(self.fgroupstore[-1].path)
+
+    def _del_group_cb(self, widget, event, data=None):
+        success = self.activity_updater.activity_list\
+                  .del_group(self.group_entry.get_text())
+        if success:
+            self.group_entry.set_text('')
+
+    def unlink_model(self):
+        self.groupview.set_model(None)
+
+    def relink_model(self):
+        self.fgroupstore.refilter()
+        self.groupview.set_model(self.fgroupstore)
+
+class ActivityPane(gtk.VBox):
+    """Container for the activity and group lists."""
+
+    def __init__(self, activity_updater):
+        gtk.VBox.__init__(self)
+        self.activity_updater = activity_updater
+        self.set_spacing(style.DEFAULT_PADDING)
+
+        ## activity list at top
+        vpaned = gtk.VPaned()
+        self.activities = ActivityListView(activity_updater, self)
+        vpaned.pack1(self.activities, resize=True, shrink=False)
+
+        ## expander/group list view at bottom
+        self.expander = gtk.Expander(label=_('Modify activity groups'))
+        self.expander.set_use_markup(True)
+        def expander_callback(expander, __):
+            if not expander.get_expanded(): # when groups are collapsed...
+                vpaned.set_position(-1) # ...unset VPaned thumb
+        self.expander.connect('notify::expanded', expander_callback)
+        self.groups = GroupListView(activity_updater)
+        self.expander.add(self.groups)
+        vpaned.pack2(self.expander, resize=False, shrink=False)
+        self.pack_start(vpaned, expand=True)
+
+        ### Install/refresh buttons below these.
+        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)
+        self.check_button = gtk.Button(stock=gtk.STOCK_REFRESH)
+        self.check_button.connect('clicked', activity_updater.refresh_cb, self)
+        button_box.pack_start(self.check_button, expand=False)
+        self.install_button = _make_button(_("Install selected"),
+                                          name='emblem-downloads')
+        self.install_button.connect('clicked', activity_updater.download_cb, self)
+        button_box.pack_start(self.install_button, expand=False)
+        def is_valid_cb(activity_list, __):
+            self.install_button.set_sensitive(activity_list.is_valid())
+        activity_updater.activity_list.connect('notify::is-valid', is_valid_cb)
+
+    def unlink_models(self):
+        self.activities.unlink_model()
+        self.groups.unlink_model()
+
+    def relink_models(self):
+        self.activities.relink_model()
+        self.groups.relink_model()
+
+    def _refresh_update_size(self):
+        """Update the 'download size' label."""
+        activity_list = self.activity_updater.activity_list
+        size = activity_list.updates_size()
+        self.size_label.set_markup(_('Download size: %s') %
+                                   _humanize_size(size))
+        self.install_button.set_sensitive(activity_list.updates_selected()!=0)
+
+    def switch(self):
+        """Make the activity list visible and the progress pane invisible."""
+        for widget, v in [ (self, True),
+                           (self.activity_updater.progress_pane, False),
+                           (self.activity_updater.expander, False)]:
+            widget.set_property('visible', v)
+
+class ProgressPane(gtk.VBox):
+    """Container which replaces the `ActivityPane` during refresh or
+    install."""
+
+    def __init__(self, activity_updater):
+        self.activity_updater = activity_updater
+        gtk.VBox.__init__(self)
+        self.set_spacing(style.DEFAULT_PADDING)
+        self.set_border_width(style.DEFAULT_SPACING * 2)
+
+        self.bar = gtk.ProgressBar()
+        self.label = gtk.Label()
+        self.label.set_line_wrap(True)
+        self.label.set_property('xalign', 0.5) # center the label.
+        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, activity_updater.cancel_cb),
+                          (self.refresh_button, activity_updater.refresh_cb),
+                          (self.try_again_button, activity_updater.refresh_cb)]:
+            widget.connect('clicked', cb, activity_updater)
+            hbox.pack_start(widget, expand=True, fill=False)
+
+        self.pack_start(self.icon)
+        self.pack_start(self.bar)
+        self.pack_start(self.label)
+        self.pack_start(hbox)
+
+    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`."""
+        if n is None:
+            self.bar.pulse()
+        else:
+            self.bar.set_fraction(n)
+        extra = _e(extra) if extra is not None else ''
+        if False and n is not None: # XXX 'percentage' disabled; it looks bad.
+            if len(extra) > 0: extra += ' '
+            extra += '%.0f%%' % (n*100)
+        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 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.activity_updater.activity_pane.set_property('visible', False)
+        self.set_property('visible', True)
+        for widget, v in [ (self.bar, 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.activity_updater.expander, False)]:
+            widget.set_property('visible', v)
+        self.cancel_button.set_sensitive(True)
+        self.activity_updater.expander.set_expanded(False)
+
+    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, msg, try_again=False):
+        self._switch(show_cancel=False, show_bar=False,
+                     show_try_again=try_again)
+        self.label.set_markup(msg)
+        self.activity_updater.expander.set_property('visible', True)
+
+class ActivityUpdater(SectionView):
+    """Software update control panel UI class."""
+
+    def __init__(self, modelwrapper, alerts):
+        SectionView.__init__(self)
+        self._in_sugar = (modelwrapper is not None)
+        self.set_spacing(style.DEFAULT_SPACING)
+        self.set_border_width(style.DEFAULT_SPACING * 2)
+
+        # top labels.
+        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.set_markup('<big>%s</big>'%_('Checking for updates...'))
+        bottom_label = gtk.Label()
+        bottom_label.set_line_wrap(True) # doesn't really work right =(
+        bottom_label.set_justify(gtk.JUSTIFY_LEFT)
+        bottom_label.set_property('xalign', 0)
+        bottom_label.set_markup(_('Software updates correct errors, eliminate security vulnerabilities, and provide new features.'))
+        vbox2 = gtk.VBox()
+        vbox2.pack_start(self.top_label, expand=False)
+        vbox2.pack_start(gtk.HSeparator(), expand=False)
+        vbox2.pack_start(bottom_label, expand=True)
+        self.pack_start(vbox2, expand=False)
+
+        # activity/group pane ####
+        self.activity_list = model.UpdateList()
+        self.activity_pane = ActivityPane(self)
+        self.pack_start(self.activity_pane, expand=True)
+
+        # progress pane ###########
+        self.progress_pane = ProgressPane(self)
+        self.pack_start(self.progress_pane, expand=True, fill=False)
+
+        # special little extension to progress pane.
+        self.expander = gtk.Expander(label=_('Modify activity groups'))
+        def expander_cb(expander, param_):
+            if expander.get_expanded():
+                self.activity_pane.switch()
+                self.activity_pane.expander.set_expanded(True)
+                expander.set_expanded(False)
+        self.expander.connect("notify::expanded", expander_cb)
+        self.pack_end(self.expander, expand=False)
+
+        # show our work!
+        self.show_all()
+        # and start refreshing.
+        self.refresh_cb(None, None)
+
+    def download_cb(self, widget, event, data=None):
+        """Invoked when the 'ok' button is clicked."""
+        from sugar.bundle.activitybundle import ActivityBundle
+        self.top_label.set_markup('<big>%s</big>' %
+                                  _('Downloading updates...'))
+        self.progress_pane.switch_to_download_progress()
+        self._progress_cb(0, _('Starting download...'))
+        self._cancel_func = lambda: self.activity_list.cancel_download()
+        def progress_cb(n, extra=None, icon=None):
+            gobject.idle_add(self._progress_cb, n, extra, icon)
+        @inhibit_suspend
+        def do_download():
+            # ensure main loop is dbus-registered
+            from dbus.mainloop.glib import DBusGMainLoop
+            DBusGMainLoop(set_as_default=True)
+            # get activity registry
+            from jarabe.model.bundleregistry import get_registry
+            registry = get_registry() # requires a dbus-registered main loop
+            # progress bar bookkeeping.
+            counts = [0, self.activity_list.updates_selected(), 0]
+            def p(n, extra, icon):
+                if n is None:
+                    progress_cb(n, extra, icon)
+                else:
+                    progress_cb((n+(counts[0]/counts[1]))/2, extra, icon)
+                counts[2] = n # last fraction.
+            def q(n, row):
+                p(n, _('Downloading %s...') % row[model.DESCRIPTION_BIG],
+                  row[model.ACTIVITY_ICON])
+            for row, f in self.activity_list.download_selected_updates(q):
+                if f is None: continue # cancelled or network error.
+                try:
+                    p(counts[2], _('Examining %s...')%row[model.DESCRIPTION_BIG],
+                      row[model.ACTIVITY_ICON])
+                    b = actutils.BundleHelper(f)
+                    p(counts[2], _('Installing %s...') % b.get_name(),
+                      _svg2pixbuf(b.get_icon_data()))
+                    b.install_or_upgrade(registry)
+                except:
+                    pass # XXX: use alert to indicate install failure.
+                if os.path.exists(f):
+                    os.unlink(f)
+                counts[0]+=1
+            # refresh when we're done.
+            gobject.idle_add(self.refresh_cb, None, None, False)
+        Thread(target=do_download).start()
+
+    def cancel_cb(self, widget, event, data=None):
+        """Callback when the cancel button is clicked."""
+        self.progress_pane.cancelling()
+        self._cancel_func()
+
+    def refresh_cb(self, widget, event, clear_cache=True):
+        """Invoked when the 'refresh' button is clicked."""
+        self.top_label.set_markup('<big>%s</big>' %
+                                  _('Checking for updates...'))
+        self.progress_pane.switch_to_check_progress()
+        self._cancel_func = lambda: self.activity_list.cancel_refresh()
+        # unlink model from treeview, and perform actual refresh in another
+        # thread.
+        self.activity_pane.unlink_models()
+        # freeze notify queue for activity_list to prevent thread problems.
+        self.activity_list.freeze_notify()
+        def progress_cb(n, extra=None):
+            gobject.idle_add(self._progress_cb, n, extra)
+        @inhibit_suspend
+        def do_refresh():
+            self.activity_list.refresh(progress_cb, clear_cache=clear_cache)
+            gobject.idle_add(self._refresh_done_cb)
+        Thread(target=do_refresh).start()
+        return False
+
+    def _progress_cb(self, n, extra=None, icon=None):
+        """Invoked in main thread during a refresh operation."""
+        self.progress_pane.update(n, extra, icon)
+        return False
+
+    def _refresh_done_cb(self):
+        """Invoked in main thread when the refresh is complete."""
+        self.activity_pane.relink_models()
+        self.activity_list.thaw_notify()
+        # clear the group url
+        self.activity_pane.groups.group_entry.set_text('')
+        # so, how did we do?
+        if not self.activity_list.saw_network_success():
+            header = _("Could not access the network")
+            self.progress_pane.switch_to_complete_message(
+                _('Could not access the network to check for updates.'),
+                try_again=True)
+        else:
+            avail = self.activity_list.updates_available()
+            if avail == 0:
+                header = _("Your software is up-to-date")
+                self.progress_pane.switch_to_complete_message(header)
+            else:
+                header = gettext.ngettext("You can install %s update",
+                                          "You can install %s updates", avail) \
+                                          % avail
+                self.activity_pane.switch()
+        self.top_label.set_markup('<big>%s</big>' % _e(header))
+        self.activity_pane._refresh_update_size()
+        # XXX: auto-close is disabled; I'm not convinced it helps the UI
+        if False and self.auto_close and \
+               self.activity_list.saw_network_success() and avail == 0:
+            # okay, automatically close since no updates were found.
+            self.emit('request-close')
+        return False
+
+    def delete_event(self, widget, event, data=None):
+        return False # destroy me!
+
+    def destroy(self, widget, data=None):
+        gtk.main_quit()
+
+    def main(self):
+        """Start gtk main loop."""
+        gtk.main()
+
+def _main():
+    """Testing code; runs updater standalone."""
+    window = gtk.Window(gtk.WINDOW_TOPLEVEL)
+    window.set_title(TITLE)
+    window.set_size_request(425, 400)
+    au = ActivityUpdater(None, None)
+    au.connect('request-close', au.destroy) # auto-close == destroy.
+    window.connect('delete_event', au.delete_event)
+    window.connect('destroy', au.destroy)
+    window.add(au)
+    au.set_border_width(style.DEFAULT_SPACING) # our window is smaller here.
+    window.show()
+    gtk.main()
+
+# set PYTHONPATH to /usr/share/sugar/shell before invoking.
+if __name__ == '__main__': _main ()
diff --git a/sugar-update-control.spec b/sugar-update-control.spec
index 0979782..262a355 100644
--- a/sugar-update-control.spec
+++ b/sugar-update-control.spec
@@ -12,7 +12,7 @@ Group: System Environment/Base
 URL: http://dev.laptop.org/git?p=users/cscott/sugar-update-control
 Source0: %{name}-%{version}.tar.gz
 BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n)
-Requires: %{PYTHON_NAME} >= %{PYTHON_VERSION}, %{PYTHON_NAME} < %{NEXT_PYTHON_VERSION}, olpc-update >= 2.13, sugar >= 0.81.8
+Requires: %{PYTHON_NAME} >= %{PYTHON_VERSION}, %{PYTHON_NAME} < %{NEXT_PYTHON_VERSION}, olpc-update >= 2.13, sugar >= 0.83.0
 BuildPrereq: %{PYTHON_NAME}-devel >= 2.5
 BuildArch: noarch
 
diff --git a/view/updater.py b/view/updater.py
deleted file mode 100755
index 9e2d6d5..0000000
--- a/view/updater.py
+++ /dev/null
@@ -1,704 +0,0 @@
-#!/usr/bin/python2.5
-# Copyright (C) 2008 One Laptop Per Child Association, Inc.
-# Licensed under the terms of the GNU GPL v2 or later; see COPYING for details.
-# Written by C. Scott Ananian <cscott at laptop.org>
-"""Activity updater.
-
-Checks for updates to activities and installs them."""
-from __future__ import with_statement
-from __future__ import division
-import pygtk
-pygtk.require('2.0')
-import gtk, gobject
-gtk.gdk.threads_init()
-
-import gettext
-import os
-import re
-from threading import Thread
-from gettext import gettext as _
-
-import bitfrost.update.actutils as actutils
-from sugar.graphics import style
-
-from controlpanel.sectionview import SectionView
-from controlpanel.inlinealert import InlineAlert
-
-import controlpanel.model.updater as model
-from controlpanel.model.updater import _humanize_size, _svg2pixbuf, inhibit_suspend
-
-# COMPATIBILITY HACK: can be removed after sugar 0.81.9 is released.
-# (be sure to update sugar-update-control.spec w/ this dependency when so)
-from controlpanel.gui import ControlPanel
-if not hasattr(ControlPanel, 'set_section_view_auto_close'):
-    OriginalSectionView = SectionView
-    class TweakedSectionView(OriginalSectionView):
-        __gsignals__ = {
-            'request-close': (gobject.SIGNAL_RUN_FIRST,
-                              gobject.TYPE_NONE, ([]))
-            }
-        def __init__(self):
-            OriginalSectionView.__init__(self)
-            self.auto_close = False
-    SectionView = TweakedSectionView
-# END COMPATIBILITY HACK
-
-# COMPATIBILITY HACK: work around trac #8532 by forcibly removing the
-# SIGCHLD handler (and the necessity for one)
-import sugar.activity.activityfactory
-if hasattr(sugar.activity.activityfactory, '_sigchild_handler'):
-    from ctypes import CDLL, Structure, c_voidp, c_int, c_ulong, pointer
-    import signal
-    libc = CDLL("libc.so.6")
-    SA_NOCLDWAIT = 2 # according to /usr/include/bits/sigaction.h
-    class SIGACTION(Structure):
-        _fields_ = [('sa_handler', c_voidp),
-                    ('sa_sigaction', c_voidp),
-                    ('sa_mask', c_ulong), # sigset_t
-                    ('sa_flags', c_int),
-                    ('sa_restorer', c_voidp)]
-    desired = SIGACTION(None, # SIG_DFL, according to /usr/include/asm-generic/signal.h
-                        None,
-                        0,
-                        SA_NOCLDWAIT,
-                        None)
-    libc.sigaction(signal.SIGCHLD, pointer(desired), None)
-# END COMPATIBILITY HACK
-
-
-# configuration constants needed for control panel framework
-CLASS = 'ActivityUpdater'
-ICON = 'module-updater'
-TITLE = _('Software update')
-
-# for debugging
-_DEBUG_VIEW_ALL=False
-"""View even activities with no pending updates."""
-
-def _escape_markup(s):
-    """Escape special characters in `s` so that it is safe to use in
-    Pango markup.  Equivalent to `g_markup_escape_text` in glib."""
-    entities = { '&': '&amp;',
-                 '<': '&lt;',
-                 '>': '&gt;',
-                 '"': '&quot;',
-                 "'": '&apos;', }
-    if s is None: return None
-    return re.sub("[&<>\"']", lambda m: entities[m.group(0)],
-                  s.decode('utf-8','ignore')).encode('utf-8')
-_e = _escape_markup
-"""Useful abbreviation."""
-
-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
-
-### Pieces of the activity updater view; factored to make the UI structure
-### more apparent in `ActivityUpdater.__init__`.
-
-class ActivityListView(gtk.ScrolledWindow):
-    """List view at the top, showing activities, versions, and sizes."""
-    def __init__(self, activity_updater, activity_pane):
-        gtk.ScrolledWindow.__init__(self)
-        self.activity_updater = activity_updater
-        self.activity_pane = activity_pane
-
-        # create the TreeView using a filtered treestore
-        self.ftreestore = self.activity_updater.activity_list.filter_new()
-        if not _DEBUG_VIEW_ALL:
-            self.ftreestore.set_visible_column(model.UPDATE_EXISTS)
-        self.treeview = gtk.TreeView(self.ftreestore)
-   
-        # create some cell renderers.
-        crbool = gtk.CellRendererToggle()
-        crbool.set_property('activatable', True)
-        crbool.set_property('xpad', style.DEFAULT_PADDING)
-        # indicator size should be themeable, but is not.
-        # if we're in sugar, use the larger indicator size.
-        # otherwise, use the hard-coded GTK default.
-        if self.activity_updater._in_sugar:
-            crbool.set_property('indicator_size', style.zoom(26))
-        def toggled_cb(crbool, path, self):
-            path = self.ftreestore.convert_path_to_child_path(path)
-            self.activity_updater.activity_list.toggle_select(path)
-            self.activity_pane._refresh_update_size()
-        crbool.connect('toggled', toggled_cb, self)
-
-        cricon = gtk.CellRendererPixbuf()
-        cricon.set_property('width', style.STANDARD_ICON_SIZE)
-        cricon.set_property('height', style.STANDARD_ICON_SIZE)
-
-        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')
-        self.column_install = gtk.TreeViewColumn('Install', crbool)
-        self.column_install.add_attribute(crbool, 'active', model.UPDATE_SELECTED)
-        self.column_install.set_cell_data_func(crbool, hide_func)
-        self.column = gtk.TreeViewColumn('Name')
-        self.column.pack_start(cricon, expand=False)
-        self.column.pack_start(crtext, expand=True)
-        self.column.add_attribute(cricon, 'pixbuf', model.ACTIVITY_ICON)
-        self.column.set_resizable(True)
-        self.column.set_cell_data_func(cricon, hide_func)
-        def markup_func(cell_layout, renderer, m, it):
-            s = '<b>%s</b>' % _e(m.get_value(it, model.DESCRIPTION_BIG))
-            if m.get_value(it, model.IS_HEADER):
-                s = '<big>%s</big>' % s
-            desc = m.get_value(it, model.DESCRIPTION_SMALL)
-            if desc is not None and desc != '':
-                s += '\n<small>%s</small>' % _e(desc)
-            renderer.set_property('markup', s)
-            insens_func(cell_layout, renderer, m, it)
-        self.column.set_cell_data_func(crtext, markup_func)
-   
-        # add tvcolumn to treeview
-        self.treeview.append_column(self.column_install)
-        self.treeview.append_column(self.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.show_context_menu)
-
-        def is_valid_cb(activity_list, __):
-            self.treeview.set_sensitive(activity_list.is_valid())
-        self.activity_updater.activity_list.connect('notify::is-valid',
-                                                    is_valid_cb)
-        is_valid_cb(self.activity_updater.activity_list, None)
-
-        # now put this all inside ourself (a gtk.ScrolledWindow)
-        self.add_with_viewport(self.treeview)
-        self.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
-
-    def unlink_model(self):
-        self.treeview.set_model(None)
-    def relink_model(self):
-        self.ftreestore.refilter()
-        self.treeview.set_model(self.ftreestore)
-
-    def show_context_menu(self, widget, event):
-        """
-        Show a context menu if a right click was performed on an update entry
-        """
-        if event.type == gtk.gdk.BUTTON_PRESS and event.button == 3:
-            activity_list = self.activity_updater.activity_list
-            def cb(__, f):
-                f()
-                self.activity_pane._refresh_update_size()
-            menu = gtk.Menu()
-            item_select_none = gtk.MenuItem(_("_Uncheck All"))
-            item_select_none.connect("activate", cb,
-                                     activity_list.unselect_all)
-            menu.add(item_select_none)
-            if activity_list.updates_available() == 0:
-                item_select_none.set_property("sensitive", False)
-            item_select_all = gtk.MenuItem(_("_Check All"))
-            item_select_all.connect("activate", cb,
-                                    activity_list.select_all)
-            menu.add(item_select_all)
-            menu.popup(None, None, None, 0, event.time)
-            menu.show_all()
-            return True
-        return False
-
-class GroupListView(gtk.VBox):
-    """List view in expander at bottom, showing groups and urls."""
-    def __init__(self, activity_updater):
-        gtk.VBox.__init__(self)
-        self.set_spacing(style.DEFAULT_PADDING)
-        self.activity_updater = activity_updater
-
-        self.fgroupstore = self.activity_updater.activity_list.filter_new()
-        def group_visibility(m, it, user_data=None):
-            # only group header rows, but not the 'local activities' group.
-            return m.get_value(it, model.IS_HEADER) and \
-                   m.get_value(it, model.UPDATE_URL) is not None
-        self.fgroupstore.set_visible_func(group_visibility)
-        self.groupview = gtk.TreeView(self.fgroupstore)
-        self.groupview.set_headers_visible(False)
-        self.groupview.set_rules_hint(True)
-        self.groupview.set_enable_search(False)
-        self.groupview.set_reorderable(False) # we'll write own DnD funcs below
-        TARGETS = gtk.target_list_add_text_targets(info=0)
-        TARGETS = gtk.target_list_add_uri_targets(list=TARGETS, info=1)
-        self.groupview.enable_model_drag_source( gtk.gdk.BUTTON1_MASK,
-                                                 TARGETS,
-                                                 gtk.gdk.ACTION_DEFAULT
-                                                 |gtk.gdk.ACTION_COPY
-                                                 |gtk.gdk.ACTION_MOVE)
-        self.groupview.enable_model_drag_dest(TARGETS,
-                                              gtk.gdk.ACTION_DEFAULT
-                                              |gtk.gdk.ACTION_COPY
-                                              |gtk.gdk.ACTION_MOVE)
-        def drag_data_get_data(groupview, context, drag_selection, target_id, etime):
-            selection = groupview.get_selection()
-            m, it = selection.get_selected()
-            gurl = m.get_value(it, model.UPDATE_URL)
-            drag_selection.set_text(gurl)
-            drag_selection.set_uris([gurl])
-        # for info on our DnD strategy here, see
-        # http://lists-archives.org/gtk/06954-treemodelfilter-drag-and-drop.html
-        def drag_drop(groupview, context, x, y, time, data=None):
-            targets = groupview.drag_dest_get_target_list()
-            t = groupview.drag_dest_find_target(context, targets)
-            drag_selection = groupview.drag_get_data(context, t)
-            return True # don't invoke parent.
-        def drag_data_received_data(groupview, context, x, y, drag_selection,
-                                    info, etime):
-            # weird gtk workaround; see mailing list post referenced above.
-            groupview.emit_stop_by_name('drag_data_received')
-            # ok, now see if this is a move from the same widget
-            same_widget = (context.get_source_widget() == groupview)
-            m = groupview.get_model()
-            gurl = drag_selection.data
-            drop_info = groupview.get_dest_row_at_pos(x, y)
-            if drop_info is None:
-                desired_group_num = -1 # at end.
-            else:
-                path, position = drop_info
-                path = self.fgroupstore.convert_path_to_child_path(path)
-                desired_group_num = self.activity_updater\
-                                    .activity_list[path][model.GROUP_NUM]
-                if position == gtk.TREE_VIEW_DROP_AFTER \
-                   or position == gtk.TREE_VIEW_DROP_INTO_OR_AFTER:
-                    desired_group_num += 1
-            if not same_widget:
-                self.activity_updater.activity_list.add_group(gurl)
-            success = self.activity_updater.activity_list\
-                      .move_group(gurl, desired_group_num)
-            want_delete = (context.action == gtk.gdk.ACTION_MOVE) and \
-                          not same_widget # we'll handle the delete ourselves
-            context.finish(success, want_delete and success, etime)
-            return True # don't invoke parent.
-        self.groupview.connect("drag_data_get", drag_data_get_data)
-        self.groupview.connect("drag_data_received", drag_data_received_data)
-        self.groupview.connect("drag_drop", drag_drop)
-        crtext = gtk.CellRendererText()
-        column = gtk.TreeViewColumn('Name', crtext)
-        def group_name_markup(cell_layout, renderer, m, it):
-            renderer.set_property('markup', '<b>%s</b>\n<small>%s</small>' % \
-                                  (_e(m.get_value(it, model.DESCRIPTION_BIG)),
-                                   _e(m.get_value(it, model.UPDATE_URL))))
-        column.set_cell_data_func(crtext, group_name_markup)
-        self.groupview.append_column(column)
-        scrolled_window = gtk.ScrolledWindow()
-        scrolled_window.add_with_viewport(self.groupview)
-        scrolled_window.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
-
-        hbox2 = gtk.HBox()
-        hbox2.set_spacing(style.DEFAULT_PADDING)
-        label = gtk.Label(_('Group URL:'))
-        hbox2.pack_start(label, expand=False)
-        self.group_entry = gtk.Entry()
-        hbox2.pack_start(self.group_entry, expand=True)
-        self.group_add_button = gtk.Button(stock=gtk.STOCK_ADD)
-        self.group_del_button = gtk.Button(stock=gtk.STOCK_REMOVE)
-        self.group_del_button.set_sensitive(False)
-        hbox2.pack_start(self.group_add_button, expand=False)
-        hbox2.pack_start(self.group_del_button, expand=False)
-
-        selection = self.groupview.get_selection()
-        selection.set_mode(gtk.SELECTION_SINGLE)
-        def group_select_cb(selection):
-            (m, it) = selection.get_selected()
-            if it is None: return
-            self.group_entry.set_text(m.get_value(it, model.UPDATE_URL))
-        def group_entry_changed_cb(group_entry):
-            selection = self.groupview.get_selection()
-            (m, it) = selection.get_selected()
-            if it is not None and \
-               group_entry.get_text() == m.get_value(it, model.UPDATE_URL):
-                is_add = False
-            else:
-                is_add = True
-                selection.unselect_all()
-            self.group_add_button.set_sensitive(is_add)
-            self.group_del_button.set_sensitive(not is_add)
-        selection.connect('changed', group_select_cb)
-        self.group_entry.connect('changed', group_entry_changed_cb)
-        self.group_entry.connect('activate', self._add_group_cb, self)
-        self.group_add_button.connect('clicked', self._add_group_cb, self)
-        self.group_del_button.connect('clicked', self._del_group_cb, self)
-
-        self.pack_start(scrolled_window, expand=True)
-        self.pack_start(hbox2, expand=False)
-
-    def _add_group_cb(self, widget, event, data=None):
-        success = self.activity_updater.activity_list\
-                  .add_group(self.group_entry.get_text())
-        if success:
-            self.group_entry.set_text('')
-            self.groupview.scroll_to_cell(self.fgroupstore[-1].path)
-
-    def _del_group_cb(self, widget, event, data=None):
-        success = self.activity_updater.activity_list\
-                  .del_group(self.group_entry.get_text())
-        if success:
-            self.group_entry.set_text('')
-
-    def unlink_model(self):
-        self.groupview.set_model(None)
-
-    def relink_model(self):
-        self.fgroupstore.refilter()
-        self.groupview.set_model(self.fgroupstore)
-
-class ActivityPane(gtk.VBox):
-    """Container for the activity and group lists."""
-
-    def __init__(self, activity_updater):
-        gtk.VBox.__init__(self)
-        self.activity_updater = activity_updater
-        self.set_spacing(style.DEFAULT_PADDING)
-
-        ## activity list at top
-        vpaned = gtk.VPaned()
-        self.activities = ActivityListView(activity_updater, self)
-        vpaned.pack1(self.activities, resize=True, shrink=False)
-
-        ## expander/group list view at bottom
-        self.expander = gtk.Expander(label=_('Modify activity groups'))
-        self.expander.set_use_markup(True)
-        def expander_callback(expander, __):
-            if not expander.get_expanded(): # when groups are collapsed...
-                vpaned.set_position(-1) # ...unset VPaned thumb
-        self.expander.connect('notify::expanded', expander_callback)
-        self.groups = GroupListView(activity_updater)
-        self.expander.add(self.groups)
-        vpaned.pack2(self.expander, resize=False, shrink=False)
-        self.pack_start(vpaned, expand=True)
-
-        ### Install/refresh buttons below these.
-        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)
-        self.check_button = gtk.Button(stock=gtk.STOCK_REFRESH)
-        self.check_button.connect('clicked', activity_updater.refresh_cb, self)
-        button_box.pack_start(self.check_button, expand=False)
-        self.install_button = _make_button(_("Install selected"),
-                                          name='emblem-downloads')
-        self.install_button.connect('clicked', activity_updater.download_cb, self)
-        button_box.pack_start(self.install_button, expand=False)
-        def is_valid_cb(activity_list, __):
-            self.install_button.set_sensitive(activity_list.is_valid())
-        activity_updater.activity_list.connect('notify::is-valid', is_valid_cb)
-
-    def unlink_models(self):
-        self.activities.unlink_model()
-        self.groups.unlink_model()
-
-    def relink_models(self):
-        self.activities.relink_model()
-        self.groups.relink_model()
-
-    def _refresh_update_size(self):
-        """Update the 'download size' label."""
-        activity_list = self.activity_updater.activity_list
-        size = activity_list.updates_size()
-        self.size_label.set_markup(_('Download size: %s') %
-                                   _humanize_size(size))
-        self.install_button.set_sensitive(activity_list.updates_selected()!=0)
-
-    def switch(self):
-        """Make the activity list visible and the progress pane invisible."""
-        for widget, v in [ (self, True),
-                           (self.activity_updater.progress_pane, False),
-                           (self.activity_updater.expander, False)]:
-            widget.set_property('visible', v)
-
-class ProgressPane(gtk.VBox):
-    """Container which replaces the `ActivityPane` during refresh or
-    install."""
-
-    def __init__(self, activity_updater):
-        self.activity_updater = activity_updater
-        gtk.VBox.__init__(self)
-        self.set_spacing(style.DEFAULT_PADDING)
-        self.set_border_width(style.DEFAULT_SPACING * 2)
-
-        self.bar = gtk.ProgressBar()
-        self.label = gtk.Label()
-        self.label.set_line_wrap(True)
-        self.label.set_property('xalign', 0.5) # center the label.
-        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, activity_updater.cancel_cb),
-                          (self.refresh_button, activity_updater.refresh_cb),
-                          (self.try_again_button, activity_updater.refresh_cb)]:
-            widget.connect('clicked', cb, activity_updater)
-            hbox.pack_start(widget, expand=True, fill=False)
-
-        self.pack_start(self.icon)
-        self.pack_start(self.bar)
-        self.pack_start(self.label)
-        self.pack_start(hbox)
-
-    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`."""
-        if n is None:
-            self.bar.pulse()
-        else:
-            self.bar.set_fraction(n)
-        extra = _e(extra) if extra is not None else ''
-        if False and n is not None: # XXX 'percentage' disabled; it looks bad.
-            if len(extra) > 0: extra += ' '
-            extra += '%.0f%%' % (n*100)
-        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 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.activity_updater.activity_pane.set_property('visible', False)
-        self.set_property('visible', True)
-        for widget, v in [ (self.bar, 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.activity_updater.expander, False)]:
-            widget.set_property('visible', v)
-        self.cancel_button.set_sensitive(True)
-        self.activity_updater.expander.set_expanded(False)
-
-    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, msg, try_again=False):
-        self._switch(show_cancel=False, show_bar=False,
-                     show_try_again=try_again)
-        self.label.set_markup(msg)
-        self.activity_updater.expander.set_property('visible', True)
-
-class ActivityUpdater(SectionView):
-    """Software update control panel UI class."""
-
-    def __init__(self, modelwrapper, alerts):
-        SectionView.__init__(self)
-        self._in_sugar = (modelwrapper is not None)
-        self.set_spacing(style.DEFAULT_SPACING)
-        self.set_border_width(style.DEFAULT_SPACING * 2)
-
-        # top labels.
-        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.set_markup('<big>%s</big>'%_('Checking for updates...'))
-        bottom_label = gtk.Label()
-        bottom_label.set_line_wrap(True) # doesn't really work right =(
-        bottom_label.set_justify(gtk.JUSTIFY_LEFT)
-        bottom_label.set_property('xalign', 0)
-        bottom_label.set_markup(_('Software updates correct errors, eliminate security vulnerabilities, and provide new features.'))
-        vbox2 = gtk.VBox()
-        vbox2.pack_start(self.top_label, expand=False)
-        vbox2.pack_start(gtk.HSeparator(), expand=False)
-        vbox2.pack_start(bottom_label, expand=True)
-        self.pack_start(vbox2, expand=False)
-
-        # activity/group pane ####
-        self.activity_list = model.UpdateList()
-        self.activity_pane = ActivityPane(self)
-        self.pack_start(self.activity_pane, expand=True)
-
-        # progress pane ###########
-        self.progress_pane = ProgressPane(self)
-        self.pack_start(self.progress_pane, expand=True, fill=False)
-
-        # special little extension to progress pane.
-        self.expander = gtk.Expander(label=_('Modify activity groups'))
-        def expander_cb(expander, param_):
-            if expander.get_expanded():
-                self.activity_pane.switch()
-                self.activity_pane.expander.set_expanded(True)
-                expander.set_expanded(False)
-        self.expander.connect("notify::expanded", expander_cb)
-        self.pack_end(self.expander, expand=False)
-
-        # show our work!
-        self.show_all()
-        # and start refreshing.
-        self.refresh_cb(None, None)
-
-    def download_cb(self, widget, event, data=None):
-        """Invoked when the 'ok' button is clicked."""
-        from sugar.bundle.activitybundle import ActivityBundle
-        self.top_label.set_markup('<big>%s</big>' %
-                                  _('Downloading updates...'))
-        self.progress_pane.switch_to_download_progress()
-        self._progress_cb(0, _('Starting download...'))
-        self._cancel_func = lambda: self.activity_list.cancel_download()
-        def progress_cb(n, extra=None, icon=None):
-            gobject.idle_add(self._progress_cb, n, extra, icon)
-        @inhibit_suspend
-        def do_download():
-            # ensure main loop is dbus-registered
-            from dbus.mainloop.glib import DBusGMainLoop
-            DBusGMainLoop(set_as_default=True)
-            # get activity registry
-            from sugar.activity.registry import get_registry
-            registry = get_registry() # requires a dbus-registered main loop
-            # progress bar bookkeeping.
-            counts = [0, self.activity_list.updates_selected(), 0]
-            def p(n, extra, icon):
-                if n is None:
-                    progress_cb(n, extra, icon)
-                else:
-                    progress_cb((n+(counts[0]/counts[1]))/2, extra, icon)
-                counts[2] = n # last fraction.
-            def q(n, row):
-                p(n, _('Downloading %s...') % row[model.DESCRIPTION_BIG],
-                  row[model.ACTIVITY_ICON])
-            for row, f in self.activity_list.download_selected_updates(q):
-                if f is None: continue # cancelled or network error.
-                try:
-                    p(counts[2], _('Examining %s...')%row[model.DESCRIPTION_BIG],
-                      row[model.ACTIVITY_ICON])
-                    b = actutils.BundleHelper(f)
-                    p(counts[2], _('Installing %s...') % b.get_name(),
-                      _svg2pixbuf(b.get_icon_data()))
-                    b.install_or_upgrade(registry)
-                except:
-                    pass # XXX: use alert to indicate install failure.
-                if os.path.exists(f):
-                    os.unlink(f)
-                counts[0]+=1
-            # refresh when we're done.
-            gobject.idle_add(self.refresh_cb, None, None, False)
-        Thread(target=do_download).start()
-
-    def cancel_cb(self, widget, event, data=None):
-        """Callback when the cancel button is clicked."""
-        self.progress_pane.cancelling()
-        self._cancel_func()
-
-    def refresh_cb(self, widget, event, clear_cache=True):
-        """Invoked when the 'refresh' button is clicked."""
-        self.top_label.set_markup('<big>%s</big>' %
-                                  _('Checking for updates...'))
-        self.progress_pane.switch_to_check_progress()
-        self._cancel_func = lambda: self.activity_list.cancel_refresh()
-        # unlink model from treeview, and perform actual refresh in another
-        # thread.
-        self.activity_pane.unlink_models()
-        # freeze notify queue for activity_list to prevent thread problems.
-        self.activity_list.freeze_notify()
-        def progress_cb(n, extra=None):
-            gobject.idle_add(self._progress_cb, n, extra)
-        @inhibit_suspend
-        def do_refresh():
-            self.activity_list.refresh(progress_cb, clear_cache=clear_cache)
-            gobject.idle_add(self._refresh_done_cb)
-        Thread(target=do_refresh).start()
-        return False
-
-    def _progress_cb(self, n, extra=None, icon=None):
-        """Invoked in main thread during a refresh operation."""
-        self.progress_pane.update(n, extra, icon)
-        return False
-
-    def _refresh_done_cb(self):
-        """Invoked in main thread when the refresh is complete."""
-        self.activity_pane.relink_models()
-        self.activity_list.thaw_notify()
-        # clear the group url
-        self.activity_pane.groups.group_entry.set_text('')
-        # so, how did we do?
-        if not self.activity_list.saw_network_success():
-            header = _("Could not access the network")
-            self.progress_pane.switch_to_complete_message(
-                _('Could not access the network to check for updates.'),
-                try_again=True)
-        else:
-            avail = self.activity_list.updates_available()
-            if avail == 0:
-                header = _("Your software is up-to-date")
-                self.progress_pane.switch_to_complete_message(header)
-            else:
-                header = gettext.ngettext("You can install %s update",
-                                          "You can install %s updates", avail) \
-                                          % avail
-                self.activity_pane.switch()
-        self.top_label.set_markup('<big>%s</big>' % _e(header))
-        self.activity_pane._refresh_update_size()
-        # XXX: auto-close is disabled; I'm not convinced it helps the UI
-        if False and self.auto_close and \
-               self.activity_list.saw_network_success() and avail == 0:
-            # okay, automatically close since no updates were found.
-            self.emit('request-close')
-        return False
-
-    def delete_event(self, widget, event, data=None):
-        return False # destroy me!
-
-    def destroy(self, widget, data=None):
-        gtk.main_quit()
-
-    def main(self):
-        """Start gtk main loop."""
-        gtk.main()
-
-def _main():
-    """Testing code; runs updater standalone."""
-    window = gtk.Window(gtk.WINDOW_TOPLEVEL)
-    window.set_title(TITLE)
-    window.set_size_request(425, 400)
-    au = ActivityUpdater(None, None)
-    au.connect('request-close', au.destroy) # auto-close == destroy.
-    window.connect('delete_event', au.delete_event)
-    window.connect('destroy', au.destroy)
-    window.add(au)
-    au.set_border_width(style.DEFAULT_SPACING) # our window is smaller here.
-    window.show()
-    gtk.main()
-
-# set PYTHONPATH to /usr/share/sugar/shell before invoking.
-if __name__ == '__main__': _main ()
-----------------------------------------------------------------------


--
/home/cscott/public_git/sugar-update-control


More information about the Commits mailing list