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,174 @@
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().start(this::download);
}

@Override
public double preparationProgress() {
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, totalBytes.get());
}

// verify checksum if provided
byte[] calculatedChecksum = sha256.digest();
if (!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);
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,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,29 @@
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() {
return IntegrationsLoader.load(UpdateMechanism.class).orElseThrow(); // Fallback "show download page" mechanism always available.
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

orElseThrow() loses diagnostic context

If no implementation is found the caller gets a naked NoSuchElementException without guidance. Provide a descriptive message or fall back to the “show-download-page” implementation directly.

🤖 Prompt for AI Agents
In src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java around
lines 11 to 13, the use of orElseThrow() without a message causes a generic
NoSuchElementException with no diagnostic context if no implementation is found.
Fix this by either providing a descriptive exception message in orElseThrow() or
by returning a default fallback implementation that shows the download page
directly to improve error clarity and handling.


/**
* Checks whether an update is available.
* @return <code>true</code> if an update is available, <code>false</code> otherwise.
*/
@Blocking
boolean isUpdateAvailable(); // TODO: let it throw?

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

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

import org.jetbrains.annotations.ApiStatus;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

@ApiStatus.Experimental
public interface UpdateProcess {

/**
* 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.
*/
double preparationProgress();

/**
* Cancels the update process and cleans up any resources that were used during the preparation.
*/
void cancel();

/**
* Blocks the current thread until the update preparation is complete or an error occurs.
* <p>
* If the preparation 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 the update preparation is complete or an error occurs, or until the specified timeout expires.
* <p>
* If the preparation 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;

/**
* Once the update preparation is complete, this method can be called to launch the external update process.
* <p>
* This method shall be called after making sure that the application is ready to be restarted, e.g. after locking all vaults.
*
* @return a {@link ProcessHandle} that represents the external update process.
* @throws IllegalStateException if the update preparation is not complete or if the update process cannot be launched.
* @throws IOException if the update preparation failed or starting the update process failed
*/
ProcessHandle applyUpdate() throws IllegalStateException, IOException;


}