8 May 2008
Opening a URL in a Viewer from Ruby

While web apps are all the rage, what with the browser being ubiquitous and everything, there are still some features that don’t scale - usually stuff that requires a lot of high-bandwidth data getting manipulated and displayed. Sorry, but interactive data-heavy apps just don’t yet run well enough on the web to make the jump. For other things, yes; but not for these.

Unfortunately, applications based on these sorts of features are the ones I’ve spent a lot of time writing. They want to be web-enabled, not web-based. It’s one thing to use the web as a communications medium, but another to use it as an interaction medium.

So, I got Lyle Johnson’s book FXRuby: Create Lean and Mean GUIs with Ruby. I’ve been messing with FXRuby lately. Quite a nice little package. I’m moving some big-application stuff into it and may have more to say about it in the upcoming weeks and months. One of the parts I just moved in deals with opening html documents and URLs. A long time ago I decided to write all of my help documents in html and use the browser for displaying them. When the user clicks a help button in an app (or something like that) I need to bring up the requested page in a browser.

Opening a URL in a browser seems like it should be almost trivial, but from a Ruby app there is a little art to it. Different vendors have different mechanisms to do it, and you have to make it work right so the users of your apps don’t get flustered. So I hide the vendor-specific logic under a general method:

def open url
  send "open_#{Config::CONFIG['target_vendor']}".to_sym, url
end

This dispatcher will call the open for the specific vendor. I make the separation here, so I can handle each vendor cleanly no matter what sort of crazy stuff they may be doing.

Some vendors make it easy. Take Apple, for instance:

def open_apple url
  system "open #{url}"
end

Apple’s made opening a file in your viewer of choice a basic function of the OS. By hiding the details, it’s less work and higher productivity for me.

In Windows it takes more effort. Because the chosen viewer is buried in the registry, I have to dig it out:

require 'win32/registry'
def windows_browser
  Win32::Registry::HKEY_CLASSES_ROOT.
       open('htmlfile\shell\open\command') { |reg|
            reg_type, reg_value = reg.read('')
              return reg_value
  }
end

Now I can do the open:

def open_pc url
  system "#{windows_browser} #{url}"
end

On other systems (say Linux, for instance) there’s no direct solution. Because the chosen viewer is buried in desktop preferences it may be different for different window managers, and there’s no definitive way to know what it is and how to pull it out. So I opt for flexibility - I depend on an outsider to inject the name of the URL viewer into the mechanism prior to opening the url.

attr_accessor :url_viewer
def method_missing(method,*args)
  if method.to_s =~ /^open_/
    if @url_viewer
      system "#{@url_viewer} #{*args}"
    else
      raise DocumentException,
            "no URL Viewer was designated to open the URL."
    end
  end
end

This isn’t enough, however. The Kernel’s system method will block until the application opening the URL returns. I don’t want the application to stop and wait! In order for the app to stay in control, the open has to run in it’s own thread.

def open_document url
  Thread.new {
    send "open_#{Config::CONFIG['target_vendor']}".to_sym, url
  }
end

But there’s a problem. I’d like to catch the raised exceptions from the dispatched opens if something unexpected happens - but since the exceptions come from a new thread that I’m not waiting for, they’ll just go into the ether. I need to do this using another mechanism. So instead of raising, I’ll hypothecate an exception handling mechanism that the thread can use to notify the app that there was a problem during the open.

attr_accessor :exception_handler
def open_document url
  Thread.new {
    begin
      send "open_#{Config::CONFIG['target_vendor']}".to_sym, url
    rescue Exception => exception
      @exception_handler.handle exception
    end
  }
end

and we’ll let the caller pre-designate the exception handler. Though this isn’t quite as nice as rescuing in the caller, I’m not as concerned since the limited set of things that can fail when opening a url really come down to configuration issues or the absence of the URL’s target.

While this will open a URL in a viewer from a Ruby app, there’s a little more work needed. I want to ensure the URL is properly-formed enough not to choke the viewer. I’ll do this by normalizing before I do the open.

def open_document url
  Thread.new {
    begin
      send "open_#{Config::CONFIG['target_vendor']}".to_sym,
           normalize(url)
    rescue Exception => exception
      @exception_handler.handle exception
    end
  }
end

The normalizing is just a bit funky, but trivial in concept - just return a string containing the normalized URL. My top-level logic is: if it’s a file resource, then normalize it as a file; otherwise, validate it as a URI. Since Ruby already comes with a URI class, I just use it.

@@using_pc_filesystem = Config::CONFIG['target_vendor'] == "pc"

def normalize(url)
  (file_url? url) ? nomalize_file(url) : URI.parse(url).to_s
end

def file_url?
  (url =~ /^file:/) or
  (url =~ /^\//) or
  ((url =~ /^[A-Za-z]:/) and @@using_pc_filesystem) or
  !(url =~ /:/)
end

It’s a URL file resource if it starts with file:, a slash or a drive designator (on a pc) or it doesn’t have a colon in it.

Normalizing a file amounts to giving back the normalized file name with file:// prepended to it.

def normalize_file file_url
  path = normalize_file_path(
       (file_url =~ /file:\/\//) ? $' : file_url)
  "file://#{path}"
end

def normalize_file_path file_url
  if absolute_file_path? file_url
    file_url
  elsif @relative_base != nil
    "#{relative_base}#{file_url}"
  else
    raise UrlException, "no relative file base was configured"
  end
end

A file path is absolute if it starts with a drive designator and it’s on a PC, or a slash (with no drive designator) if it isn’t.

def absolute_file_path? file_url
  @@using_pc_filesystem ? (file_url =~ /^[A-Za-z]:\//) :
      (!(file_url =~ /^[A-Za-z]:/) and (file_url =~ /^\//))
end

Finaly, a relative base is prepended to relative file paths. It fits between a drive designator and the relative path or a PC, or otherwise just sits at the front of the path. When I assign it, I make sure it looks like it’ll work.

def relative_base=(relative_base)
  if valid_relative_base? relative_base
    @relative_base = relative_base
  else
    raise UrlException, "Invalid relative base '#{relative_base}'
  end
end

def valid_relative_base? relative_base
  ((@@using_pc_filesystem and (relative_base =~ /^[A-Za-z]:\//)) or
   (!@@using_pc_filesystem and (relative_base =~ /^\//))) and
  (relative_base =~ /\/$/)
end

This wraps everything up nicely. Nice enough that I wrapped it up into a gem I can pull into any of my apps. I added it to my cori project (Chunks Of Ruby Infrastructure) on rubyforge in the eymiha_url rubygem. I added it as the eymiha_url rubygem, available at rubygems.org.

Having done this once, I can now get on with the meat of writing my interactive-but-data-heavy Ruby applications, waiting for enough bandwidth on the Internet to someday move them to the web.