Commits

Ronald Oussoren committed 2c91448

- Add a PackageManager clone

This version is a Cocoa application (obviously), has a favorites menu and
performs some of the blocking operations in a seperate thread. It uses an
unmodified pimp module and therefore doesn't solve the deeper problems of
pimp/PackageManager.app.

  • Participants
  • Parent commits d5be257

Comments (0)

Files changed (14)

pyobjc/Examples/PackageManager/ReadMe.txt

+=====================
+Cocoa Package Manager
+=====================
+
+This application is a Cocoa version of the Package Manager application that
+is included with MacPython.
+
+This is a first version of the application, using an unmodified ``pimp`` 
+module, it does not implement the ideas described on the `NewPackageManager`__
+page on the `MacPython wiki`_.
+
+Features w.r.t. the official Package Manager:
+
+- You can have a list of favorite databases
+
+- The scroll-list doesn't scroll back automaticly ;-)
+
+Building
+--------
+
+This version requires the latest version of PyObjC (1.1b1) and MacOS X 10.3.
+Please let me know if it also works on OSX 10.2.
+
+Run ``python buildapp.py build`` to create the application.
+
+TODO
+----
+
+- Auto-update the status, this mostly works at the moment.
+- Testing!
+- Implement help menu
+- A --semi-standalone version cannot detect if the system contains PyObjC,
+  should run detection code in a seperate process. 
+
+In the further future the pimp should be replaced by something better, see
+the `MacPython wiki`_ for more information.
+
+.. _`MacPython wiki`: http://pythonmac.org/wiki

pyobjc/Examples/PackageManager/Resources/English.lproj/MainMenu.nib/classes.nib

+{
+    IBClasses = (
+        {
+            ACTIONS = {addToFavorites = id; }; 
+            CLASS = FirstResponder; 
+            LANGUAGE = ObjC; 
+            SUPERCLASS = NSObject; 
+        }, 
+        {
+            ACTIONS = {
+                changeFavoritesTitle = id; 
+                changeFavoritesUrl = id; 
+                openStandardDatabase = id; 
+                openURL = id; 
+            }; 
+            CLASS = PackageManager; 
+            LANGUAGE = ObjC; 
+            OUTLETS = {
+                favoritesPanel = id; 
+                favoritesTable = id; 
+                favoritesTitle = id; 
+                favoritesURL = id; 
+            }; 
+            SUPERCLASS = NSObject; 
+        }
+    ); 
+    IBVersion = 1; 
+}

pyobjc/Examples/PackageManager/Resources/English.lproj/MainMenu.nib/info.nib

+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>IBDocumentLocation</key>
+	<string>465 38 356 240 0 0 1680 1028 </string>
+	<key>IBEditorPositions</key>
+	<dict>
+		<key>29</key>
+		<string>40 260 403 44 0 0 1680 1028 </string>
+	</dict>
+	<key>IBFramework Version</key>
+	<string>349.0</string>
+	<key>IBOpenObjects</key>
+	<array>
+		<integer>262</integer>
+		<integer>29</integer>
+	</array>
+	<key>IBSystem Version</key>
+	<string>7D24</string>
+</dict>
+</plist>

pyobjc/Examples/PackageManager/Resources/English.lproj/MainMenu.nib/keyedobjects.nib

Binary file added.

pyobjc/Examples/PackageManager/Resources/English.lproj/OpenPanel.nib/classes.nib

+{
+    IBClasses = (
+        {CLASS = FirstResponder; LANGUAGE = ObjC; SUPERCLASS = NSObject; }, 
+        {
+            ACTIONS = {doOpenURL = id; }; 
+            CLASS = URLOpener; 
+            LANGUAGE = ObjC; 
+            OUTLETS = {okButton = id; urlField = id; }; 
+            SUPERCLASS = NSObject; 
+        }
+    ); 
+    IBVersion = 1; 
+}

pyobjc/Examples/PackageManager/Resources/English.lproj/OpenPanel.nib/info.nib

+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>IBDocumentLocation</key>
+	<string>69 10 356 240 0 0 1680 1028 </string>
+	<key>IBFramework Version</key>
+	<string>349.0</string>
+	<key>IBOpenObjects</key>
+	<array>
+		<integer>5</integer>
+	</array>
+	<key>IBSystem Version</key>
+	<string>7D24</string>
+</dict>
+</plist>

pyobjc/Examples/PackageManager/Resources/English.lproj/OpenPanel.nib/keyedobjects.nib

Binary file added.

pyobjc/Examples/PackageManager/Resources/English.lproj/PackageDatabase.nib/classes.nib

+{
+    IBClasses = (
+        {CLASS = FirstResponder; LANGUAGE = ObjC; SUPERCLASS = NSObject; }, 
+        {
+            ACTIONS = {
+                closeProgress = id; 
+                filterPackages = id; 
+                installPackage = id; 
+                savePreferences = id; 
+                visitHome = id; 
+            }; 
+            CLASS = PackageDatabase; 
+            LANGUAGE = ObjC; 
+            OUTLETS = {
+                databaseMaintainer = id; 
+                databaseName = id; 
+                installButton = id; 
+                installDependencies = id; 
+                installationLocation = id; 
+                installationLog = id; 
+                installationPanel = id; 
+                installationProgress = id; 
+                installationTitle = id; 
+                itemDescription = NSTextView; 
+                itemHome = NSTextField; 
+                itemInstalled = NSTextField; 
+                itemStatus = NSTextField; 
+                overwrite = id; 
+                packageTable = NSTableView; 
+                progressOK = id; 
+                showHidden = id; 
+                verbose = id; 
+            }; 
+            SUPERCLASS = NSDocument; 
+        }
+    ); 
+    IBVersion = 1; 
+}

pyobjc/Examples/PackageManager/Resources/English.lproj/PackageDatabase.nib/info.nib

+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>IBDocumentLocation</key>
+	<string>857 124 356 240 0 0 1680 1028 </string>
+	<key>IBFramework Version</key>
+	<string>349.0</string>
+	<key>IBLockedObjects</key>
+	<array>
+		<integer>26</integer>
+	</array>
+	<key>IBOpenObjects</key>
+	<array>
+		<integer>6</integer>
+		<integer>89</integer>
+	</array>
+	<key>IBSystem Version</key>
+	<string>7D24</string>
+</dict>
+</plist>

pyobjc/Examples/PackageManager/Resources/English.lproj/PackageDatabase.nib/keyedobjects.nib

Binary file added.

pyobjc/Examples/PackageManager/Resources/PackageManager.icns

Binary file added.

pyobjc/Examples/PackageManager/buildapp.py

+#
+# The build script for the application
+#
+# This is very likely incorrect, as the application is non-existant at the
+# moment.
+#
+# TODO: Strip 'CVS' directories from the output
+from bundlebuilder import buildapp
+import os
+import sys
+from plistlib import Plist, Dict
+
+DB_FILE_TYPE="Python Package Database"
+
+def folderContents(name):
+    data = [ 
+        os.path.join(name, fn) 
+            for fn in os.listdir(name) 
+    ]
+    return data
+
+
+buildapp(
+    name = "Package Manager",
+    mainprogram = "packman.py",
+    resources = folderContents("Resources") + [ 'pimp2.py' ],
+    nibname = "MainMenu",
+    plist = Plist(
+        CFBundleIconFile='PackageManager.icns',
+        CFBundleDocumentTypes=[
+            Dict(
+                CFBundleTypeName=DB_FILE_TYPE,
+                CFBundleTypeRole='Editor',
+                NSDocumentClass='PackageDatabase',
+                # CFBundleTypeIconFile='Package Database.icns',
+                CFBundleTypeExtensions = ['packman', 'plist' ],
+                CFBundleTypeOSTypes=[],
+            ),
+        ],
+
+        CFBundleGetInfoString='1.0, Copyright 2004 Ronald Oussoren',
+        CFBundleIdentifier='net.sf.pyobjc.PackageManager',
+        CFBundleShortVersionString='1.0',
+        CFBundleVersion='1.0',
+
+        # We need at least Panther, it may work on Jaguar but I've not yet
+        # verified if it should work.
+        LSMinimumSystemVersion='10.3.0', 
+
+        # We're not apple-scriptable
+        NSAppleScriptEnabled='No',
+    ),
+)

pyobjc/Examples/PackageManager/packman.py

+"""
+Cocoa GUI for the Package Manager
+
+This is a first generation of the Cocoa GUI, it inherits some of the nasty 
+features of the current Carbon version:
+
+1. GUI blocks during some operations, such as downloading or installing
+
+2. Checking on GUI packages may crash the application
+
+The first item can only be solved by rewriting parts of the pimp module, the
+second part will be solved by running at least some pimp related code in a 
+seperate process.
+
+TODO:
+- Make sure 'File -> Open...' actually works
+
+XXX:
+- save preferences in the favorites db (for databases that are in in there)?
+"""
+
+import objc; objc.setVerbose(1); del objc # XXX: Debugging only
+
+from Foundation import *
+from AppKit import *
+import objc
+import threading
+
+from PyObjCTools import NibClassBuilder
+from PyObjCTools import AppHelper
+
+import sys
+import pimp
+import webbrowser
+
+# File type for packman databases
+DB_FILE_TYPE="Python Package Database"
+
+# Extract class information from the NIB files
+# - MainMenu: Global application stuff
+# - OpenPanel: The 'Open URL...' window
+# - PackageDatabase: Document window
+NibClassBuilder.extractClasses('MainMenu')
+NibClassBuilder.extractClasses('PackageDatabase')
+NibClassBuilder.extractClasses('OpenPanel')
+
+def setString(field, value):
+    """
+    Set an NSTextField to the specified value. Clears the field if 'value'
+    is None.
+    """
+    if value is None:
+        field.setStringValue_("")
+    else:
+        field.setStringValue_(value)
+
+
+##
+# We break the abstraction of some of the objects in the pimp module. That
+# is necessary because we cannot get at the required information using the
+# public interfaces :-(
+#
+def DB_DESCRIPTION(pimpDB):
+    return pimpDB._description
+
+def DB_MAINTAINER(pimpDB):
+    return pimpDB._maintainer
+
+def DB_URL(pimpDB):
+    return pimpDB._urllist[0]
+
+def PKG_HIDDEN(package):
+    """ Return True iff the package is a hidden package """
+    return (package._dict.get('Download-URL', None) is None)
+
+
+
+
+class PackageDatabase (NibClassBuilder.AutoBaseClass):
+    """
+    The document class for a package database
+    """
+
+
+    def init(self):
+        """
+        Initialize the document without a database
+        """
+
+        self = super(PackageDatabase, self).init()
+        if self is None: return None
+        self.pimp =  None
+        self._packages = []
+        return self
+
+
+    def initWithContentsOfFile_ofType_(self, path, type):
+        """
+        Open a local database.
+        """
+        self = self.init()
+        if self is None: return self
+
+        url = NSURL.fileURLWithPath_(path)
+
+        self.openDB(url.absoluteString())
+        return self
+
+    def __del__(self):
+        """ Clean up after ourselves """
+        if hasattr(self, 'timer'):
+            self.timer.invalidate()
+
+    def setDB(self, pimpURL, pimpDB):
+        self.pimp = pimpDB
+        self._packages = pimpDB.list()
+        if self.databaseName is not None:
+            self.databaseName.setStringValue_(DB_DESCRIPTION(self.pimp))
+            self.databaseMaintainer.setStringValue_(DB_MAINTAINER(self.pimp))
+
+        if self.packageTable is not None:
+            self.packageTable.reloadData()
+            self.tableViewSelectionDidChange_(None)
+
+        self.setFileName_(pimpURL)
+
+        if hasattr(self, 'timer'):
+            self.timer.invalidate()
+
+        self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
+                10.0,
+                self,
+                self.checkUpdates_,
+                None,
+                True)
+
+
+    def openDB(self, dbUrl=None):
+        """
+        Open a database at the specified URL
+        """
+        prefs = pimp.PimpPreferences()
+        if dbUrl is not None:
+            prefs.pimpDatabase = dbUrl
+        else:
+            prefs.pimpDatabase = pimp.DEFAULT_PIMPDATABASE
+
+        db = pimp.PimpDatabase(prefs)
+        db.appendURL(prefs.pimpDatabase)
+        self.setDB(dbUrl, db)
+
+    def checkUpdates_(self, sender):
+        """
+        Refresh the package information, the user may have installed or
+        removed a package. This method is called once in a while using a timer.
+        """
+        if self.packageTable is None: return
+
+        self.sortPackages()
+        self.packageTable.reloadData()
+
+    def windowNibName(self):
+        """ Return the name of the document NIB """
+        return 'PackageDatabase'
+
+    def displayName(self):
+        """ Return the document name for inside the window title """
+        if self.pimp is None:
+            return "Untitled"
+
+        return DB_URL(self.pimp)
+
+    def awakeFromNib(self):
+        """
+        Initialize the GUI now that the NIB has been loaded.
+        """
+        if self.pimp is not None:
+            self.databaseName.setStringValue_(DB_DESCRIPTION(self.pimp))
+            self.databaseMaintainer.setStringValue_(DB_MAINTAINER(self.pimp))
+
+        else:
+            self.databaseName.setStringValue_("")
+            self.databaseMaintainer.setStringValue_("")
+
+
+        self.setBoolFromDefaults(self.verbose, 'verbose')
+        self.setBoolFromDefaults(
+                self.installDependencies, 'installDependencies')
+        self.setBoolFromDefaults(self.showHidden, 'showHidden')
+        self.setBoolFromDefaults(self.overwrite, 'forceInstallation')
+       
+        b = NSUserDefaults.standardUserDefaults(
+                ).boolForKey_('installSystemWide')
+        if b:
+            self.installationLocation.setState_atRow_column_(NSOnState, 0, 0)
+        else:
+            self.installationLocation.setState_atRow_column_(NSOnState, 1, 0)
+
+        self.sortPackages()
+
+    def setBoolFromDefaults(self, field, name):
+        defaults = NSUserDefaults.standardUserDefaults()
+        b = defaults.boolForKey_(name)
+        if b:
+            field.setState_(NSOnState)
+        else:
+            field.setState_(NSOffState)
+
+    def saveBoolToDefaults(self, field, name):
+        defaults = NSUserDefaults.standardUserDefaults()
+        defaults.setBool_forKey_(field.state() == NSOnState, name)
+        defaults.synchronize()
+
+    def savePreferences_(self, sender):
+        self.saveBoolToDefaults(self.verbose, 'verbose')
+        self.saveBoolToDefaults(self.installDependencies, 'installDependencies')
+        self.saveBoolToDefaults(self.showHidden, 'showHidden')
+        self.saveBoolToDefaults(self.overwrite, 'forceInstallation')
+        self.saveBoolToDefaults(
+                self.installationLocation.cellAtRow_column_(0, 0), 
+                'installSystemWide')
+
+    def packages(self):
+        return self._packages
+
+    def selectedPackage(self):
+        row = self.packageTable.selectedRow()
+        if row == -1: return None
+
+        return self._packages[row]
+
+
+    def tableViewSelectionDidChange_(self, obj):
+        """
+        Update the detail view
+        """
+
+        package = self.selectedPackage()
+
+        if package is None:
+            # No selected package, clear the detail view
+            setString(self.itemHome, None)
+            setString(self.itemStatus, None)
+            setString(self.itemInstalled, None)
+            self.itemDescription.setString_("")
+            self.installButton.setEnabled_(False)
+
+        else:
+            # Update the detail view
+
+            setString(self.itemHome, package.homepage())
+
+            # XXX: Could we use ReST for the the description?
+            # Recognizing and 'activating' URL's would be fairly easy.
+            self.itemDescription.setString_(
+                    package.description()
+            )
+
+            status, msg = package.installed()
+            setString(self.itemInstalled, status)
+            setString(self.itemStatus, msg)
+            self.installButton.setEnabled_(True)
+
+    def addToFavorites_(self, sender):
+        appdel = NSApplication.sharedApplication().delegate()
+        appdel.addFavorite(self.pimp._description, self.pimp._urllist[0])
+
+    #
+    # NSTableDataSource implementation, for the package list
+    #
+
+    def numberOfRowsInTableView_(self, view):
+        if not hasattr(self, 'pimp') or self.pimp is None:
+            return 0
+
+        return len(self._packages)
+
+    def tableView_objectValueForTableColumn_row_(self, view, col, row):
+
+        colname = col.identifier()
+        package = self._packages[row]
+
+        if colname == 'installed':
+            # XXX: Nicer formatting
+            return getattr(package, colname)()[0]
+
+        return getattr(package, colname)()
+
+    def tableView_sortDescriptorsDidChange_(self, view, oldDescriptors):
+        self.sortPackages()
+
+
+
+
+    def sortPackages(self):
+        """
+        Sort the package list in the order wished for by the user.
+        """
+        if self.pimp is None:
+            return
+
+        if self.packageTable is None:
+            return
+
+        sortInfo = [
+            (item.key(), item.ascending(), item.selector())
+                for item in self.packageTable.sortDescriptors()
+        ]
+
+        if self.showHidden.state() == NSOnState:
+            self._packages = self.pimp.list()[:]
+        else:
+            self._packages = [ pkg
+                for pkg in self.pimp.list() if not PKG_HIDDEN(pkg) ]
+
+        if not sortInfo:
+            self.packageTable.reloadData()
+            self.tableViewSelectionDidChange_(None)
+            return
+
+        def cmpBySortInfo(l, r):
+            for key, ascending, meth in sortInfo:
+                if key == 'installed':
+                    l_val = getattr(l, key)()[0]
+                    r_val = getattr(r, key)()[0]
+                else:
+                    l_val = getattr(l, key)()
+                    r_val = getattr(r, key)()
+                if meth == 'compare:':
+                    res = cmp(l_val, r_val)
+                else:
+                    if isinstance(l_val, objc.pyobjc_unicode):
+                        l_val = l_val.nsstring()
+                    elif isinstance(l_val, (unicode, str)):
+                        l_val = NSString.stringWithString_(l_val).nsstring()
+                    res = getattr(l_val, meth)(r_val)
+
+                if not ascending:
+                    res = -res
+                if res != 0:
+                    return res
+
+            return 0
+
+        self._packages.sort(cmpBySortInfo)
+        self.packageTable.reloadData()
+
+    def filterPackages_(self, sender):
+        """
+        GUI action that is triggered when one of the view options
+        changes
+        """
+        self.sortPackages()
+
+    def visitHome_(self, sender):
+        """
+        Open the homepage of the currently selected package in the 
+        default webbrowser.
+        """
+        package = self.selectedPackage()
+        if package is None: 
+            return
+
+        home = package.homepage()
+        if home is None: 
+            return
+
+        try:
+            webbrowser.open(home)
+        except Exception, msg:
+            NSBeginAlertSheet(
+                    'Opening homepage failed',
+                    'OK', None, None, self.windowForSheet(), None, None, None,
+                    0, 'Could not open homepage: %s'%(msg,))
+
+
+    def installPackage_(self):
+        """
+        Install the currently selected package
+        """
+        package = self.selectedPackage()
+        if package is None: return
+
+        force = self.overwrite.state() == NSOnState
+        recursive = self.installDependencies.state() == NSOnState
+
+        pimpInstaller = pimp.PimpInstaller(self.pimp)
+        lst, messages = pimpInstaller.prepareInstall(package, force, recursive)
+
+        if messages:
+            NSBeginAlertSheet(
+                    'Cannot install packages',
+                    'OK', None, None,
+                    self.windowForSheet(), None, None, None, 0,
+                    '\n'.join(messages))
+            return
+
+        app = NSApplication.sharedApplication()
+        self.installationTitle.setStringValue_(
+                'Installing: %s ...'%(package.shortdescription(),))
+        self.installationProgress.setHidden_(False)
+        self.installationProgress.startAnimation_(self)
+        self.progressOK.setEnabled_(False)
+        ts = self.installationLog.textStorage()
+        ts.deleteCharactersInRange_((0, ts.length()))
+        app.beginSheet_modalForWindow_modalDelegate_didEndSelector_contextInfo_(
+                self.installationPanel,
+                self.windowForSheet(),
+                None, None, 0)
+
+        # I'm not sure if this accidental or not, but prepareInstall() returns
+        # a list of package in the order that they should be installed in,
+        # and install() installs them in the reverse order :-(
+        # XXX: This seems to be a bug in pimp.
+        self.runner = InstallerThread(
+                            self, 
+                            pimpInstaller,
+                            lst[::-1],
+                            self.verbose.state() == NSOnState,
+                            self.installationLog.textStorage()
+                        )
+
+        self.runner.start()
+
+    def closeProgress_(self, sender):
+        """
+        Close the installation progress sheet
+        """
+        self.installationPanel.close()
+        NSApplication.sharedApplication().endSheet_(self.installationPanel)
+
+    def installationDone_(self, sender):
+        """
+        The installer thread is ready, close the sheet.
+        """
+        self.progressOK.setEnabled_(True)
+        self.installationProgress.setHidden_(False)
+        self.installationProgress.stopAnimation_(self)
+
+        messages = self.runner.result
+        if messages:
+            ts = self.installationLog.textStorage()
+            ts.appendAttributedString_(
+                NSAttributedString.alloc().initWithString_attributes_(
+                    '\n\nCannot install packages\n\n',
+                    { 
+                        NSFontAttributeName: NSFont.boldSystemFontOfSize_(12),
+                    }
+                ))
+
+            ts.appendAttributedString_(
+                NSAttributedString.alloc().initWithString_(
+                    '\n'.join(messages) + '\n'))
+
+        self.packageTable.reloadData()
+        self.tableViewSelectionDidChange_(None)
+
+
+class DownloadThread (threading.Thread):
+    """
+    Thread for downloading a PackageManager database.
+
+    This is used by the application delegate to open databases.
+    """
+    daemon_thread = True
+
+    def __init__(self, master, document, url):
+        """
+        Initialize the thread.
+
+        master   - NSObject implementing dbReceived: and dbProblem:
+        document - An PackageDatabase
+        url      - The PackMan URL
+        """
+        threading.Thread.__init__(self)
+        self.master = master
+        self.document = document
+        self.url = url
+
+    def run(self):
+        """
+        Run the thread. This creates a new pimp.PimpDatabase, tells it to
+        download our database and then forwards the database to the 
+        master. The last step is done on the main thread because of Cocoa
+        threading issues.
+        """
+        pool = NSAutoreleasePool.alloc().init()
+
+        try:
+            prefs = pimp.PimpPreferences()
+            if self.url is not None:
+                prefs.pimpDatabase = self.url
+            else:
+                prefs.pimpDatabase = pimp.DEFAULT_PIMPDATABASE
+
+            db = pimp.PimpDatabase(prefs)
+            db.appendURL(prefs.pimpDatabase)
+
+            self.master.performSelectorOnMainThread_withObject_waitUntilDone_(
+                'dbReceived:', (self.document, self.url, db), False)
+
+        except:
+            self.master.performSelectorOnMainThread_withObject_waitUntilDone_(
+                'dbProblem:', (self.document, self.url, sys.exc_info()), False)
+            del pool
+            raise
+
+        del pool
+
+
+
+
+class InstallerThread (threading.Thread):
+    """
+    A thread for installing packages.
+
+    Like downloading a database, installing (and downloading!) packages is
+    a time-consuming task that is better done on a seperate thread.
+    """
+    daemon_thread = True
+
+    def __init__(self, document, installer, packages, verbose, textStorage):
+        threading.Thread.__init__(self)
+        self.document = document
+        self.installer = installer
+        self.packages = packages
+        self.verbose = verbose
+        self.textStorage = textStorage
+        self.result = None
+
+    def write(self, data):
+        self.textStorage.performSelectorOnMainThread_withObject_waitUntilDone_(
+                'appendAttributedString:', 
+                NSAttributedString.alloc().initWithString_(data),
+                False)
+
+    def run(self):
+        pool = NSAutoreleasePool.alloc().init()
+
+        if self.verbose:
+            result = self.installer.install(self.packages, self)
+        else:
+            result = self.installer.install(self.packages, None)
+
+        self.write('\nDone.\n')
+
+        self.document.performSelectorOnMainThread_withObject_waitUntilDone_(
+                'installationDone:', None, False)
+
+        del pool
+
+class URLOpener (NibClassBuilder.AutoBaseClass):
+    """
+    Model/controller for the 'File/Open URL...' panel
+    """
+
+    def __del__(self):
+        # XXX: I'm doing something wrong, this function is never called!
+        print "del URLOpener %#x"%(id(self),)
+
+
+    def awakeFromNib(self):
+        self.urlField.window().makeKeyAndOrderFront_(None)
+
+    def doOpenURL_(self, sender):
+        url = self.urlField.stringValue()
+        if not url:
+            return
+
+        # Ask the application delegate to open the selected database
+        NSApplication.sharedApplication().delegate().openDatabase(url)
+
+    def controlTextDidChange_(self, sender):
+        """
+        The value of the URL input field changed, enable the OK button
+        if there is input, disable it otherwise.
+        """
+        if self.urlField.stringValue() != "":
+            self.okButton.setEnabled_(True)
+        else:
+            self.okButton.setEnabled_(False)
+
+
+
+class PackageManager (NibClassBuilder.AutoBaseClass):
+    """
+    Application controller: application-level callbacks and actions
+    """
+
+    # XXX: Move favorite management to a seperate class.
+
+
+    #
+    # Standard actions
+    #
+
+    def awakeFromNib(self):
+        """
+        We've been restored from the NIB
+        """
+        self.loadFavorites()
+
+    #
+    # Working with favorites
+    #
+    # The favorites are stored in the user defaults for the application.
+
+    def loadFavorites(self):
+        """
+        Load our favorite database
+        """
+        self.favorites = NSUserDefaults.standardUserDefaults().arrayForKey_(
+                    'favorites')
+        if self.favorites is None:
+            self.favorites = []
+        else:
+            self.favorites = list(self.favorites)
+
+    def saveFavorites(self):
+        """
+        Save the favorites database, must be called whenever self.favorites
+        is changed.
+        """
+        defaults = NSUserDefaults.standardUserDefaults()
+        defaults.setObject_forKey_(
+                self.favorites,
+                'favorites')
+        defaults.synchronize()
+
+    def addFavorite(self, title, url):
+        """
+        Add a new favorite, and save the database
+        """
+        self.favorites.append({'title':title, 'URL':url})
+        self.favoritesTable.reloadData()
+        self.saveFavorites()
+
+    def menuNeedsUpdate_(self, menu):
+        """
+        We're the delegate for the Favorites menu
+
+        Update the menu: it should list the entries in the favorites database.
+        """
+        menuLen = menu.numberOfItems()
+
+        # Remove old items
+        for i in range(menuLen-1, 2, -1):
+            menu.removeItemAtIndex_(i)
+
+        # Insert new ones
+        for item in self.favorites:
+            title = item['title']
+            url = item['URL']
+
+            mi = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+                    title, self.openFavorite_, "")
+            mi.setTarget_(self)
+            mi.setRepresentedObject_(item)
+            menu.addItem_(mi)
+
+
+    def tableViewSelectionDidChange_(self, obj):
+        """
+        We're the delegate (and datasource) for the favorites list in the
+        edit pane for the favorites.
+
+        Update the input fields to show the current item.
+        """
+        row = self.favoritesTable.selectedRow()
+        if row == -1:
+            self.favoritesTitle.setStringValue_('')
+            self.favoritesURL.setStringValue_('')
+            self.favoritesTitle.setEnabled_(False)
+            self.favoritesURL.setEnabled_(False)
+        else:
+            self.favoritesTitle.setStringValue_(self.favorites[row]['title'])
+            self.favoritesURL.setStringValue_(self.favorites[row]['URL'])
+            self.favoritesTitle.setEnabled_(True)
+            self.favoritesURL.setEnabled_(True)
+
+    def numberOfRowsInTableView_(self, view):
+        """
+        We're the datasource for the favorites list in the Favorites panel
+        """
+        if not hasattr(self, 'favorites'):
+            return 0
+
+        return len(self.favorites)
+
+    def tableView_objectValueForTableColumn_row_(self, view, col, row):
+        """
+        We're the datasource for the favorites list in the Favorites panel
+        """
+        return self.favorites[row]['title']
+
+    def changeFavoritesTitle_(self, sender):
+        """
+        Update the title of the currently selected favorite item
+        """
+        row = self.favoritesTable.selectedRow()
+        if row == -1:
+            return
+
+        self.favorites[row]['title'] = self.favoritesTitle.stringValue()
+        self.saveFavorites()
+
+        self.favoritesTable.reloadData()
+
+
+    def changeFavoritesUrl_(self, sender):
+        """
+        Update the URL of the currently selected favorite item
+        """
+        row = self.favoritesTable.selectedRow()
+        if row == -1:
+            return
+
+        self.favorites[row]['URL'] = self.favoritesURL.stringValue()
+        self.saveFavorites()
+
+        self.favoritesTable.reloadData()
+
+    def openFavorite_(self, sender):
+        """
+        Open a favorite database (action for entries in the Favorites menu)
+        """
+        self.openDatabase(sender.representedObject()['URL'])
+
+
+    #
+    # Global actions/callbacks
+    #
+
+    def openDatabase(self, url):
+        """
+        Create a new NSDocument for the database at the specified URL.
+        """
+        doc = NSDocumentController.sharedDocumentController(
+                ).openUntitledDocumentOfType_display_(DB_FILE_TYPE, False)
+        try:
+            downloader = DownloadThread(self, doc, url)
+            downloader.start()
+        except:
+            doc.close()
+            raise
+
+    def dbReceived_(self, (doc, url, db)):
+        doc.setDB(url, db)
+        doc.showWindows()
+
+    def dbProblem_(self, (doc, url, exc_info)):
+        # TODO: Run an alert-panel
+        doc.close()
+
+
+
+    def openURL_(self, sender):
+        """
+        The user wants to open a package URL, show the user-interface.
+        """
+        res = NSBundle.loadNibNamed_owner_('OpenPanel', self)
+
+    def openStandardDatabase_(self, sender):
+        """
+        Open the standard database.
+        """
+        self.openDatabase(pimp.DEFAULT_PIMPDATABASE)
+
+    def applicationShouldOpenUntitledFile_(self, app):
+        """
+        The default window is not an untitled window, but the default
+        database
+        """
+        return False
+
+    def applicationDidFinishLaunching_(self, app):
+        """
+        The application finished launching, show the default database.
+        """
+        # XXX: We shouldn't open the standard database if the user explicitly
+        # opened another one!
+        self.openStandardDatabase_(None)
+
+#
+# Set some sensible defaults
+#
+NSUserDefaults.standardUserDefaults().registerDefaults_(
+        {
+          'verbose': True,
+          'installDependencies': True,
+          'showHidden': False,
+          'forceInstallation': False,
+          'installSystemWide': True,
+        })
+
+# 
+# A nasty hack. For some reason sys.prefix is /usr/bin/../../System/..., while
+# it is /System/... in Jack's PackageManager.app.  At least one package
+# manager database relies on sys.prefix being /System/... (Bob's additional
+# packages).
+#
+import os
+sys.prefix = os.path.abspath(sys.prefix)
+
+AppHelper.runEventLoop()
 
 An overview of the relevant changes in new, and older, releases.
 
+Version 1.1b2 (2004-?????)
+--------------------------
+
+- Add support for WebObjects 4.5 (a one-line patch!)
+
+- Add a PackageManager clone to the Examples directory
+
+
 Version 1.1b1 (2004-02-20)
 ---------------------------