Show a Try Again dialog instead of the error window when the FSKit extension is switched off

This commit is contained in:
Tobias Hagemann
2026-10-14 23:19:16 +02:00
parent eae6babb4c
commit 6701921ee5
8 changed files with 198 additions and 11 deletions
+2 -2
View File
@@ -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();
}
}