mirror of
https://github.com/cryptomator/cryptomator.git
synced 2026-08-19 21:56:07 +00:00
Added error codes to error screen (#1741)
Added error codes based on a translation of GeneratedErrorCode.kt (Cryptomator Android) into Java: Source of GeneratedErrorCode.kt: https://github.com/cryptomator/android/blob/3ae90ab521a4aa69f394c0490f27e9db6106ce0e/presentation/src/main/java/org/cryptomator/presentation/logging/GeneratedErrorCode.kt Co-authored-by: Sebastian Stenzel <sebastian.stenzel@gmail.com>
This commit is contained in:
co-authored by
Sebastian Stenzel
parent
efc77e4997
commit
3e216ed0ac
@@ -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.
|
||||
* <p>
|
||||
* 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 <code>cause</code>.
|
||||
* <p>
|
||||
* The code consists of three parts separated by {@value DELIM}:
|
||||
* <ul>
|
||||
* <li>The first part depends on the root cause and the method that threw it</li>
|
||||
* <li>The second part depends on the root cause and its stack trace</li>
|
||||
* <li>The third part depends on all the cause hierarchy</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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 <T> Stream<T> reverseStream(T[] array) {
|
||||
return IntStream.rangeClosed(1, array.length).mapToObj(i -> array[array.length - i]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 = """
|
||||
<!-- ✏️ Please describe what happened as accurately as possible. -->
|
||||
<!-- 📋 Please also copy and paste the detail text from the error window. -->
|
||||
""";
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,7 @@ public enum FontAwesome5Icon {
|
||||
CARET_RIGHT("\uF0Da"), //
|
||||
CHECK("\uF00C"), //
|
||||
CLOCK("\uF017"), //
|
||||
CLIPBOARD("\uF328"), //
|
||||
COG("\uF013"), //
|
||||
COGS("\uF085"), //
|
||||
COPY("\uF0C5"), //
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
|
||||
<?import org.cryptomator.ui.controls.FormattedLabel?>
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.ButtonBar?>
|
||||
<?import javafx.scene.control.Hyperlink?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.TextArea?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.Region?>
|
||||
<?import javafx.scene.layout.StackPane?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import javafx.scene.shape.Circle?>
|
||||
@@ -15,7 +18,7 @@
|
||||
fx:controller="org.cryptomator.ui.common.ErrorController"
|
||||
prefWidth="450"
|
||||
prefHeight="450"
|
||||
spacing="12"
|
||||
spacing="18"
|
||||
alignment="TOP_CENTER">
|
||||
<padding>
|
||||
<Insets topRightBottomLeft="24"/>
|
||||
@@ -27,12 +30,38 @@
|
||||
<FontAwesome5IconView styleClass="glyph-icon-white" glyph="EXCLAMATION" glyphSize="24"/>
|
||||
</StackPane>
|
||||
<VBox spacing="6" HBox.hgrow="ALWAYS">
|
||||
<Label text="%generic.error.title" wrapText="true"/>
|
||||
<FormattedLabel styleClass="label-large" format="%generic.error.title" arg1="${controller.errorCode}"/>
|
||||
<Label text="%generic.error.instruction" wrapText="true"/>
|
||||
<Hyperlink styleClass="hyperlink-underline" text="%generic.error.hyperlink.lookup" onAction="#searchError" contentDisplay="LEFT">
|
||||
<graphic>
|
||||
<FontAwesome5IconView glyph="LINK" glyphSize="12"/>
|
||||
</graphic>
|
||||
</Hyperlink>
|
||||
<Hyperlink styleClass="hyperlink-underline" text="%generic.error.hyperlink.report" onAction="#reportError" contentDisplay="LEFT">
|
||||
<graphic>
|
||||
<FontAwesome5IconView glyph="LINK" glyphSize="12"/>
|
||||
</graphic>
|
||||
</Hyperlink>
|
||||
</VBox>
|
||||
</HBox>
|
||||
|
||||
<TextArea VBox.vgrow="ALWAYS" text="${controller.stackTrace}" prefRowCount="5" editable="false"/>
|
||||
<VBox spacing="6" VBox.vgrow="ALWAYS">
|
||||
<HBox>
|
||||
<Label text="%generic.error.technicalDetails"/>
|
||||
<Region HBox.hgrow="ALWAYS"/>
|
||||
<Hyperlink styleClass="hyperlink-underline" text="%generic.button.copy" onAction="#copyDetails" contentDisplay="LEFT" visible="${!controller.copiedDetails}" managed="${!controller.copiedDetails}">
|
||||
<graphic>
|
||||
<FontAwesome5IconView glyph="CLIPBOARD" glyphSize="12"/>
|
||||
</graphic>
|
||||
</Hyperlink>
|
||||
<Hyperlink styleClass="hyperlink-underline" text="%generic.button.copied" onAction="#copyDetails" contentDisplay="LEFT" visible="${controller.copiedDetails}" managed="${controller.copiedDetails}">
|
||||
<graphic>
|
||||
<FontAwesome5IconView glyph="CHECK" glyphSize="12"/>
|
||||
</graphic>
|
||||
</Hyperlink>
|
||||
</HBox>
|
||||
<TextArea VBox.vgrow="ALWAYS" text="${controller.detailText}" prefRowCount="5" editable="false"/>
|
||||
</VBox>
|
||||
|
||||
<ButtonBar buttonMinWidth="120" buttonOrder="B+C">
|
||||
<buttons>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<RuntimeException> 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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user