diff --git a/CHANGELOG.md b/CHANGELOG.md index acb0f51..a5ea371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The changelog starts with version 1.7.0. Changes to prior versions can be found on the [Github release page](https://github.com/cryptomator/integrations-api/releases). -## [Unreleased] +## [Unreleased](https://github.com/cryptomator/integrations-api/compare/1.7.0...HEAD) -No changes yet. +### Added -## [1.7.0] - 2025-09-17 +* Experimental [Update API](https://github.com/cryptomator/integrations-api/blob/a522f36cf45884127e2431dd18222391669d5992/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java) (#72) + +## [1.7.0](https://github.com/cryptomator/integrations-api/releases/tag/1.7.0) - 2025-09-17 ### Changed diff --git a/pom.xml b/pom.xml index a5649da..065e69c 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ 25 2.0.17 + 2.20.0 26.0.2-1 @@ -59,6 +60,11 @@ slf4j-api ${slf4j.version} + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + org.jetbrains diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 9925772..f6c292f 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -6,11 +6,14 @@ import org.cryptomator.integrations.keychain.KeychainAccessProvider; import org.cryptomator.integrations.tray.TrayIntegrationProvider; import org.cryptomator.integrations.uiappearance.UiAppearanceProvider; +import org.cryptomator.integrations.update.UpdateMechanism; module org.cryptomator.integrations.api { requires static org.jetbrains.annotations; requires org.slf4j; + requires com.fasterxml.jackson.databind; + requires java.net.http; exports org.cryptomator.integrations.autostart; exports org.cryptomator.integrations.common; @@ -20,6 +23,7 @@ exports org.cryptomator.integrations.tray; exports org.cryptomator.integrations.uiappearance; exports org.cryptomator.integrations.quickaccess; + exports org.cryptomator.integrations.update; uses AutoStartProvider; uses KeychainAccessProvider; @@ -29,4 +33,5 @@ uses TrayMenuController; uses UiAppearanceProvider; uses QuickAccessService; + uses UpdateMechanism; } \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/Localization.java b/src/main/java/org/cryptomator/integrations/Localization.java new file mode 100644 index 0000000..9b153ac --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/Localization.java @@ -0,0 +1,14 @@ +package org.cryptomator.integrations; + +import java.util.ResourceBundle; + +public enum Localization { + INSTANCE; + + private final ResourceBundle resourceBundle = ResourceBundle.getBundle("IntegrationsApi"); + + public static ResourceBundle get() { + return INSTANCE.resourceBundle; + } + +} diff --git a/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java b/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java index a75febd..c0f5116 100644 --- a/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java +++ b/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java @@ -36,6 +36,19 @@ public static Optional load(Class clazz) { return loadAll(clazz).findFirst(); } + /** + * Loads a specific service provider by its implementation class name. + * @param clazz Service class + * @param implementationClassName fully qualified class name of the implementation + * @return Optional of the service provider if found + * @param Type of the service + */ + public static Optional loadSpecific(Class clazz, String implementationClassName) { + return ServiceLoader.load(clazz, ClassLoaderFactory.forPluginDir()).stream() + .filter(provider -> provider.type().getName().equals(implementationClassName)) + .map(ServiceLoader.Provider::get) + .findAny(); + } /** * Loads all suited service providers ordered by priority in descending order. diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateInfo.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateInfo.java new file mode 100644 index 0000000..739316e --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateInfo.java @@ -0,0 +1,8 @@ +package org.cryptomator.integrations.update; + +public record DownloadUpdateInfo( + DownloadUpdateMechanism updateMechanism, + String version, + DownloadUpdateMechanism.Asset asset +) implements UpdateInfo { +} diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java new file mode 100644 index 0000000..991c85e --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java @@ -0,0 +1,130 @@ +package org.cryptomator.integrations.update; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +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(); + + @Override + public DownloadUpdateInfo checkForUpdate(String currentVersion, HttpClient httpClient) { + try { + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(LATEST_VERSION_API_URL)).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + LOG.warn("Failed to fetch release: HTTP {}", response.statusCode()); + return null; + } + 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; + } + } + + /** + * Returns the first step to prepare the update. This downloads the {@link DownloadUpdateInfo#asset() asset} to a temporary location and verifies its checksum. + * @param updateInfo The {@link DownloadUpdateInfo} retrieved from {@link #checkForUpdate(String, HttpClient)}. + * @return a new {@link UpdateStep} that can be used to monitor the download progress. + * @throws UpdateFailedException When failing to prepare a temporary download location. + */ + @Override + public UpdateStep firstStep(DownloadUpdateInfo updateInfo) throws UpdateFailedException { + try { + Path workDir = Files.createTempDirectory("cryptomator-update"); + return new FirstStep(workDir, updateInfo); + } catch (IOException e) { + throw new UpdateFailedException("Failed to create temporary directory for update", e); + } + } + + /** + * Second step that is executed after the download has completed in the {@link #firstStep(DownloadUpdateInfo) first step}. + * @param workDir A temporary working directory to which the asset has been downloaded. + * @param assetPath The path of the downloaded asset. + * @param updateInfo The {@link DownloadUpdateInfo} representing the update. + * @return The next step of the update process. + * @throws IllegalStateException if preconditions aren't met. + * @throws IOException indicating an error preventing the next step from starting. + * @implSpec The returned {@link UpdateStep} must either be stateless or a new instance must be returned on each call. + */ + public abstract UpdateStep secondStep(Path workDir, Path assetPath, DownloadUpdateInfo updateInfo) throws IllegalStateException, IOException; + + @Nullable + @Blocking + protected abstract DownloadUpdateInfo checkForUpdate(String currentVersion, LatestVersionResponse response); + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LatestVersionResponse( + @JsonProperty("latestVersion") LatestVersion latestVersion, + @JsonProperty("assets") List 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( + @JsonProperty("name") String name, + @JsonProperty("digest") String digest, + @JsonProperty("size") long size, + @JsonProperty("downloadUrl") String downloadUrl + ) {} + + private class FirstStep extends DownloadUpdateStep { + private final Path workDir; + private final DownloadUpdateInfo updateInfo; + + public FirstStep(Path workDir, DownloadUpdateInfo updateInfo) { + var uri = URI.create(updateInfo.asset().downloadUrl); + var destination = workDir.resolve(updateInfo.asset().name); + var digest = updateInfo.asset().digest().startsWith("sha256:") + ? HexFormat.of().withLowerCase().parseHex(updateInfo.asset().digest.substring(7)) // remove "sha256:" prefix + : null; + var size = updateInfo.asset().size; + super(uri, destination, digest, size); + this.workDir = workDir; + this.updateInfo = updateInfo; + } + + @Override + public @Nullable UpdateStep nextStep() throws IllegalStateException, IOException { + if (!isDone()) { + throw new IllegalStateException("Download not yet completed."); + } else if (downloadException != null) { + throw new UpdateFailedException("Download failed.", downloadException); + } + return secondStep(workDir, destination, updateInfo); + } + } + +} diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java new file mode 100644 index 0000000..ca57441 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java @@ -0,0 +1,193 @@ +package org.cryptomator.integrations.update; + +import org.cryptomator.integrations.Localization; +import org.jetbrains.annotations.Nullable; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +public abstract class DownloadUpdateStep implements UpdateStep { + + protected final URI source; + protected final Path destination; + private final byte[] checksum; + private final AtomicLong totalBytes; + private final LongAdder loadedBytes = new LongAdder(); + private final Thread downloadThread; + private final CountDownLatch downloadCompleted = new CountDownLatch(1); + protected volatile IOException downloadException; + + /** + * Creates a new DownloadUpdateProcess instance. + * @param source The URI from which the update will be downloaded. + * @param destination The path where to save the downloaded file. + * @param checksum (optional) The expected SHA-256 checksum of the downloaded file, can be null if not required. + * @param estDownloadSize The estimated size of the download in bytes. + */ + protected DownloadUpdateStep(URI source, Path destination, @Nullable byte[] checksum, long estDownloadSize) { + this.source = source; + this.destination = destination; + this.checksum = checksum; + this.totalBytes = new AtomicLong(estDownloadSize); + this.downloadThread = Thread.ofVirtual().unstarted(this::download); + } + + @Override + public String description() { + return switch (downloadThread.getState()) { + case NEW -> Localization.get().getString("org.cryptomator.api.update.download.new"); + case TERMINATED -> Localization.get().getString("org.cryptomator.api.update.download.done"); + default -> { + double progress = preparationProgress(); + if (progress < 0.0) { + yield Localization.get().getString("org.cryptomator.api.update.download.indeterminateProgress"); + } else { + yield Localization.get().getString("org.cryptomator.api.update.download.progress").formatted(progress * 100.0); + } + } + }; + } + + @Override + public void start() { + downloadThread.start(); + } + + @Override + public double preparationProgress() { + long total = totalBytes.get(); + if (total <= 0) { + return -1.0; + } else { + return (double) loadedBytes.sum() / totalBytes.get(); + } + } + + @Override + public void await() throws InterruptedException { + downloadCompleted.await(); + } + + @Override + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return downloadCompleted.await(timeout, unit); + } + + @Override + public void cancel() { + downloadThread.interrupt(); + try { + Files.deleteIfExists(destination); + } catch (IOException e) { + // ignore, this is a best-effort cleanup + } + } + + protected void download() { + var request = HttpRequest.newBuilder().uri(source).GET().build(); + try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).connectTimeout(Duration.ofSeconds(10)).build()) { + downloadInternal(client, request); + } catch (IOException e) { + downloadException = e; + } finally { + downloadCompleted.countDown(); + } + } + + /** + * Downloads the update from the given URI and saves it to the specified filename in the working directory. + * @param client the HttpClient to use for the download + * @param request the HttpRequest which downloads the file + * @throws IOException indicating I/O errors during the download or file writing process or due to checksum mismatch + */ + protected void downloadInternal(HttpClient client, HttpRequest request) throws IOException { + try { + // make download request + var response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + throw new IOException("Failed to download update, status code: " + response.statusCode()); + } + + // update totalBytes + response.headers().firstValueAsLong("Content-Length").ifPresent(totalBytes::set); + + // prepare checksum calculation + MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("Every implementation of the Java platform is required to support [...] SHA-256", e); + } + + // write bytes to file + try (var in = new DownloadInputStream(response.body(), loadedBytes, sha256); + var src = Channels.newChannel(in); + var dst = FileChannel.open(destination, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)) { + dst.transferFrom(src, 0, Long.MAX_VALUE); + } + + // verify checksum if provided + byte[] calculatedChecksum = sha256.digest(); + if (checksum != null && !MessageDigest.isEqual(calculatedChecksum, checksum)) { + throw new IOException("Checksum verification failed for downloaded file: " + destination); + } + } catch (InterruptedException e) { + throw new InterruptedIOException("Download interrupted"); + } + } + + /** + * An InputStream decorator that counts the number of bytes read and updates a MessageDigest for checksum calculation. + */ + private static class DownloadInputStream extends FilterInputStream { + + private final LongAdder counter; + private final MessageDigest digest; + + protected DownloadInputStream(InputStream in, LongAdder counter, MessageDigest digest) { + super(in); + this.counter = counter; + this.digest = digest; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + if (n != -1) { + digest.update(b, off, n); + counter.add(n); + } + return n; + } + + @Override + public int read() throws IOException { + int b = super.read(); + if (b != -1) { + digest.update((byte) b); + counter.increment(); + } + return b; + } + + } + +} diff --git a/src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java b/src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java new file mode 100644 index 0000000..8638588 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java @@ -0,0 +1,30 @@ +package org.cryptomator.integrations.update; + +import java.util.concurrent.TimeUnit; + +record NoopUpdateStep(String description) implements UpdateStep { + + @Override + public void start() {} + + @Override + public double preparationProgress() { + return -1.0; + } + + @Override + public void cancel() {} + + @Override + public void await() {} + + @Override + public boolean await(long timeout, TimeUnit unit) { + return true; // always done + } + + @Override + public UpdateStep nextStep() { + return null; + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/SemVerComparator.java b/src/main/java/org/cryptomator/integrations/update/SemVerComparator.java new file mode 100644 index 0000000..aff1ca8 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/SemVerComparator.java @@ -0,0 +1,84 @@ +package org.cryptomator.integrations.update; + +import java.util.Comparator; +import java.util.regex.Pattern; + +/** + * Compares version strings according to SemVer 2.0.0. + */ +public class SemVerComparator implements Comparator { + + public static final SemVerComparator INSTANCE = new SemVerComparator(); + + private static final Pattern VERSION_SEP = Pattern.compile("\\."); // 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 trimmedV1 = substringBefore(version1, BUILD_SEP); + String trimmedV2 = substringBefore(version2, BUILD_SEP); + + if (trimmedV1.equals(trimmedV2)) { + return 0; + } + + String v1MajorMinorPatch = substringBefore(trimmedV1, PRE_RELEASE_SEP); + String v2MajorMinorPatch = substringBefore(trimmedV2, PRE_RELEASE_SEP); + String v1PreReleaseVersion = substringAfter(trimmedV1, PRE_RELEASE_SEP); + String v2PreReleaseVersion = substringAfter(trimmedV2, PRE_RELEASE_SEP); + return compare(v1MajorMinorPatch, v1PreReleaseVersion, v2MajorMinorPatch, v2PreReleaseVersion); + } + + private static 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 static int compareNumericallyThenLexicographically(String version1, String version2) { + final String[] vComps1 = VERSION_SEP.split(version1); + final String[] vComps2 = VERSION_SEP.split(version2); + final int commonCompCount = Math.min(vComps1.length, vComps2.length); + + for (int i = 0; i < commonCompCount; i++) { + int subversionComparisonResult; + 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; + } + + private static String substringBefore(String str, String separator) { + int index = str.indexOf(separator); + return index == -1 ? str : str.substring(0, index); + } + + private static String substringAfter(String str, String separator) { + int index = str.indexOf(separator); + return index == -1 ? "" : str.substring(index + separator.length()); + } + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java b/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java new file mode 100644 index 0000000..8d4d582 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java @@ -0,0 +1,17 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.ApiStatus; + +import java.io.IOException; + +@ApiStatus.Experimental +public class UpdateFailedException extends IOException { + + public UpdateFailedException(String message) { + super(message); + } + + public UpdateFailedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java new file mode 100644 index 0000000..b0dec37 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java @@ -0,0 +1,27 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.NotNull; + +public interface UpdateInfo> { + + /** + * @return The version string of the available update. + */ + String version(); + + /** + * @return The update mechanism that provided this update info. + */ + UpdateMechanism updateMechanism(); + + /** + * Typesafe equivalent to {@code updateMechanism().firstStep(this)}. + * @return Result of {@link UpdateMechanism#firstStep(UpdateInfo)}. + * @throws UpdateFailedException If no update process can be started, e.g. due to network or I/O issues. + */ + @NotNull + default UpdateStep useToPrepareFirstStep() throws UpdateFailedException { + @SuppressWarnings("unchecked") T self = (T) this; + return updateMechanism().firstStep(self); + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java new file mode 100644 index 0000000..cd95805 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java @@ -0,0 +1,58 @@ +package org.cryptomator.integrations.update; + +import org.cryptomator.integrations.common.IntegrationsLoader; +import org.cryptomator.integrations.common.NamedServiceProvider; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.http.HttpClient; +import java.util.Optional; + +@ApiStatus.Experimental +public interface UpdateMechanism> extends NamedServiceProvider { + + String UPDATE_MECHANISM_PROPERTY = "cryptomator.updateMechanism"; + + @SuppressWarnings("rawtypes") + static Optional get() { + return Optional.ofNullable(System.getProperty(UPDATE_MECHANISM_PROPERTY)) + .flatMap(name -> IntegrationsLoader.loadSpecific(UpdateMechanism.class, name)); + } + + /** + * Checks whether an update is available by comparing the given version strings. + * @param updateVersion The version string of the update, e.g. "1.2.3". + * @param installedVersion The version string of the currently installed application, e.g. "1.2.3-beta4". + * @return true if an update is available, false otherwise. Always true for SNAPSHOT versions. + */ + static boolean isUpdateAvailable(String updateVersion, String installedVersion) { + if (installedVersion.contains("SNAPSHOT")) { + return true; // SNAPSHOT versions are always considered to be outdated. + } else { + return SemVerComparator.INSTANCE.compare(updateVersion, installedVersion) > 0; + } + } + + /** + * Checks whether an update is available. + * @param currentVersion The full version string of the currently installed application, e.g. "1.2.3-beta4". + * @param httpClient An HTTP client that can be used to check for updates. + * @return An {@link UpdateInfo} if an update is available, or null otherwise. + * @throws UpdateFailedException If the availability of an update could not be determined + */ + @Blocking + @Nullable + T checkForUpdate(String currentVersion, HttpClient httpClient) throws UpdateFailedException; + + /** + * Returns the first step to prepare the update. This can be anything like downloading the update, checking signatures, etc. + * @param updateInfo The {@link UpdateInfo} representing the update to be prepared. + * @return a new {@link UpdateStep} that can be used to monitor the progress of the update preparation. The task will complete when the preparation is done. + * @throws UpdateFailedException If no update process can be started, e.g. due to network or I/O issues. + */ + @NotNull + UpdateStep firstStep(T updateInfo) throws UpdateFailedException; + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java new file mode 100644 index 0000000..b2cd9b5 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java @@ -0,0 +1,111 @@ +package org.cryptomator.integrations.update; + +import org.cryptomator.integrations.Localization; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Range; + +import java.io.IOException; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@ApiStatus.Experimental +public interface UpdateStep { + + /** + * A magic constant indicating that the application shall terminate. + *

+ * This step can be returned as the last step of the update process, usually immediately after a restart has been scheduled. + */ + UpdateStep EXIT = new NoopUpdateStep(Localization.get().getString("org.cryptomator.api.update.updateStep.EXIT")); + + /** + * A magic constant indicating that the update process shall be retried. + */ + UpdateStep RETRY = new NoopUpdateStep(Localization.get().getString("org.cryptomator.api.update.updateStep.RETRY")); + + + static UpdateStep of(String name, Callable nextStep) { + return new UpdateStepAdapter() { + + @Override + public UpdateStep call() throws Exception { + return nextStep.call(); + } + + @Override + public String description() { + return name; + } + }; + } + + /** + * A short description of this update step. + * @return a human-readable description of this update step. + */ + String description(); + + /** + * Starts work on this update step in a non-blocking manner. + * @throws IllegalThreadStateException if this step has already been started. + */ + @NonBlocking + void start() throws IllegalThreadStateException; + + /** + * A thread-safe method to check the progress of the update preparation. + * @return a value between 0.0 and 1.0 indicating the progress of the update preparation or -1.0 indicating indeterminate progress. + */ + double preparationProgress(); + + /** + * Cancels this update step and cleans up any temporary resources. + */ + void cancel(); + + /** + * Blocks the current thread until this update step completed or an error occurred. + * If this step failed, an exception will be rethrown as soon as attempting to invoke {@link #nextStep()}. + *

+ * If the step is already complete, this method returns immediately. + * + * @throws InterruptedException if the current thread is interrupted while waiting. + */ + void await() throws InterruptedException; + + /** + * Blocks the current thread until this update step completed or an error occurred, or until the specified timeout expires. + * If this step failed, an exception will be rethrown as soon as attempting to invoke {@link #nextStep()}. + *

+ * If the step is already complete, this method returns immediately. + * + * @param timeout the maximum time to wait + * @param unit the time unit of the {@code timeout} argument + * @return true if the update is prepared + */ + boolean await(long timeout, TimeUnit unit) throws InterruptedException; + + default boolean isDone() { + try { + return await(0, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** + * After running this step to completion, this method returns the next step of the update process. + * + * @return the next {@link UpdateStep step} of the update process or null if this was the final step. + * @throws IllegalStateException if this step didn't complete yet or other preconditions aren't met. + * @throws IOException indicating an error before reaching the next step, e.g. during execution of this step. + * @implSpec The returned {@link UpdateStep} must either be stateless or a new instance must be returned on each call. + */ + @Nullable + UpdateStep nextStep() throws IllegalStateException, IOException; + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java b/src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java new file mode 100644 index 0000000..dc0cd0d --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java @@ -0,0 +1,65 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; + +public abstract class UpdateStepAdapter implements Callable, Runnable, UpdateStep { + + protected final Thread thread; + protected volatile UpdateStep result; + protected volatile Exception exception; + + public UpdateStepAdapter() { + this.thread = Thread.ofVirtual().name("UpdateStep", 0).unstarted(this); + } + + @Override + public final void run() { + try { + this.result = this.call(); + } catch (Exception e) { + this.exception = e; + } + } + + @Override + public void start() throws IllegalThreadStateException { + thread.start(); + } + + @Override + public double preparationProgress() { + return -1.0; + } + + @Override + public void cancel() { + thread.interrupt(); + } + + @Override + public void await() throws InterruptedException { + thread.join(); + } + + @Override + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return thread.join(Duration.of(timeout, unit.toChronoUnit())); + } + + @Override + public @Nullable UpdateStep nextStep() throws IllegalStateException, IOException { + if (!isDone()) { + throw new IllegalStateException("Update step not completed yet"); + } + return switch (exception) { + case null -> result; + case IOException e -> throw e; + default -> throw new IOException("Update step failed", exception); + }; + } +} diff --git a/src/main/resources/IntegrationsApi.properties b/src/main/resources/IntegrationsApi.properties new file mode 100644 index 0000000..1e9fdd1 --- /dev/null +++ b/src/main/resources/IntegrationsApi.properties @@ -0,0 +1,7 @@ +org.cryptomator.api.update.download.new=Download... +org.cryptomator.api.update.download.indeterminateProgress=Downloading... +org.cryptomator.api.update.download.progress=Downloading... %1.0f%% +org.cryptomator.api.update.download.done=Downloaded. + +org.cryptomator.api.update.updateStep.EXIT=Exiting... +org.cryptomator.api.update.updateStep.RETRY=Retry \ No newline at end of file diff --git a/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java b/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java new file mode 100644 index 0000000..ac080d8 --- /dev/null +++ b/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java @@ -0,0 +1,79 @@ +package org.cryptomator.integrations.update; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.Comparator; + +public class SemVerComparatorTest { + + private final Comparator semVerComparator = SemVerComparator.INSTANCE; + + // equal versions + + @ParameterizedTest + @CsvSource({ + "1.23.4, 1.23.4", + "1.23.4-alpha, 1.23.4-alpha", + "1.23.4+20170101, 1.23.4+20171231", + "1.23.4-alpha+20170101, 1.23.4-alpha+20171231" + }) + public void compareEqualVersions(String left, String right) { + Assertions.assertEquals(0, Integer.signum(semVerComparator.compare(left, right))); + } + + // newer versions in first argument + + @ParameterizedTest + @CsvSource({ + "1.23.5, 1.23.4", + "1.24.4, 1.23.4", + "1.23.4, 1.23", + "1.23.4, 1.23.4-SNAPSHOT", + "1.23.4, 1.23.4-56.78", + "1.23.4-beta, 1.23.4-alpha", + "1.23.4-alpha.1, 1.23.4-alpha", + "1.23.4-56.79, 1.23.4-56.78", + "1.23.4-alpha, 1.23.4-1", + }) + public void compareHigherToLowerVersions(String higherVersion, String lowerVersion) { + Assertions.assertEquals(1, Integer.signum(semVerComparator.compare(higherVersion, lowerVersion))); + } + + // newer versions in second argument + + @ParameterizedTest + @CsvSource({ + "1.23.4, 1.23.5", + "1.23.4, 1.24.4", + "1.23, 1.23.4", + "1.23.4-SNAPSHOT, 1.23.4", + "1.23.4-56.78, 1.23.4", + "1.23.4-alpha, 1.23.4-beta", + "1.23.4-alpha, 1.23.4-alpha.1", + "1.23.4-56.78, 1.23.4-56.79", + "1.23.4-1, 1.23.4-alpha", + }) + public void compareLowerToHigherVersions(String lowerVersion, String higherVersion) { + Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare(lowerVersion, higherVersion))); + } + + // test vector from https://semver.org/spec/v2.0.0.html#spec-item-11: + // Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0. + @ParameterizedTest + @CsvSource({ + "1.0.0-alpha, 1.0.0-alpha.1", + "1.0.0-alpha.1, 1.0.0-alpha.beta", + "1.0.0-alpha.beta, 1.0.0-beta", + "1.0.0-beta, 1.0.0-beta.2", + "1.0.0-beta.2, 1.0.0-beta.11", + "1.0.0-beta.11, 1.0.0-rc.1", + "1.0.0-rc.1, 1.0.0" + }) + public void testPrecedenceSpec(String left, String right) { + Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare(left, right))); + } + +}