From 8f4392711e67ee9ddd1c4ba0229712a36c5cc5af Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 8 Aug 2025 18:21:42 +0200 Subject: [PATCH] moved update API to `integrations-api` --- pom.xml | 2 +- src/main/java/module-info.java | 4 +- .../UpdatesPreferencesController.java | 10 +- .../updater/DownloadUpdateMechanism.java | 1 + .../updater/DownloadUpdateProcess.java | 165 ------------------ .../updater/MacOsDmgUpdateMechanism.java | 46 +++-- .../cryptomator/updater/UpdateMechanism.java | 29 --- .../cryptomator/updater/UpdateProcess.java | 51 ------ .../updater/MacOsDmgUpdateMechanismTest.java | 25 --- 9 files changed, 43 insertions(+), 290 deletions(-) delete mode 100644 src/main/java/org/cryptomator/updater/DownloadUpdateProcess.java delete mode 100644 src/main/java/org/cryptomator/updater/UpdateMechanism.java delete mode 100644 src/main/java/org/cryptomator/updater/UpdateProcess.java delete mode 100644 src/test/java/org/cryptomator/updater/MacOsDmgUpdateMechanismTest.java diff --git a/pom.xml b/pom.xml index caba8e342..48ae7e703 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ 2.9.0 - 1.6.0 + 1.7.0-SNAPSHOT 1.5.0 1.4.0 1.6.0 diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index c0d7d9499..cb8f9b6bc 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,4 +1,5 @@ import ch.qos.logback.classic.spi.Configurator; +import org.cryptomator.integrations.update.UpdateMechanism; import org.cryptomator.networking.SSLContextWithPKCS12TrustStore; import org.cryptomator.common.locationpresets.DropboxLinuxLocationPresetsProvider; import org.cryptomator.common.locationpresets.DropboxMacLocationPresetsProvider; @@ -21,7 +22,6 @@ 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; @@ -64,7 +64,7 @@ open module org.cryptomator.desktop { uses org.cryptomator.event.NotificationHandler; // opens org.cryptomator.updater to org.cryptomator.integrations.api; - provides UpdateMechanism with MacOsDmgUpdateMechanism; // TODO: move to integrations-mac + provides UpdateMechanism with MacOsDmgUpdateMechanism; provides TrayMenuController with AwtTrayMenuController; provides Configurator with LogbackConfiguratorFactory; diff --git a/src/main/java/org/cryptomator/ui/preferences/UpdatesPreferencesController.java b/src/main/java/org/cryptomator/ui/preferences/UpdatesPreferencesController.java index 54c1ac4d3..12be92241 100644 --- a/src/main/java/org/cryptomator/ui/preferences/UpdatesPreferencesController.java +++ b/src/main/java/org/cryptomator/ui/preferences/UpdatesPreferencesController.java @@ -2,10 +2,10 @@ package org.cryptomator.ui.preferences; import org.cryptomator.common.Environment; import org.cryptomator.common.settings.Settings; +import org.cryptomator.integrations.update.UpdateMechanism; +import org.cryptomator.integrations.update.UpdateProcess; import org.cryptomator.ui.common.FxController; import org.cryptomator.ui.fxapp.UpdateChecker; -import org.cryptomator.updater.UpdateMechanism; -import org.cryptomator.updater.UpdateProcess; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -139,7 +139,11 @@ public class UpdatesPreferencesController implements FxController { try { // TODO: check if all vaults closed? var restartProcess = updatePreparationTask.get().applyUpdate(); - assert restartProcess.isAlive(); + if (restartProcess.isAlive()) { + Platform.exit(); + } else { + LOG.error("Update process terminated prematurely: {}", restartProcess.info().commandLine()); + } Platform.exit(); // TODO: prompt? } catch (IOException | InterruptedException | ExecutionException e) { LOG.error("Oh no", e); // TODO: Show error dialog diff --git a/src/main/java/org/cryptomator/updater/DownloadUpdateMechanism.java b/src/main/java/org/cryptomator/updater/DownloadUpdateMechanism.java index 10ca3b137..8434a9964 100644 --- a/src/main/java/org/cryptomator/updater/DownloadUpdateMechanism.java +++ b/src/main/java/org/cryptomator/updater/DownloadUpdateMechanism.java @@ -3,6 +3,7 @@ package org.cryptomator.updater; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; +import org.cryptomator.integrations.update.UpdateMechanism; import java.io.IOException; import java.io.InputStream; diff --git a/src/main/java/org/cryptomator/updater/DownloadUpdateProcess.java b/src/main/java/org/cryptomator/updater/DownloadUpdateProcess.java deleted file mode 100644 index 4d158e8ba..000000000 --- a/src/main/java/org/cryptomator/updater/DownloadUpdateProcess.java +++ /dev/null @@ -1,165 +0,0 @@ -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 index 38af5d84f..6d288bbae 100644 --- a/src/main/java/org/cryptomator/updater/MacOsDmgUpdateMechanism.java +++ b/src/main/java/org/cryptomator/updater/MacOsDmgUpdateMechanism.java @@ -3,6 +3,9 @@ package org.cryptomator.updater; import org.cryptomator.integrations.common.DisplayName; import org.cryptomator.integrations.common.OperatingSystem; import org.cryptomator.integrations.common.Priority; +import org.cryptomator.integrations.update.DownloadUpdateProcess; +import org.cryptomator.integrations.update.UpdateFailedException; +import org.cryptomator.integrations.update.UpdateProcess; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,9 +27,13 @@ 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); + public UpdateProcess prepareUpdate() throws UpdateFailedException { + try { + Path workDir = Files.createTempDirectory("cryptomator-update"); + return new UpdateProcessImpl(workDir); + } catch (IOException e) { + throw new UpdateFailedException("Failed to create temporary directory for update", e); + } } private static class UpdateProcessImpl extends DownloadUpdateProcess { @@ -35,12 +42,12 @@ public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism { 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 + public UpdateProcessImpl(Path downloadPath) { + super(downloadPath, "update.dmg", UPDATE_URI, CHECKSUM,60_000_000L); // initially assume 60 MB for the update size } @Override - protected void postDownload(Path downloadedFile) throws IOException { + protected void postDownload(Path downloadPath) throws IOException { // Extract Cryptomator.app from the .dmg file String script = """ hdiutil attach 'update.dmg' -mountpoint "/Volumes/Cryptomator_${MOUNT_ID}" -nobrowse -quiet && @@ -57,29 +64,40 @@ public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism { 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()); } + LOG.debug("Update ready: {}", workDir.resolve("Cryptomator.app")); } 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) { + public ProcessHandle applyUpdate() throws IllegalStateException, IOException { + if (!isDone()) { throw new IllegalStateException("Update not yet downloaded"); + } else if (downloadException != null) { + throw new IllegalStateException("Downloading update failed", downloadException); + } + + String selfPath = ProcessHandle.current().info().command().orElse(""); + String installPath; + if (selfPath.startsWith("/Applications/Cryptomator.app")) { + installPath = "/Applications/Cryptomator.app"; + } else if (selfPath.contains("/Cryptomator.app/")) { + installPath = selfPath.substring(0, selfPath.indexOf("/Cryptomator.app/")) + "/Cryptomator.app"; + } else { + throw new UpdateFailedException("Cannot determine destination path for Cryptomator.app, current path: " + selfPath); } - // 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' + cp -R 'Cryptomator.app' "${CRYPTOMATOR_INSTALL_PATH}"; + open -a "${CRYPTOMATOR_INSTALL_PATH}" """; 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(); + processBuilder.environment().put("CRYPTOMATOR_INSTALL_PATH", installPath); + return processBuilder.start().toHandle(); } } diff --git a/src/main/java/org/cryptomator/updater/UpdateMechanism.java b/src/main/java/org/cryptomator/updater/UpdateMechanism.java deleted file mode 100644 index 540633fec..000000000 --- a/src/main/java/org/cryptomator/updater/UpdateMechanism.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.cryptomator.updater; - -import org.cryptomator.integrations.common.NamedServiceProvider; -import org.jetbrains.annotations.Blocking; - -import javafx.concurrent.Task; -import java.io.IOException; - -public interface UpdateMechanism extends NamedServiceProvider { - - static UpdateMechanism get() { - return new MacOsDmgUpdateMechanism(); // TODO: IntegrationsLoader.load(UpdateMechanism.class).orElseThrow(); - } - - /** - * 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 new {@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 deleted file mode 100644 index 58c1b17a7..000000000 --- a/src/main/java/org/cryptomator/updater/UpdateProcess.java +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 3ec3ffc77..000000000 --- a/src/test/java/org/cryptomator/updater/MacOsDmgUpdateMechanismTest.java +++ /dev/null @@ -1,25 +0,0 @@ -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