Merge pull request #3948 from purejava/feature/app-update

Implement new update API
This commit is contained in:
Sebastian Stenzel
2025-11-24 14:41:55 +01:00
committed by GitHub
32 changed files with 822 additions and 581 deletions

View File

@@ -136,6 +136,7 @@ jobs:
--java-options "-Dcryptomator.integrationsMac.keychainServiceName=\"Cryptomator\""
--java-options "-Dcryptomator.mountPointsDir=\"@{userhome}/Library/Application Support/Cryptomator/mnt\""
--java-options "-Dcryptomator.showTrayIcon=true"
--java-options "-Dcryptomator.updateMechanism=org.cryptomator.macos.update.DmgUpdateMechanism"
--java-options "-Dcryptomator.buildNumber=\"dmg-${{ needs.get-version.outputs.revNum }}\""
--mac-package-identifier org.cryptomator
--resource-dir dist/mac/resources

View File

@@ -134,6 +134,7 @@ jobs:
--java-options "-Dcryptomator.integrationsMac.keychainServiceName=\"Cryptomator\""
--java-options "-Dcryptomator.mountPointsDir=\"@{userhome}/Library/Application Support/Cryptomator/mnt\""
--java-options "-Dcryptomator.showTrayIcon=true"
--java-options "-Dcryptomator.updateMechanism=org.cryptomator.macos.update.DmgUpdateMechanism"
--java-options "-Dcryptomator.buildNumber=\"dmg-${{ needs.get-version.outputs.revNum }}\""
--java-options "-XX:ErrorFile=/cryptomator/cryptomator_crash.log"
--mac-package-identifier org.cryptomator

View File

@@ -1,10 +1,8 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="SpellCheckingInspection" enabled="true" level="TYPO" enabled_by_default="true">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
<inspection_tool class="Deprecation" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="DEPRECATED_ATTRIBUTES" />
<inspection_tool class="MarkedForRemoval" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="MARKED_FOR_REMOVAL_ATTRIBUTES" />
<inspection_tool class="RedundantScheduledForRemovalAnnotation" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="MARKED_FOR_REMOVAL_ATTRIBUTES" />
</profile>
</component>

View File

@@ -5,7 +5,7 @@
</envs>
<option name="MAIN_CLASS_NAME" value="org.cryptomator.launcher.Cryptomator" />
<module name="cryptomator" />
<option name="VM_PARAMETERS" value="-Dapple.awt.enableTemplateImages=true -Dcryptomator.settingsPath=&quot;@{userhome}/Library/Application Support/Cryptomator/settings.json&quot; -Dcryptomator.p12Path=&quot;@{userhome}/Library/Application Support/Cryptomator/key.p12&quot; -Dcryptomator.ipcSocketPath=&quot;@{userhome}/Library/Application Support/Cryptomator/ipc.socket&quot; -Dcryptomator.logDir=&quot;@{userhome}/Library/Logs/Cryptomator&quot; -Dcryptomator.pluginDir=&quot;@{userhome}/Library/Application Support/Cryptomator/Plugins&quot; -Dcryptomator.mountPointsDir=&quot;@{userhome}/Cryptomator&quot; -Dcryptomator.showTrayIcon=true -Dcryptomator.integrationsMac.keychainServiceName=Cryptomator -Xss2m -Xmx512m -ea --enable-preview --enable-native-access=org.cryptomator.jfuse.mac,javafx.graphics" />
<option name="VM_PARAMETERS" value="-Dapple.awt.enableTemplateImages=true -Dcryptomator.settingsPath=&quot;@{userhome}/Library/Application Support/Cryptomator/settings.json&quot; -Dcryptomator.p12Path=&quot;@{userhome}/Library/Application Support/Cryptomator/key.p12&quot; -Dcryptomator.ipcSocketPath=&quot;@{userhome}/Library/Application Support/Cryptomator/ipc.socket&quot; -Dcryptomator.logDir=&quot;@{userhome}/Library/Logs/Cryptomator&quot; -Dcryptomator.pluginDir=&quot;@{userhome}/Library/Application Support/Cryptomator/Plugins&quot; -Dcryptomator.mountPointsDir=&quot;@{userhome}/Cryptomator&quot; -Dcryptomator.showTrayIcon=true -Dcryptomator.integrationsMac.keychainServiceName=Cryptomator -Dcryptomator.updateMechanism=org.cryptomator.macos.update.DmgUpdateMechanism -Xss2m -Xmx512m -ea --enable-preview --enable-native-access=org.cryptomator.jfuse.mac,javafx.graphics" />
<method v="2">
<option name="Make" enabled="true" />
</method>

View File

@@ -123,6 +123,7 @@ ${JAVA_HOME}/bin/jpackage \
--java-options "-Dcryptomator.integrationsMac.keychainServiceName=\"${APP_NAME}\"" \
--java-options "-Dcryptomator.mountPointsDir=\"@{userhome}/Library/Application Support${APP_NAME}/mnt\"" \
--java-options "-Dcryptomator.showTrayIcon=true" \
--java-options "-Dcryptomator.updateMechanism=org.cryptomator.macos.update.DmgUpdateMechanism" \
--java-options "-Dcryptomator.buildNumber=\"dmg-${REVISION_NO}\"" \
--mac-package-identifier ${PACKAGE_IDENTIFIER} \
--resource-dir ../resources

20
pom.xml
View File

@@ -34,10 +34,10 @@
<!-- cryptomator dependencies -->
<cryptomator.cryptofs.version>2.9.0</cryptomator.cryptofs.version>
<cryptomator.integrations.version>1.7.0</cryptomator.integrations.version>
<cryptomator.integrations.version>1.8.0-beta1</cryptomator.integrations.version>
<cryptomator.integrations.win.version>1.5.1</cryptomator.integrations.win.version>
<cryptomator.integrations.mac.version>1.4.1</cryptomator.integrations.mac.version>
<cryptomator.integrations.linux.version>1.6.1</cryptomator.integrations.linux.version>
<cryptomator.integrations.mac.version>1.5.0-beta1</cryptomator.integrations.mac.version>
<cryptomator.integrations.linux.version>1.7.0-beta1</cryptomator.integrations.linux.version>
<cryptomator.fuse.version>5.1.0</cryptomator.fuse.version>
<cryptomator.webdav.version>3.0.0</cryptomator.webdav.version>
@@ -75,6 +75,20 @@
<surefire.jacoco.args></surefire.jacoco.args>
</properties>
<repositories>
<repository>
<name>Central Portal Snapshots</name>
<id>central-portal-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<dependencies>
<!-- Cryptomator Libs -->
<dependency>

View File

@@ -50,12 +50,12 @@ open module org.cryptomator.desktop {
requires io.github.coffeelibs.tinyoauth2client;
requires org.slf4j;
requires org.apache.commons.lang3;
requires com.github.benmanes.caffeine;
/* dagger bs */
requires jakarta.inject;
requires static javax.inject;
requires java.compiler;
requires com.github.benmanes.caffeine;
uses org.cryptomator.common.locationpresets.LocationPresetsProvider;
uses SSLContextProvider;

View File

@@ -74,13 +74,6 @@ public abstract class CommonsModule {
return new MasterkeyFileAccess(Constants.PEPPER, csprng);
}
@Provides
@Singleton
@Named("SemVer")
static Comparator<String> providesSemVerComparator() {
return new SemVerComparator();
}
@Provides
@Singleton
static Optional<RevealPathService> provideRevealPathService() {

View File

@@ -124,6 +124,15 @@ public class Environment {
return Optional.ofNullable(System.getProperty(BUILD_NUMBER_PROP_NAME));
}
/**
* Returns the app version concatenated with the build number (if defined).
*
* @return version string formatted like {@code 1.2.3-4567} or {@code 1.2.3} if no build number is defined.
*/
public String getAppVersionWithBuildNumber() {
return getAppVersion() + getBuildNumber().map("-"::concat).orElse("");
}
public Optional<Path> getPluginDir() {
return getPath(PLUGIN_DIR_PROP_NAME);
}

View File

@@ -1,81 +0,0 @@
/*******************************************************************************
* Copyright (c) 2016, 2017 Sebastian Stenzel and others.
* All rights reserved.
* This program and the accompanying materials are made available under the terms of the accompanying LICENSE file.
*
* Contributors:
* Sebastian Stenzel - initial API and implementation
*******************************************************************************/
package org.cryptomator.common;
import org.apache.commons.lang3.StringUtils;
import java.util.Comparator;
/**
* Compares version strings according to <a href="http://semver.org/spec/v2.0.0.html">SemVer 2.0.0</a>.
*/
public class SemVerComparator implements Comparator<String> {
private static final char VERSION_SEP = '.'; // http://semver.org/spec/v2.0.0.html#spec-item-2
private static final String PRE_RELEASE_SEP = "-"; // http://semver.org/spec/v2.0.0.html#spec-item-9
private static final String BUILD_SEP = "+"; // http://semver.org/spec/v2.0.0.html#spec-item-10
@Override
public int compare(String version1, String version2) {
// "Build metadata SHOULD be ignored when determining version precedence.
// Thus two versions that differ only in the build metadata, have the same precedence."
String v1WithoutBuildMetadata = StringUtils.substringBefore(version1, BUILD_SEP);
String v2WithoutBuildMetadata = StringUtils.substringBefore(version2, BUILD_SEP);
if (v1WithoutBuildMetadata.equals(v2WithoutBuildMetadata)) {
return 0;
}
String v1MajorMinorPatch = StringUtils.substringBefore(v1WithoutBuildMetadata, PRE_RELEASE_SEP);
String v2MajorMinorPatch = StringUtils.substringBefore(v2WithoutBuildMetadata, PRE_RELEASE_SEP);
String v1PreReleaseVersion = StringUtils.substringAfter(v1WithoutBuildMetadata, PRE_RELEASE_SEP);
String v2PreReleaseVersion = StringUtils.substringAfter(v2WithoutBuildMetadata, PRE_RELEASE_SEP);
return compare(v1MajorMinorPatch, v1PreReleaseVersion, v2MajorMinorPatch, v2PreReleaseVersion);
}
private int compare(String v1MajorMinorPatch, String v1PreReleaseVersion, String v2MajorMinorPatch, String v2PreReleaseVersion) {
int comparisonResult = compareNumericallyThenLexicographically(v1MajorMinorPatch, v2MajorMinorPatch);
if (comparisonResult == 0) {
if (v1PreReleaseVersion.isEmpty()) {
return 1; // 1.0.0 > 1.0.0-BETA
} else if (v2PreReleaseVersion.isEmpty()) {
return -1; // 1.0.0-BETA < 1.0.0
} else {
return compareNumericallyThenLexicographically(v1PreReleaseVersion, v2PreReleaseVersion);
}
} else {
return comparisonResult;
}
}
private int compareNumericallyThenLexicographically(String version1, String version2) {
final String[] vComps1 = StringUtils.split(version1, VERSION_SEP);
final String[] vComps2 = StringUtils.split(version2, VERSION_SEP);
final int commonCompCount = Math.min(vComps1.length, vComps2.length);
for (int i = 0; i < commonCompCount; i++) {
int subversionComparisonResult = 0;
try {
final int v1 = Integer.parseInt(vComps1[i]);
final int v2 = Integer.parseInt(vComps2[i]);
subversionComparisonResult = v1 - v2;
} catch (NumberFormatException ex) {
// ok, lets compare this fragment lexicographically
subversionComparisonResult = vComps1[i].compareTo(vComps2[i]);
}
if (subversionComparisonResult != 0) {
return subversionComparisonResult;
}
}
// all in common so far? longest version string is considered the higher version:
return vComps1.length - vComps2.length;
}
}

View File

@@ -25,10 +25,8 @@ import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.NodeOrientation;
import java.nio.file.Path;
import java.time.Instant;
import java.util.function.Consumer;
public class Settings {
@@ -53,6 +51,7 @@ public class Settings {
static final String DEFAULT_USER_INTERFACE_ORIENTATION = NodeOrientation.LEFT_TO_RIGHT.name();
public static final Instant DEFAULT_TIMESTAMP = Instant.parse("2000-01-01T00:00:00Z");
private final SettingsProvider provider;
public final ObservableList<VaultSettings> directories;
public final BooleanProperty startHidden;
public final BooleanProperty autoCloseVaults;
@@ -78,13 +77,12 @@ public class Settings {
public final ObjectProperty<Instant> lastUpdateCheckReminder;
public final ObjectProperty<Instant> lastSuccessfulUpdateCheck;
public final ObjectProperty<Path> previouslyUsedVaultDirectory;
public final StringProperty lastUpdateAttemptedByVersion;
private Consumer<Settings> saveCmd;
public static Settings create(Environment env) {
public static Settings create(SettingsProvider provider, Environment env) {
var defaults = new SettingsJson();
defaults.showTrayIcon = env.showTrayIcon();
return new Settings(defaults);
return new Settings(provider, defaults);
}
/**
@@ -92,7 +90,8 @@ public class Settings {
*
* @param json The parsed settings.json
*/
Settings(SettingsJson json) {
Settings(SettingsProvider provider, SettingsJson json) {
this.provider = provider;
this.directories = FXCollections.observableArrayList(VaultSettings::observables);
this.startHidden = new SimpleBooleanProperty(this, "startHidden", json.startHidden);
this.autoCloseVaults = new SimpleBooleanProperty(this, "autoCloseVaults", json.autoCloseVaults);
@@ -118,6 +117,7 @@ public class Settings {
this.lastUpdateCheckReminder = new SimpleObjectProperty<>(this, "lastUpdateCheckReminder", json.lastReminderForUpdateCheck);
this.lastSuccessfulUpdateCheck = new SimpleObjectProperty<>(this, "lastSuccessfulUpdateCheck", json.lastSuccessfulUpdateCheck);
this.previouslyUsedVaultDirectory = new SimpleObjectProperty<>(this, "previouslyUsedVaultDirectory", json.previouslyUsedVaultDirectory);
this.lastUpdateAttemptedByVersion = new SimpleStringProperty(this, "lastUpdateAttemptedByVersion", json.lastUpdateAttemptedByVersion);
this.directories.addAll(json.directories.stream().map(VaultSettings::new).toList());
@@ -148,6 +148,7 @@ public class Settings {
lastUpdateCheckReminder.addListener(this::somethingChanged);
lastSuccessfulUpdateCheck.addListener(this::somethingChanged);
previouslyUsedVaultDirectory.addListener(this::somethingChanged);
lastUpdateAttemptedByVersion.addListener(this::somethingChanged);
}
@SuppressWarnings("deprecation")
@@ -210,6 +211,7 @@ public class Settings {
json.lastReminderForUpdateCheck = lastUpdateCheckReminder.get();
json.lastSuccessfulUpdateCheck = lastSuccessfulUpdateCheck.get();
json.previouslyUsedVaultDirectory = previouslyUsedVaultDirectory.get();
json.lastUpdateAttemptedByVersion = lastUpdateAttemptedByVersion.get();
return json;
}
@@ -222,20 +224,12 @@ public class Settings {
}
}
// TODO rename to setChangeListener
void setSaveCmd(Consumer<Settings> saveCmd) {
this.saveCmd = saveCmd;
}
private void somethingChanged(@SuppressWarnings("unused") Observable observable) {
this.save();
provider.scheduleSave(this);
}
void save() {
if (saveCmd != null) {
saveCmd.accept(this);
}
public void saveNow() {
provider.saveNow(this);
}
}

View File

@@ -96,4 +96,7 @@ class SettingsJson {
@JsonProperty("previouslyUsedVaultDirectory")
Path previouslyUsedVaultDirectory;
@JsonProperty("lastUpdateAttemptedByVersion")
String lastUpdateAttemptedByVersion;
}

View File

@@ -26,7 +26,9 @@ import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@@ -61,8 +63,7 @@ public class SettingsProvider implements Supplier<Settings> {
Settings settings = env.getSettingsPath() //
.flatMap(this::tryLoad) //
.findFirst() //
.orElseGet(() -> Settings.create(env));
settings.setSaveCmd(this::scheduleSave);
.orElseGet(() -> Settings.create(this, env));
return settings;
}
@@ -71,7 +72,7 @@ public class SettingsProvider implements Supplier<Settings> {
try (InputStream in = Files.newInputStream(path, StandardOpenOption.READ)) {
var json = JSON.reader().readValue(in, SettingsJson.class);
LOG.info("Settings loaded from {}", path);
var settings = new Settings(json);
var settings = new Settings(this, json);
return Stream.of(settings);
} catch (JacksonException e) {
LOG.warn("Failed to parse json file {}", path, e);
@@ -84,19 +85,33 @@ public class SettingsProvider implements Supplier<Settings> {
}
}
private void scheduleSave(Settings settings) {
if (settings == null) {
return;
void saveNow(Settings settings) {
try {
scheduleSave(settings, 0L).get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Saving settings was interrupted.", e);
} catch (ExecutionException e) {
LOG.error("Unexpected exception while saving.", e);
}
final Optional<Path> settingsPath = env.getSettingsPath().findFirst(); // always save to preferred (first) path
settingsPath.ifPresent(path -> {
Runnable saveCommand = () -> this.save(settings, path);
ScheduledFuture<?> scheduledTask = scheduler.schedule(saveCommand, SAVE_DELAY_MS, TimeUnit.MILLISECONDS);
ScheduledFuture<?> previouslyScheduledTask = scheduledSaveCmd.getAndSet(scheduledTask);
if (previouslyScheduledTask != null) {
previouslyScheduledTask.cancel(false);
}
});
}
void scheduleSave(Settings settings) {
scheduleSave(settings, SAVE_DELAY_MS);
}
private Future<?> scheduleSave(Settings settings, long delayMillis) {
if (settings == null) {
return CompletableFuture.completedFuture(null);
}
final Path settingsPath = env.getSettingsPath().findFirst().orElseThrow(); // always save to preferred (first) path
Runnable saveCommand = () -> this.save(settings, settingsPath);
ScheduledFuture<?> scheduledTask = scheduler.schedule(saveCommand, delayMillis, TimeUnit.MILLISECONDS);
ScheduledFuture<?> previouslyScheduledTask = scheduledSaveCmd.getAndSet(scheduledTask);
if (previouslyScheduledTask != null) {
previouslyScheduledTask.cancel(false);
}
return scheduledTask;
}
private void save(Settings settings, Path settingsPath) {
@@ -107,7 +122,7 @@ public class SettingsProvider implements Supplier<Settings> {
Path tmpPath = settingsPath.resolveSibling(settingsPath.getFileName().toString() + ".tmp");
try (OutputStream out = Files.newOutputStream(tmpPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
var jsonObj = settings.serialized();
jsonObj.writtenByVersion = env.getAppVersion() + env.getBuildNumber().map("-"::concat).orElse("");
jsonObj.writtenByVersion = env.getAppVersionWithBuildNumber();
JSON.writerWithDefaultPrettyPrinter().writeValue(out, jsonObj);
}
Files.move(tmpPath, settingsPath, StandardCopyOption.REPLACE_EXISTING);

View File

@@ -26,7 +26,7 @@ import javafx.scene.image.Image;
import java.io.IOException;
import java.io.InputStream;
@Module(includes = {UpdateCheckerModule.class}, subcomponents = {TrayMenuComponent.class, //
@Module(subcomponents = {TrayMenuComponent.class, //
DecryptNameComponent.class, //
MainWindowComponent.class, //
PreferencesComponent.class, //

View File

@@ -1,135 +0,0 @@
package org.cryptomator.ui.fxapp;
import org.cryptomator.common.Environment;
import org.cryptomator.common.SemVerComparator;
import org.cryptomator.common.settings.Settings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Worker;
import javafx.concurrent.WorkerStateEvent;
import javafx.util.Duration;
import java.time.Instant;
import java.util.Comparator;
@FxApplicationScoped
public class UpdateChecker {
private static final Logger LOG = LoggerFactory.getLogger(UpdateChecker.class);
private static final Duration AUTO_CHECK_DELAY = Duration.seconds(5);
private final Environment env;
private final Settings settings;
private final StringProperty latestVersion = 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 checkFailed;
@Inject
UpdateChecker(Settings settings, //
Environment env, //
ScheduledService<String> updateCheckerService) {
this.env = env;
this.settings = settings;
this.updateCheckerService = updateCheckerService;
this.lastSuccessfulUpdateCheck = settings.lastSuccessfulUpdateCheck;
this.updateAvailable = Bindings.createBooleanBinding(this::isUpdateAvailable, latestVersion);
this.checkFailed = Bindings.equal(UpdateCheckState.CHECK_FAILED, state);
}
public void automaticallyCheckForUpdatesIfEnabled() {
if (!env.disableUpdateCheck() && settings.checkForUpdates.get()) {
startCheckingForUpdates(AUTO_CHECK_DELAY);
}
}
public void checkForUpdatesNow() {
startCheckingForUpdates(Duration.ZERO);
}
private void startCheckingForUpdates(Duration initialDelay) {
updateCheckerService.cancel();
updateCheckerService.reset();
updateCheckerService.setDelay(initialDelay);
updateCheckerService.setOnRunning(this::checkStarted);
updateCheckerService.setOnSucceeded(this::checkSucceeded);
updateCheckerService.setOnFailed(this::checkFailed);
updateCheckerService.start();
}
private void checkStarted(WorkerStateEvent event) {
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);
lastSuccessfulUpdateCheck.set(Instant.now());
latestVersion.set(latestVersionString);
state.set(UpdateCheckState.CHECK_SUCCESSFUL);
}
private void checkFailed(WorkerStateEvent event) {
state.set(UpdateCheckState.CHECK_FAILED);
}
public enum UpdateCheckState {
NOT_CHECKED,
IS_CHECKING,
CHECK_SUCCESSFUL,
CHECK_FAILED;
}
/* Observable Properties */
public BooleanBinding checkingForUpdatesProperty() {
return updateCheckerService.stateProperty().isEqualTo(Worker.State.RUNNING);
}
public ReadOnlyStringProperty latestVersionProperty() {
return latestVersion;
}
public BooleanBinding updateAvailableProperty() {
return updateAvailable;
}
public BooleanBinding checkFailedProperty() {
return checkFailed;
}
public boolean isUpdateAvailable() {
String currentVersion = getCurrentVersion();
String latestVersionString = latestVersion.get();
if (currentVersion == null || latestVersionString == null) {
return false;
} else {
return versionComparator.compare(currentVersion, latestVersionString) < 0;
}
}
public ObjectProperty<Instant> lastSuccessfulUpdateCheckProperty() {
return lastSuccessfulUpdateCheck;
}
public ObjectProperty<UpdateCheckState> updateCheckStateProperty() {
return state;
}
public String getCurrentVersion() {
return env.getAppVersion();
}
}

View File

@@ -1,93 +0,0 @@
package org.cryptomator.ui.fxapp;
import dagger.Module;
import dagger.Provides;
import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.Environment;
import org.cryptomator.common.settings.Settings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Named;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.util.Duration;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
@Module
public abstract class UpdateCheckerModule {
private static final Logger LOG = LoggerFactory.getLogger(UpdateCheckerModule.class);
private static final URI LATEST_VERSION_URI = URI.create("https://api.cryptomator.org/desktop/latest-version.json");
private static final Duration UPDATE_CHECK_INTERVAL = Duration.hours(3);
private static final Duration DISABLED_UPDATE_CHECK_INTERVAL = Duration.hours(100000); // Duration.INDEFINITE leads to overflows...
@Provides
@FxApplicationScoped
static Optional<HttpClient> provideHttpClient() {
try {
return Optional.of(HttpClient.newBuilder() //
.followRedirects(HttpClient.Redirect.NORMAL) // from version 1.6.11 onwards, Cryptomator can follow redirects, in case this URL ever changes
.build());
} catch (UncheckedIOException e) {
LOG.error("HttpClient for update check cannot be created.", e);
return Optional.empty();
}
}
@Provides
@FxApplicationScoped
static HttpRequest provideCheckForUpdatesRequest(Environment env) {
String userAgent = String.format("Cryptomator VersionChecker/%s %s %s (%s)", //
env.getAppVersion(), //
SystemUtils.OS_NAME, //
SystemUtils.OS_VERSION, //
SystemUtils.OS_ARCH); //
return HttpRequest.newBuilder() //
.uri(LATEST_VERSION_URI) //
.header("User-Agent", userAgent) //
.timeout(java.time.Duration.ofSeconds(10))
.build();
}
@Provides
@Named("checkForUpdatesInterval")
@FxApplicationScoped
static ObjectBinding<Duration> provideCheckForUpdateInterval(Settings settings) {
return Bindings.when(settings.checkForUpdates).then(UPDATE_CHECK_INTERVAL).otherwise(DISABLED_UPDATE_CHECK_INTERVAL);
}
@Provides
@FxApplicationScoped
static ScheduledService<String> provideCheckForUpdatesService(ExecutorService executor, Optional<HttpClient> httpClient, HttpRequest checkForUpdatesRequest, @Named("checkForUpdatesInterval") ObjectBinding<Duration> period) {
ScheduledService<String> service = new ScheduledService<>() {
@Override
protected Task<String> createTask() {
if (httpClient.isPresent()) {
return new UpdateCheckerTask(httpClient.get(), checkForUpdatesRequest);
} else {
return new Task<>() {
@Override
protected String call() {
throw new NullPointerException("No HttpClient present.");
}
};
}
}
};
service.setOnFailed(event -> LOG.error("Failed to execute update service", service.getException()));
service.setExecutor(executor);
service.periodProperty().bind(period);
return service;
}
}

View File

@@ -1,58 +0,0 @@
package org.cryptomator.ui.fxapp;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.io.ByteStreams;
import org.apache.commons.lang3.SystemUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javafx.concurrent.Task;
import java.io.IOException;
import java.io.InputStream;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class UpdateCheckerTask extends Task<String> {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Logger LOG = LoggerFactory.getLogger(UpdateCheckerTask.class);
private static final long MAX_RESPONSE_SIZE = 10L * 1024; // 10kb should be sufficient. protect against flooding
private final HttpClient httpClient;
private final HttpRequest checkForUpdatesRequest;
UpdateCheckerTask(HttpClient httpClient, HttpRequest checkForUpdatesRequest) {
this.httpClient = httpClient;
this.checkForUpdatesRequest = checkForUpdatesRequest;
setOnFailed(event -> LOG.error("Failed to check for updates", getException()));
}
@Override
protected String call() throws IOException, InterruptedException {
HttpResponse<InputStream> response = httpClient.send(checkForUpdatesRequest, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() == 200) {
return processBody(response);
} else {
throw new IOException("Unexpected HTTP response code " + response.statusCode());
}
}
private String processBody(HttpResponse<InputStream> response) throws IOException {
try (InputStream in = response.body(); //
InputStream limitedIn = ByteStreams.limit(in, MAX_RESPONSE_SIZE)) {
var json = JSON.reader().readTree(limitedIn);
if (SystemUtils.IS_OS_MAC_OSX) {
return json.get("mac").asText();
} else if (SystemUtils.IS_OS_WINDOWS) {
return json.get("win").asText();
} else if (SystemUtils.IS_OS_LINUX) {
return json.get("linux").asText();
} else {
throw new IllegalStateException("Unsupported operating system");
}
}
}
}

View File

@@ -7,7 +7,7 @@ import org.cryptomator.common.vaults.Vault;
import org.cryptomator.common.vaults.VaultListManager;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.fxapp.FxApplicationWindows;
import org.cryptomator.ui.fxapp.UpdateChecker;
import org.cryptomator.updater.UpdateChecker;
import org.cryptomator.ui.preferences.SelectedPreferencesTab;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -3,7 +3,7 @@ package org.cryptomator.ui.preferences;
import com.google.common.io.CharStreams;
import org.cryptomator.common.Environment;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.fxapp.UpdateChecker;
import org.cryptomator.updater.UpdateChecker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -2,7 +2,7 @@ package org.cryptomator.ui.preferences;
import org.cryptomator.common.Environment;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.fxapp.UpdateChecker;
import org.cryptomator.updater.UpdateChecker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -2,25 +2,37 @@ package org.cryptomator.ui.preferences;
import org.cryptomator.common.Environment;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.integrations.update.UpdateStep;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.fxapp.UpdateChecker;
import org.cryptomator.ui.common.VaultService;
import org.cryptomator.updater.UpdateChecker;
import org.cryptomator.updater.FallbackUpdateInfo;
import org.cryptomator.updater.UpdateService;
import org.slf4j.Logger;
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.BooleanExpression;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.concurrent.Worker;
import javafx.concurrent.WorkerStateEvent;
import javafx.fxml.FXML;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ContentDisplay;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
@@ -34,73 +46,64 @@ import java.util.ResourceBundle;
@PreferencesScoped
public class UpdatesPreferencesController implements FxController {
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 static final Logger LOG = LoggerFactory.getLogger(UpdatesPreferencesController.class);
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault());
private final Application application;
private final Environment environment;
private final ResourceBundle resourceBundle;
private final Settings settings;
private final UpdateChecker updateChecker;
private final ObjectBinding<ContentDisplay> checkForUpdatesButtonState;
private final ReadOnlyStringProperty latestVersion;
private final ObservableValue<Instant> lastSuccessfulUpdateCheck;
private final StringBinding lastUpdateCheckMessage;
private final UpdateService updateService;
private final ObservableList<Vault> unlockedVaults;
private final VaultService vaultService;
private final ObjectBinding<Worker<?>> worker;
private final BooleanExpression running;
private final StringBinding updateButtonTitle;
private final ObjectBinding<ContentDisplay> updateButtonState;
private final ObservableValue<String> timeDifferenceMessage;
private final String currentVersion;
private final BooleanBinding updateAvailable;
private final BooleanBinding checkFailed;
private final StringBinding lastUpdateCheckMessage;
private final BooleanBinding prohibitUpdateWhileUnlocked;
private final BooleanBinding updateButtonDisabled;
private final StringProperty errorMessage = new SimpleStringProperty("");
private final BooleanProperty upToDateLabelVisible = new SimpleBooleanProperty(false);
private final DateTimeFormatter formatter;
private final BooleanBinding upToDate;
private final String downloadsUri;
/* FXML */
public CheckBox checkForUpdatesCheckbox;
@Inject
UpdatesPreferencesController(Application application, Environment environment, ResourceBundle resourceBundle, Settings settings, UpdateChecker updateChecker) {
UpdatesPreferencesController(Application application, Environment environment, ResourceBundle resourceBundle, Settings settings, UpdateChecker updateChecker, ObservableList<Vault> vaults, VaultService vaultService) {
this.application = application;
this.environment = environment;
this.resourceBundle = resourceBundle;
this.settings = settings;
this.updateChecker = updateChecker;
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.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.updateService = new UpdateService(updateChecker.updateProperty());
this.unlockedVaults = vaults.filtered(Vault::isUnlocked);
this.vaultService = vaultService;
this.worker = Bindings.when(updateChecker.updateAvailableProperty()).<Worker<?>>then(this.updateService).otherwise(this.updateChecker);
this.running = Bindings.createBooleanBinding(this::isRunning, updateService.stateProperty(), updateChecker.stateProperty());
this.updateButtonTitle = Bindings.createStringBinding(this::getUpdateButtonTitle, worker, updateService.stateProperty(), updateService.messageProperty());
this.updateButtonState = Bindings.createObjectBinding(this::getUpdateButtonState, updateChecker.stateProperty(), updateService.stateProperty());
this.timeDifferenceMessage = Bindings.createStringBinding(this::getTimeDifferenceMessage, updateChecker.lastSuccessfulUpdateCheckProperty());
this.lastUpdateCheckMessage = Bindings.createStringBinding(this::getLastUpdateCheckMessage, updateChecker.lastSuccessfulUpdateCheckProperty());
this.prohibitUpdateWhileUnlocked = Bindings.createBooleanBinding(this::isProhibitUpdateWhileUnlocked, unlockedVaults, updateChecker.updateProperty());
this.updateButtonDisabled = Bindings.when(worker.isEqualTo(updateChecker)).then(running).otherwise(prohibitUpdateWhileUnlocked.or(running));
}
public void initialize() {
checkForUpdatesCheckbox.selectedProperty().bindBidirectional(settings.checkForUpdates);
upToDate.addListener((_, _, newVal) -> {
if (newVal) {
updateChecker.updateAvailableProperty().addListener((_, _, hasUpdate) -> {
if (!hasUpdate) {
upToDateLabelVisible.set(true);
PauseTransition delay = new PauseTransition(javafx.util.Duration.seconds(5));
delay.setOnFinished(_ -> upToDateLabelVisible.set(false));
delay.play();
}
});
}
@FXML
public void checkNow() {
updateChecker.checkForUpdatesNow();
}
@FXML
public void visitDownloadsPage() {
application.getHostServices().showDocument(downloadsUri);
updateChecker.setOnFailed(this::checkFailed);
updateService.setOnSucceeded(this::updateSucceeded);
updateService.setOnFailed(this::updateFailed);
}
@FXML
@@ -108,38 +111,104 @@ public class UpdatesPreferencesController implements FxController {
environment.getLogDir().ifPresent(logDirPath -> application.getHostServices().showDocument(logDirPath.toUri().toString()));
}
@FXML
public void startWork() {
if (worker.get().equals(updateChecker)) {
updateChecker.checkForUpdatesNow();
} else if (!unlockedVaults.isEmpty()) {
LOG.warn("Cannot start update due to unlocked vaults.");
} else if (worker.get().equals(updateService)) {
LOG.info("User started update to version {}", updateChecker.getUpdate().version());
updateService.start();
}
}
private void checkFailed(WorkerStateEvent workerStateEvent) {
assert workerStateEvent.getSource() == updateChecker;
LOG.error("Update check failed.", updateChecker.getException());
errorMessage.set(resourceBundle.getString("preferences.updates.checkFailed"));
}
private void updateSucceeded(WorkerStateEvent workerStateEvent) {
assert workerStateEvent.getSource() == updateService;
var lastStep = updateService.getValue();
if (lastStep == UpdateStep.EXIT) {
// Record that this version attempted an update, so next launch can choose fallback if needed
settings.lastUpdateAttemptedByVersion.set(environment.getAppVersionWithBuildNumber());
settings.saveNow();
LOG.info("Exiting app to update...");
Platform.exit();
} else if (lastStep == UpdateStep.RETRY) {
updateService.reset();
} else {
LOG.info("Update succeeded.");
}
}
private void updateFailed(WorkerStateEvent workerStateEvent) {
assert workerStateEvent.getSource() == updateService;
LOG.error("Update failed.", updateService.getException());
updateService.reset();
errorMessage.set(resourceBundle.getString("preferences.updates.updateFailed"));
// try fallback mechanism:
updateChecker.recheckWithFallbackMechanism();
}
@FXML
public void lockAllGracefully() {
vaultService.lockAll(unlockedVaults, false);
}
/* Observable Properties */
public ObjectBinding<ContentDisplay> checkForUpdatesButtonStateProperty() {
return checkForUpdatesButtonState;
public UpdateChecker getUpdateChecker() {
return updateChecker;
}
public ContentDisplay getCheckForUpdatesButtonState() {
return checkForUpdatesButtonState.get();
public ObjectBinding<Worker<?>> workerProperty() {
return worker;
}
public ReadOnlyStringProperty latestVersionProperty() {
return latestVersion;
public Worker<?> getWorker() {
return worker.get();
}
public String getLatestVersion() {
return latestVersion.get();
public BooleanExpression runningProperty() {
return running;
}
public String getCurrentVersion() {
return currentVersion;
public boolean isRunning() {
return updateChecker.getState() == Worker.State.RUNNING || updateService.getState() == Worker.State.RUNNING;
}
public StringBinding lastUpdateCheckMessageProperty() {
return lastUpdateCheckMessage;
public StringBinding updateButtonTitleProperty() {
return updateButtonTitle;
}
public String getLastUpdateCheckMessage() {
Instant lastCheck = lastSuccessfulUpdateCheck.getValue();
if (lastCheck != null && !lastCheck.equals(Settings.DEFAULT_TIMESTAMP)) {
return formatter.format(LocalDateTime.ofInstant(lastCheck, ZoneId.systemDefault()));
public String getUpdateButtonTitle() {
if (worker.get() == updateChecker) {
return resourceBundle.getString("preferences.updates.checkNowBtn");
} else {
return "-";
return switch (updateService.getState()) {
case READY -> updateChecker.getUpdate().updateMechanism().getName();
case SCHEDULED, RUNNING -> updateService.getMessage();
case SUCCEEDED -> resourceBundle.getString("generic.button.done");
case FAILED, CANCELLED -> "failed"; // should never be visible
};
}
}
public ObjectBinding<ContentDisplay> updateButtonStateProperty() {
return updateButtonState;
}
public ContentDisplay getUpdateButtonState() {
if (updateService.isRunning()) { // isRunning() covers RUNNING and SCHEDULED states
return ContentDisplay.BOTTOM;
} else if (updateChecker.getState() == Worker.State.RUNNING) {
return ContentDisplay.LEFT;
} else {
return ContentDisplay.TEXT_ONLY;
}
}
@@ -148,7 +217,7 @@ public class UpdatesPreferencesController implements FxController {
}
public String getTimeDifferenceMessage() {
var lastSuccessCheck = lastSuccessfulUpdateCheck.getValue();
var lastSuccessCheck = updateChecker.getLastSuccessfulUpdateCheck();
var duration = Duration.between(lastSuccessCheck, Instant.now());
var hours = duration.toHours();
if (lastSuccessCheck.equals(Settings.DEFAULT_TIMESTAMP)) {
@@ -162,6 +231,44 @@ public class UpdatesPreferencesController implements FxController {
}
}
public StringBinding lastUpdateCheckMessageProperty() {
return lastUpdateCheckMessage;
}
public String getLastUpdateCheckMessage() {
Instant lastCheck = updateChecker.getLastSuccessfulUpdateCheck();
if (lastCheck != null && !lastCheck.equals(Settings.DEFAULT_TIMESTAMP)) {
return FORMATTER.format(LocalDateTime.ofInstant(lastCheck, ZoneId.systemDefault()));
} else {
return "-";
}
}
public String getErrorMessage() {
return errorMessage.get();
}
public ReadOnlyStringProperty errorMessageProperty() {
return errorMessage;
}
public boolean isProhibitUpdateWhileUnlocked() {
// If the result of the last update check was from the fallback mechanism, we don't need to show the warning
return !unlockedVaults.isEmpty() && !FallbackUpdateInfo.class.isInstance(updateChecker.getUpdate());
}
public BooleanBinding prohibitUpdateWhileUnlockedProperty() {
return prohibitUpdateWhileUnlocked;
}
public boolean isUpdateButtonDisabled() {
return updateButtonDisabled.get();
}
public BooleanBinding updateButtonDisabledProperty() {
return updateButtonDisabled;
}
public BooleanProperty upToDateLabelVisibleProperty() {
return upToDateLabelVisible;
}
@@ -170,20 +277,4 @@ public class UpdatesPreferencesController implements FxController {
return upToDateLabelVisible.get();
}
public BooleanBinding updateAvailableProperty() {
return updateAvailable;
}
public boolean isUpdateAvailable() {
return updateAvailable.get();
}
public BooleanBinding checkFailedProperty() {
return checkFailed;
}
public boolean isCheckFailed() {
return checkFailed.getValue();
}
}

View File

@@ -2,7 +2,7 @@ package org.cryptomator.ui.updatereminder;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.fxapp.UpdateChecker;
import org.cryptomator.updater.UpdateChecker;
import javax.inject.Inject;
import javafx.fxml.FXML;

View File

@@ -0,0 +1,86 @@
package org.cryptomator.updater;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import java.io.IOException;
import java.net.Authenticator;
import java.net.CookieHandler;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
abstract class DelegatingHttpClient extends HttpClient {
private final HttpClient delegate;
public DelegatingHttpClient(HttpClient delegate) {
this.delegate = Objects.requireNonNull(delegate, "delegate must not be null");
}
@Override
public Optional<CookieHandler> cookieHandler() {
return delegate.cookieHandler();
}
@Override
public Optional<Duration> connectTimeout() {
return delegate.connectTimeout();
}
@Override
public Redirect followRedirects() {
return delegate.followRedirects();
}
@Override
public Optional<ProxySelector> proxy() {
return delegate.proxy();
}
@Override
public SSLContext sslContext() {
return delegate.sslContext();
}
@Override
public SSLParameters sslParameters() {
return delegate.sslParameters();
}
@Override
public Optional<Authenticator> authenticator() {
return delegate.authenticator();
}
@Override
public Version version() {
return delegate.version();
}
@Override
public Optional<Executor> executor() {
return delegate.executor();
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException, InterruptedException {
return delegate.send(request, responseBodyHandler);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
return delegate.sendAsync(request, responseBodyHandler);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
return delegate.sendAsync(request, responseBodyHandler, pushPromiseHandler);
}
}

View File

@@ -0,0 +1,6 @@
package org.cryptomator.updater;
import org.cryptomator.integrations.update.UpdateInfo;
import org.cryptomator.integrations.update.UpdateMechanism;
public record FallbackUpdateInfo(String version, UpdateMechanism<FallbackUpdateInfo> updateMechanism) implements UpdateInfo<FallbackUpdateInfo> {}

View File

@@ -0,0 +1,105 @@
package org.cryptomator.updater;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.Environment;
import org.cryptomator.integrations.common.LocalizedDisplayName;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.integrations.update.UpdateStep;
import org.cryptomator.ui.fxapp.FxApplicationScoped;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javafx.application.Application;
import javafx.application.Platform;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
@FxApplicationScoped
@LocalizedDisplayName(bundle = "i18n.strings", key = "preferences.updates.visitDownloadPage")
public class FallbackUpdateMechanism implements UpdateMechanism<FallbackUpdateInfo> {
private static final Logger LOG = LoggerFactory.getLogger(FallbackUpdateMechanism.class);
private static final String LATEST_VERSION_API_URL = "https://api.cryptomator.org/connect/apps/desktop/latest-version";
private static final ObjectMapper MAPPER = new ObjectMapper();
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 FallbackUpdateInfo checkForUpdate(String currentVersion, HttpClient httpClient) {
try {
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(LATEST_VERSION_API_URL)).build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new RuntimeException("Failed to fetch release: " + response.statusCode());
}
var release = MAPPER.readValue(response.body(), LatestVersion.class);
var updateVersion = release.versionForCurrentOS();
if (UpdateMechanism.isUpdateAvailable(updateVersion, currentVersion)) {
return new FallbackUpdateInfo(updateVersion, this);
} else {
return null;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("Update check interrupted", e);
return null;
} catch (IOException e) {
LOG.warn("Update check failed", e);
return null;
}
}
@Override
public UpdateStep firstStep(FallbackUpdateInfo updateInfo) {
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
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record LatestVersion(
@JsonProperty("mac") String macVersion,
@JsonProperty("win") String winVersion,
@JsonProperty("linux") String linuxVersion
) {
public String versionForCurrentOS() {
if (SystemUtils.IS_OS_MAC_OSX) {
return macVersion;
} else if (SystemUtils.IS_OS_WINDOWS) {
return winVersion;
} else if (SystemUtils.IS_OS_LINUX) {
return linuxVersion;
} else {
throw new IllegalStateException("Unsupported operating system");
}
}
}
}

View File

@@ -0,0 +1,198 @@
package org.cryptomator.updater;
import org.cryptomator.common.Environment;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.integrations.update.UpdateFailedException;
import org.cryptomator.integrations.update.UpdateInfo;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.ui.fxapp.FxApplicationScoped;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.StringExpression;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.util.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
@FxApplicationScoped
public class UpdateChecker extends ScheduledService<UpdateInfo<?>> {
private static final Logger LOG = LoggerFactory.getLogger(UpdateChecker.class);
private static final Duration AUTO_CHECK_DELAY = Duration.seconds(5);
private static final Duration UPDATE_CHECK_INTERVAL = Duration.hours(3);
private static final Duration DISABLED_UPDATE_CHECK_INTERVAL = Duration.hours(100000); // Duration.INDEFINITE leads to overflows...
public enum UpdateCheckState {
NOT_CHECKED,
IS_CHECKING,
CHECK_SUCCESSFUL,
CHECK_FAILED
}
private final Environment env;
private final Settings settings;
private final ObjectProperty<Instant> lastSuccessfulUpdateCheck;
private final ObjectProperty<UpdateInfo<?>> update = new SimpleObjectProperty<>();
private final StringExpression latestVersion = StringExpression.stringExpression(update.map(UpdateInfo::version));
private final BooleanBinding updateAvailable = update.isNotNull();
private final ObjectBinding<UpdateCheckState> updateState = Bindings.createObjectBinding(this::getUpdateCheckState, stateProperty());
private final BooleanBinding checkFailed = Bindings.equal(UpdateCheckState.CHECK_FAILED, updateState);
private final UpdateMechanism<?> fallbackUpdateMechanism;
private UpdateMechanism<?> updateMechanism;
@Inject
UpdateChecker(Settings settings, //
Environment env,
FallbackUpdateMechanism fallbackUpdateMechanism) {
this.env = env;
this.settings = settings;
this.lastSuccessfulUpdateCheck = settings.lastSuccessfulUpdateCheck;
this.fallbackUpdateMechanism = fallbackUpdateMechanism;
// Prefer the safer fallback mechanism if the last update attempt was already made by this app version
var currentVersion = env.getAppVersionWithBuildNumber();
var lastAttemptedBy = settings.lastUpdateAttemptedByVersion.get();
if (currentVersion != null && currentVersion.equals(lastAttemptedBy)) {
this.updateMechanism = fallbackUpdateMechanism; // immediately use fallback mechanism
} else {
this.updateMechanism = UpdateMechanism.get().orElse(fallbackUpdateMechanism);
}
setExecutor(Executors.newVirtualThreadPerTaskExecutor());
periodProperty().bind(Bindings.when(settings.checkForUpdates).then(UPDATE_CHECK_INTERVAL).otherwise(DISABLED_UPDATE_CHECK_INTERVAL));
}
public void automaticallyCheckForUpdatesIfEnabled() {
if (!env.disableUpdateCheck() && settings.checkForUpdates.get()) {
startCheckingForUpdates(AUTO_CHECK_DELAY);
}
}
public void recheckWithFallbackMechanism() {
if (updateMechanism == fallbackUpdateMechanism) {
return; // already using fallback mechanism
}
updateMechanism = fallbackUpdateMechanism;
checkForUpdatesNow();
}
public void checkForUpdatesNow() {
startCheckingForUpdates(Duration.ZERO);
}
private void startCheckingForUpdates(Duration initialDelay) {
cancel();
reset();
setDelay(initialDelay);
start();
}
@Override
protected void succeeded() {
var updateInfo = getValue();
super.succeeded(); // this will nil the value property!
lastSuccessfulUpdateCheck.set(Instant.now());
if (updateInfo != null) {
LOG.info("Current version: {}, latest version: {}", getCurrentVersion(), updateInfo.version());
update.set(updateInfo);
}
}
@Override
protected Task<UpdateInfo<?>> createTask() {
return new UpdateCheckTask();
}
/* Observable Properties */
public UpdateInfo<?> getUpdate() {
return update.get();
}
public ObjectProperty<UpdateInfo<?>> updateProperty() {
return update;
}
public String getLatestVersion() {
return latestVersion.get();
}
public StringExpression latestVersionProperty() {
return latestVersion;
}
public boolean isUpdateAvailable() {
return updateAvailable.get();
}
public BooleanBinding updateAvailableProperty() {
return updateAvailable;
}
public boolean isCheckFailed() {
return checkFailed.get();
}
public BooleanBinding checkFailedProperty() {
return checkFailed;
}
public Instant getLastSuccessfulUpdateCheck() {
return lastSuccessfulUpdateCheck.get();
}
public ObjectProperty<Instant> lastSuccessfulUpdateCheckProperty() {
return lastSuccessfulUpdateCheck;
}
public ObjectBinding<UpdateCheckState> updateCheckStateProperty() {
return updateState;
}
private UpdateCheckState getUpdateCheckState() {
return switch (getState()) {
case READY -> UpdateCheckState.NOT_CHECKED;
case SCHEDULED, RUNNING -> UpdateCheckState.IS_CHECKING;
case SUCCEEDED -> UpdateCheckState.CHECK_SUCCESSFUL;
case FAILED, CANCELLED -> UpdateCheckState.CHECK_FAILED;
};
}
public String getCurrentVersion() {
return env.getAppVersion();
}
private class UpdateCheckTask extends Task<UpdateInfo<?>> {
@Override
protected UpdateInfo<?> call() {
try (var httpClient = new UpdateCheckerHttpClient(env)) {
var result = updateMechanism.checkForUpdate(env.getAppVersion(), httpClient);
if (result != null) {
return result;
}
} catch (UpdateFailedException e) {
LOG.error("Update check using {} failed.", updateMechanism.getClass(), e);
}
if (updateMechanism == fallbackUpdateMechanism) {
return null;
}
LOG.debug("Trying fallback update check...");
try (var httpClient = new UpdateCheckerHttpClient(env)) {
return fallbackUpdateMechanism.checkForUpdate(env.getAppVersion(), httpClient);
} catch (UpdateFailedException e) {
LOG.error("Fallback update check failed.", e);
return null;
}
}
}
}

View File

@@ -0,0 +1,50 @@
package org.cryptomator.updater;
import org.apache.commons.lang3.SystemUtils;
import org.cryptomator.common.Environment;
import java.io.IOException;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
public class UpdateCheckerHttpClient extends DelegatingHttpClient {
private final String userAgent;
public UpdateCheckerHttpClient(Environment env) {
var delegate = HttpClient.newBuilder() //
.followRedirects(HttpClient.Redirect.NORMAL) // from version 1.6.11 onwards, Cryptomator can follow redirects, in case this URL ever changes
.proxy(ProxySelector.getDefault()).build();
super(delegate);
this.userAgent = String.format("Cryptomator VersionChecker/%s %s %s (%s)", env.getAppVersion(), SystemUtils.OS_NAME, SystemUtils.OS_VERSION, SystemUtils.OS_ARCH);
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) throws IOException, InterruptedException {
return super.send(decorateRequest(request), responseBodyHandler);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
return super.sendAsync(decorateRequest(request), responseBodyHandler);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
return super.sendAsync(decorateRequest(request), responseBodyHandler, pushPromiseHandler);
}
private HttpRequest decorateRequest(HttpRequest request) {
return HttpRequest.newBuilder(request, (_, _) -> true) //
.header("User-Agent", this.userAgent) //
.timeout(Duration.ofSeconds(10)) //
.build();
}
}

View File

@@ -0,0 +1,81 @@
package org.cryptomator.updater;
import org.cryptomator.integrations.update.UpdateInfo;
import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.integrations.update.UpdateStep;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.util.Objects;
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 BooleanBinding updateFailed = Bindings.equal(State.FAILED, stateProperty());
private ObservableValue<UpdateInfo<?>> updateInfo;
public UpdateService(ObservableValue<UpdateInfo<?>> updateInfo) {
setExecutor(Executors.newVirtualThreadPerTaskExecutor());
this.updateInfo = updateInfo;
}
@Override
protected Task<UpdateStep> createTask() {
return new RunAllStepsTask(updateInfo.getValue());
}
private static class RunAllStepsTask extends Task<UpdateStep> {
@SuppressWarnings("rawtypes")
private final UpdateInfo updateInfo;
public RunAllStepsTask(UpdateInfo<?> updateInfo) {
this.updateInfo = Objects.requireNonNull(updateInfo);
}
@Override
protected UpdateStep call() throws IOException {
try {
UpdateStep step = updateInfo.useToPrepareFirstStep();
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));
}
}
/* Observable Properties */
public boolean isUpdateFailed() {
return updateFailed.get();
}
public BooleanBinding updateFailedProperty() {
return updateFailed;
}
}

View File

@@ -3,55 +3,73 @@
<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
<?import org.cryptomator.ui.controls.FontAwesome5Spinner?>
<?import org.cryptomator.ui.controls.FormattedLabel?>
<?import org.cryptomator.ui.controls.FormattedString?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.CheckBox?>
<?import javafx.scene.control.Hyperlink?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.ProgressBar?>
<?import javafx.scene.control.Tooltip?>
<?import javafx.scene.text.TextFlow?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.text.TextFlow?>
<VBox xmlns:fx="http://javafx.com/fxml"
xmlns="http://javafx.com/javafx"
fx:controller="org.cryptomator.ui.preferences.UpdatesPreferencesController"
spacing="12">
<fx:define>
<FormattedString fx:id="linkLabel" format="%preferences.updates.updateAvailable" arg1="${controller.latestVersion}"/>
</fx:define>
<padding>
<Insets topRightBottomLeft="24"/>
</padding>
<FormattedLabel format="%preferences.updates.currentVersion" arg1="${controller.currentVersion}" textAlignment="CENTER" wrapText="true"/>
<FormattedLabel format="%preferences.updates.currentVersion" arg1="${controller.updateChecker.currentVersion}" textAlignment="CENTER" wrapText="true"/>
<CheckBox fx:id="checkForUpdatesCheckbox" text="%preferences.updates.autoUpdateCheck"/>
<VBox alignment="CENTER" spacing="12">
<Button text="%preferences.updates.checkNowBtn" defaultButton="true" onAction="#checkNow" contentDisplay="${controller.checkForUpdatesButtonState}">
<FormattedLabel format="%preferences.updates.updateAvailable" arg1="${controller.updateChecker.latestVersion}" textAlignment="CENTER" wrapText="true" visible="${controller.updateChecker.updateAvailable}"/>
<Button text="${controller.updateButtonTitle}" defaultButton="true" onAction="#startWork" disable="${controller.updateButtonDisabled}" contentDisplay="${controller.updateButtonState}">
<graphic>
<FontAwesome5Spinner glyphSize="12"/>
<VBox spacing="5" alignment="CENTER">
<ProgressBar maxWidth="200"
maxHeight="12"
visible="${controller.running &amp;&amp; controller.worker.progress != -1.0}"
managed="${controller.running &amp;&amp; controller.worker.progress != -1.0}"
progress="${controller.worker.progress}"/>
<FontAwesome5Spinner glyphSize="12"
visible="${controller.running &amp;&amp; controller.worker.progress == -1.0}"
managed="${controller.running &amp;&amp; controller.worker.progress == -1.0}"/>
</VBox>
</graphic>
</Button>
<TextFlow styleClass="text-flow" textAlignment="CENTER" visible="${controller.checkFailed}" managed="${controller.checkFailed}">
<TextFlow styleClass="text-flow" textAlignment="CENTER" visible="${controller.prohibitUpdateWhileUnlocked}" managed="${controller.prohibitUpdateWhileUnlocked}">
<FontAwesome5IconView glyphSize="12" styleClass="glyph-icon-primary" glyph="LOCK_OPEN"/>
<Text text=" "/>
<Text text="%preferences.updates.prohibitedDueToUnlockedVaults.1"/>
<Text text=" "/>
<Hyperlink styleClass="hyperlink-underline" text="%preferences.updates.prohibitedDueToUnlockedVaults.2" onAction="#lockAllGracefully"/>
<Text text=" "/>
<Text text="%preferences.updates.prohibitedDueToUnlockedVaults.3"/>
</TextFlow>
<TextFlow styleClass="text-flow" textAlignment="CENTER" visible="${!controller.errorMessage.empty}" managed="${!controller.errorMessage.empty}">
<FontAwesome5IconView glyphSize="12" styleClass="glyph-icon-orange" glyph="EXCLAMATION_TRIANGLE"/>
<Text text=" "/>
<Text text="%preferences.updates.checkFailed"/>
<Text text="${controller.errorMessage}"/>
<Text text=" "/>
<Hyperlink styleClass="hyperlink-underline" text="%preferences.general.debugDirectory" onAction="#showLogfileDirectory"/>
</TextFlow>
<FormattedLabel format="%preferences.updates.lastUpdateCheck" arg1="${controller.timeDifferenceMessage}" textAlignment="CENTER" wrapText="true">
<FormattedLabel format="%preferences.updates.lastUpdateCheck" arg1="${controller.timeDifferenceMessage}" textAlignment="CENTER" wrapText="true" visible="${!controller.updateChecker.updateAvailable}" managed="${!controller.updateChecker.updateAvailable}">
<tooltip>
<Tooltip text="${controller.lastUpdateCheckMessage}" showDelay="10ms"/>
</tooltip>
</FormattedLabel>
<Label text="%preferences.updates.upToDate" visible="${controller.upToDateLabelVisible}" managed="${controller.upToDateLabelVisible}">
<graphic>
<FontAwesome5IconView glyphSize="12" styleClass="glyph-icon-primary" glyph="CHECK"/>
</graphic>
</Label>
<Hyperlink text="${linkLabel.value}" onAction="#visitDownloadsPage" textAlignment="CENTER" wrapText="true" styleClass="hyperlink-underline" visible="${controller.updateAvailable}" managed="${controller.updateAvailable}"/>
</VBox>
</VBox>

View File

@@ -330,8 +330,13 @@ preferences.updates.lastUpdateCheck.never=never
preferences.updates.lastUpdateCheck.recently=recently
preferences.updates.lastUpdateCheck.daysAgo=%s days ago
preferences.updates.lastUpdateCheck.hoursAgo=%s hours ago
preferences.updates.prohibitedDueToUnlockedVaults.1=Please
preferences.updates.prohibitedDueToUnlockedVaults.2=lock your vaults
preferences.updates.prohibitedDueToUnlockedVaults.3=to install the update.
preferences.updates.checkFailed=Looking for updates failed. Please check your internet connection or try again later.
preferences.updates.updateFailed=Update failed. Please install the update manually.
preferences.updates.upToDate=Cryptomator is up-to-date.
preferences.updates.visitDownloadPage=Visit Download Page
## Contribution
preferences.contribute=Support Us

View File

@@ -1,58 +0,0 @@
/*******************************************************************************
* Copyright (c) 2016, 2017 Sebastian Stenzel and others.
* All rights reserved.
* This program and the accompanying materials are made available under the terms of the accompanying LICENSE file.
*
* Contributors:
* Sebastian Stenzel - initial API and implementation
*******************************************************************************/
package org.cryptomator.common;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Comparator;
public class SemVerComparatorTest {
private final Comparator<String> semVerComparator = new SemVerComparator();
// equal versions
@Test
public void compareEqualVersions() {
Assertions.assertEquals(0, Integer.signum(semVerComparator.compare("1.23.4", "1.23.4")));
Assertions.assertEquals(0, Integer.signum(semVerComparator.compare("1.23.4-alpha", "1.23.4-alpha")));
Assertions.assertEquals(0, Integer.signum(semVerComparator.compare("1.23.4+20170101", "1.23.4+20171231")));
Assertions.assertEquals(0, Integer.signum(semVerComparator.compare("1.23.4-alpha+20170101", "1.23.4-alpha+20171231")));
}
// newer versions in first argument
@Test
public void compareHigherToLowerVersions() {
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.5", "1.23.4")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.24.4", "1.23.4")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4", "1.23")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4", "1.23.4-SNAPSHOT")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4", "1.23.4-56.78")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4-beta", "1.23.4-alpha")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4-alpha.1", "1.23.4-alpha")));
Assertions.assertEquals(1, Integer.signum(semVerComparator.compare("1.23.4-56.79", "1.23.4-56.78")));
}
// newer versions in second argument
@Test
public void compareLowerToHigherVersions() {
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4", "1.23.5")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4", "1.24.4")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23", "1.23.4")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-SNAPSHOT", "1.23.4")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-56.78", "1.23.4")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-alpha", "1.23.4-beta")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-alpha", "1.23.4-alpha.1")));
Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare("1.23.4-56.78", "1.23.4-56.79")));
}
}

View File

@@ -9,31 +9,28 @@ import org.cryptomator.common.Environment;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.function.Consumer;
public class SettingsTest {
@Test
public void testAutoSave() {
Environment env = Mockito.mock(Environment.class);
@SuppressWarnings("unchecked") Consumer<Settings> changeListener = Mockito.mock(Consumer.class);
SettingsProvider provider = Mockito.mock(SettingsProvider.class);
Settings settings = Settings.create(env);
settings.setSaveCmd(changeListener);
Settings settings = Settings.create(provider, env);
VaultSettings vaultSettings = VaultSettings.withRandomId();
Mockito.verify(changeListener, Mockito.times(0)).accept(settings);
Mockito.verify(provider, Mockito.times(0)).scheduleSave(settings);
// first change (to property):
settings.port.set(42428);
Mockito.verify(changeListener, Mockito.times(1)).accept(settings);
Mockito.verify(provider, Mockito.times(1)).scheduleSave(settings);
// second change (to list):
settings.directories.add(vaultSettings);
Mockito.verify(changeListener, Mockito.times(2)).accept(settings);
Mockito.verify(provider, Mockito.times(2)).scheduleSave(settings);
// third change (to property of list item):
vaultSettings.displayName.set("asd");
Mockito.verify(changeListener, Mockito.times(3)).accept(settings);
Mockito.verify(provider, Mockito.times(3)).scheduleSave(settings);
}
}