Browse Source

Merge pull request #2096 from cryptomator/feature/integrations-api-1.1.0

Integrations API 1.1.0
Sebastian Stenzel 3 năm trước cách đây
mục cha
commit
ccd3da3b09

+ 4 - 4
pom.xml

@@ -28,10 +28,10 @@
 
 		<!-- cryptomator dependencies -->
 		<cryptomator.cryptofs.version>2.4.1</cryptomator.cryptofs.version>
-		<cryptomator.integrations.version>1.1.0-beta1</cryptomator.integrations.version>
-		<cryptomator.integrations.win.version>1.0.0</cryptomator.integrations.win.version>
-		<cryptomator.integrations.mac.version>1.0.0</cryptomator.integrations.mac.version>
-		<cryptomator.integrations.linux.version>1.0.1</cryptomator.integrations.linux.version>
+		<cryptomator.integrations.version>1.1.0</cryptomator.integrations.version>
+		<cryptomator.integrations.win.version>1.1.0</cryptomator.integrations.win.version>
+		<cryptomator.integrations.mac.version>1.1.0</cryptomator.integrations.mac.version>
+		<cryptomator.integrations.linux.version>1.1.0</cryptomator.integrations.linux.version>
 		<cryptomator.fuse.version>1.3.3</cryptomator.fuse.version>
 		<cryptomator.dokany.version>1.3.3</cryptomator.dokany.version>
 		<cryptomator.webdav.version>1.2.7</cryptomator.webdav.version>

+ 4 - 8
src/main/java/module-info.java

@@ -1,7 +1,5 @@
-import org.cryptomator.integrations.autostart.AutoStartProvider;
-import org.cryptomator.integrations.keychain.KeychainAccessProvider;
-import org.cryptomator.integrations.tray.TrayIntegrationProvider;
-import org.cryptomator.integrations.uiappearance.UiAppearanceProvider;
+import org.cryptomator.integrations.tray.TrayMenuController;
+import org.cryptomator.ui.traymenu.AwtTrayMenuController;
 
 module org.cryptomator.desktop {
 	requires static org.jetbrains.annotations;
@@ -31,10 +29,8 @@ module org.cryptomator.desktop {
 	requires logback.classic;
 	requires logback.core;
 
-	uses AutoStartProvider;
-	uses KeychainAccessProvider;
-	uses TrayIntegrationProvider;
-	uses UiAppearanceProvider;
+	exports org.cryptomator.ui.traymenu to org.cryptomator.integrations.api;
+	provides TrayMenuController with AwtTrayMenuController;
 
 	opens org.cryptomator.common.settings to com.google.gson;
 

+ 0 - 66
src/main/java/org/cryptomator/common/PluginClassLoader.java

@@ -1,66 +0,0 @@
-package org.cryptomator.common;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.net.URLClassLoader;
-import java.nio.file.FileVisitOption;
-import java.nio.file.FileVisitResult;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.SimpleFileVisitor;
-import java.nio.file.attribute.BasicFileAttributes;
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.List;
-
-@Singleton
-public class PluginClassLoader extends URLClassLoader {
-
-	private static final Logger LOG = LoggerFactory.getLogger(PluginClassLoader.class);
-	private static final String NAME = "PluginClassLoader";
-	private static final String JAR_SUFFIX = ".jar";
-
-	@Inject
-	public PluginClassLoader(Environment env) {
-		super(NAME, env.getPluginDir().map(PluginClassLoader::findJars).orElse(new URL[0]), PluginClassLoader.class.getClassLoader());
-	}
-
-	private static URL[] findJars(Path path) {
-		if (!Files.isDirectory(path)) {
-			return new URL[0];
-		} else {
-			try {
-				var visitor = new JarVisitor();
-				Files.walkFileTree(path, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, visitor);
-				return visitor.urls.toArray(URL[]::new);
-			} catch (IOException e) {
-				LOG.warn("Failed to scan plugin dir " + path, e);
-				return new URL[0];
-			}
-		}
-	}
-
-	private static final class JarVisitor extends SimpleFileVisitor<Path> {
-
-		private final List<URL> urls = new ArrayList<>();
-
-		@Override
-		public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
-			if (attrs.isRegularFile() && file.getFileName().toString().toLowerCase().endsWith(JAR_SUFFIX)) {
-				try {
-					urls.add(file.toUri().toURL());
-				} catch (MalformedURLException e) {
-					LOG.warn("Failed to create URL for jar file {}", file);
-				}
-			}
-			return FileVisitResult.CONTINUE;
-		}
-	}
-
-}

+ 0 - 14
src/main/java/org/cryptomator/common/keychain/KeychainManager.java

@@ -43,12 +43,6 @@ public class KeychainManager implements KeychainAccessProvider {
 		return getClass().getName();
 	}
 
-	@Override
-	public void storePassphrase(String key, CharSequence passphrase) throws KeychainAccessException {
-		getKeychainOrFail().storePassphrase(key, passphrase);
-		setPassphraseStored(key, true);
-	}
-
 	@Override
 	public void storePassphrase(String key, String displayName, CharSequence passphrase) throws KeychainAccessException {
 		getKeychainOrFail().storePassphrase(key, displayName, passphrase);
@@ -68,14 +62,6 @@ public class KeychainManager implements KeychainAccessProvider {
 		setPassphraseStored(key, false);
 	}
 
-	@Override
-	public void changePassphrase(String key, CharSequence passphrase) throws KeychainAccessException {
-		if (isPassphraseStored(key)) {
-			getKeychainOrFail().changePassphrase(key, passphrase);
-			setPassphraseStored(key, true);
-		}
-	}
-
 	@Override
 	public void changePassphrase(String key, String displayName, CharSequence passphrase) throws KeychainAccessException {
 		if (isPassphraseStored(key)) {

+ 5 - 17
src/main/java/org/cryptomator/common/keychain/KeychainModule.java

@@ -2,42 +2,30 @@ package org.cryptomator.common.keychain;
 
 import dagger.Module;
 import dagger.Provides;
-import org.cryptomator.common.PluginClassLoader;
 import org.cryptomator.common.settings.Settings;
 import org.cryptomator.integrations.keychain.KeychainAccessProvider;
 
 import javax.inject.Singleton;
 import javafx.beans.binding.Bindings;
 import javafx.beans.binding.ObjectExpression;
-import java.util.ServiceLoader;
-import java.util.Set;
-import java.util.stream.Collectors;
+import java.util.List;
 
 @Module
 public class KeychainModule {
 
 	@Provides
 	@Singleton
-	static Set<ServiceLoader.Provider<KeychainAccessProvider>> provideAvailableKeychainAccessProviderFactories(PluginClassLoader classLoader) {
-		return ServiceLoader.load(KeychainAccessProvider.class, classLoader).stream().collect(Collectors.toUnmodifiableSet());
+	static List<KeychainAccessProvider> provideSupportedKeychainAccessProviders() {
+		return KeychainAccessProvider.get().toList();
 	}
 
 	@Provides
 	@Singleton
-	static Set<KeychainAccessProvider> provideSupportedKeychainAccessProviders(Set<ServiceLoader.Provider<KeychainAccessProvider>> availableFactories) {
-		return availableFactories.stream() //
-				.map(ServiceLoader.Provider::get) //
-				.filter(KeychainAccessProvider::isSupported) //
-				.collect(Collectors.toUnmodifiableSet());
-	}
-
-	@Provides
-	@Singleton
-	static ObjectExpression<KeychainAccessProvider> provideKeychainAccessProvider(Settings settings, Set<KeychainAccessProvider> providers) {
+	static ObjectExpression<KeychainAccessProvider> provideKeychainAccessProvider(Settings settings, List<KeychainAccessProvider> providers) {
 		return Bindings.createObjectBinding(() -> {
 			var selectedProviderClass = settings.keychainProvider().get();
 			var selectedProvider = providers.stream().filter(provider -> provider.getClass().getName().equals(selectedProviderClass)).findAny();
-			var fallbackProvider = providers.stream().findAny().orElse(null);
+			var fallbackProvider = providers.stream().findFirst().orElse(null);
 			return selectedProvider.orElse(fallbackProvider);
 		}, settings.keychainProvider());
 	}

+ 6 - 11
src/main/java/org/cryptomator/launcher/CryptomatorModule.java

@@ -2,7 +2,6 @@ package org.cryptomator.launcher;
 
 import dagger.Module;
 import dagger.Provides;
-import org.cryptomator.common.PluginClassLoader;
 import org.cryptomator.integrations.autostart.AutoStartProvider;
 import org.cryptomator.integrations.tray.TrayIntegrationProvider;
 import org.cryptomator.integrations.uiappearance.UiAppearanceProvider;
@@ -12,7 +11,6 @@ import javax.inject.Named;
 import javax.inject.Singleton;
 import java.util.Optional;
 import java.util.ResourceBundle;
-import java.util.ServiceLoader;
 import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.BlockingQueue;
 
@@ -32,25 +30,22 @@ class CryptomatorModule {
 		return new ArrayBlockingQueue<>(10);
 	}
 
-	// TODO: still needed after integrations-api 1.1.0?
-
 	@Provides
 	@Singleton
-	static Optional<UiAppearanceProvider> provideAppearanceProvider(PluginClassLoader classLoader) {
-		return ServiceLoader.load(UiAppearanceProvider.class, classLoader).findFirst();
+	static Optional<UiAppearanceProvider> provideAppearanceProvider() {
+		return UiAppearanceProvider.get();
 	}
 
 	@Provides
 	@Singleton
-	static Optional<AutoStartProvider> provideAutostartProvider(PluginClassLoader classLoader) {
-		return ServiceLoader.load(AutoStartProvider.class, classLoader).findFirst();
+	static Optional<AutoStartProvider> provideAutostartProvider() {
+		return AutoStartProvider.get();
 	}
 
 	@Provides
 	@Singleton
-	static Optional<TrayIntegrationProvider> provideTrayIntegrationProvider(PluginClassLoader classLoader) {
-		return ServiceLoader.load(TrayIntegrationProvider.class, classLoader).findFirst();
+	static Optional<TrayIntegrationProvider> provideTrayIntegrationProvider() {
+		return TrayIntegrationProvider.get();
 	}
 
-
 }

+ 1 - 6
src/main/java/org/cryptomator/ui/fxapp/FxApplication.java

@@ -9,11 +9,6 @@ import org.slf4j.LoggerFactory;
 import javax.inject.Inject;
 import javax.inject.Named;
 import javafx.application.Platform;
-import javafx.stage.Stage;
-import javafx.stage.StageStyle;
-import java.awt.SystemTray;
-import java.io.IOException;
-import java.io.UncheckedIOException;
 
 @FxApplicationScoped
 public class FxApplication {
@@ -49,7 +44,7 @@ public class FxApplication {
 
 		// init system tray
 		final boolean hasTrayIcon;
-		if (SystemTray.isSupported() && settings.showTrayIcon().get()) {
+		if (settings.showTrayIcon().get() && trayMenu.get().isSupported()) {
 			trayMenu.get().initializeTrayIcon();
 			Platform.setImplicitExit(false); // don't quit when closing all windows
 			hasTrayIcon = true;

+ 5 - 5
src/main/java/org/cryptomator/ui/preferences/GeneralPreferencesController.java

@@ -19,8 +19,8 @@ import javafx.scene.control.ChoiceBox;
 import javafx.scene.control.ToggleGroup;
 import javafx.stage.Stage;
 import javafx.util.StringConverter;
+import java.util.List;
 import java.util.Optional;
-import java.util.Set;
 
 @PreferencesScoped
 public class GeneralPreferencesController implements FxController {
@@ -32,7 +32,7 @@ public class GeneralPreferencesController implements FxController {
 	private final Optional<AutoStartProvider> autoStartProvider;
 	private final Application application;
 	private final Environment environment;
-	private final Set<KeychainAccessProvider> keychainAccessProviders;
+	private final List<KeychainAccessProvider> keychainAccessProviders;
 	private final FxApplicationWindows appWindows;
 	public ChoiceBox<KeychainAccessProvider> keychainBackendChoiceBox;
 	public CheckBox startHiddenCheckbox;
@@ -41,7 +41,7 @@ public class GeneralPreferencesController implements FxController {
 	public ToggleGroup nodeOrientation;
 
 	@Inject
-	GeneralPreferencesController(@PreferencesWindow Stage window, Settings settings, Optional<AutoStartProvider> autoStartProvider, Set<KeychainAccessProvider> keychainAccessProviders, Application application, Environment environment, FxApplicationWindows appWindows) {
+	GeneralPreferencesController(@PreferencesWindow Stage window, Settings settings, Optional<AutoStartProvider> autoStartProvider, List<KeychainAccessProvider> keychainAccessProviders, Application application, Environment environment, FxApplicationWindows appWindows) {
 		this.window = window;
 		this.settings = settings;
 		this.autoStartProvider = autoStartProvider;
@@ -115,9 +115,9 @@ public class GeneralPreferencesController implements FxController {
 
 	private static class KeychainProviderClassNameConverter extends StringConverter<KeychainAccessProvider> {
 
-		private final Set<KeychainAccessProvider> keychainAccessProviders;
+		private final List<KeychainAccessProvider> keychainAccessProviders;
 
-		public KeychainProviderClassNameConverter(Set<KeychainAccessProvider> keychainAccessProviders) {
+		public KeychainProviderClassNameConverter(List<KeychainAccessProvider> keychainAccessProviders) {
 			this.keychainAccessProviders = keychainAccessProviders;
 		}
 

+ 79 - 0
src/main/java/org/cryptomator/ui/traymenu/AwtTrayMenuController.java

@@ -0,0 +1,79 @@
+package org.cryptomator.ui.traymenu;
+
+import org.apache.commons.lang3.SystemUtils;
+import org.cryptomator.integrations.common.CheckAvailability;
+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.TrayMenuController;
+import org.cryptomator.integrations.tray.TrayMenuException;
+import org.cryptomator.integrations.tray.TrayMenuItem;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.awt.AWTException;
+import java.awt.Menu;
+import java.awt.MenuItem;
+import java.awt.PopupMenu;
+import java.awt.SystemTray;
+import java.awt.Toolkit;
+import java.awt.TrayIcon;
+import java.util.List;
+
+@CheckAvailability
+@Priority(Priority.FALLBACK)
+public class AwtTrayMenuController implements TrayMenuController {
+
+	private static final Logger LOG = LoggerFactory.getLogger(AwtTrayMenuController.class);
+
+	private final PopupMenu menu = new PopupMenu();
+
+	@CheckAvailability
+	public static boolean isAvailable() {
+		return SystemTray.isSupported();
+	}
+
+	@Override
+	public void showTrayIcon(byte[] rawImageData, Runnable defaultAction, String tooltip) throws TrayMenuException {
+		var image = Toolkit.getDefaultToolkit().createImage(rawImageData);
+		var trayIcon = new TrayIcon(image, tooltip, menu);
+
+		trayIcon.setImageAutoSize(true);
+		if (SystemUtils.IS_OS_WINDOWS) {
+			trayIcon.addActionListener(evt -> defaultAction.run());
+		}
+
+		try {
+			SystemTray.getSystemTray().add(trayIcon);
+			LOG.debug("initialized tray icon");
+		} catch (AWTException e) {
+			throw new TrayMenuException("Failed to add icon to system tray.", e);
+		}
+	}
+
+	@Override
+	public void updateTrayMenu(List<TrayMenuItem> items) {
+		menu.removeAll();
+		addChildren(menu, items);
+	}
+
+	private void addChildren(Menu menu, List<TrayMenuItem> items) {
+		for (var item : items) {
+			// TODO: use Pattern Matching for switch, once available
+			if (item instanceof ActionItem a) {
+				var menuItem = new MenuItem(a.title());
+				menuItem.addActionListener(evt -> a.action().run());
+				menuItem.setEnabled(a.enabled());
+				menu.add(menuItem);
+			} else if (item instanceof SeparatorItem) {
+				menu.addSeparator();
+			} else if (item instanceof SubMenuItem s) {
+				var submenu = new Menu(s.title());
+				addChildren(submenu, s.items());
+				menu.add(submenu);
+			}
+		}
+	}
+
+}

+ 0 - 51
src/main/java/org/cryptomator/ui/traymenu/TrayIconController.java

@@ -1,51 +0,0 @@
-package org.cryptomator.ui.traymenu;
-
-import com.google.common.base.Preconditions;
-import org.apache.commons.lang3.SystemUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.inject.Inject;
-import java.awt.AWTException;
-import java.awt.SystemTray;
-import java.awt.TrayIcon;
-
-@TrayMenuScoped
-public class TrayIconController {
-
-	private static final Logger LOG = LoggerFactory.getLogger(TrayIconController.class);
-
-	private final TrayMenuController trayMenuController;
-	private final TrayIcon trayIcon;
-	private volatile boolean initialized;
-
-	@Inject
-	TrayIconController(TrayImageFactory imageFactory, TrayMenuController trayMenuController) {
-		this.trayMenuController = trayMenuController;
-		this.trayIcon = new TrayIcon(imageFactory.loadImage(), "Cryptomator", trayMenuController.getMenu());
-	}
-
-	public synchronized void initializeTrayIcon() throws IllegalStateException {
-		Preconditions.checkState(!initialized);
-
-		trayIcon.setImageAutoSize(true);
-		if (SystemUtils.IS_OS_WINDOWS) {
-			trayIcon.addActionListener(trayMenuController::showMainWindow);
-		}
-
-		try {
-			SystemTray.getSystemTray().add(trayIcon);
-			LOG.debug("initialized tray icon");
-		} catch (AWTException e) {
-			LOG.error("Error adding tray icon", e);
-		}
-
-		trayMenuController.initTrayMenu();
-
-		this.initialized = true;
-	}
-
-	public boolean isInitialized() {
-		return initialized;
-	}
-}

+ 0 - 35
src/main/java/org/cryptomator/ui/traymenu/TrayImageFactory.java

@@ -1,35 +0,0 @@
-package org.cryptomator.ui.traymenu;
-
-import org.apache.commons.lang3.SystemUtils;
-import org.cryptomator.integrations.uiappearance.Theme;
-import org.cryptomator.integrations.uiappearance.UiAppearanceProvider;
-
-import javax.inject.Inject;
-import java.awt.Image;
-import java.awt.Toolkit;
-import java.util.Optional;
-
-@TrayMenuScoped
-class TrayImageFactory {
-
-	private final Optional<UiAppearanceProvider> appearanceProvider;
-
-	@Inject
-	TrayImageFactory(Optional<UiAppearanceProvider> appearanceProvider) {
-		this.appearanceProvider = appearanceProvider;
-	}
-
-	public Image loadImage() {
-		String resourceName = SystemUtils.IS_OS_MAC_OSX ? getMacResourceName() : getWinOrLinuxResourceName();
-		return Toolkit.getDefaultToolkit().getImage(getClass().getResource(resourceName));
-	}
-
-	private String getMacResourceName() {
-		return "/img/tray_icon_mac.png";
-	}
-
-	private String getWinOrLinuxResourceName() {
-		return "/img/tray_icon.png";
-	}
-
-}

+ 151 - 0
src/main/java/org/cryptomator/ui/traymenu/TrayMenuBuilder.java

@@ -0,0 +1,151 @@
+package org.cryptomator.ui.traymenu;
+
+import com.google.common.base.Preconditions;
+import org.apache.commons.lang3.SystemUtils;
+import org.cryptomator.common.vaults.Vault;
+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.cryptomator.ui.common.VaultService;
+import org.cryptomator.ui.fxapp.FxApplicationTerminator;
+import org.cryptomator.ui.fxapp.FxApplicationWindows;
+import org.cryptomator.ui.preferences.SelectedPreferencesTab;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.inject.Inject;
+import javafx.application.Platform;
+import javafx.beans.Observable;
+import javafx.collections.ObservableList;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.ResourceBundle;
+
+@TrayMenuScoped
+public class TrayMenuBuilder {
+
+	private static final Logger LOG = LoggerFactory.getLogger(TrayMenuBuilder.class);
+	private static final String TRAY_ICON_MAC = "/img/tray_icon_mac.png";
+	private static final String TRAY_ICON = "/img/tray_icon.png";
+
+	private final ResourceBundle resourceBundle;
+	private final VaultService vaultService;
+	private final FxApplicationWindows appWindows;
+	private final FxApplicationTerminator appTerminator;
+	private final ObservableList<Vault> vaults;
+	private final TrayMenuController trayMenu;
+
+	private volatile boolean initialized;
+
+	@Inject
+	TrayMenuBuilder(ResourceBundle resourceBundle, VaultService vaultService, FxApplicationWindows appWindows, FxApplicationTerminator appTerminator, ObservableList<Vault> vaults, Optional<TrayMenuController> trayMenu) {
+		this.resourceBundle = resourceBundle;
+		this.vaultService = vaultService;
+		this.appWindows = appWindows;
+		this.appTerminator = appTerminator;
+		this.vaults = vaults;
+		this.trayMenu = trayMenu.orElse(null);
+	}
+
+	public synchronized void initTrayMenu() {
+		Preconditions.checkState(!initialized, "tray icon already initialized");
+
+		vaults.addListener(this::vaultListChanged);
+		vaults.forEach(v -> {
+			v.displayNameProperty().addListener(this::vaultListChanged);
+		});
+
+		try (var image = getClass().getResourceAsStream(SystemUtils.IS_OS_MAC_OSX ? TRAY_ICON_MAC : TRAY_ICON)) {
+			trayMenu.showTrayIcon(image.readAllBytes(), this::showMainWindow, "Cryptomator");
+			rebuildMenu();
+			initialized = true;
+		} catch (IOException e) {
+			throw new UncheckedIOException("Failed to load embedded resource", e);
+		} catch (TrayMenuException e) {
+			LOG.error("Adding tray icon failed", e);
+		}
+	}
+
+	public boolean isInitialized() {
+		return initialized;
+	}
+
+	private void vaultListChanged(@SuppressWarnings("unused") Observable observable) {
+		assert Platform.isFxApplicationThread();
+		rebuildMenu();
+	}
+
+	private void rebuildMenu() {
+		List<TrayMenuItem> menu = new ArrayList<>();
+
+		menu.add(new ActionItem(resourceBundle.getString("traymenu.showMainWindow"), this::showMainWindow));
+		menu.add(new ActionItem(resourceBundle.getString("traymenu.showPreferencesWindow"), this::showPreferencesWindow));
+		menu.add(new SeparatorItem());
+		for (Vault vault : vaults) {
+			List<TrayMenuItem> submenu = buildSubmenu(vault);
+			var label = vault.isUnlocked() ? "* ".concat(vault.getDisplayName()) : vault.getDisplayName();
+			menu.add(new SubMenuItem(label, submenu));
+		}
+		menu.add(new SeparatorItem());
+		menu.add(new ActionItem(resourceBundle.getString("traymenu.lockAllVaults"), this::lockAllVaults, vaults.stream().anyMatch(Vault::isUnlocked)));
+		menu.add(new ActionItem(resourceBundle.getString("traymenu.quitApplication"), this::quitApplication));
+
+		try {
+			trayMenu.updateTrayMenu(menu);
+		} catch (TrayMenuException e) {
+			LOG.error("Updating tray menu failed", e);
+		}
+	}
+
+	private List<TrayMenuItem> buildSubmenu(Vault vault) {
+		if (vault.isLocked()) {
+			return List.of( //
+					new ActionItem(resourceBundle.getString("traymenu.vault.unlock"), () -> this.unlockVault(vault)) //
+			);
+		} else if (vault.isUnlocked()) {
+			return List.of( //
+					new ActionItem(resourceBundle.getString("traymenu.vault.lock"), () -> this.lockVault(vault)), //
+					new ActionItem(resourceBundle.getString("traymenu.vault.reveal"), () -> this.revealVault(vault)) //
+			);
+		} else {
+			return List.of();
+		}
+	}
+
+	/* action listeners: */
+
+	private void quitApplication() {
+		appTerminator.terminate();
+	}
+
+	private void unlockVault(Vault vault) {
+		appWindows.startUnlockWorkflow(vault, null);
+	}
+
+	private void lockVault(Vault vault) {
+		appWindows.startLockWorkflow(vault, null);
+	}
+
+	private void lockAllVaults() {
+		vaultService.lockAll(vaults.filtered(Vault::isUnlocked), false);
+	}
+
+	private void revealVault(Vault vault) {
+		vaultService.reveal(vault);
+	}
+
+	void showMainWindow() {
+		appWindows.showMainWindow();
+	}
+
+	private void showPreferencesWindow() {
+		appWindows.showPreferencesWindow(SelectedPreferencesTab.ANY);
+	}
+
+}

+ 15 - 9
src/main/java/org/cryptomator/ui/traymenu/TrayMenuComponent.java

@@ -5,38 +5,44 @@
  *******************************************************************************/
 package org.cryptomator.ui.traymenu;
 
-import dagger.Lazy;
+import com.google.common.base.Preconditions;
 import dagger.Subcomponent;
-import java.awt.SystemTray;
+import org.cryptomator.integrations.tray.TrayMenuController;
+
+import java.util.Optional;
 
 @TrayMenuScoped
-@Subcomponent
+@Subcomponent(modules = {TrayMenuModule.class})
 public interface TrayMenuComponent {
 
-	Lazy<TrayIconController> trayIconController();
+	Optional<TrayMenuController> trayMenuController();
+
+	TrayMenuBuilder trayMenuBuilder();
 
 	/**
 	 * @return <code>true</code> if a tray icon can be installed
 	 */
 	default boolean isSupported() {
-		return SystemTray.isSupported();
+		return trayMenuController().isPresent();
 	}
 
 	/**
 	 * @return <code>true</code> if a tray icon has been installed
 	 */
 	default boolean isInitialized() {
-		return isSupported() && trayIconController().get().isInitialized();
+		return isSupported() && trayMenuBuilder().isInitialized();
 	}
 
 	/**
 	 * Installs a tray icon to the system tray.
 	 *
-	 * @throws IllegalStateException If already added
+	 * @throws IllegalStateException If not {@link #isSupported() supported}
 	 */
 	default void initializeTrayIcon() throws IllegalStateException {
-		assert isSupported();
-		trayIconController().get().initializeTrayIcon();
+		Preconditions.checkState(isSupported(), "system tray not supported");
+		if (!trayMenuBuilder().isInitialized()) {
+			trayMenuBuilder().initTrayMenu();
+		}
 	}
 
 	@Subcomponent.Builder

+ 0 - 141
src/main/java/org/cryptomator/ui/traymenu/TrayMenuController.java

@@ -1,141 +0,0 @@
-package org.cryptomator.ui.traymenu;
-
-import org.cryptomator.common.vaults.Vault;
-import org.cryptomator.ui.common.VaultService;
-import org.cryptomator.ui.fxapp.FxApplicationTerminator;
-import org.cryptomator.ui.fxapp.FxApplicationWindows;
-import org.cryptomator.ui.preferences.SelectedPreferencesTab;
-
-import javax.inject.Inject;
-import javafx.application.Platform;
-import javafx.beans.Observable;
-import javafx.collections.ObservableList;
-import java.awt.Menu;
-import java.awt.MenuItem;
-import java.awt.PopupMenu;
-import java.awt.event.ActionEvent;
-import java.awt.event.ActionListener;
-import java.util.EventObject;
-import java.util.ResourceBundle;
-import java.util.function.Consumer;
-
-@TrayMenuScoped
-class TrayMenuController {
-
-	private final ResourceBundle resourceBundle;
-	private final VaultService vaultService;
-	private final FxApplicationWindows appWindows;
-	private final FxApplicationTerminator appTerminator;
-	private final ObservableList<Vault> vaults;
-	private final PopupMenu menu;
-
-	@Inject
-	TrayMenuController(ResourceBundle resourceBundle, VaultService vaultService, FxApplicationWindows appWindows, FxApplicationTerminator appTerminator, ObservableList<Vault> vaults) {
-		this.resourceBundle = resourceBundle;
-		this.vaultService = vaultService;
-		this.appWindows = appWindows;
-		this.appTerminator = appTerminator;
-		this.vaults = vaults;
-		this.menu = new PopupMenu();
-	}
-
-	public PopupMenu getMenu() {
-		return menu;
-	}
-
-	public void initTrayMenu() {
-		vaults.addListener(this::vaultListChanged);
-		vaults.forEach(v -> {
-			v.displayNameProperty().addListener(this::vaultListChanged);
-		});
-		rebuildMenu();
-	}
-
-	private void vaultListChanged(@SuppressWarnings("unused") Observable observable) {
-		assert Platform.isFxApplicationThread();
-		rebuildMenu();
-	}
-
-	private void rebuildMenu() {
-		menu.removeAll();
-
-		MenuItem showMainWindowItem = new MenuItem(resourceBundle.getString("traymenu.showMainWindow"));
-		showMainWindowItem.addActionListener(this::showMainWindow);
-		menu.add(showMainWindowItem);
-
-		MenuItem showPreferencesItem = new MenuItem(resourceBundle.getString("traymenu.showPreferencesWindow"));
-		showPreferencesItem.addActionListener(this::showPreferencesWindow);
-		menu.add(showPreferencesItem);
-
-		menu.addSeparator();
-		for (Vault v : vaults) {
-			MenuItem submenu = buildSubmenu(v);
-			menu.add(submenu);
-		}
-		menu.addSeparator();
-
-		MenuItem lockAllItem = new MenuItem(resourceBundle.getString("traymenu.lockAllVaults"));
-		lockAllItem.addActionListener(this::lockAllVaults);
-		lockAllItem.setEnabled(!vaults.filtered(Vault::isUnlocked).isEmpty());
-		menu.add(lockAllItem);
-
-		MenuItem quitApplicationItem = new MenuItem(resourceBundle.getString("traymenu.quitApplication"));
-		quitApplicationItem.addActionListener(this::quitApplication);
-		menu.add(quitApplicationItem);
-	}
-
-	private Menu buildSubmenu(Vault vault) {
-		Menu submenu = new Menu(vault.getDisplayName());
-
-		if (vault.isLocked()) {
-			MenuItem unlockItem = new MenuItem(resourceBundle.getString("traymenu.vault.unlock"));
-			unlockItem.addActionListener(createActionListenerForVault(vault, this::unlockVault));
-			submenu.add(unlockItem);
-		} else if (vault.isUnlocked()) {
-			submenu.setLabel("* ".concat(submenu.getLabel()));
-
-			MenuItem lockItem = new MenuItem(resourceBundle.getString("traymenu.vault.lock"));
-			lockItem.addActionListener(createActionListenerForVault(vault, this::lockVault));
-			submenu.add(lockItem);
-
-			MenuItem revealItem = new MenuItem(resourceBundle.getString("traymenu.vault.reveal"));
-			revealItem.addActionListener(createActionListenerForVault(vault, this::revealVault));
-			submenu.add(revealItem);
-		}
-
-		return submenu;
-	}
-
-	private ActionListener createActionListenerForVault(Vault vault, Consumer<Vault> consumer) {
-		return actionEvent -> consumer.accept(vault);
-	}
-
-	private void quitApplication(EventObject actionEvent) {
-		appTerminator.terminate();
-	}
-
-	private void unlockVault(Vault vault) {
-		appWindows.startUnlockWorkflow(vault, null);
-	}
-
-	private void lockVault(Vault vault) {
-		appWindows.startLockWorkflow(vault, null);
-	}
-
-	private void lockAllVaults(ActionEvent actionEvent) {
-		vaultService.lockAll(vaults.filtered(Vault::isUnlocked), false);
-	}
-
-	private void revealVault(Vault vault) {
-		vaultService.reveal(vault);
-	}
-
-	void showMainWindow(@SuppressWarnings("unused") ActionEvent actionEvent) {
-		appWindows.showMainWindow();
-	}
-
-	private void showPreferencesWindow(@SuppressWarnings("unused") EventObject actionEvent) {
-		appWindows.showPreferencesWindow(SelectedPreferencesTab.ANY);
-	}
-
-}

+ 18 - 0
src/main/java/org/cryptomator/ui/traymenu/TrayMenuModule.java

@@ -0,0 +1,18 @@
+package org.cryptomator.ui.traymenu;
+
+import dagger.Module;
+import dagger.Provides;
+import org.cryptomator.integrations.tray.TrayMenuController;
+
+import java.util.Optional;
+
+@Module
+public class TrayMenuModule {
+
+	@Provides
+	@TrayMenuScoped
+	static Optional<TrayMenuController> provideSupportedKeychainAccessProviders() {
+		return TrayMenuController.get();
+	}
+
+}

+ 6 - 7
src/test/java/org/cryptomator/common/keychain/KeychainManagerTest.java

@@ -2,17 +2,16 @@ package org.cryptomator.common.keychain;
 
 
 import org.cryptomator.integrations.keychain.KeychainAccessException;
-import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 
 import javafx.application.Platform;
 import javafx.beans.property.ReadOnlyBooleanProperty;
 import javafx.beans.property.SimpleObjectProperty;
+import java.time.Duration;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -23,7 +22,7 @@ public class KeychainManagerTest {
 	@Test
 	public void testStoreAndLoad() throws KeychainAccessException {
 		KeychainManager keychainManager = new KeychainManager(new SimpleObjectProperty<>(new MapKeychainAccess()));
-		keychainManager.storePassphrase("test", "asd");
+		keychainManager.storePassphrase("test", "Test", "asd");
 		Assertions.assertArrayEquals("asd".toCharArray(), keychainManager.loadPassphrase("test"));
 	}
 
@@ -42,9 +41,9 @@ public class KeychainManagerTest {
 		public void testPropertyChangesWhenStoringPassword() throws KeychainAccessException, InterruptedException {
 			KeychainManager keychainManager = new KeychainManager(new SimpleObjectProperty<>(new MapKeychainAccess()));
 			ReadOnlyBooleanProperty property = keychainManager.getPassphraseStoredProperty("test");
-			Assertions.assertEquals(false, property.get());
+			Assertions.assertFalse(property.get());
 
-			keychainManager.storePassphrase("test", "bar");
+			keychainManager.storePassphrase("test", null,"bar");
 
 			AtomicBoolean result = new AtomicBoolean(false);
 			CountDownLatch latch = new CountDownLatch(1);
@@ -52,8 +51,8 @@ public class KeychainManagerTest {
 				result.set(property.get());
 				latch.countDown();
 			});
-			latch.await(1, TimeUnit.SECONDS);
-			Assertions.assertEquals(true, result.get());
+			Assertions.assertTimeoutPreemptively(Duration.ofSeconds(1), () -> latch.await());
+			Assertions.assertTrue(result.get());
 		}
 
 	}

+ 3 - 3
src/test/java/org/cryptomator/common/keychain/MapKeychainAccess.java

@@ -20,7 +20,7 @@ class MapKeychainAccess implements KeychainAccessProvider {
 	}
 
 	@Override
-	public void storePassphrase(String key, CharSequence passphrase) {
+	public void storePassphrase(String key, String displayName,CharSequence passphrase) {
 		char[] pw = new char[passphrase.length()];
 		for (int i = 0; i < passphrase.length(); i++) {
 			pw[i] = passphrase.charAt(i);
@@ -39,9 +39,9 @@ class MapKeychainAccess implements KeychainAccessProvider {
 	}
 
 	@Override
-	public void changePassphrase(String key, CharSequence passphrase) {
+	public void changePassphrase(String key, String displayName, CharSequence passphrase) {
 		map.get(key);
-		storePassphrase(key, passphrase);
+		storePassphrase(key, displayName, passphrase);
 	}
 
 	@Override