~exprez135/cryptomator-libre

c6d88f1dc4065590098286e036cc78691b17bc1d — Sebastian Stenzel 1 year, 8 months ago a6e680e + 4a2d714 1.5.0-beta3
Merge branch 'release/1.5.0-beta3'
110 files changed, 1925 insertions(+), 716 deletions(-)

M .github/FUNDING.yml
M .idea/compiler.xml
M main/buildkit/pom.xml
M main/commons/pom.xml
M main/commons/src/main/java/org/cryptomator/common/CommonsModule.java
M main/commons/src/main/java/org/cryptomator/common/settings/SettingsProvider.java
M main/commons/src/main/java/org/cryptomator/common/vaults/WindowsDriveLetters.java
M main/keychain/pom.xml
M main/keychain/src/main/java/org/cryptomator/keychain/KeychainModule.java
M main/launcher/pom.xml
M main/launcher/src/main/java/org/cryptomator/launcher/FileOpenRequestHandler.java
M main/launcher/src/main/java/org/cryptomator/launcher/IpcProtocolImpl.java
M main/launcher/src/main/java/org/cryptomator/logging/LoggerConfiguration.java
M main/launcher/src/test/java/org/cryptomator/launcher/FileOpenRequestHandlerTest.java
M main/pom.xml
M main/ui/pom.xml
M main/ui/src/main/java/org/cryptomator/ui/addvaultwizard/AddVaultModule.java
M main/ui/src/main/java/org/cryptomator/ui/addvaultwizard/ReadmeGenerator.java
M main/ui/src/main/java/org/cryptomator/ui/changepassword/ChangePasswordModule.java
A main/ui/src/main/java/org/cryptomator/ui/common/Animations.java
M main/ui/src/main/java/org/cryptomator/ui/common/FxmlFile.java
M main/ui/src/main/java/org/cryptomator/ui/common/StackTraceController.java
M main/ui/src/main/java/org/cryptomator/ui/common/Tasks.java
M main/ui/src/main/java/org/cryptomator/ui/common/VaultService.java
M main/ui/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java
M main/ui/src/main/java/org/cryptomator/ui/controls/FontAwesome5IconView.java
M main/ui/src/main/java/org/cryptomator/ui/controls/NiceSecurePasswordField.java
M main/ui/src/main/java/org/cryptomator/ui/forgetPassword/ForgetPasswordModule.java
M main/ui/src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java
M main/ui/src/main/java/org/cryptomator/ui/launcher/AppLaunchEvent.java
A main/ui/src/main/java/org/cryptomator/ui/launcher/AppLifecycleListener.java
M main/ui/src/main/java/org/cryptomator/ui/launcher/UiLauncher.java
M main/ui/src/main/java/org/cryptomator/ui/mainwindow/MainWindowModule.java
M main/ui/src/main/java/org/cryptomator/ui/mainwindow/MainWindowTitleController.java
M main/ui/src/main/java/org/cryptomator/ui/mainwindow/VaultDetailController.java
M main/ui/src/main/java/org/cryptomator/ui/mainwindow/VaultListCellController.java
A main/ui/src/main/java/org/cryptomator/ui/migration/MigrationCapabilityErrorController.java
A main/ui/src/main/java/org/cryptomator/ui/migration/MigrationGenericErrorController.java
M main/ui/src/main/java/org/cryptomator/ui/migration/MigrationModule.java
M main/ui/src/main/java/org/cryptomator/ui/migration/MigrationRunController.java
M main/ui/src/main/java/org/cryptomator/ui/preferences/PreferencesModule.java
M main/ui/src/main/java/org/cryptomator/ui/quit/QuitModule.java
A main/ui/src/main/java/org/cryptomator/ui/recoverykey/AutoCompleter.java
M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyComponent.java
M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyCreationController.java
M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyDisplayController.java
M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyFactory.java
M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyModule.java
A main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyRecoverController.java
A main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyResetPasswordController.java
M main/ui/src/main/java/org/cryptomator/ui/recoverykey/WordEncoder.java
M main/ui/src/main/java/org/cryptomator/ui/removevault/RemoveVaultModule.java
M main/ui/src/main/java/org/cryptomator/ui/traymenu/TrayMenuController.java
M main/ui/src/main/java/org/cryptomator/ui/unlock/UnlockController.java
M main/ui/src/main/java/org/cryptomator/ui/unlock/UnlockModule.java
M main/ui/src/main/java/org/cryptomator/ui/vaultoptions/GeneralVaultOptionsController.java
A main/ui/src/main/java/org/cryptomator/ui/vaultoptions/MasterkeyOptionsController.java
M main/ui/src/main/java/org/cryptomator/ui/vaultoptions/VaultOptionsModule.java
M main/ui/src/main/java/org/cryptomator/ui/wrongfilealert/WrongFileAlertModule.java
A main/ui/src/main/resources/bot.png
A main/ui/src/main/resources/bot@2x.png
D main/ui/src/main/resources/bot_welcome.png
D main/ui/src/main/resources/bot_welcome@2x.png
M main/ui/src/main/resources/css/dark_theme.css
D main/ui/src/main/resources/css/dosis-bold.ttf
R main/ui/src/main/resources/css/{fontawesome5-pro-solid.otf => fontawesome5-free-solid.otf}
M main/ui/src/main/resources/css/light_theme.css
A main/ui/src/main/resources/css/quicksand-bold.ttf
M main/ui/src/main/resources/fxml/addvault_welcome.fxml
M main/ui/src/main/resources/fxml/changepassword.fxml
M main/ui/src/main/resources/fxml/main_window_title.fxml
A main/ui/src/main/resources/fxml/migration_capability_error.fxml
A main/ui/src/main/resources/fxml/migration_generic_error.fxml
M main/ui/src/main/resources/fxml/preferences_donationkey.fxml
M main/ui/src/main/resources/fxml/recoverykey_display.fxml
A main/ui/src/main/resources/fxml/recoverykey_recover.fxml
A main/ui/src/main/resources/fxml/recoverykey_reset_password.fxml
M main/ui/src/main/resources/fxml/vault_detail.fxml
M main/ui/src/main/resources/fxml/vault_list.fxml
M main/ui/src/main/resources/fxml/vault_options.fxml
M main/ui/src/main/resources/fxml/vault_options_general.fxml
A main/ui/src/main/resources/fxml/vault_options_masterkey.fxml
M main/ui/src/main/resources/i18n/strings.properties
M main/ui/src/main/resources/i18n/strings_ar.properties
M main/ui/src/main/resources/i18n/strings_cs.properties
M main/ui/src/main/resources/i18n/strings_de.properties
M main/ui/src/main/resources/i18n/strings_el.properties
M main/ui/src/main/resources/i18n/strings_es.properties
M main/ui/src/main/resources/i18n/strings_fr.properties
M main/ui/src/main/resources/i18n/strings_hr.properties
M main/ui/src/main/resources/i18n/strings_it.properties
M main/ui/src/main/resources/i18n/strings_ja.properties
M main/ui/src/main/resources/i18n/strings_ko.properties
M main/ui/src/main/resources/i18n/strings_nl.properties
M main/ui/src/main/resources/i18n/strings_pt.properties
M main/ui/src/main/resources/i18n/strings_pt_BR.properties
M main/ui/src/main/resources/i18n/strings_ru.properties
M main/ui/src/main/resources/i18n/strings_sv.properties
M main/ui/src/main/resources/i18n/strings_tr.properties
M main/ui/src/main/resources/i18n/strings_zh.properties
M main/ui/src/main/resources/i18n/strings_zh_TW.properties
M main/ui/src/main/resources/tray_icon.png
D main/ui/src/main/resources/tray_icon_unlocked.png
D main/ui/src/main/resources/tray_icon_unlocked_mac_black.png
D main/ui/src/main/resources/tray_icon_unlocked_mac_black@2x.png
D main/ui/src/main/resources/tray_icon_unlocked_mac_white.png
D main/ui/src/main/resources/tray_icon_unlocked_mac_white@2x.png
M main/ui/src/main/resources/window_icon_32.png
M main/ui/src/main/resources/window_icon_512.png
A main/ui/src/test/java/org/cryptomator/ui/recoverykey/AutoCompleterTest.java
M .github/FUNDING.yml => .github/FUNDING.yml +1 -1
@@ 1,6 1,6 @@
# These are supported funding model platforms

github: [overheadhunter, tobihagemann] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: [cryptomator] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username

M .idea/compiler.xml => .idea/compiler.xml +6 -6
@@ 7,20 7,20 @@
        <sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
        <outputRelativeToContentRoot value="true" />
        <processorPath useClasspath="false">
          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-compiler/2.25.2/dagger-compiler-2.25.2.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger/2.25.2/dagger-2.25.2.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-compiler/2.26/dagger-compiler-2.26.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger/2.26/dagger-2.26.jar" />
          <entry name="$MAVEN_REPOSITORY$/javax/inject/javax.inject/1/javax.inject-1.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-producers/2.25.2/dagger-producers-2.25.2.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-producers/2.26/dagger-producers-2.26.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/guava/guava/27.1-jre/guava-27.1-jre.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/code/findbugs/jsr305/3.0.1/jsr305-3.0.1.jar" />
          <entry name="$MAVEN_REPOSITORY$/org/checkerframework/checker-qual/2.5.2/checker-qual-2.5.2.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/errorprone/error_prone_annotations/2.2.0/error_prone_annotations-2.2.0.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/j2objc/j2objc-annotations/1.1/j2objc-annotations-1.1.jar" />
          <entry name="$MAVEN_REPOSITORY$/org/codehaus/mojo/animal-sniffer-annotations/1.17/animal-sniffer-annotations-1.17.jar" />
          <entry name="$MAVEN_REPOSITORY$/org/checkerframework/checker-compat-qual/2.5.3/checker-compat-qual-2.5.3.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-spi/2.25.2/dagger-spi-2.25.2.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-spi/2.26/dagger-spi-2.26.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/squareup/javapoet/1.11.1/javapoet-1.11.1.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/googlejavaformat/google-java-format/1.5/google-java-format-1.5.jar" />
          <entry name="$MAVEN_REPOSITORY$/com/google/errorprone/javac-shaded/9-dev-r4023-3/javac-shaded-9-dev-r4023-3.jar" />


@@ 32,9 32,9 @@
          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlinx/kotlinx-metadata-jvm/0.1.0/kotlinx-metadata-jvm-0.1.0.jar" />
        </processorPath>
        <module name="keychain" />
        <module name="launcher" />
        <module name="commons" />
        <module name="ui" />
        <module name="launcher" />
      </profile>
    </annotationProcessing>
  </component>

M main/buildkit/pom.xml => main/buildkit/pom.xml +1 -1
@@ 4,7 4,7 @@
	<parent>
		<groupId>org.cryptomator</groupId>
		<artifactId>main</artifactId>
		<version>1.5.0-beta2</version>
		<version>1.5.0-beta3</version>
	</parent>
	<artifactId>buildkit</artifactId>
	<packaging>pom</packaging>

M main/commons/pom.xml => main/commons/pom.xml +1 -1
@@ 4,7 4,7 @@
	<parent>
		<groupId>org.cryptomator</groupId>
		<artifactId>main</artifactId>
		<version>1.5.0-beta2</version>
		<version>1.5.0-beta3</version>
	</parent>
	<artifactId>commons</artifactId>
	<name>Cryptomator Commons</name>

M main/commons/src/main/java/org/cryptomator/common/CommonsModule.java => main/commons/src/main/java/org/cryptomator/common/CommonsModule.java +32 -5
@@ 5,7 5,6 @@
 *******************************************************************************/
package org.cryptomator.common;

import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import javafx.beans.binding.Binding;


@@ 19,6 18,8 @@ import org.cryptomator.common.vaults.VaultComponent;
import org.cryptomator.common.vaults.VaultListManager;
import org.cryptomator.frontend.webdav.WebDavServer;
import org.fxmisc.easybind.EasyBind;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Named;
import javax.inject.Singleton;


@@ 27,12 28,18 @@ import java.util.Comparator;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

@Module(subcomponents = {VaultComponent.class})
public abstract class CommonsModule {

	private static final int NUM_SCHEDULER_THREADS = 4;
	private static final Logger LOG = LoggerFactory.getLogger(CommonsModule.class);
	private static final int NUM_SCHEDULER_THREADS = 2;
	private static final int NUM_CORE_BG_THREADS = 6;
	private static final long BG_THREAD_KEEPALIVE_SECONDS = 60l;

	@Provides
	@Singleton


@@ 69,18 76,38 @@ public abstract class CommonsModule {
	static ScheduledExecutorService provideScheduledExecutorService(ShutdownHook shutdownHook) {
		final AtomicInteger threadNumber = new AtomicInteger(1);
		ScheduledExecutorService executorService = Executors.newScheduledThreadPool(NUM_SCHEDULER_THREADS, r -> {
			String name = String.format("App Scheduled Executor %02d", threadNumber.getAndIncrement());
			Thread t = new Thread(r);
			t.setName("Background Thread " + threadNumber.getAndIncrement());
			t.setName(name);
			t.setUncaughtExceptionHandler(CommonsModule::handleUncaughtExceptionInBackgroundThread);
			t.setDaemon(true);
			LOG.debug("Starting {}", t.getName());
			return t;
		});
		shutdownHook.runOnShutdown(executorService::shutdown);
		return executorService;
	}

	@Binds
	@Provides
	@Singleton
	abstract ExecutorService bindExecutorService(ScheduledExecutorService executor);
	static ExecutorService provideExecutorService(ShutdownHook shutdownHook) {
		final AtomicInteger threadNumber = new AtomicInteger(1);
		ExecutorService executorService = new ThreadPoolExecutor(NUM_CORE_BG_THREADS, Integer.MAX_VALUE, BG_THREAD_KEEPALIVE_SECONDS, TimeUnit.SECONDS, new SynchronousQueue<>(), r -> {
			String name = String.format("App Background Thread %03d", threadNumber.getAndIncrement());
			Thread t = new Thread(r);
			t.setName(name);
			t.setUncaughtExceptionHandler(CommonsModule::handleUncaughtExceptionInBackgroundThread);
			t.setDaemon(true);
			LOG.debug("Starting {}", t.getName());
			return t;
		});
		shutdownHook.runOnShutdown(executorService::shutdown);
		return executorService;
	}

	private static void handleUncaughtExceptionInBackgroundThread(Thread thread, Throwable throwable) {
		LOG.error("Uncaught exception in " + thread.getName(), throwable);
	}

	@Provides
	@Singleton

M main/commons/src/main/java/org/cryptomator/common/settings/SettingsProvider.java => main/commons/src/main/java/org/cryptomator/common/settings/SettingsProvider.java +4 -4
@@ 32,7 32,6 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;


@@ 46,16 45,17 @@ public class SettingsProvider implements Supplier<Settings> {
	private static final Logger LOG = LoggerFactory.getLogger(SettingsProvider.class);
	private static final long SAVE_DELAY_MS = 1000;

	private final ScheduledExecutorService saveScheduler = Executors.newSingleThreadScheduledExecutor();
	private final AtomicReference<ScheduledFuture<?>> scheduledSaveCmd = new AtomicReference<>();
	private final AtomicReference<Settings> settings = new AtomicReference<>();
	private final SettingsJsonAdapter settingsJsonAdapter = new SettingsJsonAdapter();
	private final Environment env;
	private final ScheduledExecutorService scheduler;
	private final Gson gson;

	@Inject
	public SettingsProvider(Environment env) {
	public SettingsProvider(Environment env, ScheduledExecutorService scheduler) {
		this.env = env;
		this.scheduler = scheduler;
		this.gson = new GsonBuilder() //
				.setPrettyPrinting().setLenient().disableHtmlEscaping() //
				.registerTypeAdapter(Settings.class, settingsJsonAdapter) //


@@ 98,7 98,7 @@ public class SettingsProvider implements Supplier<Settings> {
		final Optional<Path> settingsPath = env.getSettingsPath().findFirst(); // alway save to preferred (first) path
		settingsPath.ifPresent(path -> {
			Runnable saveCommand = () -> this.save(settings, path);
			ScheduledFuture<?> scheduledTask = saveScheduler.schedule(saveCommand, SAVE_DELAY_MS, TimeUnit.MILLISECONDS);
			ScheduledFuture<?> scheduledTask = scheduler.schedule(saveCommand, SAVE_DELAY_MS, TimeUnit.MILLISECONDS);
			ScheduledFuture<?> previouslyScheduledTask = scheduledSaveCmd.getAndSet(scheduledTask);
			if (previouslyScheduledTask != null) {
				previouslyScheduledTask.cancel(false);

M main/commons/src/main/java/org/cryptomator/common/vaults/WindowsDriveLetters.java => main/commons/src/main/java/org/cryptomator/common/vaults/WindowsDriveLetters.java +0 -1
@@ 41,7 41,6 @@ public final class WindowsDriveLetters {

	public Set<String> getOccupiedDriveLetters() {
		if (!SystemUtils.IS_OS_WINDOWS) {
			LOG.warn("Attempted to get occupied drive letters on non-Windows machine.");
			return Set.of();
		} else {
			Iterable<Path> rootDirs = FileSystems.getDefault().getRootDirectories();

M main/keychain/pom.xml => main/keychain/pom.xml +1 -1
@@ 4,7 4,7 @@
	<parent>
		<groupId>org.cryptomator</groupId>
		<artifactId>main</artifactId>
		<version>1.5.0-beta2</version>
		<version>1.5.0-beta3</version>
	</parent>
	<artifactId>keychain</artifactId>
	<name>System Keychain Access</name>

M main/keychain/src/main/java/org/cryptomator/keychain/KeychainModule.java => main/keychain/src/main/java/org/cryptomator/keychain/KeychainModule.java +2 -0
@@ 11,6 11,7 @@ import dagger.Provides;
import dagger.multibindings.ElementsIntoSet;
import org.cryptomator.common.JniModule;

import javax.inject.Singleton;
import java.util.Optional;
import java.util.Set;



@@ 24,6 25,7 @@ public class KeychainModule {
	}

	@Provides
	@Singleton
	public Optional<KeychainAccess> provideSupportedKeychain(Set<KeychainAccessStrategy> keychainAccessStrategies) {
		return keychainAccessStrategies.stream().filter(KeychainAccessStrategy::isSupported).map(KeychainAccess.class::cast).findFirst();
	}

M main/launcher/pom.xml => main/launcher/pom.xml +1 -1
@@ 4,7 4,7 @@
	<parent>
		<groupId>org.cryptomator</groupId>
		<artifactId>main</artifactId>
		<version>1.5.0-beta2</version>
		<version>1.5.0-beta3</version>
	</parent>
	<artifactId>launcher</artifactId>
	<name>Cryptomator Launcher</name>

M main/launcher/src/main/java/org/cryptomator/launcher/FileOpenRequestHandler.java => main/launcher/src/main/java/org/cryptomator/launcher/FileOpenRequestHandler.java +9 -5
@@ 21,8 21,10 @@ import java.nio.file.FileSystems;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collection;
import java.util.Objects;
import java.util.concurrent.BlockingQueue;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Singleton


@@ 40,7 42,7 @@ class FileOpenRequestHandler {
	}

	private void openFiles(OpenFilesEvent evt) {
		Stream<Path> pathsToOpen = evt.getFiles().stream().map(File::toPath);
		Collection<Path> pathsToOpen = evt.getFiles().stream().map(File::toPath).collect(Collectors.toList());
		AppLaunchEvent launchEvent = new AppLaunchEvent(AppLaunchEvent.EventType.OPEN_FILE, pathsToOpen);
		tryToEnqueueFileOpenRequest(launchEvent);
	}


@@ 51,16 53,18 @@ class FileOpenRequestHandler {

	// visible for testing
	void handleLaunchArgs(FileSystem fs, String[] args) {
		Stream<Path> pathsToOpen = Arrays.stream(args).map(str -> {
		Collection<Path> pathsToOpen = Arrays.stream(args).map(str -> {
			try {
				return fs.getPath(str);
			} catch (InvalidPathException e) {
				LOG.trace("Argument not a valid path: {}", str);
				return null;
			}
		}).filter(Objects::nonNull);
		AppLaunchEvent launchEvent = new AppLaunchEvent(AppLaunchEvent.EventType.OPEN_FILE, pathsToOpen);
		tryToEnqueueFileOpenRequest(launchEvent);
		}).filter(Objects::nonNull).collect(Collectors.toList());
		if (!pathsToOpen.isEmpty()) {
			AppLaunchEvent launchEvent = new AppLaunchEvent(AppLaunchEvent.EventType.OPEN_FILE, pathsToOpen);
			tryToEnqueueFileOpenRequest(launchEvent);
		}
	}



M main/launcher/src/main/java/org/cryptomator/launcher/IpcProtocolImpl.java => main/launcher/src/main/java/org/cryptomator/launcher/IpcProtocolImpl.java +2 -1
@@ 8,6 8,7 @@ import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.BlockingQueue;
import java.util.stream.Stream;



@@ 27,7 28,7 @@ class IpcProtocolImpl implements IpcProtocol {

	@Override
	public void revealRunningApp() {
		launchEventQueue.add(new AppLaunchEvent(AppLaunchEvent.EventType.REVEAL_APP, Stream.empty()));
		launchEventQueue.add(new AppLaunchEvent(AppLaunchEvent.EventType.REVEAL_APP, Collections.emptyList()));
	}

	@Override

M main/launcher/src/main/java/org/cryptomator/logging/LoggerConfiguration.java => main/launcher/src/main/java/org/cryptomator/logging/LoggerConfiguration.java +2 -1
@@ 55,10 55,11 @@ public class LoggerConfiguration {
			}

			// configure upgrade logger:
			Logger upgrades = context.getLogger("org.cryptomator.ui.model.upgrade");
			Logger upgrades = context.getLogger("org.cryptomator.cryptofs.migration");
			upgrades.setLevel(Level.DEBUG);
			upgrades.addAppender(stdout);
			upgrades.addAppender(upgrade);
			upgrades.addAppender(file);
			upgrades.setAdditive(false);

			// add shutdown hook

M main/launcher/src/test/java/org/cryptomator/launcher/FileOpenRequestHandlerTest.java => main/launcher/src/test/java/org/cryptomator/launcher/FileOpenRequestHandlerTest.java +8 -12
@@ 15,16 15,14 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class FileOpenRequestHandlerTest {



@@ 39,32 37,30 @@ public class FileOpenRequestHandlerTest {

	@Test
	@DisplayName("./cryptomator.exe foo bar")
	public void testOpenArgsWithCorrectPaths() throws IOException {
	public void testOpenArgsWithCorrectPaths() {
		inTest.handleLaunchArgs(new String[]{"foo", "bar"});

		AppLaunchEvent evt = queue.poll();
		Assertions.assertNotNull(evt);
		List<Path> paths = evt.getPathsToOpen().collect(Collectors.toList());
		Collection<Path> paths = evt.getPathsToOpen();
		MatcherAssert.assertThat(paths, CoreMatchers.hasItems(Paths.get("foo"), Paths.get("bar")));
	}

	@Test
	@DisplayName("./cryptomator.exe foo (with 'foo' being an invalid path)")
	public void testOpenArgsWithIncorrectPaths() throws IOException {
	public void testOpenArgsWithIncorrectPaths() {
		FileSystem fs = Mockito.mock(FileSystem.class);
		Mockito.when(fs.getPath("foo")).thenThrow(new InvalidPathException("foo", "foo is not a path"));
		inTest.handleLaunchArgs(fs, new String[]{"foo"});

		AppLaunchEvent evt = queue.poll();
		Assertions.assertNotNull(evt);
		List<Path> paths = evt.getPathsToOpen().collect(Collectors.toList());
		Assertions.assertTrue(paths.isEmpty());
		Assertions.assertNull(evt);
	}

	@Test
	@DisplayName("./cryptomator.exe foo (with full event queue)")
	public void testOpenArgsWithFullQueue() throws IOException {
		queue.add(new AppLaunchEvent(AppLaunchEvent.EventType.OPEN_FILE, Stream.empty()));
	public void testOpenArgsWithFullQueue() {
		queue.add(new AppLaunchEvent(AppLaunchEvent.EventType.OPEN_FILE, Collections.emptyList()));
		Assumptions.assumeTrue(queue.remainingCapacity() == 0);

		inTest.handleLaunchArgs(new String[]{"foo"});

M main/pom.xml => main/pom.xml +8 -8
@@ 3,13 3,13 @@
	<modelVersion>4.0.0</modelVersion>
	<groupId>org.cryptomator</groupId>
	<artifactId>main</artifactId>
	<version>1.5.0-beta2</version>
	<version>1.5.0-beta3</version>
	<packaging>pom</packaging>
	<name>Cryptomator</name>

	<organization>
		<name>cryptomator.org</name>
		<url>http://cryptomator.org</url>
		<url>https://cryptomator.org</url>
	</organization>

	<developers>


@@ 24,33 24,33 @@
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

		<!-- cryptomator dependencies -->
		<cryptomator.cryptofs.version>1.9.0-rc2</cryptomator.cryptofs.version>
		<cryptomator.cryptofs.version>1.9.3</cryptomator.cryptofs.version>
		<cryptomator.jni.version>2.2.2</cryptomator.jni.version>
		<cryptomator.fuse.version>1.2.2</cryptomator.fuse.version>
		<cryptomator.dokany.version>1.1.12</cryptomator.dokany.version>
		<cryptomator.webdav.version>1.0.10</cryptomator.webdav.version>

		<!-- 3rd party dependencies -->
		<javafx.version>13.0.1</javafx.version>
		<javafx.version>14-ea+8</javafx.version>
		<commons-lang3.version>3.9</commons-lang3.version>
		<jwt.version>3.8.3</jwt.version>
		<easybind.version>1.0.3</easybind.version>
		<guava.version>28.1-jre</guava.version>
		<dagger.version>2.25.2</dagger.version>
		<dagger.version>2.26</dagger.version>
		<gson.version>2.8.6</gson.version>
		<slf4j.version>1.7.29</slf4j.version>
		<logback.version>1.2.3</logback.version>

		<!-- test dependencies -->
		<junit.jupiter.version>5.5.2</junit.jupiter.version>
		<mockito.version>3.1.0</mockito.version>
		<junit.jupiter.version>5.6.0</junit.jupiter.version>
		<mockito.version>3.2.4</mockito.version>
		<hamcrest.version>2.2</hamcrest.version>
	</properties>

	<repositories>
		<repository>
			<id>jcenter</id>
			<url>http://jcenter.bintray.com</url>
			<url>https://jcenter.bintray.com</url>
		</repository>
	</repositories>


M main/ui/pom.xml => main/ui/pom.xml +1 -1
@@ 4,7 4,7 @@
	<parent>
		<groupId>org.cryptomator</groupId>
		<artifactId>main</artifactId>
		<version>1.5.0-beta2</version>
		<version>1.5.0-beta3</version>
	</parent>
	<artifactId>ui</artifactId>
	<name>Cryptomator GUI</name>

M main/ui/src/main/java/org/cryptomator/ui/addvaultwizard/AddVaultModule.java => main/ui/src/main/java/org/cryptomator/ui/addvaultwizard/AddVaultModule.java +5 -5
@@ 27,8 27,8 @@ import org.cryptomator.ui.recoverykey.RecoveryKeyDisplayController;
import javax.inject.Named;
import javax.inject.Provider;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module


@@ 51,13 51,13 @@ public abstract class AddVaultModule {
	@Provides
	@AddVaultWizardWindow
	@AddVaultWizardScoped
	static Stage provideStage(@MainWindow Stage owner, ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(@MainWindow Stage owner, ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage();
		stage.setTitle(resourceBundle.getString("addvaultwizard.title"));
		stage.setResizable(false);
		stage.initModality(Modality.WINDOW_MODAL);
		stage.initOwner(owner);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}



@@ 193,8 193,8 @@ public abstract class AddVaultModule {
	@Provides
	@IntoMap
	@FxControllerKey(RecoveryKeyDisplayController.class)
	static FxController provideRecoveryKeyDisplayController(@AddVaultWizardWindow Stage window, @Named("vaultName") StringProperty vaultName, @Named("recoveryKey") StringProperty recoveryKey) {
		return new RecoveryKeyDisplayController(window, vaultName.get(), recoveryKey.get());
	static FxController provideRecoveryKeyDisplayController(@AddVaultWizardWindow Stage window, @Named("vaultName") StringProperty vaultName, @Named("recoveryKey") StringProperty recoveryKey, ResourceBundle localization) {
		return new RecoveryKeyDisplayController(window, vaultName.get(), recoveryKey.get(), localization);
	}

	@Binds

M main/ui/src/main/java/org/cryptomator/ui/addvaultwizard/ReadmeGenerator.java => main/ui/src/main/java/org/cryptomator/ui/addvaultwizard/ReadmeGenerator.java +1 -1
@@ 10,7 10,7 @@ public class ReadmeGenerator {
	// specs: https://web.archive.org/web/20190708132914/http://www.kleinlercher.at/tools/Windows_Protocols/Word2007RTFSpec9.pdf
	private static final String RTF_HEADER = "{\\rtf1\\fbidis\\ansi\\uc0\\fs32\n";
	private static final String RTF_FOOTER = "}";
	private static final String HELP_URL = "{\\field{\\*\\fldinst HYPERLINK \"http://www.google.com/\"}{\\fldrslt google.com}}";
	private static final String HELP_URL = "{\\field{\\*\\fldinst HYPERLINK \"http://docs.cryptoamtor.org/\"}{\\fldrslt docs.cryptoamtor.org}}";

	private final ResourceBundle resourceBundle;


M main/ui/src/main/java/org/cryptomator/ui/changepassword/ChangePasswordModule.java => main/ui/src/main/java/org/cryptomator/ui/changepassword/ChangePasswordModule.java +3 -3
@@ 22,8 22,8 @@ import org.cryptomator.ui.common.PasswordStrengthUtil;
import javax.inject.Named;
import javax.inject.Provider;
import java.nio.CharBuffer;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module


@@ 46,13 46,13 @@ abstract class ChangePasswordModule {
	@Provides
	@ChangePasswordWindow
	@ChangePasswordScoped
	static Stage provideStage(@Named("changePasswordOwner") Stage owner, ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(@Named("changePasswordOwner") Stage owner, ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage();
		stage.setTitle(resourceBundle.getString("changepassword.title"));
		stage.setResizable(false);
		stage.initModality(Modality.WINDOW_MODAL);
		stage.initOwner(owner);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}


A main/ui/src/main/java/org/cryptomator/ui/common/Animations.java => main/ui/src/main/java/org/cryptomator/ui/common/Animations.java +36 -0
@@ 0,0 1,36 @@
package org.cryptomator.ui.common;

import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.value.WritableValue;
import javafx.stage.Window;
import javafx.util.Duration;

public class Animations {
	
	public static Timeline createShakeWindowAnimation(Window window) {
		WritableValue<Double> writableWindowX = new WritableValue<>() {
			@Override
			public Double getValue() {
				return window.getX();
			}

			@Override
			public void setValue(Double value) {
				window.setX(value);
			}
		};
		return new Timeline( //
				new KeyFrame(Duration.ZERO, new KeyValue(writableWindowX, window.getX())), //
				new KeyFrame(new Duration(100), new KeyValue(writableWindowX, window.getX() - 22.0)), //
				new KeyFrame(new Duration(200), new KeyValue(writableWindowX, window.getX() + 18.0)), //
				new KeyFrame(new Duration(300), new KeyValue(writableWindowX, window.getX() - 14.0)), //
				new KeyFrame(new Duration(400), new KeyValue(writableWindowX, window.getX() + 10.0)), //
				new KeyFrame(new Duration(500), new KeyValue(writableWindowX, window.getX() - 6.0)), //
				new KeyFrame(new Duration(600), new KeyValue(writableWindowX, window.getX() + 2.0)), //
				new KeyFrame(new Duration(700), new KeyValue(writableWindowX, window.getX())) //
		);
	}

}

M main/ui/src/main/java/org/cryptomator/ui/common/FxmlFile.java => main/ui/src/main/java/org/cryptomator/ui/common/FxmlFile.java +4 -0
@@ 12,12 12,16 @@ public enum FxmlFile {
	CHANGEPASSWORD("/fxml/changepassword.fxml"), //
	FORGET_PASSWORD("/fxml/forget_password.fxml"), //
	MAIN_WINDOW("/fxml/main_window.fxml"), //
	MIGRATION_CAPABILITY_ERROR("/fxml/migration_capability_error.fxml"), //
	MIGRATION_GENERIC_ERROR("/fxml/migration_generic_error.fxml"), //
	MIGRATION_RUN("/fxml/migration_run.fxml"), //
	MIGRATION_START("/fxml/migration_start.fxml"), //
	MIGRATION_SUCCESS("/fxml/migration_success.fxml"), //
	PREFERENCES("/fxml/preferences.fxml"), //
	QUIT("/fxml/quit.fxml"), //
	RECOVERYKEY_CREATE("/fxml/recoverykey_create.fxml"), //
	RECOVERYKEY_RECOVER("/fxml/recoverykey_recover.fxml"), //
	RECOVERYKEY_RESET_PASSWORD("/fxml/recoverykey_reset_password.fxml"), //
	RECOVERYKEY_SUCCESS("/fxml/recoverykey_success.fxml"), //
	REMOVE_VAULT("/fxml/remove_vault.fxml"), //
	UNLOCK("/fxml/unlock.fxml"),

M main/ui/src/main/java/org/cryptomator/ui/common/StackTraceController.java => main/ui/src/main/java/org/cryptomator/ui/common/StackTraceController.java +2 -2
@@ 8,11 8,11 @@ public class StackTraceController implements FxController {

	private final String stackTrace;

	public StackTraceController(Exception cause) {
	public StackTraceController(Throwable cause) {
		this.stackTrace = provideStackTrace(cause);
	}

	static String provideStackTrace(Exception cause) {
	private static String provideStackTrace(Throwable cause) {
		ByteArrayOutputStream baos = new ByteArrayOutputStream();
		cause.printStackTrace(new PrintStream(baos));
		return baos.toString(StandardCharsets.UTF_8);

M main/ui/src/main/java/org/cryptomator/ui/common/Tasks.java => main/ui/src/main/java/org/cryptomator/ui/common/Tasks.java +6 -6
@@ 73,21 73,21 @@ public class Tasks {
			return new TaskImpl<>(callable, successHandler, errorHandlers, finallyHandler);
		}

		public Task<T> runOnce(ExecutorService executorService) {
		public Task<T> runOnce(ExecutorService executor) {
			Task<T> task = build();
			executorService.submit(task);
			executor.submit(task);
			return task;
		}

		public Task<T> scheduleOnce(ScheduledExecutorService executorService, long delay, TimeUnit unit) {
		public Task<T> scheduleOnce(ScheduledExecutorService scheduler, long delay, TimeUnit unit) {
			Task<T> task = build();
			executorService.schedule(task, delay, unit);
			scheduler.schedule(task, delay, unit);
			return task;
		}

		public ScheduledService<T> schedulePeriodically(ExecutorService executorService, Duration initialDelay, Duration period) {
		public ScheduledService<T> schedulePeriodically(ExecutorService executor, Duration initialDelay, Duration period) {
			ScheduledService<T> service = new RestartingService<>(this::build);
			service.setExecutor(executorService);
			service.setExecutor(executor);
			service.setDelay(initialDelay);
			service.setPeriod(period);
			Platform.runLater(service::start);

M main/ui/src/main/java/org/cryptomator/ui/common/VaultService.java => main/ui/src/main/java/org/cryptomator/ui/common/VaultService.java +162 -2
@@ 4,15 4,20 @@ import javafx.concurrent.Task;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.common.vaults.VaultState;
import org.cryptomator.common.vaults.Volume;
import org.cryptomator.cryptolib.api.InvalidPassphraseException;
import org.cryptomator.keychain.KeychainAccess;
import org.cryptomator.ui.fxapp.FxApplicationScoped;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import java.nio.CharBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.stream.Collectors;


@@ 23,10 28,12 @@ public class VaultService {
	private static final Logger LOG = LoggerFactory.getLogger(VaultService.class);

	private final ExecutorService executorService;
	private final Optional<KeychainAccess> keychain;

	@Inject
	public VaultService(ExecutorService executorService) {
	public VaultService(ExecutorService executorService, Optional<KeychainAccess> keychain) {
		this.executorService = executorService;
		this.keychain = keychain;
	}

	public void reveal(Vault vault) {


@@ 46,6 53,72 @@ public class VaultService {
	}

	/**
	 * Attempts to unlock all given vaults in a background thread using passwords stored in the system keychain.
	 *
	 * @param vaults The vaults to unlock
	 * @implNote No-op if no system keychain is present
	 */
	public void attemptAutoUnlock(Collection<Vault> vaults) {
		if (!keychain.isPresent()) {
			LOG.debug("No system keychain found. Unable to auto unlock without saved passwords.");
		} else {
			for (Vault vault : vaults) {
				attemptAutoUnlock(vault, keychain.get());
			}
		}
	}

	/**
	 * Unlocks a vault in a background thread using a stored passphrase
	 *
	 * @param vault The vault to unlock
	 * @param keychainAccess The system keychain holding the passphrase for the vault
	 */
	public void attemptAutoUnlock(Vault vault, KeychainAccess keychainAccess) {
		executorService.execute(createAutoUnlockTask(vault, keychainAccess));
	}

	/**
	 * Creates but doesn't start an auto-unlock task.
	 *
	 * @param vault The vault to unlock
	 * @param keychainAccess The system keychain holding the passphrase for the vault
	 * @return The task
	 */
	public Task<Vault> createAutoUnlockTask(Vault vault, KeychainAccess keychainAccess) {
		Task<Vault> task = new AutoUnlockVaultTask(vault, keychainAccess);
		task.setOnSucceeded(evt -> LOG.info("Auto-unlocked {}", vault.getDisplayableName()));
		task.setOnFailed(evt -> LOG.error("Failed to auto-unlock " + vault.getDisplayableName(), evt.getSource().getException()));
		return task;
	}

	/**
	 * Unlocks a vault in a background thread
	 *
	 * @param vault The vault to unlock
	 * @param passphrase The password to use - wipe this param asap
	 * @implNote A copy of the passphrase will be made, which is wiped as soon as the task ran.
	 */
	public void unlock(Vault vault, CharSequence passphrase) {
		executorService.execute(createUnlockTask(vault, passphrase));
	}

	/**
	 * Creates but doesn't start an unlock task.
	 *
	 * @param vault The vault to unlock
	 * @param passphrase The password to use - wipe this param asap
	 * @return The task
	 * @implNote A copy of the passphrase will be made, which is wiped as soon as the task ran.
	 */
	public Task<Vault> createUnlockTask(Vault vault, CharSequence passphrase) {
		Task<Vault> task = new UnlockVaultTask(vault, passphrase);
		task.setOnSucceeded(evt -> LOG.info("Unlocked {}", vault.getDisplayableName()));
		task.setOnFailed(evt -> LOG.error("Failed to unlock " + vault.getDisplayableName(), evt.getSource().getException()));
		return task;
	}

	/**
	 * Locks a vault in a background thread.
	 *
	 * @param vault The vault to lock


@@ 60,6 133,7 @@ public class VaultService {
	 *
	 * @param vault The vault to lock
	 * @param forced Whether to attempt a forced lock
	 * @return The task
	 */
	public Task<Vault> createLockTask(Vault vault, boolean forced) {
		Task<Vault> task = new LockVaultTask(vault, forced);


@@ 145,6 219,93 @@ public class VaultService {
		}
	}

	private static class AutoUnlockVaultTask extends Task<Vault> {

		private final Vault vault;
		private final KeychainAccess keychain;

		public AutoUnlockVaultTask(Vault vault, KeychainAccess keychain) {
			this.vault = vault;
			this.keychain = keychain;
		}

		@Override
		protected Vault call() throws Exception {
			char[] storedPw = null;
			try {
				storedPw = keychain.loadPassphrase(vault.getId());
				if (storedPw == null) {
					throw new InvalidPassphraseException();
				}
				vault.unlock(CharBuffer.wrap(storedPw));
			} finally {
				if (storedPw != null) {
					Arrays.fill(storedPw, ' ');
				}
			}
			return vault;
		}

		@Override
		protected void scheduled() {
			vault.setState(VaultState.PROCESSING);
		}

		@Override
		protected void succeeded() {
			vault.setState(VaultState.UNLOCKED);
		}

		@Override
		protected void failed() {
			vault.setState(VaultState.LOCKED);
		}
	}

	private static class UnlockVaultTask extends Task<Vault> {

		private final Vault vault;
		private final CharBuffer passphrase;

		/**
		 * @param vault The vault to unlock
		 * @param passphrase The password to use - wipe this param asap
		 * @implNote A copy of the passphrase will be made, which is wiped as soon as the task ran.
		 */
		public UnlockVaultTask(Vault vault, CharSequence passphrase) {
			this.vault = vault;
			this.passphrase = CharBuffer.allocate(passphrase.length());
			for (int i = 0; i < passphrase.length(); i++) {
				this.passphrase.put(i, passphrase.charAt(i));
			}
		}

		@Override
		protected Vault call() throws Exception {
			try {
				vault.unlock(passphrase);
			} finally {
				Arrays.fill(passphrase.array(), ' ');
			}
			return vault;
		}

		@Override
		protected void scheduled() {
			vault.setState(VaultState.PROCESSING);
		}

		@Override
		protected void succeeded() {
			vault.setState(VaultState.UNLOCKED);
		}

		@Override
		protected void failed() {
			vault.setState(VaultState.LOCKED);
		}
	}

	/**
	 * A task that locks a vault
	 */


@@ 186,5 347,4 @@ public class VaultService {
	}



}

M main/ui/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java => main/ui/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java +7 -6
@@ 5,12 5,13 @@ package org.cryptomator.ui.controls;
 */
public enum FontAwesome5Icon {
	ANCHOR("\uF13D"), //
	ARROW_ALT_UP("\uF357"), //
	ARROW_UP("\uF062"), //
	CHECK("\uF00C"), //
	COG("\uF013"), //
	COGS("\uF085"), //
	COPY("\uF0C5"), //
	EXCLAMATION("\uF12A"),
	CROWN("\uF521"), //
	EXCLAMATION("\uF12A"), //
	EXCLAMATION_CIRCLE("\uF06A"), //
	EXCLAMATION_TRIANGLE("\uF071"), //
	EYE("\uF06E"), //


@@ 22,17 23,17 @@ public enum FontAwesome5Icon {
	HDD("\uF0A0"), //
	KEY("\uF084"), //
	LINK("\uF0C1"), //
	LOCK_ALT("\uF30D"), //
	LOCK_OPEN_ALT("\uF3C2"), //
	LOCK("\uF023"), //
	LOCK_OPEN("\uF3C1"), //
	MAGIC("\uF0D0"), //
	PLUS("\uF067"), //
	PRINT("\uF02F"), //
	QUESTION("\uF128"), //
	SPARKLES("\uF890"), //
	SPINNER("\uF110"), //
	SYNC("\uF021"), //
	TIMES("\uF00D"), //
	USER_CROWN("\uF6A4"), //
	WRENCH("\uF0AD"), //
	WINDOW_MINIMIZE("\uF2D1"), //
	;

	private final String unicode;

M main/ui/src/main/java/org/cryptomator/ui/controls/FontAwesome5IconView.java => main/ui/src/main/java/org/cryptomator/ui/controls/FontAwesome5IconView.java +1 -1
@@ 18,7 18,7 @@ public class FontAwesome5IconView extends Text {

	private static final FontAwesome5Icon DEFAULT_GLYPH = FontAwesome5Icon.ANCHOR;
	private static final double DEFAULT_GLYPH_SIZE = 12.0;
	private static final String FONT_PATH = "/css/fontawesome5-pro-solid.otf";
	private static final String FONT_PATH = "/css/fontawesome5-free-solid.otf";
	private static final Font FONT;

	private ObjectProperty<FontAwesome5Icon> glyph = new SimpleObjectProperty<>(this, "glyph", DEFAULT_GLYPH);

M main/ui/src/main/java/org/cryptomator/ui/controls/NiceSecurePasswordField.java => main/ui/src/main/java/org/cryptomator/ui/controls/NiceSecurePasswordField.java +1 -1
@@ 32,7 32,7 @@ public class NiceSecurePasswordField extends StackPane {
		iconContainer.getStyleClass().add(ICONS_STLYE_CLASS);
		StackPane.setAlignment(iconContainer, Pos.CENTER_RIGHT);

		capsLockedIcon.setGlyph(FontAwesome5Icon.ARROW_ALT_UP);
		capsLockedIcon.setGlyph(FontAwesome5Icon.ARROW_UP);
		capsLockedIcon.setGlyphSize(ICON_SIZE);
		capsLockedIcon.visibleProperty().bind(passwordField.capsLockedProperty());
		capsLockedIcon.managedProperty().bind(passwordField.capsLockedProperty());

M main/ui/src/main/java/org/cryptomator/ui/forgetPassword/ForgetPasswordModule.java => main/ui/src/main/java/org/cryptomator/ui/forgetPassword/ForgetPasswordModule.java +3 -3
@@ 20,8 20,8 @@ import org.cryptomator.ui.common.FxmlScene;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module


@@ 37,13 37,13 @@ abstract class ForgetPasswordModule {
	@Provides
	@ForgetPasswordWindow
	@ForgetPasswordScoped
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon, @Named("forgetPasswordOwner") Stage owner) {
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons, @Named("forgetPasswordOwner") Stage owner) {
		Stage stage = new Stage();
		stage.setTitle(resourceBundle.getString("forgetPassword.title"));
		stage.setResizable(false);
		stage.initModality(Modality.WINDOW_MODAL);
		stage.initOwner(owner);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}


M main/ui/src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java => main/ui/src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java +20 -8
@@ 22,7 22,9 @@ import org.cryptomator.ui.unlock.UnlockComponent;
import javax.inject.Named;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
import java.io.UncheckedIOException;
import java.util.Collections;
import java.util.List;

@Module(includes = {UpdateCheckerModule.class}, subcomponents = {MainWindowComponent.class, PreferencesComponent.class, UnlockComponent.class, QuitComponent.class})
abstract class FxApplicationModule {


@@ 34,19 36,29 @@ abstract class FxApplicationModule {
	}

	@Provides
	@Named("windowIcon")
	@Named("windowIcons")
	@FxApplicationScoped
	static Optional<Image> provideWindowIcon() {
	static List<Image> provideWindowIcons() {
		if (SystemUtils.IS_OS_MAC) {
			return Optional.empty();
			return Collections.emptyList();
		}
		try (InputStream in = FxApplicationModule.class.getResourceAsStream("/window_icon_32.png")) { // TODO: use some higher res depending on display?
			return Optional.of(new Image(in));

		try {
			return List.of( //
					createImageFromResource("/window_icon_32.png"), //
					createImageFromResource("/window_icon_512.png") //
			);
		} catch (IOException e) {
			return Optional.empty();
			throw new UncheckedIOException("Failed to load embedded resource.", e);
		}
	}
	

	private static Image createImageFromResource(String resourceName) throws IOException {
		try (InputStream in = FxApplicationModule.class.getResourceAsStream(resourceName)) {
			return new Image(in);
		}
	}

	@Binds
	abstract Application bindApplication(FxApplication application);


M main/ui/src/main/java/org/cryptomator/ui/launcher/AppLaunchEvent.java => main/ui/src/main/java/org/cryptomator/ui/launcher/AppLaunchEvent.java +4 -3
@@ 1,16 1,17 @@
package org.cryptomator.ui.launcher;

import java.nio.file.Path;
import java.util.Collection;
import java.util.stream.Stream;

public class AppLaunchEvent {

	private final Stream<Path> pathsToOpen;
	private final EventType type;
	private final Collection<Path> pathsToOpen;

	public enum EventType {REVEAL_APP, OPEN_FILE}

	public AppLaunchEvent(EventType type, Stream<Path> pathsToOpen) {
	public AppLaunchEvent(EventType type, Collection<Path> pathsToOpen) {
		this.type = type;
		this.pathsToOpen = pathsToOpen;
	}


@@ 19,7 20,7 @@ public class AppLaunchEvent {
		return type;
	}

	public Stream<Path> getPathsToOpen() {
	public Collection<Path> getPathsToOpen() {
		return pathsToOpen;
	}
}

A main/ui/src/main/java/org/cryptomator/ui/launcher/AppLifecycleListener.java => main/ui/src/main/java/org/cryptomator/ui/launcher/AppLifecycleListener.java +125 -0
@@ 0,0 1,125 @@
package org.cryptomator.ui.launcher;

import javafx.application.Platform;
import javafx.beans.Observable;
import javafx.collections.ObservableList;
import org.cryptomator.common.ShutdownHook;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.common.vaults.VaultState;
import org.cryptomator.common.vaults.Volume;
import org.cryptomator.ui.preferences.SelectedPreferencesTab;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import java.awt.Desktop;
import java.awt.EventQueue;
import java.awt.desktop.QuitResponse;
import java.util.EnumSet;
import java.util.EventObject;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;

@Singleton
public class AppLifecycleListener {

	private static final Logger LOG = LoggerFactory.getLogger(AppLifecycleListener.class);
	public static final Set<VaultState> STATES_ALLOWING_TERMINATION = EnumSet.of(VaultState.LOCKED, VaultState.NEEDS_MIGRATION, VaultState.MISSING, VaultState.ERROR);

	private final FxApplicationStarter fxApplicationStarter;
	private final CountDownLatch shutdownLatch;
	private final ObservableList<Vault> vaults;
	private final AtomicBoolean allowQuitWithoutPrompt;

	@Inject
	AppLifecycleListener(FxApplicationStarter fxApplicationStarter, @Named("shutdownLatch") CountDownLatch shutdownLatch, ShutdownHook shutdownHook, ObservableList<Vault> vaults) {
		this.fxApplicationStarter = fxApplicationStarter;
		this.shutdownLatch = shutdownLatch;
		this.vaults = vaults;
		this.allowQuitWithoutPrompt = new AtomicBoolean(true);
		vaults.addListener(this::vaultListChanged);

		// register preferences shortcut
		if (Desktop.getDesktop().isSupported(Desktop.Action.APP_PREFERENCES)) {
			Desktop.getDesktop().setPreferencesHandler(this::showPreferencesWindow);
		}

		// register quit handler
		if (Desktop.getDesktop().isSupported(Desktop.Action.APP_QUIT_HANDLER)) {
			Desktop.getDesktop().setQuitHandler(this::handleQuitRequest);
		}

		shutdownHook.runOnShutdown(this::forceUnmountRemainingVaults);
	}

	/**
	 * Gracefully terminates the application.
	 */
	public void quit() {
		handleQuitRequest(null, new QuitResponse() {
			@Override
			public void performQuit() {
				System.exit(0);
			}

			@Override
			public void cancelQuit() {
				// no-op
			}
		});
	}

	private void handleQuitRequest(@SuppressWarnings("unused") EventObject e, QuitResponse response) {
		QuitResponse decoratedQuitResponse = decorateQuitResponse(response);
		if (allowQuitWithoutPrompt.get()) {
			decoratedQuitResponse.performQuit();
		} else {
			fxApplicationStarter.get(true).thenAccept(app -> app.showQuitWindow(decoratedQuitResponse));
		}
	}

	private QuitResponse decorateQuitResponse(QuitResponse originalQuitResponse) {
		return new QuitResponse() {
			@Override
			public void performQuit() {
				Platform.exit(); // will be no-op, if JavaFX never started.
				shutdownLatch.countDown(); // main thread is waiting for this latch
				EventQueue.invokeLater(originalQuitResponse::performQuit); // this will eventually call System.exit(0)
			}

			@Override
			public void cancelQuit() {
				originalQuitResponse.cancelQuit();
			}
		};
	}

	private void vaultListChanged(@SuppressWarnings("unused") Observable observable) {
		assert Platform.isFxApplicationThread();
		boolean allVaultsAllowTermination = vaults.stream().map(Vault::getState).allMatch(STATES_ALLOWING_TERMINATION::contains);
		boolean suddenTerminationChanged = allowQuitWithoutPrompt.compareAndSet(!allVaultsAllowTermination, allVaultsAllowTermination);
		if (suddenTerminationChanged) {
			LOG.debug("Allow quitting without prompt: {}", allVaultsAllowTermination);
		}
	}

	private void showPreferencesWindow(@SuppressWarnings("unused") EventObject actionEvent) {
		fxApplicationStarter.get(true).thenAccept(app -> app.showPreferencesWindow(SelectedPreferencesTab.ANY));
	}

	private void forceUnmountRemainingVaults() {
		for (Vault vault : vaults) {
			if (vault.isUnlocked()) {
				try {
					vault.lock(true);
				} catch (Volume.VolumeException e) {
					LOG.error("Failed to unmount vault " + vault.getPath(), e);
				}
			}
		}
	}

}

M main/ui/src/main/java/org/cryptomator/ui/launcher/UiLauncher.java => main/ui/src/main/java/org/cryptomator/ui/launcher/UiLauncher.java +13 -4
@@ 1,6 1,8 @@
package org.cryptomator.ui.launcher;

import javafx.collections.ObservableList;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.jni.JniException;
import org.cryptomator.jni.MacApplicationUiState;
import org.cryptomator.jni.MacFunctions;


@@ 13,9 15,8 @@ import javax.inject.Inject;
import javax.inject.Singleton;
import java.awt.Desktop;
import java.awt.SystemTray;
import java.awt.desktop.AppReopenedEvent;
import java.awt.desktop.AppReopenedListener;
import java.awt.desktop.SystemEventListener;
import java.util.Collection;
import java.util.Optional;

@Singleton


@@ 24,14 25,16 @@ public class UiLauncher {
	private static final Logger LOG = LoggerFactory.getLogger(UiLauncher.class);

	private final Settings settings;
	private final ObservableList<Vault> vaults;
	private final TrayMenuComponent.Builder trayComponent;
	private final FxApplicationStarter fxApplicationStarter;
	private final AppLaunchEventHandler launchEventHandler;
	private final Optional<MacFunctions> macFunctions;

	@Inject
	public UiLauncher(Settings settings, TrayMenuComponent.Builder trayComponent, FxApplicationStarter fxApplicationStarter, AppLaunchEventHandler launchEventHandler, Optional<MacFunctions> macFunctions) {
	public UiLauncher(Settings settings, ObservableList<Vault> vaults, TrayMenuComponent.Builder trayComponent, FxApplicationStarter fxApplicationStarter, AppLaunchEventHandler launchEventHandler, Optional<MacFunctions> macFunctions) {
		this.settings = settings;
		this.vaults = vaults;
		this.trayComponent = trayComponent;
		this.fxApplicationStarter = fxApplicationStarter;
		this.launchEventHandler = launchEventHandler;


@@ 48,7 51,7 @@ public class UiLauncher {
		}

		// show window on start?
		if (settings.startHidden().get()) {
		if (hasTrayIcon && settings.startHidden().get()) {
			LOG.debug("Hiding application...");
			macFunctions.map(MacFunctions::uiState).ifPresent(JniException.ignore(MacApplicationUiState::transformToAgentApplication));
		} else {


@@ 58,6 61,12 @@ public class UiLauncher {
		// register app reopen listener
		Desktop.getDesktop().addAppEventListener((AppReopenedListener) e -> showMainWindowAsync(hasTrayIcon));

		// auto unlock
		Collection<Vault> vaultsWithAutoUnlockEnabled = vaults.filtered(v -> v.getVaultSettings().unlockAfterStartup().get());
		if (!vaultsWithAutoUnlockEnabled.isEmpty()) {
			fxApplicationStarter.get(hasTrayIcon).thenAccept(app -> app.getVaultService().attemptAutoUnlock(vaultsWithAutoUnlockEnabled));
		}

		launchEventHandler.startHandlingLaunchEvents(hasTrayIcon);
	}


M main/ui/src/main/java/org/cryptomator/ui/mainwindow/MainWindowModule.java => main/ui/src/main/java/org/cryptomator/ui/mainwindow/MainWindowModule.java +3 -3
@@ 25,8 25,8 @@ import org.cryptomator.ui.wrongfilealert.WrongFileAlertComponent;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module(subcomponents = {AddVaultWizardComponent.class, MigrationComponent.class, RemoveVaultComponent.class, VaultOptionsComponent.class, WrongFileAlertComponent.class})


@@ 42,7 42,7 @@ abstract class MainWindowModule {
	@Provides
	@MainWindow
	@MainWindowScoped
	static Stage provideStage(@Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(@Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage(StageStyle.UNDECORATED);
		// TODO: min/max values chosen arbitrarily. We might wanna take a look at the user's resolution...
		stage.setMinWidth(650);


@@ 50,7 50,7 @@ abstract class MainWindowModule {
		stage.setMaxWidth(1000);
		stage.setMaxHeight(700);
		stage.setTitle("Cryptomator");
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}


M main/ui/src/main/java/org/cryptomator/ui/mainwindow/MainWindowTitleController.java => main/ui/src/main/java/org/cryptomator/ui/mainwindow/MainWindowTitleController.java +17 -5
@@ 5,12 5,11 @@ import javafx.fxml.FXML;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;
import org.cryptomator.common.LicenseHolder;
import org.cryptomator.common.vaults.VaultListManager;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.fxapp.FxApplication;
import org.cryptomator.ui.fxapp.UpdateChecker;
import org.cryptomator.ui.launcher.AppLifecycleListener;
import org.cryptomator.ui.preferences.SelectedPreferencesTab;
import org.cryptomator.ui.wrongfilealert.WrongFileAlertComponent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;



@@ 24,6 23,7 @@ public class MainWindowTitleController implements FxController {

	public HBox titleBar;

	private final AppLifecycleListener appLifecycle;
	private final Stage window;
	private final FxApplication application;
	private final boolean minimizeToSysTray;


@@ 35,7 35,8 @@ public class MainWindowTitleController implements FxController {
	private double yOffset;

	@Inject
	MainWindowTitleController(@MainWindow Stage window, FxApplication application, @Named("trayMenuSupported") boolean minimizeToSysTray, UpdateChecker updateChecker, LicenseHolder licenseHolder) {
	MainWindowTitleController(AppLifecycleListener appLifecycle, @MainWindow Stage window, FxApplication application, @Named("trayMenuSupported") boolean minimizeToSysTray, UpdateChecker updateChecker, LicenseHolder licenseHolder) {
		this.appLifecycle = appLifecycle;
		this.window = window;
		this.application = application;
		this.minimizeToSysTray = minimizeToSysTray;


@@ 56,6 57,10 @@ public class MainWindowTitleController implements FxController {
			window.setX(event.getScreenX() - xOffset);
			window.setY(event.getScreenY() - yOffset);
		});
		window.setOnCloseRequest(event -> {
			close();
			event.consume();
		});
	}

	@FXML


@@ 63,11 68,16 @@ public class MainWindowTitleController implements FxController {
		if (minimizeToSysTray) {
			window.close();
		} else {
			window.setIconified(true);
			appLifecycle.quit();
		}
	}

	@FXML
	public void minimize() {
		window.setIconified(true);
	}

	@FXML
	public void showPreferences() {
		application.showPreferencesWindow(SelectedPreferencesTab.ANY);
	}


@@ 91,5 101,7 @@ public class MainWindowTitleController implements FxController {
		return updateAvailable.get();
	}


	public boolean isMinimizeToSysTray() {
		return minimizeToSysTray;
	}
}

M main/ui/src/main/java/org/cryptomator/ui/mainwindow/VaultDetailController.java => main/ui/src/main/java/org/cryptomator/ui/mainwindow/VaultDetailController.java +2 -2
@@ 33,11 33,11 @@ public class VaultDetailController implements FxController {
	private FontAwesome5Icon getGlyphForVaultState(VaultState state) {
		switch (state) {
			case LOCKED:
				return FontAwesome5Icon.LOCK_ALT;
				return FontAwesome5Icon.LOCK;
			case PROCESSING:
				return FontAwesome5Icon.SPINNER;
			case UNLOCKED:
				return FontAwesome5Icon.LOCK_OPEN_ALT;
				return FontAwesome5Icon.LOCK_OPEN;
			default:
				return FontAwesome5Icon.EXCLAMATION_TRIANGLE;
		}

M main/ui/src/main/java/org/cryptomator/ui/mainwindow/VaultListCellController.java => main/ui/src/main/java/org/cryptomator/ui/mainwindow/VaultListCellController.java +2 -2
@@ 25,11 25,11 @@ public class VaultListCellController implements FxController {
	private FontAwesome5Icon getGlyphForVaultState(VaultState state) {
		switch (state) {
			case LOCKED:
				return FontAwesome5Icon.LOCK_ALT;
				return FontAwesome5Icon.LOCK;
			case PROCESSING:
				return FontAwesome5Icon.SPINNER;
			case UNLOCKED:
				return FontAwesome5Icon.LOCK_OPEN_ALT;
				return FontAwesome5Icon.LOCK_OPEN;
			default:
				return FontAwesome5Icon.EXCLAMATION_TRIANGLE;
		}

A main/ui/src/main/java/org/cryptomator/ui/migration/MigrationCapabilityErrorController.java => main/ui/src/main/java/org/cryptomator/ui/migration/MigrationCapabilityErrorController.java +57 -0
@@ 0,0 1,57 @@
package org.cryptomator.ui.migration;

import dagger.Lazy;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.cryptomator.cryptofs.common.FileSystemCapabilityChecker;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;

import javax.inject.Inject;
import javax.inject.Named;
import java.util.ResourceBundle;

@MigrationScoped
public class MigrationCapabilityErrorController implements FxController {

	private final Stage window;
	private final ResourceBundle localization;
	private final Lazy<Scene> startScene;
	private final StringBinding missingCapabilityDescription;
	private final ReadOnlyObjectProperty<FileSystemCapabilityChecker.Capability> missingCapability;

	@Inject
	MigrationCapabilityErrorController(@MigrationWindow Stage window, @Named("capabilityErrorCause") ObjectProperty<FileSystemCapabilityChecker.Capability> missingCapability, ResourceBundle localization, @FxmlScene(FxmlFile.MIGRATION_START) Lazy<Scene> startScene) {
		this.window = window;
		this.missingCapability = missingCapability;
		this.localization = localization;
		this.startScene = startScene;
		this.missingCapabilityDescription = Bindings.createStringBinding(this::getMissingCapabilityDescription, missingCapability);
	}

	@FXML
	public void back() {
		window.setScene(startScene.get());
	}

	/* Getters */

	public StringBinding missingCapabilityDescriptionProperty() {
		return missingCapabilityDescription;
	}

	public String getMissingCapabilityDescription() {
		FileSystemCapabilityChecker.Capability c = missingCapability.get();
		if (c != null) {
			return localization.getString("migration.error.missingFileSystemCapabilities.reason." + c.name());
		} else {
			return null;
		}
	}
}

A main/ui/src/main/java/org/cryptomator/ui/migration/MigrationGenericErrorController.java => main/ui/src/main/java/org/cryptomator/ui/migration/MigrationGenericErrorController.java +29 -0
@@ 0,0 1,29 @@
package org.cryptomator.ui.migration;

import dagger.Lazy;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;

import javax.inject.Inject;

@MigrationScoped
public class MigrationGenericErrorController implements FxController {

	private final Stage window;
	private final Lazy<Scene> startScene;

	@Inject
	MigrationGenericErrorController(@MigrationWindow Stage window, @FxmlScene(FxmlFile.MIGRATION_START) Lazy<Scene> startScene) {
		this.window = window;
		this.startScene = startScene;
	}

	@FXML
	public void back() {
		window.setScene(startScene.get());
	}
}

M main/ui/src/main/java/org/cryptomator/ui/migration/MigrationModule.java => main/ui/src/main/java/org/cryptomator/ui/migration/MigrationModule.java +53 -3
@@ 4,22 4,26 @@ import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import dagger.multibindings.IntoMap;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.stage.Modality;
import javafx.stage.Stage;
import org.cryptomator.cryptofs.common.FileSystemCapabilityChecker;
import org.cryptomator.ui.common.DefaultSceneFactory;
import org.cryptomator.ui.common.FXMLLoaderFactory;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxControllerKey;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;
import org.cryptomator.ui.common.StackTraceController;
import org.cryptomator.ui.mainwindow.MainWindow;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module


@@ 35,17 39,31 @@ abstract class MigrationModule {
	@Provides
	@MigrationWindow
	@MigrationScoped
	static Stage provideStage(@MainWindow Stage owner, ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(@MainWindow Stage owner, ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage();
		stage.setTitle(resourceBundle.getString("migration.title"));
		stage.setResizable(false);
		stage.initModality(Modality.WINDOW_MODAL);
		stage.initOwner(owner);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}

	@Provides
	@Named("genericErrorCause")
	@MigrationScoped
	static ObjectProperty<Throwable> provideGenericErrorCause() {
		return new SimpleObjectProperty<>();
	}

	@Provides
	@Named("capabilityErrorCause")
	@MigrationScoped
	static ObjectProperty<FileSystemCapabilityChecker.Capability> provideCapabilityErrorCause() {
		return new SimpleObjectProperty<>();
	}

	@Provides
	@FxmlScene(FxmlFile.MIGRATION_START)
	@MigrationScoped
	static Scene provideMigrationStartScene(@MigrationWindow FXMLLoaderFactory fxmlLoaders) {


@@ 66,6 84,21 @@ abstract class MigrationModule {
		return fxmlLoaders.createScene("/fxml/migration_success.fxml");
	}

	@Provides
	@FxmlScene(FxmlFile.MIGRATION_CAPABILITY_ERROR)
	@MigrationScoped
	static Scene provideMigrationCapabilityErrorScene(@MigrationWindow FXMLLoaderFactory fxmlLoaders) {
		return fxmlLoaders.createScene("/fxml/migration_capability_error.fxml");
	}

	@Provides
	@FxmlScene(FxmlFile.MIGRATION_GENERIC_ERROR)
	@MigrationScoped
	static Scene provideMigrationGenericErrorScene(@MigrationWindow FXMLLoaderFactory fxmlLoaders) {
		return fxmlLoaders.createScene("/fxml/migration_generic_error.fxml");
	}


	// ------------------

	@Binds


@@ 83,4 116,21 @@ abstract class MigrationModule {
	@FxControllerKey(MigrationSuccessController.class)
	abstract FxController bindMigrationSuccessController(MigrationSuccessController controller);

	@Binds
	@IntoMap
	@FxControllerKey(MigrationCapabilityErrorController.class)
	abstract FxController bindMigrationCapabilityErrorController(MigrationCapabilityErrorController controller);

	@Binds
	@IntoMap
	@FxControllerKey(MigrationGenericErrorController.class)
	abstract FxController bindMigrationGenericErrorController(MigrationGenericErrorController controller);

	@Provides
	@IntoMap
	@FxControllerKey(StackTraceController.class)
	static FxController provideStackTraceController(@Named("genericErrorCause") ObjectProperty<Throwable> errorCause) {
		return new StackTraceController(errorCause.get());
	}

}

M main/ui/src/main/java/org/cryptomator/ui/migration/MigrationRunController.java => main/ui/src/main/java/org/cryptomator/ui/migration/MigrationRunController.java +36 -72
@@ 1,32 1,28 @@
package org.cryptomator.ui.migration;

import dagger.Lazy;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.value.WritableValue;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.scene.control.ContentDisplay;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.common.vaults.VaultState;
import org.cryptomator.cryptofs.common.FileSystemCapabilityChecker;
import org.cryptomator.cryptofs.migration.Migrators;
import org.cryptomator.cryptofs.migration.api.MigrationProgressListener;
import org.cryptomator.cryptofs.migration.api.NoApplicableMigratorException;
import org.cryptomator.cryptolib.api.InvalidPassphraseException;
import org.cryptomator.keychain.KeychainAccess;
import org.cryptomator.keychain.KeychainAccessException;
import org.cryptomator.ui.common.Animations;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;


@@ 36,44 32,54 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import javax.inject.Named;
import java.util.Arrays;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

@MigrationScoped
public class MigrationRunController implements FxController {

	private static final Logger LOG = LoggerFactory.getLogger(MigrationRunController.class);
	private static final String MASTERKEY_FILENAME = "masterkey.cryptomator"; // TODO: deduplicate constant declared in multiple classes
	private static final Duration MIGRATION_PROGRESS_UPDATE_INTERVAL = Duration.millis(25);
	private static final long MIGRATION_PROGRESS_UPDATE_MILLIS = 50;

	private final Stage window;
	private final Vault vault;
	private final ExecutorService executor;
	private final ScheduledExecutorService scheduler;
	private final Optional<KeychainAccess> keychainAccess;
	private final ObjectProperty<FileSystemCapabilityChecker.Capability> missingCapability;
	private final ObjectProperty<Throwable> errorCause;
	private final Lazy<Scene> startScene;
	private final Lazy<Scene> successScene;
	private final ObjectBinding<ContentDisplay> migrateButtonContentDisplay;
	private final Lazy<Scene> capabilityErrorScene;
	private final Lazy<Scene> genericErrorScene;
	private final BooleanProperty migrationButtonDisabled;
	private final DoubleProperty migrationProgress;
	private final ScheduledService<Double> migrationProgressObservationService;
	private volatile double volatileMigrationProgress = -1.0;
	public NiceSecurePasswordField passwordField;

	@Inject
	public MigrationRunController(@MigrationWindow Stage window, @MigrationWindow Vault vault, ExecutorService executor, Optional<KeychainAccess> keychainAccess, @FxmlScene(FxmlFile.MIGRATION_START) Lazy<Scene> startScene, @FxmlScene(FxmlFile.MIGRATION_SUCCESS) Lazy<Scene> successScene) {
	public MigrationRunController(@MigrationWindow Stage window, @MigrationWindow Vault vault, ExecutorService executor, ScheduledExecutorService scheduler, Optional<KeychainAccess> keychainAccess, @Named("capabilityErrorCause") ObjectProperty<FileSystemCapabilityChecker.Capability> missingCapability, @Named("genericErrorCause") ObjectProperty<Throwable> errorCause, @FxmlScene(FxmlFile.MIGRATION_START) Lazy<Scene> startScene, @FxmlScene(FxmlFile.MIGRATION_SUCCESS) Lazy<Scene> successScene, @FxmlScene(FxmlFile.MIGRATION_CAPABILITY_ERROR) Lazy<Scene> capabilityErrorScene, @FxmlScene(FxmlFile.MIGRATION_GENERIC_ERROR) Lazy<Scene> genericErrorScene) {
		this.window = window;
		this.vault = vault;
		this.executor = executor;
		this.scheduler = scheduler;
		this.keychainAccess = keychainAccess;
		this.missingCapability = missingCapability;
		this.errorCause = errorCause;
		this.startScene = startScene;
		this.successScene = successScene;
		this.migrateButtonContentDisplay = Bindings.createObjectBinding(this::getMigrateButtonContentDisplay, vault.stateProperty());
		this.capabilityErrorScene = capabilityErrorScene;
		this.genericErrorScene = genericErrorScene;
		this.migrationButtonDisabled = new SimpleBooleanProperty();
		this.migrationProgress = new SimpleDoubleProperty(volatileMigrationProgress);
		this.migrationProgressObservationService = new MigrationProgressObservationService();
		migrationProgressObservationService.setExecutor(executor);
		migrationProgressObservationService.setPeriod(MIGRATION_PROGRESS_UPDATE_INTERVAL);
	}

	public void initialize() {


@@ 93,36 99,42 @@ public class MigrationRunController implements FxController {
		LOG.info("Migrating vault {}", vault.getPath());
		CharSequence password = passwordField.getCharacters();
		vault.setState(VaultState.PROCESSING);
		migrationProgressObservationService.start();
		ScheduledFuture<?> progressSyncTask = scheduler.scheduleAtFixedRate(() -> {
			Platform.runLater(() -> {
				migrationProgress.set(volatileMigrationProgress);
			});
		}, 0, MIGRATION_PROGRESS_UPDATE_MILLIS, TimeUnit.MILLISECONDS);
		Tasks.create(() -> {
			Migrators migrators = Migrators.get();
			migrators.migrate(vault.getPath(), MASTERKEY_FILENAME, password, this::migrationProgressChanged);
			return migrators.needsMigration(vault.getPath(), MASTERKEY_FILENAME);
		}).onSuccess(needsAnotherMigration -> {
			LOG.info("Migration of '{}' succeeded.", vault.getDisplayableName());
			if (needsAnotherMigration) {
				LOG.info("Migration of '{}' succeeded, but another migration is required.", vault.getDisplayableName());
				vault.setState(VaultState.NEEDS_MIGRATION);
			} else {
				LOG.info("Migration of '{}' succeeded.", vault.getDisplayableName());
				vault.setState(VaultState.LOCKED);
				passwordField.swipe();
				window.setScene(successScene.get());
			}
		}).onError(InvalidPassphraseException.class, e -> {
			shakeWindow();
			Animations.createShakeWindowAnimation(window).play();
			passwordField.selectAll();
			passwordField.requestFocus();
			vault.setState(VaultState.NEEDS_MIGRATION);
		}).onError(NoApplicableMigratorException.class, e -> {
			LOG.error("Can not migrate vault.", e);
		}).onError(FileSystemCapabilityChecker.MissingCapabilityException.class, e -> {
			LOG.error("Underlying file system not supported.", e);
			vault.setState(VaultState.ERROR);
			// TODO show specific error screen
			missingCapability.set(e.getMissingCapability());
			window.setScene(capabilityErrorScene.get());
		}).onError(Exception.class, e -> { // including RuntimeExceptions
			LOG.error("Migration failed for technical reasons.", e);
			vault.setState(VaultState.ERROR);
			// TODO show generic error screen
			vault.setState(VaultState.NEEDS_MIGRATION);
			errorCause.set(e);
			window.setScene(genericErrorScene.get());
		}).andFinally(() -> {
			migrationProgressObservationService.cancel();
			migrationProgressObservationService.reset();
			progressSyncTask.cancel(true);
		}).runOnce(executor);
	}



@@ 161,54 173,6 @@ public class MigrationRunController implements FxController {
		}
	}

	// Sets migrationProgress to volatileMigrationProgress at its configured interval
	private class MigrationProgressObservationService extends ScheduledService<Double> {

		@Override
		protected Task<Double> createTask() {
			return new Task<>() {
				@Override
				protected Double call() {
					return volatileMigrationProgress;
				}
			};
		}

		@Override
		protected void succeeded() {
			assert getValue() != null;
			migrationProgress.set(getValue());
			super.succeeded();
		}
	}

	/* Animations */

	private void shakeWindow() {
		WritableValue<Double> writableWindowX = new WritableValue<>() {
			@Override
			public Double getValue() {
				return window.getX();
			}

			@Override
			public void setValue(Double value) {
				window.setX(value);
			}
		};
		Timeline timeline = new Timeline( //
				new KeyFrame(Duration.ZERO, new KeyValue(writableWindowX, window.getX())), //
				new KeyFrame(new Duration(100), new KeyValue(writableWindowX, window.getX() - 22.0)), //
				new KeyFrame(new Duration(200), new KeyValue(writableWindowX, window.getX() + 18.0)), //
				new KeyFrame(new Duration(300), new KeyValue(writableWindowX, window.getX() - 14.0)), //
				new KeyFrame(new Duration(400), new KeyValue(writableWindowX, window.getX() + 10.0)), //
				new KeyFrame(new Duration(500), new KeyValue(writableWindowX, window.getX() - 6.0)), //
				new KeyFrame(new Duration(600), new KeyValue(writableWindowX, window.getX() + 2.0)), //
				new KeyFrame(new Duration(700), new KeyValue(writableWindowX, window.getX())) //
		);
		timeline.play();
	}

	/* Getter/Setter */

	public Vault getVault() {

M main/ui/src/main/java/org/cryptomator/ui/preferences/PreferencesModule.java => main/ui/src/main/java/org/cryptomator/ui/preferences/PreferencesModule.java +3 -3
@@ 18,8 18,8 @@ import org.cryptomator.ui.common.FxmlScene;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module(includes = {AutoStartModule.class})


@@ 41,11 41,11 @@ abstract class PreferencesModule {
	@Provides
	@PreferencesWindow
	@PreferencesScoped
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage();
		stage.setTitle(resourceBundle.getString("preferences.title"));
		stage.setResizable(false);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}


M main/ui/src/main/java/org/cryptomator/ui/quit/QuitModule.java => main/ui/src/main/java/org/cryptomator/ui/quit/QuitModule.java +3 -3
@@ 20,8 20,8 @@ import org.cryptomator.ui.common.FxmlScene;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module


@@ 37,12 37,12 @@ abstract class QuitModule {
	@Provides
	@QuitWindow
	@QuitScoped
	static Stage provideStage(@Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(@Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage();
		stage.setMinWidth(300);
		stage.setMinHeight(100);
		stage.initModality(Modality.APPLICATION_MODAL);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}


A main/ui/src/main/java/org/cryptomator/ui/recoverykey/AutoCompleter.java => main/ui/src/main/java/org/cryptomator/ui/recoverykey/AutoCompleter.java +69 -0
@@ 0,0 1,69 @@
package org.cryptomator.ui.recoverykey;

import com.google.common.base.Strings;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

public class AutoCompleter {

	private final List<String> dictionary;

	public AutoCompleter(Collection<String> dictionary) {
		this.dictionary = unmodifiableSortedRandomAccessList(dictionary);
	}

	private static <T extends Comparable<T>> List<T> unmodifiableSortedRandomAccessList(Collection<T> items) {
		List<T> result = new ArrayList<>(items);
		Collections.sort(result);
		return Collections.unmodifiableList(result);
	}

	public Optional<String> autocomplete(String prefix) {
		if (Strings.isNullOrEmpty(prefix)) {
			return Optional.empty();
		}
		int potentialMatchIdx = findIndexOfLexicographicallyPreceeding(0, dictionary.size(), prefix);
		if (potentialMatchIdx < dictionary.size()) {
			String potentialMatch = dictionary.get(potentialMatchIdx);
			return potentialMatch.startsWith(prefix) ? Optional.of(potentialMatch) : Optional.empty();
		} else {
			return Optional.empty();
		}
	}

	/**
	 * Find the index of the first word in {@link #dictionary} that starts with a given prefix.
	 * 
	 * This method performs an "unsuccessful" binary search (it doesn't return when encountering an exact match).
	 * Instead it continues searching in the left half (which includes the exact match) until only one element is left.
	 * 
	 * If the dictionary doesn't contain a word "left" of the given prefix, this method returns an invalid index, though.
	 *
	 * @param begin Index of first element (inclusive)
	 * @param end Index of last element (exclusive)
	 * @param prefix
	 * @return index between [0, dictLen], i.e. index can exceed the upper bounds of {@link #dictionary}.
	 */
	private int findIndexOfLexicographicallyPreceeding(int begin, int end, String prefix) {
		if (begin >= end) {
			return begin; // this is usually where a binary search ends "unsuccessful"
		}

		int mid = (begin + end) / 2;
		String word = dictionary.get(mid);
		if (prefix.compareTo(word) <= 0) { // prefix preceeds or matches word
			// proceed in left half
			assert mid < end;
			return findIndexOfLexicographicallyPreceeding(0, mid, prefix);
		} else {
			// proceed in right half
			assert mid >= begin;
			return findIndexOfLexicographicallyPreceeding(mid + 1, end, prefix);
		}
	}

}

M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyComponent.java => main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyComponent.java +12 -2
@@ 21,11 21,21 @@ public interface RecoveryKeyComponent {
	Stage window();

	@FxmlScene(FxmlFile.RECOVERYKEY_CREATE)
	Lazy<Scene> scene();
	Lazy<Scene> creationScene();

	@FxmlScene(FxmlFile.RECOVERYKEY_RECOVER)
	Lazy<Scene> recoverScene();

	default void showRecoveryKeyCreationWindow() {
		Stage stage = window();
		stage.setScene(scene().get());
		stage.setScene(creationScene().get());
		stage.sizeToScene();
		stage.show();
	}

	default void showRecoveryKeyRecoverWindow() {
		Stage stage = window();
		stage.setScene(recoverScene().get());
		stage.sizeToScene();
		stage.show();
	}

M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyCreationController.java => main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyCreationController.java +24 -41
@@ 1,27 1,21 @@
package org.cryptomator.ui.recoverykey;

import dagger.Lazy;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.WritableValue;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.cryptolib.api.InvalidPassphraseException;
import org.cryptomator.ui.common.Animations;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;
import org.cryptomator.ui.common.Tasks;
import org.cryptomator.ui.controls.NiceSecurePasswordField;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import javax.inject.Inject;
import java.io.IOException;
import java.util.concurrent.ExecutorService;


@@ 48,19 42,26 @@ public class RecoveryKeyCreationController implements FxController {
		this.recoveryKeyFactory = recoveryKeyFactory;
		this.recoveryKeyProperty = recoveryKey;
	}
	

	@FXML
	public void createRecoveryKey() {
		Tasks.create(() -> {
			return recoveryKeyFactory.createRecoveryKey(vault.getPath(), passwordField.getCharacters());
		}).onSuccess(result -> {
			recoveryKeyProperty.set(result);
		Task<String> task = new RecoveryKeyCreationTask();
		task.setOnScheduled(event -> {
			LOG.debug("Creating recovery key for {}.", vault.getDisplayablePath());
		});
		task.setOnSucceeded(event -> {
			String recoveryKey = task.getValue();
			recoveryKeyProperty.set(recoveryKey);
			window.setScene(successScene.get());
		}).onError(IOException.class, e -> {
			LOG.error("Creation of recovery key failed.", e);
		}).onError(InvalidPassphraseException.class, e -> {
			shakeWindow();
		}).runOnce(executor);
		});
		task.setOnFailed(event -> {
			if (task.getException() instanceof InvalidPassphraseException) {
				Animations.createShakeWindowAnimation(window).play();
			} else {
				LOG.error("Creation of recovery key failed.", task.getException());
			}
		});
		executor.submit(task);
	}

	@FXML


@@ 68,31 69,13 @@ public class RecoveryKeyCreationController implements FxController {
		window.close();
	}

	/* Animations */
	private class RecoveryKeyCreationTask extends Task<String> {

	private void shakeWindow() {
		WritableValue<Double> writableWindowX = new WritableValue<>() {
			@Override
			public Double getValue() {
				return window.getX();
			}
		@Override
		protected String call() throws IOException {
			return recoveryKeyFactory.createRecoveryKey(vault.getPath(), passwordField.getCharacters());
		}

			@Override
			public void setValue(Double value) {
				window.setX(value);
			}
		};
		Timeline timeline = new Timeline( //
				new KeyFrame(Duration.ZERO, new KeyValue(writableWindowX, window.getX())), //
				new KeyFrame(new Duration(100), new KeyValue(writableWindowX, window.getX() - 22.0)), //
				new KeyFrame(new Duration(200), new KeyValue(writableWindowX, window.getX() + 18.0)), //
				new KeyFrame(new Duration(300), new KeyValue(writableWindowX, window.getX() - 14.0)), //
				new KeyFrame(new Duration(400), new KeyValue(writableWindowX, window.getX() + 10.0)), //
				new KeyFrame(new Duration(500), new KeyValue(writableWindowX, window.getX() - 6.0)), //
				new KeyFrame(new Duration(600), new KeyValue(writableWindowX, window.getX() + 2.0)), //
				new KeyFrame(new Duration(700), new KeyValue(writableWindowX, window.getX())) //
		);
		timeline.play();
	}

	/* Getter/Setter */

M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyDisplayController.java => main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyDisplayController.java +10 -2
@@ 4,6 4,7 @@ import javafx.fxml.FXML;
import javafx.print.PageLayout;
import javafx.print.Printer;
import javafx.print.PrinterJob;
import javafx.scene.control.Button;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.text.Font;


@@ 16,6 17,8 @@ import org.cryptomator.ui.common.FxController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ResourceBundle;

public class RecoveryKeyDisplayController implements FxController {
	
	private static final Logger LOG = LoggerFactory.getLogger(RecoveryKeyDisplayController.class);


@@ 23,11 26,14 @@ public class RecoveryKeyDisplayController implements FxController {
	private final Stage window;
	private final String vaultName;
	private final String recoveryKey;
	
	public RecoveryKeyDisplayController(Stage window, String vaultName, String recoveryKey) {
	private final ResourceBundle localization;
	public Button copyButton;

	public RecoveryKeyDisplayController(Stage window, String vaultName, String recoveryKey, ResourceBundle localization) {
		this.window = window;
		this.vaultName = vaultName;
		this.recoveryKey = recoveryKey;
		this.localization = localization;
	}

	@FXML


@@ 68,6 74,8 @@ public class RecoveryKeyDisplayController implements FxController {
		clipboardContent.putString(recoveryKey);
		Clipboard.getSystemClipboard().setContent(clipboardContent);
		LOG.info("Recovery key copied to clipboard.");

		copyButton.setText(localization.getString("generic.button.copied"));
	}

	@FXML

M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyFactory.java => main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyFactory.java +43 -10
@@ 10,11 10,13 @@ import javax.inject.Singleton;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collection;

@Singleton
public class RecoveryKeyFactory {

	private static final String MASTERKEY_FILENAME = "masterkey.cryptomator"; // TODO: deduplicate constant declared in multiple classes
	private static final byte[] PEPPER = new byte[0];
	
	private final WordEncoder wordEncoder;
	


@@ 22,6 24,10 @@ public class RecoveryKeyFactory {
	public RecoveryKeyFactory(WordEncoder wordEncoder) {
		this.wordEncoder = wordEncoder;
	}
	
	public Collection<String> getDictionary() {
		return wordEncoder.getWords();
	}

	/**
	 * @param vaultPath Path to the storage location of a vault


@@ 32,7 38,7 @@ public class RecoveryKeyFactory {
	 * @apiNote This is a long-running operation and should be invoked in a background thread
	 */
	public String createRecoveryKey(Path vaultPath, CharSequence password) throws IOException, InvalidPassphraseException {
		byte[] rawKey = CryptoFileSystemProvider.exportRawKey(vaultPath, MASTERKEY_FILENAME, new byte[0], password);
		byte[] rawKey = CryptoFileSystemProvider.exportRawKey(vaultPath, MASTERKEY_FILENAME, PEPPER, password);
		try {
			return createRecoveryKey(rawKey);
		} finally {


@@ 54,25 60,52 @@ public class RecoveryKeyFactory {
	}

	/**
	 * Creates a completely new masterkey using a recovery key.
	 * @param vaultPath Path to the storage location of a vault
	 * @param recoveryKey A recovery key for this vault
	 * @param newPassword The new password used to encrypt the keys
	 * @throws IOException If the masterkey file could not be written
	 * @throws IllegalArgumentException If the recoveryKey is invalid
	 * @apiNote This is a long-running operation and should be invoked in a background thread
	 */
	public void resetPasswordWithRecoveryKey(Path vaultPath, String recoveryKey, CharSequence newPassword) throws IOException, IllegalArgumentException {
		final byte[] rawKey = decodeRecoveryKey(recoveryKey);
		try {
			CryptoFileSystemProvider.restoreRawKey(vaultPath, MASTERKEY_FILENAME, rawKey, PEPPER, newPassword);
		} finally {
			Arrays.fill(rawKey, (byte) 0x00);
		}
	}

	/**
	 * Checks whether a String is a syntactically correct recovery key with a valid checksum
	 * @param recoveryKey A word sequence which might be a recovery key
	 * @return <code>true</code> if this seems to be a legitimate recovery key
	 */
	public boolean validateRecoveryKey(String recoveryKey) {
		final byte[] paddedKey;
		try {
			paddedKey = wordEncoder.decode(recoveryKey);
			byte[] key = decodeRecoveryKey(recoveryKey);
			Arrays.fill(key, (byte) 0x00);
			return true;
		} catch (IllegalArgumentException e) {
			return false;
		}
		if (paddedKey.length != 66) {
			return false;
	}
	
	private byte[] decodeRecoveryKey(String recoveryKey) throws IllegalArgumentException {
		byte[] paddedKey = new byte[0];
		try {
			paddedKey = wordEncoder.decode(recoveryKey);
			Preconditions.checkArgument(paddedKey.length == 66, "Recovery key doesn't consist of 66 bytes.");
			byte[] rawKey = Arrays.copyOf(paddedKey, 64);
			byte[] expectedCrc16 = Arrays.copyOfRange(paddedKey, 64, 66);
			byte[] actualCrc32 = Hashing.crc32().hashBytes(rawKey).asBytes();
			byte[] actualCrc16 = Arrays.copyOf(actualCrc32, 2);
			Preconditions.checkArgument(Arrays.equals(expectedCrc16, actualCrc16), "Recovery key has invalid CRC.");
			return rawKey;
		} finally {
			Arrays.fill(paddedKey, (byte) 0x00);
		}
		byte[] rawKey = Arrays.copyOf(paddedKey, 64);
		byte[] expectedCrc16 = Arrays.copyOfRange(paddedKey, 64, 66);
		byte[] actualCrc32 = Hashing.crc32().hashBytes(rawKey).asBytes();
		byte[] actualCrc16 = Arrays.copyOf(actualCrc32, 2);
		return Arrays.equals(expectedCrc16, actualCrc16);
	}

}

M main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyModule.java => main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyModule.java +49 -6
@@ 4,6 4,8 @@ import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import dagger.multibindings.IntoMap;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.Scene;


@@ 17,11 19,13 @@ import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxControllerKey;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;
import org.cryptomator.ui.common.NewPasswordController;
import org.cryptomator.ui.common.PasswordStrengthUtil;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module


@@ 37,13 41,13 @@ abstract class RecoveryKeyModule {
	@Provides
	@RecoveryKeyWindow
	@RecoveryKeyScoped
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon, @Named("keyRecoveryOwner") Stage owner) {
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons, @Named("keyRecoveryOwner") Stage owner) {
		Stage stage = new Stage();
		stage.setTitle(resourceBundle.getString("recoveryKey.title"));
		stage.setResizable(false);
		stage.initModality(Modality.WINDOW_MODAL);
		stage.initOwner(owner);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}
	


@@ 53,7 57,15 @@ abstract class RecoveryKeyModule {
	static StringProperty provideRecoveryKeyProperty() {
		return new SimpleStringProperty();
	}
	

	@Provides
	@RecoveryKeyScoped
	@Named("newPassword")
	static ObjectProperty<CharSequence> provideNewPasswordProperty() {
		return new SimpleObjectProperty<>("");
	}


	// ------------------

	@Provides


@@ 70,6 82,20 @@ abstract class RecoveryKeyModule {
		return fxmlLoaders.createScene("/fxml/recoverykey_success.fxml");
	}

	@Provides
	@FxmlScene(FxmlFile.RECOVERYKEY_RECOVER)
	@RecoveryKeyScoped
	static Scene provideRecoveryKeyRecoverScene(@RecoveryKeyWindow FXMLLoaderFactory fxmlLoaders) {
		return fxmlLoaders.createScene("/fxml/recoverykey_recover.fxml");
	}

	@Provides
	@FxmlScene(FxmlFile.RECOVERYKEY_RESET_PASSWORD)
	@RecoveryKeyScoped
	static Scene provideRecoveryKeyResetPasswordScene(@RecoveryKeyWindow FXMLLoaderFactory fxmlLoaders) {
		return fxmlLoaders.createScene("/fxml/recoverykey_reset_password.fxml");
	}

	// ------------------

	@Binds


@@ 80,13 106,30 @@ abstract class RecoveryKeyModule {
	@Provides
	@IntoMap
	@FxControllerKey(RecoveryKeyDisplayController.class)
	static FxController provideRecoveryKeyDisplayController(@RecoveryKeyWindow Stage window, @RecoveryKeyWindow Vault vault, @RecoveryKeyWindow StringProperty recoveryKey) {
		return new RecoveryKeyDisplayController(window, vault.getDisplayableName(), recoveryKey.get());
	static FxController provideRecoveryKeyDisplayController(@RecoveryKeyWindow Stage window, @RecoveryKeyWindow Vault vault, @RecoveryKeyWindow StringProperty recoveryKey, ResourceBundle localization) {
		return new RecoveryKeyDisplayController(window, vault.getDisplayableName(), recoveryKey.get(), localization);
	}

	@Binds
	@IntoMap
	@FxControllerKey(RecoveryKeyRecoverController.class)
	abstract FxController provideRecoveryKeyRecoverController(RecoveryKeyRecoverController controller);

	@Binds
	@IntoMap
	@FxControllerKey(RecoveryKeySuccessController.class)
	abstract FxController bindRecoveryKeySuccessController(RecoveryKeySuccessController controller);

	@Binds
	@IntoMap
	@FxControllerKey(RecoveryKeyResetPasswordController.class)
	abstract FxController bindRecoveryKeyResetPasswordController(RecoveryKeyResetPasswordController controller);

	@Provides
	@IntoMap
	@FxControllerKey(NewPasswordController.class)
	static FxController provideNewPasswordController(ResourceBundle resourceBundle, PasswordStrengthUtil strengthRater, @Named("newPassword") ObjectProperty<CharSequence> password) {
		return new NewPasswordController(resourceBundle, strengthRater, password);
	}
	
}

A main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyRecoverController.java => main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyRecoverController.java +116 -0
@@ 0,0 1,116 @@
package org.cryptomator.ui.recoverykey;

import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import dagger.Lazy;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.StringProperty;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextFormatter;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;

import javax.inject.Inject;
import java.util.Optional;

@RecoveryKeyScoped
public class RecoveryKeyRecoverController implements FxController {

	private final static CharMatcher ALLOWED_CHARS = CharMatcher.inRange('a', 'z').or(CharMatcher.is(' '));

	private final Stage window;
	private final Vault vault;
	private final StringProperty recoveryKey;
	private final RecoveryKeyFactory recoveryKeyFactory;
	private final BooleanBinding validRecoveryKey;
	private final Lazy<Scene> resetPasswordScene;
	private final AutoCompleter autoCompleter;

	public TextArea textarea;

	@Inject
	public RecoveryKeyRecoverController(@RecoveryKeyWindow Stage window, @RecoveryKeyWindow Vault vault, @RecoveryKeyWindow StringProperty recoveryKey, RecoveryKeyFactory recoveryKeyFactory, @FxmlScene(FxmlFile.RECOVERYKEY_RESET_PASSWORD) Lazy<Scene> resetPasswordScene) {
		this.window = window;
		this.vault = vault;
		this.recoveryKey = recoveryKey;
		this.recoveryKeyFactory = recoveryKeyFactory;
		this.validRecoveryKey = Bindings.createBooleanBinding(this::isValidRecoveryKey, recoveryKey);
		this.resetPasswordScene = resetPasswordScene;
		this.autoCompleter = new AutoCompleter(recoveryKeyFactory.getDictionary());
	}

	@FXML
	public void initialize() {
		recoveryKey.bind(textarea.textProperty());
	}

	private TextFormatter.Change filterTextChange(TextFormatter.Change change) {
		if (Strings.isNullOrEmpty(change.getText())) {
			// pass-through caret/selection changes that don't affect the text
			return change;
		}
		if (!ALLOWED_CHARS.matchesAllOf(change.getText())) {
			return null; // reject change
		}

		String text = change.getControlNewText();
		int caretPos = change.getCaretPosition();
		if (caretPos == text.length() || text.charAt(caretPos) == ' ') { // are we at the end of a word?
			int beginOfWord = Math.max(text.substring(0, caretPos).lastIndexOf(' ') + 1, 0);
			String currentWord = text.substring(beginOfWord, caretPos);
			Optional<String> suggestion = autoCompleter.autocomplete(currentWord);
			if (suggestion.isPresent()) {
				String completion = suggestion.get().substring(currentWord.length());
				change.setText(change.getText() + completion);
				change.setAnchor(caretPos + completion.length());
			}
		}
		return change;
	}

	@FXML
	public void onKeyPressed(KeyEvent keyEvent) {
		if (keyEvent.getCode() == KeyCode.TAB && textarea.getAnchor() > textarea.getCaretPosition()) {
			// apply autocompletion:
			int pos = textarea.getAnchor();
			textarea.insertText(pos, " ");
			textarea.positionCaret(pos + 1);
		}
	}

	@FXML
	public void close() {
		window.close();
	}

	@FXML
	public void recover() {
		window.setScene(resetPasswordScene.get());
	}

	/* Getter/Setter */

	public Vault getVault() {
		return vault;
	}

	public BooleanBinding validRecoveryKeyProperty() {
		return validRecoveryKey;
	}

	public boolean isValidRecoveryKey() {
		return recoveryKeyFactory.validateRecoveryKey(recoveryKey.get());
	}

	public TextFormatter getRecoveryKeyTextFormatter() {
		return new TextFormatter<>(this::filterTextChange);
	}
}

A main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyResetPasswordController.java => main/ui/src/main/java/org/cryptomator/ui/recoverykey/RecoveryKeyResetPasswordController.java +94 -0
@@ 0,0 1,94 @@
package org.cryptomator.ui.recoverykey;

import dagger.Lazy;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.cryptolib.api.InvalidPassphraseException;
import org.cryptomator.ui.common.Animations;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import javax.inject.Named;
import java.io.IOException;
import java.util.concurrent.ExecutorService;

@RecoveryKeyScoped
public class RecoveryKeyResetPasswordController implements FxController {
	
	private static final Logger LOG = LoggerFactory.getLogger(RecoveryKeyResetPasswordController.class);

	private final Stage window;
	private final Vault vault;
	private final RecoveryKeyFactory recoveryKeyFactory;
	private final ExecutorService executor;
	private final StringProperty recoveryKey;
	private final ObjectProperty<CharSequence> newPassword;
	private final Lazy<Scene> recoverScene;
	private final BooleanBinding invalidNewPassword;

	@Inject
	public RecoveryKeyResetPasswordController(@RecoveryKeyWindow Stage window, @RecoveryKeyWindow Vault vault, RecoveryKeyFactory recoveryKeyFactory, ExecutorService executor, @RecoveryKeyWindow StringProperty recoveryKey, @Named("newPassword")ObjectProperty<CharSequence> newPassword, @FxmlScene(FxmlFile.RECOVERYKEY_RECOVER) Lazy<Scene> recoverScene) {
		this.window = window;
		this.vault = vault;
		this.recoveryKeyFactory = recoveryKeyFactory;
		this.executor = executor;
		this.recoveryKey = recoveryKey;
		this.newPassword = newPassword;
		this.recoverScene = recoverScene;
		this.invalidNewPassword = Bindings.createBooleanBinding(this::isInvalidNewPassword, newPassword);
	}

	@FXML
	public void back() {
		window.setScene(recoverScene.get());
	}

	@FXML
	public void done() {
		Task<Void> task = new ResetPasswordTask();
		task.setOnScheduled(event -> {
			LOG.debug("Using recovery key to reset password for {}.", vault.getDisplayablePath());
		});
		task.setOnSucceeded(event -> {
			LOG.info("Used recovery key to reset password for {}.", vault.getDisplayablePath());
			// TODO show success screen
			window.close();
		});
		task.setOnFailed(event -> {
			// TODO show generic error screen
			LOG.error("Creation of recovery key failed.", task.getException());
		});
		executor.submit(task);
	}

	private class ResetPasswordTask extends Task<Void> {

		@Override
		protected Void call() throws IOException, IllegalArgumentException {
			recoveryKeyFactory.resetPasswordWithRecoveryKey(vault.getPath(), recoveryKey.get(), newPassword.get());
			return null;
		}

	}
	
	/* Getter/Setter */

	public BooleanBinding invalidNewPasswordProperty() {
		return invalidNewPassword;
	}

	public boolean isInvalidNewPassword() {
		return newPassword.get() == null || newPassword.get().length() == 0;
	}
}

M main/ui/src/main/java/org/cryptomator/ui/recoverykey/WordEncoder.java => main/ui/src/main/java/org/cryptomator/ui/recoverykey/WordEncoder.java +7 -1
@@ 2,6 2,7 @@ package org.cryptomator.ui.recoverykey;

import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;

import javax.inject.Inject;
import javax.inject.Singleton;


@@ 11,6 12,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;


@@ 31,6 33,10 @@ class WordEncoder {
		this(DEFAULT_WORD_FILE);
	}
	
	public List<String> getWords() {
		return words;
	}
	
	public WordEncoder(String wordFile) {
		try (InputStream in = getClass().getResourceAsStream(wordFile); //
			 Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII.newDecoder()); //


@@ 78,7 84,7 @@ class WordEncoder {
	 * @throws IllegalArgumentException If the encoded string doesn't consist of a multiple of two words or one of the words is unknown to this encoder.
	 */
	public byte[] decode(String encoded) {
		List<String> splitted = Splitter.on(DELIMITER).omitEmptyStrings().splitToList(encoded);
		List<String> splitted = Splitter.on(DELIMITER).omitEmptyStrings().splitToList(Strings.nullToEmpty(encoded));
		Preconditions.checkArgument(splitted.size() % 2 == 0, "%s needs to be a multiple of two words", encoded);
		byte[] result = new byte[splitted.size() / 2 * 3];
		for (int i = 0; i < splitted.size(); i+=2) {

M main/ui/src/main/java/org/cryptomator/ui/removevault/RemoveVaultModule.java => main/ui/src/main/java/org/cryptomator/ui/removevault/RemoveVaultModule.java +3 -3
@@ 21,8 21,8 @@ import org.cryptomator.ui.mainwindow.MainWindow;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module


@@ 38,13 38,13 @@ abstract class RemoveVaultModule {
	@Provides
	@RemoveVaultWindow
	@RemoveVaultScoped
	static Stage provideStage(@MainWindow Stage owner,  ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(@MainWindow Stage owner,  ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage();
		stage.setTitle(resourceBundle.getString("removeVault.title"));
		stage.setResizable(false);
		stage.initModality(Modality.WINDOW_MODAL);
		stage.initOwner(owner);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}


M main/ui/src/main/java/org/cryptomator/ui/traymenu/TrayMenuController.java => main/ui/src/main/java/org/cryptomator/ui/traymenu/TrayMenuController.java +5 -82
@@ 3,57 3,37 @@ package org.cryptomator.ui.traymenu;
import javafx.application.Platform;
import javafx.beans.Observable;
import javafx.collections.ObservableList;
import org.cryptomator.common.ShutdownHook;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.common.vaults.VaultState;
import org.cryptomator.common.vaults.Volume;
import org.cryptomator.ui.fxapp.FxApplication;
import org.cryptomator.ui.launcher.AppLifecycleListener;
import org.cryptomator.ui.launcher.FxApplicationStarter;
import org.cryptomator.ui.preferences.SelectedPreferencesTab;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import javax.inject.Named;
import java.awt.Desktop;
import java.awt.Menu;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.desktop.QuitResponse;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.EnumSet;
import java.util.EventObject;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;

@TrayMenuScoped
class TrayMenuController {

	private static final Logger LOG = LoggerFactory.getLogger(TrayMenuController.class);
	public static final Set<VaultState> STATES_ALLOWING_TERMINATION = EnumSet.of(VaultState.LOCKED, VaultState.NEEDS_MIGRATION, VaultState.MISSING, VaultState.ERROR);

	private final ResourceBundle resourceBundle;
	private final AppLifecycleListener appLifecycle;
	private final FxApplicationStarter fxApplicationStarter;
	private final CountDownLatch shutdownLatch;
	private final ShutdownHook shutdownHook;
	private final ObservableList<Vault> vaults;
	private final PopupMenu menu;
	private final AtomicBoolean allowSuddenTermination;

	@Inject
	TrayMenuController(ResourceBundle resourceBundle, FxApplicationStarter fxApplicationStarter, @Named("shutdownLatch") CountDownLatch shutdownLatch, ShutdownHook shutdownHook, ObservableList<Vault> vaults) {
	TrayMenuController(ResourceBundle resourceBundle, AppLifecycleListener appLifecycle, FxApplicationStarter fxApplicationStarter, ObservableList<Vault> vaults) {
		this.resourceBundle = resourceBundle;
		this.appLifecycle = appLifecycle;
		this.fxApplicationStarter = fxApplicationStarter;
		this.shutdownLatch = shutdownLatch;
		this.shutdownHook = shutdownHook;
		this.vaults = vaults;
		this.menu = new PopupMenu();
		this.allowSuddenTermination = new AtomicBoolean(true);
	}

	public PopupMenu getMenu() {


@@ 62,40 42,12 @@ class TrayMenuController {

	public void initTrayMenu() {
		vaults.addListener(this::vaultListChanged);

		rebuildMenu();

		// register preferences shortcut
		if (Desktop.getDesktop().isSupported(Desktop.Action.APP_PREFERENCES)) {
			Desktop.getDesktop().setPreferencesHandler(this::showPreferencesWindow);
		}

		// register quit handler
		if (Desktop.getDesktop().isSupported(Desktop.Action.APP_QUIT_HANDLER)) {
			Desktop.getDesktop().setQuitHandler(this::handleQuitRequest);
		}
		shutdownHook.runOnShutdown(this::forceUnmountRemainingVaults);

		// allow sudden termination
		if (Desktop.getDesktop().isSupported(Desktop.Action.APP_SUDDEN_TERMINATION)) {
			Desktop.getDesktop().enableSuddenTermination();
		}
	}

	private void vaultListChanged(@SuppressWarnings("unused") Observable observable) {
		assert Platform.isFxApplicationThread();
		rebuildMenu();
		boolean allVaultsAllowTermination = vaults.stream().map(Vault::getState).allMatch(STATES_ALLOWING_TERMINATION::contains);
		boolean suddenTerminationChanged = allowSuddenTermination.compareAndSet(!allVaultsAllowTermination, allVaultsAllowTermination);
		if (suddenTerminationChanged && Desktop.getDesktop().isSupported(Desktop.Action.APP_SUDDEN_TERMINATION)) {
			if (allVaultsAllowTermination) {
				Desktop.getDesktop().enableSuddenTermination();
				LOG.debug("sudden termination enabled");
			} else {
				Desktop.getDesktop().disableSuddenTermination();
				LOG.debug("sudden termination disabled");
			}
		}
	}

	private void rebuildMenu() {


@@ 174,37 126,8 @@ class TrayMenuController {
		fxApplicationStarter.get(true).thenAccept(app -> app.showPreferencesWindow(SelectedPreferencesTab.ANY));
	}

	private void handleQuitRequest(EventObject e, QuitResponse response) {
		if (allowSuddenTermination.get()) {
			response.performQuit(); // really?
		} else {
			fxApplicationStarter.get(true).thenAccept(app -> app.showQuitWindow(response));
		}
	}

	private void quitApplication(EventObject actionEvent) {
		handleQuitRequest(actionEvent, new QuitResponse() {
			@Override
			public void performQuit() {
				shutdownLatch.countDown();
			}

			@Override
			public void cancelQuit() {
				// no-op
			}
		});
		appLifecycle.quit();
	}

	private void forceUnmountRemainingVaults() {
		for (Vault vault : vaults) {
			if (vault.isUnlocked()) {
				try {
					vault.lock(true);
				} catch (Volume.VolumeException e) {
					LOG.error("Failed to unmount vault " + vault.getPath(), e);
				}
			}
		}
	}
}

M main/ui/src/main/java/org/cryptomator/ui/unlock/UnlockController.java => main/ui/src/main/java/org/cryptomator/ui/unlock/UnlockController.java +31 -60
@@ 1,32 1,28 @@
package org.cryptomator.ui.unlock;

import dagger.Lazy;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.WritableValue;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ContentDisplay;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.common.vaults.VaultState;
import org.cryptomator.cryptolib.api.InvalidPassphraseException;
import org.cryptomator.cryptolib.api.UnsupportedVaultFormatException;
import org.cryptomator.keychain.KeychainAccess;
import org.cryptomator.keychain.KeychainAccessException;
import org.cryptomator.ui.common.Animations;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;
import org.cryptomator.ui.common.Tasks;
import org.cryptomator.ui.common.VaultService;
import org.cryptomator.ui.controls.NiceSecurePasswordField;
import org.cryptomator.ui.forgetPassword.ForgetPasswordComponent;
import org.slf4j.Logger;


@@ 50,22 46,24 @@ public class UnlockController implements FxController {
	private final ExecutorService executor;
	private final ObjectBinding<ContentDisplay> unlockButtonState;
	private final Optional<KeychainAccess> keychainAccess;
	private final VaultService vaultService;
	private final Lazy<Scene> successScene;
	private final Lazy<Scene> invalidMountPointScene;
	private final Lazy<Scene> genericErrorScene;
	private final ObjectProperty<Exception> genericErrorCause;
	private final ObjectProperty<Throwable> genericErrorCause;
	private final ForgetPasswordComponent.Builder forgetPassword;
	private final BooleanProperty unlockButtonDisabled;
	public NiceSecurePasswordField passwordField;
	public CheckBox savePassword;

	@Inject
	public UnlockController(@UnlockWindow Stage window, @UnlockWindow Vault vault, ExecutorService executor, Optional<KeychainAccess> keychainAccess, @FxmlScene(FxmlFile.UNLOCK_SUCCESS) Lazy<Scene> successScene, @FxmlScene(FxmlFile.UNLOCK_INVALID_MOUNT_POINT) Lazy<Scene> invalidMountPointScene, @FxmlScene(FxmlFile.UNLOCK_GENERIC_ERROR) Lazy<Scene> genericErrorScene, @Named("genericErrorCause") ObjectProperty<Exception> genericErrorCause, ForgetPasswordComponent.Builder forgetPassword) {
	public UnlockController(@UnlockWindow Stage window, @UnlockWindow Vault vault, ExecutorService executor, Optional<KeychainAccess> keychainAccess, VaultService vaultService, @FxmlScene(FxmlFile.UNLOCK_SUCCESS) Lazy<Scene> successScene, @FxmlScene(FxmlFile.UNLOCK_INVALID_MOUNT_POINT) Lazy<Scene> invalidMountPointScene, @FxmlScene(FxmlFile.UNLOCK_GENERIC_ERROR) Lazy<Scene> genericErrorScene, @Named("genericErrorCause") ObjectProperty<Throwable> genericErrorCause, ForgetPasswordComponent.Builder forgetPassword) {
		this.window = window;
		this.vault = vault;
		this.executor = executor;
		this.unlockButtonState = Bindings.createObjectBinding(this::getUnlockButtonState, vault.stateProperty());
		this.keychainAccess = keychainAccess;
		this.vaultService = vaultService;
		this.successScene = successScene;
		this.invalidMountPointScene = invalidMountPointScene;
		this.genericErrorScene = genericErrorScene;


@@ 93,36 91,36 @@ public class UnlockController implements FxController {
	public void unlock() {
		LOG.trace("UnlockController.unlock()");
		CharSequence password = passwordField.getCharacters();
		vault.setState(VaultState.PROCESSING);
		Tasks.create(() -> {
			vault.unlock(password);
		
		Task<Vault> task = vaultService.createUnlockTask(vault, password);
		task.setOnSucceeded(event -> {
			if (keychainAccess.isPresent() && savePassword.isSelected()) {
				keychainAccess.get().storePassphrase(vault.getId(), password);
				try {
					keychainAccess.get().storePassphrase(vault.getId(), password);
				} catch (KeychainAccessException e) {
					LOG.error("Failed to store passphrase in system keychain.", e);
				}
			}
		}).onSuccess(() -> {
			vault.setState(VaultState.UNLOCKED);
			passwordField.swipe();
			LOG.info("Unlock of '{}' succeeded.", vault.getDisplayableName());
			window.setScene(successScene.get());
		}).onError(InvalidPassphraseException.class, e -> {
			shakeWindow();
			passwordField.selectAll();
			passwordField.requestFocus();
		}).onError(NotDirectoryException.class, e -> {
			LOG.error("Unlock failed. Mount point not a directory: {}", e.getMessage());
			window.setScene(invalidMountPointScene.get());
		}).onError(DirectoryNotEmptyException.class, e -> {
			LOG.error("Unlock failed. Mount point not empty: {}", e.getMessage());
			window.setScene(invalidMountPointScene.get());
		}).onError(Exception.class, e -> { // including RuntimeExceptions
			LOG.error("Unlock failed for technical reasons.", e);
			genericErrorCause.set(e);
			window.setScene(genericErrorScene.get());
		}).andFinally(() -> {
			if (!vault.isUnlocked()) {
				vault.setState(VaultState.LOCKED);
		});
		task.setOnFailed(event -> {
			if (task.getException() instanceof InvalidPassphraseException) {
				Animations.createShakeWindowAnimation(window).play();
				passwordField.selectAll();
				passwordField.requestFocus();
			} else if (task.getException() instanceof NotDirectoryException
				|| task.getException() instanceof DirectoryNotEmptyException) {
				LOG.error("Unlock failed. Mount point not an empty directory: {}", task.getException().getMessage());
				window.setScene(invalidMountPointScene.get());
			} else {
				LOG.error("Unlock failed for technical reasons.", task.getException());
				genericErrorCause.set(task.getException());
				window.setScene(genericErrorScene.get());
			}
		}).runOnce(executor);
		});
		executor.execute(task);
	}

	/* Save Password */


@@ 167,33 165,6 @@ public class UnlockController implements FxController {
		}
	}

	/* Animations */

	private void shakeWindow() {
		WritableValue<Double> writableWindowX = new WritableValue<>() {
			@Override
			public Double getValue() {
				return window.getX();
			}

			@Override
			public void setValue(Double value) {
				window.setX(value);
			}
		};
		Timeline timeline = new Timeline( //
				new KeyFrame(Duration.ZERO, new KeyValue(writableWindowX, window.getX())), //
				new KeyFrame(new Duration(100), new KeyValue(writableWindowX, window.getX() - 22.0)), //
				new KeyFrame(new Duration(200), new KeyValue(writableWindowX, window.getX() + 18.0)), //
				new KeyFrame(new Duration(300), new KeyValue(writableWindowX, window.getX() - 14.0)), //
				new KeyFrame(new Duration(400), new KeyValue(writableWindowX, window.getX() + 10.0)), //
				new KeyFrame(new Duration(500), new KeyValue(writableWindowX, window.getX() - 6.0)), //
				new KeyFrame(new Duration(600), new KeyValue(writableWindowX, window.getX() + 2.0)), //
				new KeyFrame(new Duration(700), new KeyValue(writableWindowX, window.getX())) //
		);
		timeline.play();
	}

	/* Getter/Setter */

	public Vault getVault() {

M main/ui/src/main/java/org/cryptomator/ui/unlock/UnlockModule.java => main/ui/src/main/java/org/cryptomator/ui/unlock/UnlockModule.java +5 -5
@@ 21,8 21,8 @@ import org.cryptomator.ui.forgetPassword.ForgetPasswordComponent;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module(subcomponents = {ForgetPasswordComponent.class})


@@ 38,19 38,19 @@ abstract class UnlockModule {
	@Provides
	@UnlockWindow
	@UnlockScoped
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage();
		stage.setTitle(resourceBundle.getString("unlock.title"));
		stage.setResizable(false);
		stage.initModality(Modality.APPLICATION_MODAL);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}

	@Provides
	@Named("genericErrorCause")
	@UnlockScoped
	static ObjectProperty<Exception> provideGenericErrorCause() {
	static ObjectProperty<Throwable> provideGenericErrorCause() {
		return new SimpleObjectProperty<>();
	}



@@ 109,7 109,7 @@ abstract class UnlockModule {
	@Provides
	@IntoMap
	@FxControllerKey(StackTraceController.class)
	static FxController provideStackTraceController(@Named("genericErrorCause") ObjectProperty<Exception> errorCause) {
	static FxController provideStackTraceController(@Named("genericErrorCause") ObjectProperty<Throwable> errorCause) {
		return new StackTraceController(errorCause.get());
	}


M main/ui/src/main/java/org/cryptomator/ui/vaultoptions/GeneralVaultOptionsController.java => main/ui/src/main/java/org/cryptomator/ui/vaultoptions/GeneralVaultOptionsController.java +5 -18
@@ 1,11 1,9 @@
package org.cryptomator.ui.vaultoptions;

import javafx.fxml.FXML;
import javafx.stage.Stage;
import javafx.scene.control.CheckBox;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.ui.changepassword.ChangePasswordComponent;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.recoverykey.RecoveryKeyComponent;

import javax.inject.Inject;



@@ 13,26 11,15 @@ import javax.inject.Inject;
public class GeneralVaultOptionsController implements FxController {

	private final Vault vault;
	private final Stage window;
	private final ChangePasswordComponent.Builder changePasswordWindow;
	private final RecoveryKeyComponent.Builder recoveryKeyWindow;
	public CheckBox unlockOnStartupCheckbox;

	@Inject
	GeneralVaultOptionsController(@VaultOptionsWindow Vault vault, @VaultOptionsWindow Stage window, ChangePasswordComponent.Builder changePasswordWindow, RecoveryKeyComponent.Builder recoveryKeyWindow) {
	GeneralVaultOptionsController(@VaultOptionsWindow Vault vault) {
		this.vault = vault;
		this.window = window;
		this.changePasswordWindow = changePasswordWindow;
		this.recoveryKeyWindow = recoveryKeyWindow;
	}

	@FXML
	public void changePassword() {
		changePasswordWindow.vault(vault).owner(window).build().showChangePasswordWindow();
	public void initialize() {
		unlockOnStartupCheckbox.selectedProperty().bindBidirectional(vault.getVaultSettings().unlockAfterStartup());
	}

	@FXML
	public void showRecoveryKey() {
		recoveryKeyWindow.vault(vault).owner(window).build().showRecoveryKeyCreationWindow();
	}

}

A main/ui/src/main/java/org/cryptomator/ui/vaultoptions/MasterkeyOptionsController.java => main/ui/src/main/java/org/cryptomator/ui/vaultoptions/MasterkeyOptionsController.java +42 -0
@@ 0,0 1,42 @@
package org.cryptomator.ui.vaultoptions;

import javafx.fxml.FXML;
import javafx.stage.Stage;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.ui.changepassword.ChangePasswordComponent;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.recoverykey.RecoveryKeyComponent;

import javax.inject.Inject;

@VaultOptionsScoped
public class MasterkeyOptionsController implements FxController {

	private final Vault vault;
	private final Stage window;
	private final ChangePasswordComponent.Builder changePasswordWindow;
	private final RecoveryKeyComponent.Builder recoveryKeyWindow;

	@Inject
	MasterkeyOptionsController(@VaultOptionsWindow Vault vault, @VaultOptionsWindow Stage window, ChangePasswordComponent.Builder changePasswordWindow, RecoveryKeyComponent.Builder recoveryKeyWindow) {
		this.vault = vault;
		this.window = window;
		this.changePasswordWindow = changePasswordWindow;
		this.recoveryKeyWindow = recoveryKeyWindow;
	}

	@FXML
	public void changePassword() {
		changePasswordWindow.vault(vault).owner(window).build().showChangePasswordWindow();
	}

	@FXML
	public void showRecoveryKey() {
		recoveryKeyWindow.vault(vault).owner(window).build().showRecoveryKeyCreationWindow();
	}

	@FXML
	public void showRecoverVaultDialogue() {
		recoveryKeyWindow.vault(vault).owner(window).build().showRecoveryKeyRecoverWindow();
	}
}

M main/ui/src/main/java/org/cryptomator/ui/vaultoptions/VaultOptionsModule.java => main/ui/src/main/java/org/cryptomator/ui/vaultoptions/VaultOptionsModule.java +11 -4
@@ 21,8 21,8 @@ import org.cryptomator.ui.recoverykey.RecoveryKeyComponent;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module(subcomponents = {ChangePasswordComponent.class, RecoveryKeyComponent.class})


@@ 38,13 38,15 @@ abstract class VaultOptionsModule {
	@Provides
	@VaultOptionsWindow
	@VaultOptionsScoped
	static Stage provideStage(@MainWindow Stage owner, @VaultOptionsWindow Vault vault, ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(@MainWindow Stage owner, @VaultOptionsWindow Vault vault, ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage();
		stage.setTitle(vault.getDisplayableName());
		stage.setResizable(false);
		stage.setResizable(true);
		stage.setMinWidth(400);
		stage.setMinHeight(300);
		stage.initModality(Modality.WINDOW_MODAL);
		stage.initOwner(owner);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}



@@ 72,4 74,9 @@ abstract class VaultOptionsModule {
	@FxControllerKey(MountOptionsController.class)
	abstract FxController bindMountOptionsController(MountOptionsController controller);

	@Binds
	@IntoMap
	@FxControllerKey(MasterkeyOptionsController.class)
	abstract FxController bindMasterkeyOptionsController(MasterkeyOptionsController controller);

}

M main/ui/src/main/java/org/cryptomator/ui/wrongfilealert/WrongFileAlertModule.java => main/ui/src/main/java/org/cryptomator/ui/wrongfilealert/WrongFileAlertModule.java +3 -3
@@ 17,8 17,8 @@ import org.cryptomator.ui.common.FxmlScene;

import javax.inject.Named;
import javax.inject.Provider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;

@Module


@@ 34,12 34,12 @@ abstract class WrongFileAlertModule {
	@Provides
	@WrongFileAlertWindow
	@WrongFileAlertScoped
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcon") Optional<Image> windowIcon) {
	static Stage provideStage(ResourceBundle resourceBundle, @Named("windowIcons") List<Image> windowIcons) {
		Stage stage = new Stage();
		stage.setTitle(resourceBundle.getString("wrongFileAlert.title"));
		stage.setResizable(false);
		stage.initModality(Modality.WINDOW_MODAL);
		windowIcon.ifPresent(stage.getIcons()::add);
		stage.getIcons().addAll(windowIcons);
		return stage;
	}


A main/ui/src/main/resources/bot.png => main/ui/src/main/resources/bot.png +0 -0
A main/ui/src/main/resources/bot@2x.png => main/ui/src/main/resources/bot@2x.png +0 -0
D main/ui/src/main/resources/bot_welcome.png => main/ui/src/main/resources/bot_welcome.png +0 -0
D main/ui/src/main/resources/bot_welcome@2x.png => main/ui/src/main/resources/bot_welcome@2x.png +0 -0
M main/ui/src/main/resources/css/dark_theme.css => main/ui/src/main/resources/css/dark_theme.css +88 -97
@@ 17,7 17,7 @@
}

@font-face {
	src: url('dosis-bold.ttf');
	src: url('quicksand-bold.ttf');
}

/*******************************************************************************


@@ 27,39 27,37 @@
 ******************************************************************************/

.root {
	GREEN_0: #373B30;
	GREEN_1: #384D14;
	GREEN_2: #476611;
	GREEN_3: #598016;
	GREEN_4: #699917;
	GREEN_5: #79B01A;
	GREEN_6: #91C734;
	GREEN_7: #B9E070;
	GREEN_8: #D7E7BA;
	GREEN_9: #F3F5F0;

	GRAY_0: #222222;
	GRAY_1: #3B3B3B;
	GRAY_2: #515151;
	GRAY_3: #626262;
	GRAY_4: #7E7E7E;
	GRAY_5: #9E9E9E;
	GRAY_6: #B1B1B1;
	GRAY_7: #CFCFCF;
	GRAY_8: #E1E1E1;
	GRAY_9: #F7F7F7;
	PRIMARY_D2: #2D4D2E;
	PRIMARY_D1: #407F41;
	PRIMARY: #49B04A;
	PRIMARY_L1: #66CC68;
	PRIMARY_L2: #EBF5EB;

	SECONDARY: #008A7B;

	GRAY_0: #1F2122;
	GRAY_1: #35393B;
	GRAY_2: #494E51;
	GRAY_3: #585E62;
	GRAY_4: #71797E;
	GRAY_5: #8E989E;
	GRAY_6: #9FAAB1;
	GRAY_7: #BEC9CF;
	GRAY_8: #D3DCE1;
	GRAY_9: #EDF3F7;

	RED_5: #E74C3C;
	ORANGE_5: #E67E22;
	YELLOW_5: #F1C40F;

	PRIMARY_BG: GREEN_3;
	SECONDARY_BG: GRAY_3;
	MAIN_BG: GRAY_1;
	TEXT_FILL: GRAY_9;
	TEXT_FILL_PRIMARY: GREEN_5;
	TEXT_FILL_SECONDARY: GRAY_5;
	TEXT_FILL_WHITE: white;
	TEXT_FILL_HIGHLIGHTED: PRIMARY;
	TEXT_FILL_MUTED: GRAY_5;

	TITLE_BG: linear-gradient(to bottom, GRAY_2, GRAY_1);
	TITLE_TEXT_FILL: PRIMARY;

	CONTROL_BORDER_NORMAL: GRAY_3;
	CONTROL_BORDER_FOCUSED: GRAY_5;
	CONTROL_BORDER_DISABLED: GRAY_2;


@@ 67,27 65,20 @@
	CONTROL_BG_HOVER: GRAY_1;
	CONTROL_BG_ARMED: GRAY_2;
	CONTROL_BG_DISABLED: GRAY_1;
	CONTROL_PRIMARY_BORDER_NORMAL: GREEN_5;
	CONTROL_PRIMARY_BORDER_FOCUSED: GREEN_7;
	CONTROL_PRIMARY_BORDER_DISABLED: GREEN_3;
	CONTROL_PRIMARY_BG_NORMAL: GREEN_3;
	CONTROL_PRIMARY_BG_ARMED: GREEN_4;
	CONTROL_PRIMARY_BG_DISABLED: GREEN_2;
	CONTROL_PRIMARY_LIGHT_BG_NORMAL: GREEN_0;
	CONTROL_WHITE_BG_ARMED: GRAY_8;
	CONTROL_BG_SELECTED: GRAY_1;

	CONTROL_PRIMARY_BORDER_NORMAL: PRIMARY;
	CONTROL_PRIMARY_BORDER_ARMED: PRIMARY_L1;
	CONTROL_PRIMARY_BORDER_FOCUSED: SECONDARY;
	CONTROL_PRIMARY_BG_NORMAL: PRIMARY;
	CONTROL_PRIMARY_BG_ARMED: PRIMARY_L1;

	SCROLL_BAR_THUMB_NORMAL: GRAY_3;
	SCROLL_BAR_THUMB_HOVER: GRAY_4;
	INDICATOR_BG: RED_5;
	DRAG_N_DROP_INDICATOR_BG: GRAY_3;

	PROGRESS_INDICATOR_BEGIN: GRAY_7;
	PROGRESS_INDICATOR_END: GRAY_5;
	PROGRESS_BAR_BG: GRAY_2;
	PASSWORD_STRENGTH_INDICATOR_BG: GRAY_3;
	PASSWORD_STRENGTH_INDICATOR_BG_STRENGTH_0: RED_5;
	PASSWORD_STRENGTH_INDICATOR_BG_STRENGTH_1: ORANGE_5;
	PASSWORD_STRENGTH_INDICATOR_BG_STRENGTH_2: YELLOW_5;
	PASSWORD_STRENGTH_INDICATOR_BG_STRENGTH_3: GREEN_6;
	PASSWORD_STRENGTH_INDICATOR_BG_STRENGTH_4: GREEN_5;

	-fx-background-color: MAIN_BG;
    -fx-text-fill: TEXT_FILL;


@@ 105,7 96,7 @@
}

.label-secondary {
	-fx-text-fill: TEXT_FILL_SECONDARY;
	-fx-text-fill: TEXT_FILL_MUTED;
}

.label-large {


@@ 136,11 127,11 @@
}

.glyph-icon-primary {
	-fx-fill: PRIMARY_BG;
	-fx-fill: PRIMARY;
}

.glyph-icon-secondary {
	-fx-fill: SECONDARY_BG;
.glyph-icon-muted {
	-fx-fill: TEXT_FILL_MUTED;
}

.glyph-icon-white {


@@ 158,15 149,16 @@
 ******************************************************************************/

.main-window .title {
	-fx-background-color: PRIMARY_BG;
	-fx-background-color: CONTROL_BORDER_NORMAL, TITLE_BG;
	-fx-background-insets: 0, 0 0 1px 0;
}

.main-window .title .label {
	-fx-font-family: 'Dosis';
	-fx-font-size: 21px;
	-fx-font-family: 'Quicksand';
	-fx-font-size: 16px;
	-fx-font-style: normal;
	-fx-font-weight: 700;
	-fx-text-fill: white;
	-fx-text-fill: TITLE_TEXT_FILL;
}

.main-window .title .button {


@@ 181,24 173,23 @@
}

.main-window .title .button:armed .glyph-icon {
	-fx-fill: CONTROL_WHITE_BG_ARMED;
	-fx-fill: GRAY_8;
}

.main-window .update-indicator {
	-fx-background-color: PRIMARY_BG, white, INDICATOR_BG;
	-fx-background-insets: 0, 1px, 2px;
	-fx-background-radius: 6px, 5px, 4px;
	-fx-translate-x: -1px;
	-fx-translate-y: 1px;
	-fx-background-color: white, RED_5;
	-fx-background-insets: 1px, 2px;
	-fx-background-radius: 6px, 5px;
	-fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.8), 2, 0, 0, 0);
}

.main-window .drag-n-drop-indicator {
	-fx-border-color: DRAG_N_DROP_INDICATOR_BG;
	-fx-border-color: SECONDARY;
	-fx-border-width: 3px;
}

.main-window .drag-n-drop-indicator .drag-n-drop-header {
	-fx-background-color: DRAG_N_DROP_INDICATOR_BG;
	-fx-background-color: SECONDARY;
	-fx-padding: 3px;
}



@@ 225,24 216,24 @@
}

.tab-pane .tab:selected {
	-fx-background-color: PRIMARY_BG, CONTROL_PRIMARY_LIGHT_BG_NORMAL;
	-fx-background-color: PRIMARY, CONTROL_BG_SELECTED;
}

.tab-pane .tab .tab-label {
	-fx-text-fill: SECONDARY_BG;
	-fx-text-fill: TEXT_FILL_MUTED;
	-fx-alignment: CENTER;
}

.tab-pane .tab .glyph-icon {
	-fx-fill: SECONDARY_BG;
	-fx-fill: TEXT_FILL_MUTED;
}

.tab-pane .tab:selected .glyph-icon {
	-fx-fill: PRIMARY_BG;
	-fx-fill: PRIMARY;
}

.tab-pane .tab:selected .tab-label {
	-fx-text-fill: TEXT_FILL_PRIMARY;
	-fx-text-fill: TEXT_FILL_HIGHLIGHTED;
}

/*******************************************************************************


@@ 271,16 262,16 @@
}

.list-view:focused .list-cell:selected {
	-fx-background-color: PRIMARY_BG, CONTROL_PRIMARY_LIGHT_BG_NORMAL;
	-fx-background-color: PRIMARY, CONTROL_BG_SELECTED;
	-fx-background-insets: 0, 0 0 0 3px;
}

.list-cell:selected {
	-fx-background-color: CONTROL_PRIMARY_LIGHT_BG_NORMAL;
	-fx-background-color: CONTROL_BG_SELECTED;
}

.list-cell .glyph-icon {
	-fx-fill: SECONDARY_BG;
	-fx-fill: TEXT_FILL_MUTED;
}

.list-cell .header-label {


@@ 289,16 280,16 @@
}

.list-cell .detail-label {
	-fx-text-fill: TEXT_FILL_SECONDARY;
	-fx-text-fill: TEXT_FILL_MUTED;
	-fx-font-size: 0.8em;
}

.list-cell:selected .glyph-icon {
	-fx-fill: PRIMARY_BG;
	-fx-fill: PRIMARY;
}

.list-cell:selected .header-label {
	-fx-text-fill: TEXT_FILL_PRIMARY;
	-fx-text-fill: TEXT_FILL_HIGHLIGHTED;
}

.list-cell.drop-above {


@@ 379,13 370,13 @@
}

.badge-primary {
	-fx-text-fill: TEXT_FILL_WHITE;
	-fx-background-color: PRIMARY_BG;
	-fx-text-fill: white;