Merge branch 'develop' into feature/restore-vaultconfig

This commit is contained in:
Jan-Peter Klein
2025-03-18 11:53:06 +01:00
23 changed files with 259 additions and 115 deletions

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
});
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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();
}