Sunday, September 6, 2015

Using a webapp as a local application GUI

This is just a quick proof-of-concept for using a local webserver and a browser instance as a standalone GUI application. The idea is to use a browser and an embedded webserver as your UI toolkit, instead of hoping that the user was able to get qtbindings or shoes installed.


Http Application

The main application spawns two child processes: a local web server and a web browser instance.

In an ideal world, the application would wait for both processes to exit, and perhaps offer to restart one or the other in the event of a crash. Because of the way modern browsers work -- opening URLs in an existing process instead of starting a new one -- this has to be simplified to waiting only for the web server process to exit, and hoping that the browser process can successfully manage things.

The HttpApplication class provides a run() method that starts a webserver on localhost using the first available port, then opens a browser window/tab connected to the webserver:


class HttpApplication

  def run(host=nil, port=nil, browser=nil, path=nil)
    @server = ServerProcess.new(host, port, @controller)
    @server.start
    uri = server.uri.dup
    if path
      path = '/' + path if (! path.start_with? '/')
      uri.path = path
    end
    Browser.open(uri, browser)
    Process.waitpid(@server.pid)
  end
end

When the webserver process exits, the application will exit. Note that this means the webapp itself must close the webserver process, either through an explicit signout, or through a Javascript on_close or on_unload event handler.


ServerProcess

The webserver process is simply a Webrick instance with a servlet acting as a controller. This is pretty straightforward: the only interesting part is detecting the next available port so that it can be passed to the Webrick constructor.

require 'uri'
require 'webrick'
class ServerProcess
  attr_reader :host, :port, :controller, :pid, :uri, :webrick

  def initialize(host=nil, port=nil, controller=nil)
    @port = port || get_avail_port(host)
    @host = host || IPSocket.getaddress(Socket.gethostname)
    @controller = controller
  end
  def start
    @pid = Process.fork do
      @webrick = WEBrick::HTTPServer.new( :Port => @port )
      @webrick.mount('/', @controller) if @controller
      trap('HUP') { @webrick.stop; @webrick.start }
      trap('INT') { @webrick.shutdown }
      @webrick.start
    end

    trap('INT') { Process.kill 'INT', @pid }
    trap('TERM') { Process.kill 'INT', @pid }
    @uri = URI::HTTP.build( {:host => @host, :port => @port} )
    self
  end
  def stop
    @webrick.shutdown if @webrick
    Process.kill('INT', @pid) if @pid
  end


  private
  def get_avail_port(host)
    host ||= (Socket::gethostbyname('')||['localhost'])[0]
    inf = Socket::getaddrinfo(host, nil, Socket::AF_UNSPEC,
Socket::SOCK_STREAM, 0, Socket::AI_PASSIVE)
    fam = inf.inject({}) { |h, arr| h[arr[0]]= arr[2]; h }
    sock_host = fam['AF_INET'] || fam['AF_INET6']
    sock = sock_host ? TCPServer.open(sock_host, 0) : TCPServer.open(0)
    port = sock.addr[1]
    sock.close
    port
  end
end


Browser

The browser process  is a simple launcher that opens a URL in a browser instance , in what is hopefully a platform-independent manner. One could use the launchy gem or some other suitably over-engineered solution, but there's really not much need to support more than Windows, OS X, and Linux. Let's face it, anyone who doesn't have xdg-open installed probably doesn't want to run your webserver-based app on their ideologically-pure system.

require 'shellwords'
class Browser
  if RUBY_PLATFORM =~ /darwin/
    URL_OPEN_COMMAND = 'open'
  elsif RUBY_PLATFORM =~ /linux/ or RUBY_PLATFORM =~ /bsd/
    URL_OPEN_COMMAND = 'xdg-open'

  else
    # assume windows
    URL_OPEN_COMMAND = 'start'
  end
  def self.open(uri, cmd=nil)
    pid = Process.fork do
      uri_s = Shellwords.shellescape uri.to_s
      `#{Shellwords.shellescape(cmd || URL_OPEN_COMMAND)} #{uri_s}`
    end
  
    Process.detach(pid)
  end
end


RestContoller

The meat of the UI is in the controller object which HttpApplication uses to handle webserver routes. This particular one is based on Webrick's AbstractServlet class, and uses regex patterns to determine the handler for a particular route.

Each route consists of a pattern and a block. The pattern is used to match the URL; for best results, the ^ and $ anchors should be used, and the URL path should be absolute (i.e., starting with '/').

The block receives three arguments: query, request, and server. The query is a Hash extracted from the Webrick HTTPRequest object, the request is the complete HTTPRequest object, and the server is the Webrick HTTPServer object for the webserver (used mainly to shutdown the server). The block must return either the response body or an array [body, content_type, status]; these return values will be written to the Webrick HTTPResponse object.

class RestController < WEBrick::HTTPServlet::AbstractServlet
  DEFAULT_STATUS = 200
  DEFAULT_CONTENT_TYPE = 'text/html'
  DEFAULT_BODY = '404 : not found' # this should really be html

  @routes = []
  def self.route(pat, &block)
    @routes << [pat, Proc.new(&block)]
  end
  def self.routes; @routes; end
  def initialize(server)
    @webrick = server
    super server
  end
  def fill_response(response, bodycontent_type=DEFAULT_CONTENT_TYPEstatus=DEFAULT_STATUS)
    response.status = status
    response['Content-Type'] = content_type
    response.body = body
    response
  end
  def route_request(request)
    content_type = DEFAULT_CONTENT_TYPE
    status = 404
    body = DEFAULT_BODY
    self.class.routes.each do |pat, proc_obj|
      if request.path =~ pat
        arr = [ proc_obj.call(request.query, request, @webrick) ].flatten
        body = arr[0]
        content_type = arr[1] || DEFAULT_CONTENT_TYPE
        status = arr[2] || DEFAULT_STATUS
        break
      end
    end
    [body, content_type, status]
  end
  def do_GET(request, response)
    body, content_type, status = route_request(request)
    fill_response(response, body, content_type, status)
  end
  def do_POST(request, response)
    body, content_type, status = route_request(request)
    fill_response(response, body, content_type, status)
  end
end


Example

Here's a quick example that defines a few handlers.

def create_rest_controller
  cls = RestController

  # /test
  cls.route(/^\/test$/) { |q,r| ["test", "text/plain"] }
  #/quit
  cls.route(/^\/quit$/) { |q,r,s| s.shutdown; ["shutting down", "text/plain"] }

 

 #/
  cls.route(/^\/$/) { |q,r,s| s.shutdown; ["root!""text/plain"] }
  cls
end

app = HttpApplication.new( create_rest_controller )
app.run

The content returned by the routes is all plaintext because hey, the blogger interface sucks for entering < and  > characters.

Note the /quit handler: this is necessary to shutdown the webserver process (otherwise it will continue to run in the background). If you are certain your users permit Javascript, you can have an event handler hit this URL when the user closes the browser tab/window.

An alternative is to have the web server manage a PID file, so that subsequent invocations of the application will connect to the webserver process running in the background (much like the browsers that forced us into this mess).

As usual, the code is available on github.

No comments:

Post a Comment