diff --git a/src/main/java/de/labystudio/spotifyapi/platform/osx/api/AppleScript.java b/src/main/java/de/labystudio/spotifyapi/platform/osx/api/AppleScript.java
index 31164ee..9a2257e 100644
--- a/src/main/java/de/labystudio/spotifyapi/platform/osx/api/AppleScript.java
+++ b/src/main/java/de/labystudio/spotifyapi/platform/osx/api/AppleScript.java
@@ -58,17 +58,34 @@ public String get(Action request) throws Exception {
/**
* Execute an AppleScript command.
*
- * It basically calls the AppleScript application with the following command:
- * osascript -e tell application "{@literal <}application{@literal >}" to {@literal <}action{@literal >}
+ * It executes the following script, in-process if possible:
+ * tell application "{@literal <}application{@literal >}" to {@literal <}action{@literal >}
+ *
+ * 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);
diff --git a/src/main/java/de/labystudio/spotifyapi/platform/osx/api/AppleScriptEngine.java b/src/main/java/de/labystudio/spotifyapi/platform/osx/api/AppleScriptEngine.java
new file mode 100644
index 0000000..b215d63
--- /dev/null
+++ b/src/main/java/de/labystudio/spotifyapi/platform/osx/api/AppleScriptEngine.java
@@ -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.
+ *
+ * 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.
+ *
+ * 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 compiledScripts = new LinkedHashMap(16, 0.75F, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry 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;
+ }
+
+}
diff --git a/src/main/java/de/labystudio/spotifyapi/platform/osx/api/jna/Carbon.java b/src/main/java/de/labystudio/spotifyapi/platform/osx/api/jna/Carbon.java
new file mode 100644
index 0000000..1763fb6
--- /dev/null
+++ b/src/main/java/de/labystudio/spotifyapi/platform/osx/api/jna/Carbon.java
@@ -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 getFieldOrder() {
+ return Arrays.asList("descriptorType", "dataHandle");
+ }
+ }
+}
diff --git a/src/main/java/de/labystudio/spotifyapi/platform/osx/api/jna/ObjC.java b/src/main/java/de/labystudio/spotifyapi/platform/osx/api/jna/ObjC.java
new file mode 100644
index 0000000..35cbab3
--- /dev/null
+++ b/src/main/java/de/labystudio/spotifyapi/platform/osx/api/jna/ObjC.java
@@ -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);
+}