WebViews en el escritorio


La programación en móviles ha conseguido poner de moda las WebViews. Esta técnica consiste en abrir un navegador y cargar la aplicación como si fuera una página web, embebida dentro del resto de la aplicación.

La primera vez que vi esta técnica, sin embargo, no fue en móvil, sino el cliente de Steam, que se ejecutaba bajo Windows (ahora ya también está disponible para GNU/Linux). Desde entonces me picó la curiosidad sobre cómo se haría... y aquí lo tenemos.

Para los ejemplos usaré Python y GTK, aprovechando el post sobre micro-framework web anterior.

La idea

El diseño es sencillo: Necesitamos al menos dos hilos, uno para gestionar el servidor web y otro para la interfaz. Dado que la interfaz debe estar en el hilo principal por necesidades de GTK, es lógico pensar que lo fácil es lanzar el servidor web en segundo plano. Por eso aquí está el código principal, que es lo más importante de todo:

def finish(ignorable=None):
    gtk.main_quit()

def main():
    signal.signal(signal.SIGINT, finish)
    gtk.gdk.threads_init()

    web = WebServer()
    web.run()

    uri = 'http://localhost:{}'.format(web.port)
    print 'serving at', web.port
    print 'connecting to ', uri
    run_gui(uri)

    gtk.mainloop()

if __name__ == '__main__':
    main()

Como vemos, lo primero es inicializar gtk y permitir que un CTRL+C finalice la aplicación. Inmediatamente después, se arranca el servidor web que, como dije, se ejecutará en segundo plano. Ahora iremos con él. A continuación lanzamos la GUI indicándole el puerto donde está escuchando el servidor web.

A partir de este punto tendremos dos aplicaciones: El servidor web y la GUI, comunicándose por HTTP.

El servidor web

El servidor web está basado en el micro-framework web que ya comenté:

import sys
import re
import httplib
import shutil
from StringIO import StringIO
from urlparse import urlparse, parse_qs
import thread

from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler


class Handler(object):
    def __init__(self, request):
        self.request = request
        self._output = StringIO()
        self.write = self._output.write

    def finish(self):
        length = self._output.tell()
        self._output.seek(0)
        self.request.send_response(httplib.OK)
        encoding = sys.getfilesystemencoding()
        self.request.send_header("Content-type", "text/html; charset=%s" % encoding)
        self.request.send_header("Content-Length", str(length))
        self.request.end_headers()
        shutil.copyfileobj(self._output, self.request.wfile)
        self._output.close()

    def get(self, **kwargs):
        pass


class MainHandler(Handler):
    def get(self):
        self.write(
            '<html>'
            '  <body>'
            '    <form action="/greet" method="GET">'
            '      <input name="name" placeholder="Your name"/>'
            '      <input type="submit" value="Greet"/>'
            '    </form>'
            '  </body>'
            '</html>'
        )


class GreetingsHandler(Handler):
    def get(self, **kwargs):
        self.write('<h1>Cheers, {name[0]}!</h1><br/><br/><a href="/">Go back</a>'.format(**kwargs))


class HTTPRequestHandler(BaseHTTPRequestHandler):
    HANDLERS = [
        (re.compile('/greet'), GreetingsHandler),
        (re.compile('/'), MainHandler),
        ]

    def do_GET(self):
        url = urlparse(self.path)

        for regex, clazz in self.HANDLERS:
            m = regex.match(url.path)
            if m:
                params = parse_qs(url.query, True)

                handler = clazz(self)
                handler.get(**params)
                return handler.finish()
        self.send_error(httplib.NOT_FOUND)


class WebServer(object):
    def __init__(self):
        address = ('', 0)
        self._httpd = HTTPServer(address, HTTPRequestHandler)

    @property
    def port(self):
        return self._httpd.server_address[1]

    def run(self):
        thread.start_new_thread(self.__background_run, ())

    def __background_run(self):
        self._httpd.serve_forever()

Los que hayáis leído el artículo sobre micro-framework web veréis aquí algunas diferencias:

  • El método get recibe como parámetros los parámetros indicados en la url. Así, la url "/greet?name=MagMax" recibirá como argumentos "name = 'MagMax'".
  • El objeto WebServer tiene un método run que ejecuta el servidor en background. Eso es algo que necesito para que el GUI esté en primer plano.

No creo que resulte difícil entender el código.

El HTML que se muestra es muy cutre... Perdonad, pero no quería ensuciar el código con más HTML y he pretendido que se parezca lo máximo posible al del artículo micro-framework web.

La GUI

Ahora vamos con la interfaz gráfica. Uno de los problemas aquí es ver qué motor se va a usar. Podemos elegir Gecko o bien WebKit. La mejor opción es soportar ambos:

try:
    import webkit
except:
    webkit = False
try:
    import gtkmozembed as gecko
except:
    gecko = False

if not webkit and not gecko:
    raise Exception('Failed to load both webkit and gecko modules')


class WebKitDriver(object):
    def create_browser(self):
        return webkit.WebView()

    def open_uri(self, browser, uri):
        browser.open(uri)


class GeckoDriver(object):
    def create_browser(self):
        return gecko.MozEmbed()

    def open_uri(self, browser, uri):
        browser.load_url(uri)


def run_gui(uri, echo=True):
    window = gtk.Window()
    driver = WebKitDriver() if webkit else GeckoDriver()
    browser = driver.create_browser()

    box = gtk.VBox(homogeneous=False, spacing=0)
    window.add(box)

    box.pack_start(browser, expand=True, fill=True, padding=0)

    window.connect('destroy', finish)
    window.set_default_size(800, 600)
    window.show_all()

    driver.open_uri(browser, uri)

Lo primero es tratar de cargar uno de los dos motores. Aquí se usa un patrón típico en python que consiste en crear una variable nula con el nombre del módulo cuando éste no existe. Si no se pudo cargar ninguno, se lanza un error.

Tenemos dos clases con la misma interfaz, aunque no he creado la interfaz explícitamente, sino que he hecho uso del "duck typing". Éstas permiten crear el objeto GTK con el browser y abrir una URL. Así que lo primero que se hace es crear el "driver", que oculta la implementación usada. Y así basta con crear un objeto básico y meterle el browser.

Para terminar preparamos la finalización y lo mostramos. Y, finalmente, mostramos la página con nuestra aplicación.

Todo el código

Ale, aquí tenéis el código todo junto. Yo lo dividiría en, al menos, 2 archivos, pero lo he puesto junto para que os sea más sencillo probarlo si queréis. De todas maneras he puesto comentarios donde yo dividiría, separando también los imports.

Necesitaréis el paquete debian python-webkit, por ejemplo, así como python-gtk2:

#!/usr/bin/env python

# file webserver.py
import sys
import re
import httplib
import shutil
from StringIO import StringIO
from urlparse import urlparse, parse_qs
import thread

from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler


class Handler(object):
    def __init__(self, request):
        self.request = request
        self._output = StringIO()
        self.write = self._output.write

    def finish(self):
        length = self._output.tell()
        self._output.seek(0)
        self.request.send_response(httplib.OK)
        encoding = sys.getfilesystemencoding()
        self.request.send_header("Content-type", "text/html; charset=%s" % encoding)
        self.request.send_header("Content-Length", str(length))
        self.request.end_headers()
        shutil.copyfileobj(self._output, self.request.wfile)
        self._output.close()

    def get(self, **kwargs):
        pass


class MainHandler(Handler):
    def get(self):
        self.write(
            '<html>'
            '  <body>'
            '    <form action="/greet" method="GET">'
            '      <input name="name" placeholder="Your name"/>'
            '      <input type="submit" value="Greet"/>'
            '    </form>'
            '  </body>'
            '</html>'
        )


class GreetingsHandler(Handler):
    def get(self, **kwargs):
        self.write('<h1>Cheers, {name[0]}!</h1><br/><br/><a href="/">Go back</a>'.format(**kwargs))


class HTTPRequestHandler(BaseHTTPRequestHandler):
    HANDLERS = [
        (re.compile('/greet'), GreetingsHandler),
        (re.compile('/'), MainHandler),
        ]

    def do_GET(self):
        url = urlparse(self.path)

        for regex, clazz in self.HANDLERS:
            m = regex.match(url.path)
            if m:
                params = parse_qs(url.query, True)

                handler = clazz(self)
                handler.get(**params)
                return handler.finish()
        self.send_error(httplib.NOT_FOUND)


class WebServer(object):
    def __init__(self):
        address = ('', 0)
        self._httpd = HTTPServer(address, HTTPRequestHandler)

    @property
    def port(self):
        return self._httpd.server_address[1]

    def run(self):
        thread.start_new_thread(self.__background_run, ())

    def __background_run(self):
        self._httpd.serve_forever()


# File gui.py
import signal
import gtk
from webserver import WebServer

try:
    import webkit
except:
    webkit = False
try:
    import gtkmozembed as gecko
except:
    gecko = False

if not webkit and not gecko:
    raise Exception('Failed to load both webkit and gecko modules')


class WebKitDriver(object):
    def create_browser(self):
        return webkit.WebView()

    def open_uri(self, browser, uri):
        browser.open(uri)


class GeckoDriver(object):
    def create_browser(self):
        return gecko.MozEmbed()

    def open_uri(self, browser, uri):
        browser.load_url(uri)


def run_gui(uri, echo=True):
    window = gtk.Window()
    driver = WebKitDriver() if webkit else GeckoDriver()
    browser = driver.create_browser()

    box = gtk.VBox(homogeneous=False, spacing=0)
    window.add(box)

    box.pack_start(browser, expand=True, fill=True, padding=0)

    window.connect('destroy', finish)
    window.set_default_size(800, 600)
    window.show_all()

    driver.open_uri(browser, uri)


def finish(ignorable=None):
    gtk.main_quit()


def main():
    signal.signal(signal.SIGINT, finish)
    gtk.gdk.threads_init()

    web = WebServer()
    web.run()

    uri = 'http://localhost:{}'.format(web.port)
    print 'serving at', web.port
    print 'connecting to ', uri
    run_gui(uri)

    gtk.mainloop()


if __name__ == '__main__':
    main()

Depuración y testing

Resulta bastante complejo depurar el código JavaScript en la aplicación. Pero recordemos que tenemos una página web, así que podemos abrir un navegador en el puerto que se haya seleccionado y depurar ahí. Se depurará igual que cualquier aplicación web normal.

Es más: Se pueden ejecutar regresiones de tests sobre la página web o utilizar sistemas típicos como WebOb o cualquier otra librería de testing.

Para tests de aceptación, se podría proporcionar un argumento que permita seleccionar un puerto, guardarlo en un archivo o bien procesar la salida. Para los tests de JavaScript también pueden utilizarse los sistemas habituales.

Ventajas e inconvenientes

Manejar aplicaciones de esta manera tiene numerosas ventajas e inconvenientes. La mayor de las ventajas es que no hace falta aprender a usar una librería de GUI tan compleja como pueda ser GTK o QT. Estas librerías tienen sus propios componentes, sus sistemas de eventos, etc. Que hacen que no sea nada trivial cambiar de una a otra.

Además, a menudo resulta tedioso portar los programas creados con estas librerías a sistemas operativos como Windows. Es cierto que lo aquí expuesto se basa en usarlas como base, pero en Windows se podría hacer una aplicación mínima nativa que lance el servidor web y el browser, y a partir de ese momento todo sería compatible.

Evidentemente, resultará muy sencillo transformar la aplicación de escritorio en una página web, ya que es una página web. Como tal, además, podríamos utilizar recursos que se encuentren en la Web, aunque con esto perderíamos autonomía.

Hay quien pueda pensar que el mayor inconveniente es la seguridad, ya que se está compartiendo en un puerto de nuestra máquina. Este problema es fácil de solucionar, añadiendo un filtro en el método do_GET de manera que sólo se admita localhost.

Sin embargo, para mí el mayor problema es la comunicación Servidor-Cliente. ¿Qué ocurre cuando queremos mostrar algo que ha ocurrido en el servidor, en segundo plano? Existen dos soluciones, igual que en web:

  • Utilizar polling, de manera que el cliente pregunte periódicamente al servidor.
  • Utilizar websockets, lo que implica complicar un poquito el cliente y el servidor, además de tener otra conexión TCP abierta.

No hay que olvidar los recursos necesarios para ejecutar este tipo de aplicaciones, ya que estamos abriendo un navegador Web para algo que puede ser bastante "pequeño".

Finalmente, las aplicaciones no son Python, sino que serán principalmente JavaScript. Esto puede verse como una ventaja, ya que se puede cambiar el servidor hasta de lenguaje sin tocar el cliente, consiguiendo así tener claramente diferenciadas las capas de visualización y negocio.

Algo que puede verse como una ventaja y una desventaja es que las aplicaciones no dependerán del estilo de ventanas propio del sistema operativo. El diseño de la aplicación será algo propio, de la misma manera que cada página web tiene su propio diseño y no se integra con el escritorio.

Para gustos los colores

Una vez más, en informática no exite la mejor solución. Este tipo de aplicaciones pueden ser geniales en algunos casos, pero en otros ser un auténtico tormento. Habrá que decidir en cada caso cuál es la mejor herramienta para obtener el resultado deseado.

Pero eso es justo lo que pretendo: Para proporcionar mayor número de herramientas para que nos sea más sencillo resolver problemas más adelante.


Comentarios

Comments powered by Disqus