Prechádzať zdrojové kódy

Merge branch 'develop' into feature/restore-vaultconfig

Jan-Peter Klein 2 mesiacov pred
rodič
commit
f9e8031acb
23 zmenil súbory, kde vykonal 257 pridanie a 113 odobranie
  1. 9 9
      .github/ISSUE_TEMPLATE/bug.yml
  2. 8 8
      .github/ISSUE_TEMPLATE/feature.yml
  3. 1 1
      .github/workflows/debian.yml
  4. 13 9
      dist/linux/common/org.cryptomator.Cryptomator.tray-unlocked.svg
  5. 8 6
      dist/linux/common/org.cryptomator.Cryptomator.tray.svg
  6. 3 3
      pom.xml
  7. 22 0
      src/main/java/org/cryptomator/JavaFXUtil.java
  8. 63 12
      src/main/java/org/cryptomator/common/keychain/KeychainManager.java
  9. 3 1
      src/main/java/org/cryptomator/ui/addvaultwizard/ReadmeGenerator.java
  10. 2 2
      src/main/java/org/cryptomator/ui/dialogs/Dialogs.java
  11. 3 4
      src/main/java/org/cryptomator/ui/dialogs/SimpleDialog.java
  12. 6 0
      src/main/java/org/cryptomator/ui/dialogs/SimpleDialogController.java
  13. 4 4
      src/main/java/org/cryptomator/ui/keyloading/masterkeyfile/MasterkeyFileLoadingStrategy.java
  14. 33 11
      src/main/java/org/cryptomator/ui/mainwindow/MainWindowController.java
  15. 5 8
      src/main/java/org/cryptomator/ui/mainwindow/VaultDetailLockedController.java
  16. 37 1
      src/main/java/org/cryptomator/ui/preferences/GeneralPreferencesController.java
  17. 6 0
      src/main/resources/css/dark_theme.css
  18. 6 0
      src/main/resources/css/light_theme.css
  19. 8 8
      src/main/resources/fxml/simple_dialog.fxml
  20. 1 2
      src/main/resources/i18n/strings.properties
  21. 10 12
      src/test/java/org/cryptomator/common/keychain/KeychainManagerTest.java
  22. 3 3
      src/test/java/org/cryptomator/ui/addvaultwizard/ReadMeGeneratorTest.java
  23. 3 9
      src/test/java/org/cryptomator/ui/controls/SecurePasswordFieldTest.java

+ 9 - 9
.github/ISSUE_TEMPLATE/bug.yml

@@ -1,7 +1,14 @@
 name: Bug Report
 description: Create a report to help us improve
-labels: ["type:bug"]
+type: "Bug"
 body:
+  - type: input
+    id: summary
+    attributes:
+      label: Summary
+      placeholder: Please summarize your problem.
+    validations:
+      required: true
   - type: checkboxes
     id: terms
     attributes:
@@ -11,13 +18,6 @@ body:
         required: true
       - label: I agree to follow this project's [Code of Conduct](https://github.com/cryptomator/cryptomator/blob/develop/.github/CODE_OF_CONDUCT.md)
         required: true
-  - type: input
-    id: summary
-    attributes:
-      label: Summary
-      placeholder: Please summarize your problem.
-    validations:
-      required: true
   - type: textarea
     id: software-versions
     attributes:
@@ -97,4 +97,4 @@ body:
     id: further-info
     attributes:
       label: Anything else?
-      description: Links? References? Screenshots? Configurations? Any data that might be necessary to reproduce the issue?
+      description: Links? References? Screenshots? Configurations? Any data that might be necessary to reproduce the issue?

+ 8 - 8
.github/ISSUE_TEMPLATE/feature.yml

@@ -1,7 +1,14 @@
 name: Feature Request
 description: Suggest an idea for this project
-labels: ["type:feature-request"]
+type: "Feature"
 body:
+  - type: input
+    id: summary
+    attributes:
+      label: Summary
+      placeholder: Please summarize your feature request.
+    validations:
+      required: true
   - type: checkboxes
     id: terms
     attributes:
@@ -11,13 +18,6 @@ body:
         required: true
       - label: I agree to follow this project's [Code of Conduct](https://github.com/cryptomator/cryptomator/blob/develop/.github/CODE_OF_CONDUCT.md)
         required: true
-  - type: input
-    id: summary
-    attributes:
-      label: Summary
-      placeholder: Please summarize your feature request.
-    validations:
-      required: true
   - type: textarea
     id: motivation
     attributes:

+ 1 - 1
.github/workflows/debian.yml

@@ -28,7 +28,7 @@ env:
 jobs:
   build:
     name: Build Debian Package
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     steps:
       - uses: actions/checkout@v4
       - id: versions

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 13 - 9
dist/linux/common/org.cryptomator.Cryptomator.tray-unlocked.svg


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 8 - 6
dist/linux/common/org.cryptomator.Cryptomator.tray.svg


+ 3 - 3
pom.xml

@@ -33,10 +33,10 @@
 		<nonModularGroupIds>org.ow2.asm,org.apache.jackrabbit,org.apache.httpcomponents</nonModularGroupIds>
 
 		<!-- cryptomator dependencies -->
-		<cryptomator.cryptofs.version>2.8.0</cryptomator.cryptofs.version>
+		<cryptomator.cryptofs.version>2.9.0-beta2</cryptomator.cryptofs.version>
 		<cryptomator.integrations.version>1.5.0</cryptomator.integrations.version>
 		<cryptomator.integrations.win.version>1.3.0</cryptomator.integrations.win.version>
-		<cryptomator.integrations.mac.version>1.2.4</cryptomator.integrations.mac.version>
+		<cryptomator.integrations.mac.version>1.3.0</cryptomator.integrations.mac.version>
 		<cryptomator.integrations.linux.version>1.5.2</cryptomator.integrations.linux.version>
 		<cryptomator.fuse.version>5.0.2</cryptomator.fuse.version>
 		<cryptomator.webdav.version>2.0.7</cryptomator.webdav.version>
@@ -46,7 +46,7 @@
 		<dagger.version>2.55</dagger.version>
 		<easybind.version>2.2</easybind.version>
 		<jackson.version>2.18.2</jackson.version>
-		<javafx.version>23.0.1</javafx.version>
+		<javafx.version>23.0.2</javafx.version>
 		<jwt.version>4.4.0</jwt.version>
 		<nimbus-jose.version>9.37.3</nimbus-jose.version>
 		<logback.version>1.5.16</logback.version>

+ 22 - 0
src/main/java/org/cryptomator/JavaFXUtil.java

@@ -0,0 +1,22 @@
+package org.cryptomator;
+
+import javafx.application.Platform;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+public class JavaFXUtil {
+
+	private JavaFXUtil() {}
+
+	public static boolean startPlatform() throws InterruptedException {
+		CountDownLatch latch = new CountDownLatch(1);
+		try {
+			Platform.startup(latch::countDown);
+		} catch (IllegalStateException e) {
+			//already initialized
+			latch.countDown();
+		}
+		return latch.await(5, TimeUnit.SECONDS);
+	}
+
+}

+ 63 - 12
src/main/java/org/cryptomator/common/keychain/KeychainManager.java

@@ -2,6 +2,7 @@ package org.cryptomator.common.keychain;
 
 import com.github.benmanes.caffeine.cache.Caffeine;
 import com.github.benmanes.caffeine.cache.LoadingCache;
+import org.cryptomator.common.Passphrase;
 import org.cryptomator.integrations.keychain.KeychainAccessException;
 import org.cryptomator.integrations.keychain.KeychainAccessProvider;
 
@@ -13,20 +14,24 @@ import javafx.beans.property.BooleanProperty;
 import javafx.beans.property.ReadOnlyBooleanProperty;
 import javafx.beans.property.SimpleBooleanProperty;
 import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
 
 @Singleton
 public class KeychainManager implements KeychainAccessProvider {
 
 	private final ObjectExpression<KeychainAccessProvider> keychain;
 	private final LoadingCache<String, BooleanProperty> passphraseStoredProperties;
+	private final ReentrantReadWriteLock lock;
 
 	@Inject
 	KeychainManager(ObjectExpression<KeychainAccessProvider> selectedKeychain) {
 		this.keychain = selectedKeychain;
 		this.passphraseStoredProperties = Caffeine.newBuilder() //
-				.weakValues() //
+				.softValues() //
 				.build(this::createStoredPassphraseProperty);
 		keychain.addListener(ignored -> passphraseStoredProperties.invalidateAll());
+		this.lock = new ReentrantReadWriteLock(false);
 	}
 
 	private KeychainAccessProvider getKeychainOrFail() throws KeychainAccessException {
@@ -42,29 +47,59 @@ public class KeychainManager implements KeychainAccessProvider {
 		return getClass().getName();
 	}
 
+	@Override
+	public void storePassphrase(String key, String displayName, CharSequence passphrase) throws KeychainAccessException {
+		storePassphrase(key, displayName, passphrase, true);
+	}
+
+	//TODO: remove ignored parameter once the API is fixed
 	@Override
 	public void storePassphrase(String key, String displayName, CharSequence passphrase, boolean ignored) throws KeychainAccessException {
-		getKeychainOrFail().storePassphrase(key, displayName, passphrase);
+		try {
+			lock.writeLock().lock();
+			var kc = getKeychainOrFail();
+			//this is the only keychain actually using the parameter
+			var usesOSAuth = (kc.getClass().getName().equals("org.cryptomator.macos.keychain.TouchIdKeychainAccess"));
+			kc.storePassphrase(key, displayName, passphrase, usesOSAuth);
+		} finally {
+			lock.writeLock().unlock();
+		}
 		setPassphraseStored(key, true);
 	}
 
 	@Override
 	public char[] loadPassphrase(String key) throws KeychainAccessException {
-		char[] passphrase = getKeychainOrFail().loadPassphrase(key);
+		char[] passphrase = null;
+		try {
+			lock.readLock().lock();
+			passphrase = getKeychainOrFail().loadPassphrase(key);
+		} finally {
+			lock.readLock().unlock();
+		}
 		setPassphraseStored(key, passphrase != null);
 		return passphrase;
 	}
 
 	@Override
 	public void deletePassphrase(String key) throws KeychainAccessException {
-		getKeychainOrFail().deletePassphrase(key);
+		try {
+			lock.writeLock().lock();
+			getKeychainOrFail().deletePassphrase(key);
+		} finally {
+			lock.writeLock().unlock();
+		}
 		setPassphraseStored(key, false);
 	}
 
 	@Override
 	public void changePassphrase(String key, String displayName, CharSequence passphrase) throws KeychainAccessException {
 		if (isPassphraseStored(key)) {
-			getKeychainOrFail().changePassphrase(key, displayName, passphrase);
+			try {
+				lock.writeLock().lock();
+				getKeychainOrFail().changePassphrase(key, displayName, passphrase);
+			} finally {
+				lock.writeLock().unlock();
+			}
 			setPassphraseStored(key, true);
 		}
 	}
@@ -101,13 +136,11 @@ public class KeychainManager implements KeychainAccessProvider {
 	}
 
 	private void setPassphraseStored(String key, boolean value) {
-		BooleanProperty property = passphraseStoredProperties.getIfPresent(key);
-		if (property != null) {
-			if (Platform.isFxApplicationThread()) {
-				property.set(value);
-			} else {
-				Platform.runLater(() -> property.set(value));
-			}
+		BooleanProperty property = passphraseStoredProperties.get(key, _ -> new SimpleBooleanProperty(value));
+		if (Platform.isFxApplicationThread()) {
+			property.set(value);
+		} else {
+			Platform.runLater(() -> property.set(value));
 		}
 	}
 
@@ -134,4 +167,22 @@ public class KeychainManager implements KeychainAccessProvider {
 		}
 	}
 
+	public ObjectExpression<KeychainAccessProvider> getKeychainImplementation() {
+		return this.keychain;
+	}
+
+	public static void migrate(KeychainAccessProvider oldProvider, KeychainAccessProvider newProvider, Map<String, String> idsAndNames) throws KeychainAccessException {
+		if (oldProvider instanceof KeychainManager || newProvider instanceof KeychainManager) {
+			throw new IllegalArgumentException("KeychainManger must not be the source or target of migration");
+		}
+		for (var entry : idsAndNames.entrySet()) {
+			var passphrase = oldProvider.loadPassphrase(entry.getKey());
+			if (passphrase != null) {
+				var wrapper = new Passphrase(passphrase);
+				oldProvider.deletePassphrase(entry.getKey()); //we cannot apply "first-write-then-delete" pattern here, since we can potentially write to the same passphrase store (e.g., touchID and regular keychain)
+				newProvider.storePassphrase(entry.getKey(), entry.getValue(), wrapper);
+				wrapper.destroy();
+			}
+		}
+	}
 }

+ 3 - 1
src/main/java/org/cryptomator/ui/addvaultwizard/ReadmeGenerator.java

@@ -76,8 +76,10 @@ public class ReadmeGenerator {
 		input.chars().forEachOrdered(c -> {
 			if (c < 128) {
 				sb.append((char) c);
+			} else if (c <= 0xFF) {
+				sb.append("\\'").append(String.format("%02X", c));
 			} else if (c < 0xFFFF) {
-				sb.append("\\u").append(c);
+				sb.append("\\uc1\\u").append(c);
 			}
 		});
 	}

+ 2 - 2
src/main/java/org/cryptomator/ui/dialogs/Dialogs.java

@@ -38,7 +38,7 @@ public class Dialogs {
 				.setMessageKey("removeVault.message") //
 				.setDescriptionKey("removeVault.description") //
 				.setIcon(FontAwesome5Icon.QUESTION) //
-				.setOkButtonKey("removeVault.confirmBtn") //
+				.setOkButtonKey("generic.button.remove") //
 				.setCancelButtonKey("generic.button.cancel") //
 				.setOkAction(stage -> {
 					LOG.debug("Removing vault {}.", vault.getDisplayName());
@@ -64,7 +64,7 @@ public class Dialogs {
 				.setMessageKey("removeCert.message") //
 				.setDescriptionKey("removeCert.description") //
 				.setIcon(FontAwesome5Icon.QUESTION) //
-				.setOkButtonKey("removeCert.confirmBtn") //
+				.setOkButtonKey("generic.button.remove") //
 				.setCancelButtonKey("generic.button.cancel") //
 				.setOkAction(stage -> {
 					settings.licenseKey.set(null);

+ 3 - 4
src/main/java/org/cryptomator/ui/dialogs/SimpleDialog.java

@@ -31,8 +31,9 @@ public class SimpleDialog {
 		FxmlLoaderFactory loaderFactory = FxmlLoaderFactory.forController( //
 				new SimpleDialogController(resolveText(builder.messageKey, null), //
 						resolveText(builder.descriptionKey, null), //
-						builder.icon, resolveText(builder.okButtonKey, null), //
-						resolveText(builder.cancelButtonKey, null), //
+						builder.icon, //
+						resolveText(builder.okButtonKey, null), //
+						builder.cancelButtonKey != null ? resolveText(builder.cancelButtonKey, null) : null, //
 						() -> builder.okAction.accept(dialogStage), //
 						() -> builder.cancelAction.accept(dialogStage)), //
 				Scene::new, builder.resourceBundle);
@@ -67,7 +68,6 @@ public class SimpleDialog {
 		private String descriptionKey;
 		private String okButtonKey;
 		private String cancelButtonKey;
-
 		private FontAwesome5Icon icon;
 		private Consumer<Stage> okAction = Stage::close;
 		private Consumer<Stage> cancelAction = Stage::close;
@@ -128,7 +128,6 @@ public class SimpleDialog {
 			Objects.requireNonNull(messageKey, "SimpleDialog messageKey must be set.");
 			Objects.requireNonNull(descriptionKey, "SimpleDialog descriptionKey must be set.");
 			Objects.requireNonNull(okButtonKey, "SimpleDialog okButtonKey must be set.");
-			Objects.requireNonNull(cancelButtonKey, "SimpleDialog cancelButtonKey must be set.");
 
 			try {
 				return new SimpleDialog(this);

+ 6 - 0
src/main/java/org/cryptomator/ui/dialogs/SimpleDialogController.java

@@ -14,6 +14,7 @@ public class SimpleDialogController implements FxController {
 	private final String cancelButtonText;
 	private final Runnable okAction;
 	private final Runnable cancelAction;
+	private final boolean cancelButtonVisible;
 
 	public SimpleDialogController(String message, String description, FontAwesome5Icon icon, String okButtonText, String cancelButtonText, Runnable okAction, Runnable cancelAction) {
 		this.message = message;
@@ -23,6 +24,11 @@ public class SimpleDialogController implements FxController {
 		this.cancelButtonText = cancelButtonText;
 		this.okAction = okAction;
 		this.cancelAction = cancelAction;
+		this.cancelButtonVisible = cancelButtonText != null && !cancelButtonText.isEmpty();
+	}
+
+	public boolean isCancelButtonVisible() {
+		return cancelButtonVisible;
 	}
 
 	public String getMessage() {

+ 4 - 4
src/main/java/org/cryptomator/ui/keyloading/masterkeyfile/MasterkeyFileLoadingStrategy.java

@@ -112,12 +112,12 @@ public class MasterkeyFileLoadingStrategy implements KeyLoadingStrategy {
 	}
 
 	private void savePasswordToSystemkeychain(Passphrase passphrase) {
-		if (keychain.isSupported()) {
-			try {
+		try {
+			if (keychain.isSupported() && !keychain.getPassphraseStoredProperty(vault.getId()).get()) {
 				keychain.storePassphrase(vault.getId(), vault.getDisplayName(), passphrase);
-			} catch (KeychainAccessException e) {
-				LOG.error("Failed to store passphrase in system keychain.", e);
 			}
+		} catch (KeychainAccessException e) {
+			LOG.error("Failed to store passphrase in system keychain.", e);
 		}
 	}
 

+ 33 - 11
src/main/java/org/cryptomator/ui/mainwindow/MainWindowController.java

@@ -19,9 +19,11 @@ import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.ReadOnlyBooleanProperty;
 import javafx.beans.property.ReadOnlyObjectProperty;
 import javafx.fxml.FXML;
+import javafx.geometry.Rectangle2D;
 import javafx.scene.layout.StackPane;
 import javafx.stage.Screen;
 import javafx.stage.Stage;
+import javafx.stage.WindowEvent;
 
 @MainWindowScoped
 public class MainWindowController implements FxController {
@@ -68,18 +70,15 @@ public class MainWindowController implements FxController {
 		int y = settings.windowYPosition.get();
 		int width = settings.windowWidth.get();
 		int height = settings.windowHeight.get();
-		if (windowPositionSaved(x, y, width, height) ) {
-			if(isWithinDisplayBounds(x, y, width, height)) { //use stored window position
-				window.setX(x);
-				window.setY(y);
-				window.setWidth(Math.clamp(width, window.getMinWidth(), window.getMaxWidth()));
-				window.setHeight(Math.clamp(height, window.getMinHeight(), window.getMaxHeight()));
-			} else if(isWithinDisplayBounds((int) window.getX(), (int) window.getY(), width, height)) { //just reset position of upper left corner, keep window size
-				window.setWidth(Math.clamp(width, window.getMinWidth(), window.getMaxWidth()));
-				window.setHeight(Math.clamp(height, window.getMinHeight(), window.getMaxHeight()));
-			} //else reset window completely
+		if (windowPositionSaved(x, y, width, height)) {
+			window.setX(x);
+			window.setY(y);
+			window.setWidth(Math.clamp(width, window.getMinWidth(), window.getMaxWidth()));
+			window.setHeight(Math.clamp(height, window.getMinHeight(), window.getMaxHeight()));
 		}
 
+		window.setOnShowing(this::checkDisplayBounds);
+
 		settings.windowXPosition.bind(window.xProperty());
 		settings.windowYPosition.bind(window.yProperty());
 		settings.windowWidth.bind(window.widthProperty());
@@ -90,6 +89,29 @@ public class MainWindowController implements FxController {
 		return x != 0 || y != 0 || width != 0 || height != 0;
 	}
 
+	private void checkDisplayBounds(WindowEvent windowEvent) {
+		int x = settings.windowXPosition.get();
+		int y = settings.windowYPosition.get();
+		int width = settings.windowWidth.get();
+		int height = settings.windowHeight.get();
+
+		Rectangle2D primaryScreenBounds = Screen.getPrimary().getBounds();
+		if (!isWithinDisplayBounds(x, y, width, height)) { //use stored window position
+			LOG.debug("Resetting window position due to insufficient screen overlap");
+			var centeredX = (primaryScreenBounds.getWidth() - window.getMinWidth()) / 2;
+			var centeredY = (primaryScreenBounds.getHeight() - window.getMinHeight()) / 2;
+			//check if we can keep width and height
+			if (isWithinDisplayBounds((int) centeredX, (int) centeredY, width, height)) {
+				//if so, keep window size
+				window.setWidth(Math.clamp(width, window.getMinWidth(), window.getMaxWidth()));
+				window.setHeight(Math.clamp(height, window.getMinHeight(), window.getMaxHeight()));
+			}
+			//reset position of upper left corner
+			window.setX(centeredX);
+			window.setY(centeredY);
+		}
+	}
+
 	private boolean isWithinDisplayBounds(int x, int y, int width, int height) {
 		// define a rect which is inset on all sides from the window's rect:
 		final int shrinkedX = x + 20; // 20px left
@@ -144,7 +166,7 @@ public class MainWindowController implements FxController {
 		return updateAvailable.get();
 	}
 
-	public BooleanBinding licenseValidProperty(){
+	public BooleanBinding licenseValidProperty() {
 		return licenseHolder.validLicenseProperty();
 	}
 

+ 5 - 8
src/main/java/org/cryptomator/ui/mainwindow/VaultDetailLockedController.java

@@ -8,9 +8,9 @@ import org.cryptomator.ui.vaultoptions.SelectedVaultOptionsTab;
 import org.cryptomator.ui.vaultoptions.VaultOptionsComponent;
 
 import javax.inject.Inject;
+import javafx.beans.binding.Bindings;
 import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.ReadOnlyObjectProperty;
-import javafx.beans.property.SimpleBooleanProperty;
 import javafx.beans.value.ObservableValue;
 import javafx.fxml.FXML;
 import javafx.stage.Stage;
@@ -21,7 +21,6 @@ public class VaultDetailLockedController implements FxController {
 	private final ReadOnlyObjectProperty<Vault> vault;
 	private final FxApplicationWindows appWindows;
 	private final VaultOptionsComponent.Factory vaultOptionsWindow;
-	private final KeychainManager keychain;
 	private final Stage mainWindow;
 	private final ObservableValue<Boolean> passwordSaved;
 
@@ -30,13 +29,11 @@ public class VaultDetailLockedController implements FxController {
 		this.vault = vault;
 		this.appWindows = appWindows;
 		this.vaultOptionsWindow = vaultOptionsWindow;
-		this.keychain = keychain;
 		this.mainWindow = mainWindow;
-		if (keychain.isSupported() && !keychain.isLocked()) {
-			this.passwordSaved = vault.flatMap(v -> keychain.getPassphraseStoredProperty(v.getId())).orElse(false);
-		} else {
-			this.passwordSaved = new SimpleBooleanProperty(false);
-		}
+		this.passwordSaved = Bindings.createBooleanBinding(() -> {
+			var v = vault.get();
+			return v != null && keychain.getPassphraseStoredProperty(v.getId()).getValue();
+		}, vault, keychain.getKeychainImplementation());
 	}
 
 	@FXML

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

@@ -1,10 +1,13 @@
 package org.cryptomator.ui.preferences;
 
+import org.apache.commons.lang3.SystemUtils;
 import org.cryptomator.common.Environment;
+import org.cryptomator.common.keychain.KeychainManager;
 import org.cryptomator.common.settings.Settings;
 import org.cryptomator.integrations.autostart.AutoStartProvider;
 import org.cryptomator.integrations.autostart.ToggleAutoStartFailedException;
 import org.cryptomator.integrations.common.NamedServiceProvider;
+import org.cryptomator.integrations.keychain.KeychainAccessException;
 import org.cryptomator.integrations.keychain.KeychainAccessProvider;
 import org.cryptomator.integrations.quickaccess.QuickAccessService;
 import org.cryptomator.ui.common.FxController;
@@ -14,6 +17,7 @@ import org.slf4j.LoggerFactory;
 
 import javax.inject.Inject;
 import javafx.application.Application;
+import javafx.beans.Observable;
 import javafx.beans.binding.Bindings;
 import javafx.fxml.FXML;
 import javafx.scene.control.CheckBox;
@@ -23,6 +27,10 @@ import javafx.stage.Stage;
 import javafx.util.StringConverter;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.ExecutorService;
+import java.util.stream.Collectors;
 
 @PreferencesScoped
 public class GeneralPreferencesController implements FxController {
@@ -36,6 +44,8 @@ public class GeneralPreferencesController implements FxController {
 	private final Application application;
 	private final Environment environment;
 	private final List<KeychainAccessProvider> keychainAccessProviders;
+	private final KeychainManager keychain;
+	private final ExecutorService backgroundExecutor;
 	private final FxApplicationWindows appWindows;
 	public CheckBox useKeychainCheckbox;
 	public ChoiceBox<KeychainAccessProvider> keychainBackendChoiceBox;
@@ -47,12 +57,18 @@ public class GeneralPreferencesController implements FxController {
 	public CheckBox autoStartCheckbox;
 	public ToggleGroup nodeOrientation;
 
+	private CompletionStage<Void> keychainMigrations = CompletableFuture.completedFuture(null);
+
 	@Inject
-	GeneralPreferencesController(@PreferencesWindow Stage window, Settings settings, Optional<AutoStartProvider> autoStartProvider, List<KeychainAccessProvider> keychainAccessProviders, Application application, Environment environment, FxApplicationWindows appWindows) {
+	GeneralPreferencesController(@PreferencesWindow Stage window, Settings settings, Optional<AutoStartProvider> autoStartProvider, //
+								 List<KeychainAccessProvider> keychainAccessProviders, KeychainManager keychain, Application application, //
+								 Environment environment, FxApplicationWindows appWindows, ExecutorService backgroundExecutor) {
 		this.window = window;
 		this.settings = settings;
 		this.autoStartProvider = autoStartProvider;
 		this.keychainAccessProviders = keychainAccessProviders;
+		this.keychain = keychain;
+		this.backgroundExecutor = backgroundExecutor;
 		this.quickAccessServices = QuickAccessService.get().toList();
 		this.application = application;
 		this.environment = environment;
@@ -73,6 +89,7 @@ public class GeneralPreferencesController implements FxController {
 		Bindings.bindBidirectional(settings.keychainProvider, keychainBackendChoiceBox.valueProperty(), keychainSettingsConverter);
 		useKeychainCheckbox.selectedProperty().bindBidirectional(settings.useKeychain);
 		keychainBackendChoiceBox.disableProperty().bind(useKeychainCheckbox.selectedProperty().not());
+		keychainBackendChoiceBox.valueProperty().addListener(this::migrateKeychainEntries);
 
 		useQuickAccessCheckbox.selectedProperty().bindBidirectional(settings.useQuickAccess);
 		var quickAccessSettingsConverter = new ServiceToSettingsConverter<>(quickAccessServices);
@@ -83,6 +100,25 @@ public class GeneralPreferencesController implements FxController {
 		quickAccessServiceChoiceBox.disableProperty().bind(useQuickAccessCheckbox.selectedProperty().not());
 	}
 
+	private void migrateKeychainEntries(Observable observable, KeychainAccessProvider oldProvider, KeychainAccessProvider newProvider) {
+		//currently, we only migrate on macOS (touchID vs regular keychain)
+		if (SystemUtils.IS_OS_MAC) {
+			var idsAndNames = settings.directories.stream().collect(Collectors.toMap(vs -> vs.id, vs -> vs.displayName.getValue()));
+			if (!idsAndNames.isEmpty()) {
+				if (LOG.isDebugEnabled()) {
+					LOG.debug("Migrating keychain entries {} from {} to {}", idsAndNames.keySet(), oldProvider.displayName(), newProvider.displayName());
+				}
+				keychainMigrations = keychainMigrations.thenRunAsync(() -> {
+					try {
+						KeychainManager.migrate(oldProvider, newProvider, idsAndNames);
+					} catch (KeychainAccessException e) {
+						LOG.warn("Failed to migrate all entries from {} to {}", oldProvider.displayName(), newProvider.displayName(), e);
+					}
+				}, backgroundExecutor);
+			}
+		}
+	}
+
 	public boolean isAutoStartSupported() {
 		return autoStartProvider.isPresent();
 	}

+ 6 - 0
src/main/resources/css/dark_theme.css

@@ -359,6 +359,12 @@
 	-fx-background-color: PRIMARY;
 }
 
+.notification-debug:hover .notification-label,
+.notification-update:hover .notification-label,
+.notification-support:hover .notification-label {
+ 	-fx-underline:true;
+}
+
 /*******************************************************************************
  *                                                                             *
  * ScrollBar                                                                   *

+ 6 - 0
src/main/resources/css/light_theme.css

@@ -358,6 +358,12 @@
 	-fx-background-color: PRIMARY;
 }
 
+.notification-debug:hover .notification-label,
+.notification-update:hover .notification-label,
+.notification-support:hover .notification-label {
+	-fx-underline:true;
+}
+
 /*******************************************************************************
  *                                                                             *
  * ScrollBar                                                                   *

+ 8 - 8
src/main/resources/fxml/simple_dialog.fxml

@@ -1,16 +1,16 @@
 <?xml version="1.0" encoding="UTF-8"?>
 
-<?import javafx.scene.control.Label?>
-<?import javafx.scene.layout.HBox?>
-<?import javafx.scene.control.Button?>
+<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
 <?import javafx.geometry.Insets?>
+<?import javafx.scene.control.Button?>
+<?import javafx.scene.control.ButtonBar?>
+<?import javafx.scene.control.Label?>
 <?import javafx.scene.Group?>
+<?import javafx.scene.layout.HBox?>
+<?import javafx.scene.layout.Region?>
 <?import javafx.scene.layout.StackPane?>
-<?import javafx.scene.shape.Circle?>
-<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
 <?import javafx.scene.layout.VBox?>
-<?import javafx.scene.layout.Region?>
-<?import javafx.scene.control.ButtonBar?>
+<?import javafx.scene.shape.Circle?>
 <HBox xmlns:fx="http://javafx.com/fxml"
 	  xmlns="http://javafx.com/javafx"
 	  fx:controller="org.cryptomator.ui.dialogs.SimpleDialogController"
@@ -41,7 +41,7 @@
 			<Region VBox.vgrow="ALWAYS" minHeight="18"/>
 			<ButtonBar buttonMinWidth="120" buttonOrder="+CI">
 				<buttons>
-					<Button text="${controller.cancelButtonText}" ButtonBar.buttonData="CANCEL_CLOSE" cancelButton="true" onAction="#handleCancel"/>
+					<Button text="${controller.cancelButtonText}" ButtonBar.buttonData="CANCEL_CLOSE" cancelButton="true" onAction="#handleCancel" visible="${controller.cancelButtonVisible}" managed="${controller.cancelButtonVisible}"/>
 					<Button text="${controller.okButtonText}" ButtonBar.buttonData="FINISH" defaultButton="true" onAction="#handleOk"/>
 				</buttons>
 			</ButtonBar>

+ 1 - 2
src/main/resources/i18n/strings.properties

@@ -109,7 +109,6 @@ addvaultwizard.success.unlockNow=Unlock Now
 removeVault.title=Remove "%s"
 removeVault.message=Remove vault?
 removeVault.description=This will only make Cryptomator forget about this vault. You can add it again. No encrypted files will be deleted from your hard drive.
-removeVault.confirmBtn=Remove Vault
 
 # Contact Hub Admin
 contactHubAdmin.title=Contact Admin
@@ -291,7 +290,7 @@ preferences.title=Preferences
 ## General
 preferences.general=General
 preferences.general.startHidden=Hide window when starting Cryptomator
-preferences.general.autoCloseVaults=Lock open vaults automatically when quitting application
+preferences.general.autoCloseVaults=Lock vaults without asking when quitting application
 preferences.general.debugLogging=Enable debug logging
 preferences.general.debugDirectory=Reveal log files
 preferences.general.autoStart=Launch Cryptomator on system start

+ 10 - 12
src/test/java/org/cryptomator/common/keychain/KeychainManagerTest.java

@@ -1,6 +1,7 @@
 package org.cryptomator.common.keychain;
 
 
+import org.cryptomator.JavaFXUtil;
 import org.cryptomator.integrations.keychain.KeychainAccessException;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Assumptions;
@@ -13,11 +14,16 @@ import javafx.beans.property.ReadOnlyBooleanProperty;
 import javafx.beans.property.SimpleObjectProperty;
 import java.time.Duration;
 import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 
-public class KeychainManagerTest {
+class KeychainManagerTest {
+
+	@BeforeAll
+	public static void startup() throws InterruptedException {
+		var isRunning = JavaFXUtil.startPlatform();
+		Assumptions.assumeTrue(isRunning);
+	}
 
 	@Test
 	public void testStoreAndLoad() throws KeychainAccessException {
@@ -27,15 +33,7 @@ public class KeychainManagerTest {
 	}
 
 	@Nested
-	public static class WhenObservingProperties {
-
-		@BeforeAll
-		public static void startup() throws InterruptedException {
-			CountDownLatch latch = new CountDownLatch(1);
-			Platform.startup(latch::countDown);
-			var javafxStarted = latch.await(5, TimeUnit.SECONDS);
-			Assumptions.assumeTrue(javafxStarted);
-		}
+	class WhenObservingProperties {
 
 		@Test
 		public void testPropertyChangesWhenStoringPassword() throws KeychainAccessException, InterruptedException {
@@ -43,7 +41,7 @@ public class KeychainManagerTest {
 			ReadOnlyBooleanProperty property = keychainManager.getPassphraseStoredProperty("test");
 			Assertions.assertFalse(property.get());
 
-			keychainManager.storePassphrase("test", null,"bar");
+			keychainManager.storePassphrase("test", null, "bar");
 
 			AtomicBoolean result = new AtomicBoolean(false);
 			CountDownLatch latch = new CountDownLatch(1);

+ 3 - 3
src/test/java/org/cryptomator/ui/addvaultwizard/ReadMeGeneratorTest.java

@@ -15,8 +15,8 @@ public class ReadMeGeneratorTest {
 	@ParameterizedTest
 	@CsvSource({ //
 			"test,test", //
-			"t\u00E4st,t\\u228st", //
-			"t\uD83D\uDE09st,t\\u55357\\u56841st", //
+			"t\u00E4st,t\\'E4st", //
+			"t\uD83D\uDE09st,t\\uc1\\u55357\\uc1\\u56841st", //
 	})
 	public void testEscapeNonAsciiChars(String input, String expectedResult) {
 		ReadmeGenerator readmeGenerator = new ReadmeGenerator(null);
@@ -40,7 +40,7 @@ public class ReadMeGeneratorTest {
 		MatcherAssert.assertThat(result, CoreMatchers.startsWith("{\\rtf1\\fbidis\\ansi\\uc0\\fs32"));
 		MatcherAssert.assertThat(result, CoreMatchers.containsString("{\\sa80 Dear User,}\\par"));
 		MatcherAssert.assertThat(result, CoreMatchers.containsString("{\\sa80 \\b please don't touch the \"d\" directory.}\\par "));
-		MatcherAssert.assertThat(result, CoreMatchers.containsString("{\\sa80 Thank you for your cooperation \\u55357\\u56841}\\par"));
+		MatcherAssert.assertThat(result, CoreMatchers.containsString("{\\sa80 Thank you for your cooperation \\uc1\\u55357\\uc1\\u56841}\\par"));
 		MatcherAssert.assertThat(result, CoreMatchers.endsWith("}"));
 	}
 

+ 3 - 9
src/test/java/org/cryptomator/ui/controls/SecurePasswordFieldTest.java

@@ -1,6 +1,6 @@
 package org.cryptomator.ui.controls;
 
-import org.junit.jupiter.api.AfterAll;
+import org.cryptomator.JavaFXUtil;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.BeforeAll;
@@ -8,20 +8,14 @@ import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 
-import javafx.application.Platform;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-
 public class SecurePasswordFieldTest {
 
 	private SecurePasswordField pwField = new SecurePasswordField();
 
 	@BeforeAll
 	public static void initJavaFx() throws InterruptedException {
-		CountDownLatch latch = new CountDownLatch(1);
-		Platform.startup(latch::countDown);
-		var javafxStarted = latch.await(5, TimeUnit.SECONDS);
-		Assumptions.assumeTrue(javafxStarted);
+		var isRunning = JavaFXUtil.startPlatform();
+		Assumptions.assumeTrue(isRunning);
 	}
 
 	@Nested