Skip to content

External update mechanism #62

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
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