[sample code] Calling AppleScript from Swift

Belatedly, here’s a demo project showing how to use AppleScript directly from Swift via the AppleScriptObjC framework:


https://github.com/hhas/Swift-AppleScriptObjC


AppleScript-ObjC (ASOC) allows ObjC/Swift code to access AppleScript scripts and handlers much like native Cocoa classes/instances and methods, converting parameters and return values between standard AppleScript data types and Foundation equivalents. While ASOC is neither seamless nor complete, nor a patch on a native Apple event bridge, for non-trivial tasks it’s vastly simpler and way more capable than clunky NSAppleScript/osascript or broken ScriptingBridge framework.


HTH

Replies

This looks great, THANK YOU!


It looks like this is fully sandboxed, so it should meet App Store requirements? I have read lots of (older) discussion the default Apple prefers is a script in Library/Application Scripts/

`~/Library/Application Scripts/` is a sandbox-friendly location where your users' own scripts should be stored (see `NSApplicationScriptsDirectory`). Your app can then execute (but not modify) those scripts using `NSUserAppleScriptTask`. For instance, your app might provide a simple "Scripts" menu that lists the available user scripts, or it might provide the ability to trigger user-supplied scripts when a certain event occurs (e.g. Mail rules).


`NSUserScriptTask` classes are limited in capability and a chore to use, but execute user scripts outside of the app's own sandbox so the user's ability to control other apps isn't limited by your app's own entitlements. Excellent guide here:


objc.io/issues/14-mac/sandbox-scripting/


The AppleScript-ObjC approach can be used to run your application's internal helper scripts, embedded in the `.app` bundle when Xcode builds the app. ASOC scripts execute in-process and their capabilities are restricted according to your app's sandbox entitlements. For instance, your app might rely on an AppleScript helper script to create a new email message in Apple Mail with To, Subject, and Content fields automatically populated when the user selects "Help ➞ Contact Us".


Official AppleScript-ObjC documentation is thin and only covers calling Cocoa APIs from AppleScript. However, that example project should cover the bits you need to call into AppleScript from another language. If you've ever used other ObjC bridges such as PyObjC or RubyCocoa, you should find it quite familiar. If you need any help with ASOC or AppleScript in general, try the AppleScript mailing list or ask Shane Stanley who's written e-books on ASOC.


HTH

Awesome and thank you for this work !


I was looking for this kind of bridge because I have an AppleScript to get all my iTunes Playlist in a tree and I'd like to show it in a source list in a Swift OSX app.


But my script return a long list which contains all my playlist like for 20 (but I have more 500) :

{{theName:"Bibliothèque", theID:"66270731FDBE2C50", isFolder:false, theClass:library playlist, isSmart:false, theCount:37581}, {theName:"Clips vidéo", theID:"07D5032B96891D67", isFolder:false, theClass:user playlist, isSmart:true, theCount:283}, {theName:"Musique", theID:"CBDD9214A5BD0B6F", isFolder:false, theClass:user playlist, isSmart:true, theCount:35901}, {theName:"Films", theID:"CBDD9214A5BD0B70", isFolder:false, theClass:user playlist, isSmart:true, theCount:136}, {theName:"Vidéos personnelles", theID:"4B4E7FC3F07E6F4E", isFolder:false, theClass:user playlist, isSmart:true, theCount:48}, {theName:"Séries TV", theID:"CBDD9214A5BD0B71", isFolder:false, theClass:user playlist, isSmart:true, theCount:819}, {theName:"Podcasts", theID:"BCF56C8DABE66010", isFolder:false, theClass:user playlist, isSmart:false, theCount:1264}, {theName:"Livres audio", theID:"CBDD9214A5BD0B72", isFolder:false, theClass:user playlist, isSmart:true, theCount:0}, {theName:"Acheté sur NailleucoPhone", theID:"E91BEB5B6EF9BCC2", isFolder:false, theClass:user playlist, isSmart:false, theCount:1}, {theName:"=ALL MUSIC=", theID:"95D86D03EC2861F0", isFolder:true, theClass:folder playlist, isSmart:false, theCount:7880, theChildren:{{theName:"Smart", theID:"260C92610890EF91", isFolder:true, theClass:folder playlist, isSmart:false, theCount:7880, theChildren:{{theName:"--All Music ++--", theID:"973CB171739FAB9D", isFolder:false, theClass:user playlist, isSmart:true, theCount:2938}, {theName:"--Hard & Metal + Rock & Folk--", theID:"3D99CE986F699F13", isFolder:false, theClass:user playlist, isSmart:true, theCount:2371}, {theName:"--Hard & Metal ++--", theID:"49296220B7164B67", isFolder:false, theClass:user playlist, isSmart:true, theCount:1336}, {theName:"--Rap & Dance ++--", theID:"ECB5F697EB6FF887", isFolder:false, theClass:user playlist, isSmart:true, theCount:603}, {theName:"--Rock & Folk ++--", theID:"05E45490A8F8F012", isFolder:false, theClass:user playlist, isSmart:true, theCount:1064}, {theName:"All Music", theID:"95D86D03EC2861EF", isFolder:false, theClass:user playlist, isSmart:true, theCount:4008}, {theName:"Bientôt dans All Music", theID:"31E4095A87322236", isFolder:false, theClass:user playlist, isSmart:true, theCount:3871}, {theName:"No All Music", theID:"76EBD18939CC3778", isFolder:false, theClass:user playlist, isSmart:false, theCount:1}}}, {theName:"--Fun & Délire--", theID:"932E6066C9E582D7", isFolder:false, theClass:user playlist, isSmart:false, theCount:77}}}}


And as soon as the return is not a record (like in your example to get the current track infos) but a list, the return fails (nothing in the console).


Any idea ?


Thx a lot.

Please show your code—specifically the AppleScript script object containing your handler, your `@objc(NSObject) protocol` describing that handler's interface, and a simple test of calling that handler from Swift.

Hi,


My AppleScript function in the iTunes bridge is very simple :

to getStaticPlaylistsTree()
        tell application "iTunes"
        set theList to {{theName:"Bibliothèque", theID:"66270731FDBE2C50", isFolder:false, theClass:library playlist, isSmart:false, theCount:37581}, {theName:"Clips vidéo", theID:"07D5032B96891D67", isFolder:false, theClass:user playlist, isSmart:true, theCount:283}}
        end tell
        return theList
end getStaticPlaylistsTree

Of course, I added to the protocol :

var getStaticPlaylistsTree: [NSArray:AnyObject]? { get }


I tried as a String like your "var trackInfo: [NSString:AnyObject]? { get }" example and as a simple Array and triggered the method in the applicationDidFinishLaunching :

func applicationDidFinishLaunching(_ aNotification: Notification) {
        // iTunes emits track change notifications; very handy for UI refreshes
        let dnc = DistributedNotificationCenter.default()
        dnc.addObserver(self, selector:#selector(AppDelegate.updateTrackInfo),
                         name:NSNotification.Name(rawValue:"com.apple.iTunes.playerInfo"), object:nil)
        // update UI only if iTunes is already running, otherwise wait until user performs an action
        if self.iTunesBridge.isRunning { self.updateTrackInfo() }
       
        if let theTree = self.iTunesBridge.getStaticPlaylistsTree {
            print("toto")
            print(theTree)
        }   
    }


>>> 2019-01-21 12:27:08.028543-0400 Swift-AppleScriptObjC[85542:8049707] -[__NSArrayI objectForKey:]: unrecognized selector sent to instance 0x60c00003c7a0


Thx.

Ok so someone answers me on StackOverflow.


I just have to type my return as [NSDictionary]. Very nice !


But in a project from scratch in a new XCode project, what do I need to do except to include the iTunesBridge.swift, iTunesBridge.applescript and Swift_AppleScriptObjC.entitlements ? No config to do in a plist ?


Thx.

"I just have to type my return as [NSDictionary]."


Correct. Your protocol's return type was all wrong. A more precise type would be `NSArray<NSDictionary<NSString,AnyObject>>`, which I imagine can be written more cleanly as `[[String:AnyObject]]`. (The semantics are subtly different, mind: the first uses ObjC/Cocoa types; the second native Swift ones. Ah, the joys of crossing bridges.)


"But in a project from scratch in a new XCode project, what do I need to do"


Info.plist requires an `NSAppleEventsUsageDescription` entry describing how your app uses other apps.


Also make sure the new project:


- links to the AppleScriptObjC framework


- has a build phase to compile its .applescript files to .scpt


- calls `Bundle.main.loadAppleScriptObjectiveCScripts()` during initialization (see AppDelegate.swift) to load those files' script objects into the ObjC runtime as Cocoa-based classes. How and when you instantiate them is up to you.

Thank you for your answer.


I have one more problem : some playlists are folder playlist so I have the isFolder bool to know that + theChildren property which contains all the children :

{{theName:"Acheté sur NailleucoPhone", theID:"E91BEB5B6EF9BCC2", isFolder:false, theClass:user playlist, isSmart:false, theCount:1}, {theName:"=ALL MUSIC=", theID:"95D86D03EC2861F0", isFolder:true, theClass:folder playlist, isSmart:false, theCount:7880, theChildren:{{theName:"Smart", theID:"260C92610890EF91", isFolder:true, theClass:folder playlist, isSmart:false, theCount:7880, theChildren:{{theName:"--All Music ++--", theID:"973CB171739FAB9D", isFolder:false, theClass:user playlist, isSmart:true, theCount:2953}, {theName:"--Hard & Metal + Rock & Folk--", theID:"3D99CE986F699F13", isFolder:false, theClass:user playlist, isSmart:true, theCount:2385}, {theName:"--Hard & Metal ++--", theID:"49296220B7164B67", isFolder:false, theClass:user playlist, isSmart:true, theCount:1349}, {theName:"--Rap & Dance ++--", theID:"ECB5F697EB6FF887", isFolder:false, theClass:user playlist, isSmart:true, theCount:605}, {theName:"--Rock & Folk ++--", theID:"05E45490A8F8F012", isFolder:false, theClass:user playlist, isSmart:true, theCount:1065}, {theName:"All Music", theID:"95D86D03EC2861EF", isFolder:false, theClass:user playlist, isSmart:true, theCount:4007}, {theName:"Bientôt dans All Music", theID:"31E4095A87322236", isFolder:false, theClass:user playlist, isSmart:true, theCount:3872}, {theName:"No All Music", theID:"76EBD18939CC3778", isFolder:false, theClass:user playlist, isSmart:false, theCount:1}}}, {theName:"--Fun & Délire--", theID:"932E6066C9E582D7", isFolder:false, theClass:user playlist, isSmart:false, theCount:77}, {theName:"--Hard & Metal--", theID:"722A7714BCFAF3F0", isFolder:false, theClass:user playlist, isSmart:false, theCount:1804}, {theName:"--Rap & Dance--", theID:"722A7714BCFAF3ED", isFolder:false, theClass:user playlist, isSmart:false, theCount:713}, {theName:"--Rock & Folk--", theID:"722A7714BCFAF3EE", isFolder:false, theClass:user playlist, isSmart:false, theCount:1338}, {theName:"--Slow & Balade--", theID:"722A7714BCFAF3EF", isFolder:false, theClass:user playlist, isSmart:false, theCount:121}}}}

But theChildren is not mapped, I think it is because of the type [NSDictionary] of the getStaticPlaylistsTree to get the tree.


What is the right type ?


Thx.

My mistake... I forgot to declare theChildren in the mapping.


Now, it is in the dump :

Swift_AppleScriptObjC.SwiftModel
      ▿ isFolder: Optional(false)
        - some: false
      ▿ isSmart: Optional(false)
        - some: false
      ▿ theCount: Optional(1)
        - some: 1
      ▿ theID: Optional("E91BEB5B6EF9BCC2")
        - some: "E91BEB5B6EF9BCC2"
      ▿ theName: Optional("Acheté sur NailleucoPhone")
        - some: "Acheté sur NailleucoPhone"
      ▿ theClass: Optional(<NSAppleEventDescriptor: 'cUsP'>)
        - some: <NSAppleEventDescriptor: 'cUsP'> #0
          - super: NSObject
      - theChildren: nil
    ▿ Swift_AppleScriptObjC.SwiftModel
      ▿ isFolder: Optional(true)
        - some: true
      ▿ isSmart: Optional(false)
        - some: false
      ▿ theCount: Optional(7880)
        - some: 7880
      ▿ theID: Optional("95D86D03EC2861F0")
        - some: "95D86D03EC2861F0"
      ▿ theName: Optional("=ALL MUSIC=")
        - some: "=ALL MUSIC="
      ▿ theClass: Optional(<NSAppleEventDescriptor: 'cFoP'>)
        - some: <NSAppleEventDescriptor: 'cFoP'> #1
          - super: NSObject
      - theChildren: nil

But is nil...


This is my struct :

struct SwiftModel {
    
    let isFolder: Bool?
    let isSmart: Bool?
    let theCount: Int?
    let theID: String?
    let theName: String?
    let theClass: NSAppleEventDescriptor?
    let theChildren: [NSDictionary]?
    
    init(dictionary: NSDictionary) {
        self.isFolder = dictionary.value(forKey: "isFolder") as! Bool
        self.isSmart = dictionary.value(forKey: "isSmart") as! Bool
        self.theCount = dictionary.value(forKey: "theCount") as? Int
        self.theID = dictionary.value(forKey: "theID") as? String
        self.theName = dictionary.value(forKey: "theName") as? String
        self.theClass = (dictionary.value(forKey: "theClass") as? NSAppleEventDescriptor)
        self.theChildren = (dictionary.value(forKey: "theChildre") as? [NSDictionary])
    }
}


and it is declare like described in StackOveflow :

let staticPlayListTree = theTree?.compactMap({SwiftModel(dictionary: $0 as! NSDictionary)})


Thx.

My mistake again...


self.theChildren = (dictionary.value(forKey: "theChildre") as? [NSDictionary]) >>> self.theChildren = (dictionary.value(forKey: "theChildren") as? [NSDictionary]) : BETTER ! 🙂

This is nice.


Works fine if App Sandbox is Off, but fails if App Sandbox is On in Xcode 10.1 w/ the console saying ... iTunes got an error: Application isn't running. (error -600).


you can, if in your entitlements, you place:


<key>com.apple.security.temporary-exception.apple-events</key>

<arrary>

<string>com.apple.itunes</string>

</arrary>


cheers,

Ok so everything works fine as expected. I can load all my iTunes playlists from an AppleScript and show them in a tree (NSOutlineView) in a Mac OSX app.


But but as you can see in this short demo, it could takes a long time to load all the data if like me you have more than 500 playlists (iTunes user for 15 years 😉).


In the AppleScript script, I use the progress methods to show what's happening when the script is executed in Script Editor but in OSX, how can I trigger an event from AppleScript to dispatch the loading progress ?


Thx.

And also one more thing :


How to pass a parameter to an AppleScript function declared in the iTunesBridge protocol ?


I tried to pass a simple string but it fails :

unrecognized selector sent to object


The method is declared like that :

func getStaticPlaylistsTree(theString: String) -> [NSDictionary]?


Thx.

Hello hhas01,


Any idea for this question ? Still stuck...


Thx.

> I tried to pass a simple string but it fails: unrecognized selector sent to object


Sounds like your Swift method signature doesn’t match the AppleScript handler’s signature. Go read up on how Swift maps native method names to ObjC method names. You probably meant:


func getStaticPlaylistsTree(_ theString: String) -> [NSDictionary]?


to getStaticPlaylistsTree: theString