unified "check for update" and "do update" button

This commit is contained in:
Sebastian Stenzel
2025-11-02 15:40:32 +01:00
parent 59560193ee
commit c938c42c00
16 changed files with 419 additions and 484 deletions

View File

@@ -52,12 +52,13 @@ open module org.cryptomator.desktop {
requires io.github.coffeelibs.tinyoauth2client;
requires org.slf4j;
requires org.apache.commons.lang3;
requires com.github.benmanes.caffeine;
requires com.fasterxml.jackson.annotation;
/* dagger bs */
requires jakarta.inject;
requires static javax.inject;
requires java.compiler;
requires com.github.benmanes.caffeine;
uses org.cryptomator.common.locationpresets.LocationPresetsProvider;
uses SSLContextProvider;

View File

@@ -74,13 +74,6 @@ public abstract class CommonsModule {
return new MasterkeyFileAccess(Constants.PEPPER, csprng);
}
@Provides
@Singleton
@Named("SemVer")
static Comparator<String> providesSemVerComparator() {
return new SemVerComparator();
}
@Provides
@Singleton
static Optional<RevealPathService> provideRevealPathService() {

View File

@@ -1,81 +0,0 @@
/*******************************************************************************
* Copyright (c) 2016, 2017 Sebastian Stenzel and others.
* All rights reserved.
* This program and the accompanying materials are made available under the terms of the accompanying LICENSE file.
*
* Contributors:
* Sebastian Stenzel - initial API and implementation
*******************************************************************************/
package org.cryptomator.common;
import org.apache.commons.lang3.StringUtils;
import java.util.Comparator;
/**
* Compares version strings according to <a href="http://semver.org/spec/v2.0.0.html">SemVer 2.0.0</a>.
*/
public class SemVerComparator implements Comparator<String> {
private static final char VERSION_SEP = '.'; // http://semver.org/spec/v2.0.0.html#spec-item-2
private static final String PRE_RELEASE_SEP = "-"; // http://semver.org/spec/v2.0.0.html#spec-item-9
private static final String BUILD_SEP = "+"; // http://semver.org/spec/v2.0.0.html#spec-item-10
@Override
public int compare(String version1, String version2) {
// "Build metadata SHOULD be ignored when determining version precedence.
// Thus two versions that differ only in the build metadata, have the same precedence."
String v1WithoutBuildMetadata = StringUtils.substringBefore(version1, BUILD_SEP);
String v2WithoutBuildMetadata = StringUtils.substringBefore(version2, BUILD_SEP);
if (v1WithoutBuildMetadata.equals(v2WithoutBuildMetadata)) {
return 0;
}
String v1MajorMinorPatch = StringUtils.substringBefore(v1WithoutBuildMetadata, PRE_RELEASE_SEP);
String v2MajorMinorPatch = StringUtils.substringBefore(v2WithoutBuildMetadata, PRE_RELEASE_SEP);
String v1PreReleaseVersion = StringUtils.substringAfter(v1WithoutBuildMetadata, PRE_RELEASE_SEP);
String v2PreReleaseVersion = StringUtils.substringAfter(v2WithoutBuildMetadata, PRE_RELEASE_SEP);
return compare(v1MajorMinorPatch, v1PreReleaseVersion, v2MajorMinorPatch, v2PreReleaseVersion);
}
private int compare(String v1MajorMinorPatch, String v1PreReleaseVersion, String v2MajorMinorPatch, String v2PreReleaseVersion) {
int comparisonResult = compareNumericallyThenLexicographically(v1MajorMinorPatch, v2MajorMinorPatch);
if (comparisonResult == 0) {
if (v1PreReleaseVersion.isEmpty()) {
return 1; // 1.0.0 > 1.0.0-BETA
} else if (v2PreReleaseVersion.isEmpty()) {
return -1; // 1.0.0-BETA < 1.0.0
} else {
return compareNumericallyThenLexicographically(v1PreReleaseVersion, v2PreReleaseVersion);
}
} else {
return comparisonResult;
}
}
private int compareNumericallyThenLexicographically(String version1, String version2) {
final String[] vComps1 = StringUtils.split(version1, VERSION_SEP);
final String[] vComps2 = StringUtils.split(version2, VERSION_SEP);
final int commonCompCount = Math.min(vComps1.length, vComps2.length);
for (int i = 0; i < commonCompCount; i++) {
int subversionComparisonResult = 0;
try {
final int v1 = Integer.parseInt(vComps1[i]);
final int v2 = Integer.parseInt(vComps2[i]);
subversionComparisonResult = v1 - v2;
} catch (NumberFormatException ex) {
// ok, lets compare this fragment lexicographically
subversionComparisonResult = vComps1[i].compareTo(vComps2[i]);
}
if (subversionComparisonResult != 0) {
return subversionComparisonResult;
}
}
// all in common so far? longest version string is considered the higher version:
return vComps1.length - vComps2.length;
}
}

View File

@@ -0,0 +1,86 @@
package org.cryptomator.ui.fxapp;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import java.io.IOException;
import java.net.Authenticator;
import java.net.CookieHandler;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
abstract class DelegatingHttpClient extends HttpClient {
private final HttpClient delegate;
public DelegatingHttpClient(HttpClient delegate) {
this.delegate = Objects.requireNonNull(delegate, "delegate must not be null");
}
@Override
public Optional<CookieHandler> cookieHandler() {
return delegate.cookieHandler();
}
@Override
public Optional<Duration> connectTimeout() {
return delegate.connectTimeout();
}
@Override
public Redirect followRedirects() {
return delegate.followRedirects();
}
@Override
public Optional<ProxySelector> proxy() {
return delegate.proxy();
}
@Override
public SSLContext sslContext() {
return delegate.sslContext();
}
@Override
public SSLParameters sslParameters() {
return delegate.sslParameters();
}
@Override
public Optional<Authenticator> authenticator() {
return delegate.authenticator();
}
@Override
public Version version() {
return delegate.version();
}
@Override
public Optional<Executor> executor() {
return delegate.executor();
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException, InterruptedException {
return delegate.send(request, responseBodyHandler);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
return delegate.sendAsync(request, responseBodyHandler);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
return delegate.sendAsync(request, responseBodyHandler, pushPromiseHandler);
}
}

View File

@@ -26,7 +26,7 @@ import javafx.scene.image.Image;
import java.io.IOException;
import java.io.InputStream;
@Module(includes = {UpdateCheckerModule.class}, subcomponents = {TrayMenuComponent.class, //
@Module(subcomponents = {TrayMenuComponent.class, //
DecryptNameComponent.class, //
MainWindowComponent.class, //
PreferencesComponent.class, //

View File

@@ -1,53 +1,68 @@
package org.cryptomator.ui.fxapp;
import org.cryptomator.common.Environment;
import org.cryptomator.common.SemVerComparator;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.integrations.update.UpdateFailedException;
import org.cryptomator.integrations.update.UpdateInfo;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.updater.FallbackUpdateMechanism;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.StringExpression;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.concurrent.WorkerStateEvent;
import javafx.util.Duration;
import java.net.http.HttpClient;
import java.time.Instant;
import java.util.Comparator;
import java.util.concurrent.Executors;
@Deprecated(forRemoval = true) // to be replaced by fallback org.cryptomator.integrations.update.UpdateMechanism
@FxApplicationScoped
public class UpdateChecker {
public class UpdateChecker extends ScheduledService<UpdateInfo> {
private static final Logger LOG = LoggerFactory.getLogger(UpdateChecker.class);
private static final Duration AUTO_CHECK_DELAY = Duration.seconds(5);
private static final Duration UPDATE_CHECK_INTERVAL = Duration.hours(3);
private static final Duration DISABLED_UPDATE_CHECK_INTERVAL = Duration.hours(100000); // Duration.INDEFINITE leads to overflows...
public enum UpdateCheckState {
NOT_CHECKED,
IS_CHECKING,
CHECK_SUCCESSFUL,
CHECK_FAILED
}
private final Environment env;
private final Settings settings;
private final StringProperty latestVersion = new SimpleStringProperty();
private final ScheduledService<String> updateCheckerService;
private final ObjectProperty<UpdateCheckState> state = new SimpleObjectProperty<>(UpdateCheckState.NOT_CHECKED);
private final ObjectProperty<Instant> lastSuccessfulUpdateCheck;
private final Comparator<String> versionComparator = new SemVerComparator();
private final BooleanBinding updateAvailable;
private final BooleanBinding checkFailed;
private final StringExpression latestVersion = StringExpression.stringExpression(lastValueProperty().map(UpdateInfo::version));
private final BooleanBinding updateAvailable = lastValueProperty().isNotNull();
private final ObjectBinding<UpdateCheckState> updateState = Bindings.createObjectBinding(this::getUpdateCheckState, stateProperty());
private final BooleanBinding checkFailed = Bindings.equal(UpdateCheckState.CHECK_FAILED, updateState);
private final HttpClient httpClient;
private final UpdateMechanism primaryUpdateMechanism;
private final UpdateMechanism fallbackUpdateMechanism;
@Inject
UpdateChecker(Settings settings, //
Environment env, //
ScheduledService<String> updateCheckerService) {
Environment env,
FallbackUpdateMechanism fallbackUpdateMechanism,
UpdateCheckerHttpClient httpClient) {
this.env = env;
this.settings = settings;
this.updateCheckerService = updateCheckerService;
this.lastSuccessfulUpdateCheck = settings.lastSuccessfulUpdateCheck;
this.updateAvailable = Bindings.createBooleanBinding(this::isUpdateAvailable, latestVersion);
this.checkFailed = Bindings.equal(UpdateCheckState.CHECK_FAILED, state);
this.httpClient = httpClient;
this.primaryUpdateMechanism = UpdateMechanism.get().orElse(fallbackUpdateMechanism);
this.fallbackUpdateMechanism = fallbackUpdateMechanism;
setExecutor(Executors.newVirtualThreadPerTaskExecutor());
periodProperty().bind(Bindings.when(settings.checkForUpdates).then(UPDATE_CHECK_INTERVAL).otherwise(DISABLED_UPDATE_CHECK_INTERVAL));
}
public void automaticallyCheckForUpdatesIfEnabled() {
@@ -61,46 +76,42 @@ public class UpdateChecker {
}
private void startCheckingForUpdates(Duration initialDelay) {
updateCheckerService.cancel();
updateCheckerService.reset();
updateCheckerService.setDelay(initialDelay);
updateCheckerService.setOnRunning(this::checkStarted);
updateCheckerService.setOnSucceeded(this::checkSucceeded);
updateCheckerService.setOnFailed(this::checkFailed);
updateCheckerService.start();
cancel();
reset();
setDelay(initialDelay);
start();
}
private void checkStarted(WorkerStateEvent event) {
LOG.debug("Checking for updates...");
state.set(UpdateCheckState.IS_CHECKING);
@Override
protected void succeeded() {
var updateInfo = getValue();
super.succeeded(); // this will nil the value property!
if (updateInfo != null) {
LOG.info("Current version: {}, latest version: {}", getCurrentVersion(), updateInfo.version());
lastSuccessfulUpdateCheck.set(Instant.now());
}
}
private void checkSucceeded(WorkerStateEvent event) {
var latestVersionString = updateCheckerService.getValue();
LOG.info("Current version: {}, latest version: {}", getCurrentVersion(), latestVersionString);
lastSuccessfulUpdateCheck.set(Instant.now());
latestVersion.set(latestVersionString);
state.set(UpdateCheckState.CHECK_SUCCESSFUL);
@Override
protected Task<UpdateInfo> createTask() {
return new UpdateCheckTask();
}
private void checkFailed(WorkerStateEvent event) {
state.set(UpdateCheckState.CHECK_FAILED);
@Override
protected void failed() {
super.failed();
LOG.error("Update check failed.", getException());
}
public enum UpdateCheckState {
NOT_CHECKED,
IS_CHECKING,
CHECK_SUCCESSFUL,
CHECK_FAILED
}
/* Observable Properties */
public BooleanBinding checkingForUpdatesProperty() {
return updateCheckerService.stateProperty().isEqualTo(Worker.State.RUNNING);
public StringExpression latestVersionProperty() {
return latestVersion;
}
public ReadOnlyStringProperty latestVersionProperty() {
return latestVersion;
public boolean isUpdateAvailable() {
return updateAvailable.get();
}
public BooleanBinding updateAvailableProperty() {
@@ -111,26 +122,38 @@ public class UpdateChecker {
return checkFailed;
}
public boolean isUpdateAvailable() {
String currentVersion = getCurrentVersion();
String latestVersionString = latestVersion.get();
if (currentVersion == null || latestVersionString == null) {
return false;
} else {
return versionComparator.compare(currentVersion, latestVersionString) < 0;
}
}
public ObjectProperty<Instant> lastSuccessfulUpdateCheckProperty() {
return lastSuccessfulUpdateCheck;
}
public ObjectProperty<UpdateCheckState> updateCheckStateProperty() {
return state;
public ObjectBinding<UpdateCheckState> updateCheckStateProperty() {
return updateState;
}
private UpdateCheckState getUpdateCheckState() {
return switch (getState()) {
case READY -> UpdateCheckState.NOT_CHECKED;
case SCHEDULED, RUNNING -> UpdateCheckState.IS_CHECKING;
case SUCCEEDED -> UpdateCheckState.CHECK_SUCCESSFUL;
case FAILED, CANCELLED -> UpdateCheckState.CHECK_FAILED;
};
}
public String getCurrentVersion() {
return env.getAppVersion();
}
private class UpdateCheckTask extends Task<UpdateInfo> {
@Override
protected UpdateInfo call() throws UpdateFailedException, InterruptedException {
var result = primaryUpdateMechanism.checkForUpdate(env.getAppVersion(), httpClient);
if (result == null && primaryUpdateMechanism != fallbackUpdateMechanism) {
LOG.debug("Primary update mechanism did not find an update. Try fallback update mechanism...");
result = fallbackUpdateMechanism.checkForUpdate(env.getAppVersion(), httpClient);
}
return result;
}
}
}

View File

@@ -0,0 +1,53 @@
package org.cryptomator.ui.fxapp;
import jakarta.inject.Inject;
import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.Environment;
import java.io.IOException;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
@FxApplicationScoped
public class UpdateCheckerHttpClient extends DelegatingHttpClient {
private final String userAgent;
@Inject
public UpdateCheckerHttpClient(Environment env) {
var delegate = HttpClient.newBuilder() //
.followRedirects(HttpClient.Redirect.NORMAL) // from version 1.6.11 onwards, Cryptomator can follow redirects, in case this URL ever changes
.proxy(ProxySelector.getDefault()).build();
super(delegate);
this.userAgent = String.format("Cryptomator VersionChecker/%s %s %s (%s)", env.getAppVersion(), SystemUtils.OS_NAME, SystemUtils.OS_VERSION, SystemUtils.OS_ARCH);
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException, InterruptedException {
return super.send(decorateRequest(request), responseBodyHandler);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
return super.sendAsync(decorateRequest(request), responseBodyHandler);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
return super.sendAsync(decorateRequest(request), responseBodyHandler, pushPromiseHandler);
}
private HttpRequest decorateRequest(HttpRequest request) {
return HttpRequest.newBuilder(request, (_, _) -> true) //
.header("User-Agent", this.userAgent) //
.timeout(Duration.ofSeconds(10)) //
.build();
}
}

View File

@@ -1,93 +0,0 @@
package org.cryptomator.ui.fxapp;
import dagger.Module;
import dagger.Provides;
import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.Environment;
import org.cryptomator.common.settings.Settings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Named;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.util.Duration;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
@Module
public abstract class UpdateCheckerModule {
private static final Logger LOG = LoggerFactory.getLogger(UpdateCheckerModule.class);
private static final URI LATEST_VERSION_URI = URI.create("https://api.cryptomator.org/desktop/latest-version.json");
private static final Duration UPDATE_CHECK_INTERVAL = Duration.hours(3);
private static final Duration DISABLED_UPDATE_CHECK_INTERVAL = Duration.hours(100000); // Duration.INDEFINITE leads to overflows...
@Provides
@FxApplicationScoped
static Optional<HttpClient> provideHttpClient() {
try {
return Optional.of(HttpClient.newBuilder() //
.followRedirects(HttpClient.Redirect.NORMAL) // from version 1.6.11 onwards, Cryptomator can follow redirects, in case this URL ever changes
.build());
} catch (UncheckedIOException e) {
LOG.error("HttpClient for update check cannot be created.", e);
return Optional.empty();
}
}
@Provides
@FxApplicationScoped
static HttpRequest provideCheckForUpdatesRequest(Environment env) {
String userAgent = String.format("Cryptomator VersionChecker/%s %s %s (%s)", //
env.getAppVersion(), //
SystemUtils.OS_NAME, //
SystemUtils.OS_VERSION, //
SystemUtils.OS_ARCH); //
return HttpRequest.newBuilder() //
.uri(LATEST_VERSION_URI) //
.header("User-Agent", userAgent) //
.timeout(java.time.Duration.ofSeconds(10))
.build();
}
@Provides
@Named("checkForUpdatesInterval")
@FxApplicationScoped
static ObjectBinding<Duration> provideCheckForUpdateInterval(Settings settings) {
return Bindings.when(settings.checkForUpdates).then(UPDATE_CHECK_INTERVAL).otherwise(DISABLED_UPDATE_CHECK_INTERVAL);
}
@Provides
@FxApplicationScoped
static ScheduledService<String> provideCheckForUpdatesService(ExecutorService executor, Optional<HttpClient> httpClient, HttpRequest checkForUpdatesRequest, @Named("checkForUpdatesInterval") ObjectBinding<Duration> period) {
ScheduledService<String> service = new ScheduledService<>() {
@Override
protected Task<String> createTask() {
if (httpClient.isPresent()) {
return new UpdateCheckerTask(httpClient.get(), checkForUpdatesRequest);
} else {
return new Task<>() {
@Override
protected String call() {
throw new NullPointerException("No HttpClient present.");
}
};
}
}
};
service.setOnFailed(event -> LOG.error("Failed to execute update service", service.getException()));
service.setExecutor(executor);
service.periodProperty().bind(period);
return service;
}
}

View File

@@ -1,58 +0,0 @@
package org.cryptomator.ui.fxapp;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.io.ByteStreams;
import org.apache.commons.lang3.SystemUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javafx.concurrent.Task;
import java.io.IOException;
import java.io.InputStream;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class UpdateCheckerTask extends Task<String> {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Logger LOG = LoggerFactory.getLogger(UpdateCheckerTask.class);
private static final long MAX_RESPONSE_SIZE = 10L * 1024; // 10kb should be sufficient. protect against flooding
private final HttpClient httpClient;
private final HttpRequest checkForUpdatesRequest;
UpdateCheckerTask(HttpClient httpClient, HttpRequest checkForUpdatesRequest) {
this.httpClient = httpClient;
this.checkForUpdatesRequest = checkForUpdatesRequest;
setOnFailed(event -> LOG.error("Failed to check for updates", getException()));
}
@Override
protected String call() throws IOException, InterruptedException {
HttpResponse<InputStream> response = httpClient.send(checkForUpdatesRequest, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() == 200) {
return processBody(response);
} else {
throw new IOException("Unexpected HTTP response code " + response.statusCode());
}
}
private String processBody(HttpResponse<InputStream> response) throws IOException {
try (InputStream in = response.body(); //
InputStream limitedIn = ByteStreams.limit(in, MAX_RESPONSE_SIZE)) {
var json = JSON.reader().readTree(limitedIn);
if (SystemUtils.IS_OS_MAC_OSX) {
return json.get("mac").asText();
} else if (SystemUtils.IS_OS_WINDOWS) {
return json.get("win").asText();
} else if (SystemUtils.IS_OS_LINUX) {
return json.get("linux").asText();
} else {
throw new IllegalStateException("Unsupported operating system");
}
}
}
}

View File

@@ -2,7 +2,7 @@ package org.cryptomator.ui.preferences;
import org.cryptomator.common.Environment;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.integrations.update.UpdateInfo;
import org.cryptomator.integrations.update.UpdateStep;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.fxapp.UpdateChecker;
@@ -17,21 +17,18 @@ import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.StringBinding;
import javafx.beans.binding.StringExpression;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Service;
import javafx.concurrent.Worker;
import javafx.concurrent.WorkerStateEvent;
import javafx.event.EventType;
import javafx.fxml.FXML;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ContentDisplay;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
@@ -46,60 +43,56 @@ import java.util.ResourceBundle;
public class UpdatesPreferencesController implements FxController {
private static final Logger LOG = LoggerFactory.getLogger(UpdatesPreferencesController.class);
private static final String DOWNLOADS_URI_TEMPLATE = "https://cryptomator.org/downloads/" //
+ "?utm_source=cryptomator-desktop" //
+ "&utm_medium=update-notification&" //
+ "utm_campaign=app-update-%s";
private final Application application;
private final Environment environment;
private final ResourceBundle resourceBundle;
private final Settings settings;
private final Environment env;
private final UpdateChecker updateChecker;
private final ObjectBinding<ContentDisplay> checkForUpdatesButtonState;
private final ReadOnlyStringProperty latestVersion;
private final ObjectBinding<ContentDisplay> updateButtonState;
private final StringExpression latestVersion;
private final ObservableValue<Instant> lastSuccessfulUpdateCheck;
private final StringBinding lastUpdateCheckMessage;
private final ObservableValue<String> timeDifferenceMessage;
private final String currentVersion;
private final BooleanBinding updateAvailable;
private final BooleanBinding checkFailed;
private final BooleanProperty upToDateLabelVisible = new SimpleBooleanProperty(false);
private final DateTimeFormatter formatter;
private final BooleanBinding upToDate;
private final String downloadsUri;
private final UpdateMechanism updateMechanism;
private final Service<UpdateStep> updateService;
private final UpdateService updateService;
private final StringBinding updateButtonTitle;
private final BooleanBinding updateReady;
private final ObjectBinding<Worker<?>> worker;
private final BooleanExpression running;
/* FXML */
public CheckBox checkForUpdatesCheckbox;
@Inject
UpdatesPreferencesController(Application application, Environment environment, ResourceBundle resourceBundle, Settings settings, UpdateChecker updateChecker, Environment env, FallbackUpdateMechanism fallbackUpdateMechanism) {
UpdatesPreferencesController(Application application, Environment environment, ResourceBundle resourceBundle, Settings settings, UpdateChecker updateChecker, FallbackUpdateMechanism fallbackUpdateMechanism) {
this.application = application;
this.environment = environment;
this.resourceBundle = resourceBundle;
this.settings = settings;
this.env = env;
this.updateChecker = updateChecker;
this.checkForUpdatesButtonState = Bindings.when(updateChecker.checkingForUpdatesProperty()).then(ContentDisplay.LEFT).otherwise(ContentDisplay.TEXT_ONLY);
this.latestVersion = updateChecker.latestVersionProperty();
this.lastSuccessfulUpdateCheck = updateChecker.lastSuccessfulUpdateCheckProperty();
this.timeDifferenceMessage = Bindings.createStringBinding(this::getTimeDifferenceMessage, lastSuccessfulUpdateCheck);
this.currentVersion = environment.getAppVersion();
this.updateAvailable = updateChecker.updateAvailableProperty();
this.lastUpdateCheckMessage = Bindings.createStringBinding(this::getLastUpdateCheckMessage, lastSuccessfulUpdateCheck);
this.currentVersion = updateChecker.getCurrentVersion();
this.formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault());
this.upToDate = updateChecker.updateCheckStateProperty().isEqualTo(UpdateChecker.UpdateCheckState.CHECK_SUCCESSFUL).and(latestVersion.isEqualTo(currentVersion));
this.checkFailed = updateChecker.checkFailedProperty();
this.lastUpdateCheckMessage = Bindings.createStringBinding(this::getLastUpdateCheckMessage, lastSuccessfulUpdateCheck);
this.downloadsUri = DOWNLOADS_URI_TEMPLATE.formatted(URLEncoder.encode(currentVersion, StandardCharsets.US_ASCII));
this.updateMechanism = UpdateMechanism.get().orElse(fallbackUpdateMechanism);
this.updateService = new UpdateService(updateMechanism);
this.updateButtonTitle = Bindings.createStringBinding(this::getUpdateButtonTitle, updateService.stateProperty(), updateService.messageProperty());
this.updateReady = updateService.stateProperty().isEqualTo(Worker.State.READY);
this.updateService = new UpdateService(updateChecker.lastValueProperty().map(UpdateInfo::updateMechanism));
this.worker = Bindings.when(updateChecker.updateAvailableProperty()).<Worker<?>>then(updateService).otherwise(updateChecker);
this.running = Bindings.createBooleanBinding(this::isRunning, updateService.stateProperty(), updateChecker.stateProperty());
this.updateButtonTitle = Bindings.createStringBinding(this::getUpdateButtonTitle, worker, updateService.stateProperty(), updateService.messageProperty());
this.updateButtonState = Bindings.createObjectBinding(this::getUpdateButtonState, updateChecker.stateProperty(), updateService.stateProperty());
updateChecker.updateAvailableProperty().addListener((_, _, newVal) -> LOG.info("Update available: {}", newVal));
updateService.setOnSucceeded(this::updateSucceeded);
updateService.setOnFailed(this::updateFailed);
@@ -117,24 +110,18 @@ public class UpdatesPreferencesController implements FxController {
});
}
@FXML
public void checkNow() {
updateChecker.checkForUpdatesNow();
}
@FXML
public void visitDownloadsPage() {
application.getHostServices().showDocument(downloadsUri);
}
@FXML
public void showLogfileDirectory() {
environment.getLogDir().ifPresent(logDirPath -> application.getHostServices().showDocument(logDirPath.toUri().toString()));
}
@FXML
public void doUpdate() {
updateService.start();
public void startWork() {
if (worker.get().equals(updateChecker)) {
updateChecker.checkForUpdatesNow();
} else if (worker.get().equals(updateService)) {
updateService.start();
}
}
private void updateSucceeded(WorkerStateEvent workerStateEvent) {
@@ -157,15 +144,21 @@ public class UpdatesPreferencesController implements FxController {
/* Observable Properties */
public ObjectBinding<ContentDisplay> checkForUpdatesButtonStateProperty() {
return checkForUpdatesButtonState;
public ObjectBinding<ContentDisplay> updateButtonStateProperty() {
return updateButtonState;
}
public ContentDisplay getCheckForUpdatesButtonState() {
return checkForUpdatesButtonState.get();
public ContentDisplay getUpdateButtonState() {
if (updateService.isRunning()) { // isRunning() covers RUNNING and SCHEDULED states
return ContentDisplay.BOTTOM;
} else if (updateChecker.getState() == Worker.State.RUNNING) {
return ContentDisplay.LEFT;
} else {
return ContentDisplay.TEXT_ONLY;
}
}
public ReadOnlyStringProperty latestVersionProperty() {
public StringExpression latestVersionProperty() {
return latestVersion;
}
@@ -217,14 +210,6 @@ public class UpdatesPreferencesController implements FxController {
return upToDateLabelVisible.get();
}
public BooleanBinding updateAvailableProperty() {
return updateAvailable;
}
public boolean isUpdateAvailable() {
return updateAvailable.get();
}
public BooleanBinding checkFailedProperty() {
return checkFailed;
}
@@ -233,8 +218,20 @@ public class UpdatesPreferencesController implements FxController {
return checkFailed.getValue();
}
public Service<UpdateStep> getUpdateService() {
return updateService;
public ObjectBinding<Worker<?>> workerProperty() {
return worker;
}
public Worker<?> getWorker() {
return worker.get();
}
public BooleanExpression runningProperty() {
return running;
}
public boolean isRunning() {
return updateChecker.getState() == Worker.State.RUNNING || updateService.getState() == Worker.State.RUNNING;
}
public StringBinding updateButtonTitleProperty() {
@@ -242,20 +239,20 @@ public class UpdatesPreferencesController implements FxController {
}
public String getUpdateButtonTitle() {
return switch (updateService.getState()) {
case READY -> updateMechanism.getName();
case SCHEDULED, RUNNING -> updateService.getMessage(); // "Preparing Update..."; // TODO: resourceBundle.getString("preferences.updates.preparingUpdate")...
case SUCCEEDED -> "Restart to Update"; // TODO: resourceBundle.getString("preferences.updates.readyToRestart")...
case FAILED, CANCELLED -> "failed";
};
if (worker.get() == updateChecker) {
return resourceBundle.getString("preferences.updates.checkNowBtn");
} else {
return switch (updateService.getState()) {
case READY -> updateChecker.getLastValue().updateMechanism().getName();
case SCHEDULED, RUNNING -> updateService.getMessage(); // "Preparing Update..."; // TODO: resourceBundle.getString("preferences.updates.preparingUpdate")...
case SUCCEEDED -> "Restart to Update"; // TODO: resourceBundle.getString("preferences.updates.readyToRestart")...
case FAILED, CANCELLED -> "failed";
};
}
}
public boolean isUpdateReady() {
return updateReady.get();
}
public BooleanBinding updateReadyProperty() {
return updateReady;
public UpdateChecker getUpdateChecker() {
return updateChecker;
}
}

View File

@@ -3,7 +3,12 @@ package org.cryptomator.updater;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.cryptomator.integrations.update.UpdateInfo;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
@@ -15,45 +20,53 @@ import java.util.List;
public abstract class DownloadUpdateMechanism implements UpdateMechanism {
private static final Logger LOG = LoggerFactory .getLogger(DownloadUpdateMechanism.class);
private static final String LATEST_VERSION_API_URL = "https://api.cryptomator.org/connect/apps/desktop/latest-version?format=1";
private static final ObjectMapper MAPPER = new ObjectMapper();
private final String assetSuffix;
protected DownloadUpdateMechanism(String assetSuffix) {
this.assetSuffix = assetSuffix;
}
@Override
public boolean isUpdateAvailable(String currentVersion) {
try (var client = HttpClient.newHttpClient()) {
// TODO: check different source
HttpRequest request = HttpRequest.newBuilder().uri(URI.create("https://api.github.com/repos/cryptomator/cryptomator/releases/latest")).header("Accept", "application/vnd.github+json").build();
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
public UpdateInfo checkForUpdate(String currentVersion, HttpClient httpClient) {
try {
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(LATEST_VERSION_API_URL)).build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new RuntimeException("Failed to fetch release: " + response.statusCode());
}
var release = MAPPER.readValue(response.body(), GitHubRelease.class);
return release.assets.stream().anyMatch(a -> a.name.endsWith(assetSuffix))
&& UpdateMechanism.isUpdateAvailable(release.tagName, currentVersion);
} catch (IOException | InterruptedException e) {
return false;
var release = MAPPER.readValue(response.body(), LatestVersionResponse.class);
return checkForUpdate(currentVersion, release);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.debug("Update check interrupted.");
return null;
} catch (IOException e) {
LOG.warn("Update check failed", e);
return null;
}
}
@Nullable
@Blocking
abstract UpdateInfo checkForUpdate(String currentVersion, LatestVersionResponse response);
@JsonIgnoreProperties(ignoreUnknown = true)
public record GitHubRelease(
@JsonProperty("tag_name") String tagName,
List<Asset> assets
public record LatestVersionResponse(
@JsonProperty("latestVersion") LatestVersion latestVersion,
@JsonProperty("assets") List<Asset> assets
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record LatestVersion(
@JsonProperty("mac") String macVersion,
@JsonProperty("win") String winVersion,
@JsonProperty("linux") String linuxVersion
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Asset(
String name,
@JsonProperty("browser_download_url") String downloadUrl
@JsonProperty("name") String name,
@JsonProperty("digest") String digest,
@JsonProperty("size") long size,
@JsonProperty("downloadUrl") String downloadUrl
) {}
}

View File

@@ -1,18 +1,31 @@
package org.cryptomator.updater;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.Environment;
import org.cryptomator.integrations.common.DisplayName;
import org.cryptomator.integrations.common.Priority;
import org.cryptomator.integrations.update.UpdateInfo;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.integrations.update.UpdateStep;
import org.cryptomator.integrations.update.UpdateStepAdapter;
import org.cryptomator.ui.fxapp.FxApplicationScoped;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javafx.application.Application;
import javafx.application.Platform;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
@@ -21,6 +34,9 @@ import java.util.concurrent.TimeUnit;
@DisplayName("Show Download Page") // TODO localize
public class FallbackUpdateMechanism implements UpdateMechanism {
private static final Logger LOG = LoggerFactory.getLogger(FallbackUpdateMechanism.class);
private static final String LATEST_VERSION_API_URL = "https://api.cryptomator.org/connect/apps/desktop/latest-version";
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String DOWNLOADS_URI_TEMPLATE = "https://cryptomator.org/downloads/" //
+ "?utm_source=cryptomator-desktop" //
+ "&utm_medium=update-notification&" //
@@ -36,9 +52,24 @@ public class FallbackUpdateMechanism implements UpdateMechanism {
}
@Override
public boolean isUpdateAvailable(String currentVersion) {
// FIXME: what source shall we use? self-hosted JSON?
return true;
public UpdateInfo checkForUpdate(String currentVersion, HttpClient httpClient) {
try {
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(LATEST_VERSION_API_URL)).build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new RuntimeException("Failed to fetch release: " + response.statusCode());
}
var release = MAPPER.readValue(response.body(), LatestVersion.class);
var updateVersion = release.versionForCurrentOS();
if (UpdateMechanism.isUpdateAvailable(currentVersion, updateVersion)) {
return new UpdateInfo(updateVersion, this);
} else {
return null;
}
} catch (IOException | InterruptedException e) {
LOG.warn("Update check failed", e);
return null;
}
}
@Override
@@ -54,4 +85,23 @@ public class FallbackUpdateMechanism implements UpdateMechanism {
return UpdateStep.RETRY; // allow running this "update mechanism" as many times as the user wants
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record LatestVersion(
@JsonProperty("mac") String macVersion,
@JsonProperty("win") String winVersion,
@JsonProperty("linux") String linuxVersion
) {
public String versionForCurrentOS() {
if (SystemUtils.IS_OS_MAC_OSX) {
return macVersion;
} else if (SystemUtils.IS_OS_WINDOWS) {
return winVersion;
} else if (SystemUtils.IS_OS_LINUX) {
return linuxVersion;
} else {
throw new IllegalStateException("Unsupported operating system");
}
}
}
}

View File

@@ -5,6 +5,8 @@ import org.cryptomator.integrations.common.OperatingSystem;
import org.cryptomator.integrations.common.Priority;
import org.cryptomator.integrations.update.DownloadUpdateStep;
import org.cryptomator.integrations.update.UpdateFailedException;
import org.cryptomator.integrations.update.UpdateInfo;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.integrations.update.UpdateStep;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
@@ -27,12 +29,18 @@ public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism {
private static final Logger LOG = LoggerFactory.getLogger(MacOsDmgUpdateMechanism.class);
public MacOsDmgUpdateMechanism() {
@Override
UpdateInfo checkForUpdate(String currentVersion, LatestVersionResponse response) {
String suffix = switch (System.getProperty("os.arch")) {
case "aarch64", "arm64" -> "arm64.dmg";
default -> "x64.dmg";
};
super(suffix);
if (UpdateMechanism.isUpdateAvailable(response.latestVersion().macVersion(), currentVersion)
&& response.assets().stream().map(Asset::name).anyMatch(s -> s.endsWith(suffix))) {
return new UpdateInfo(response.latestVersion().macVersion(), this);
} else {
return null;
}
}
@Override

View File

@@ -3,10 +3,12 @@ package org.cryptomator.updater;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.integrations.update.UpdateStep;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@@ -15,18 +17,25 @@ import java.util.concurrent.TimeUnit;
*/
public class UpdateService extends Service<UpdateStep> {
private final UpdateMechanism updateMechanism;
private ObservableValue<UpdateMechanism> updateMechanism;
public UpdateService(UpdateMechanism updateMechanism) {
public UpdateService(ObservableValue<UpdateMechanism> updateMechanism) {
setExecutor(Executors.newVirtualThreadPerTaskExecutor());
this.updateMechanism = updateMechanism;
setExecutor(Executors.newVirtualThreadPerTaskExecutor()); }
}
@Override
protected Task<UpdateStep> createTask() {
return new RunAllStepsTask();
return new RunAllStepsTask(updateMechanism.getValue());
}
private class RunAllStepsTask extends Task<UpdateStep> {
private static class RunAllStepsTask extends Task<UpdateStep> {
private final UpdateMechanism updateMechanism;
public RunAllStepsTask(UpdateMechanism updateMechanism) {
this.updateMechanism = Objects.requireNonNull(updateMechanism);
}
@Override
protected UpdateStep call() throws IOException {
@@ -52,8 +61,6 @@ public class UpdateService extends Service<UpdateStep> {
updateMessage(step.description());
} while (!step.await(100, TimeUnit.MILLISECONDS));
}
}
}

View File

@@ -18,9 +18,6 @@
xmlns="http://javafx.com/javafx"
fx:controller="org.cryptomator.ui.preferences.UpdatesPreferencesController"
spacing="12">
<fx:define>
<FormattedString fx:id="linkLabel" format="%preferences.updates.updateAvailable" arg1="${controller.latestVersion}"/>
</fx:define>
<padding>
<Insets topRightBottomLeft="24"/>
</padding>
@@ -29,9 +26,20 @@
<CheckBox fx:id="checkForUpdatesCheckbox" text="%preferences.updates.autoUpdateCheck"/>
<VBox alignment="CENTER" spacing="12">
<Button text="%preferences.updates.checkNowBtn" defaultButton="true" onAction="#checkNow" contentDisplay="${controller.checkForUpdatesButtonState}">
<FormattedLabel format="%preferences.updates.updateAvailable" arg1="${controller.latestVersion}" textAlignment="CENTER" wrapText="true" visible="${controller.updateChecker.updateAvailable}"/>
<Button text="${controller.updateButtonTitle}" defaultButton="true" onAction="#startWork" disable="${controller.running}" contentDisplay="${controller.updateButtonState}">
<graphic>
<FontAwesome5Spinner glyphSize="12"/>
<VBox spacing="5" alignment="CENTER">
<ProgressBar maxWidth="200"
maxHeight="12"
visible="${controller.running &amp;&amp; controller.worker.progress != -1.0}"
managed="${controller.running &amp;&amp; controller.worker.progress != -1.0}"
progress="${controller.worker.progress}"/>
<FontAwesome5Spinner glyphSize="12"
visible="${controller.running &amp;&amp; controller.worker.progress == -1.0}"
managed="${controller.running &amp;&amp; controller.worker.progress == -1.0}"/>
</VBox>
</graphic>
</Button>
@@ -52,19 +60,5 @@
<FontAwesome5IconView glyphSize="12" styleClass="glyph-icon-primary" glyph="CHECK"/>
</graphic>
</Label>
<Hyperlink text="${linkLabel.value}" onAction="#visitDownloadsPage" textAlignment="CENTER" wrapText="true" styleClass="hyperlink-underline" visible="${controller.updateAvailable}" managed="${controller.updateAvailable}"/>
<Button onAction="#doUpdate" disable="${!controller.updateReady}"> <!-- TODO: visible="${controller.updateAvailable}" -->
<graphic>
<VBox spacing="5" alignment="CENTER">
<Label text="${controller.updateButtonTitle}"/>
<ProgressBar maxWidth="200"
maxHeight="12"
visible="${controller.updateService.running}"
managed="${controller.updateService.running}"
progress="${controller.updateService.progress}"/>
</VBox>
</graphic>
</Button>
</VBox>
</VBox>

View File

@@ -1,58 +0,0 @@
/*******************************************************************************
* Copyright (c) 2016, 2017 Sebastian Stenzel and others.
* All rights reserved.
* This program and the accompanying materials are made available under the terms of the accompanying LICENSE file.
*
* Contributors:
* Sebastian Stenzel - initial API and implementation
*******************************************************************************/
package org.cryptomator.common;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Comparator;
public class SemVerComparatorTest {
private final Comparator<String> semVerComparator = new SemVerComparator();
// equal versions
@Test
public void compareEqualVersions() {
Assertions.assertEquals(0, Integer.signum(semVerComparator.compare("1.23.4", "1.23.4")));
Assertions.assertEquals(0, Integer.signum(semVerComparator.compare("1.23.4-alpha", "1.23.4-alpha")));
Assertions.assertEquals(0, Integer.signum(semVerComparator.compare("1.23.4+20170101", "1.23.4+20171231")));
Assertions.assertEquals(0, Integer.signum(semVerComparator.compare("1.23.4-alpha+20170101", "1.23.4-alpha+20171231")));
}
// newer versions in first argument
@Test
public void compareHigherToLowerVersions() {
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.5", "1.23.4")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.24.4", "1.23.4")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4", "1.23")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4", "1.23.4-SNAPSHOT")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4", "1.23.4-56.78")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4-beta", "1.23.4-alpha")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4-alpha.1", "1.23.4-alpha")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4-56.79", "1.23.4-56.78")));
}
// newer versions in second argument
@Test
public void compareLowerToHigherVersions() {
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4", "1.23.5")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4", "1.24.4")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23", "1.23.4")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-SNAPSHOT", "1.23.4")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-56.78", "1.23.4")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-alpha", "1.23.4-beta")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-alpha", "1.23.4-alpha.1")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-56.78", "1.23.4-56.79")));
}
}