Merge branch 'feature/802-custom-mount-flags' into develop

fixes #802
This commit is contained in:
Sebastian Stenzel
2019-07-03 16:08:38 +02:00
18 changed files with 376 additions and 183 deletions

View File

@@ -10,6 +10,7 @@ package org.cryptomator.common.settings;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import org.cryptomator.common.Environment;
import org.cryptomator.common.LazyInitializer;
import org.slf4j.Logger;
@@ -29,6 +30,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Optional;
import java.util.concurrent.Executors;
@@ -109,12 +111,14 @@ public class SettingsProvider implements Provider<Settings> {
LOG.debug("Attempting to save settings to {}", settingsPath);
try {
Files.createDirectories(settingsPath.getParent());
try (OutputStream out = Files.newOutputStream(settingsPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); //
Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) {
Path tmpPath = settingsPath.resolveSibling(settingsPath.getFileName().toString() + ".tmp");
try (OutputStream out = Files.newOutputStream(tmpPath, StandardOpenOption.CREATE_NEW); //
Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) {
gson.toJson(settings, writer);
LOG.info("Settings saved to {}", settingsPath);
}
} catch (IOException e) {
Files.move(tmpPath, settingsPath, StandardCopyOption.REPLACE_EXISTING);
LOG.info("Settings saved to {}", settingsPath);
} catch (IOException | JsonParseException e) {
LOG.error("Failed to save settings.", e);
}
}

View File

@@ -20,6 +20,7 @@ import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -34,9 +35,10 @@ public class VaultSettings {
public static final boolean DEFAULT_REAVEAL_AFTER_MOUNT = true;
public static final boolean DEFAULT_USES_INDIVIDUAL_MOUNTPATH = false;
public static final boolean DEFAULT_USES_READONLY_MODE = false;
public static final String DEFAULT_MOUNT_FLAGS = "";
private final String id;
private final ObjectProperty<Path> path = new SimpleObjectProperty<>();
private final ObjectProperty<Path> path = new SimpleObjectProperty();
private final StringProperty mountName = new SimpleStringProperty();
private final StringProperty winDriveLetter = new SimpleStringProperty();
private final BooleanProperty unlockAfterStartup = new SimpleBooleanProperty(DEFAULT_UNLOCK_AFTER_STARTUP);
@@ -44,6 +46,7 @@ public class VaultSettings {
private final BooleanProperty usesIndividualMountPath = new SimpleBooleanProperty(DEFAULT_USES_INDIVIDUAL_MOUNTPATH);
private final StringProperty individualMountPath = new SimpleStringProperty();
private final BooleanProperty usesReadOnlyMode = new SimpleBooleanProperty(DEFAULT_USES_READONLY_MODE);
private final StringProperty mountFlags = new SimpleStringProperty(DEFAULT_MOUNT_FLAGS);
public VaultSettings(String id) {
this.id = Objects.requireNonNull(id);
@@ -52,7 +55,7 @@ public class VaultSettings {
}
Observable[] observables() {
return new Observable[]{path, mountName, winDriveLetter, unlockAfterStartup, revealAfterMount, usesIndividualMountPath, individualMountPath, usesReadOnlyMode};
return new Observable[]{path, mountName, winDriveLetter, unlockAfterStartup, revealAfterMount, usesIndividualMountPath, individualMountPath, usesReadOnlyMode, mountFlags};
}
private void deriveMountNameFromPath(Path path) {
@@ -147,6 +150,10 @@ public class VaultSettings {
return usesReadOnlyMode;
}
public StringProperty mountFlags() {
return mountFlags;
}
/* Hashcode/Equals */
@Override

View File

@@ -26,8 +26,9 @@ class VaultSettingsJsonAdapter {
out.name("unlockAfterStartup").value(value.unlockAfterStartup().get());
out.name("revealAfterMount").value(value.revealAfterMount().get());
out.name("usesIndividualMountPath").value(value.usesIndividualMountPath().get());
out.name("individualMountPath").value(value.individualMountPath().get()); //TODO: should this always be written? ( because it could contain metadata, which the user may not want to save!)
out.name("individualMountPath").value(value.individualMountPath().get());
out.name("usesReadOnlyMode").value(value.usesReadOnlyMode().get());
out.name("mountFlags").value(value.mountFlags().get());
out.endObject();
}
@@ -41,6 +42,7 @@ class VaultSettingsJsonAdapter {
boolean revealAfterMount = VaultSettings.DEFAULT_REAVEAL_AFTER_MOUNT;
boolean usesIndividualMountPath = VaultSettings.DEFAULT_USES_INDIVIDUAL_MOUNTPATH;
boolean usesReadOnlyMode = VaultSettings.DEFAULT_USES_READONLY_MODE;
String mountFlags = VaultSettings.DEFAULT_MOUNT_FLAGS;
in.beginObject();
while (in.hasNext()) {
@@ -73,6 +75,9 @@ class VaultSettingsJsonAdapter {
case "usesReadOnlyMode":
usesReadOnlyMode = in.nextBoolean();
break;
case "mountFlags":
mountFlags = in.nextString();
break;
default:
LOG.warn("Unsupported vault setting found in JSON: " + name);
in.skipValue();
@@ -90,6 +95,7 @@ class VaultSettingsJsonAdapter {
vaultSettings.usesIndividualMountPath().set(usesIndividualMountPath);
vaultSettings.individualMountPath().set(individualMountPath);
vaultSettings.usesReadOnlyMode().set(usesReadOnlyMode);
vaultSettings.mountFlags().set(mountFlags);
return vaultSettings;
}

View File

@@ -6,12 +6,17 @@
package org.cryptomator.common.settings;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import org.hamcrest.CoreMatchers;
import org.hamcrest.MatcherAssert;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.nio.file.Paths;
import java.util.Arrays;
public class VaultSettingsJsonAdapterTest {
@@ -19,7 +24,7 @@ public class VaultSettingsJsonAdapterTest {
@Test
public void testDeserialize() throws IOException {
String json = "{\"id\": \"foo\", \"path\": \"/foo/bar\", \"mountName\": \"test\", \"winDriveLetter\": \"X\", \"shouldBeIgnored\": true, \"individualMountPath\": \"/home/test/crypto\"}";
String json = "{\"id\": \"foo\", \"path\": \"/foo/bar\", \"mountName\": \"test\", \"winDriveLetter\": \"X\", \"shouldBeIgnored\": true, \"individualMountPath\": \"/home/test/crypto\", \"mountFlags\":\"--foo --bar\"}";
JsonReader jsonReader = new JsonReader(new StringReader(json));
VaultSettings vaultSettings = adapter.read(jsonReader);
@@ -28,6 +33,27 @@ public class VaultSettingsJsonAdapterTest {
Assertions.assertEquals("test", vaultSettings.mountName().get());
Assertions.assertEquals("X", vaultSettings.winDriveLetter().get());
Assertions.assertEquals("/home/test/crypto", vaultSettings.individualMountPath().get());
Assertions.assertEquals("--foo --bar", vaultSettings.mountFlags().get());
}
@Test
public void testSerialize() throws IOException {
VaultSettings vaultSettings = new VaultSettings("test");
vaultSettings.path().set(Paths.get("/foo/bar"));
vaultSettings.mountName().set("mountyMcMountFace");
vaultSettings.mountFlags().set("--foo --bar");
StringWriter buf = new StringWriter();
JsonWriter jsonWriter = new JsonWriter(buf);
adapter.write(jsonWriter, vaultSettings);
String result = buf.toString();
MatcherAssert.assertThat(result, CoreMatchers.containsString("\"id\":\"test\""));
MatcherAssert.assertThat(result, CoreMatchers.containsString("\"path\":\"/foo/bar\""));
MatcherAssert.assertThat(result, CoreMatchers.containsString("\"mountName\":\"mountyMcMountFace\""));
MatcherAssert.assertThat(result, CoreMatchers.containsString("\"mountFlags\":\"--foo --bar\""));
}
}

View File

@@ -27,8 +27,8 @@
<cryptomator.cryptolib.version>1.2.1</cryptomator.cryptolib.version>
<cryptomator.cryptofs.version>1.8.5</cryptomator.cryptofs.version>
<cryptomator.jni.version>2.0.0</cryptomator.jni.version>
<cryptomator.fuse.version>1.1.3</cryptomator.fuse.version>
<cryptomator.dokany.version>1.1.8</cryptomator.dokany.version>
<cryptomator.fuse.version>1.2.0</cryptomator.fuse.version>
<cryptomator.dokany.version>1.1.9</cryptomator.dokany.version>
<cryptomator.webdav.version>1.0.10</cryptomator.webdav.version>
<javafx.version>12</javafx.version>

View File

@@ -11,6 +11,8 @@ package org.cryptomator.ui.controllers;
import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import javafx.application.Application;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML;
@@ -20,13 +22,13 @@ import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.DirectoryChooser;
import javafx.stage.Stage;
@@ -83,6 +85,7 @@ public class UnlockController implements ViewController {
private Vault vault;
private Optional<UnlockListener> listener = Optional.empty();
private Subscription vaultSubs = Subscription.EMPTY;
private BooleanProperty unlocking = new SimpleBooleanProperty();
@Inject
public UnlockController(Application app, @Named("mainWindow") Stage mainWindow, Localization localization, WindowsDriveLetters driveLetters, Optional<KeychainAccess> keychainAccess, Settings settings, ExecutorService executor) {
@@ -113,11 +116,17 @@ public class UnlockController implements ViewController {
@FXML
private TextField mountName;
@FXML
private CheckBox useCustomMountFlags;
@FXML
private TextField mountFlags;
@FXML
private CheckBox revealAfterMount;
@FXML
private Label winDriveLetterLabel;
private CheckBox useCustomWinDriveLetter;
@FXML
private ChoiceBox<Character> winDriveLetter;
@@ -131,20 +140,14 @@ public class UnlockController implements ViewController {
@FXML
private Label customMountPointLabel;
@FXML
private ProgressIndicator progressIndicator;
@FXML
private Text progressText;
@FXML
private Hyperlink downloadsPageLink;
@FXML
private GridPane advancedOptions;
private VBox advancedOptions;
@FXML
private GridPane root;
private VBox root;
@FXML
private CheckBox unlockAfterStartup;
@@ -155,25 +158,31 @@ public class UnlockController implements ViewController {
@Override
public void initialize() {
advancedOptions.managedProperty().bind(advancedOptions.visibleProperty());
unlockButton.disableProperty().bind(passwordField.textProperty().isEmpty());
advancedOptions.disableProperty().bind(unlocking);
unlockButton.disableProperty().bind(unlocking.or(passwordField.textProperty().isEmpty()));
mountName.addEventFilter(KeyEvent.KEY_TYPED, this::filterAlphanumericKeyEvents);
mountName.textProperty().addListener(this::mountNameDidChange);
useReadOnlyMode.selectedProperty().addListener(this::useReadOnlyDidChange);
useCustomMountFlags.selectedProperty().addListener(this::useCustomMountFlagsDidChange);
mountFlags.disableProperty().bind(useCustomMountFlags.selectedProperty().not());
mountFlags.textProperty().addListener(this::mountFlagsDidChange);
savePassword.setDisable(!keychainAccess.isPresent());
unlockAfterStartup.disableProperty().bind(savePassword.disabledProperty().or(savePassword.selectedProperty().not()));
downloadsPageLink.visibleProperty().bind(downloadsPageLink.managedProperty());
customMountPoint.visibleProperty().bind(useCustomMountPoint.selectedProperty());
customMountPoint.managedProperty().bind(useCustomMountPoint.selectedProperty());
winDriveLetter.setConverter(new WinDriveLetterLabelConverter());
winDriveLetter.disableProperty().bind(useCustomWinDriveLetter.selectedProperty().not());
if (!SystemUtils.IS_OS_WINDOWS) {
winDriveLetterLabel.setVisible(false);
winDriveLetterLabel.setManaged(false);
useCustomWinDriveLetter.setVisible(false);
useCustomWinDriveLetter.setManaged(false);
winDriveLetter.setVisible(false);
winDriveLetter.setManaged(false);
}
}
@Override
public Parent getRoot() {
return root;
@@ -198,20 +207,12 @@ public class UnlockController implements ViewController {
this.vault = vault;
advancedOptions.setVisible(false);
advancedOptionsButton.setText(localization.getString("unlock.button.advancedOptions.show"));
progressIndicator.setVisible(false);
progressText.setText(null);
unlockButton.setContentDisplay(ContentDisplay.TEXT_ONLY);
state.successMessage().map(localization::getString).ifPresent(messageText::setText);
if (SystemUtils.IS_OS_WINDOWS) {
winDriveLetter.valueProperty().removeListener(driveLetterChangeListener);
winDriveLetter.getItems().clear();
winDriveLetter.getItems().add(null);
winDriveLetter.getItems().addAll(driveLetters.getAvailableDriveLetters());
winDriveLetter.getItems().sort(new WinDriveLetterComparator());
winDriveLetter.valueProperty().addListener(driveLetterChangeListener);
chooseSelectedDriveLetter();
}
downloadsPageLink.setVisible(false);
downloadsPageLink.setManaged(false);
mountName.setText(vault.getMountName());
useCustomMountFlags.setSelected(vault.isHavingCustomMountFlags());
mountFlags.setText(vault.getMountFlags());
savePassword.setSelected(false);
// auto-fill pw from keychain:
if (keychainAccess.isPresent()) {
@@ -226,6 +227,7 @@ public class UnlockController implements ViewController {
VaultSettings vaultSettings = vault.getVaultSettings();
unlockAfterStartup.setSelected(savePassword.isSelected() && vaultSettings.unlockAfterStartup().get());
revealAfterMount.setSelected(vaultSettings.revealAfterMount().get());
useReadOnlyMode.setSelected(vaultSettings.usesReadOnlyMode().get());
// WEBDAV-dependent controls:
if (VolumeImpl.WEBDAV.equals(settings.preferredVolumeImpl().get())) {
@@ -241,26 +243,21 @@ public class UnlockController implements ViewController {
}
}
// DOKANY-dependent controls:
if (VolumeImpl.DOKANY.equals(settings.preferredVolumeImpl().get())) {
winDriveLetter.visibleProperty().bind(useCustomMountPoint.selectedProperty().not());
winDriveLetter.managedProperty().bind(useCustomMountPoint.selectedProperty().not());
winDriveLetterLabel.visibleProperty().bind(useCustomMountPoint.selectedProperty().not());
winDriveLetterLabel.managedProperty().bind(useCustomMountPoint.selectedProperty().not());
// readonly not yet supported by dokany
useReadOnlyMode.setSelected(false);
useReadOnlyMode.setVisible(false);
useReadOnlyMode.setManaged(false);
} else {
useReadOnlyMode.setSelected(vaultSettings.usesReadOnlyMode().get());
}
// OS-dependent controls:
if (SystemUtils.IS_OS_WINDOWS) {
winDriveLetter.valueProperty().removeListener(driveLetterChangeListener);
winDriveLetter.getItems().clear();
winDriveLetter.getItems().add(null);
winDriveLetter.getItems().addAll(driveLetters.getAvailableDriveLetters());
winDriveLetter.getItems().sort(new WinDriveLetterComparator());
winDriveLetter.valueProperty().addListener(driveLetterChangeListener);
chooseSelectedDriveLetter();
winDriveLetter.visibleProperty().bind(useCustomMountPoint.selectedProperty().not());
winDriveLetter.managedProperty().bind(useCustomMountPoint.selectedProperty().not());
winDriveLetterLabel.visibleProperty().bind(useCustomMountPoint.selectedProperty().not());
winDriveLetterLabel.managedProperty().bind(useCustomMountPoint.selectedProperty().not());
useCustomWinDriveLetter.visibleProperty().bind(useCustomMountPoint.selectedProperty().not());
useCustomWinDriveLetter.managedProperty().bind(useCustomMountPoint.selectedProperty().not());
}
vaultSubs = vaultSubs.and(EasyBind.subscribe(unlockAfterStartup.selectedProperty(), vaultSettings.unlockAfterStartup()::set));
@@ -297,6 +294,7 @@ public class UnlockController implements ViewController {
@FXML
private void didClickAdvancedOptionsButton() {
messageText.setText(null);
downloadsPageLink.setManaged(false);
advancedOptions.setVisible(!advancedOptions.isVisible());
if (advancedOptions.isVisible()) {
advancedOptionsButton.setText(localization.getString("unlock.button.advancedOptions.hide"));
@@ -318,6 +316,30 @@ public class UnlockController implements ViewController {
} else {
vault.setMountName(newValue);
}
if (!useCustomMountFlags.isSelected()) {
mountFlags.setText(vault.getMountFlags()); // update default flags
}
}
private void useReadOnlyDidChange(@SuppressWarnings("unused") ObservableValue<? extends Boolean> property, @SuppressWarnings("unused")Boolean oldValue, Boolean newValue) {
vault.getVaultSettings().usesReadOnlyMode().setValue(newValue);
if (!useCustomMountFlags.isSelected()) {
mountFlags.setText(vault.getMountFlags()); // update default flags
}
}
private void useCustomMountFlagsDidChange(@SuppressWarnings("unused") ObservableValue<? extends Boolean> property, @SuppressWarnings("unused")Boolean oldValue, Boolean newValue) {
if (!newValue) {
vault.setMountFlags(VaultSettings.DEFAULT_MOUNT_FLAGS);
mountFlags.setText(vault.getMountFlags());
}
}
private void mountFlagsDidChange(@SuppressWarnings("unused") ObservableValue<? extends String> property, @SuppressWarnings("unused")String oldValue, String newValue) {
if (useCustomMountFlags.isSelected()) {
vault.setMountFlags(newValue);
}
}
@FXML
@@ -330,6 +352,13 @@ public class UnlockController implements ViewController {
}
}
@FXML
public void didClickCustomWinDriveLetterCheckbox() {
if (!useCustomWinDriveLetter.isSelected()) {
winDriveLetter.setValue(null);
}
}
/**
* Converts 'C' to "C:" to translate between model and GUI.
*/
@@ -379,7 +408,7 @@ public class UnlockController implements ViewController {
private void chooseSelectedDriveLetter() {
assert SystemUtils.IS_OS_WINDOWS;
// if the vault prefers a drive letter, that is currently occupied, this is our last chance to reset this:
if (driveLetters.getOccupiedDriveLetters().contains(vault.getWinDriveLetter())) {
if (vault.getWinDriveLetter() != null && driveLetters.getOccupiedDriveLetters().contains(vault.getWinDriveLetter())) {
vault.setWinDriveLetter(null);
}
final Character letter = vault.getWinDriveLetter();
@@ -428,35 +457,34 @@ public class UnlockController implements ViewController {
@FXML
private void didClickUnlockButton() {
advancedOptions.setDisable(true);
advancedOptions.setVisible(false);
unlocking.set(true);
advancedOptionsButton.setText(localization.getString("unlock.button.advancedOptions.show"));
progressIndicator.setVisible(true);
unlockButton.setContentDisplay(ContentDisplay.LEFT);
CharSequence password = passwordField.getCharacters();
Tasks.create(() -> {
progressText.setText(localization.getString("unlock.pendingMessage.unlocking"));
vault.unlock(password);
if (keychainAccess.isPresent() && savePassword.isSelected()) {
keychainAccess.get().storePassphrase(vault.getId(), password);
}
}).onSuccess(() -> {
messageText.setText("");
downloadsPageLink.setVisible(false);
messageText.setText(null);
downloadsPageLink.setManaged(false);
listener.ifPresent(lstnr -> lstnr.didUnlock(vault));
passwordField.swipe();
}).onError(InvalidPassphraseException.class, e -> {
messageText.setText(localization.getString("unlock.errorMessage.wrongPassword"));
downloadsPageLink.setManaged(false);
passwordField.selectAll();
passwordField.requestFocus();
}).onError(UnsupportedVaultFormatException.class, e -> {
if (e.isVaultOlderThanSoftware()) {
// whitespace after localized text used as separator between text and hyperlink
messageText.setText(localization.getString("unlock.errorMessage.unsupportedVersion.vaultOlderThanSoftware") + " ");
downloadsPageLink.setVisible(true);
downloadsPageLink.setManaged(true);
} else if (e.isSoftwareOlderThanVault()) {
messageText.setText(localization.getString("unlock.errorMessage.unsupportedVersion.softwareOlderThanVault") + " ");
downloadsPageLink.setVisible(true);
downloadsPageLink.setManaged(true);
} else if (e.getDetectedVersion() == Integer.MAX_VALUE) {
messageText.setText(localization.getString("unlock.errorMessage.unauthenticVersionMac"));
}
@@ -464,19 +492,21 @@ public class UnlockController implements ViewController {
LOG.error("Unlock failed. Mount point not a directory: {}", e.getMessage());
advancedOptions.setVisible(true);
messageText.setText(null);
downloadsPageLink.setManaged(false);
showUnlockFailedErrorDialog("unlock.failedDialog.content.mountPathNonExisting");
}).onError(DirectoryNotEmptyException.class, e -> {
LOG.error("Unlock failed. Mount point not empty: {}", e.getMessage());
advancedOptions.setVisible(true);
messageText.setText(null);
downloadsPageLink.setManaged(false);
showUnlockFailedErrorDialog("unlock.failedDialog.content.mountPathNotEmpty");
}).onError(Exception.class, e -> { // including RuntimeExceptions
LOG.error("Unlock failed for technical reasons.", e);
messageText.setText(localization.getString("unlock.errorMessage.unlockFailed"));
downloadsPageLink.setManaged(false);
}).andFinally(() -> {
advancedOptions.setDisable(false);
progressIndicator.setVisible(false);
progressText.setText(null);
unlocking.set(false);
unlockButton.setContentDisplay(ContentDisplay.TEXT_ONLY);
}).runOnce(executor);
}

View File

@@ -0,0 +1,13 @@
package org.cryptomator.ui.model;
import javax.inject.Qualifier;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Qualifier
@Documented
@Retention(RUNTIME)
public @interface DefaultMountFlags {
}

View File

@@ -44,11 +44,11 @@ public class DokanyVolume implements Volume {
}
@Override
public void mount(CryptoFileSystem fs) throws VolumeException, IOException {
public void mount(CryptoFileSystem fs, String mountFlags) throws VolumeException, IOException {
Path mountPath = getMountPoint();
String mountName = vaultSettings.mountName().get();
try {
this.mount = mountFactory.mount(fs.getPath("/"), mountPath, mountName, FS_TYPE_NAME);
this.mount = mountFactory.mount(fs.getPath("/"), mountPath, mountName, FS_TYPE_NAME, mountFlags);
} catch (MountFailedException e) {
if (vaultSettings.getIndividualMountPath().isPresent()) {
LOG.warn("Failed to mount vault into {}. Is this directory currently accessed by another process (e.g. Windows Explorer)?", mountPath);

View File

@@ -1,5 +1,6 @@
package org.cryptomator.ui.model;
import com.google.common.base.Splitter;
import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.Environment;
import org.cryptomator.common.settings.VaultSettings;
@@ -9,6 +10,7 @@ import org.cryptomator.frontend.fuse.mount.EnvironmentVariables;
import org.cryptomator.frontend.fuse.mount.FuseMountFactory;
import org.cryptomator.frontend.fuse.mount.FuseNotSupportedException;
import org.cryptomator.frontend.fuse.mount.Mount;
import org.cryptomator.frontend.fuse.mount.Mounter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -42,7 +44,7 @@ public class FuseVolume implements Volume {
}
@Override
public void mount(CryptoFileSystem fs) throws IOException, FuseNotSupportedException, VolumeException {
public void mount(CryptoFileSystem fs, String mountFlags) throws IOException, FuseNotSupportedException, VolumeException {
Optional<String> optionalCustomMountPoint = vaultSettings.getIndividualMountPath();
if (optionalCustomMountPoint.isPresent()) {
Path customMountPoint = Paths.get(optionalCustomMountPoint.get());
@@ -53,7 +55,7 @@ public class FuseVolume implements Volume {
this.mountPoint = prepareTemporaryMountPoint();
LOG.debug("Successfully created mount point: {}", mountPoint);
}
mount(fs.getPath("/"));
mount(fs.getPath("/"), mountFlags);
}
private void checkProvidedMountPoint(Path mountPoint) throws IOException {
@@ -94,18 +96,23 @@ public class FuseVolume implements Volume {
throw new VolumeException("Did not find feasible mount point.");
}
private void mount(Path root) throws VolumeException {
private void mount(Path root, String mountFlags) throws VolumeException {
try {
Mounter mounter = FuseMountFactory.getMounter();
EnvironmentVariables envVars = EnvironmentVariables.create() //
.withMountName(vaultSettings.mountName().getValue()) //
.withMountPath(mountPoint) //
.withFlags(splitFlags(mountFlags))
.withMountPoint(mountPoint) //
.build();
this.fuseMnt = FuseMountFactory.getMounter().mount(root, envVars);
this.fuseMnt = mounter.mount(root, envVars);
} catch (CommandFailedException e) {
throw new VolumeException("Unable to mount Filesystem", e);
}
}
private String[] splitFlags(String str) {
return Splitter.on(' ').splitToList(str).toArray(String[]::new);
}
@Override
public void reveal() throws VolumeException {
try {

View File

@@ -0,0 +1,13 @@
package org.cryptomator.ui.model;
import javax.inject.Scope;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Scope
@Documented
@Retention(RetentionPolicy.RUNTIME)
@interface PerVault {
}

View File

@@ -25,7 +25,6 @@ import org.cryptomator.cryptofs.CryptoFileSystemProperties.FileSystemFlags;
import org.cryptomator.cryptofs.CryptoFileSystemProvider;
import org.cryptomator.cryptolib.api.CryptoException;
import org.cryptomator.cryptolib.api.InvalidPassphraseException;
import org.cryptomator.ui.model.VaultModule.PerVault;
import org.fxmisc.easybind.EasyBind;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -44,6 +43,7 @@ import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
import java.util.function.Supplier;
@PerVault
public class Vault {
@@ -54,6 +54,7 @@ public class Vault {
private final VaultSettings vaultSettings;
private final Provider<Volume> volumeProvider;
private final Supplier<String> defaultMountFlags;
private final AtomicReference<CryptoFileSystem> cryptoFileSystem = new AtomicReference<>();
private final ObjectProperty<State> state = new SimpleObjectProperty<State>(State.LOCKED);
@@ -64,9 +65,10 @@ public class Vault {
}
@Inject
Vault(VaultSettings vaultSettings, Provider<Volume> volumeProvider) {
Vault(VaultSettings vaultSettings, Provider<Volume> volumeProvider, @DefaultMountFlags Supplier<String> defaultMountFlags) {
this.vaultSettings = vaultSettings;
this.volumeProvider = volumeProvider;
this.defaultMountFlags = defaultMountFlags;
}
// ******************************************************************************
@@ -110,7 +112,7 @@ public class Vault {
}
CryptoFileSystem fs = getCryptoFileSystem(passphrase);
volume = volumeProvider.get();
volume.mount(fs);
volume.mount(fs, getMountFlags());
Platform.runLater(() -> {
state.set(State.UNLOCKED);
});
@@ -241,10 +243,6 @@ public class Vault {
}
}
public String getMountName() {
return vaultSettings.mountName().get();
}
public String getCustomMountPath() {
return vaultSettings.individualMountPath().getValueSafe();
}
@@ -253,6 +251,10 @@ public class Vault {
vaultSettings.individualMountPath().set(mountPath);
}
public String getMountName() {
return vaultSettings.mountName().get();
}
public void setMountName(String mountName) throws IllegalArgumentException {
if (StringUtils.isBlank(mountName)) {
throw new IllegalArgumentException("mount name is empty");
@@ -261,6 +263,23 @@ public class Vault {
}
}
public boolean isHavingCustomMountFlags() {
return !Strings.isNullOrEmpty(vaultSettings.mountFlags().get());
}
public String getMountFlags() {
String mountFlags = vaultSettings.mountFlags().get();
if (Strings.isNullOrEmpty(mountFlags)) {
return defaultMountFlags.get();
} else {
return mountFlags;
}
}
public void setMountFlags(String mountFlags) {
vaultSettings.mountFlags().set(mountFlags);
}
public Character getWinDriveLetter() {
if (vaultSettings.winDriveLetter().get() == null) {
return null;

View File

@@ -7,7 +7,6 @@ package org.cryptomator.ui.model;
import dagger.BindsInstance;
import org.cryptomator.common.settings.VaultSettings;
import org.cryptomator.ui.model.VaultModule.PerVault;
import dagger.Subcomponent;

View File

@@ -7,28 +7,24 @@ package org.cryptomator.ui.model;
import dagger.Module;
import dagger.Provides;
import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.common.settings.VaultSettings;
import org.cryptomator.common.settings.VolumeImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Scope;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.function.Supplier;
@Module
public class VaultModule {
private static final Logger LOG = LoggerFactory.getLogger(VaultModule.class);
@Scope
@Documented
@Retention(RetentionPolicy.RUNTIME)
@interface PerVault {
}
@Provides
public Volume provideVolume(Settings settings, WebDavVolume webDavVolume, FuseVolume fuseVolume, DokanyVolume dokanyVolume) {
VolumeImpl preferredImpl = settings.preferredVolumeImpl().get();
@@ -45,4 +41,91 @@ public class VaultModule {
}
}
@Provides
@PerVault
@DefaultMountFlags
public Supplier<String> provideDefaultMountFlags(Settings settings, VaultSettings vaultSettings) {
VolumeImpl preferredImpl = settings.preferredVolumeImpl().get();
switch (preferredImpl) {
case FUSE:
if (SystemUtils.IS_OS_MAC_OSX) {
return () -> getMacFuseDefaultMountFlags(settings, vaultSettings);
} else if (SystemUtils.IS_OS_LINUX) {
return () -> getLinuxFuseDefaultMountFlags(settings, vaultSettings);
}
case DOKANY:
return () -> getDokanyDefaultMountFlags(settings, vaultSettings);
default:
return () -> "--flags-supported-on-FUSE-or-DOKANY-only";
}
}
// see: https://github.com/osxfuse/osxfuse/wiki/Mount-options
private String getMacFuseDefaultMountFlags(Settings settings, VaultSettings vaultSettings) {
assert SystemUtils.IS_OS_MAC_OSX;
StringBuilder flags = new StringBuilder();
if (vaultSettings.usesReadOnlyMode().get()) {
flags.append(" -ordonly");
}
flags.append(" -ovolname=").append(vaultSettings.mountName().get());
flags.append(" -oatomic_o_trunc");
flags.append(" -oauto_xattr");
flags.append(" -oauto_cache");
flags.append(" -omodules=iconv,from_code=UTF-8,to_code=UTF-8-MAC"); // show files names in Unicode NFD encoding
flags.append(" -onoappledouble"); // vastly impacts performance for some reason...
flags.append(" -odefault_permissions"); // let the kernel assume permissions based on file attributes etc
try {
Path userHome = Paths.get(System.getProperty("user.home"));
int uid = (int) Files.getAttribute(userHome, "unix:uid");
int gid = (int) Files.getAttribute(userHome, "unix:gid");
flags.append(" -ouid=").append(uid);
flags.append(" -ogid=").append(gid);
} catch (IOException e) {
LOG.error("Could not read uid/gid from USER_HOME", e);
}
return flags.toString();
}
// see https://manpages.debian.org/testing/fuse/mount.fuse.8.en.html
private String getLinuxFuseDefaultMountFlags(Settings settings, VaultSettings vaultSettings) {
assert SystemUtils.IS_OS_LINUX;
StringBuilder flags = new StringBuilder();
if (vaultSettings.usesReadOnlyMode().get()) {
flags.append(" -oro");
}
flags.append(" -oauto_unmount");
try {
Path userHome = Paths.get(System.getProperty("user.home"));
int uid = (int) Files.getAttribute(userHome, "unix:uid");
int gid = (int) Files.getAttribute(userHome, "unix:gid");
flags.append(" -ouid=").append(uid);
flags.append(" -ogid=").append(gid);
} catch (IOException e) {
LOG.error("Could not read uid/gid from USER_HOME", e);
}
return flags.toString();
}
// see https://github.com/cryptomator/dokany-nio-adapter/blob/develop/src/main/java/org/cryptomator/frontend/dokany/MountUtil.java#L30-L34
private String getDokanyDefaultMountFlags(Settings settings, VaultSettings vaultSettings) {
assert SystemUtils.IS_OS_WINDOWS;
StringBuilder flags = new StringBuilder();
flags.append(" --options CURRENT_SESSION");
if (vaultSettings.usesReadOnlyMode().get()) {
flags.append(",WRITE_PROTECTION");
}
flags.append(" --threadCount 5");
flags.append(" --timeout 10000");
flags.append(" --allocation-unit-size 4096");
flags.append(" --sector-size 4096");
return flags.toString();
}
}

View File

@@ -22,7 +22,7 @@ public interface Volume {
* @param fs
* @throws IOException
*/
void mount(CryptoFileSystem fs) throws IOException, VolumeException;
void mount(CryptoFileSystem fs, String mountFlags) throws IOException, VolumeException;
void reveal() throws VolumeException;

View File

@@ -11,7 +11,6 @@ import org.cryptomator.frontend.webdav.servlet.WebDavServletController;
import javax.inject.Inject;
import javax.inject.Provider;
import java.net.InetAddress;
import java.net.UnknownHostException;
@@ -35,7 +34,7 @@ public class WebDavVolume implements Volume {
}
@Override
public void mount(CryptoFileSystem fs) throws VolumeException {
public void mount(CryptoFileSystem fs, String mountFlags) throws VolumeException {
if (server == null) {
server = serverProvider.get();
}

View File

@@ -8,6 +8,8 @@ package org.cryptomator.ui.model;
import org.apache.commons.lang3.CharUtils;
import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.FxApplicationScoped;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import java.nio.file.FileSystems;
@@ -21,6 +23,7 @@ import java.util.stream.StreamSupport;
@FxApplicationScoped
public final class WindowsDriveLetters {
private static final Logger LOG = LoggerFactory.getLogger(WindowsDriveLetters.class);
private static final Set<Character> D_TO_Z;
static {
@@ -35,10 +38,12 @@ public final class WindowsDriveLetters {
public Set<Character> getOccupiedDriveLetters() {
if (!SystemUtils.IS_OS_WINDOWS) {
throw new UnsupportedOperationException("This method is only defined for Windows file systems");
LOG.warn("Attempted to get occupied drive letters on non-Windows machine.");
return Set.of();
} else {
Iterable<Path> rootDirs = FileSystems.getDefault().getRootDirectories();
return StreamSupport.stream(rootDirs.spliterator(), false).map(Path::toString).map(CharUtils::toChar).map(Character::toUpperCase).collect(Collectors.toSet());
}
Iterable<Path> rootDirs = FileSystems.getDefault().getRootDirectories();
return StreamSupport.stream(rootDirs.spliterator(), false).map(Path::toString).map(CharUtils::toChar).map(Character::toUpperCase).collect(Collectors.toSet());
}
public Set<Character> getAvailableDriveLetters() {

View File

@@ -17,102 +17,85 @@
<?import javafx.scene.control.ProgressIndicator?>
<?import javafx.scene.control.Separator?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.text.TextFlow?>
<?import org.cryptomator.ui.controls.SecPasswordField?>
<GridPane fx:controller="org.cryptomator.ui.controllers.UnlockController" fx:id="root" vgap="12.0" hgap="12.0" prefWidth="400.0" xmlns:fx="http://javafx.com/fxml" cacheShape="true" cache="true">
<VBox fx:controller="org.cryptomator.ui.controllers.UnlockController" fx:id="root" spacing="12" alignment="BOTTOM_CENTER" xmlns:fx="http://javafx.com/fxml" prefWidth="400">
<padding>
<Insets top="24.0" right="12.0" bottom="24.0" left="12.0" />
<Insets top="24" right="12" bottom="24" left="12" />
</padding>
<columnConstraints>
<ColumnConstraints percentWidth="38.2"/>
<ColumnConstraints percentWidth="61.8"/>
</columnConstraints>
<!-- Password Field -->
<HBox spacing="12" alignment="BASELINE_LEFT">
<Label text="%unlock.label.password" HBox.hgrow="NEVER"/>
<SecPasswordField fx:id="passwordField" capslockWarning="%ctrl.secPasswordField.capsLocked" nonPrintableCharsWarning="%ctrl.secPasswordField.nonPrintableChars" maxWidth="Infinity" HBox.hgrow="ALWAYS"/>
</HBox>
<children>
<!-- Row 0 -->
<Label text="%unlock.label.password" GridPane.rowIndex="0" GridPane.columnIndex="0" cacheShape="true" cache="true" />
<SecPasswordField fx:id="passwordField" capslockWarning="%ctrl.secPasswordField.capsLocked" nonPrintableCharsWarning="%ctrl.secPasswordField.nonPrintableChars" GridPane.rowIndex="0" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
<!-- Unlock Button / Advanced Options Button -->
<HBox spacing="12" alignment="CENTER_RIGHT">
<Button fx:id="advancedOptionsButton" text="%unlock.button.advancedOptions.show" prefWidth="150.0" onAction="#didClickAdvancedOptionsButton"/>
<Button fx:id="unlockButton" text="%unlock.button.unlock" defaultButton="true" prefWidth="150.0" onAction="#didClickUnlockButton" disable="true" contentDisplay="TEXT_ONLY">
<graphic>
<ProgressIndicator progress="-1" prefWidth="12" prefHeight="12"/>
</graphic>
</Button>
</HBox>
<!-- Row 1 -->
<HBox GridPane.rowIndex="2" GridPane.columnIndex="0" GridPane.columnSpan="2" spacing="12.0" alignment="CENTER_RIGHT" cacheShape="true" cache="true">
<Button fx:id="advancedOptionsButton" text="%unlock.button.advancedOptions.show" prefWidth="150.0" onAction="#didClickAdvancedOptionsButton" cacheShape="true" cache="true" />
<Button fx:id="unlockButton" text="%unlock.button.unlock" defaultButton="true" prefWidth="150.0" onAction="#didClickUnlockButton" disable="true" cacheShape="true" cache="true" />
<!-- Status Text -->
<TextFlow prefWidth="400" textAlignment="LEFT">
<children>
<Text fx:id="messageText"/>
<Hyperlink fx:id="downloadsPageLink" text="%unlock.label.downloadsPageLink" managed="false" onAction="#didClickDownloadsLink"/>
</children>
</TextFlow>
<!-- Advanced Options -->
<VBox fx:id="advancedOptions" spacing="12" VBox.vgrow="ALWAYS" visible="false">
<Separator/>
<!-- Mount Name -->
<HBox spacing="12" alignment="BASELINE_LEFT">
<Label text="%unlock.label.mountName"/>
<TextField fx:id="mountName" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
</HBox>
<!-- Row 3 -->
<Text fx:id="messageText" cache="true" visible="true" GridPane.rowIndex="3" GridPane.columnIndex="0" GridPane.columnSpan="2"/>
<!-- Save Password -->
<CheckBox fx:id="savePassword" text="%unlock.label.savePassword" onAction="#didClickSavePasswordCheckbox"/>
<!-- Row 3 -->
<GridPane fx:id="advancedOptions" vgap="12.0" hgap="12.0" prefWidth="400.0" GridPane.rowIndex="3" GridPane.columnIndex="0" GridPane.columnSpan="2" visible="false" cacheShape="true" cache="true">
<!-- Auto Unlock -->
<CheckBox fx:id="unlockAfterStartup" text="%unlock.label.unlockAfterStartup"/>
<!-- Reveal Drive -->
<CheckBox fx:id="revealAfterMount" text="%unlock.label.revealAfterMount"/>
<!-- Read-Only -->
<CheckBox fx:id="useReadOnlyMode" text="%unlock.label.useReadOnlyMode"/>
<!-- Custom Mount Point -->
<CheckBox fx:id="useCustomMountPoint" text="%unlock.label.useOwnMountPath"/>
<HBox fx:id="customMountPoint" spacing="6" alignment="BASELINE_LEFT">
<padding>
<Insets top="24.0" />
<Insets left="20.0"/>
</padding>
<Label HBox.hgrow="ALWAYS" fx:id="customMountPointLabel" textOverrun="LEADING_ELLIPSIS"/>
<Button HBox.hgrow="NEVER" minWidth="-Infinity" text="&#xf434;" styleClass="ionicons" onAction="#didClickChooseCustomMountPoint" focusTraversable="true"/>
</HBox>
<columnConstraints>
<ColumnConstraints percentWidth="38.2"/>
<ColumnConstraints percentWidth="61.8"/>
</columnConstraints>
<!-- Mount Flags -->
<HBox spacing="12" alignment="BASELINE_LEFT">
<CheckBox fx:id="useCustomMountFlags" text="%unlock.label.useCustomMountFlags"/>
<TextField fx:id="mountFlags" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
</HBox>
<!-- Row 3.0 -->
<Separator GridPane.rowIndex="0" GridPane.columnIndex="0" GridPane.columnSpan="2" cacheShape="true" cache="true"/>
<HBox alignment="CENTER" prefWidth="400.0" GridPane.rowIndex="0" GridPane.columnIndex="0" GridPane.columnSpan="2" cacheShape="true" cache="true">
<Label text="%unlock.label.advancedHeading" style="-fx-background-color: COLOR_BACKGROUND;" cacheShape="true" cache="true">
<padding>
<Insets left="6.0" right="6.0"/>
</padding>
</Label>
</HBox>
<!-- Windows Drive Letter -->
<HBox spacing="12" alignment="BASELINE_LEFT">
<CheckBox fx:id="useCustomWinDriveLetter" text="%unlock.label.winDriveLetter" onAction="#didClickCustomWinDriveLetterCheckbox"/>
<ChoiceBox fx:id="winDriveLetter" HBox.hgrow="NEVER" maxWidth="Infinity"/>
</HBox>
</VBox>
<!-- Row 3.1 -->
<CheckBox GridPane.rowIndex="1" GridPane.columnIndex="0" GridPane.columnSpan="2" fx:id="savePassword" text="%unlock.label.savePassword" onAction="#didClickSavePasswordCheckbox" cacheShape="true" cache="true" />
<!-- Row 3.2 -->
<CheckBox GridPane.rowIndex="2" GridPane.columnIndex="0" GridPane.columnSpan="2" fx:id="unlockAfterStartup" text="%unlock.label.unlockAfterStartup" cacheShape="true" cache="true" />
<!-- Row 3.3 -->
<Label GridPane.rowIndex="3" GridPane.columnIndex="0" text="%unlock.label.mountName" cacheShape="true" cache="true" />
<TextField GridPane.rowIndex="3" GridPane.columnIndex="1" fx:id="mountName" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
<!-- Row 3.4 -->
<CheckBox GridPane.rowIndex="4" GridPane.columnIndex="0" GridPane.columnSpan="2" fx:id="revealAfterMount" text="%unlock.label.revealAfterMount" cacheShape="true" cache="true" />
<!-- Row 3.5 -->
<CheckBox GridPane.rowIndex="5" GridPane.columnIndex="0" GridPane.columnSpan="2" fx:id="useReadOnlyMode" text="%unlock.label.useReadOnlyMode" cacheShape="true" cache="true" />
<!-- Row 3.6 -->
<CheckBox GridPane.rowIndex="6" GridPane.columnIndex="0" GridPane.columnSpan="2" fx:id="useCustomMountPoint" text="%unlock.label.useOwnMountPath" cacheShape="true" cache="true" />
<!-- Row 3.7 Alt1 -->
<Label GridPane.rowIndex="7" GridPane.columnIndex="0" fx:id="winDriveLetterLabel" text="%unlock.label.winDriveLetter" cacheShape="true" cache="true" />
<ChoiceBox GridPane.rowIndex="7" GridPane.columnIndex="1" fx:id="winDriveLetter" GridPane.hgrow="ALWAYS" maxWidth="Infinity" cacheShape="true" cache="true" />
<!-- Row 3.7 Alt2 -->
<HBox fx:id="customMountPoint" GridPane.rowIndex="7" GridPane.columnIndex="0" GridPane.columnSpan="2" spacing="6" alignment="BASELINE_LEFT" cacheShape="true" cache="true">
<padding>
<Insets left="20.0" />
</padding>
<Label HBox.hgrow="ALWAYS" fx:id="customMountPointLabel" textOverrun="LEADING_ELLIPSIS" cacheShape="true" cache="true" />
<Button HBox.hgrow="NEVER" minWidth="-Infinity" text="&#xf434;" styleClass="ionicons" onAction="#didClickChooseCustomMountPoint" focusTraversable="true" cacheShape="true" cache="true" />
</HBox>
</GridPane>
<!-- Row 4 -->
<TextFlow GridPane.rowIndex="4" GridPane.columnIndex="0" GridPane.columnSpan="2" cacheShape="true" cache="true">
<GridPane.margin>
<Insets top="24.0"/>
</GridPane.margin>
<children>
<Hyperlink fx:id="downloadsPageLink" text="%unlock.label.downloadsPageLink" visible="false" onAction="#didClickDownloadsLink" cacheShape="true" cache="true" />
</children>
</TextFlow>
<!-- Row 5 -->
<VBox GridPane.rowIndex="5" GridPane.columnIndex="0" GridPane.columnSpan="2" spacing="12.0" alignment="CENTER" cacheShape="true" cache="true">
<ProgressIndicator progress="-1" fx:id="progressIndicator" cacheShape="true" cache="true" cacheHint="SPEED" />
<Text fx:id="progressText" cache="true" />
</VBox>
</children>
</GridPane>
</VBox>

View File

@@ -74,14 +74,14 @@ upgrade.version5toX.msg=This vault needs to be migrated to a newer format.\nPlea
unlock.label.password=Password
unlock.label.savePassword=Save Password
unlock.label.mountName=Drive Name
unlock.label.useCustomMountFlags=Custom Mount Flags
unlock.label.unlockAfterStartup=Auto-Unlock on Start (Experimental)
unlock.label.revealAfterMount=Reveal Drive
unlock.label.useReadOnlyMode=Read-Only
unlock.label.winDriveLetter=Drive Letter
unlock.label.useOwnMountPath=Use Custom Mount Point
unlock.label.useOwnMountPath=Custom Mount Point
unlock.label.chooseMountPath=Choose empty directory…
unlock.label.downloadsPageLink=All Cryptomator versions
unlock.label.advancedHeading=Advanced Options
unlock.button.unlock=Unlock Vault
unlock.button.advancedOptions.show=More Options
unlock.button.advancedOptions.hide=Less Options
@@ -89,7 +89,6 @@ unlock.savePassword.delete.confirmation.title=Delete Saved Password
unlock.savePassword.delete.confirmation.header=Do you really want to delete the saved password of this vault?
unlock.savePassword.delete.confirmation.content=The saved password of this vault will be immediately deleted from your system keychain. If you'd like to save your password again, you'd have to unlock your vault with the "Save Password" option enabled.
unlock.choicebox.winDriveLetter.auto=Assign automatically
unlock.pendingMessage.unlocking=Unlocking vault...
unlock.errorMessage.wrongPassword=Wrong password
unlock.errorMessage.unlockFailed=Unlock failed. See log file for details.
unlock.errorMessage.unsupportedVersion.vaultOlderThanSoftware=Unsupported vault. This vault has been created with an older version of Cryptomator.