mirror of
https://github.com/cryptomator/cryptomator.git
synced 2026-10-22 14:22:39 +00:00
Show a Try Again dialog instead of the error window when the FSKit extension is switched off
This commit is contained in:
@@ -40,8 +40,8 @@
|
||||
<cryptomator.integrations.mac.version>1.5.0</cryptomator.integrations.mac.version>
|
||||
<cryptomator.integrations.linux.version>1.8.0-beta1</cryptomator.integrations.linux.version>
|
||||
<cryptomator.fuse.version>6.0.1</cryptomator.fuse.version>
|
||||
<!-- on every bump, check the entitlements and provisioning profiles in dist/mac against "Embed the extension in an app" in the README of fskit-nio-adapter at that tag -->
|
||||
<cryptomator.fskit.version>0.3.1</cryptomator.fskit.version>
|
||||
<!-- on every bump, check the entitlements and provisioning profiles in dist/mac against "Embed the extension in an app" in the README of fskit-nio-adapter at that tag. Also run MounterTest on a Mac, because CI never runs its check that the adapter class Mounter matches by name still exists -->
|
||||
<cryptomator.fskit.version>0.4.0</cryptomator.fskit.version>
|
||||
<cryptomator.webdav.version>3.0.2</cryptomator.webdav.version>
|
||||
<cryptomator.webdav-servlet.version>1.2.12</cryptomator.webdav-servlet.version>
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.cryptomator.common.mount;
|
||||
|
||||
import org.cryptomator.integrations.mount.MountFailedException;
|
||||
|
||||
/**
|
||||
* Thrown by {@link Mounter} to indicate that mounting failed because the FSKit extension is switched off.
|
||||
*/
|
||||
public class FSKitExtensionSwitchedOffException extends MountFailedException {
|
||||
|
||||
public FSKitExtensionSwitchedOffException(MountFailedException cause) {
|
||||
super(cause.getMessage(), cause);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ public class Mounter {
|
||||
"org.cryptomator.frontend.fuse.mount.FuseTMountProvider", Set.of("org.cryptomator.frontend.fuse.mount.MacFuseMountProvider")
|
||||
);
|
||||
|
||||
// matched by name, as fskit-nio-adapter is a dependency only in the mac build
|
||||
static final String FSKIT_EXTENSION_SWITCHED_OFF_EXCEPTION = "org.cryptomator.frontend.fskit.mount.ExtensionSwitchedOffException";
|
||||
|
||||
private final Environment env;
|
||||
private final Settings settings;
|
||||
private final WindowsDriveLetters driveLetters;
|
||||
@@ -171,7 +174,14 @@ public class Mounter {
|
||||
LOG.debug("Using mount service {} for mounting vault {}", mountService.getClass().getName(), vaultSettings.displayName);
|
||||
var internal = new SettledMounter(mountService, builder, vaultSettings); // FIXME: no need for an inner class
|
||||
var cleanup = internal.prepare();
|
||||
return new MountHandle(builder.mount(), mountService.hasCapability(UNMOUNT_FORCED), mountService.hasCapability(WHEN_TERMINATED), cleanup);
|
||||
try {
|
||||
return new MountHandle(builder.mount(), mountService.hasCapability(UNMOUNT_FORCED), mountService.hasCapability(WHEN_TERMINATED), cleanup);
|
||||
} catch (MountFailedException e) {
|
||||
if (e.getClass().getName().equals(FSKIT_EXTENSION_SWITCHED_OFF_EXCEPTION)) {
|
||||
throw new FSKitExtensionSwitchedOffException(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConflictingMountService(MountService service) {
|
||||
|
||||
@@ -140,6 +140,19 @@ public class Dialogs {
|
||||
.setCancelAction(Stage::close);
|
||||
}
|
||||
|
||||
public SimpleDialog.Builder prepareFSKitExtensionSwitchedOffDialog(Stage window, Vault vault, Consumer<Stage> okAction) {
|
||||
return createDialogBuilder() //
|
||||
.setOwner(window) //
|
||||
.setTitleKey("unlock.error.title", vault.getDisplayName()) //
|
||||
.setMessageKey("unlock.error.extensionSwitchedOff.message") //
|
||||
.setDescriptionKey("unlock.error.extensionSwitchedOff.description") //
|
||||
.setIcon(FontAwesome5Icon.EXCLAMATION) //
|
||||
.setOkButtonKey("unlock.error.extensionSwitchedOff.retryBtn") //
|
||||
.setCancelButtonKey("generic.button.cancel") //
|
||||
.setOkAction(okAction) //
|
||||
.setCancelAction(Stage::close);
|
||||
}
|
||||
|
||||
public SimpleDialog.Builder prepareNotAVaultDirectoryDialog(Stage window, NotAVaultDirectoryException e) {
|
||||
String descriptionKey = switch (e.notAVaultReason()) {
|
||||
case MISSING_DATA_DIR -> "addvaultwizard.existing.notAVault.description.missingDataDir";
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.cryptomator.ui.unlock;
|
||||
|
||||
import dagger.Lazy;
|
||||
import org.cryptomator.common.mount.ConflictingMountServiceException;
|
||||
import org.cryptomator.common.mount.FSKitExtensionSwitchedOffException;
|
||||
import org.cryptomator.common.mount.IllegalMountPointException;
|
||||
import org.cryptomator.common.vaults.Vault;
|
||||
import org.cryptomator.common.vaults.VaultState;
|
||||
@@ -14,10 +15,12 @@ import org.cryptomator.ui.dialogs.Dialogs;
|
||||
import org.cryptomator.ui.fxapp.FxApplicationWindows;
|
||||
import org.cryptomator.ui.fxapp.PrimaryStage;
|
||||
import org.cryptomator.ui.keyloading.KeyLoadingStrategy;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.concurrent.Task;
|
||||
@@ -41,6 +44,7 @@ public class UnlockWorkflow extends Task<Void> {
|
||||
|
||||
private final Stage mainWindow;
|
||||
private final Stage window;
|
||||
private final Stage owner;
|
||||
private final Vault vault;
|
||||
private final VaultService vaultService;
|
||||
private final Lazy<Scene> successScene;
|
||||
@@ -54,6 +58,7 @@ public class UnlockWorkflow extends Task<Void> {
|
||||
@Inject
|
||||
UnlockWorkflow(@PrimaryStage Stage mainWindow, //
|
||||
@UnlockWindow Stage window, //
|
||||
@Nullable @Named("unlockWindowOwner") Stage owner, //
|
||||
@UnlockWindow Vault vault, //
|
||||
VaultService vaultService, //
|
||||
@FxmlScene(FxmlFile.UNLOCK_SUCCESS) Lazy<Scene> successScene, //
|
||||
@@ -65,6 +70,7 @@ public class UnlockWorkflow extends Task<Void> {
|
||||
Dialogs dialogs) {
|
||||
this.mainWindow = mainWindow;
|
||||
this.window = window;
|
||||
this.owner = owner;
|
||||
this.vault = vault;
|
||||
this.vaultService = vaultService;
|
||||
this.successScene = successScene;
|
||||
@@ -157,6 +163,7 @@ public class UnlockWorkflow extends Task<Void> {
|
||||
case IllegalMountPointException e -> handleIllegalMountPointError(e);
|
||||
case ConflictingMountServiceException _ -> handleConflictingMountServiceException();
|
||||
case ReadOnlyFileSystemException _ -> handleReadOnlyFileSystem();
|
||||
case FSKitExtensionSwitchedOffException _ -> handleExtensionSwitchedOff();
|
||||
default -> handleGenericError(throwable);
|
||||
}
|
||||
}
|
||||
@@ -164,20 +171,32 @@ public class UnlockWorkflow extends Task<Void> {
|
||||
private void handleReadOnlyFileSystem() {
|
||||
var readOnlyDialog = dialogs.prepareRetryIfReadonlyDialog(mainWindow, stage -> {
|
||||
stage.close();
|
||||
this.retry();
|
||||
vault.getVaultSettings().usesReadOnlyMode.set(true);
|
||||
restartUnlock(mainWindow);
|
||||
}).build();
|
||||
|
||||
Platform.runLater(readOnlyDialog::showAndWait);
|
||||
}
|
||||
|
||||
private void retry() {
|
||||
private void handleExtensionSwitchedOff() {
|
||||
var extensionSwitchedOffDialog = dialogs.prepareFSKitExtensionSwitchedOffDialog(mainWindow, vault, stage -> {
|
||||
stage.close();
|
||||
restartUnlock(owner);
|
||||
}).build();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
window.close();
|
||||
extensionSwitchedOffDialog.showAndWait();
|
||||
});
|
||||
}
|
||||
|
||||
private void restartUnlock(@Nullable Stage unlockOwner) {
|
||||
try {
|
||||
vault.getVaultSettings().usesReadOnlyMode.set(true);
|
||||
var isLocked = vault.stateProperty().awaitState(VaultState.Value.LOCKED, 5, TimeUnit.SECONDS);
|
||||
if (!isLocked) {
|
||||
LOG.error("Vault did not changed to LOCKED state within 5 seconds. Aborting unlock retry.");
|
||||
} else {
|
||||
appWindows.startUnlockWorkflow(vault, mainWindow);
|
||||
appWindows.startUnlockWorkflow(vault, unlockOwner);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
LOG.error("Waiting for LOCKED vault state was interrupted. Aborting unlock retry.", e);
|
||||
|
||||
@@ -164,6 +164,9 @@ unlock.error.customPath.description.notEmptyDir=The custom mount path "%s" is no
|
||||
unlock.error.customPath.description.generic=You have selected a custom mount path for this vault, but using it failed with the message: %2$s
|
||||
unlock.error.restartRequired.message=Unable to unlock vault
|
||||
unlock.error.restartRequired.description=Change the volume type in vault options or restart Cryptomator.
|
||||
unlock.error.extensionSwitchedOff.message=File system extension switched off
|
||||
unlock.error.extensionSwitchedOff.description=To use the FSKit volume type, switch on the Cryptomator extension in System Settings > General > Login Items & Extensions > File System Extensions, then try again.
|
||||
unlock.error.extensionSwitchedOff.retryBtn=Try Again
|
||||
unlock.error.title=Unlock "%s" failed
|
||||
## Hub
|
||||
hub.noKeychain.message=Unable to access device key
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package org.cryptomator.common.mount;
|
||||
|
||||
import net.bytebuddy.ByteBuddy;
|
||||
import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
|
||||
import org.cryptomator.common.Environment;
|
||||
import org.cryptomator.common.settings.Settings;
|
||||
import org.cryptomator.common.settings.SettingsProvider;
|
||||
import org.cryptomator.common.settings.VaultSettings;
|
||||
import org.cryptomator.integrations.mount.MountBuilder;
|
||||
import org.cryptomator.integrations.mount.MountCapability;
|
||||
import org.cryptomator.integrations.mount.MountFailedException;
|
||||
import org.cryptomator.integrations.mount.MountService;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledOnOs;
|
||||
import org.junit.jupiter.api.condition.OS;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
@@ -26,10 +31,20 @@ class MounterTest {
|
||||
Path tmpDir;
|
||||
|
||||
private Mounter.MountHandle mount(Set<MountCapability> capabilities) throws Exception {
|
||||
return mount(capabilities, Mockito.mock(MountBuilder.class));
|
||||
}
|
||||
|
||||
private Mounter.MountHandle mountFailingWith(MountFailedException exception) throws Exception {
|
||||
var builder = Mockito.mock(MountBuilder.class);
|
||||
Mockito.when(builder.mount()).thenThrow(exception);
|
||||
return mount(EnumSet.of(MountCapability.MOUNT_TO_SYSTEM_CHOSEN_PATH), builder);
|
||||
}
|
||||
|
||||
private Mounter.MountHandle mount(Set<MountCapability> capabilities, MountBuilder builder) throws Exception {
|
||||
var mountService = Mockito.mock(MountService.class);
|
||||
Mockito.when(mountService.capabilities()).thenReturn(capabilities);
|
||||
Mockito.when(mountService.hasCapability(Mockito.any())).thenAnswer(invocation -> capabilities.contains(invocation.getArgument(0, MountCapability.class)));
|
||||
Mockito.when(mountService.forFileSystem(Mockito.any())).thenReturn(Mockito.mock(MountBuilder.class));
|
||||
Mockito.when(mountService.forFileSystem(Mockito.any())).thenReturn(builder);
|
||||
var env = Mockito.mock(Environment.class);
|
||||
Mockito.when(env.getMountPointsDir()).thenReturn(Optional.of(tmpDir));
|
||||
var settings = Settings.create(Mockito.mock(SettingsProvider.class), env);
|
||||
@@ -53,4 +68,35 @@ class MounterTest {
|
||||
Assertions.assertTrue(mountHandle.supportsUnmountForced());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMountTranslatesExtensionSwitchedOff() throws Exception {
|
||||
var exceptionClass = new ByteBuddy().subclass(MountFailedException.class) //
|
||||
.name(Mounter.FSKIT_EXTENSION_SWITCHED_OFF_EXCEPTION) //
|
||||
.make() //
|
||||
.load(getClass().getClassLoader(), ClassLoadingStrategy.Default.CHILD_FIRST) //
|
||||
.getLoaded();
|
||||
var extensionSwitchedOff = exceptionClass.getConstructor(String.class).newInstance("switched off");
|
||||
|
||||
var thrown = Assertions.assertThrows(FSKitExtensionSwitchedOffException.class, () -> mountFailingWith(extensionSwitchedOff));
|
||||
|
||||
Assertions.assertSame(extensionSwitchedOff, thrown.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMountRethrowsOtherMountFailures() {
|
||||
var mountFailed = new MountFailedException("failed");
|
||||
|
||||
var thrown = Assertions.assertThrows(MountFailedException.class, () -> mountFailingWith(mountFailed));
|
||||
|
||||
Assertions.assertSame(mountFailed, thrown);
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnabledOnOs(OS.MAC)
|
||||
public void testExtensionSwitchedOffExceptionExistsInAdapter() throws Exception {
|
||||
var exceptionClass = Class.forName(Mounter.FSKIT_EXTENSION_SWITCHED_OFF_EXCEPTION);
|
||||
|
||||
Assertions.assertTrue(MountFailedException.class.isAssignableFrom(exceptionClass));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
package org.cryptomator.ui.unlock;
|
||||
|
||||
import dagger.Lazy;
|
||||
import org.cryptomator.common.mount.FSKitExtensionSwitchedOffException;
|
||||
import org.cryptomator.common.settings.VaultSettings;
|
||||
import org.cryptomator.common.settings.WhenUnlocked;
|
||||
import org.cryptomator.common.vaults.Vault;
|
||||
import org.cryptomator.common.vaults.VaultState;
|
||||
import org.cryptomator.integrations.mount.MountFailedException;
|
||||
import org.cryptomator.ui.common.VaultService;
|
||||
import org.cryptomator.ui.dialogs.Dialogs;
|
||||
import org.cryptomator.ui.dialogs.SimpleDialog;
|
||||
import org.cryptomator.ui.fxapp.FxApplicationWindows;
|
||||
import org.cryptomator.ui.keyloading.KeyLoadingStrategy;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
@@ -19,23 +24,38 @@ import javafx.application.Platform;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.stage.Stage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.ReadOnlyFileSystemException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
class UnlockWorkflowTest {
|
||||
|
||||
private final Vault vault = Mockito.mock(Vault.class);
|
||||
private final VaultService vaultService = Mockito.mock(VaultService.class);
|
||||
private final KeyLoadingStrategy keyLoadingStrategy = Mockito.mock(KeyLoadingStrategy.class);
|
||||
private final Dialogs dialogs = Mockito.mock(Dialogs.class);
|
||||
private final FxApplicationWindows appWindows = Mockito.mock(FxApplicationWindows.class);
|
||||
private final Stage mainWindow = Mockito.mock(Stage.class);
|
||||
private final Stage window = Mockito.mock(Stage.class);
|
||||
private final Stage owner = Mockito.mock(Stage.class);
|
||||
private final SimpleDialog.Builder dialogBuilder = Mockito.mock(SimpleDialog.Builder.class);
|
||||
private final SimpleDialog dialog = Mockito.mock(SimpleDialog.class);
|
||||
private final VaultSettings vaultSettings = VaultSettings.withRandomId();
|
||||
private MockedStatic<Platform> platform;
|
||||
private UnlockWorkflow workflow;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
public void setup() {
|
||||
platform = Mockito.mockStatic(Platform.class);
|
||||
var vaultSettings = VaultSettings.withRandomId();
|
||||
vaultSettings.actionAfterUnlock.set(WhenUnlocked.REVEAL);
|
||||
Mockito.when(vault.getVaultSettings()).thenReturn(vaultSettings);
|
||||
workflow = new UnlockWorkflow(Mockito.mock(Stage.class), Mockito.mock(Stage.class), vault, vaultService, Mockito.mock(Lazy.class), Mockito.mock(Lazy.class), Mockito.mock(Lazy.class), Mockito.mock(FxApplicationWindows.class), keyLoadingStrategy, new SimpleObjectProperty<>(), Mockito.mock(Dialogs.class));
|
||||
Mockito.when(dialogBuilder.build()).thenReturn(dialog);
|
||||
workflow = createWorkflow(owner);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private UnlockWorkflow createWorkflow(Stage unlockOwner) {
|
||||
return new UnlockWorkflow(mainWindow, window, unlockOwner, vault, vaultService, Mockito.mock(Lazy.class), Mockito.mock(Lazy.class), Mockito.mock(Lazy.class), appWindows, keyLoadingStrategy, new SimpleObjectProperty<>(), dialogs);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -89,4 +109,67 @@ class UnlockWorkflowTest {
|
||||
Mockito.verify(vaultService, Mockito.never()).reveal(vault);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtensionSwitchedOffShowsDialog() throws Exception {
|
||||
Mockito.when(dialogs.prepareFSKitExtensionSwitchedOffDialog(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(dialogBuilder);
|
||||
|
||||
runFailingWith(new FSKitExtensionSwitchedOffException(new MountFailedException("switched off")));
|
||||
|
||||
Mockito.verify(window).close();
|
||||
Mockito.verify(dialog).showAndWait();
|
||||
Mockito.verify(appWindows, Mockito.never()).showErrorWindow(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtensionSwitchedOffRetryRestartsUnlockWithOwner() throws Exception {
|
||||
assertExtensionSwitchedOffRetryRestartsUnlockWith(owner);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExtensionSwitchedOffRetryRestartsUnlockWithoutOwner() throws Exception {
|
||||
assertExtensionSwitchedOffRetryRestartsUnlockWith(null);
|
||||
}
|
||||
|
||||
private void assertExtensionSwitchedOffRetryRestartsUnlockWith(Stage unlockOwner) throws Exception {
|
||||
workflow = createWorkflow(unlockOwner);
|
||||
Mockito.when(dialogs.prepareFSKitExtensionSwitchedOffDialog(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(dialogBuilder);
|
||||
runFailingWith(new FSKitExtensionSwitchedOffException(new MountFailedException("switched off")));
|
||||
ArgumentCaptor<Consumer<Stage>> okAction = ArgumentCaptor.captor();
|
||||
Mockito.verify(dialogs).prepareFSKitExtensionSwitchedOffDialog(Mockito.any(), Mockito.any(), okAction.capture());
|
||||
var dialogStage = Mockito.mock(Stage.class);
|
||||
|
||||
okAction.getValue().accept(dialogStage);
|
||||
|
||||
Mockito.verify(dialogStage).close();
|
||||
Mockito.verify(appWindows).startUnlockWorkflow(vault, unlockOwner);
|
||||
Assertions.assertFalse(vaultSettings.usesReadOnlyMode.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReadOnlyRetryRestartsUnlockWithMainWindow() throws Exception {
|
||||
Mockito.when(dialogs.prepareRetryIfReadonlyDialog(Mockito.any(), Mockito.any())).thenReturn(dialogBuilder);
|
||||
runFailingWith(new ReadOnlyFileSystemException());
|
||||
ArgumentCaptor<Consumer<Stage>> okAction = ArgumentCaptor.captor();
|
||||
Mockito.verify(dialogs).prepareRetryIfReadonlyDialog(Mockito.any(), okAction.capture());
|
||||
|
||||
okAction.getValue().accept(Mockito.mock(Stage.class));
|
||||
|
||||
Assertions.assertTrue(vaultSettings.usesReadOnlyMode.get());
|
||||
Mockito.verify(appWindows).startUnlockWorkflow(vault, mainWindow);
|
||||
}
|
||||
|
||||
private void runFailingWith(Exception exception) throws Exception {
|
||||
platform.when(Platform::isFxApplicationThread).thenReturn(true);
|
||||
platform.when(() -> Platform.runLater(Mockito.any())).thenAnswer(invocation -> {
|
||||
invocation.getArgument(0, Runnable.class).run();
|
||||
return null;
|
||||
});
|
||||
var vaultState = Mockito.mock(VaultState.class);
|
||||
Mockito.when(vaultState.awaitState(VaultState.Value.LOCKED, 5, TimeUnit.SECONDS)).thenReturn(true);
|
||||
Mockito.when(vault.stateProperty()).thenReturn(vaultState);
|
||||
Mockito.doThrow(exception).when(keyLoadingStrategy).use(Mockito.any());
|
||||
|
||||
workflow.run();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user