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); +}