From b0ed133e05c07479dda400454492a210d77c1b7d Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sun, 6 Jul 2025 14:28:57 +0200 Subject: [PATCH] PoC --- src/main/java/module-info.java | 5 + .../updater/DownloadUpdateMechanism.java | 51 ++++++ .../updater/DownloadUpdateProcess.java | 165 ++++++++++++++++++ .../updater/MacOsDmgUpdateMechanism.java | 87 +++++++++ .../cryptomator/updater/UpdateMechanism.java | 31 ++++ .../cryptomator/updater/UpdateProcess.java | 51 ++++++ .../updater/MacOsDmgUpdateMechanismTest.java | 25 +++ 7 files changed, 415 insertions(+) create mode 100644 src/main/java/org/cryptomator/updater/DownloadUpdateMechanism.java create mode 100644 src/main/java/org/cryptomator/updater/DownloadUpdateProcess.java create mode 100644 src/main/java/org/cryptomator/updater/MacOsDmgUpdateMechanism.java create mode 100644 src/main/java/org/cryptomator/updater/UpdateMechanism.java create mode 100644 src/main/java/org/cryptomator/updater/UpdateProcess.java create mode 100644 src/test/java/org/cryptomator/updater/MacOsDmgUpdateMechanismTest.java diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 459d3c52d..c0d7d9499 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -20,6 +20,8 @@ import org.cryptomator.networking.SSLContextWithWindowsCertStore; import org.cryptomator.integrations.tray.TrayMenuController; import org.cryptomator.logging.LogbackConfiguratorFactory; import org.cryptomator.ui.traymenu.AwtTrayMenuController; +import org.cryptomator.updater.MacOsDmgUpdateMechanism; +import org.cryptomator.updater.UpdateMechanism; open module org.cryptomator.desktop { requires static org.jetbrains.annotations; @@ -61,6 +63,9 @@ open module org.cryptomator.desktop { uses SSLContextProvider; uses org.cryptomator.event.NotificationHandler; + // opens org.cryptomator.updater to org.cryptomator.integrations.api; + provides UpdateMechanism with MacOsDmgUpdateMechanism; // TODO: move to integrations-mac + provides TrayMenuController with AwtTrayMenuController; provides Configurator with LogbackConfiguratorFactory; provides SSLContextProvider with SSLContextWithWindowsCertStore, SSLContextWithMacKeychain, SSLContextWithPKCS12TrustStore; diff --git a/src/main/java/org/cryptomator/updater/DownloadUpdateMechanism.java b/src/main/java/org/cryptomator/updater/DownloadUpdateMechanism.java new file mode 100644 index 000000000..10ca3b137 --- /dev/null +++ b/src/main/java/org/cryptomator/updater/DownloadUpdateMechanism.java @@ -0,0 +1,51 @@ +package org.cryptomator.updater; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; + +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.util.List; + +public abstract class DownloadUpdateMechanism implements UpdateMechanism { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public boolean isUpdateAvailable() { + try (var client = HttpClient.newHttpClient()) { + // TODO: check different source + HttpRequest request = HttpRequest.newBuilder().uri(URI.create("https://api.github.com/repos/cryptomator/cryptomator/releases/latest")).header("Accept", "application/vnd.github+json").build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + throw new RuntimeException("Failed to fetch release: " + response.statusCode()); + } + + var release = MAPPER.readValue(response.body(), GitHubRelease.class); + + return release.assets.stream().anyMatch(a -> a.name.endsWith("arm64.dmg")); + } catch (IOException | InterruptedException e) { + return false; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record GitHubRelease( + @JsonProperty("tag_name") String tagName, + List assets + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Asset( + String name, + @JsonProperty("browser_download_url") String downloadUrl + ) {} + +} diff --git a/src/main/java/org/cryptomator/updater/DownloadUpdateProcess.java b/src/main/java/org/cryptomator/updater/DownloadUpdateProcess.java new file mode 100644 index 000000000..4d158e8ba --- /dev/null +++ b/src/main/java/org/cryptomator/updater/DownloadUpdateProcess.java @@ -0,0 +1,165 @@ +package org.cryptomator.updater; + +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 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; + protected volatile boolean downloadSuccessful; + + /** + * Creates a new DownloadUpdateProcess instance. + * @param workdir The directory where the update will be downloaded to. Ideally, this should be a temporary directory that is cleaned up after the update process is complete. + * @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, URI uri, byte[] checksum, long estDownloadSize) { + this.workDir = workdir; + 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); + } + + @Override + public void cancel() { + downloadThread.interrupt(); + } + + private void download() { + try { + download("update.dmg"); + downloadSuccessful = true; + } 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 filename the name of the file to save the update as in the working directory + * @throws IOException indicating I/O errors during the download or file writing process or due to checksum mismatch + */ + protected void download(String filename) throws IOException { + var request = HttpRequest.newBuilder().uri(uri).GET().build(); + var downloadFile = workDir.resolve(filename); + try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) { + // 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(downloadFile, 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: " + filename); + } + + // post-download processing + postDownload(downloadFile); + } catch (InterruptedException e) { + throw new InterruptedIOException("Download interrupted"); + } + } + + protected void postDownload(Path downloadedFile) 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; + } + + } + +} diff --git a/src/main/java/org/cryptomator/updater/MacOsDmgUpdateMechanism.java b/src/main/java/org/cryptomator/updater/MacOsDmgUpdateMechanism.java new file mode 100644 index 000000000..38af5d84f --- /dev/null +++ b/src/main/java/org/cryptomator/updater/MacOsDmgUpdateMechanism.java @@ -0,0 +1,87 @@ +package org.cryptomator.updater; + +import org.cryptomator.integrations.common.DisplayName; +import org.cryptomator.integrations.common.OperatingSystem; +import org.cryptomator.integrations.common.Priority; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; + + +@Priority(1000) +@OperatingSystem(OperatingSystem.Value.MAC) +@DisplayName("download .dmg file") // TODO: localize +public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism { + + private static final Logger LOG = LoggerFactory.getLogger(MacOsDmgUpdateMechanism.class); + + @Override + public UpdateProcess prepareUpdate() throws IOException { + Path workDir = Files.createTempDirectory("cryptomator-update"); + return new UpdateProcessImpl(workDir); + } + + private static class UpdateProcessImpl extends DownloadUpdateProcess { + + // FIXME: use URI and CHECKSUM from update API + private static final URI UPDATE_URI = URI.create("https://github.com/cryptomator/cryptomator/releases/download/1.17.0/Cryptomator-1.17.0-arm64.dmg"); + private static final byte[] CHECKSUM = HexFormat.of().withLowerCase().parseHex("03f45e203204e93b39925cbb04e19c9316da4f77debaba4fb5071f0ec8e727e8"); + + public UpdateProcessImpl(Path workDir) { + super(workDir, UPDATE_URI, CHECKSUM,60_000_000L); // initially assume 60 MB for the update size + } + + @Override + protected void postDownload(Path downloadedFile) throws IOException { + // Extract Cryptomator.app from the .dmg file + String script = """ + hdiutil attach 'update.dmg' -mountpoint "/Volumes/Cryptomator_${MOUNT_ID}" -nobrowse -quiet && + cp -R "/Volumes/Cryptomator_${MOUNT_ID}/Cryptomator.app" 'Cryptomator.app' && + hdiutil detach "/Volumes/Cryptomator_${MOUNT_ID}" -quiet + """; + var command = List.of("bash", "-c", script); + var processBuilder = new ProcessBuilder(command); + processBuilder.directory(workDir.toFile()); + processBuilder.environment().put("MOUNT_ID", UUID.randomUUID().toString()); + Process p = processBuilder.start(); + try { + if (p.waitFor() != 0) { + LOG.error("Failed to extract DMG, exit code: {}, output: {}", p.exitValue(), new String(p.getErrorStream().readAllBytes())); + throw new IOException("Failed to extract DMG, exit code: " + p.exitValue()); + } + } catch (InterruptedException e) { + throw new InterruptedIOException("Failed to extract DMG, interrupted"); + } + } + + @Override + public Process applyUpdate() throws IllegalStateException, IOException { + if (downloadException != null) { + throw new IllegalStateException("Downloading update failed", downloadException); + } else if (!downloadSuccessful) { + throw new IllegalStateException("Update not yet downloaded"); + } + // TODO: use /Applications/Cryptomator.app or ~/Applications/Cryptomator.app depending on the path of the current process (ProcessHandle.current().info().command()?) + String script = """ + while kill -0 ${CRYPTOMATOR_PID} 2> /dev/null; do sleep 0.5; done; + cp -R 'Cryptomator.app' '/Applications/Cryptomator.app'; + open -a '/Applications/Cryptomator.app' + """; + var command = List.of("bash", "-c", "nohup bash -c \"" + script + "\" >/Users/sebastian/Downloads/nohup.out 2>&1 &"); + var processBuilder = new ProcessBuilder(command); + processBuilder.directory(workDir.toFile()); + processBuilder.environment().put("CRYPTOMATOR_PID", String.valueOf(ProcessHandle.current().pid())); + return processBuilder.start(); + } + } + + +} diff --git a/src/main/java/org/cryptomator/updater/UpdateMechanism.java b/src/main/java/org/cryptomator/updater/UpdateMechanism.java new file mode 100644 index 000000000..7a599e529 --- /dev/null +++ b/src/main/java/org/cryptomator/updater/UpdateMechanism.java @@ -0,0 +1,31 @@ +package org.cryptomator.updater; + +import org.cryptomator.integrations.common.IntegrationsLoader; +import org.cryptomator.integrations.common.NamedServiceProvider; +import org.jetbrains.annotations.Blocking; + +import javafx.concurrent.Task; +import java.io.IOException; +import java.util.stream.Stream; + +public interface UpdateMechanism extends NamedServiceProvider { + + static Stream get() { + return IntegrationsLoader.loadAll(UpdateMechanism.class); + } + + /** + * Checks whether an update is available. + * @return true if an update is available, false otherwise. + */ + @Blocking + boolean isUpdateAvailable(); + + /** + * Performs as much as possible to prepare the update. This may include downloading the update, checking signatures, etc. + * @return a {@link Task} that can be used to monitor the progress of the update preparation. The task will complete when the preparation is done. + * @throws IOException I/O error during preparation, such as network issues or file access problems. + */ + UpdateProcess prepareUpdate() throws IOException; // TODO: exception types? + +} diff --git a/src/main/java/org/cryptomator/updater/UpdateProcess.java b/src/main/java/org/cryptomator/updater/UpdateProcess.java new file mode 100644 index 000000000..58c1b17a7 --- /dev/null +++ b/src/main/java/org/cryptomator/updater/UpdateProcess.java @@ -0,0 +1,51 @@ +package org.cryptomator.updater; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +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. + *

+ * 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. + *

+ * 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. + *

+ * 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 Process} 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 starting the update process fails + */ + Process applyUpdate() throws IllegalStateException, IOException; + + +} diff --git a/src/test/java/org/cryptomator/updater/MacOsDmgUpdateMechanismTest.java b/src/test/java/org/cryptomator/updater/MacOsDmgUpdateMechanismTest.java new file mode 100644 index 000000000..3ec3ffc77 --- /dev/null +++ b/src/test/java/org/cryptomator/updater/MacOsDmgUpdateMechanismTest.java @@ -0,0 +1,25 @@ +package org.cryptomator.updater; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +class MacOsDmgUpdateMechanismTest { + + public static void main(String args[]) throws InterruptedException, IOException { + UpdateMechanism updateMechanism = new MacOsDmgUpdateMechanism(); + if (updateMechanism.isUpdateAvailable()) { + System.out.println("Update is available."); + } + var updateProcess = updateMechanism.prepareUpdate(); + do { + double percentage = updateProcess.preparationProgress() * 100.0; + System.out.printf("\rPreparing update: %.2f%%", percentage); + } while (!updateProcess.await(100, TimeUnit.MILLISECONDS)); + System.out.println("\nUpdate ready..."); + Process p = updateProcess.applyUpdate(); + p.isAlive(); + System.out.println("Update running, exiting..."); + // exit. + } + +} \ No newline at end of file