diff --git a/.gitignore b/.gitignore index b35bb5e..190649d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ pom.xml.versionsBackup .idea/compiler.xml .idea/jarRepositories.xml *.iml + +# MacOS +.DS_Store \ No newline at end of file diff --git a/src/main/headers/org_cryptomator_macos_tray_MacTrayMenuController_Native.h b/src/main/headers/org_cryptomator_macos_tray_MacTrayMenuController_Native.h new file mode 100644 index 0000000..62b411c --- /dev/null +++ b/src/main/headers/org_cryptomator_macos_tray_MacTrayMenuController_Native.h @@ -0,0 +1,71 @@ +/* DO NOT EDIT THIS FILE - it is machine generated */ +#include +/* Header for class org_cryptomator_macos_tray_MacTrayMenuController_Native */ + +#ifndef _Included_org_cryptomator_macos_tray_MacTrayMenuController_Native +#define _Included_org_cryptomator_macos_tray_MacTrayMenuController_Native +#ifdef __cplusplus +extern "C" { +#endif +#undef org_cryptomator_macos_tray_MacTrayMenuController_Native_ROOT_MENU +#define org_cryptomator_macos_tray_MacTrayMenuController_Native_ROOT_MENU 0LL +/* + * Class: org_cryptomator_macos_tray_MacTrayMenuController_Native + * Method: showTrayIcon + * Signature: ([BLjava/lang/String;Ljava/lang/Runnable;)V + */ +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_showTrayIcon + (JNIEnv *, jobject, jbyteArray, jstring, jobject); + +/* + * Class: org_cryptomator_macos_tray_MacTrayMenuController_Native + * Method: updateTrayIcon + * Signature: ([B)V + */ +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_updateTrayIcon + (JNIEnv *, jobject, jbyteArray); + +/* + * Class: org_cryptomator_macos_tray_MacTrayMenuController_Native + * Method: clearMenu + * Signature: ()V + */ +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_clearMenu + (JNIEnv *, jobject); + +/* + * Class: org_cryptomator_macos_tray_MacTrayMenuController_Native + * Method: addActionItem + * Signature: (JLjava/lang/String;ZLjava/lang/Runnable;)V + */ +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_addActionItem + (JNIEnv *, jobject, jlong, jstring, jboolean, jobject); + +/* + * Class: org_cryptomator_macos_tray_MacTrayMenuController_Native + * Method: addSeparator + * Signature: (J)V + */ +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_addSeparator + (JNIEnv *, jobject, jlong); + +/* + * Class: org_cryptomator_macos_tray_MacTrayMenuController_Native + * Method: addSubMenuItem + * Signature: (JLjava/lang/String;)J + */ +JNIEXPORT jlong JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_addSubMenuItem + (JNIEnv *, jobject, jlong, jstring); + +/* + * Class: org_cryptomator_macos_tray_MacTrayMenuController_Native + * Method: setBeforeOpenMenuListener + * Signature: (Ljava/lang/Runnable;)V + */ +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_setBeforeOpenMenuListener + (JNIEnv *, jobject, jobject); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 25c4ab3..dc2b4e7 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -2,6 +2,7 @@ import org.cryptomator.integrations.keychain.KeychainAccessProvider; import org.cryptomator.integrations.revealpath.RevealPathService; import org.cryptomator.integrations.tray.TrayIntegrationProvider; +import org.cryptomator.integrations.tray.TrayMenuController; import org.cryptomator.integrations.uiappearance.UiAppearanceProvider; import org.cryptomator.integrations.update.UpdateMechanism; import org.cryptomator.macos.autostart.MacAutoStartProvider; @@ -9,6 +10,7 @@ import org.cryptomator.macos.keychain.TouchIdKeychainAccess; import org.cryptomator.macos.revealpath.OpenCmdRevealPathService; import org.cryptomator.macos.tray.MacTrayIntegrationProvider; +import org.cryptomator.macos.tray.MacTrayMenuController; import org.cryptomator.macos.uiappearance.MacUiAppearanceProvider; import org.cryptomator.macos.update.DmgUpdateMechanism; @@ -20,6 +22,7 @@ provides KeychainAccessProvider with MacSystemKeychainAccess, TouchIdKeychainAccess; provides RevealPathService with OpenCmdRevealPathService; provides TrayIntegrationProvider with MacTrayIntegrationProvider; + provides TrayMenuController with MacTrayMenuController; provides UiAppearanceProvider with MacUiAppearanceProvider; provides UpdateMechanism with DmgUpdateMechanism; } \ No newline at end of file diff --git a/src/main/java/org/cryptomator/macos/tray/MacTrayMenuController.java b/src/main/java/org/cryptomator/macos/tray/MacTrayMenuController.java new file mode 100644 index 0000000..a8f16de --- /dev/null +++ b/src/main/java/org/cryptomator/macos/tray/MacTrayMenuController.java @@ -0,0 +1,87 @@ +package org.cryptomator.macos.tray; + +import org.cryptomator.integrations.common.OperatingSystem; +import org.cryptomator.integrations.common.Priority; +import org.cryptomator.integrations.tray.ActionItem; +import org.cryptomator.integrations.tray.SeparatorItem; +import org.cryptomator.integrations.tray.SubMenuItem; +import org.cryptomator.integrations.tray.TrayIconLoader; +import org.cryptomator.integrations.tray.TrayMenuController; +import org.cryptomator.integrations.tray.TrayMenuException; +import org.cryptomator.integrations.tray.TrayMenuItem; +import org.cryptomator.macos.common.NativeLibLoader; + +import java.util.List; +import java.util.function.Consumer; + +@Priority(1000) +@OperatingSystem(OperatingSystem.Value.MAC) +public class MacTrayMenuController implements TrayMenuController { + + @Override + public void showTrayIcon(Consumer iconLoader, Runnable defaultAction, String tooltip) throws TrayMenuException { + Native.INSTANCE.showTrayIcon(loadPng(iconLoader), tooltip, defaultAction); + } + + @Override + public void updateTrayIcon(Consumer iconLoader) { + Native.INSTANCE.updateTrayIcon(loadPng(iconLoader)); + } + + @Override + public void updateTrayMenu(List items) { + Native.INSTANCE.clearMenu(); + buildMenu(Native.ROOT_MENU, items); + } + + @Override + public void onBeforeOpenMenu(Runnable listener) { + Native.INSTANCE.setBeforeOpenMenuListener(listener); + } + + private static byte[] loadPng(Consumer iconLoader) { + byte[][] holder = {null}; + iconLoader.accept((TrayIconLoader.PngData) data -> holder[0] = data); + if (holder[0] == null) { + throw new IllegalStateException("Icon loader did not provide PNG data"); + } + return holder[0]; + } + + private void buildMenu(long menuHandle, List items) { + for (var item : items) { + switch (item) { + case ActionItem a -> Native.INSTANCE.addActionItem(menuHandle, a.title(), a.enabled(), a.action()); + case SeparatorItem ignored -> Native.INSTANCE.addSeparator(menuHandle); + case SubMenuItem s -> { + long submenuHandle = Native.INSTANCE.addSubMenuItem(menuHandle, s.title()); + buildMenu(submenuHandle, s.items()); + } + } + } + } + + private static final class Native { + + static final long ROOT_MENU = 0L; + static final Native INSTANCE = new Native(); + + private Native() { + NativeLibLoader.loadLib(); + } + + native void showTrayIcon(byte[] pngData, String tooltip, Runnable defaultAction); + + native void updateTrayIcon(byte[] pngData); + + native void clearMenu(); + + native void addActionItem(long menuHandle, String title, boolean enabled, Runnable action); + + native void addSeparator(long menuHandle); + + native long addSubMenuItem(long menuHandle, String title); + + native void setBeforeOpenMenuListener(Runnable listener); + } +} diff --git a/src/main/native/Integrations.xcodeproj/project.pbxproj b/src/main/native/Integrations.xcodeproj/project.pbxproj index a1c46cb..23a3751 100644 --- a/src/main/native/Integrations.xcodeproj/project.pbxproj +++ b/src/main/native/Integrations.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 06B9771B2FB9FC7200949F2E /* org_cryptomator_macos_tray_MacTrayMenuController_Native.m in Sources */ = {isa = PBXBuildFile; fileRef = 06B9771A2FB9FC7200949F2E /* org_cryptomator_macos_tray_MacTrayMenuController_Native.m */; }; 74BEF1852C0384FB006731AC /* SKYLaunchService.m in Sources */ = {isa = PBXBuildFile; fileRef = 74BEF1842C0384FB006731AC /* SKYLaunchService.m */; }; 74BEF1862C0384FB006731AC /* SKYLaunchService.h in Headers */ = {isa = PBXBuildFile; fileRef = 74BEF1832C0384FB006731AC /* SKYLaunchService.h */; }; 74CF2E7B254C295A006266D6 /* org_cryptomator_macos_autostart_MacLaunchServices_Native.m in Sources */ = {isa = PBXBuildFile; fileRef = 74CF2E7A254C295A006266D6 /* org_cryptomator_macos_autostart_MacLaunchServices_Native.m */; }; @@ -21,6 +22,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 06B9771A2FB9FC7200949F2E /* org_cryptomator_macos_tray_MacTrayMenuController_Native.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = org_cryptomator_macos_tray_MacTrayMenuController_Native.m; sourceTree = ""; }; 74BEF1832C0384FB006731AC /* SKYLaunchService.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SKYLaunchService.h; sourceTree = ""; }; 74BEF1842C0384FB006731AC /* SKYLaunchService.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SKYLaunchService.m; sourceTree = ""; }; 74CF2E7A254C295A006266D6 /* org_cryptomator_macos_autostart_MacLaunchServices_Native.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = org_cryptomator_macos_autostart_MacLaunchServices_Native.m; sourceTree = ""; }; @@ -49,6 +51,7 @@ 9E0819AE1D748ECC000C89E6 = { isa = PBXGroup; children = ( + 06B9771A2FB9FC7200949F2E /* org_cryptomator_macos_tray_MacTrayMenuController_Native.m */, 74CF2E7A254C295A006266D6 /* org_cryptomator_macos_autostart_MacLaunchServices_Native.m */, 9E0819C71D75CABC000C89E6 /* org_cryptomator_macos_keychain_MacKeychain_Native.m */, 9EACB1B325557865000F3214 /* org_cryptomator_macos_tray_ActivationPolicy_Native.m */, @@ -145,6 +148,7 @@ 74BEF1852C0384FB006731AC /* SKYLaunchService.m in Sources */, 9EACB1B425557865000F3214 /* org_cryptomator_macos_tray_ActivationPolicy_Native.m in Sources */, 74CF2E7B254C295A006266D6 /* org_cryptomator_macos_autostart_MacLaunchServices_Native.m in Sources */, + 06B9771B2FB9FC7200949F2E /* org_cryptomator_macos_tray_MacTrayMenuController_Native.m in Sources */, 9E21340C255429CA006EA872 /* SKYAppearanceObserver.m in Sources */, 9E21340025542735006EA872 /* org_cryptomator_macos_uiappearance_AppAppearance_Native.m in Sources */, 9E21341225542ECE006EA872 /* SKYAppearanceNotifier.m in Sources */, diff --git a/src/main/native/org_cryptomator_macos_tray_MacTrayMenuController_Native.m b/src/main/native/org_cryptomator_macos_tray_MacTrayMenuController_Native.m new file mode 100644 index 0000000..485918c --- /dev/null +++ b/src/main/native/org_cryptomator_macos_tray_MacTrayMenuController_Native.m @@ -0,0 +1,282 @@ +// +// org_cryptomator_macos_tray_MacTrayMenuController_Native.m +// + +#import +#include +#include "org_cryptomator_macos_tray_MacTrayMenuController_Native.h" + +static JavaVM *gJvm = NULL; +static NSStatusItem *gStatusItem = nil; +static NSMenu *gRootMenu = nil; + +static jobject gBeforeOpenListener = NULL; + +// Cached java.lang.Runnable.run() method ID for invoking Java callbacks. +static jmethodID gRunnableRun = NULL; + +static JNIEnv *getEnv(void) { + JNIEnv *env = NULL; + (*gJvm)->AttachCurrentThread(gJvm, (void **)&env, NULL); + return env; +} + +static void callRunnable(JNIEnv *env, jobject runnable) { + (*env)->CallVoidMethod(env, runnable, gRunnableRun); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionDescribe(env); // Log to stderr for debugging + (*env)->ExceptionClear(env); + } +} + +@interface SKYActionHandler : NSObject +- (instancetype)initWithAction:(jobject)action; +- (void)fire:(id)sender; +@end + +@implementation SKYActionHandler { + jobject _action; +} + +- (instancetype)initWithAction:(jobject)action { + self = [super init]; + _action = action; // global ref owned by this handler + return self; +} + +- (void)dealloc { + JNIEnv *env = getEnv(); + (*env)->DeleteGlobalRef(env, _action); +} + +- (void)fire:(id)sender { + JNIEnv *env = getEnv(); + callRunnable(env, _action); +} + +@end + +@interface SKYMenuDelegate : NSObject +@end + +@implementation SKYMenuDelegate + +- (void)menuWillOpen:(NSMenu *)menu { + // Only the root tray menu shall trigger the callback, not submenus. + if (menu != gRootMenu) { + return; + } + if (gBeforeOpenListener != NULL) { + JNIEnv *env = getEnv(); + callRunnable(env, gBeforeOpenListener); + } +} + +@end + +// NSMenuItem.target is a weak reference, so action handlers are retained here +// for the menu's lifetime and released in clearMenu(). +static NSMutableArray *gActionHandlers = nil; + +// Single delegate created during JNI_OnLoad so its lifecycle is independent of +// the call order of showTrayIcon and setBeforeOpenMenuListener. +static SKYMenuDelegate *gMenuDelegate = nil; + +// Menu handles crossing the JNI boundary are raw NSMenu pointers cast to jlong; +// 0 (ROOT_MENU) denotes the status item's root menu. +static NSMenu *menuFromHandle(jlong handle) { + if (handle == 0L) { + return gRootMenu; + } + return (__bridge NSMenu *)(void *)(intptr_t)handle; +} + +JNIEXPORT jint JNI_OnLoad(JavaVM *jvm, void *reserved) { + @autoreleasepool { + gJvm = jvm; + gActionHandlers = [NSMutableArray array]; + gMenuDelegate = [[SKYMenuDelegate alloc] init]; + + JNIEnv *env; + if ((*jvm)->GetEnv(jvm, (void **)&env, JNI_VERSION_1_8) == JNI_OK) { + jclass runnableClass = (*env)->FindClass(env, "java/lang/Runnable"); + gRunnableRun = (*env)->GetMethodID(env, runnableClass, "run", "()V"); + (*env)->DeleteLocalRef(env, runnableClass); + } + } + return JNI_VERSION_1_8; +} + +JNIEXPORT void JNI_OnUnload(JavaVM *jvm, void *reserved) { + @autoreleasepool { + if (gBeforeOpenListener != NULL) { + JNIEnv *env = getEnv(); + (*env)->DeleteGlobalRef(env, gBeforeOpenListener); + gBeforeOpenListener = NULL; + } + gMenuDelegate = nil; + gActionHandlers = nil; + gJvm = NULL; + } +} + +static void runOnMainThread(void (^block)(void)) { + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_sync(dispatch_get_main_queue(), block); + } +} + +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_showTrayIcon +(JNIEnv *env, jobject obj, jbyteArray pngData, jstring tooltip, jobject defaultAction) { + // defaultAction is intentionally unused: this status item always presents its + // menu on click (see gStatusItem.menu below) + (void)defaultAction; + + @autoreleasepool { + jsize len = (*env)->GetArrayLength(env, pngData); + jbyte *bytes = (*env)->GetByteArrayElements(env, pngData, NULL); + NSData *imageData = [NSData dataWithBytes:bytes length:len]; + (*env)->ReleaseByteArrayElements(env, pngData, bytes, JNI_ABORT); + + const char *tooltipChars = (*env)->GetStringUTFChars(env, tooltip, NULL); + NSString *tooltipStr = [NSString stringWithUTF8String:tooltipChars]; + (*env)->ReleaseStringUTFChars(env, tooltip, tooltipChars); + + runOnMainThread((dispatch_block_t)^(void) { + @autoreleasepool { + if (gStatusItem != nil) { + [[NSStatusBar systemStatusBar] removeStatusItem:gStatusItem]; + gStatusItem = nil; + } + + [gRootMenu removeAllItems]; + [gActionHandlers removeAllObjects]; + + gRootMenu = [[NSMenu alloc] init]; + [gRootMenu setAutoenablesItems:NO]; + gRootMenu.delegate = gMenuDelegate; + + NSImage *image = [[NSImage alloc] initWithData:imageData]; + image.size = NSMakeSize(18, 18); + image.template = YES; + + gStatusItem = [[NSStatusBar systemStatusBar] statusItemWithLength:NSSquareStatusItemLength]; + gStatusItem.button.image = image; + gStatusItem.button.toolTip = tooltipStr; + gStatusItem.menu = gRootMenu; + } + }); + } +} + +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_updateTrayIcon +(JNIEnv *env, jobject obj, jbyteArray pngData) { + @autoreleasepool { + jsize len = (*env)->GetArrayLength(env, pngData); + jbyte *bytes = (*env)->GetByteArrayElements(env, pngData, NULL); + NSData *imageData = [NSData dataWithBytes:bytes length:len]; + (*env)->ReleaseByteArrayElements(env, pngData, bytes, JNI_ABORT); + + runOnMainThread((dispatch_block_t)^(void) { + @autoreleasepool { + NSImage *image = [[NSImage alloc] initWithData:imageData]; + image.size = NSMakeSize(18, 18); + image.template = YES; + gStatusItem.button.image = image; + } + }); + } +} + +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_clearMenu +(JNIEnv *env, jobject obj) { + runOnMainThread((dispatch_block_t)^(void) { + @autoreleasepool { + [gRootMenu removeAllItems]; + [gActionHandlers removeAllObjects]; + } + }); +} + +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_addActionItem +(JNIEnv *env, jobject obj, jlong menuHandle, jstring title, jboolean enabled, jobject action) { + @autoreleasepool { + const char *titleChars = (*env)->GetStringUTFChars(env, title, NULL); + NSString *titleStr = [NSString stringWithUTF8String:titleChars]; + (*env)->ReleaseStringUTFChars(env, title, titleChars); + + jobject actionRef = (*env)->NewGlobalRef(env, action); + + runOnMainThread((dispatch_block_t)^(void) { + @autoreleasepool { + SKYActionHandler *handler = [[SKYActionHandler alloc] initWithAction:actionRef]; + [gActionHandlers addObject:handler]; + + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:titleStr + action:@selector(fire:) + keyEquivalent:@""]; + item.target = handler; + item.enabled = (BOOL)enabled; + [menuFromHandle(menuHandle) addItem:item]; + } + }); + } +} + +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_addSeparator +(JNIEnv *env, jobject obj, jlong menuHandle) { + runOnMainThread((dispatch_block_t)^(void) { + @autoreleasepool { + [menuFromHandle(menuHandle) addItem:[NSMenuItem separatorItem]]; + } + }); +} + +JNIEXPORT jlong JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_addSubMenuItem +(JNIEnv *env, jobject obj, jlong menuHandle, jstring title) { + __block jlong result = 0L; + + @autoreleasepool { + const char *titleChars = (*env)->GetStringUTFChars(env, title, NULL); + NSString *titleStr = [NSString stringWithUTF8String:titleChars]; + (*env)->ReleaseStringUTFChars(env, title, titleChars); + + runOnMainThread((dispatch_block_t)^(void) { + @autoreleasepool { + NSMenu *submenu = [[NSMenu alloc] initWithTitle:titleStr]; + [submenu setAutoenablesItems:NO]; + // Submenus share the delegate, but menuWillOpen: ignores non-root menus. + submenu.delegate = gMenuDelegate; + + NSMenuItem *parentItem = [[NSMenuItem alloc] initWithTitle:titleStr + action:NULL + keyEquivalent:@""]; + parentItem.submenu = submenu; + [menuFromHandle(menuHandle) addItem:parentItem]; + + result = (jlong)(intptr_t)(__bridge void *)submenu; + } + }); + } + + return result; +} + +JNIEXPORT void JNICALL Java_org_cryptomator_macos_tray_MacTrayMenuController_00024Native_setBeforeOpenMenuListener +(JNIEnv *env, jobject obj, jobject listener) { + @autoreleasepool { + jobject listenerRef = (*env)->NewGlobalRef(env, listener); + + runOnMainThread((dispatch_block_t)^(void) { + @autoreleasepool { + if (gBeforeOpenListener != NULL) { + JNIEnv *mainEnv = getEnv(); + (*mainEnv)->DeleteGlobalRef(mainEnv, gBeforeOpenListener); + } + gBeforeOpenListener = listenerRef; + } + }); + } +}