japlscript / japlscript

Less than perfect bridge from Java to AppleScript and back

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

LGPL 2.1 Maven Central Build and Test CodeCov

JaplScript

JaplScript is an imperfect bridge layer between Java and AppleScript. It was created to serve a specific purpose and not to be a grand powerful library.

The overall approach is to

  1. Read .sdef files (exported with macOS's Script Editor).
  2. Generate annotated Java interfaces and enumerations for the defined AppleScript classes.
  3. Compile the interfaces/enums before runtime.
  4. Use them just like Java objects.

Installation

JaplScript is released via Maven. You can install it via the following dependency:

<dependencies>
    <dependency>
        <groupId>com.tagtraum</groupId>
        <artifactId>japlscript-runtime</artifactId>
    </dependency>
    <dependency>
        <groupId>com.tagtraum</groupId>
        <artifactId>japlscript-generator</artifactId>
        <!-- the generator is not necessary during runtime -->
        <scope>provided</scope>
    </dependency>
</dependencies>

Ant-based Interface Generation

The generator class is implemented as Ant task, so you can use it from any Ant file like this:

<project default="generate.interfaces">
    <target name="generate.interfaces">
        <taskdef name="japlscript"
                 classname="com.tagtraum.japlscript.generation.GeneratorAntTask"
                 classpathref="your.reference"/>
        <japlscript application="Music"
                    sdef="Music.sdef"
                    out="src/generated-sources"
                    packagePrefix="com.apple.music">
            <excludeclass name="rgb color"/>
        </japlscript>
    </target>
</project>

The attribute application describes the application's name as used in a regular AppleScript tell command (which implies you can also use the bundle name).

Note that the sample above uses an <excludeclass/> tag, which simply means that JaplScript should not generate a Java interface for the given AppleScript class or type (in this example: rgb color).

Maven-based Interface Generation

From Maven, you can run a suitable Ant file using the maven-antrun-plugin. If you do so, and have declared JaplScript as a dependency, you can set classpathref="maven.compile.classpath" when you define the japlscript code generator task.

Sample Ant file japlscript.xml:

<project default="generate.interfaces">
    <target name="generate.interfaces">
        <taskdef name="japlscript"
                 classname="com.tagtraum.japlscript.generation.GeneratorAntTask"
                 classpathref="maven.compile.classpath"/>
        <japlscript application="Music"
                    sdef="Music.sdef"
                    out="${project.build.directory}/generated-sources/main/java"
                    packagePrefix="com.apple.music">
            <excludeclass name="rgb color"/>
        </japlscript>
    </target>
</project>

Sample Maven pom.xml excerpt:

<plugin>
    <artifactId>maven-antrun-plugin</artifactId>
    <executions>
        <execution>
            <configuration>
                <target>
                    <!--
                    pass project.build.directory to ant, so you can use it when
                    specifying the output folder, which could be
                    ${project.build.directory}/generated-sources/main/java 
                     -->
                    <property name="project.build.directory" value="${project.build.directory}" />
                    <ant antfile="japlscript.xml" inheritRefs="true" />
                </target>
                <sourceRoot>${project.build.directory}/generated-sources/main/java</sourceRoot>
            </configuration>
            <phase>generate-sources</phase>
            <goals>
                <goal>run</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Custom Type Mappings

To introduce custom mappings from AppleScript classes to your own classes, you can use the <typemapping/> tag in your Ant file, for example:

<project default="generate.interfaces">
    <target name="generate.interfaces">
        <taskdef name="japlscript"
                 classname="com.tagtraum.japlscript.generation.GeneratorAntTask"
                 classpathref="maven.compile.classpath"/>
        <japlscript application="Music"
                    sdef="Music.sdef"
                    out="${project.build.directory}/generated-sources/main/java"
                    packagePrefix="com.apple.music">
            
            <!-- mapping from "file" to "com.apple.finder.File" -->
            <typemapping applescript="file" java="com.apple.finder.File"/>
            
        </japlscript>
    </target>
</project>

Note that your custom Java types should implement the interface Codec to support encoding/decoding from an AppleScript object (specifier).

If your custom type is not a primitive, you probably also want to implement the Reference interface.

Scripting Additions

To generate Java APIs for scripting additions, set the scriptingAddition attribute to true. Example:

<project default="generate.interfaces">
    <target name="generate.interfaces">
        <taskdef name="japlscript"
                 classname="com.tagtraum.japlscript.generation.GeneratorAntTask"
                 classpathref="maven.compile.classpath"/>
        <japlscript application="StandardAdditions"
                    scriptingAddition="true"
                    sdef="StandardAdditions.sdef"
                    out="${project.build.directory}/generated-sources/main/java"
                    packagePrefix="com.apple.macos"/>
    </target>
</project>

Note that typically the main class for an application is aptly named Application.class. For scripting additions that is not the case—they are called ScriptingAddition.class instead.

Usage

Getting Started...

To use the generated code, do something like this:

// if you have generated classes for the Music.app
com.apple.music.Application app = com.apple.music.Application.getInstance();

// then use app, for example, toggle playback (if a track is in the player)
app.playpause();

AppleScript Type System Support

Every JaplScript object that refers to an AppleScript counterpart implements the interface Reference. As such, you can <T> T cast(java.lang.Class<T> klass) an object to another Java type that in turn corresponds to another AppleScript type. Note that type checks may be lazy, i.e. you might not get an exception right away, should the cast not work.

If you want to check, whether a cast would be legitimate, you can call boolean isInstanceOf(TypeClass typeClass). A TypeClass is the Java-side pendant for an AppleScript class. Each of the generated interfaces exposes its TypeClass via it CLASS field. For example, if you have an instance of Java-interface Track, you can access Track.CLASS to retrieve its AppleScript type. This means, you could ask an instance of Track whether its also an instance of the sub-class FileTrack:

Application application = Application.getInstance();
Track track = application.getCurrrentTrack();
// check, whether the AppleScript object references by track
// is actually a FileTrack and not just a Track. 
if (track.isInstanceOf(FileTrack.CLASS)) {
    // cast the track Java instance to FileTrack. 
    FileTrack fileTrack = track.cast(FileTrack.class);
    ...
}

Implicitly, isInstanceof(..) uses the method TypeClass getTypeClass(), which lets you find out the actual type of the referenced AppleScript object. This could be a subtype of the interface you are currently using.

Note that using the AppleScript type system support is not always necessary. Oftentimes, the regular Java type system works just as well (see example below), but note that there is no strict guarantee.

Application application = Application.getInstance();
Track track = application.getCurrrentTrack();
if (track instanceof FileTrack) {
    FileTrack fileTrack = (FileTrack)track;
    ...
}

Accessing Elements/Collections

In AppleScript, objects can have properties and elements. Elements are really just collections, which can be accessed in JaplScript via generated methods. Let's assume you have a PlayList instance, which has a Track elements. Then JaplScript will generate the following standard methods:

import com.tagtraum.japlscript.Id;

public interface Playlist extends com.tagtraum.japlscript.Reference {

    /**
     * @return an array of all {@link Track}s
     */
    default Track[] getTracks() {
        return getTracks(null);
    }

    /**
     * @param filter AppleScript filter clause without the leading &quot;whose&quot; or &quot;where&quot;
     * @return an array of all {@link Track}s
     */
    Track[] getTracks(java.lang.String filter);

    /**
     * @param index index into the element list (zero-based)
     * @return the {@link Track} at the requested index
     */
    Track getTrack(int index);

    /**
     * @param id id of the item
     * @return the {@link Track} with the requested id
     */
    Track getTrack(Id id);

    /**
     * @return number of all {@link Track}s
     */
    default int countTracks() {
        return countTracks(null);
    }

    /**
     * @param filter AppleScript filter clause without the leading &quot;whose&quot; or &quot;where&quot;
     * @return the number of elements that pass the filter
     */
    int countTracks(String filter);
}

They will let you count the tracks and access them in bulk, by zero-based index and by id. Additionally, they let you specify filters. These are just little AppleScript snippets that you would usually use in an AppleScript where clause.

For example:

int count = playlist.countTracks("year > 1984");

This snippet counts all the tracks in the given playlist that have a year greater than 1984. Note that this assumes that the Track instance has a year property (AppleScript property name, not Java property name!). Similar filters can be used in the other provided methods.

Note that you have to pass well-formed AppleScripts, i.e., if you want to filter by a string value, you have to properly quote the string.

For example:

int count = playlist.countTracks("persistent ID = \"0123456789abcde\"");

Creating new Objects

Creating new AppleScript objects is sometimes not as straightforward as one might wish. For example, to create a new playlist in the Apple Music app (or iTunes), you would use the application's make() command.

Application application = Application.getInstance();
UserPlaylist userPlaylist = getApplication().make(UserPlaylist.class);

Note that using the Java class here is just a convenience. If you want to specify additional arguments, like a parent playlist of folder, you would have to write something like this:

Reference reference = application.make(UserPlaylist.CLASS, someParentPlaylist, null);
UserPlaylist userPlaylist = reference.cast(UserPlaylist.CLASS);

Bulk Accessing Properties

Every JaplScript object has a method java.util.Map<String, Object> getProperties(), which lets you retrieve the object's properties in a convenient java.util.Map. Note that the keys correspond to the Java property names. The advantage of using getProperties() instead of individually accessing properties one by one is efficiency, since fewer AppleScript calls are needed.

Sessions

When calling multiple setters in a row, JaplScript will translate each call to an AppleScript snippet and execute it. This of course is inefficient. It may make more sense to first collect a bunch of calls and then execute them all at once. You can achieve this kind of behavior by starting a Session:

import com.tagtraum.japlscript.execution.Session;

[...]

Application application = Application.getInstance();
// start session for the current thread
Session session = Session.startSession();
// call some setters
application.setThis("this");
application.setThat("that");
application.setOther("other");
// call commit in order to execute the combined AppleScript snippets
session.commit();

Logging

JaplScript uses java.util.logging. In order to see what scripts are being executed and when, just dial up the log level.

Artificial References

Usually you will be able to obtain Java objects for your AppleScript objects using the generated interfaces and their methods. But sometimes this can be awkward and you much rather just want to use an AppleScript snippet. This can easily be done by using a generic ReferenceImpl.

To do so you have to understand that each Reference consists of two parts:

  1. An object reference, describing an object within an application's context
  2. An Application reference, describing the application context

So to create a Java object for an arbitrary AppleScript object, you can simply do something like this:

Application application = Application.getInstance();
final String objectReference = "(first source where kind is library)";
Reference reference = new ReferenceImpl(objectReference, application.getApplicationReference());
// cast to the Java interface that you know fits
Source librarySource = reference.cast(Source.class); 

The snippet above allows you to create a Java instance for the first library source of some application (think Music.app or iTunes) without executing a single line of AppleScript. Obviously, objectReference could also be some other random snippet of AppleScript that returns some object.

Sample Projects

  • JaplSA - Java API for AppleScript Standard Additions
  • JaplSE - Java API for AppleScript System Events
  • Japlphoto - Java API for Apple's Photos app
  • Japlfind - Java API for Apple's Finder app
  • Japlcontact - Java API for Apple's Contacts app
  • Obstunes - Java API for iTunes
  • Obstmusic - Java API for Apple's Music app
  • Obstspot - Java API for the Spotify app

Have you generated an API stored in your repository? Open a PR to list it here.

Want to have your API repository listed under https://github.com/japlscript, consider transferring ownership to the japlscript GitHub organization.

Java Module

JaplScript is shipped as a Java module (see JPMS) with the name tagtraum.japlscript.

Note that module support is also possible for the generated code. If you specify a module name during generation, the generated code will also be a module. For example:

<project default="generate.interfaces">
    <target name="generate.interfaces">
        <taskdef name="japlscript"
                 classname="com.tagtraum.japlscript.generation.GeneratorAntTask"
                 classpathref="maven.compile.classpath"/>
        <japlscript application="Music"
                    module="tagtraum.music"
                    sdef="Music.sdef"
                    out="${project.build.directory}/generated-sources/main/java"
                    packagePrefix="com.apple.music"/>
    </target>
</project>

This will create an appropriate module-info.java file exporting the module named tagtraum.music.

Note that the generator requires Ant, which has not yet transitioned to modules, which may lead to problems.

AppleScript Sandbox

Since macOS 10.14 (Mojave), Apple imposed a sandbox on AppleScript. Therefore you may see dialog boxes requesting authorization to perform certain actions. After a while, these boxes simply disappear and there does not seem to be an easy way to authorize your app. In this case, you need to open the system preferences, navigate to Security & Privacy, Privacy, and then Automation, and make sure your app is allowed to remote control whatever app you are trying to remote control (see also this article).

If you are shipping a real app with a UI and not just a command line tool, you need to customize the sandbox permission dialog. You can do so by adding the key NSAppleEventsUsageDescription to your app bundle's /Contents/Info.plist file. For example:

[...]
<key>NSAppleEventsUsageDescription</key>
<string>SuperMusic uses AppleEvents to access your Music.app library,
        e.g., to set BPM values or create playlists.</string>
[...]

Apple's documentation for the keyword is here.

Notarization and Hardened Runtime

If you would like to notarize your app, you must enable macOS's Hardened Runtime. This also means that applications that want to send Apple Events to other applications (automation) must be signed with the com.apple.security.automation.apple-events entitlement.

Here's a sample entitlements.plist file:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
        "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.automation.apple-events</key>
    <true/>
</dict>
</plist>

Which is then used in your codesign call:

codesign --entitlements entitlements.plist --options runtime \
   --deep -vvv -f --sign "Developer ID Application: YOUR NAME" Your.app

Known Shortcomings

Note that the generated interfaces may not always be perfect. This is especially true for complex AppleScript types and the cardinality of command return types. In some cases, you may need to fix the generated Java interface manually (e.g. the cardinality of the return type of the Music.app's search-command).

There are also issues with generating all possible versions of overloaded AppleScript commands.

Ant really should not be necessary during generation. Instead a simple Maven plugin should do the job.

API

You can find the complete API here.

Additional Resources

About

Less than perfect bridge from Java to AppleScript and back

License:GNU Lesser General Public License v2.1


Languages

Language:Java 95.4%Language:Objective-C 4.6%