Writing AppleScripts That Dynamically Target Either Safari or WebKit

If that title sounds eerily familiar, it’s because I stole it from Gruber. I did that because 1) I’m doing the exact same thing here, 2) there is a small bug in his solution, and 3) there’s a big ass bug in the processes collection in System Events’s dictionary, which combined, lead his solution to double failure.

The small bug is really simple, Gruber returns the name of _app. But name will return “Safari” for both Safari and WebKit. The property that we want is short name.

Now for the Fun Part

Gruber’s code, corrected to use short name, ends up as:

tell application "System Events"
    set _app to item 1 of (every process whose frontmost is true)
    return short name of _app
end tell

And that will generally work. Sometimes.

If we have both Safari and WebKit open, System Events gets confused. As a general rule, it’ll always return the process for the application which was started first. Here’s a quick example in Ruby.

First I started WebKit, then Safari, then ran this:

>> Appscript::app('System Events').processes['Safari'].short_name.get
=> "WebKit"

But that’s not the particular way in which System Events gets confused in Gruber’s script.

Superfluous Assignments Considered Harmful

It’s a big pet peeve of mine that people spend way too many lines of code assigning stuff to variables, instead of just doing whatever is needed to be done with the values right away. For once — however incidentally — I’m validated.

Here’s Gruber’s code, refactored yet again for brevity:

tell application "System Events" to set _app to the first process whose frontmost is true
get the short name of _app

When he gets the first process and assigns it to the _app variable, that’s where System Events can get confused and return the wrong process. Funny enough, this code works perfectly:

tell application "System Events" to get the short name of the first process whose frontmost is true

Somehow, skipping the intermediate assignment and going straight for the kill makes System Events behave sanely.

Now, I went through great lengths to confirm my observed behavior. I wrote a script that gets the short name for the process that is frontmost, testing it with different launch orders and varying the frontmost browser, using both code variants: with temporary assignment, and with direct access. I reformatted the output into the following pretty ASCII table:

+----------------+-----------+----------+----------+
| Launch order   | Frontmost | Directly | Assigned |
+----------------+-----------+----------+----------+
| Safari         |           | Safari   | Safari   |
| WebKit         |           | WebKit   | WebKit   |
| Safari, WebKit | Safari    | Safari   | Safari   |
| Safari, WebKit | WebKit    | WebKit   | Safari * |
| WebKit, Safari | WebKit    | WebKit   | WebKit   |
| WebKit, Safari | Safari    | Safari   | WebKit * |
+----------------+-----------+----------+----------+

The two entries marked with an * are where we the script returned the incorrect browser name.

TL;DR Already

So if you just love plain AppleScript and want a fix for Gruber’s framework, here’s his amended GetCurrentApp:

on GetCurrentApp()
  tell application "System Events" to ¬
    get short name of first process whose frontmost is true
end GetCurrentApp

Simple enough.

Ruby Roolz AppleScript Droolz

It wouldn’t be me without a Ruby solution, especially since all the past scripts I posted here were written in Ruby.

The following method will return an Appscript::Application for the preferred browser, according to similar rules as originally defined in Gruber’s article, but with a bit of extra smarts:

First, it’ll look at the frontmost app; if it’s one of the valid browsers (WebKit or Safari), it’ll return that. Then it’ll look into the running browser processes. It’ll return the running browser, giving preference to the default browser if both are running. If no valid browser is running, it’ll just return the default. It’ll only return a valid browser. If it has to fallback to the default browser and it is, say, Chrome, it’ll just return nil.

VALID_BROWSERS = %w[ WebKit Safari ]
def browser
  processes = Appscript::app("System Events").processes
  target,   = VALID_BROWSERS & processes[Appscript.its.frontmost.eq(true)].short_name.get
  target  ||= begin
    default_browser  = VALID_BROWSERS & [%x(VERSIONER_PERL_PREFER_32_BIT=yes /usr/bin/perl -MMac::InternetConfig -le 'print +(GetICHelper "http")[1]').chomp]
    running_browsers = VALID_BROWSERS & processes.short_name.get
    (running_browsers & default_browser)[0] || running_browsers[0] || default_browser[0]
  end
  Appscript::app(target) if target
end

Here’s the full thing as a standalone Ruby file.

Objective-C Supplement

In a follow-up article, Gruber talks about premature optimization, how shelling out to Perl is potentially expensive, but fast enough for practical purposes. He gives an execution time for his browser detection script of “less than 0.1 seconds”.

Now, I tend to agree with him. That’s fast enough. Hell, I write my stuff in rb-appscript, which has a significant overhead over compiled AppleScripts. But still, just for fun, here’s a solution that clocks in at ~0.023s on my machine.

It’s an Objective-C file, which has to be compiled. The compiler command is listed as a comment at the beginning of the source file. It relies on AppKit to get the frontmost process, and LaunchServices to get the default browser. If anything, LaunchServices should stand the test of time longer than Perl’s Mac::InternetConfig.

The semantics for this are pretty much identical to Gruber’s original GetCurrentApp/GetDefaultWebBrowser solution, without any of the extra smarts of my Ruby version, because writing Objective-C is nearly as boring as writing AppleScript, and this was just a gimmick anyway.

Next Up

In the next weeks I intend to post my Safari/WebKit appscript scripts so we can actually put my browser.rb to some use.

Got comments? Use reddit and/or poke me on twitter.