Browse Source

Merge pull request #2996 from cryptomator/feature/2856-folder-mounts-win

Improve handling of folder-mounts on Win
JaniruTEC 1 year ago
parent
commit
221b4e85bc

+ 17 - 0
src/main/java/org/cryptomator/common/mount/HideawayNotDirectoryException.java

@@ -0,0 +1,17 @@
+package org.cryptomator.common.mount;
+
+import java.nio.file.Path;
+
+public class HideawayNotDirectoryException extends IllegalMountPointException {
+
+	private final Path hideaway;
+
+	public HideawayNotDirectoryException(Path path, Path hideaway) {
+		super(path, "Existing hideaway (" + hideaway.toString() + ") for mountpoint is not a directory: " + path.toString());
+		this.hideaway = hideaway;
+	}
+
+	public Path getHideaway() {
+		return hideaway;
+	}
+}

+ 18 - 2
src/main/java/org/cryptomator/common/mount/IllegalMountPointException.java

@@ -1,9 +1,25 @@
 package org.cryptomator.common.mount;
 package org.cryptomator.common.mount;
 
 
+import java.nio.file.Path;
+
+/**
+ * Indicates that validation or preparation of a mountpoint failed due to a configuration error or an invalid system state.<br>
+ * Instances of this exception are usually caught and displayed to the user in an appropriate fashion, e.g. by {@link org.cryptomator.ui.unlock.UnlockInvalidMountPointController UnlockInvalidMountPointController.}
+ */
 public class IllegalMountPointException extends IllegalArgumentException {
 public class IllegalMountPointException extends IllegalArgumentException {
 
 
-	public IllegalMountPointException(String msg) {
+	private final Path mountpoint;
+
+	public IllegalMountPointException(Path mountpoint) {
+		this(mountpoint, "The provided mountpoint has a problem: " + mountpoint.toString());
+	}
+
+	public IllegalMountPointException(Path mountpoint, String msg) {
 		super(msg);
 		super(msg);
+		this.mountpoint = mountpoint;
 	}
 	}
 
 
-}
+	public Path getMountpoint() {
+		return mountpoint;
+	}
+}

+ 10 - 0
src/main/java/org/cryptomator/common/mount/MountPointCleanupFailedException.java

@@ -0,0 +1,10 @@
+package org.cryptomator.common.mount;
+
+import java.nio.file.Path;
+
+public class MountPointCleanupFailedException extends IllegalMountPointException {
+
+	public MountPointCleanupFailedException(Path path) {
+		super(path, "Mountpoint could not be cleared: " + path.toString());
+	}
+}

+ 4 - 2
src/main/java/org/cryptomator/common/mount/MountPointInUseException.java

@@ -1,8 +1,10 @@
 package org.cryptomator.common.mount;
 package org.cryptomator.common.mount;
 
 
+import java.nio.file.Path;
+
 public class MountPointInUseException extends IllegalMountPointException {
 public class MountPointInUseException extends IllegalMountPointException {
 
 
-	public MountPointInUseException(String msg) {
-		super(msg);
+	public MountPointInUseException(Path path) {
+		super(path);
 	}
 	}
 }
 }

+ 10 - 0
src/main/java/org/cryptomator/common/mount/MountPointNotEmptyDirectoryException.java

@@ -0,0 +1,10 @@
+package org.cryptomator.common.mount;
+
+import java.nio.file.Path;
+
+public class MountPointNotEmptyDirectoryException extends IllegalMountPointException {
+
+	public MountPointNotEmptyDirectoryException(Path path, String msg) {
+		super(path, msg);
+	}
+}

+ 14 - 0
src/main/java/org/cryptomator/common/mount/MountPointNotExistingException.java

@@ -0,0 +1,14 @@
+package org.cryptomator.common.mount;
+
+import java.nio.file.Path;
+
+public class MountPointNotExistingException extends IllegalMountPointException {
+
+	public MountPointNotExistingException(Path path, String msg) {
+		super(path, msg);
+	}
+
+	public MountPointNotExistingException(Path path) {
+		super(path, "Mountpoint does not exist: " + path);
+	}
+}

+ 0 - 8
src/main/java/org/cryptomator/common/mount/MountPointNotExistsException.java

@@ -1,8 +0,0 @@
-package org.cryptomator.common.mount;
-
-public class MountPointNotExistsException extends IllegalMountPointException {
-
-	public MountPointNotExistsException(String msg) {
-		super(msg);
-	}
-}

+ 4 - 2
src/main/java/org/cryptomator/common/mount/MountPointNotSupportedException.java

@@ -1,8 +1,10 @@
 package org.cryptomator.common.mount;
 package org.cryptomator.common.mount;
 
 
+import java.nio.file.Path;
+
 public class MountPointNotSupportedException extends IllegalMountPointException {
 public class MountPointNotSupportedException extends IllegalMountPointException {
 
 
-	public MountPointNotSupportedException(String msg) {
-		super(msg);
+	public MountPointNotSupportedException(Path path, String msg) {
+		super(path, msg);
 	}
 	}
 }
 }

+ 0 - 12
src/main/java/org/cryptomator/common/mount/MountPointPreparationException.java

@@ -1,12 +0,0 @@
-package org.cryptomator.common.mount;
-
-public class MountPointPreparationException extends RuntimeException {
-
-	public MountPointPreparationException(String msg) {
-		super(msg);
-	}
-
-	public MountPointPreparationException(Throwable cause) {
-		super(cause);
-	}
-}

+ 72 - 30
src/main/java/org/cryptomator/common/mount/MountWithinParentUtil.java

@@ -5,13 +5,11 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
 
 
 import java.io.IOException;
 import java.io.IOException;
-import java.nio.file.DirectoryNotEmptyException;
 import java.nio.file.FileAlreadyExistsException;
 import java.nio.file.FileAlreadyExistsException;
 import java.nio.file.Files;
 import java.nio.file.Files;
 import java.nio.file.LinkOption;
 import java.nio.file.LinkOption;
-import java.nio.file.NoSuchFileException;
-import java.nio.file.NotDirectoryException;
 import java.nio.file.Path;
 import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
 
 
 public final class MountWithinParentUtil {
 public final class MountWithinParentUtil {
 
 
@@ -22,31 +20,33 @@ public final class MountWithinParentUtil {
 
 
 	private MountWithinParentUtil() {}
 	private MountWithinParentUtil() {}
 
 
-	static void prepareParentNoMountPoint(Path mountPoint) throws MountPointPreparationException {
+	static void prepareParentNoMountPoint(Path mountPoint) throws IllegalMountPointException, IOException {
 		Path hideaway = getHideaway(mountPoint);
 		Path hideaway = getHideaway(mountPoint);
-		var mpExists = Files.exists(mountPoint, LinkOption.NOFOLLOW_LINKS);
+		var mpState = getMountPointState(mountPoint);
 		var hideExists = Files.exists(hideaway, LinkOption.NOFOLLOW_LINKS);
 		var hideExists = Files.exists(hideaway, LinkOption.NOFOLLOW_LINKS);
 
 
-		//TODO: possible improvement by just deleting an _empty_ hideaway
-		if (mpExists && hideExists) { //both resources exist (whatever type)
-			throw new MountPointPreparationException(new FileAlreadyExistsException(hideaway.toString()));
-		} else if (!mpExists && !hideExists) { //neither mountpoint nor hideaway exist
-			throw new MountPointPreparationException(new NoSuchFileException(mountPoint.toString()));
-		} else if (!mpExists) { //only hideaway exists
-			checkIsDirectory(hideaway);
+		if (mpState == MountPointState.BROKEN_JUNCTION) {
+			LOG.info("Mountpoint \"{}\" is still a junction. Deleting it.", mountPoint);
+			Files.delete(mountPoint); //Throws if mountPoint is also a non-empty folder
+			mpState = MountPointState.NOT_EXISTING;
+		}
+
+		if (mpState == MountPointState.NOT_EXISTING && !hideExists) { //neither mountpoint nor hideaway exist
+			throw new MountPointNotExistingException(mountPoint);
+		} else if (mpState == MountPointState.NOT_EXISTING) { //only hideaway exists
+			checkIsHideawayDirectory(mountPoint, hideaway);
 			LOG.info("Mountpoint {} seems to be not properly cleaned up. Will be fixed on unmount.", mountPoint);
 			LOG.info("Mountpoint {} seems to be not properly cleaned up. Will be fixed on unmount.", mountPoint);
-			try {
-				if (SystemUtils.IS_OS_WINDOWS) {
-					Files.setAttribute(hideaway, WIN_HIDDEN_ATTR, true, LinkOption.NOFOLLOW_LINKS);
-				}
-			} catch (IOException e) {
-				throw new MountPointPreparationException(e);
+			if (SystemUtils.IS_OS_WINDOWS) {
+				Files.setAttribute(hideaway, WIN_HIDDEN_ATTR, true, LinkOption.NOFOLLOW_LINKS);
 			}
 			}
-		} else { //only mountpoint exists
+		} else {
+			assert mpState == MountPointState.EMPTY_DIR;
 			try {
 			try {
-				checkIsDirectory(mountPoint);
-				checkIsEmpty(mountPoint);
+				if (hideExists) { //... with hideaway
+					removeResidualHideaway(mountPoint, hideaway);
+				}
 
 
+				//... (now) without hideaway
 				Files.move(mountPoint, hideaway);
 				Files.move(mountPoint, hideaway);
 				if (SystemUtils.IS_OS_WINDOWS) {
 				if (SystemUtils.IS_OS_WINDOWS) {
 					Files.setAttribute(hideaway, WIN_HIDDEN_ATTR, true, LinkOption.NOFOLLOW_LINKS);
 					Files.setAttribute(hideaway, WIN_HIDDEN_ATTR, true, LinkOption.NOFOLLOW_LINKS);
@@ -54,30 +54,66 @@ public final class MountWithinParentUtil {
 				int attempts = 0;
 				int attempts = 0;
 				while (!Files.notExists(mountPoint)) {
 				while (!Files.notExists(mountPoint)) {
 					if (attempts >= 10) {
 					if (attempts >= 10) {
-						throw new MountPointPreparationException("Path " + mountPoint + " could not be cleared");
+						throw new MountPointCleanupFailedException(mountPoint);
 					}
 					}
 					Thread.sleep(1000);
 					Thread.sleep(1000);
 					attempts++;
 					attempts++;
 				}
 				}
-			} catch (IOException e) {
-				throw new MountPointPreparationException(e);
 			} catch (InterruptedException e) {
 			} catch (InterruptedException e) {
 				Thread.currentThread().interrupt();
 				Thread.currentThread().interrupt();
-				throw new MountPointPreparationException(e);
+				throw new RuntimeException(e);
 			}
 			}
 		}
 		}
 	}
 	}
 
 
+	//visible for testing
+	static MountPointState getMountPointState(Path path) throws IOException, IllegalMountPointException {
+		if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
+			return MountPointState.NOT_EXISTING;
+		}
+		if (!Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS).isOther()) {
+			checkIsMountPointDirectory(path);
+			checkIsMountPointEmpty(path);
+			return MountPointState.EMPTY_DIR;
+		}
+		if (Files.exists(path /* FOLLOW_LINKS */)) { //Both junction and target exist
+			throw new MountPointInUseException(path);
+		}
+		return MountPointState.BROKEN_JUNCTION;
+	}
+
+	//visible for testing
+	enum MountPointState {
+
+		NOT_EXISTING,
+
+		EMPTY_DIR,
+
+		BROKEN_JUNCTION;
+
+	}
+
+	//visible for testing
+	static void removeResidualHideaway(Path mountPoint, Path hideaway) throws IOException {
+		checkIsHideawayDirectory(mountPoint, hideaway);
+		Files.delete(hideaway); //Fails if not empty
+	}
+
 	static void cleanup(Path mountPoint) {
 	static void cleanup(Path mountPoint) {
 		Path hideaway = getHideaway(mountPoint);
 		Path hideaway = getHideaway(mountPoint);
 		try {
 		try {
 			waitForMountpointRestoration(mountPoint);
 			waitForMountpointRestoration(mountPoint);
+			if (Files.notExists(hideaway, LinkOption.NOFOLLOW_LINKS)) {
+				LOG.error("Unable to restore hidden directory to mountpoint \"{}\": Directory does not exist.", mountPoint);
+				return;
+			}
+
 			Files.move(hideaway, mountPoint);
 			Files.move(hideaway, mountPoint);
 			if (SystemUtils.IS_OS_WINDOWS) {
 			if (SystemUtils.IS_OS_WINDOWS) {
 				Files.setAttribute(mountPoint, WIN_HIDDEN_ATTR, false);
 				Files.setAttribute(mountPoint, WIN_HIDDEN_ATTR, false);
 			}
 			}
 		} catch (IOException e) {
 		} catch (IOException e) {
-			LOG.error("Unable to restore hidden directory to mountpoint {}.", mountPoint, e);
+			LOG.error("Unable to restore hidden directory to mountpoint \"{}\".", mountPoint, e);
 		}
 		}
 	}
 	}
 
 
@@ -99,16 +135,22 @@ public final class MountWithinParentUtil {
 		}
 		}
 	}
 	}
 
 
-	private static void checkIsDirectory(Path toCheck) throws MountPointPreparationException {
+	private static void checkIsMountPointDirectory(Path toCheck) throws IllegalMountPointException {
 		if (!Files.isDirectory(toCheck, LinkOption.NOFOLLOW_LINKS)) {
 		if (!Files.isDirectory(toCheck, LinkOption.NOFOLLOW_LINKS)) {
-			throw new MountPointPreparationException(new NotDirectoryException(toCheck.toString()));
+			throw new MountPointNotEmptyDirectoryException(toCheck, "Mountpoint is not a directory: " + toCheck);
+		}
+	}
+
+	private static void checkIsHideawayDirectory(Path mountPoint, Path hideawayToCheck) {
+		if (!Files.isDirectory(hideawayToCheck, LinkOption.NOFOLLOW_LINKS)) {
+			throw new HideawayNotDirectoryException(mountPoint, hideawayToCheck);
 		}
 		}
 	}
 	}
 
 
-	private static void checkIsEmpty(Path toCheck) throws MountPointPreparationException, IOException {
+	private static void checkIsMountPointEmpty(Path toCheck) throws IllegalMountPointException, IOException {
 		try (var dirStream = Files.list(toCheck)) {
 		try (var dirStream = Files.list(toCheck)) {
 			if (dirStream.findFirst().isPresent()) {
 			if (dirStream.findFirst().isPresent()) {
-				throw new MountPointPreparationException(new DirectoryNotEmptyException(toCheck.toString()));
+				throw new MountPointNotEmptyDirectoryException(toCheck, "Mountpoint directory is not empty: " + toCheck);
 			}
 			}
 		}
 		}
 	}
 	}

+ 4 - 4
src/main/java/org/cryptomator/common/mount/Mounter.java

@@ -99,7 +99,7 @@ public class Mounter {
 				var mpIsDriveLetter = userChosenMountPoint.toString().matches("[A-Z]:\\\\");
 				var mpIsDriveLetter = userChosenMountPoint.toString().matches("[A-Z]:\\\\");
 				if (mpIsDriveLetter) {
 				if (mpIsDriveLetter) {
 					if (driveLetters.getOccupied().contains(userChosenMountPoint)) {
 					if (driveLetters.getOccupied().contains(userChosenMountPoint)) {
-						throw new MountPointInUseException(userChosenMountPoint.toString());
+						throw new MountPointInUseException(userChosenMountPoint);
 					}
 					}
 				} else if (canMountToParent && !canMountToDir) {
 				} else if (canMountToParent && !canMountToDir) {
 					MountWithinParentUtil.prepareParentNoMountPoint(userChosenMountPoint);
 					MountWithinParentUtil.prepareParentNoMountPoint(userChosenMountPoint);
@@ -115,13 +115,13 @@ public class Mounter {
 							|| (!canMountToParent && !mpIsDriveLetter) //
 							|| (!canMountToParent && !mpIsDriveLetter) //
 							|| (!canMountToDir && !canMountToParent && !canMountToSystem && !canMountToDriveLetter);
 							|| (!canMountToDir && !canMountToParent && !canMountToSystem && !canMountToDriveLetter);
 					if (configNotSupported) {
 					if (configNotSupported) {
-						throw new MountPointNotSupportedException(e.getMessage());
+						throw new MountPointNotSupportedException(userChosenMountPoint, e.getMessage());
 					} else if (canMountToDir && !canMountToParent && !Files.exists(userChosenMountPoint)) {
 					} else if (canMountToDir && !canMountToParent && !Files.exists(userChosenMountPoint)) {
 						//mountpoint must exist
 						//mountpoint must exist
-						throw new MountPointNotExistsException(e.getMessage());
+						throw new MountPointNotExistingException(userChosenMountPoint, e.getMessage());
 					} else {
 					} else {
 						//TODO: add specific exception for !canMountToDir && canMountToParent && !Files.notExists(userChosenMountPoint)
 						//TODO: add specific exception for !canMountToDir && canMountToParent && !Files.notExists(userChosenMountPoint)
-						throw new IllegalMountPointException(e.getMessage());
+						throw new IllegalMountPointException(userChosenMountPoint, e.getMessage());
 					}
 					}
 				}
 				}
 			}
 			}

+ 79 - 21
src/main/java/org/cryptomator/ui/unlock/UnlockInvalidMountPointController.java

@@ -1,7 +1,12 @@
 package org.cryptomator.ui.unlock;
 package org.cryptomator.ui.unlock;
 
 
+import org.cryptomator.common.ObservableUtil;
+import org.cryptomator.common.mount.HideawayNotDirectoryException;
+import org.cryptomator.common.mount.IllegalMountPointException;
+import org.cryptomator.common.mount.MountPointCleanupFailedException;
 import org.cryptomator.common.mount.MountPointInUseException;
 import org.cryptomator.common.mount.MountPointInUseException;
-import org.cryptomator.common.mount.MountPointNotExistsException;
+import org.cryptomator.common.mount.MountPointNotEmptyDirectoryException;
+import org.cryptomator.common.mount.MountPointNotExistingException;
 import org.cryptomator.common.mount.MountPointNotSupportedException;
 import org.cryptomator.common.mount.MountPointNotSupportedException;
 import org.cryptomator.common.vaults.Vault;
 import org.cryptomator.common.vaults.Vault;
 import org.cryptomator.ui.common.FxController;
 import org.cryptomator.ui.common.FxController;
@@ -9,12 +14,15 @@ import org.cryptomator.ui.controls.FormattedLabel;
 import org.cryptomator.ui.fxapp.FxApplicationWindows;
 import org.cryptomator.ui.fxapp.FxApplicationWindows;
 import org.cryptomator.ui.preferences.SelectedPreferencesTab;
 import org.cryptomator.ui.preferences.SelectedPreferencesTab;
 import org.cryptomator.ui.vaultoptions.SelectedVaultOptionsTab;
 import org.cryptomator.ui.vaultoptions.SelectedVaultOptionsTab;
+import org.jetbrains.annotations.PropertyKey;
 
 
 import javax.inject.Inject;
 import javax.inject.Inject;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.value.ObservableValue;
 import javafx.fxml.FXML;
 import javafx.fxml.FXML;
 import javafx.stage.Stage;
 import javafx.stage.Stage;
+import java.nio.file.Path;
 import java.util.ResourceBundle;
 import java.util.ResourceBundle;
-import java.util.concurrent.atomic.AtomicReference;
 
 
 //At the current point in time only the CustomMountPointChooser may cause this window to be shown.
 //At the current point in time only the CustomMountPointChooser may cause this window to be shown.
 @UnlockScoped
 @UnlockScoped
@@ -23,28 +31,31 @@ public class UnlockInvalidMountPointController implements FxController {
 	private final Stage window;
 	private final Stage window;
 	private final Vault vault;
 	private final Vault vault;
 	private final FxApplicationWindows appWindows;
 	private final FxApplicationWindows appWindows;
-	private final ResourceBundle resourceBundle;
-	private final ExceptionType exceptionType;
-	private final String exceptionMessage;
+
+	private final ObservableValue<ExceptionType> exceptionType;
+	private final ObservableValue<Path> exceptionPath;
+	private final ObservableValue<String> exceptionMessage;
+	private final ObservableValue<Path> hideawayPath;
+	private final ObservableValue<String> format;
+	private final ObservableValue<Boolean> showPreferences;
+	private final ObservableValue<Boolean> showVaultOptions;
 
 
 	public FormattedLabel dialogDescription;
 	public FormattedLabel dialogDescription;
 
 
 	@Inject
 	@Inject
-	UnlockInvalidMountPointController(@UnlockWindow Stage window, @UnlockWindow Vault vault, @UnlockWindow AtomicReference<Throwable> unlockException, FxApplicationWindows appWindows, ResourceBundle resourceBundle) {
+	UnlockInvalidMountPointController(@UnlockWindow Stage window, @UnlockWindow Vault vault, @UnlockWindow ObjectProperty<IllegalMountPointException> illegalMountPointException, FxApplicationWindows appWindows, ResourceBundle resourceBundle) {
 		this.window = window;
 		this.window = window;
 		this.vault = vault;
 		this.vault = vault;
 		this.appWindows = appWindows;
 		this.appWindows = appWindows;
-		this.resourceBundle = resourceBundle;
 
 
-		var exc = unlockException.get();
-		this.exceptionType = getExceptionType(exc);
-		this.exceptionMessage = exc.getMessage();
-	}
+		this.exceptionType = illegalMountPointException.map(this::getExceptionType);
+		this.exceptionPath = illegalMountPointException.map(IllegalMountPointException::getMountpoint);
+		this.exceptionMessage = illegalMountPointException.map(IllegalMountPointException::getMessage);
+		this.hideawayPath = illegalMountPointException.map(e -> e instanceof HideawayNotDirectoryException haeExc ? haeExc.getHideaway() : null);
 
 
-	@FXML
-	public void initialize() {
-		dialogDescription.setFormat(resourceBundle.getString(exceptionType.translationKey));
-		dialogDescription.setArg1(exceptionMessage);
+		this.format = ObservableUtil.mapWithDefault(exceptionType, type -> resourceBundle.getString(type.translationKey), "");
+		this.showPreferences = ObservableUtil.mapWithDefault(exceptionType, type -> type.action == ButtonAction.SHOW_PREFERENCES, false);
+		this.showVaultOptions = ObservableUtil.mapWithDefault(exceptionType, type -> type.action == ButtonAction.SHOW_VAULT_OPTIONS, false);
 	}
 	}
 
 
 	@FXML
 	@FXML
@@ -67,8 +78,11 @@ public class UnlockInvalidMountPointController implements FxController {
 	private ExceptionType getExceptionType(Throwable unlockException) {
 	private ExceptionType getExceptionType(Throwable unlockException) {
 		return switch (unlockException) {
 		return switch (unlockException) {
 			case MountPointNotSupportedException x -> ExceptionType.NOT_SUPPORTED;
 			case MountPointNotSupportedException x -> ExceptionType.NOT_SUPPORTED;
-			case MountPointNotExistsException x -> ExceptionType.NOT_EXISTING;
+			case MountPointNotExistingException x -> ExceptionType.NOT_EXISTING;
 			case MountPointInUseException x -> ExceptionType.IN_USE;
 			case MountPointInUseException x -> ExceptionType.IN_USE;
+			case HideawayNotDirectoryException x -> ExceptionType.HIDEAWAY_NOT_DIR;
+			case MountPointCleanupFailedException x -> ExceptionType.COULD_NOT_BE_CLEARED;
+			case MountPointNotEmptyDirectoryException x -> ExceptionType.NOT_EMPTY_DIRECTORY;
 			default -> ExceptionType.GENERIC;
 			default -> ExceptionType.GENERIC;
 		};
 		};
 	}
 	}
@@ -78,12 +92,15 @@ public class UnlockInvalidMountPointController implements FxController {
 		NOT_SUPPORTED("unlock.error.customPath.description.notSupported", ButtonAction.SHOW_PREFERENCES),
 		NOT_SUPPORTED("unlock.error.customPath.description.notSupported", ButtonAction.SHOW_PREFERENCES),
 		NOT_EXISTING("unlock.error.customPath.description.notExists", ButtonAction.SHOW_VAULT_OPTIONS),
 		NOT_EXISTING("unlock.error.customPath.description.notExists", ButtonAction.SHOW_VAULT_OPTIONS),
 		IN_USE("unlock.error.customPath.description.inUse", ButtonAction.SHOW_VAULT_OPTIONS),
 		IN_USE("unlock.error.customPath.description.inUse", ButtonAction.SHOW_VAULT_OPTIONS),
+		HIDEAWAY_NOT_DIR("unlock.error.customPath.description.hideawayNotDir", ButtonAction.SHOW_VAULT_OPTIONS),
+		COULD_NOT_BE_CLEARED("unlock.error.customPath.description.couldNotBeCleaned", ButtonAction.SHOW_VAULT_OPTIONS),
+		NOT_EMPTY_DIRECTORY("unlock.error.customPath.description.notEmptyDir", ButtonAction.SHOW_VAULT_OPTIONS),
 		GENERIC("unlock.error.customPath.description.generic", ButtonAction.SHOW_PREFERENCES);
 		GENERIC("unlock.error.customPath.description.generic", ButtonAction.SHOW_PREFERENCES);
 
 
 		private final String translationKey;
 		private final String translationKey;
 		private final ButtonAction action;
 		private final ButtonAction action;
 
 
-		ExceptionType(String translationKey, ButtonAction action) {
+		ExceptionType(@PropertyKey(resourceBundle = "i18n.strings") String translationKey, ButtonAction action) {
 			this.translationKey = translationKey;
 			this.translationKey = translationKey;
 			this.action = action;
 			this.action = action;
 		}
 		}
@@ -91,6 +108,7 @@ public class UnlockInvalidMountPointController implements FxController {
 
 
 	private enum ButtonAction {
 	private enum ButtonAction {
 
 
+		//TODO Add option to show filesystem, e.g. for ExceptionType.HIDEAWAY_EXISTS
 		SHOW_PREFERENCES,
 		SHOW_PREFERENCES,
 		SHOW_VAULT_OPTIONS;
 		SHOW_VAULT_OPTIONS;
 
 
@@ -98,11 +116,51 @@ public class UnlockInvalidMountPointController implements FxController {
 
 
 	/* Getter */
 	/* Getter */
 
 
-	public boolean isShowPreferences() {
-		return exceptionType.action == ButtonAction.SHOW_PREFERENCES;
+	public Path getExceptionPath() {
+		return exceptionPath.getValue();
+	}
+
+	public ObservableValue<Path> exceptionPathProperty() {
+		return exceptionPath;
+	}
+
+	public String getFormat() {
+		return format.getValue();
+	}
+
+	public ObservableValue<String> formatProperty() {
+		return format;
+	}
+
+	public String getExceptionMessage() {
+		return exceptionMessage.getValue();
+	}
+
+	public ObservableValue<String> exceptionMessageProperty() {
+		return exceptionMessage;
+	}
+
+	public Path getHideawayPath() {
+		return hideawayPath.getValue();
+	}
+
+	public ObservableValue<Path> hideawayPathProperty() {
+		return hideawayPath;
+	}
+
+	public Boolean getShowPreferences() {
+		return showPreferences.getValue();
+	}
+
+	public ObservableValue<Boolean> showPreferencesProperty() {
+		return showPreferences;
+	}
+
+	public Boolean getShowVaultOptions() {
+		return showVaultOptions.getValue();
 	}
 	}
 
 
-	public boolean isShowVaultOptions() {
-		return exceptionType.action == ButtonAction.SHOW_VAULT_OPTIONS;
+	public ObservableValue<Boolean> showVaultOptionsProperty() {
+		return showVaultOptions;
 	}
 	}
 }
 }

+ 5 - 3
src/main/java/org/cryptomator/ui/unlock/UnlockModule.java

@@ -4,6 +4,7 @@ import dagger.Binds;
 import dagger.Module;
 import dagger.Module;
 import dagger.Provides;
 import dagger.Provides;
 import dagger.multibindings.IntoMap;
 import dagger.multibindings.IntoMap;
+import org.cryptomator.common.mount.IllegalMountPointException;
 import org.cryptomator.common.vaults.Vault;
 import org.cryptomator.common.vaults.Vault;
 import org.cryptomator.ui.common.DefaultSceneFactory;
 import org.cryptomator.ui.common.DefaultSceneFactory;
 import org.cryptomator.ui.common.FxController;
 import org.cryptomator.ui.common.FxController;
@@ -18,12 +19,13 @@ import org.jetbrains.annotations.Nullable;
 
 
 import javax.inject.Named;
 import javax.inject.Named;
 import javax.inject.Provider;
 import javax.inject.Provider;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
 import javafx.scene.Scene;
 import javafx.scene.Scene;
 import javafx.stage.Modality;
 import javafx.stage.Modality;
 import javafx.stage.Stage;
 import javafx.stage.Stage;
 import java.util.Map;
 import java.util.Map;
 import java.util.ResourceBundle;
 import java.util.ResourceBundle;
-import java.util.concurrent.atomic.AtomicReference;
 
 
 @Module(subcomponents = {KeyLoadingComponent.class})
 @Module(subcomponents = {KeyLoadingComponent.class})
 abstract class UnlockModule {
 abstract class UnlockModule {
@@ -61,8 +63,8 @@ abstract class UnlockModule {
 	@Provides
 	@Provides
 	@UnlockWindow
 	@UnlockWindow
 	@UnlockScoped
 	@UnlockScoped
-	static AtomicReference<Throwable> unlockException() {
-		return new AtomicReference<>();
+	static ObjectProperty<IllegalMountPointException> illegalMountPointException() {
+		return new SimpleObjectProperty<>();
 	}
 	}
 
 
 	@Provides
 	@Provides

+ 5 - 5
src/main/java/org/cryptomator/ui/unlock/UnlockWorkflow.java

@@ -17,11 +17,11 @@ import org.slf4j.LoggerFactory;
 
 
 import javax.inject.Inject;
 import javax.inject.Inject;
 import javafx.application.Platform;
 import javafx.application.Platform;
+import javafx.beans.property.ObjectProperty;
 import javafx.concurrent.Task;
 import javafx.concurrent.Task;
 import javafx.scene.Scene;
 import javafx.scene.Scene;
 import javafx.stage.Stage;
 import javafx.stage.Stage;
 import java.io.IOException;
 import java.io.IOException;
-import java.util.concurrent.atomic.AtomicReference;
 
 
 /**
 /**
  * A multi-step task that consists of background activities as well as user interaction.
  * A multi-step task that consists of background activities as well as user interaction.
@@ -40,10 +40,10 @@ public class UnlockWorkflow extends Task<Boolean> {
 	private final Lazy<Scene> invalidMountPointScene;
 	private final Lazy<Scene> invalidMountPointScene;
 	private final FxApplicationWindows appWindows;
 	private final FxApplicationWindows appWindows;
 	private final KeyLoadingStrategy keyLoadingStrategy;
 	private final KeyLoadingStrategy keyLoadingStrategy;
-	private final AtomicReference<Throwable> unlockFailedException;
+	private final ObjectProperty<IllegalMountPointException> illegalMountPointException;
 
 
 	@Inject
 	@Inject
-	UnlockWorkflow(@UnlockWindow Stage window, @UnlockWindow Vault vault, VaultService vaultService, @FxmlScene(FxmlFile.UNLOCK_SUCCESS) Lazy<Scene> successScene, @FxmlScene(FxmlFile.UNLOCK_INVALID_MOUNT_POINT) Lazy<Scene> invalidMountPointScene, FxApplicationWindows appWindows, @UnlockWindow KeyLoadingStrategy keyLoadingStrategy, @UnlockWindow AtomicReference<Throwable> unlockFailedException) {
+	UnlockWorkflow(@UnlockWindow Stage window, @UnlockWindow Vault vault, VaultService vaultService, @FxmlScene(FxmlFile.UNLOCK_SUCCESS) Lazy<Scene> successScene, @FxmlScene(FxmlFile.UNLOCK_INVALID_MOUNT_POINT) Lazy<Scene> invalidMountPointScene, FxApplicationWindows appWindows, @UnlockWindow KeyLoadingStrategy keyLoadingStrategy, @UnlockWindow ObjectProperty<IllegalMountPointException> illegalMountPointException) {
 		this.window = window;
 		this.window = window;
 		this.vault = vault;
 		this.vault = vault;
 		this.vaultService = vaultService;
 		this.vaultService = vaultService;
@@ -51,7 +51,7 @@ public class UnlockWorkflow extends Task<Boolean> {
 		this.invalidMountPointScene = invalidMountPointScene;
 		this.invalidMountPointScene = invalidMountPointScene;
 		this.appWindows = appWindows;
 		this.appWindows = appWindows;
 		this.keyLoadingStrategy = keyLoadingStrategy;
 		this.keyLoadingStrategy = keyLoadingStrategy;
-		this.unlockFailedException = unlockFailedException;
+		this.illegalMountPointException = illegalMountPointException;
 	}
 	}
 
 
 	@Override
 	@Override
@@ -79,7 +79,7 @@ public class UnlockWorkflow extends Task<Boolean> {
 
 
 	private void handleIllegalMountPointError(IllegalMountPointException impe) {
 	private void handleIllegalMountPointError(IllegalMountPointException impe) {
 		Platform.runLater(() -> {
 		Platform.runLater(() -> {
-			unlockFailedException.set(impe);
+			illegalMountPointException.set(impe);
 			window.setScene(invalidMountPointScene.get());
 			window.setScene(invalidMountPointScene.get());
 			window.show();
 			window.show();
 		});
 		});

+ 1 - 1
src/main/resources/fxml/unlock_invalid_mount_point.fxml

@@ -40,7 +40,7 @@
 				</padding>
 				</padding>
 			</Label>
 			</Label>
 
 
-			<FormattedLabel fx:id="dialogDescription" wrapText="true" textAlignment="LEFT"/>
+			<FormattedLabel fx:id="dialogDescription" wrapText="true" textAlignment="LEFT" format="${controller.format}" arg1="${controller.exceptionPath}" arg2="${controller.exceptionMessage}" arg3="${controller.hideawayPath}"/>
 
 
 			<Region VBox.vgrow="ALWAYS" minHeight="18"/>
 			<Region VBox.vgrow="ALWAYS" minHeight="18"/>
 			<ButtonBar buttonMinWidth="120" buttonOrder="+CI">
 			<ButtonBar buttonMinWidth="120" buttonOrder="+CI">

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

@@ -136,8 +136,11 @@ unlock.success.revealBtn=Reveal Drive
 unlock.error.customPath.message=Unable to mount vault to custom path
 unlock.error.customPath.message=Unable to mount vault to custom path
 unlock.error.customPath.description.notSupported=If you wish to keep using the custom path, please go to the preferences and select a volume type that supports it. Otherwise, go to the vault options and choose a supported mount point.
 unlock.error.customPath.description.notSupported=If you wish to keep using the custom path, please go to the preferences and select a volume type that supports it. Otherwise, go to the vault options and choose a supported mount point.
 unlock.error.customPath.description.notExists=The custom mount path does not exist. Either create it in your local filesystem or change it in the vault options.
 unlock.error.customPath.description.notExists=The custom mount path does not exist. Either create it in your local filesystem or change it in the vault options.
-unlock.error.customPath.description.inUse=Drive letter "%s" is already in use.
-unlock.error.customPath.description.generic=You have selected a custom mount path for this vault, but using it failed with the message: %s
+unlock.error.customPath.description.inUse=The drive letter or custom mount path "%s" is already in use.
+unlock.error.customPath.description.hideawayNotDir=The temporary, hidden file "%3$s" used for unlock could not be removed. Please check the file and then delete it manually.
+unlock.error.customPath.description.couldNotBeCleaned=Your vault could not be mounted to the path "%s". Please try again or choose a different path.
+unlock.error.customPath.description.notEmptyDir=The custom mount path "%s" is not an empty folder. Please choose an empty folder and try again.
+unlock.error.customPath.description.generic=You have selected a custom mount path for this vault, but using it failed with the message: %2$s
 ## Hub
 ## Hub
 hub.noKeychain.message=Unable to access device key
 hub.noKeychain.message=Unable to access device key
 hub.noKeychain.description=In order to unlock Hub vaults, a device key is required, which is secured using a keychain. To proceed, enable “%s” and select a keychain in the preferences.
 hub.noKeychain.description=In order to unlock Hub vaults, a device key is required, which is secured using a keychain. To proceed, enable “%s” and select a keychain in the preferences.

+ 241 - 0
src/test/java/org/cryptomator/common/mount/MountWithinParentUtilTest.java

@@ -0,0 +1,241 @@
+package org.cryptomator.common.mount;
+
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.DirectoryNotEmptyException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Objects;
+
+import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
+import static org.cryptomator.common.mount.MountWithinParentUtil.MountPointState.EMPTY_DIR;
+import static org.cryptomator.common.mount.MountWithinParentUtil.MountPointState.NOT_EXISTING;
+import static org.cryptomator.common.mount.MountWithinParentUtil.cleanup;
+import static org.cryptomator.common.mount.MountWithinParentUtil.getHideaway;
+import static org.cryptomator.common.mount.MountWithinParentUtil.getMountPointState;
+import static org.cryptomator.common.mount.MountWithinParentUtil.prepareParentNoMountPoint;
+import static org.cryptomator.common.mount.MountWithinParentUtil.removeResidualHideaway;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+class MountWithinParentUtilTest {
+
+	@Test
+	void testPrepareNeitherExist(@TempDir Path parentDir) {
+		assertThrows(MountPointNotExistingException.class, () -> {
+			prepareParentNoMountPoint(parentDir.resolve("mount"));
+		});
+	}
+
+	@Test
+	void testPrepareOnlyHideawayFileExists(@TempDir Path parentDir) throws IOException {
+		var mount = parentDir.resolve("mount");
+		var hideaway = getHideaway(mount);
+		Files.createFile(hideaway);
+
+		assertThrows(HideawayNotDirectoryException.class, () -> {
+			prepareParentNoMountPoint(mount);
+		});
+	}
+
+	@Test
+	void testPrepareOnlyHideawayDirExists(@TempDir Path parentDir) throws IOException {
+		var mount = parentDir.resolve("mount");
+		var hideaway = getHideaway(mount);
+		Files.createDirectory(hideaway);
+		assertFalse(isHidden(hideaway));
+
+		prepareParentNoMountPoint(mount);
+
+		assumeTrue(SystemUtils.IS_OS_WINDOWS);
+		assertTrue(isHidden(hideaway));
+	}
+
+	@Test
+	void testPrepareBothExistHideawayFile(@TempDir Path parentDir) throws IOException {
+		var mount = parentDir.resolve("mount");
+		var hideaway = getHideaway(mount);
+		Files.createFile(hideaway);
+		Files.createDirectory(mount);
+
+		assertThrows(HideawayNotDirectoryException.class, () -> {
+			prepareParentNoMountPoint(mount);
+		});
+	}
+
+	@Test
+	void testPrepareBothExistHideawayNotEmpty(@TempDir Path parentDir) throws IOException {
+		var mount = parentDir.resolve("mount");
+		var hideaway = getHideaway(mount);
+		Files.createDirectory(hideaway);
+		Files.createFile(hideaway.resolve("dummy"));
+		Files.createDirectory(mount);
+
+		assertThrows(DirectoryNotEmptyException.class, () -> {
+			prepareParentNoMountPoint(mount);
+		});
+	}
+
+	@Test
+	void testPrepareBothExistMountNotDir(@TempDir Path parentDir) throws IOException {
+		var mount = parentDir.resolve("mount");
+		var hideaway = getHideaway(mount);
+		Files.createFile(hideaway);
+		Files.createFile(mount);
+
+		assertThrows(MountPointNotEmptyDirectoryException.class, () -> {
+			prepareParentNoMountPoint(mount); //Must not throw something related to hideaway
+		});
+		assertTrue(Files.exists(hideaway, NOFOLLOW_LINKS));
+	}
+
+	@Test
+	void testPrepareBothExistMountNotEmpty(@TempDir Path parentDir) throws IOException {
+		var mount = parentDir.resolve("mount");
+		var hideaway = getHideaway(mount);
+		Files.createFile(hideaway);
+		Files.createDirectory(mount);
+		Files.createFile(mount.resolve("dummy"));
+
+		assertThrows(MountPointNotEmptyDirectoryException.class, () -> {
+			prepareParentNoMountPoint(mount); //Must not throw something related to hideaway
+		});
+		assertTrue(Files.exists(hideaway, NOFOLLOW_LINKS));
+	}
+
+	@Test
+	void testPrepareBothExist(@TempDir Path parentDir) throws IOException {
+		var mount = parentDir.resolve("mount");
+		var hideaway = getHideaway(mount);
+		Files.createDirectory(hideaway);
+		Files.createDirectory(mount);
+
+		prepareParentNoMountPoint(mount);
+		assertTrue(Files.notExists(mount, NOFOLLOW_LINKS));
+
+		assumeTrue(SystemUtils.IS_OS_WINDOWS);
+		assertTrue(isHidden(hideaway));
+	}
+
+	@Test
+	void testHandleMountPointFolderDoesNotExist(@TempDir Path parentDir) throws IOException {
+		assertSame(getMountPointState(parentDir.resolve("notExisting")), NOT_EXISTING);
+	}
+
+	@Test
+	void testHandleMountPointFolderIsFile(@TempDir Path parentDir) throws IOException {
+		var regularFile = parentDir.resolve("regularFile");
+		Files.createFile(regularFile);
+
+		assertThrows(MountPointNotEmptyDirectoryException.class, () -> {
+			getMountPointState(regularFile);
+		});
+	}
+
+	@Test
+	void testHandleMountPointFolderIsNotEmpty(@TempDir Path parentDir) throws IOException {
+		var regularFolder = parentDir.resolve("regularFolder");
+		var dummyFile = regularFolder.resolve("dummy");
+		Files.createDirectory(regularFolder);
+		Files.createFile(dummyFile);
+
+		assertThrows(MountPointNotEmptyDirectoryException.class, () -> {
+			getMountPointState(regularFolder);
+		});
+	}
+
+	@Test
+	void testHandleMountPointFolder(@TempDir Path parentDir) throws IOException {
+		//Sadly can't easily create files with "Other" attribute
+		var regularFolder = parentDir.resolve("regularFolder");
+		Files.createDirectory(regularFolder);
+
+		assertSame(getMountPointState(regularFolder), EMPTY_DIR);
+	}
+
+	@Test
+	void testRemoveResidualHideawayFile(@TempDir Path parentDir) throws IOException {
+		var hideaway = parentDir.resolve("hideaway");
+		Files.createFile(hideaway);
+
+		assertThrows(HideawayNotDirectoryException.class, () -> removeResidualHideaway(parentDir.resolve("mount"), hideaway));
+	}
+
+	@Test
+	void testRemoveResidualHideawayNotEmpty(@TempDir Path parentDir) throws IOException {
+		var hideaway = parentDir.resolve("hideaway");
+		Files.createDirectory(hideaway);
+		Files.createFile(hideaway.resolve("dummy"));
+
+		assertThrows(DirectoryNotEmptyException.class, () -> removeResidualHideaway(parentDir.resolve("mount"), hideaway));
+	}
+
+	@Test
+	void testCleanupNoHideaway(@TempDir Path parentDir) {
+		assertDoesNotThrow(() -> cleanup(parentDir.resolve("mount")));
+	}
+
+	@Test
+	void testCleanup(@TempDir Path parentDir) throws IOException {
+		var mount = parentDir.resolve("mount");
+		var hideaway = getHideaway(mount);
+		Files.createDirectory(hideaway);
+
+		cleanup(mount);
+		assertTrue(Files.exists(mount, NOFOLLOW_LINKS));
+		assertTrue(Files.notExists(hideaway, NOFOLLOW_LINKS));
+		assertFalse(isHidden(mount));
+	}
+
+	@Test
+	@EnabledOnOs(OS.WINDOWS)
+	void testGetHideawayRootDirWin() {
+		var mount = Path.of("C:\\mount");
+		var hideaway = getHideaway(mount);
+
+		assertEquals(mount.getParent(), hideaway.getParent());
+		assertEquals(mount.getParent().resolve(".~$mount.tmp"), hideaway);
+		assertEquals(mount.getParent().toAbsolutePath() + ".~$mount.tmp", hideaway.toAbsolutePath().toString());
+	}
+
+	@Test
+	@DisabledOnOs(OS.WINDOWS)
+	void testGetHideawayRootDirUnix() {
+		var mount = Path.of("/mount");
+		var hideaway = getHideaway(mount);
+
+		assertEquals(mount.getParent(), hideaway.getParent());
+		assertEquals(mount.getParent().resolve(".~$mount.tmp"), hideaway);
+		assertEquals(mount.getParent().toAbsolutePath() + ".~$mount.tmp", hideaway.toAbsolutePath().toString());
+	}
+
+	@Test
+	void testGetHideaway(@TempDir Path parentDir) {
+		var mount = parentDir.resolve("mount");
+		var hideaway = getHideaway(mount);
+
+		assertEquals(mount.getParent(), hideaway.getParent());
+		assertEquals(mount.getParent().resolve(".~$mount.tmp"), hideaway);
+		assertEquals(mount.getParent().toAbsolutePath() + File.separator + ".~$mount.tmp", hideaway.toAbsolutePath().toString());
+	}
+
+	private static boolean isHidden(Path path) throws IOException {
+		try {
+			return (boolean) Objects.requireNonNullElse(Files.getAttribute(path, "dos:hidden", NOFOLLOW_LINKS), false);
+		} catch (UnsupportedOperationException | IllegalMountPointException e) {
+			return false;
+		}
+	}
+}