diff --git a/src/main/java/org/cryptomator/common/ErrorCode.java b/src/main/java/org/cryptomator/common/ErrorCode.java
new file mode 100644
index 000000000..51fb355b6
--- /dev/null
+++ b/src/main/java/org/cryptomator/common/ErrorCode.java
@@ -0,0 +1,121 @@
+package org.cryptomator.common;
+
+import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
+
+import java.util.Locale;
+import java.util.Objects;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+/**
+ * Holds a throwable and provides a human-readable {@link #toString() three-component string representation}
+ * aiming to allow documentation and lookup of same or similar errors.
+ */
+public class ErrorCode {
+
+ private final static int A_PRIME = 31;
+ private final static int SEED = 0xdeadbeef;
+ public final static String DELIM = ":";
+
+ private final static int LATEST_FRAME = 1;
+ private final static int ALL_FRAMES = Integer.MAX_VALUE;
+
+ private final Throwable throwable;
+ private final Throwable rootCause;
+ private final int rootCauseSpecificFrames;
+
+ private ErrorCode(Throwable throwable, Throwable rootCause, int rootCauseSpecificFrames) {
+ this.throwable = Objects.requireNonNull(throwable);
+ this.rootCause = Objects.requireNonNull(rootCause);
+ this.rootCauseSpecificFrames = rootCauseSpecificFrames;
+ }
+
+ // visible for testing
+ String methodCode() {
+ return format(traceCode(rootCause, LATEST_FRAME));
+ }
+
+ // visible for testing
+ String rootCauseCode() {
+ return format(traceCode(rootCause, rootCauseSpecificFrames));
+ }
+
+ // visible for testing
+ String throwableCode() {
+ return format(traceCode(throwable, ALL_FRAMES));
+ }
+
+ /**
+ * Produces an error code consisting of three {@value DELIM}-separated components.
+ *
+ * A full match of the error code indicates the exact same throwable (to the extent possible
+ * without hash collisions). A partial match of the first or second component indicates related problems
+ * with the same root cause.
+ *
+ * @return A three-part error code
+ */
+ @Override
+ public String toString() {
+ return methodCode() + DELIM + rootCauseCode() + DELIM + throwableCode();
+ }
+
+ /**
+ * Deterministically creates an error code from the stack trace of the given cause.
+ *
+ * The code consists of three parts separated by {@value DELIM}:
+ *
+ *
The first part depends on the root cause and the method that threw it
+ *
The second part depends on the root cause and its stack trace
+ *
The third part depends on all the cause hierarchy
+ *
+ *
+ * Parts may be identical if the cause is the root cause or the root cause has just one single item in its stack trace.
+ *
+ * @param throwable The exception
+ * @return A three-part error code
+ */
+ public static ErrorCode of(Throwable throwable) {
+ var causalChain = Throwables.getCausalChain(throwable);
+ if (causalChain.size() > 1) {
+ var rootCause = causalChain.get(causalChain.size() - 1);
+ var parentOfRootCause = causalChain.get(causalChain.size() - 2);
+ var rootSpecificFrames = nonOverlappingFrames(parentOfRootCause.getStackTrace(), rootCause.getStackTrace());
+ return new ErrorCode(throwable, rootCause, rootSpecificFrames);
+ } else {
+ return new ErrorCode(throwable, throwable, ALL_FRAMES);
+ }
+ }
+
+ private String format(int value) {
+ // Cut off highest 12 bits (only leave 20 least significant bits) and XOR rest with cutoff
+ value = (value & 0xfffff) ^ (value >>> 20);
+ return Strings.padStart(Integer.toString(value, 32).toUpperCase(Locale.ROOT), 4, '0');
+ }
+
+ private int traceCode(Throwable e, int frameCount) {
+ int result = SEED;
+ if (e.getCause() != null) {
+ result = traceCode(e.getCause(), frameCount);
+ }
+ result = result * A_PRIME + e.getClass().getName().hashCode();
+ var stack = e.getStackTrace();
+ for (int i = 0; i < Math.min(stack.length, frameCount); i++) {
+ result = result * A_PRIME + stack[i].getClassName().hashCode();
+ result = result * A_PRIME + stack[i].getMethodName().hashCode();
+ }
+ return result;
+ }
+
+ private static int nonOverlappingFrames(StackTraceElement[] frames, StackTraceElement[] enclosingFrames) {
+ // Compute the number of elements in `frames` not contained in `enclosingFrames` by iterating backwards
+ // Result should usually be equal to the difference in size of both traces
+ var i = reverseStream(enclosingFrames).iterator();
+ return (int) reverseStream(frames).dropWhile(f -> i.hasNext() && i.next().equals(f)).count();
+ }
+
+ private static Stream reverseStream(T[] array) {
+ return IntStream.rangeClosed(1, array.length).mapToObj(i -> array[array.length - i]);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/cryptomator/ui/common/ErrorController.java b/src/main/java/org/cryptomator/ui/common/ErrorController.java
index 85b335b15..c75df26ce 100644
--- a/src/main/java/org/cryptomator/ui/common/ErrorController.java
+++ b/src/main/java/org/cryptomator/ui/common/ErrorController.java
@@ -1,22 +1,47 @@
package org.cryptomator.ui.common;
+import org.cryptomator.common.ErrorCode;
import org.cryptomator.common.Nullable;
import javax.inject.Inject;
import javax.inject.Named;
+import javafx.application.Application;
+import javafx.application.Platform;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
import javafx.fxml.FXML;
import javafx.scene.Scene;
+import javafx.scene.input.Clipboard;
+import javafx.scene.input.ClipboardContent;
import javafx.stage.Stage;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
public class ErrorController implements FxController {
+ private static final String SEARCH_URL_FORMAT = "https://github.com/cryptomator/cryptomator/discussions/categories/errors?discussions_q=category:Errors+%s";
+ private static final String REPORT_URL_FORMAT = "https://github.com/cryptomator/cryptomator/discussions/new?category=Errors&title=Error+%s&body=%s";
+ private static final String SEARCH_ERRORCODE_DELIM = " OR ";
+ private static final String REPORT_BODY_TEMPLATE = """
+
+
+ """;
+
+ private final Application application;
private final String stackTrace;
+ private final ErrorCode errorCode;
private final Scene previousScene;
private final Stage window;
+ private BooleanProperty copiedDetails = new SimpleBooleanProperty();
+
@Inject
- ErrorController(@Named("stackTrace") String stackTrace, @Nullable Scene previousScene, Stage window) {
+ ErrorController(Application application, @Named("stackTrace") String stackTrace, ErrorCode errorCode, @Nullable Scene previousScene, Stage window) {
+ this.application = application;
this.stackTrace = stackTrace;
+ this.errorCode = errorCode;
this.previousScene = previousScene;
this.window = window;
}
@@ -33,6 +58,31 @@ public class ErrorController implements FxController {
window.close();
}
+ @FXML
+ public void searchError() {
+ var searchTerm = URLEncoder.encode(getErrorCode().replace(ErrorCode.DELIM, SEARCH_ERRORCODE_DELIM), StandardCharsets.UTF_8);
+ application.getHostServices().showDocument(SEARCH_URL_FORMAT.formatted(searchTerm));
+ }
+
+ @FXML
+ public void reportError() {
+ var title = URLEncoder.encode(getErrorCode(), StandardCharsets.UTF_8);
+ var body = URLEncoder.encode(REPORT_BODY_TEMPLATE, StandardCharsets.UTF_8);
+ application.getHostServices().showDocument(REPORT_URL_FORMAT.formatted(title, body));
+ }
+
+ @FXML
+ public void copyDetails() {
+ ClipboardContent clipboardContent = new ClipboardContent();
+ clipboardContent.putString(getDetailText());
+ Clipboard.getSystemClipboard().setContent(clipboardContent);
+
+ copiedDetails.set(true);
+ CompletableFuture.delayedExecutor(2, TimeUnit.SECONDS, Platform::runLater).execute(() -> {
+ copiedDetails.set(false);
+ });
+ }
+
/* Getter/Setter */
public boolean isPreviousScenePresent() {
@@ -42,4 +92,20 @@ public class ErrorController implements FxController {
public String getStackTrace() {
return stackTrace;
}
+
+ public String getErrorCode() {
+ return errorCode.toString();
+ }
+
+ public String getDetailText() {
+ return "```\nError Code " + getErrorCode() + "\n" + getStackTrace() + "\n```";
+ }
+
+ public BooleanProperty copiedDetailsProperty() {
+ return copiedDetails;
+ }
+
+ public boolean getCopiedDetails() {
+ return copiedDetails.get();
+ }
}
diff --git a/src/main/java/org/cryptomator/ui/common/ErrorModule.java b/src/main/java/org/cryptomator/ui/common/ErrorModule.java
index d2515e661..01b8790c1 100644
--- a/src/main/java/org/cryptomator/ui/common/ErrorModule.java
+++ b/src/main/java/org/cryptomator/ui/common/ErrorModule.java
@@ -4,6 +4,7 @@ import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import dagger.multibindings.IntoMap;
+import org.cryptomator.common.ErrorCode;
import javax.inject.Named;
import javax.inject.Provider;
@@ -31,6 +32,11 @@ abstract class ErrorModule {
return baos.toString(StandardCharsets.UTF_8);
}
+ @Provides
+ static ErrorCode provideErrorCode(Throwable cause) {
+ return ErrorCode.of(cause);
+ }
+
@Binds
@IntoMap
@FxControllerKey(ErrorController.class)
diff --git a/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java b/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java
index 15b1718e1..66eda7556 100644
--- a/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java
+++ b/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java
@@ -12,6 +12,7 @@ public enum FontAwesome5Icon {
CARET_RIGHT("\uF0Da"), //
CHECK("\uF00C"), //
CLOCK("\uF017"), //
+ CLIPBOARD("\uF328"), //
COG("\uF013"), //
COGS("\uF085"), //
COPY("\uF0C5"), //
diff --git a/src/main/resources/fxml/error.fxml b/src/main/resources/fxml/error.fxml
index adcebdb67..4dbddc4c3 100644
--- a/src/main/resources/fxml/error.fxml
+++ b/src/main/resources/fxml/error.fxml
@@ -1,12 +1,15 @@
+
+
+
@@ -15,7 +18,7 @@
fx:controller="org.cryptomator.ui.common.ErrorController"
prefWidth="450"
prefHeight="450"
- spacing="12"
+ spacing="18"
alignment="TOP_CENTER">
@@ -27,12 +30,38 @@
-
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/i18n/strings.properties b/src/main/resources/i18n/strings.properties
index 44fab3640..86a0b5955 100644
--- a/src/main/resources/i18n/strings.properties
+++ b/src/main/resources/i18n/strings.properties
@@ -14,8 +14,11 @@ generic.button.done=Done
generic.button.next=Next
generic.button.print=Print
## Error
-generic.error.title=An unexpected error occurred
-generic.error.instruction=This should not have happened. Please report the error text below and include a description of what steps did lead to this error.
+generic.error.title=Error %s
+generic.error.instruction=Oops! Cryptomator didn't expect this to happen. You can look up existing solutions for this error. Or if it has not been reported yet, feel free to do so.
+generic.error.hyperlink.lookup=Look up this error
+generic.error.hyperlink.report=Report this error
+generic.error.technicalDetails=Details:
# Defaults
defaults.vault.vaultName=Vault
diff --git a/src/test/java/org/cryptomator/common/ErrorCodeTest.java b/src/test/java/org/cryptomator/common/ErrorCodeTest.java
new file mode 100644
index 000000000..34c0c2ec0
--- /dev/null
+++ b/src/test/java/org/cryptomator/common/ErrorCodeTest.java
@@ -0,0 +1,130 @@
+package org.cryptomator.common;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+public class ErrorCodeTest {
+
+ private static ErrorCode codeCaughtFrom(RunnableThrowingException runnable) {
+ try {
+ runnable.run();
+ throw new IllegalStateException("should not reach this point");
+ } catch (RuntimeException e) {
+ return ErrorCode.of(e);
+ }
+ }
+
+ @Test
+ @DisplayName("same exception leads to same error code")
+ public void testDifferentErrorCodes() {
+ var code1 = codeCaughtFrom(this::throwNpe);
+ var code2 = codeCaughtFrom(this::throwNpe);
+
+ Assertions.assertEquals(code1.toString(), code2.toString());
+ }
+
+ private void throwNpe() {
+ throwException(new NullPointerException());
+ }
+
+ private void throwException(RuntimeException e) throws RuntimeException {
+ throw e;
+ }
+
+ @DisplayName("when different cause but same root cause")
+ @Nested
+ public class SameRootCauseDifferentCause {
+
+ private final ErrorCode code1 = codeCaughtFrom(this::foo);
+ private final ErrorCode code2 = codeCaughtFrom(this::bar);
+
+ private void foo() throws IllegalArgumentException {
+ try {
+ throwNpe();
+ } catch (NullPointerException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ private void bar() throws IllegalStateException {
+ try {
+ throwNpe();
+ } catch (NullPointerException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @Test
+ @DisplayName("error codes are different")
+ public void testDifferentCodes() {
+ Assertions.assertNotEquals(code1.toString(), code2.toString());
+ }
+
+ @Test
+ @DisplayName("throwableCodes are different")
+ public void testDifferentThrowableCodes() {
+ Assertions.assertNotEquals(code1.throwableCode(), code2.throwableCode());
+ }
+
+ @Test
+ @DisplayName("rootCauseCodes are equal")
+ public void testSameRootCauseCodes() {
+ Assertions.assertEquals(code1.rootCauseCode(), code2.rootCauseCode());
+ }
+
+ @Test
+ @DisplayName("methodCode are equal")
+ public void testSameMethodCodes() {
+ Assertions.assertEquals(code1.methodCode(), code2.methodCode());
+ }
+
+ }
+
+ @DisplayName("when same cause but different call stack")
+ @Nested
+ public class SameCauseDifferentCallStack {
+
+ private final ErrorCode code1 = codeCaughtFrom(this::foo);
+ private final ErrorCode code2 = codeCaughtFrom(this::bar);
+
+ private void foo() throws NullPointerException {
+ try {
+ throwNpe();
+ } catch (NullPointerException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ private void bar() throws NullPointerException {
+ foo();
+ }
+
+ @Test
+ @DisplayName("error codes are different")
+ public void testDifferentCodes() {
+ Assertions.assertNotEquals(code1.toString(), code2.toString());
+ }
+
+ @Test
+ @DisplayName("throwableCodes are different")
+ public void testDifferentThrowableCodes() {
+ Assertions.assertNotEquals(code1.throwableCode(), code2.throwableCode());
+ }
+
+ @Test
+ @DisplayName("rootCauseCodes are equal")
+ public void testSameRootCauseCodes() {
+ Assertions.assertEquals(code1.rootCauseCode(), code2.rootCauseCode());
+ }
+
+ @Test
+ @DisplayName("methodCode are equal")
+ public void testSameMethodCodes() {
+ Assertions.assertEquals(code1.methodCode(), code2.methodCode());
+ }
+
+ }
+
+}
\ No newline at end of file