Merge branch 'feature/self-update-poc' into feature/app-update

# Conflicts:
#	src/main/java/org/cryptomator/ui/preferences/UpdatesPreferencesController.java
#	src/main/resources/fxml/preferences_updates.fxml
This commit is contained in:
Sebastian Stenzel
2025-08-09 09:26:38 +02:00
12 changed files with 269 additions and 298 deletions

View File

@@ -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;
@@ -20,6 +21,7 @@ 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;
open module org.cryptomator.desktop {
requires static org.jetbrains.annotations;
@@ -50,7 +52,6 @@ open module org.cryptomator.desktop {
requires io.github.coffeelibs.tinyoauth2client;
requires org.slf4j;
requires org.apache.commons.lang3;
requires org.purejava.portal;
/* dagger bs */
requires jakarta.inject;
@@ -62,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;
provides TrayMenuController with AwtTrayMenuController;
provides Configurator with LogbackConfiguratorFactory;
provides SSLContextProvider with SSLContextWithWindowsCertStore, SSLContextWithMacKeychain, SSLContextWithPKCS12TrustStore;

View File

@@ -11,7 +11,6 @@ import org.cryptomator.common.keychain.KeychainModule;
import org.cryptomator.common.mount.MountModule;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.common.settings.SettingsProvider;
import org.cryptomator.common.updates.UpdatesModule;
import org.cryptomator.common.vaults.VaultComponent;
import org.cryptomator.common.vaults.VaultListModule;
import org.cryptomator.cryptolib.common.MasterkeyFileAccess;
@@ -31,7 +30,7 @@ import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@Module(subcomponents = {VaultComponent.class}, includes = {VaultListModule.class, KeychainModule.class, MountModule.class, UpdatesModule.class})
@Module(subcomponents = {VaultComponent.class}, includes = {VaultListModule.class, KeychainModule.class, MountModule.class})
public abstract class CommonsModule {
private static final Logger LOG = LoggerFactory.getLogger(CommonsModule.class);

View File

@@ -1,78 +0,0 @@
package org.cryptomator.common.updates;
import org.cryptomator.integrations.common.DisplayName;
import org.cryptomator.integrations.update.UpdateService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import java.util.List;
import java.util.Optional;
public class AppUpdateChecker {
private static final Logger LOG = LoggerFactory.getLogger(AppUpdateChecker.class);
private static final String DISPLAY_NAME_FLATPAK = "Update via Flatpak update";
private final List<UpdateService> updateServices;
@Inject
public AppUpdateChecker(List<UpdateService> updateServices) {
this.updateServices = updateServices;
}
public boolean isUpdateServiceAvailable(Optional<String> buildNumber) {
if (buildNumber.isEmpty()) {
return false;
}
switch (buildNumber.get()) {
case "flatpak-1" -> {
return !updateServices.isEmpty() && doServicesContainChannel(updateServices, DISPLAY_NAME_FLATPAK);
}
default -> {
LOG.error("Unexpected value 'buildNumber': {}", buildNumber.get());
return false;
}
}
}
public Object getUpdater(Optional<String> buildNumber) {
if (updateServices.isEmpty()) {
LOG.error("No UpdateService found");
return null;
}
switch (buildNumber.get()) {
case "flatpak-1" -> {
var flatpakService = getServiceForChannel(updateServices, DISPLAY_NAME_FLATPAK);
if(null == flatpakService) {
LOG.error("Required service for channel LINUX_FLATPAK not available");
return null;
} else {
return flatpakService.getLatestReleaseChecker();
}
}
default -> throw new IllegalStateException("Unexpected value 'buildNumber': " + buildNumber.get());
}
}
private boolean doServicesContainChannel(List<UpdateService> services, String displayName) {
return services.stream().anyMatch(service -> {
DisplayName annotation = service.getClass().getAnnotation(DisplayName.class);
return annotation != null && annotation.value().equals(displayName);
});
}
private UpdateService getServiceForChannel(List<UpdateService> services, String displayName) {
return services.stream().filter(service -> {
DisplayName annotation = service.getClass().getAnnotation(DisplayName.class);
return annotation != null && annotation.value().equals(displayName);
}).findFirst().orElse(null);
}
public UpdateService getServiceForChannel(String displayName) {
return updateServices.stream().filter(service -> {
DisplayName annotation = service.getClass().getAnnotation(DisplayName.class);
return annotation != null && annotation.value().equals(displayName);
}).findFirst().orElse(null);
}
}

View File

@@ -1,25 +0,0 @@
package org.cryptomator.common.updates;
import dagger.Module;
import dagger.Provides;
import org.cryptomator.integrations.update.UpdateService;
import javax.inject.Singleton;
import java.util.List;
import java.util.Optional;
@Module
public class UpdatesModule {
@Provides
@Singleton
static List<UpdateService> provideSupportedUpdateServices() {
return UpdateService.get().toList();
}
@Provides
@Singleton
static Optional<UpdateService> provideUpdateService(List<UpdateService> updateServices) {
return updateServices.stream().findFirst();
}
}

View File

@@ -3,17 +3,10 @@ package org.cryptomator.ui.fxapp;
import org.cryptomator.common.Environment;
import org.cryptomator.common.SemVerComparator;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.common.updates.AppUpdateChecker;
import org.cryptomator.integrations.update.Progress;
import org.cryptomator.integrations.update.ProgressListener;
import org.cryptomator.integrations.update.UpdateFailedException;
import org.cryptomator.ui.preferences.UpdatesPreferencesController;
import org.purejava.portal.rest.UpdateCheckerTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.ObjectProperty;
@@ -26,105 +19,45 @@ import javafx.concurrent.Worker;
import javafx.concurrent.WorkerStateEvent;
import javafx.util.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
@Deprecated(forRemoval = true) // to be replaced by fallback org.cryptomator.integrations.update.UpdateMechanism
@FxApplicationScoped
public class UpdateChecker {
private static final Logger LOG = LoggerFactory.getLogger(UpdateChecker.class);
private static final Duration AUTO_CHECK_DELAY = Duration.seconds(5);
private static final String DISPLAY_NAME_FLATPAK = "Update via Flatpak update";
private final Environment env;
private final Settings settings;
private final StringProperty latestVersion = new SimpleStringProperty();
private final StringProperty latestAppUpdaterVersion = new SimpleStringProperty();
private final ScheduledService<String> updateCheckerService;
private final ObjectProperty<UpdateCheckState> state = new SimpleObjectProperty<>(UpdateCheckState.NOT_CHECKED);
private final ObjectProperty<Instant> lastSuccessfulUpdateCheck;
private final Comparator<String> versionComparator = new SemVerComparator();
private final BooleanBinding updateAvailable;
private final BooleanBinding appUpdateAvailable;
private final BooleanBinding checkFailed;
private final AppUpdateChecker updateChecker;
private final FxApplicationTerminator appTerminator;
@Inject
UpdateChecker(Settings settings, //
Environment env, //
ScheduledService<String> updateCheckerService, //
AppUpdateChecker updateChecker, //
FxApplicationTerminator appTerminator) {
ScheduledService<String> updateCheckerService) {
this.env = env;
this.settings = settings;
this.updateCheckerService = updateCheckerService;
this.lastSuccessfulUpdateCheck = settings.lastSuccessfulUpdateCheck;
this.updateAvailable = Bindings.createBooleanBinding(this::isUpdateAvailable, latestVersion);
this.appUpdateAvailable = Bindings.createBooleanBinding(this::isAppUpdateAvailable, latestAppUpdaterVersion);
this.checkFailed = Bindings.equal(UpdateCheckState.CHECK_FAILED, state);
this.updateChecker = updateChecker;
this.appTerminator = appTerminator;
}
public void automaticallyCheckForUpdatesIfEnabled() {
if (!env.disableUpdateCheck() && settings.checkForUpdates.get()) {
decideOnUpdateChecker();
startCheckingForUpdates(AUTO_CHECK_DELAY);
}
}
public void checkForUpdatesNow() {
decideOnUpdateChecker();
}
private void decideOnUpdateChecker() {
if (updateChecker.isUpdateServiceAvailable(env.getBuildNumber())) { // prefer AppUpdateChecker
switch (env.getBuildNumber().get()) {
case "flatpak-1" -> startCheckingWithFlatpakUpdater((UpdateCheckerTask) updateChecker.getUpdater(env.getBuildNumber()), Duration.ZERO);
default -> LOG.error("Unexpected value 'buildNumber': {}", env.getBuildNumber().get());
}
} else { // fallback is the "redirect user to website" approach
startCheckingForUpdates(Duration.ZERO);
}
}
public void updateAppNow() throws UpdateFailedException {
var service = updateChecker.getServiceForChannel(DISPLAY_NAME_FLATPAK);
service.triggerUpdate();
}
public void terminateFlatpakOnUpdateCompleted(Runnable onComplete, UpdatesPreferencesController controller) {
var service = updateChecker.getServiceForChannel(DISPLAY_NAME_FLATPAK);
service.addProgressListener(new ProgressListener() {
@Override
public void onProgress(Progress progress) {
if (progress.getStatus() == 1) {
LOG.info("No update to install");
return;
}
if (progress.getStatus() == 3) {
LOG.info("Update failed");
return;
}
if (progress.getStatus() == 0 || progress.getStatus() == 2) {
LOG.debug("Update progess is at percentage: {} and has status: {}", progress.getProgress(), progress.getStatus());
Platform.runLater(() -> controller.flatpakProgressProperty().set(progress.getProgress() / 100.0));
}
if (progress.getStatus() == 2 && progress.getProgress() == 100) {
LOG.debug("Update successfully finished, restarting App now");
service.removeProgressListener(this);
if (onComplete != null) {
Platform.runLater(onComplete);
}
service.spawnApp();
appTerminator.terminate();
}
}
});
startCheckingForUpdates(Duration.ZERO);
}
private void startCheckingForUpdates(Duration initialDelay) {
@@ -137,31 +70,11 @@ public class UpdateChecker {
updateCheckerService.start();
}
private void startCheckingWithFlatpakUpdater(UpdateCheckerTask service, Duration initialDelay) {
service.cancel();
service.reset();
service.setDelay(convertFxToJavaTime(initialDelay));
service.setOnRunning(this::checkStarted);
service.setOnSucceeded(this::checkSucceeded);
service.setOnFailed(this::checkFailed);
service.start();
}
private java.time.Duration convertFxToJavaTime(javafx.util.Duration fxDuration) {
double millis = fxDuration.toMillis();
return java.time.Duration.of((long) millis, ChronoUnit.MILLIS);
}
private void checkStarted(WorkerStateEvent event) {
LOG.debug("Checking for updates...");
state.set(UpdateCheckState.IS_CHECKING);
}
private void checkStarted() {
LOG.debug("Checking for updates...");
state.set(UpdateCheckState.IS_CHECKING);
}
private void checkSucceeded(WorkerStateEvent event) {
var latestVersionString = updateCheckerService.getValue();
LOG.info("Current version: {}, latest version: {}", getCurrentVersion(), latestVersionString);
@@ -170,21 +83,10 @@ public class UpdateChecker {
state.set(UpdateCheckState.CHECK_SUCCESSFUL);
}
private void checkSucceeded(String version) {
LOG.info("Current version: {}, latest version: {}", getCurrentVersion(), version);
lastSuccessfulUpdateCheck.set(Instant.now());
latestAppUpdaterVersion.set(version);
state.set(UpdateCheckState.CHECK_SUCCESSFUL);
}
private void checkFailed(WorkerStateEvent event) {
state.set(UpdateCheckState.CHECK_FAILED);
}
private void checkFailed(Throwable throwable) {
state.set(UpdateCheckState.CHECK_FAILED);
}
public enum UpdateCheckState {
NOT_CHECKED,
IS_CHECKING,
@@ -201,38 +103,23 @@ public class UpdateChecker {
return latestVersion;
}
public ReadOnlyStringProperty latestAppUpdaterVersionProperty() {
return latestAppUpdaterVersion;
}
public BooleanBinding updateAvailableProperty() {
return updateAvailable;
}
public BooleanBinding appUpdateAvailableProperty() {
return appUpdateAvailable;
}
public BooleanBinding checkFailedProperty() {
return checkFailed;
}
public boolean isUpdateAvailable(StringProperty versionProperty) {
public boolean isUpdateAvailable() {
String currentVersion = getCurrentVersion();
String latestVersionString = versionProperty.get();
String latestVersionString = latestVersion.get();
if (currentVersion == null || latestVersionString == null) {
return false;
} else {
return versionComparator.compare(currentVersion, latestVersionString) < 0;
}
return versionComparator.compare(currentVersion, latestVersionString) < 0;
}
public boolean isUpdateAvailable() {
return isUpdateAvailable(latestVersion);
}
public boolean isAppUpdateAvailable() {
return isUpdateAvailable(latestAppUpdaterVersion);
}
public ObjectProperty<Instant> lastSuccessfulUpdateCheckProperty() {

View File

@@ -51,7 +51,7 @@ public class MainWindowController implements FxController {
this.selectedVault = selectedVault;
this.settings = settings;
this.appWindows = appWindows;
this.updateAvailable = updateChecker.updateAvailableProperty().or(updateChecker.appUpdateAvailableProperty());
this.updateAvailable = updateChecker.updateAvailableProperty();
this.licenseHolder = licenseHolder;
updateChecker.automaticallyCheckForUpdatesIfEnabled();

View File

@@ -2,9 +2,8 @@ package org.cryptomator.ui.preferences;
import org.cryptomator.common.Environment;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.common.updates.AppUpdateChecker;
import org.cryptomator.integrations.common.DisplayName;
import org.cryptomator.integrations.update.UpdateFailedException;
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.slf4j.Logger;
@@ -13,21 +12,20 @@ import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javafx.animation.PauseTransition;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@@ -38,6 +36,8 @@ 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
@@ -48,7 +48,6 @@ public class UpdatesPreferencesController implements FxController {
+ "?utm_source=cryptomator-desktop" //
+ "&utm_medium=update-notification&" //
+ "utm_campaign=app-update-%s";
private static final String DISPLAY_NAME_FLATPAK = "Update via Flatpak update";
private final Application application;
private final Environment environment;
@@ -56,7 +55,6 @@ public class UpdatesPreferencesController implements FxController {
private final Settings settings;
private final Environment env;
private final UpdateChecker updateChecker;
private final AppUpdateChecker appUpdateChecker;
private final ObjectBinding<ContentDisplay> checkForUpdatesButtonState;
private final ReadOnlyStringProperty latestVersion;
private final ObservableValue<Instant> lastSuccessfulUpdateCheck;
@@ -64,49 +62,53 @@ public class UpdatesPreferencesController implements FxController {
private final ObservableValue<String> timeDifferenceMessage;
private final String currentVersion;
private final BooleanBinding updateAvailable;
private final BooleanBinding appUpdateAvailable;
private final BooleanBinding checkFailed;
private final BooleanProperty upToDateLabelVisible = new SimpleBooleanProperty(false);
private final DateTimeFormatter formatter;
private final BooleanBinding upToDate;
private final String downloadsUri;
private final BooleanProperty updatingFlatpak = new SimpleBooleanProperty(false);
private final DoubleProperty flatpakProgress = new SimpleDoubleProperty(ProgressBar.INDETERMINATE_PROGRESS);
private final UpdateMechanism updateMechanism;
public final Task<UpdateProcess> updatePreparationTask;
private final StringBinding updateButtonTitle;
/* FXML */
public CheckBox checkForUpdatesCheckbox;
public Label flatpakButtonLabel;
@Inject
UpdatesPreferencesController(Application application, Environment environment, ResourceBundle resourceBundle, Settings settings, UpdateChecker updateChecker, AppUpdateChecker appUpdateChecker, Environment env) {
UpdatesPreferencesController(Application application, Environment environment, ResourceBundle resourceBundle, Settings settings, UpdateChecker updateChecker, Environment env) {
this.application = application;
this.environment = environment;
this.resourceBundle = resourceBundle;
this.settings = settings;
this.env = env;
this.updateChecker = updateChecker;
this.appUpdateChecker = appUpdateChecker;
this.checkForUpdatesButtonState = Bindings.when(updateChecker.checkingForUpdatesProperty()).then(ContentDisplay.LEFT).otherwise(ContentDisplay.TEXT_ONLY);
this.latestVersion = updateChecker.latestVersionProperty();
this.lastSuccessfulUpdateCheck = updateChecker.lastSuccessfulUpdateCheckProperty();
this.timeDifferenceMessage = Bindings.createStringBinding(this::getTimeDifferenceMessage, lastSuccessfulUpdateCheck);
this.currentVersion = environment.getAppVersion();
this.updateAvailable = updateChecker.updateAvailableProperty();
this.appUpdateAvailable = updateChecker.appUpdateAvailableProperty();
this.formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault());
this.upToDate = updateChecker.updateCheckStateProperty().isEqualTo(UpdateChecker.UpdateCheckState.CHECK_SUCCESSFUL).and(latestVersion.isEqualTo(currentVersion));
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());
}
public void initialize() {
checkForUpdatesCheckbox.selectedProperty().bindBidirectional(settings.checkForUpdates);
switch (env.getBuildNumber().get()) {
case "flatpak-1" -> flatpakButtonLabel.setText(appUpdateChecker.getServiceForChannel(DISPLAY_NAME_FLATPAK).getClass().getAnnotation(DisplayName.class).value());
default -> LOG.error("Unexpected value 'buildNumber': {}", env.getBuildNumber().get());
}
upToDate.addListener((_, _, newVal) -> {
if (newVal) {
upToDateLabelVisible.set(true);
@@ -122,20 +124,6 @@ public class UpdatesPreferencesController implements FxController {
updateChecker.checkForUpdatesNow();
}
@FXML
public void updateFlatpakNow() {
updatingFlatpak.set(true);
updateChecker.terminateFlatpakOnUpdateCompleted(
() -> updatingFlatpak.set(false), this
);
try {
updateChecker.updateAppNow();
} catch (UpdateFailedException e) {
updatingFlatpak.set(false);
}
}
@FXML
public void visitDownloadsPage() {
application.getHostServices().showDocument(downloadsUri);
@@ -146,6 +134,30 @@ public class UpdatesPreferencesController implements FxController {
environment.getLogDir().ifPresent(logDirPath -> application.getHostServices().showDocument(logDirPath.toUri().toString()));
}
@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");
} else {
Thread.startVirtualThread(updatePreparationTask);
}
}
/* Observable Properties */
public ObjectBinding<ContentDisplay> checkForUpdatesButtonStateProperty() {
@@ -212,18 +224,10 @@ public class UpdatesPreferencesController implements FxController {
return updateAvailable;
}
public BooleanBinding appUdateAvailableProperty() {
return appUpdateAvailable;
}
public boolean isUpdateAvailable() {
return updateAvailable.get();
}
public boolean isAppUpdateAvailable() {
return appUpdateAvailable.get();
}
public BooleanBinding checkFailedProperty() {
return checkFailed;
}
@@ -232,19 +236,20 @@ public class UpdatesPreferencesController implements FxController {
return checkFailed.getValue();
}
public BooleanProperty updatingFlatpakProperty() {
return updatingFlatpak;
public Task<UpdateProcess> getUpdatePreparationTask() {
return updatePreparationTask;
}
public boolean isUpdatingFlatpak() {
return updatingFlatpak.get();
public StringBinding updateButtonTitleProperty() {
return updateButtonTitle;
}
public DoubleProperty flatpakProgressProperty() {
return flatpakProgress;
}
public double getFlatpakProgress() {
return flatpakProgress.get();
public String getUpdateButtonTitle() {
return switch (updatePreparationTask.getState()) {
case READY -> updateMechanism.getName();
case SCHEDULED, RUNNING -> "Preparing Update..."; // TODO: resourceBundle.getString("preferences.updates.preparingUpdate")...
case SUCCEEDED -> "Restart to Update"; // TODO: resourceBundle.getString("preferences.updates.readyToRestart")...
case FAILED, CANCELLED -> "failed";
};
}
}

View File

@@ -0,0 +1,52 @@
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;
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<InputStream> 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<Asset> assets
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Asset(
String name,
@JsonProperty("browser_download_url") String downloadUrl
) {}
}

View File

@@ -0,0 +1,105 @@
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;
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 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 {
// 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 downloadPath) {
super(downloadPath, "update.dmg", UPDATE_URI, CHECKSUM,60_000_000L); // initially assume 60 MB for the update size
}
@Override
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 &&
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());
}
LOG.debug("Update ready: {}", workDir.resolve("Cryptomator.app"));
} catch (InterruptedException e) {
throw new InterruptedIOException("Failed to extract DMG, interrupted");
}
}
@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);
}
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);
}
String script = """
while kill -0 ${CRYPTOMATOR_PID} 2> /dev/null; do sleep 0.5; done;
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()));
processBuilder.environment().put("CRYPTOMATOR_INSTALL_PATH", installPath);
return processBuilder.start().toHandle();
}
}
}