Thursday, May 08, 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 should 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 viewer of choice 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 viewer-of-choice is buried in desktop preferences that may be different for different window managers, 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 aether. 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.

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.

No comments: