-
Notifications
You must be signed in to change notification settings - Fork 5
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
purejava
wants to merge
12
commits into
cryptomator:develop
Choose a base branch
from
purejava:update-mechanism
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
88f7231
Invent UpdateService
purejava a501a2b
Have multiple UpdateServices
purejava 6af0aa8
Invent @DistributionChannel
purejava a2a6f98
Drop isUpdateAvailable in favor of getLatestReleaseChecker
purejava f3f3c35
Add spawnApp()
purejava 5f29005
Add listener interfaces
purejava 28680db
Implement missing signals
purejava eaa63e5
Correlate with API as suggested in a separate PoC: UpdateMechanism an…
purejava 84076df
Reduce API surface
overheadhunter e875adb
Merge branch 'develop' into update-mechanism
overheadhunter dde78a1
API refinement
overheadhunter 0f765d6
added test case
overheadhunter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
185 changes: 185 additions & 0 deletions
185
src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
|
||
} | ||
|
||
} |
84 changes: 84 additions & 0 deletions
84
src/main/java/org/cryptomator/integrations/update/SemVerComparator.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
overheadhunter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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; | ||
overheadhunter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
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()); | ||
} | ||
|
||
} |
17 changes: 17 additions & 0 deletions
17
src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
46 changes: 46 additions & 0 deletions
46
src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
|
||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.