adjust to new multi-step update API

This commit is contained in:
Sebastian Stenzel
2025-10-21 11:32:45 +02:00
parent a6b31e19b9
commit 59560193ee
8 changed files with 217 additions and 70 deletions

View File

@@ -3,9 +3,11 @@ 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.integrations.update.UpdateStep;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.fxapp.UpdateChecker;
import org.cryptomator.updater.FallbackUpdateMechanism;
import org.cryptomator.updater.UpdateService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -21,11 +23,13 @@ import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Task;
import javafx.concurrent.Service;
import javafx.concurrent.Worker;
import javafx.concurrent.WorkerStateEvent;
import javafx.event.EventType;
import javafx.fxml.FXML;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ContentDisplay;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@@ -36,8 +40,6 @@ import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
import java.util.ResourceBundle;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
@PreferencesScoped
@@ -68,14 +70,15 @@ public class UpdatesPreferencesController implements FxController {
private final BooleanBinding upToDate;
private final String downloadsUri;
private final UpdateMechanism updateMechanism;
public final Task<UpdateProcess> updatePreparationTask;
private final Service<UpdateStep> updateService;
private final StringBinding updateButtonTitle;
private final BooleanBinding updateReady;
/* FXML */
public CheckBox checkForUpdatesCheckbox;
@Inject
UpdatesPreferencesController(Application application, Environment environment, ResourceBundle resourceBundle, Settings settings, UpdateChecker updateChecker, Environment env) {
UpdatesPreferencesController(Application application, Environment environment, ResourceBundle resourceBundle, Settings settings, UpdateChecker updateChecker, Environment env, FallbackUpdateMechanism fallbackUpdateMechanism) {
this.application = application;
this.environment = environment;
this.resourceBundle = resourceBundle;
@@ -93,18 +96,13 @@ public class UpdatesPreferencesController implements FxController {
this.checkFailed = updateChecker.checkFailedProperty();
this.lastUpdateCheckMessage = Bindings.createStringBinding(this::getLastUpdateCheckMessage, lastSuccessfulUpdateCheck);
this.downloadsUri = DOWNLOADS_URI_TEMPLATE.formatted(URLEncoder.encode(currentVersion, StandardCharsets.US_ASCII));
this.updateMechanism = UpdateMechanism.get();
this.updatePreparationTask = new Task<>() { // TODO custom class?
@Override
protected UpdateProcess call() throws IOException, InterruptedException {
var updateProcess = updateMechanism.prepareUpdate();
do {
updateProgress(updateProcess.preparationProgress(), 1.0);
} while (!updateProcess.await(100, TimeUnit.MILLISECONDS));
return updateProcess;
}
};
this.updateButtonTitle = Bindings.createStringBinding(this::getUpdateButtonTitle, updatePreparationTask.stateProperty());
this.updateMechanism = UpdateMechanism.get().orElse(fallbackUpdateMechanism);
this.updateService = new UpdateService(updateMechanism);
this.updateButtonTitle = Bindings.createStringBinding(this::getUpdateButtonTitle, updateService.stateProperty(), updateService.messageProperty());
this.updateReady = updateService.stateProperty().isEqualTo(Worker.State.READY);
updateService.setOnSucceeded(this::updateSucceeded);
updateService.setOnFailed(this::updateFailed);
}
public void initialize() {
@@ -136,28 +134,27 @@ public class UpdatesPreferencesController implements FxController {
@FXML
public void doUpdate() {
if (updatePreparationTask.isDone()) {
try {
// TODO: check if all vaults closed?
var restartProcess = updatePreparationTask.get().applyUpdate();
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
}
} else if (updatePreparationTask.isRunning()) {
throw new IllegalStateException("Update already in progress");
} else if (updatePreparationTask.isCancelled()) {
throw new IllegalStateException("Update preparation task was cancelled");
updateService.start();
}
private void updateSucceeded(WorkerStateEvent workerStateEvent) {
assert workerStateEvent.getSource() == updateService;
var lastStep = updateService.getValue();
if (lastStep == UpdateStep.EXIT) {
LOG.info("Exiting app to update...");
Platform.exit();
} else if (lastStep == UpdateStep.RETRY) {
updateService.reset();
} else {
Thread.startVirtualThread(updatePreparationTask);
LOG.info("Update succeeded.");
}
}
private void updateFailed(WorkerStateEvent workerStateEvent) {
assert workerStateEvent.getSource() == updateService;
LOG.error("Update failed.", updateService.getException());
}
/* Observable Properties */
public ObjectBinding<ContentDisplay> checkForUpdatesButtonStateProperty() {
@@ -236,8 +233,8 @@ public class UpdatesPreferencesController implements FxController {
return checkFailed.getValue();
}
public Task<UpdateProcess> getUpdatePreparationTask() {
return updatePreparationTask;
public Service<UpdateStep> getUpdateService() {
return updateService;
}
public StringBinding updateButtonTitleProperty() {
@@ -245,11 +242,20 @@ public class UpdatesPreferencesController implements FxController {
}
public String getUpdateButtonTitle() {
return switch (updatePreparationTask.getState()) {
return switch (updateService.getState()) {
case READY -> updateMechanism.getName();
case SCHEDULED, RUNNING -> "Preparing Update..."; // TODO: resourceBundle.getString("preferences.updates.preparingUpdate")...
case SCHEDULED, RUNNING -> updateService.getMessage(); // "Preparing Update..."; // TODO: resourceBundle.getString("preferences.updates.preparingUpdate")...
case SUCCEEDED -> "Restart to Update"; // TODO: resourceBundle.getString("preferences.updates.readyToRestart")...
case FAILED, CANCELLED -> "failed";
};
}
public boolean isUpdateReady() {
return updateReady.get();
}
public BooleanBinding updateReadyProperty() {
return updateReady;
}
}

View File

@@ -17,8 +17,14 @@ public abstract class DownloadUpdateMechanism implements UpdateMechanism {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final String assetSuffix;
protected DownloadUpdateMechanism(String assetSuffix) {
this.assetSuffix = assetSuffix;
}
@Override
public boolean isUpdateAvailable() {
public boolean isUpdateAvailable(String currentVersion) {
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();
@@ -31,7 +37,8 @@ public abstract class DownloadUpdateMechanism implements UpdateMechanism {
var release = MAPPER.readValue(response.body(), GitHubRelease.class);
return release.assets.stream().anyMatch(a -> a.name.endsWith("arm64.dmg"));
return release.assets.stream().anyMatch(a -> a.name.endsWith(assetSuffix))
&& UpdateMechanism.isUpdateAvailable(release.tagName, currentVersion);
} catch (IOException | InterruptedException e) {
return false;
}

View File

@@ -0,0 +1,57 @@
package org.cryptomator.updater;
import org.cryptomator.common.Environment;
import org.cryptomator.integrations.common.DisplayName;
import org.cryptomator.integrations.common.Priority;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.integrations.update.UpdateStep;
import org.cryptomator.integrations.update.UpdateStepAdapter;
import org.cryptomator.ui.fxapp.FxApplicationScoped;
import org.jetbrains.annotations.Nullable;
import javax.inject.Inject;
import javafx.application.Application;
import javafx.application.Platform;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
@FxApplicationScoped
@Priority(Priority.FALLBACK)
@DisplayName("Show Download Page") // TODO localize
public class FallbackUpdateMechanism implements UpdateMechanism {
private static final String DOWNLOADS_URI_TEMPLATE = "https://cryptomator.org/downloads/" //
+ "?utm_source=cryptomator-desktop" //
+ "&utm_medium=update-notification&" //
+ "utm_campaign=app-update-%s";
private final Application app;
private final Environment env;
@Inject
public FallbackUpdateMechanism(Application app, Environment env) {
this.app = app;
this.env = env;
}
@Override
public boolean isUpdateAvailable(String currentVersion) {
// FIXME: what source shall we use? self-hosted JSON?
return true;
}
@Override
public UpdateStep firstStep() {
return UpdateStep.of("Go to download page", this::openDownloadPage); // TODO localize
}
private UpdateStep openDownloadPage() {
var downloadUrl = DOWNLOADS_URI_TEMPLATE.formatted(URLEncoder.encode(env.getAppVersion(), StandardCharsets.US_ASCII));
Platform.runLater(() -> {
app.getHostServices().showDocument(downloadUrl);
});
return UpdateStep.RETRY; // allow running this "update mechanism" as many times as the user wants
}
}

View File

@@ -3,9 +3,10 @@ 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.DownloadUpdateStep;
import org.cryptomator.integrations.update.UpdateFailedException;
import org.cryptomator.integrations.update.UpdateProcess;
import org.cryptomator.integrations.update.UpdateStep;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,8 +27,16 @@ public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism {
private static final Logger LOG = LoggerFactory.getLogger(MacOsDmgUpdateMechanism.class);
public MacOsDmgUpdateMechanism() {
String suffix = switch (System.getProperty("os.arch")) {
case "aarch64", "arm64" -> "arm64.dmg";
default -> "x64.dmg";
};
super(suffix);
}
@Override
public UpdateProcess prepareUpdate() throws UpdateFailedException {
public UpdateStep firstStep() throws UpdateFailedException {
try {
Path workDir = Files.createTempDirectory("cryptomator-update");
return new UpdateProcessImpl(workDir);
@@ -36,18 +45,30 @@ public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism {
}
}
private static class UpdateProcessImpl extends DownloadUpdateProcess {
private static class UpdateProcessImpl extends DownloadUpdateStep {
// 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");
private final Path workDir;
public UpdateProcessImpl(Path downloadPath) {
super(downloadPath, "update.dmg", UPDATE_URI, CHECKSUM,60_000_000L); // initially assume 60 MB for the update size
public UpdateProcessImpl(Path workDir) {
var destination = workDir.resolve("update.dmg");
super(UPDATE_URI, destination, CHECKSUM, 60_000_000L); // initially assume 60 MB for the update size
this.workDir = workDir;
}
@Override
protected void postDownload(Path downloadPath) throws IOException {
public @Nullable UpdateStep nextStep() throws IllegalStateException, IOException {
if (!isDone()) {
throw new IllegalStateException("Update not yet downloaded");
} else if (downloadException != null) {
throw new UpdateFailedException("Download failed", downloadException);
}
return UpdateStep.of("Mounting...", this::mount);
}
private UpdateStep mount() throws IOException {
// Extract Cryptomator.app from the .dmg file
String script = """
hdiutil attach 'update.dmg' -mountpoint "/Volumes/Cryptomator_${MOUNT_ID}" -nobrowse -quiet &&
@@ -68,16 +89,10 @@ public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism {
} catch (InterruptedException e) {
throw new InterruptedIOException("Failed to extract DMG, interrupted");
}
return UpdateStep.of("Restarting...", this::restart);
}
@Override
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);
}
public UpdateStep restart() throws IllegalStateException, IOException {
String selfPath = ProcessHandle.current().info().command().orElse("");
String installPath;
if (selfPath.startsWith("/Applications/Cryptomator.app")) {
@@ -85,7 +100,8 @@ public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism {
} 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);
installPath = "/Applications/Cryptomator.app";
// throw new UpdateFailedException("Cannot determine destination path for Cryptomator.app, current path: " + selfPath);
}
String script = """
while kill -0 ${CRYPTOMATOR_PID} 2> /dev/null; do sleep 0.5; done;
@@ -97,9 +113,10 @@ public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism {
processBuilder.directory(workDir.toFile());
processBuilder.environment().put("CRYPTOMATOR_PID", String.valueOf(ProcessHandle.current().pid()));
processBuilder.environment().put("CRYPTOMATOR_INSTALL_PATH", installPath);
return processBuilder.start().toHandle();
processBuilder.start();
return UpdateStep.EXIT;
}
}
}

View File

@@ -0,0 +1,59 @@
package org.cryptomator.updater;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.integrations.update.UpdateStep;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* A service that performs all update steps provided by the given {@link UpdateMechanism} in sequence.
*/
public class UpdateService extends Service<UpdateStep> {
private final UpdateMechanism updateMechanism;
public UpdateService(UpdateMechanism updateMechanism) {
this.updateMechanism = updateMechanism;
setExecutor(Executors.newVirtualThreadPerTaskExecutor()); }
@Override
protected Task<UpdateStep> createTask() {
return new RunAllStepsTask();
}
private class RunAllStepsTask extends Task<UpdateStep> {
@Override
protected UpdateStep call() throws IOException {
try {
UpdateStep step = updateMechanism.firstStep();
UpdateStep lastStep;
do {
step.start();
observeAndWaitFor(step);
lastStep = step;
step = step.nextStep();
} while (step != null);
return lastStep;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new InterruptedIOException("Update interrupted");
}
}
private void observeAndWaitFor(UpdateStep step) throws InterruptedException {
do {
updateProgress(step.preparationProgress(), 1.0);
updateMessage(step.description());
} while (!step.await(100, TimeUnit.MILLISECONDS));
}
}
}