Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,34 @@ public String get(Action request) throws Exception {
/**
* Execute an AppleScript command.
* <p>
* It basically calls the AppleScript application with the following command:<br>
* <code>osascript -e tell application "{@literal <}application{@literal >}" to {@literal <}action{@literal >}</code>
* It executes the following script, in-process if possible:<br>
* <code>tell application "{@literal <}application{@literal >}" to {@literal <}action{@literal >}</code>
* <p>
* Without in-process support it falls back to the osascript command line tool.
*
* @param actions The actions to execute
* @return The result of the command
* @throws Exception If the command failed
*/
public String execute(Action... actions) throws Exception {
// Update runtime parameters
String action = Action.toString(actions);
this.runtimeParameters[2] = String.format(GRAMMAR_FORMAT, this.application, action);
String script = String.format(GRAMMAR_FORMAT, this.application, action);

AppleScriptEngine engine = AppleScriptEngine.getInstance();
if (engine == null) {
return this.executeProcess(script, action);
}

try {
return engine.execute(script);
} catch (Exception e) {
throw new Exception("AppleScript execution \"" + action + "\" failed with " + e.getMessage(), e);
}
}

private synchronized String executeProcess(String script, String action) throws Exception {
// Update runtime parameters
this.runtimeParameters[2] = script;

// Execute AppleScript process
Process process = this.runtime.exec(this.runtimeParameters);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package de.labystudio.spotifyapi.platform.osx.api;

import com.sun.jna.Pointer;
import com.sun.jna.ptr.IntByReference;
import de.labystudio.spotifyapi.platform.osx.api.jna.Carbon;
import de.labystudio.spotifyapi.platform.osx.api.jna.ObjC;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* Executes AppleScript inside the current process using the Open Scripting Architecture.
* <p>
* The alternative, an osascript process per request, is not only slow: macOS registers every one of
* them as an application of their own. In a process that was started by an application, like a game
* started by its launcher, each of these short-lived processes shows up as another Dock icon of that
* application, several times per second.
* <p>
* The scripting component is shared by the whole process and may only be used by one thread at a time.
*/
public class AppleScriptEngine {

private static final int MAX_COMPILED_SCRIPTS = 32;

// AppleScript runs the event loop of the calling thread while it talks to another application, both
// to wait for the reply and in between to let the user cancel. Off the main thread that loop never
// receives anything while the main thread runs an event loop of its own (any GUI application, the
// java launcher itself), the request hangs until it times out. AESendMessage waits on a reply port
// instead and there is no user to cancel anything.
private static final Carbon.OSASendProc SEND_PROC = (appleEvent, reply, sendMode, sendPriority, timeOutInTicks, idleProc, filterProc, refCon)
-> (short) Carbon.INSTANCE.AESendMessage(appleEvent, reply, sendMode, timeOutInTicks);
private static final Carbon.OSAActiveProc ACTIVE_PROC = refCon -> 0;

private static AppleScriptEngine instance;
private static boolean unavailable;

private final Pointer component;

private final Map<String, Integer> compiledScripts = new LinkedHashMap<String, Integer>(16, 0.75F, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
if (this.size() <= MAX_COMPILED_SCRIPTS) {
return false;
}
Carbon.INSTANCE.OSADispose(AppleScriptEngine.this.component, eldest.getValue());
return true;
}
};

private AppleScriptEngine() {
this.component = Carbon.INSTANCE.OpenDefaultComponent(Carbon.K_OSA_COMPONENT_TYPE, Carbon.K_APPLE_SCRIPT_SUBTYPE);
if (this.component == null) {
throw new IllegalStateException("AppleScript component is not available");
}

int error = Carbon.INSTANCE.OSASetSendProc(this.component, SEND_PROC, null);
if (error != 0) {
throw new IllegalStateException("Could not set the AppleScript send procedure: " + error);
}

error = Carbon.INSTANCE.OSASetActiveProc(this.component, ACTIVE_PROC, null);
if (error != 0) {
throw new IllegalStateException("Could not set the AppleScript active procedure: " + error);
}
}

/**
* Get the shared engine of this process.
*
* @return The engine or null if AppleScript can't be executed in-process on this system
*/
public static synchronized AppleScriptEngine getInstance() {
if (instance == null && !unavailable) {
try {
instance = new AppleScriptEngine();
} catch (Throwable e) {
unavailable = true;
}
}
return instance;
}

/**
* Compile and execute an AppleScript source.
* The compiled script is kept, executing the same source again skips the compilation.
*
* @param source The AppleScript source code
* @return The result in the same human-readable form osascript prints it
* @throws Exception If the script could not be compiled or failed during execution
*/
public synchronized String execute(String source) throws Exception {
Pointer pool = ObjC.INSTANCE.objc_autoreleasePoolPush();
try {
int scriptId = this.compile(source);

IntByReference resultId = new IntByReference(Carbon.K_OSA_NULL_SCRIPT);
this.check(Carbon.INSTANCE.OSAExecute(
this.component,
scriptId,
Carbon.K_OSA_NULL_SCRIPT,
Carbon.K_OSA_MODE_NULL,
resultId
));

try {
return this.display(resultId.getValue());
} finally {
Carbon.INSTANCE.OSADispose(this.component, resultId.getValue());
}
} finally {
ObjC.INSTANCE.objc_autoreleasePoolPop(pool);
}
}

private int compile(String source) throws Exception {
Integer compiledScriptId = this.compiledScripts.get(source);
if (compiledScriptId != null) {
return compiledScriptId;
}

byte[] bytes = source.getBytes(StandardCharsets.UTF_8);
Carbon.AEDesc sourceDesc = new Carbon.AEDesc();
short error = Carbon.INSTANCE.AECreateDesc(Carbon.TYPE_UTF8_TEXT, bytes, bytes.length, sourceDesc);
if (error != 0) {
throw new Exception("Could not create AppleScript source descriptor: " + error);
}

IntByReference scriptId = new IntByReference(Carbon.K_OSA_NULL_SCRIPT);
try {
this.check(Carbon.INSTANCE.OSACompile(this.component, sourceDesc, Carbon.K_OSA_MODE_NULL, scriptId));
} finally {
Carbon.INSTANCE.AEDisposeDesc(sourceDesc);
}

this.compiledScripts.put(source, scriptId.getValue());
return scriptId.getValue();
}

private String display(int scriptValueId) throws Exception {
Carbon.AEDesc text = new Carbon.AEDesc();
this.check(Carbon.INSTANCE.OSADisplay(
this.component,
scriptValueId,
Carbon.TYPE_UTF8_TEXT,
Carbon.K_OSA_MODE_DISPLAY_FOR_HUMANS,
text
));

try {
return new String(readData(text), StandardCharsets.UTF_8);
} finally {
Carbon.INSTANCE.AEDisposeDesc(text);
}
}

private void check(int error) throws Exception {
if (error == 0) {
return;
}

// Anything but a script error has no further details
if (error != Carbon.ERR_OSA_SCRIPT_ERROR) {
throw new Exception("error " + error);
}

Carbon.AEDesc number = new Carbon.AEDesc();
Carbon.AEDesc message = new Carbon.AEDesc();
try {
Carbon.INSTANCE.OSAScriptError(this.component, Carbon.K_OSA_ERROR_NUMBER, Carbon.TYPE_SINT32, number);
Carbon.INSTANCE.OSAScriptError(this.component, Carbon.K_OSA_ERROR_MESSAGE, Carbon.TYPE_UTF8_TEXT, message);

byte[] numberData = readData(number);
int errorNumber = numberData.length == 4
? ByteBuffer.wrap(numberData).order(ByteOrder.nativeOrder()).getInt()
: error;
throw new Exception("error " + errorNumber + ": " + new String(readData(message), StandardCharsets.UTF_8));
} finally {
Carbon.INSTANCE.AEDisposeDesc(number);
Carbon.INSTANCE.AEDisposeDesc(message);
}
}

private static byte[] readData(Carbon.AEDesc desc) {
long size = Carbon.INSTANCE.AEGetDescDataSize(desc);
byte[] data = new byte[(int) size];
if (size > 0) {
Carbon.INSTANCE.AEGetDescData(desc, data, size);
}
return data;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package de.labystudio.spotifyapi.platform.osx.api.jna;

import com.sun.jna.Callback;
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.Structure;
import com.sun.jna.ptr.IntByReference;

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;

/**
* The Apple Event Manager and Open Scripting Architecture functions of the Carbon framework.
* They compile and execute AppleScript inside the current process.
*/
public interface Carbon extends Library {

Carbon INSTANCE = Native.load("Carbon", Carbon.class);

int K_OSA_COMPONENT_TYPE = fourCharCode("osa ");
int K_APPLE_SCRIPT_SUBTYPE = fourCharCode("ascr");

int K_OSA_NULL_SCRIPT = 0;
int K_OSA_MODE_NULL = 0;
int K_OSA_MODE_DISPLAY_FOR_HUMANS = 0x00000008;

int K_OSA_ERROR_NUMBER = fourCharCode("errn");
int K_OSA_ERROR_MESSAGE = fourCharCode("errs");

int TYPE_UTF8_TEXT = fourCharCode("utf8");
int TYPE_SINT32 = fourCharCode("long");

int ERR_OSA_SCRIPT_ERROR = -1753;

Pointer OpenDefaultComponent(int componentType, int componentSubType);

short AECreateDesc(int typeCode, byte[] dataPtr, long dataSize, AEDesc result);

long AEGetDescDataSize(AEDesc desc);

short AEGetDescData(AEDesc desc, byte[] dataPtr, long maximumSize);

short AEDisposeDesc(AEDesc desc);

int OSACompile(Pointer scriptingComponent, AEDesc sourceData, int modeFlags, IntByReference previousAndResultingScriptID);

int OSAExecute(Pointer scriptingComponent, int compiledScriptID, int contextID, int modeFlags, IntByReference resultingScriptValueID);

int OSADisplay(Pointer scriptingComponent, int scriptValueID, int desiredType, int modeFlags, AEDesc resultingText);

int OSAScriptError(Pointer scriptingComponent, int selector, int desiredType, AEDesc resultingErrorDescription);

int OSADispose(Pointer scriptingComponent, int scriptID);

int OSASetSendProc(Pointer scriptingComponent, OSASendProc sendProc, Pointer refCon);

int OSASetActiveProc(Pointer scriptingComponent, OSAActiveProc activeProc, Pointer refCon);

int AESendMessage(Pointer event, Pointer reply, int sendMode, long timeOutInTicks);

interface OSAActiveProc extends Callback {
short invoke(Pointer refCon);
}

interface OSASendProc extends Callback {
short invoke(Pointer appleEvent, Pointer reply, int sendMode, short sendPriority, int timeOutInTicks,
Pointer idleProc, Pointer filterProc, Pointer refCon);
}

/**
* Convert a four character code like "utf8" into its integer representation.
*
* @param code The four character code
* @return The integer representation of the code
*/
static int fourCharCode(String code) {
byte[] bytes = code.getBytes(StandardCharsets.US_ASCII);
return (bytes[0] & 0xFF) << 24 | (bytes[1] & 0xFF) << 16 | (bytes[2] & 0xFF) << 8 | (bytes[3] & 0xFF);
}

class AEDesc extends Structure {

public int descriptorType;
public Pointer dataHandle;

@Override
protected List<String> getFieldOrder() {
return Arrays.asList("descriptorType", "dataHandle");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package de.labystudio.spotifyapi.platform.osx.api.jna;

import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Pointer;

/**
* The autorelease pool functions of the Objective-C runtime.
* A Java thread has no autorelease pool, objects the system frameworks autorelease on it would never be freed.
*/
public interface ObjC extends Library {

ObjC INSTANCE = Native.load("objc", ObjC.class);

Pointer objc_autoreleasePoolPush();

void objc_autoreleasePoolPop(Pointer pool);
}