Swift Scripting (Part 1)

NOTE: This series of posts was first published when Swift 1.x was the current version of the language. The series and the accompanying GitHub project have been updated for Xcode 7 and Swift 2.0.

When I first heard of the ability to invoke Swift scripts via the Unix hash-bang (#!) convention, what first came to mind was using Swift for the sort of tasks that one might tackle with Bash or Python. It turns out that the language isn’t quite set-up for that pattern of usage, although some have taken steps to fill in the blanks (See SwiftShell.)

Later, I began to explore the use of Swift as a replacement for AppleScript. AppleScript is the user-facing component of the Open Scripting Architecture. It enables end users to orchestrate workflows of varying complexity by sending appropriate commands to one or more applications. I’ve dabbled in AppleScript since it’s debut in 1993, and I’ve often found the language to be a source of frustration. This is partly due to my strong bias towards “normal” programming languages.

So, given the rapid rise of Swift, it’s ability to be run interactively, and my historic frustration with AppleScript, a motive was born. Wouldn’t it be great to use the same language for automation that we will be using more and more for developing applications? But how do we get there from here?

Take the Bridge

As part of the OS X 10.5 (Leopard) release, Apple introduced the Scripting Bridge. The Scripting Bridge is a technology that makes it possible for developers to use Objective-C to control scriptable OS X applications without having to deal with the low-level Apple Event APIs. (Apple Events are foundational to the Open Scripting Architecture.) While the Scripting Bridge wasn’t written with Swift in mind, Swift developers can take advantage of this technology with a bit of extra work.

Getting Started

The first step in leveraging the Scripting Bridge is to extract the scripting definition from a scriptable application. This is done using the sdef command. Use of sdef is straight forward:

sdef /path/to/SomeApp.app > SomeApp.sdef

The next step is to create an Objective-C header file for the interface of the scriptable application. This is done by processing the output of sdef with another tool, sdp. The sdp tool is invoked as follows:

sdp -fh --basename SomeApp SomeApp.sdef

Ideally, you would always be able to pipe the output of sdef straight into sdp without the need for the intermediate sdef file. Unfortunately, this is not the case. You’ll often encounter irregularities in the sdef files that may require some manual edits to correct. When you invoke sdp, it will output messages for each of the issues detected in the sdef file.

Here’s an (extremely abbreviated) excerpt of the generated header file for the Finder.

@interface FinderApplication : SBApplication

- (SBElementArray *) disks;

@end

To leverage this interface from Objective-C, you could create a simple command line application like this one

#import <Foundation/Foundation.h>
#import <ScriptingBridge/ScriptingBridge.h>

@interface FinderApplication : SBApplication

- (SBElementArray *) disks;

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        FinderApplication *finder = [SBApplication applicationWithBundleIdentifier:@"com.apple.Finder"];
        for (id disk in [finder disks]) {
            NSLog(@"disk: %@", [disk name]);
        }
    }
    return 0;
}

The output of this program might look like this

2015-03-31 22:31:41.627 FinderDemo[13012:20835322] disk: Macintosh HD
2015-03-31 22:31:41.628 FinderDemo[13012:20835322] disk: home
2015-03-31 22:31:41.629 FinderDemo[13012:20835322] disk: net

On to Swift

A reasonable translation of this code into Swift might look like this

// Assume that the Finder header file is accessible via the bridging header
import Foundation
import ScriptingBridge

let finder = SBApplication(bundleIdentifier: "com.apple.Finder") as! FinderApplication
for item: AnyObject in finder.disks() {
    print("disk: \((item as! FinderDisk).name)");
}

However, this won’t link successfully. You see, the classes defined by the Scripting Bridge interface don’t come into being until runtime. Therefore, FinderApplication and FinderDisk are not available to the linker. So, you end up having to modify the code to use AnyObject in place of FinderApplication.

import Foundation
import ScriptingBridge

let finder: AnyObject = SBApplication(bundleIdentifier: "com.apple.Finder")!
for item: AnyObject in finder.disks() {
    print("disk: \(item.name as NSString)");
}

So far, so good, but the pervasive use of AnyObject is less than ideal and you will often require explicit casting to resolve method or property names. An alternate approach to using the Objective-C header directly is to define a set of protocols that cover the Objective-C API. We’ll discuss this approach in depth in the part two of this series.

For a sneak peak at where this will lead, see SwiftScripting on GitHub.

Advertisements

10 thoughts on “Swift Scripting (Part 1)

    1. Hey there,

      Your SwiftScripting library on Github is awesome! The tutorial on your blog is pretty neat too except for initialising the classes of which I found only one single exemple in your Pages sample directory.
      I have some trouble mimicking the behaviour of that sample project:

      let mail = SBApplication(bundleIdentifier: “com.apple.mail”) as! MailApplication
      let message:MailOutgoingMessage? = objectWithApplication(mail, scriptingClass: MailScripting.outgoingMessage, properties: [“subject”: “one important message”])
      if message == nil {
      print(“STOP”)
      return
      }
      mail.outgoingMessages!().add(message!)
      print(“Subject: \(message?.subject)”)
      print(“Outgoing messages: \(mail.outgoingMessages!().count)”)

      You see, I always get:
      Subject: nil (which is weird because it’s a String, even if empty should be “”)
      Outgoing messages: 0
      Even though I have no errors nor crashes. Which makes it difficult to debug.
      Do you have any idea as of how this happened? You seem to do exactly the same thing in “Mail as PDF.swift”

      I’m using the latest release of XCode and Swift on macOS Sierra.

      Thank you!

      1. The sample script you have is close to correct. Here’s a slight variation that works as desired. The key difference is the assignment of “message” as a MailOutgoingMessage. Also, the sample below also uses the “application” convenience function in the ScriptingUtilities framework.


        #!/usr/bin/xcrun swift -F /Library/Frameworks

        import ScriptingUtilities
        import MailScripting

        let mail = application(name: "Mail") as! MailApplication
        let futureMessage = objectWithApplication(mail, scriptingClass: MailScripting.outgoingMessage, properties: ["subject": "one important message"])

        mail.outgoingMessages!().add(futureMessage!)
        let message = futureMessage as! MailOutgoingMessage

        print("Subject: \(message.subject!)")
        print("Outgoing messages: \(mail.outgoingMessages!().count)")

        mail.activate()

  1. Many thanks for this article and source code. It took me awhile, but I got the same done for Numbers and was able to successfully integrate the scripting with SQLite. However, I had to make the change to the “var running” for Swift 4.0 in the resulting Numbers.swift file:

    @objc public protocol SBApplicationProtocol: SBObjectProtocol {
    func activate()
    var delegate: SBApplicationDelegate! { get set }
    // var running: Bool { @objc(isRunning) get }
    @objc optional var isRunning: Bool { get }

    }

    there are some properities like NumbersCell.value that have to be brought in through ScriptingBridge, and I have not found out why.

    Once again, many thanks!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

w

Connecting to %s