Skip to content
4 changes: 4 additions & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
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 java.net.http;

exports org.cryptomator.integrations.autostart;
exports org.cryptomator.integrations.common;
Expand All @@ -20,6 +22,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;
Expand All @@ -29,4 +32,5 @@
uses TrayMenuController;
uses UiAppearanceProvider;
uses QuickAccessService;
uses UpdateMechanism;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package org.cryptomator.integrations.update;

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.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
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 DownloadUpdateProcess implements UpdateProcess {

protected final Path workDir;
private final String downloadFileName;
private final URI uri;
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 workDir A temporary directory where to download the update file.
* @param downloadFileName The name of the file to which the update will be downloaded
* @param uri The URI from which the update will be downloaded.
* @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 DownloadUpdateProcess(Path workDir, String downloadFileName, URI uri, byte[] checksum, long estDownloadSize) {
this.workDir = workDir;
this.downloadFileName = downloadFileName;
this.uri = uri;
this.checksum = checksum;
this.totalBytes = new AtomicLong(estDownloadSize);
this.downloadThread = Thread.ofVirtual().unstarted(this::download);
}

protected void startDownload() {
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);
}

protected boolean isDone() {
try {
return downloadCompleted.await(0, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}

@Override
public void cancel() {
downloadThread.interrupt();
}

private void download() {
try {
download(workDir.resolve(downloadFileName));
} catch (IOException e) {
// TODO: eventually handle this via structured concurrency?
downloadException = e;
} finally {
downloadCompleted.countDown();
}
}

/**
* Downloads the update from the given URI and saves it to the specified filename in the working directory.
* @param downloadPath The path to where to save the downloaded file.
* @throws IOException indicating I/O errors during the download or file writing process or due to checksum mismatch
*/
protected void download(Path downloadPath) throws IOException {
var request = HttpRequest.newBuilder().uri(uri).GET().build();
try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) { // TODO: make http client injectable?
// 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"); // Initialize SHA-256 digest, not used here but can be extended for checksum validation
} 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(downloadPath, 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: " + downloadPath);
}

// post-download processing
postDownload(downloadPath);
} catch (InterruptedException e) {
throw new InterruptedIOException("Download interrupted");
}
}

protected void postDownload(Path downloadPath) throws IOException {
// Default implementation does nothing, can be overridden by subclasses for specific post-download actions
}

/**
* 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;
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package org.cryptomator.integrations.update;

import java.util.Comparator;
import java.util.regex.Pattern;

/**
* 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> {

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());
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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;

@ApiStatus.Experimental
public interface UpdateMechanism extends NamedServiceProvider {

static UpdateMechanism get() {
// TODO: load preferred udpate mechanism, if specified in system properties.
return IntegrationsLoader.load(UpdateMechanism.class).orElseThrow(); // Fallback "show download page" mechanism always available.
}

/**
* 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 <code>true</code> if an update is available, <code>false</code> otherwise. Always <code>true</code> 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".
* @return <code>true</code> if an update is available, <code>false</code> otherwise.
* @throws UpdateFailedException If the availability of an update could not be determined
*/
@Blocking
boolean isUpdateAvailable(String currentVersion) throws UpdateFailedException;

/**
* Performs as much as possible to prepare the update. This may include downloading the update, checking signatures, etc.
* @return a new {@link UpdateProcess} 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.
*/
UpdateProcess prepareUpdate() throws UpdateFailedException;

}
Loading