Browse Source

Add AppindicatorTrayMenuController

Ralph Plawetzki 1 year ago
parent
commit
6da107f4db

File diff suppressed because it is too large
+ 1 - 1
.idea/runConfigurations/Cryptomator_Linux.xml


File diff suppressed because it is too large
+ 1 - 1
.idea/runConfigurations/Cryptomator_Linux_Dev.xml


+ 9 - 1
pom.xml

@@ -34,7 +34,7 @@
 
 		<!-- cryptomator dependencies -->
 		<cryptomator.cryptofs.version>2.6.2</cryptomator.cryptofs.version>
-		<cryptomator.integrations.version>1.2.0</cryptomator.integrations.version>
+		<cryptomator.integrations.version>1.3.0-SNAPSHOT</cryptomator.integrations.version>
 		<cryptomator.integrations.win.version>1.2.0</cryptomator.integrations.win.version>
 		<cryptomator.integrations.mac.version>1.2.0</cryptomator.integrations.mac.version>
 		<cryptomator.integrations.linux.version>1.2.0</cryptomator.integrations.linux.version>
@@ -55,6 +55,7 @@
 		<slf4j.version>2.0.6</slf4j.version>
 		<tinyoauth2.version>0.5.1</tinyoauth2.version>
 		<zxcvbn.version>1.7.0</zxcvbn.version>
+		<appindicator.version>1.1.0</appindicator.version>
 
 		<!-- test dependencies -->
 		<junit.jupiter.version>5.9.2</junit.jupiter.version>
@@ -250,6 +251,13 @@
 			<version>${jetbrains.annotations.version}</version>
 			<scope>provided</scope>
 		</dependency>
+
+		<!-- Java bindings for libappindicator -->
+		<dependency>
+			<groupId>org.purejava</groupId>
+			<artifactId>libappindicator-gtk3-java-minimal</artifactId>
+			<version>${appindicator.version}</version>
+		</dependency>
 	</dependencies>
 
 	<build>

+ 3 - 1
src/main/java/module-info.java

@@ -1,6 +1,7 @@
 import ch.qos.logback.classic.spi.Configurator;
 import org.cryptomator.integrations.tray.TrayMenuController;
 import org.cryptomator.logging.LogbackConfiguratorFactory;
+import org.cryptomator.ui.traymenu.AppindicatorTrayMenuController;
 import org.cryptomator.ui.traymenu.AwtTrayMenuController;
 
 open module org.cryptomator.desktop {
@@ -31,12 +32,13 @@ open module org.cryptomator.desktop {
 	requires com.tobiasdiez.easybind;
 	requires dagger;
 	requires io.github.coffeelibs.tinyoauth2client;
+	requires libappindicator.gtk3.java.minimal;
 	requires org.slf4j;
 	requires org.apache.commons.lang3;
 
 	/* TODO: filename-based modules: */
 	requires static javax.inject; /* ugly dagger/guava crap */
 
-	provides TrayMenuController with AwtTrayMenuController;
+	provides TrayMenuController with AwtTrayMenuController, AppindicatorTrayMenuController;
 	provides Configurator with LogbackConfiguratorFactory;
 }

+ 21 - 0
src/main/java/org/cryptomator/ui/traymenu/ActionItemCallback.java

@@ -0,0 +1,21 @@
+package org.cryptomator.ui.traymenu;
+
+import org.cryptomator.integrations.tray.ActionItem;
+import org.purejava.linux.GCallback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ActionItemCallback implements GCallback {
+	private static final Logger LOG = LoggerFactory.getLogger(ActionItemCallback.class);
+	private ActionItem actionItem;
+
+	public ActionItemCallback(ActionItem actionItem) {
+		this.actionItem = actionItem;
+	}
+
+	@Override
+	public void apply() {
+		LOG.debug("Hit tray menu action '{}'", actionItem.title());
+		actionItem.action().run();
+	}
+}

+ 110 - 0
src/main/java/org/cryptomator/ui/traymenu/AppindicatorTrayMenuController.java

@@ -0,0 +1,110 @@
+package org.cryptomator.ui.traymenu;
+
+import org.apache.commons.lang3.SystemUtils;
+import org.cryptomator.integrations.common.CheckAvailability;
+import org.cryptomator.integrations.tray.ActionItem;
+import org.cryptomator.integrations.tray.SeparatorItem;
+import org.cryptomator.integrations.tray.SubMenuItem;
+import org.cryptomator.integrations.tray.TrayMenuController;
+import org.cryptomator.integrations.tray.TrayMenuException;
+import org.cryptomator.integrations.tray.TrayMenuItem;
+import org.purejava.linux.MemoryAllocator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.lang.foreign.MemoryAddress;
+import java.lang.foreign.MemorySession;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Paths;
+import java.util.List;
+
+import static org.purejava.linux.app_indicator_h.*;
+
+@CheckAvailability
+public class AppindicatorTrayMenuController implements TrayMenuController {
+
+	private static final Logger LOG = LoggerFactory.getLogger(AppindicatorTrayMenuController.class);
+
+	private final MemorySession session = MemorySession.openShared();
+	private MemoryAddress indicator;
+	private MemoryAddress menu = gtk_menu_new();
+
+	@CheckAvailability
+	public static boolean isAvailable() {
+		return SystemUtils.IS_OS_LINUX && MemoryAllocator.isLoadedNativeLib();
+	}
+
+	@Override
+	public void showTrayIcon(URI uri, Runnable runnable, String s) throws TrayMenuException {
+		indicator = app_indicator_new(MemoryAllocator.ALLOCATE_FOR("org.cryptomator.Cryptomator"),
+				MemoryAllocator.ALLOCATE_FOR(getAbsolutePath(getPathString(uri))),
+				APP_INDICATOR_CATEGORY_APPLICATION_STATUS());
+		gtk_widget_show_all(menu);
+		app_indicator_set_menu(indicator, menu);
+		app_indicator_set_status(indicator, APP_INDICATOR_STATUS_ACTIVE());
+	}
+
+	@Override
+	public void updateTrayIcon(URI uri) {
+		app_indicator_set_icon(indicator, MemoryAllocator.ALLOCATE_FOR(getAbsolutePath(getPathString(uri))));
+	}
+
+	@Override
+	public void updateTrayMenu(List<TrayMenuItem> items) throws TrayMenuException {
+		menu = gtk_menu_new();
+		addChildren(menu, items);
+		gtk_widget_show_all(menu);
+		app_indicator_set_menu(indicator, menu);
+	}
+
+	@Override
+	public void onBeforeOpenMenu(Runnable runnable) {
+
+	}
+
+	private void addChildren(MemoryAddress menu, List<TrayMenuItem> items) {
+		for (var item : items) {
+			// TODO: use Pattern Matching for switch, once available
+			if (item instanceof ActionItem a) {
+				var gtkMenuItem = gtk_menu_item_new();
+				gtk_menu_item_set_label(gtkMenuItem, MemoryAllocator.ALLOCATE_FOR(a.title()));
+				g_signal_connect_object(gtkMenuItem,
+						MemoryAllocator.ALLOCATE_FOR("activate"),
+						MemoryAllocator.ALLOCATE_CALLBACK_FOR(new ActionItemCallback(a), session),
+						menu,
+						0);
+				gtk_menu_shell_append(menu, gtkMenuItem);
+			} else if (item instanceof SeparatorItem) {
+				var gtkSeparator = gtk_menu_item_new();
+				gtk_menu_shell_append(menu, gtkSeparator);
+			} else if (item instanceof SubMenuItem s) {
+				var gtkMenuItem = gtk_menu_item_new();
+				var gtkSubmenu = gtk_menu_new();
+				gtk_menu_item_set_label(gtkMenuItem, MemoryAllocator.ALLOCATE_FOR(s.title()));
+				addChildren(gtkSubmenu, s.items());
+				gtk_menu_item_set_submenu(gtkMenuItem, gtkSubmenu);
+				gtk_menu_shell_append(menu, gtkMenuItem);
+			}
+			gtk_widget_show_all(menu);
+		}
+	}
+	private String getAbsolutePath(String iconName) {
+		var res = getClass().getClassLoader().getResource(iconName);
+		if (null == res) {
+			throw new IllegalArgumentException("Icon '" + iconName + "' cannot be found in resource folder");
+		}
+		File file = null;
+		try {
+			file = Paths.get(res.toURI()).toFile();
+		} catch (URISyntaxException e) {
+			throw new IllegalArgumentException("Icon '" + iconName + "' cannot be converted to file", e);
+		}
+		return file.getAbsolutePath();
+	}
+
+	private String getPathString(URI uri) {
+		return uri.getPath().substring(1);
+	}
+}

+ 16 - 8
src/main/java/org/cryptomator/ui/traymenu/AwtTrayMenuController.java

@@ -22,25 +22,32 @@ import java.awt.Toolkit;
 import java.awt.TrayIcon;
 import java.awt.event.MouseAdapter;
 import java.awt.event.MouseEvent;
+import java.net.URI;
+import java.util.Base64;
 import java.util.List;
 
+/**
+ * Responsible to manage the tray icon on macOS and Windows using AWT.
+ * For Linux, we use {@link AppindicatorTrayMenuController}
+ */
 @CheckAvailability
 @Priority(Priority.FALLBACK)
 public class AwtTrayMenuController implements TrayMenuController {
 
 	private static final Logger LOG = LoggerFactory.getLogger(AwtTrayMenuController.class);
 
+	private static final String DATA_URI_SCHEME = "data:image/png;base64,";
 	private final PopupMenu menu = new PopupMenu();
 	private TrayIcon trayIcon;
 
 	@CheckAvailability
 	public static boolean isAvailable() {
-		return SystemTray.isSupported();
+		return !SystemUtils.IS_OS_LINUX && SystemTray.isSupported();
 	}
 
 	@Override
-	public void showTrayIcon(byte[] imageData, Runnable defaultAction, String tooltip) throws TrayMenuException {
-		var image = Toolkit.getDefaultToolkit().createImage(imageData);
+	public void showTrayIcon(URI uri, Runnable defaultAction, String tooltip) throws TrayMenuException {
+		var image = Toolkit.getDefaultToolkit().createImage(getImageBytes(uri));
 		trayIcon = new TrayIcon(image, tooltip, menu);
 
 		trayIcon.setImageAutoSize(true);
@@ -57,11 +64,8 @@ public class AwtTrayMenuController implements TrayMenuController {
 	}
 
 	@Override
-	public void updateTrayIcon(byte[] imageData) {
-		if (trayIcon == null) {
-			throw new IllegalStateException("Failed to update the icon as it has not yet been added");
-		}
-		var image = Toolkit.getDefaultToolkit().createImage(imageData);
+	public void updateTrayIcon(URI uri) {
+		var image = Toolkit.getDefaultToolkit().createImage(getImageBytes(uri));
 		trayIcon.setImage(image);
 	}
 
@@ -100,4 +104,8 @@ public class AwtTrayMenuController implements TrayMenuController {
 		}
 	}
 
+	private byte[] getImageBytes(URI uri) {
+		var data = uri.toString().split(DATA_URI_SCHEME)[1];
+		return Base64.getDecoder().decode(data);
+	}
 }

+ 16 - 3
src/main/java/org/cryptomator/ui/traymenu/TrayMenuBuilder.java

@@ -23,7 +23,9 @@ import javafx.beans.Observable;
 import javafx.collections.ObservableList;
 import java.io.IOException;
 import java.io.UncheckedIOException;
+import java.net.URI;
 import java.util.ArrayList;
+import java.util.Base64;
 import java.util.List;
 import java.util.Optional;
 import java.util.ResourceBundle;
@@ -36,6 +38,10 @@ public class TrayMenuBuilder {
 	private static final String TRAY_ICON_UNLOCKED_MAC = "/img/tray_icon_unlocked_mac@2x.png";
 	private static final String TRAY_ICON = "/img/tray_icon.png";
 	private static final String TRAY_ICON_UNLOCKED = "/img/tray_icon_unlocked.png";
+	private static final String TRAY_ICON_SVG = "tray_icon.svg";
+	private static final String TRAY_ICON_UNLOCKED_SVG = "tray_icon_unlocked.svg";
+	private static final String DATA_URI_SCHEME = "data:image/png;base64,";
+	private static final String FILE_URI_SCHEME = "file:///";
 
 	private final ResourceBundle resourceBundle;
 	private final VaultService vaultService;
@@ -155,10 +161,16 @@ public class TrayMenuBuilder {
 		appWindows.showPreferencesWindow(SelectedPreferencesTab.ANY);
 	}
 
-	private byte[] getAppropriateTrayIconImage() {
+	private URI getAppropriateTrayIconImage() {
 		boolean isAnyVaultUnlocked = vaults.stream().anyMatch(Vault::isUnlocked);
 
 		String resourceName;
+
+		if (SystemUtils.IS_OS_LINUX) {
+			resourceName = isAnyVaultUnlocked ? TRAY_ICON_UNLOCKED_SVG : TRAY_ICON_SVG;
+			return URI.create(FILE_URI_SCHEME + resourceName);
+		}
+
 		if (SystemUtils.IS_OS_MAC_OSX) {
 			resourceName = isAnyVaultUnlocked ? TRAY_ICON_UNLOCKED_MAC : TRAY_ICON_MAC;
 		} else {
@@ -167,10 +179,11 @@ public class TrayMenuBuilder {
 
 		try (var image = getClass().getResourceAsStream(resourceName)) {
 			assert image != null;
-			return image.readAllBytes();
+			var imageBytes = image.readAllBytes();
+			var data = Base64.getEncoder().encodeToString(imageBytes);
+			return URI.create(DATA_URI_SCHEME + data);
 		} catch (IOException e) {
 			throw new UncheckedIOException("Failed to load tray icon image: " + resourceName, e);
 		}
 	}
-
 }

File diff suppressed because it is too large
+ 1 - 0
src/main/resources/tray_icon.svg


File diff suppressed because it is too large
+ 1 - 0
src/main/resources/tray_icon_unlocked.svg