Python XPCOM and Hulahop

Python XPCOM and Hulahop: XULrunner from a declarative perspective

XULrunner, the technology behind projects such as Firefox, is both powerful and obscure. Even getting started with XULrunner is tricky, and even more so from dynamic languages such as python. pyxpcomext addresses these issues, and does so from the perspective where the developer creates a "bundle" which is registered with XULrunner. XULrunner is then started by the user, and the user opens a magic URL which triggers loading of the pyxpcomext-based application.

Thanks to the OLPC Sugar team, there is now another way, starting from the python prompt. "import hulahop" is where it begins. This article will show and explain the voodoo magic incantations necessary to bring up a window where you can begin to gain access to the DOM model of the XULrunner technology. In this way, you can begin to use technology which was designed for web browsers but has become something much much more powerful than originally intended by its designers.

Python Hulahop

XULrunner the application is stand-alone, and is responsible for bringing up a window, in which the web application is displayed. pyxpcomext provides the linkage between XULrunner and python. Python Hulahop does something similar, but starts from a different angle. Hulahop joins a standard GTK window to the technology behind XULrunner, thus requiring the python developer to use "import hulahop", to create a hulahop WebView and to add it to a GTK app just as you would any other GTK widget, and to use "import xpcom" to then gain access to the WebView's interfaces.

This is a completely different perspective, which allows the developer to embed XULrunner into GTK applications as "just another widget". This article will therefore first cover the "startup" phase, and then go on to describe how to interact with the resultant widget, including being able to respond to DOM events (with python callbacks). Future articles will cover more complex topics such as how to use XMLHttpRequest from python.

Diving in

First, two files must be created - hula.py and progresslistener.py. hula.py is adapted from the OLPC "web browser", and progresslistener.py is copied verbatim from the same project.

Here is the hula.py file:

# Copyright (C) 2006, Red Hat, Inc.
# Copyright (C) 2007, One Laptop Per Child
# Copyright (C) 2009, Luke Kenneth Casson Leighton <lkcl@lkcl.net>
#
# 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

import os
import hulahop

#from sugar import env
#hulahop.startup(os.path.join(env.get_profile_path(), 'gecko'))
# this is equivalent to ~/.firefox profiles subdirectory
hulahop.startup('/home/lkcl/test')

from hulahop.webview import WebView

import gtk
import gobject
import xpcom

from xpcom.nsError import *
from xpcom import components
from xpcom.components import interfaces

from progresslistener import ProgressListener


class ContentInvoker:
    _com_interfaces_ = interfaces.nsIDOMEventListener

    def __init__(self, browser):
        self._browser = browser

    def handleEvent(self, event):
        print event.type, event.button

        target = event.target
        print "target:", target.tagName.lower()

class Browser(WebView):
    def __init__(self):
        WebView.__init__(self)
        self.progress = ProgressListener()

        io_service_class = components.classes[ \
        "@mozilla.org/network/io-service;1"]
        io_service = io_service_class.getService(interfaces.nsIIOService)

        # Use xpcom to turn off "offline mode" detection, which disables
        # access to localhost for no good reason.  (Trac #6250.)
        io_service2 = io_service_class.getService(interfaces.nsIIOService2)
        io_service2.manageOfflineStatus = False

        self.progress.connect('loading-stop', self._loaded)
        self.progress.connect('loading-progress', self._loading)

    def do_setup(self):
        WebView.do_setup(self)
        self.progress.setup(self)
        
        listener = xpcom.server.WrapObject(ContentInvoker(self),
                                            interfaces.nsIDOMEventListener)
        self.window_root.addEventListener('click', listener, False)

    def _loaded(self, progress_listener):
        """ once document is loaded, we can now "do" stuff.
            until the document is "loaded", it's unsafe to go
            messing with the DOM, because stuff either isn't
            initialised properly or, more importantly, the
            loaded document will overwrite anything that's
            been done up to that point!
        """
        doc = self.get_dom_window().document

        body = doc.createElement("body")
        n = doc.createTextNode("hello")

        doc.body = body
        body.appendChild(n)

        r = body.getBoundingClientRect()
        print "body rect", r.bottom, r.top, r.left, r.right

        body.style.setProperty('background-color', '#00ff00', None)

    def _loading(self, progress_listener, progress):
        print "loading", progress
      

win = gtk.Window(gtk.WINDOW_TOPLEVEL)
win.set_size_request(800,600)
win.connect('destroy', gtk.main_quit)

wv = Browser()
wv.show()
win.add(wv)
win.show()

# you have to create a blank document, to trigger a loading callback,
# unfortunately.
f = open("/tmp/blank.html", "w")
f.write("")
f.close()

# load the blank document.
wv.load_uri('file:///tmp/blank.html')

# start gtk
gtk.main()

Briefly, then: the equivalent of firefox profile directory is set up; a ContentListener class is declared, which is used to wrap event handling; the main "Browser" class is declared (in which the key point of interest is the _loaded function); the rest is GTK and startup code.

Here is the progresslistener.py file:

# Copyright (C) 2006, Red Hat, Inc.
# Copyright (C) 2007, One Laptop Per Child
#
# 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

import gobject
import xpcom
from xpcom.components import interfaces

class ProgressListener(gobject.GObject):
    _com_interfaces_ = interfaces.nsIWebProgressListener

    __gsignals__ = {
        'location-changed': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
                             ([object])),
        'loading-start':    (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
                             ([])),
        'loading-stop':     (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
                             ([])),
        'loading-progress': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
                             ([float]))
    }

    def __init__(self):
        gobject.GObject.__init__(self)

        self.total_requests = 0
        self.completed_requests = 0

        self._wrapped_self = xpcom.server.WrapObject( \
                self, interfaces.nsIWebProgressListener)
        weak_ref = xpcom.client.WeakReference(self._wrapped_self)

        self._reset_requests_count()

    def setup(self, browser):
        mask = interfaces.nsIWebProgress.NOTIFY_STATE_NETWORK | \
               interfaces.nsIWebProgress.NOTIFY_STATE_REQUEST | \
               interfaces.nsIWebProgress.NOTIFY_LOCATION

        browser.web_progress.addProgressListener(self._wrapped_self, mask)
    
    def _reset_requests_count(self):
        self.total_requests = 0
        self.completed_requests = 0
    
    def onLocationChange(self, webProgress, request, location):
        self.emit('location-changed', location)
        
    def onProgressChange(self, webProgress, request, curSelfProgress,
                         maxSelfProgress, curTotalProgress, maxTotalProgress):
        pass
    
    def onSecurityChange(self, webProgress, request, state):
        pass
        
    def onStateChange(self, webProgress, request, stateFlags, status):
        if stateFlags & interfaces.nsIWebProgressListener.STATE_IS_REQUEST:
            if stateFlags & interfaces.nsIWebProgressListener.STATE_START:
                self.total_requests += 1
            elif stateFlags & interfaces.nsIWebProgressListener.STATE_STOP:
                self.completed_requests += 1

        if stateFlags & interfaces.nsIWebProgressListener.STATE_IS_NETWORK:
            if stateFlags & interfaces.nsIWebProgressListener.STATE_START:
                self.emit('loading-start')
                self._reset_requests_count()                
            elif stateFlags & interfaces.nsIWebProgressListener.STATE_STOP:
                self.emit('loading-stop')

        if self.total_requests < self.completed_requests:
            self.emit('loading-progress', 1.0)
        elif self.total_requests > 0:
            self.emit('loading-progress', float(self.completed_requests) /
                                          float(self.total_requests))
        else:
            self.emit('loading-progress', 0.0)

    def onStatusChange(self, webProgress, request, status, message):
        pass

Briefly: the OLPC team have linked python-gobject and the xpcom "nsIWebProgressListener" interface together, so that gobject callbacks to python functions can occur when xpcom progress events occur.

What happens?

Firstly, and crucially, the equivalent of the firefox profiles subdirectory is set up, if it doesn't exist:

hulahop.startup('/home/lkcl/test')

In this subdirectory, you will find that prefs.js, xpti.dat, pluginreg.dat, cert8.db and many other XULrunner / Gecko essential files are created, along with a Cache subdirectory.

Next, a GTK window is created, of size 800 by 600, and a Browser instance is created (Browser is derived from hulahop.WebView). Note also that a progress listener instance is created:

    self.progress = ProgressListener()

    self.progress.connect('loading-stop', self._loaded)
    self.progress.connect('loading-progress', self._loading)

It's thanks to ProgressListener, which wraps the nsiWebProgressListener interface, that the Browser._loaded and Browser._loading functions will be called (through gobject signals) as the page load progresses and finishes. Of course, it's necessary to load a page, but that will be covered shortly. Before then, it's important to note the Browser.do_setup function:

    WebView.do_setup(self)
    self.progress.setup(self)
    
    listener = xpcom.server.WrapObject(ContentInvoker(self),
                                        interfaces.nsIDOMEventListener)
    self.window_root.addEventListener('click', listener, False)

The OLPC team created a convention whereby WebView.do_setup will be called at an appropriate time (presumably after XULrunner interfaces are initialised properly, which is something that has to be taken into consideration). We take advantage of this to set up the WebView itself, and the progress listener, and then also to add an Event Listener to window. As can be seen, this is the voodoo-magic incantation that is equivalent to "window.onclick = function() {...}" in javascript.

It is absolutely crucial to note the use of the ContentInvoker class, here. There are several conventions which the various XULrunner interfaces expect. In the case of nsIDOMEventListener, the class instance that is wrapped using xpcom.server.WrapObject is expected to have a method "handleEvent". In the case of nsIWebProgressListener, the class instance that is wrapped is expected to have functions onLocationChange, onProgressChange, onStateChange etc. even if those functions are not used (hence, in progresslistener.py, the use of "pass" to ensure that runtime exceptions do not occur when the XULrunner engine cannot find the callbacks it expects).

Now we get to trigger the Browser._loading function, by creating a dummy web page containing no content:

f = open("/tmp/blank.html", "w")
f.write("")
f.close()

# load the blank document.
wv.load_uri('file:///tmp/blank.html')

It's worth noting here that the use of a blank document is entirely deliberate for demonstration purposes, but that literally any content, and any valid URL, could be given here. Absolutely anything, including a remote HTML page, another XUL application (with a chrome: URI), is perfectly acceptable. All we care about, however, is that the page successfully loads, and Browser._loaded gets called.

Finally, we get to do something!

After all that voodoo incantation magic, we can get at the browser document instance, confident that it will not "disappear" out from under us, and confident that all the XULrunner interfaces have been initialised and will be valid. Now it's possible, in Browser._loaded, to do something:

    doc = self.get_dom_window().document

    body = doc.createElement("body")
    n = doc.createTextNode("hello")

    doc.body = body
    body.appendChild(n)

    r = body.getBoundingClientRect()
    print "body rect", r.bottom, r.top, r.left, r.right

    body.style.setProperty('background-color', '#00ff00', None)

None of these functions and properties should come as a surprise: they should in fact look eerily familar to anyone who has done javascript programming. However, we're no longer in Kansas: this is python! Now it can be seen clearly why a blank document was loaded, so as to be able to demonstrate the use of nsIDOMDocument's createElement function to create a <body> element, and place it into the document. A text node is also created, with the word "hello" in it, and this is added to the body element using appendChild. For demonstration purposes, getBoundingRectClient is called, in order to print (on the standard console) the outer bounds of the body element. Finally: instead of having a separate CSS stylesheet, the body's "background-color" property is modified to an eye-glaring green.

Not a single bit of javascript is involved.

Window Event Handling

Lastly - there's just one more trick that's easy to miss: the nsIDOMWindow click handler that was created in Browser.do_setup(). When running the application, click anywhere in the window. On the standard console, there should be some output: "click 0" and "html" should typically be observed, indicating that the left button was used and that the main content was clicked. Clicking on the word "hello" should show the word "body" instead. Examine ContentInvoker.handleEvent to find out why:

    def handleEvent(self, event):
        print event.type, event.button

        target = event.target
        print "target:", target.tagName.lower()

So, it can be seen that the actual event can be accessed, just as it can as if this was javascript, not python. Note also that because of the ContentInvoker class instance, it's possible to gain access to the Browser window (through self._browser), so there is absolutely no excuse for creating global variables: it's completely unnecessary to do that. If more state information is required in the event handler, adapt ContentInvoker and add more arguments to its constructor, passing them in when the listener xpcom wrapper is created.

Conclusion

Through a little bit of voodoo magic, thanks to the OLPC Sugar team's efforts, a declarative style of python web programming is now possible. As Hulahop is also a GTK Widget as well as a XULrunner / Gecko Engine, it's possible to embed Gecko into standard python gtk2 applications and still manipulate and interact with the DOM model. Adding <embed<>> nodes to the DOM, on-demand, to fire up NPAPI plugins such as Flash and Java plugins is perfectly within the realms of possibility.

Taking DOM model manipulation to its fullest logical and inevitable conclusion, an entire new python GUI Widget Set API can be created, utilising absolutely every single feature that is available in the DOM of web browser engine technology. The result is Pyjamas Desktop

So, for the curious: here in Pyjamas Desktop is a more sophisticated version of hula.py (progresslistener.py is still the same), and its cousin, pywebkitgtk.py. These two, hula.py and pywebkitgtk.py, are slowly being made to be identical, so that declarative applications can be created which will have exactly the same underlying python API as far as developers are concerned, and the developer or even the user can choose, at runtime, whether to pick XULrunner or WebKit, with the application needing absolutely no modifications whatsoever.